mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
v1.11.0 (#523)
* Feature request network graph
* Fixing PR442:
- Fixed:
- UI design elemets
- UI and button colors
- JSON export
- recent activity is default again
- Removed:
- Online/Offline UI labels
- left-click menu on hosts
- Added:
- small pulsing dot inside the hosts to indicate online status like in the left bar
* fix: electron build errors and skip macos job
* fix: testflight submit failure
* fix: made submit job match build type
* fix: resolve Vite build warnings for mixed static/dynamic imports (#473)
* Update Crowdin configuration file
* Update Crowdin configuration file
* fix: resolve Vite build warnings for mixed static/dynamic imports
- Convert all dynamic imports of main-axios.ts to static imports (10 files)
- Convert all dynamic imports of sonner to static imports (4 files)
- Add manual chunking configuration to vite.config.ts for better bundle splitting
- react-vendor: React and React DOM
- ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge
- monaco: Monaco Editor
- codemirror: CodeMirror and related packages
- Increase chunkSizeWarningLimit to 1000kB
This resolves Vite warnings about mixed import strategies preventing
proper code-splitting.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
* fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent)
* fix: build error on docker
* Handle enter button (#481)
* Update Crowdin configuration file
* Update Crowdin configuration file
* Update Linux Portable section with AUR link (#474)
* fix: file manager incorrectly decoding/encoding when editing files (#476)
* fix: electron build errors and skip macos job
* fix: testflight submit failure
* fix: made submit job match build type
* fix: resolve Vite build warnings for mixed static/dynamic imports (#473)
* Update Crowdin configuration file
* Update Crowdin configuration file
* fix: resolve Vite build warnings for mixed static/dynamic imports
- Convert all dynamic imports of main-axios.ts to static imports (10 files)
- Convert all dynamic imports of sonner to static imports (4 files)
- Add manual chunking configuration to vite.config.ts for better bundle splitting
- react-vendor: React and React DOM
- ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge
- monaco: Monaco Editor
- codemirror: CodeMirror and related packages
- Increase chunkSizeWarningLimit to 1000kB
This resolves Vite warnings about mixed import strategies preventing
proper code-splitting.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
* fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent)
---------
Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
* fix: build error on docker (#477)
* fix: electron build errors and skip macos job
* fix: testflight submit failure
* fix: made submit job match build type
* fix: resolve Vite build warnings for mixed static/dynamic imports (#473)
* Update Crowdin configuration file
* Update Crowdin configuration file
* fix: resolve Vite build warnings for mixed static/dynamic imports
- Convert all dynamic imports of main-axios.ts to static imports (10 files)
- Convert all dynamic imports of sonner to static imports (4 files)
- Add manual chunking configuration to vite.config.ts for better bundle splitting
- react-vendor: React and React DOM
- ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge
- monaco: Monaco Editor
- codemirror: CodeMirror and related packages
- Increase chunkSizeWarningLimit to 1000kB
This resolves Vite warnings about mixed import strategies preventing
proper code-splitting.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
* fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent)
* fix: build error on docker
---------
Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
* Increase max old space size for npm builds
* Increase Node.js memory limit in Dockerfile
* Remove NODE_OPTIONS from build commands in Dockerfile
* Change runner to blacksmith-4vcpu-ubuntu-2404
* fix: build error on docker
* Add handle on enter button;
---------
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Gaylord Julien <g.j@mailbox.org>
Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
* fix: remove top tech
* fix: update readme
* fix: prevent long container names from overflowing card (#496)
Added min-w-0 to CardTitle to allow text truncation in flexbox.
Without this, flex items have min-width: auto which prevents
the truncate class from working properly.
Fixes #411
* fix: use SFTP readdir for file listing to support non-Linux systems (#495)
The file manager now uses SFTP readdir as the primary method for
listing files, with ls -la as a fallback. This enables compatibility
with MikroTik RouterOS and other non-Linux systems that don't have
standard shell commands.
Fixes #317
* fix: restore SSH connection timeout to 120s for 2FA authentication (#494)
The timeout was reduced from 120s to 30s in v1.10, causing 2FA login
failures. Users with keyboard-interactive authentication (TOTP/2FA)
need sufficient time to enter their verification codes before the
SSH connection times out.
Fixes #404
* feat: add Docker container healthcheck (#493)
* fix: owner should not be marked as shared when host is shared to their role (#492)
* fix: use correct MIME types for image preview (#491)
* fix: prevent session reset when updating host properties (#490)
* fix: add shell creation timeout and improve error handling (#489)
* fix: set default lineHeight to 1.0 for TUI apps compatibility (#488)
* fix: delete all related data when removing user (#487)
* fix: nginx permission denied on restricted kernels (#486)
* fix: skip existing hosts and credentials during JSON import (#485)
Added duplicate detection for SSH hosts (by ip+port+username) and
credentials (by name) during import. Existing items are now skipped
by default, or updated if replaceExisting option is enabled.
This matches the existing behavior of importDismissedAlerts.
Fixes #389
* feat: add firewall status widget for server stats (#484)
* Feature: PWA (#479)
* feat: add PWA support with offline capabilities
- Add web app manifest with icons and theme configuration
- Add service worker with cache-first strategy for static assets
- Add useServiceWorker hook for SW registration
- Add PWA meta tags and Apple-specific tags to index.html
- Update vite.config.ts for optimal asset caching
* Update package-lock.json
* New Crowdin updates (#472)
* New translations en.json (Romanian)
* New translations en.json (French)
* New translations en.json (Spanish)
* New translations en.json (Afrikaans)
* New translations en.json (Arabic)
* New translations en.json (Catalan)
* New translations en.json (Czech)
* New translations en.json (Danish)
* New translations en.json (German)
* New translations en.json (Greek)
* New translations en.json (Finnish)
* New translations en.json (Hebrew)
* New translations en.json (Hungarian)
* New translations en.json (Italian)
* New translations en.json (Japanese)
* New translations en.json (Korean)
* New translations en.json (Dutch)
* New translations en.json (Norwegian)
* New translations en.json (Polish)
* New translations en.json (Portuguese)
* New translations en.json (Russian)
* New translations en.json (Serbian (Cyrillic))
* New translations en.json (Swedish)
* New translations en.json (Turkish)
* New translations en.json (Ukrainian)
* New translations en.json (Chinese Simplified)
* New translations en.json (English)
* New translations en.json (Vietnamese)
* New translations en.json (German)
* feat: add listening ports widget for server stats (#483)
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
* feat: fix network stats merge and add openapi jsdocs comments
* feat: add workflow/config to auto generate openapi json
* feat: remove locales
* feat: support URL routes to open terminal directly (#156) (#503)
* fix: resolve merge conflict artifacts in dev-1.10.1
- Fix missing closing tags in AppView.tsx NetworkGraphView
- Fix incomplete catch blocks in server-stats.ts and db/index.ts
- Fix missing closing brace in en.json ports section
- Fix HostManagerApp.tsx import path
- Fix stats-widgets.ts type definition
- Fix schema.ts networkTopology table definition
- Add type annotations in user-data-import.ts
* feat: support URL routes to open terminal directly (#156)
- Add /terminal/{hostNameOrId} route for new format
- Keep /hosts/{id}/terminal for backward compatibility
- Smart detection: numeric IDs for ID lookup, otherwise name lookup
- Clean URL after opening to prevent duplicate on refresh
- Show toast error when host not found
---------
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
* feat: add Ctrl+Alt key remapping for browser-blocked shortcuts (#501)
Browsers intercept Ctrl+W/T/N/Q, making them unusable in terminal.
This adds Ctrl+Alt+<key> as an alternative that sends Ctrl+<key>.
- Ctrl+Alt+W → Ctrl+W (nano search, delete word)
- Ctrl+Alt+T → Ctrl+T (transpose chars)
- Ctrl+Alt+N → Ctrl+N (next line)
- Ctrl+Alt+Q → Ctrl+Q (XON flow control)
Fixes Termix-SSH/Support#407
* feat: remove locales
* New Crowdin updates (#504)
* New translations en.json (Romanian)
* New translations en.json (French)
* New translations en.json (Spanish)
* New translations en.json (Afrikaans)
* New translations en.json (Arabic)
* New translations en.json (Catalan)
* New translations en.json (Czech)
* New translations en.json (Danish)
* New translations en.json (German)
* New translations en.json (Greek)
* New translations en.json (Finnish)
* New translations en.json (Hebrew)
* New translations en.json (Hungarian)
* New translations en.json (Italian)
* New translations en.json (Japanese)
* New translations en.json (Korean)
* New translations en.json (Dutch)
* New translations en.json (Norwegian)
* New translations en.json (Polish)
* New translations en.json (Portuguese)
* New translations en.json (Russian)
* New translations en.json (Serbian (Cyrillic))
* New translations en.json (Swedish)
* New translations en.json (Turkish)
* New translations en.json (Ukrainian)
* New translations en.json (Chinese Simplified)
* New translations en.json (English)
* New translations en.json (Vietnamese)
* New translations en.json (German)
* New translations en.json (Norwegian)
* New translations en.json (Romanian)
* New translations en.json (French)
* New translations en.json (Spanish)
* New translations en.json (Arabic)
* New translations en.json (Czech)
* New translations en.json (Danish)
* New translations en.json (Greek)
* New translations en.json (Finnish)
* New translations en.json (Italian)
* New translations en.json (Japanese)
* New translations en.json (Dutch)
* New translations en.json (Norwegian)
* New translations en.json (Polish)
* New translations en.json (Portuguese)
* New translations en.json (Russian)
* New translations en.json (Swedish)
* New translations en.json (Ukrainian)
* New translations en.json (Chinese Simplified)
* New translations en.json (Chinese Traditional)
* New translations en.json (Finnish)
* New translations en.json (Chinese Simplified)
* New translations en.json (Chinese Traditional)
* feat: add option to disable update checker (#502)
* feat: add option to disable update checker
Add a new setting in User Profile > Settings to disable automatic
update checking on startup and dashboard.
- Adds 'Disable Update Check' toggle in profile settings
- Skips GitHub API calls when disabled (reduces network requests)
- Works for both web app and Electron client
Fixes Termix-SSH/Support#410
* feat: remove locales
---------
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
* New Crowdin updates (#505)
* New translations en.json (Romanian)
* New translations en.json (French)
* New translations en.json (Spanish)
* New translations en.json (Afrikaans)
* New translations en.json (Arabic)
* New translations en.json (Catalan)
* New translations en.json (Czech)
* New translations en.json (Danish)
* New translations en.json (German)
* New translations en.json (Greek)
* New translations en.json (Finnish)
* New translations en.json (Hebrew)
* New translations en.json (Hungarian)
* New translations en.json (Italian)
* New translations en.json (Japanese)
* New translations en.json (Korean)
* New translations en.json (Dutch)
* New translations en.json (Norwegian)
* New translations en.json (Polish)
* New translations en.json (Portuguese)
* New translations en.json (Russian)
* New translations en.json (Serbian (Cyrillic))
* New translations en.json (Swedish)
* New translations en.json (Turkish)
* New translations en.json (Ukrainian)
* New translations en.json (Chinese Simplified)
* New translations en.json (English)
* New translations en.json (Vietnamese)
* New translations en.json (German)
* New translations en.json (Norwegian)
* New translations en.json (Romanian)
* New translations en.json (French)
* New translations en.json (Spanish)
* New translations en.json (Arabic)
* New translations en.json (Czech)
* New translations en.json (Danish)
* New translations en.json (Greek)
* New translations en.json (Finnish)
* New translations en.json (Italian)
* New translations en.json (Japanese)
* New translations en.json (Dutch)
* New translations en.json (Norwegian)
* New translations en.json (Polish)
* New translations en.json (Portuguese)
* New translations en.json (Russian)
* New translations en.json (Swedish)
* New translations en.json (Ukrainian)
* New translations en.json (Chinese Simplified)
* New translations en.json (Chinese Traditional)
* New translations en.json (Finnish)
* New translations en.json (Chinese Simplified)
* New translations en.json (Chinese Traditional)
* New translations en.json (Chinese Simplified)
* New translations en.json (Chinese Traditional)
* New translations en.json (Bulgarian)
* New translations en.json (Indonesian)
* New translations en.json (Hindi)
* feat: add crowdin i18n
* feat: remove locales
* New Crowdin updates (#506)
* New translations en.json (Romanian)
* New translations en.json (French)
* New translations en.json (Spanish)
* New translations en.json (Afrikaans)
* New translations en.json (Arabic)
* New translations en.json (Catalan)
* New translations en.json (Czech)
* New translations en.json (Danish)
* New translations en.json (German)
* New translations en.json (Greek)
* New translations en.json (Finnish)
* New translations en.json (Hebrew)
* New translations en.json (Hungarian)
* New translations en.json (Italian)
* New translations en.json (Japanese)
* New translations en.json (Korean)
* New translations en.json (Dutch)
* New translations en.json (Norwegian)
* New translations en.json (Polish)
* New translations en.json (Portuguese)
* New translations en.json (Russian)
* New translations en.json (Serbian (Cyrillic))
* New translations en.json (Swedish)
* New translations en.json (Turkish)
* New translations en.json (Ukrainian)
* New translations en.json (Chinese Simplified)
* New translations en.json (English)
* New translations en.json (Vietnamese)
* New translations en.json (German)
* New translations en.json (Norwegian)
* New translations en.json (Romanian)
* New translations en.json (French)
* New translations en.json (Spanish)
* New translations en.json (Arabic)
* New translations en.json (Czech)
* New translations en.json (Danish)
* New translations en.json (Greek)
* New translations en.json (Finnish)
* New translations en.json (Italian)
* New translations en.json (Japanese)
* New translations en.json (Dutch)
* New translations en.json (Norwegian)
* New translations en.json (Polish)
* New translations en.json (Portuguese)
* New translations en.json (Russian)
* New translations en.json (Swedish)
* New translations en.json (Ukrainian)
* New translations en.json (Chinese Simplified)
* New translations en.json (Chinese Traditional)
* New translations en.json (Finnish)
* New translations en.json (Chinese Simplified)
* New translations en.json (Chinese Traditional)
* New translations en.json (Chinese Simplified)
* New translations en.json (Chinese Traditional)
* New translations en.json (Bulgarian)
* New translations en.json (Indonesian)
* New translations en.json (Hindi)
* New translations en.json (Romanian)
* New translations en.json (French)
* New translations en.json (Spanish)
* New translations en.json (Afrikaans)
* New translations en.json (Arabic)
* New translations en.json (Catalan)
* New translations en.json (Czech)
* New translations en.json (German)
* New translations en.json (Greek)
* New translations en.json (Finnish)
* New translations en.json (Hebrew)
* New translations en.json (Hungarian)
* New translations en.json (Italian)
* New translations en.json (Japanese)
* New translations en.json (Korean)
* New translations en.json (Dutch)
* New translations en.json (Polish)
* New translations en.json (Portuguese)
* New translations en.json (Russian)
* New translations en.json (Serbian (Cyrillic))
* New translations en.json (Turkish)
* New translations en.json (Ukrainian)
* New translations en.json (Chinese Simplified)
* New translations en.json (Chinese Traditional)
* New translations en.json (Vietnamese)
* New translations en.json (Portuguese, Brazilian)
* New translations en.json (Bulgarian)
* New translations en.json (Indonesian)
* New translations en.json (Hindi)
* New translations en.json (Bengali)
* New translations en.json (Thai)
* feat: update readme
* feat: update readme
* feat: update credential editor to use submitting system and add health monitor
* feat: added toggle for command pallete
* feat: added close button on tab dropdown
* feat: added sidebar management and improved some host manager UI/UX
* feat: re-added missing users.ts route from merge
* feat: add toggle for password reset feature in admin settings (#508)
* feat: add sudo support for file manager operations (#509)
* fix: add sudo support for listFiles and improve permission error handling (#512)
* feat: add sudo support for file manager operations
* fix: add sudo support for listFiles and improve permission error handling
---------
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
* feat: fix sudo password dialog ui, add totp/pass reset limiting, and refreshed users screen when auth is outdated
* feat: add copy password button and fixed new line carriage issues and backend crash for auth key
* feat: added quick connection system (ad-hoc)
* Enter Key for Quick Login (#513)
* feat: added -r and -l support for tunnels
* feat: begin dashboard overhaul by splitting into cards and adding customization
* feat: improved full screen apps, overhauled dashboard, updated server stats ui, etc.
* feat: add auth.tsx suppot for fullscreen
* feat: greatly improve network graph ui/ux and migrated to use translations and theme system
* feat: update to use blacksmith
* feat: improve ui for customized tabs and hide add/edit host/credential when submiting
* feat: add warpgate support with a dialog (terminal only)
* feat: expand warpgate to docker/file manager
* fix: docker not working wtih warpgate and none auth failing for terminal
* fix: prevent owner permission loss when sharing host to own role (#514)
* Update Crowdin configuration file
* Update Crowdin configuration file
* Update Linux Portable section with AUR link (#474)
* fix: file manager incorrectly decoding/encoding when editing files (#476)
* fix: electron build errors and skip macos job
* fix: testflight submit failure
* fix: made submit job match build type
* fix: resolve Vite build warnings for mixed static/dynamic imports (#473)
* Update Crowdin configuration file
* Update Crowdin configuration file
* fix: resolve Vite build warnings for mixed static/dynamic imports
- Convert all dynamic imports of main-axios.ts to static imports (10 files)
- Convert all dynamic imports of sonner to static imports (4 files)
- Add manual chunking configuration to vite.config.ts for better bundle splitting
- react-vendor: React and React DOM
- ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge
- monaco: Monaco Editor
- codemirror: CodeMirror and related packages
- Increase chunkSizeWarningLimit to 1000kB
This resolves Vite warnings about mixed import strategies preventing
proper code-splitting.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
* fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent)
---------
Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
* fix: build error on docker (#477)
* fix: electron build errors and skip macos job
* fix: testflight submit failure
* fix: made submit job match build type
* fix: resolve Vite build warnings for mixed static/dynamic imports (#473)
* Update Crowdin configuration file
* Update Crowdin configuration file
* fix: resolve Vite build warnings for mixed static/dynamic imports
- Convert all dynamic imports of main-axios.ts to static imports (10 files)
- Convert all dynamic imports of sonner to static imports (4 files)
- Add manual chunking configuration to vite.config.ts for better bundle splitting
- react-vendor: React and React DOM
- ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge
- monaco: Monaco Editor
- codemirror: CodeMirror and related packages
- Increase chunkSizeWarningLimit to 1000kB
This resolves Vite warnings about mixed import strategies preventing
proper code-splitting.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
* fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent)
* fix: build error on docker
---------
Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
* Increase max old space size for npm builds
* Increase Node.js memory limit in Dockerfile
* Remove NODE_OPTIONS from build commands in Dockerfile
* Change runner to blacksmith-4vcpu-ubuntu-2404
* fix: build error on docker
* fix: prevent owner permission loss when sharing host to own role
Fixes #391
---------
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Gaylord Julien <g.j@mailbox.org>
Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
* fix: SSH key passphrase not passed for Docker and Tunnel (#521)
* perf: optimize Host Manager for large host lists
- Add ServerStatusContext for shared status polling (reduces API calls from N to 1)
- Move TooltipProvider to component root (eliminates N context instances)
- Add pagination with "Show More" button (limits initial DOM nodes per folder)
Fixes performance issues when managing ~1000 hosts with status monitoring enabled.
* fix: SSH key passphrase not passed to ssh2 for Docker and Tunnel
Database field is `key_password` but code used `keyPassword`.
Added fallback to check both field names.
Affected:
- docker.ts: Docker SSH connections with encrypted keys
- tunnel.ts: Tunnel connections with encrypted keys
---------
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
* perf: optimize Host Manager for large host lists (#520)
- Add ServerStatusContext for shared status polling (reduces API calls from N to 1)
- Move TooltipProvider to component root (eliminates N context instances)
- Add pagination with "Show More" button (limits initial DOM nodes per folder)
Fixes performance issues when managing ~1000 hosts with status monitoring enabled.
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
* fix: add missing mimeTypes definition for image preview (#518)
Fixes Termix-SSH/Support#408
* fix: prevent session reset when updating host properties (#517)
Move WebSocket cleanup logic to a separate unmount-only effect to
prevent SSH sessions from being closed when host properties are updated.
Closes Termix-SSH/Support#401
* fix: backend type error
* feat: make terminal connections more resilient, added connection log, and fixed https/proxy reconnection loop (issue #385)
* feat: improved conneciton log ui/logic
* feat: improved conneciton log ui/logic
* feat: expanded connection log to work across all components (readying for release)
* feat: update readme
* feat: update readme
* fix: build error
* fix: build error
* fix: changed ver
* chore: clean up
* chore: continue clean up
* fix: remove attempts remaining and fix electron errors and some connection log ui inconsistencies
* fix: added missing nginx routes and fixed sudo password copy with sudo password autofil field
* fix: update readme and run cleaner
* fix: update readme
* fix: update readme
* fix: update readme
* feat: update chinese readme
---------
Co-authored-by: Steven Josefs <s.josefs@gmx.de>
Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com>
Co-authored-by: Gaylord Julien <g.j@mailbox.org>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Aditya Tawade <36890395+aditya-tawade@users.noreply.github.com>
This commit is contained in:
+138
-15
@@ -9,14 +9,24 @@ import {
|
||||
} from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx";
|
||||
import { CommandHistoryProvider } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
|
||||
import { ServerStatusProvider } from "@/ui/contexts/ServerStatusContext";
|
||||
import { AdminSettings } from "@/ui/desktop/apps/admin/AdminSettings.tsx";
|
||||
import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx";
|
||||
import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
|
||||
import { Toaster } from "@/components/ui/sonner.tsx";
|
||||
import { toast } from "sonner";
|
||||
import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette.tsx";
|
||||
import { getUserInfo, logoutUser, isElectron } from "@/ui/main-axios.ts";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { dbHealthMonitor } from "@/lib/db-health-monitor.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function AppContent() {
|
||||
function AppContent({
|
||||
onAuthStateChange,
|
||||
}: {
|
||||
onAuthStateChange?: (isAuthenticated: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
@@ -29,11 +39,12 @@ function AppContent() {
|
||||
const [transitionPhase, setTransitionPhase] = useState<
|
||||
"idle" | "fadeOut" | "fadeIn"
|
||||
>("idle");
|
||||
const { currentTab, tabs, updateTab } = useTabs();
|
||||
const { currentTab, tabs, updateTab, addTab } = useTabs();
|
||||
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
|
||||
const [rightSidebarWidth, setRightSidebarWidth] = useState(400);
|
||||
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
|
||||
|
||||
const isDarkMode =
|
||||
theme === "dark" ||
|
||||
@@ -45,12 +56,49 @@ function AppContent() {
|
||||
|
||||
const lastAltPressTime = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDatabaseConnectionLost = () => {
|
||||
setDbConnectionFailed(true);
|
||||
setIsAuthenticated(false);
|
||||
};
|
||||
|
||||
const handleDatabaseConnectionRestored = () => {
|
||||
setDbConnectionFailed(false);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
dbHealthMonitor.on(
|
||||
"database-connection-lost",
|
||||
handleDatabaseConnectionLost,
|
||||
);
|
||||
dbHealthMonitor.on(
|
||||
"database-connection-restored",
|
||||
handleDatabaseConnectionRestored,
|
||||
);
|
||||
|
||||
return () => {
|
||||
dbHealthMonitor.off(
|
||||
"database-connection-lost",
|
||||
handleDatabaseConnectionLost,
|
||||
);
|
||||
dbHealthMonitor.off(
|
||||
"database-connection-restored",
|
||||
handleDatabaseConnectionRestored,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code === "ShiftLeft") {
|
||||
if (event.repeat) {
|
||||
return;
|
||||
}
|
||||
const shortcutEnabled =
|
||||
localStorage.getItem("commandPaletteShortcutEnabled") !== "false";
|
||||
if (!shortcutEnabled) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (now - lastShiftPressTime.current < 300) {
|
||||
setIsCommandPaletteOpen((isOpen) => !isOpen);
|
||||
@@ -86,6 +134,47 @@ function AppContent() {
|
||||
};
|
||||
}, [theme, setTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const path = window.location.pathname;
|
||||
const terminalMatch = path.match(/^\/terminal\/([a-zA-Z0-9_-]+)$/);
|
||||
const legacyMatch = path.match(/^\/hosts\/([a-zA-Z0-9_-]+)\/terminal$/);
|
||||
const hostIdentifier = terminalMatch?.[1] || legacyMatch?.[1];
|
||||
|
||||
if (hostIdentifier) {
|
||||
const openTerminal = async () => {
|
||||
try {
|
||||
const { getSSHHostById, getSSHHosts } =
|
||||
await import("@/ui/main-axios.ts");
|
||||
let host = null;
|
||||
|
||||
if (/^\d+$/.test(hostIdentifier)) {
|
||||
host = await getSSHHostById(parseInt(hostIdentifier, 10));
|
||||
} else {
|
||||
const hosts = await getSSHHosts();
|
||||
host =
|
||||
hosts.find((h: { name?: string }) => h.name === hostIdentifier) ||
|
||||
null;
|
||||
}
|
||||
|
||||
if (host) {
|
||||
addTab({
|
||||
type: "terminal",
|
||||
title: host.name || host.ip,
|
||||
data: { host, initialCommand: "" },
|
||||
});
|
||||
window.history.replaceState({}, "", "/");
|
||||
} else {
|
||||
toast.error(`Host "${hostIdentifier}" not found`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to open terminal:", error);
|
||||
toast.error("Failed to open terminal for host");
|
||||
}
|
||||
};
|
||||
openTerminal();
|
||||
}
|
||||
}, [addTab]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
setAuthLoading(true);
|
||||
@@ -131,7 +220,9 @@ function AppContent() {
|
||||
localStorage.setItem("topNavbarOpen", JSON.stringify(isTopbarOpen));
|
||||
}, [isTopbarOpen]);
|
||||
|
||||
const handleSelectView = () => {};
|
||||
useEffect(() => {
|
||||
onAuthStateChange?.(isAuthenticated);
|
||||
}, [isAuthenticated, onAuthStateChange]);
|
||||
|
||||
const handleAuthSuccess = useCallback(
|
||||
(authData: {
|
||||
@@ -182,16 +273,17 @@ function AppContent() {
|
||||
currentTabData?.type === "server_stats" ||
|
||||
currentTabData?.type === "file_manager" ||
|
||||
currentTabData?.type === "tunnel" ||
|
||||
currentTabData?.type === "docker";
|
||||
currentTabData?.type === "docker" ||
|
||||
currentTabData?.type === "network_graph";
|
||||
const showHome = currentTabData?.type === "home";
|
||||
const showSshManager = currentTabData?.type === "ssh_manager";
|
||||
const showAdmin = currentTabData?.type === "admin";
|
||||
const showProfile = currentTabData?.type === "user_profile";
|
||||
|
||||
if (authLoading) {
|
||||
if (authLoading && !dbConnectionFailed) {
|
||||
return (
|
||||
<div
|
||||
className="h-screen w-screen flex items-center justify-center"
|
||||
className="fixed inset-0 flex items-center justify-center"
|
||||
style={{
|
||||
background: "var(--bg-elevated)",
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
@@ -203,13 +295,44 @@ function AppContent() {
|
||||
)`,
|
||||
}}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-primary/30 border-t-primary rounded-full animate-spin mx-auto" />
|
||||
<div className="w-[420px] max-w-full p-8 flex flex-col backdrop-blur-sm bg-card/50 rounded-2xl shadow-xl border-2 border-edge overflow-y-auto thin-scrollbar my-2 animate-in fade-in zoom-in-95 duration-300">
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="text-center">
|
||||
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
{t("common.checkingAuthentication")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -219,7 +342,6 @@ function AppContent() {
|
||||
{!isAuthenticated && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-background">
|
||||
<Dashboard
|
||||
onSelectView={handleSelectView}
|
||||
isAuthenticated={isAuthenticated}
|
||||
authLoading={authLoading}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
@@ -230,7 +352,6 @@ function AppContent() {
|
||||
|
||||
{isAuthenticated && (
|
||||
<LeftSidebar
|
||||
onSelectView={handleSelectView}
|
||||
disabled={!isAuthenticated || authLoading}
|
||||
isAdmin={isAdmin}
|
||||
username={username}
|
||||
@@ -250,7 +371,6 @@ function AppContent() {
|
||||
{showHome && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||
<Dashboard
|
||||
onSelectView={handleSelectView}
|
||||
isAuthenticated={isAuthenticated}
|
||||
authLoading={authLoading}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
@@ -264,7 +384,6 @@ function AppContent() {
|
||||
{showSshManager && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||
<HostManager
|
||||
onSelectView={handleSelectView}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
initialTab={currentTabData?.initialTab}
|
||||
hostConfig={currentTabData?.hostConfig}
|
||||
@@ -497,11 +616,15 @@ function AppContent() {
|
||||
}
|
||||
|
||||
function DesktopApp() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
return (
|
||||
<TabProvider>
|
||||
<CommandHistoryProvider>
|
||||
<AppContent />
|
||||
</CommandHistoryProvider>
|
||||
<ServerStatusProvider isAuthenticated={isAuthenticated}>
|
||||
<CommandHistoryProvider>
|
||||
<AppContent onAuthStateChange={setIsAuthenticated} />
|
||||
</CommandHistoryProvider>
|
||||
</ServerStatusProvider>
|
||||
</TabProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { TabProvider } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import { CommandHistoryProvider } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar.tsx";
|
||||
import { getSSHHosts, getUserInfo } from "@/ui/main-axios.ts";
|
||||
import type { SSHHost } from "@/types";
|
||||
import { Dashboard } from "@/ui/desktop/apps/dashboard/Dashboard.tsx";
|
||||
import { Toaster } from "@/components/ui/sonner.tsx";
|
||||
|
||||
interface FullScreenAppWrapperProps {
|
||||
hostId?: string;
|
||||
children: (hostConfig: SSHHost | null, loading: boolean) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
|
||||
hostId,
|
||||
children,
|
||||
}) => {
|
||||
const [hostConfig, setHostConfig] = useState<SSHHost | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const userInfo = await getUserInfo();
|
||||
if (userInfo) {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(userInfo.isAdmin || false);
|
||||
}
|
||||
} catch (error) {
|
||||
setIsAuthenticated(false);
|
||||
} finally {
|
||||
setAuthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchHost = async () => {
|
||||
if (!hostId || !isAuthenticated) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const hosts = await getSSHHosts();
|
||||
const host = hosts.find((h) => h.id === parseInt(hostId, 10));
|
||||
if (host) {
|
||||
setHostConfig(host);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch host:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!authLoading && isAuthenticated) {
|
||||
fetchHost();
|
||||
}
|
||||
}, [hostId, isAuthenticated, authLoading]);
|
||||
|
||||
const handleAuthSuccess = (authData: {
|
||||
isAdmin: boolean;
|
||||
username: string | null;
|
||||
userId: string | null;
|
||||
}) => {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(authData.isAdmin);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div
|
||||
className="w-full h-screen overflow-hidden flex items-center justify-center"
|
||||
style={{ backgroundColor: "#18181b" }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<TabProvider>
|
||||
<CommandHistoryProvider>
|
||||
<div
|
||||
className="w-full h-screen overflow-hidden flex items-center justify-center"
|
||||
style={{ backgroundColor: "#18181b" }}
|
||||
>
|
||||
<Dashboard
|
||||
isAuthenticated={false}
|
||||
authLoading={authLoading}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
isTopbarOpen={false}
|
||||
onSelectView={() => {}}
|
||||
/>
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
richColors={false}
|
||||
closeButton
|
||||
duration={5000}
|
||||
offset={20}
|
||||
/>
|
||||
</div>
|
||||
</CommandHistoryProvider>
|
||||
</TabProvider>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<TabProvider>
|
||||
<CommandHistoryProvider>
|
||||
<div
|
||||
className="w-full h-screen overflow-hidden"
|
||||
style={{ backgroundColor: "#18181b" }}
|
||||
>
|
||||
{children(hostConfig, loading)}
|
||||
</div>
|
||||
</CommandHistoryProvider>
|
||||
</TabProvider>
|
||||
</SidebarProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { HostManager } from "@/ui/desktop/apps/host-manager/hosts/HostManager";
|
||||
import React from "react";
|
||||
|
||||
const HostManagerApp: React.FC = () => {
|
||||
return (
|
||||
<div className="w-full h-screen">
|
||||
<HostManager isTopbarOpen={false} onSelectView={() => {}} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HostManagerApp;
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getAdminOIDCConfig,
|
||||
getRegistrationAllowed,
|
||||
getPasswordLoginAllowed,
|
||||
getPasswordResetAllowed,
|
||||
getUserList,
|
||||
getUserInfo,
|
||||
isElectron,
|
||||
@@ -48,6 +49,7 @@ export function AdminSettings({
|
||||
|
||||
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
||||
const [allowPasswordLogin, setAllowPasswordLogin] = React.useState(true);
|
||||
const [allowPasswordReset, setAllowPasswordReset] = React.useState(true);
|
||||
|
||||
const [oidcConfig, setOidcConfig] = React.useState({
|
||||
client_id: "",
|
||||
@@ -193,6 +195,28 @@ export function AdminSettings({
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
getPasswordResetAllowed()
|
||||
.then((res) => {
|
||||
if (typeof res === "boolean") {
|
||||
setAllowPasswordReset(res);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.code !== "NO_SERVER_CONFIGURED") {
|
||||
console.warn("Failed to fetch password reset status", err);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as { configuredServerUrl?: string })
|
||||
@@ -367,6 +391,8 @@ export function AdminSettings({
|
||||
setAllowRegistration={setAllowRegistration}
|
||||
allowPasswordLogin={allowPasswordLogin}
|
||||
setAllowPasswordLogin={setAllowPasswordLogin}
|
||||
allowPasswordReset={allowPasswordReset}
|
||||
setAllowPasswordReset={setAllowPasswordReset}
|
||||
oidcConfig={oidcConfig}
|
||||
/>
|
||||
</TabsContent>
|
||||
@@ -413,7 +439,6 @@ export function AdminSettings({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dialogs */}
|
||||
<CreateUserDialog
|
||||
open={createUserDialogOpen}
|
||||
onOpenChange={setCreateUserDialogOpen}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
updateRegistrationAllowed,
|
||||
updatePasswordLoginAllowed,
|
||||
updatePasswordResetAllowed,
|
||||
} from "@/ui/main-axios.ts";
|
||||
|
||||
interface GeneralSettingsTabProps {
|
||||
@@ -13,6 +14,8 @@ interface GeneralSettingsTabProps {
|
||||
setAllowRegistration: (value: boolean) => void;
|
||||
allowPasswordLogin: boolean;
|
||||
setAllowPasswordLogin: (value: boolean) => void;
|
||||
allowPasswordReset: boolean;
|
||||
setAllowPasswordReset: (value: boolean) => void;
|
||||
oidcConfig: {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
@@ -27,6 +30,8 @@ export function GeneralSettingsTab({
|
||||
setAllowRegistration,
|
||||
allowPasswordLogin,
|
||||
setAllowPasswordLogin,
|
||||
allowPasswordReset,
|
||||
setAllowPasswordReset,
|
||||
oidcConfig,
|
||||
}: GeneralSettingsTabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
@@ -34,6 +39,7 @@ export function GeneralSettingsTab({
|
||||
|
||||
const [regLoading, setRegLoading] = React.useState(false);
|
||||
const [passwordLoginLoading, setPasswordLoginLoading] = React.useState(false);
|
||||
const [passwordResetLoading, setPasswordResetLoading] = React.useState(false);
|
||||
|
||||
const handleToggleRegistration = async (checked: boolean) => {
|
||||
setRegLoading(true);
|
||||
@@ -96,6 +102,16 @@ export function GeneralSettingsTab({
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePasswordReset = async (checked: boolean) => {
|
||||
setPasswordResetLoading(true);
|
||||
try {
|
||||
await updatePasswordResetAllowed(checked);
|
||||
setAllowPasswordReset(checked);
|
||||
} finally {
|
||||
setPasswordResetLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t("admin.userRegistration")}</h3>
|
||||
@@ -120,6 +136,19 @@ export function GeneralSettingsTab({
|
||||
/>
|
||||
{t("admin.allowPasswordLogin")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={allowPasswordReset}
|
||||
onCheckedChange={handleTogglePasswordReset}
|
||||
disabled={passwordResetLoading || !allowPasswordLogin}
|
||||
/>
|
||||
{t("admin.allowPasswordReset")}
|
||||
{!allowPasswordLogin && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({t("admin.requiresPasswordLogin")})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -232,7 +232,6 @@ export function RolesTab(): React.ReactElement {
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Role Dialog */}
|
||||
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { ButtonGroup } from "@/components/ui/button-group.tsx";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -353,6 +354,18 @@ export function CommandPalette({
|
||||
hasTunnelConnections = false;
|
||||
}
|
||||
|
||||
const visibleButtons = [
|
||||
host.enableTerminal && (host.showTerminalInSidebar ?? true),
|
||||
host.enableFileManager &&
|
||||
(host.showFileManagerInSidebar ?? false),
|
||||
host.enableTunnel &&
|
||||
hasTunnelConnections &&
|
||||
(host.showTunnelInSidebar ?? false),
|
||||
host.enableDocker && (host.showDockerInSidebar ?? false),
|
||||
shouldShowMetrics &&
|
||||
(host.showServerStatsInSidebar ?? false),
|
||||
].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={`host-${index}-${host.id}`}
|
||||
@@ -364,19 +377,94 @@ export function CommandPalette({
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
<span>{title}</span>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Server className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">{title}</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
<ButtonGroup
|
||||
className="flex-shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{host.enableTerminal &&
|
||||
(host.showTerminalInSidebar ?? true) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 h-7 border-1 border-edge"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostTerminalClick(host);
|
||||
}}
|
||||
>
|
||||
<Terminal className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{host.enableFileManager &&
|
||||
(host.showFileManagerInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 h-7 border-1 border-edge"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostFileManagerClick(host);
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{host.enableTunnel &&
|
||||
hasTunnelConnections &&
|
||||
(host.showTunnelInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 h-7 border-1 border-edge"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostTunnelClick(host);
|
||||
}}
|
||||
>
|
||||
<ArrowDownUp className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{host.enableDocker &&
|
||||
(host.showDockerInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 h-7 border-1 border-edge"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostDockerClick(host);
|
||||
}}
|
||||
>
|
||||
<Container className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{shouldShowMetrics &&
|
||||
(host.showServerStatsInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 h-7 border-1 border-edge"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostServerDetailsClick(host);
|
||||
}}
|
||||
>
|
||||
<Server className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 h-7 border-1 border-edge"
|
||||
className={cn(
|
||||
"!px-2 h-7 border-1 border-edge",
|
||||
visibleButtons > 0 &&
|
||||
"rounded-l-none border-l-0",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVertical className="h-3 w-3" />
|
||||
@@ -387,62 +475,82 @@ export function CommandPalette({
|
||||
side="right"
|
||||
className="w-56 bg-canvas border-edge text-foreground"
|
||||
>
|
||||
{shouldShowMetrics && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostServerDetailsClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="flex-1">
|
||||
{t("hosts.openServerStats")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableFileManager && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostFileManagerClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="flex-1">
|
||||
{t("hosts.openFileManager")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableTunnel && hasTunnelConnections && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostTunnelClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<ArrowDownUp className="h-4 w-4" />
|
||||
<span className="flex-1">
|
||||
{t("hosts.openTunnels")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableDocker && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostDockerClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<Container className="h-4 w-4" />
|
||||
<span className="flex-1">
|
||||
{t("hosts.openDocker")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableTerminal &&
|
||||
!(host.showTerminalInSidebar ?? true) && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostTerminalClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span className="flex-1">
|
||||
{t("hosts.openTerminal")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{shouldShowMetrics &&
|
||||
!(host.showServerStatsInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostServerDetailsClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="flex-1">
|
||||
{t("hosts.openServerStats")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableFileManager &&
|
||||
!(host.showFileManagerInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostFileManagerClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="flex-1">
|
||||
{t("hosts.openFileManager")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableTunnel &&
|
||||
hasTunnelConnections &&
|
||||
!(host.showTunnelInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostTunnelClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<ArrowDownUp className="h-4 w-4" />
|
||||
<span className="flex-1">
|
||||
{t("hosts.openTunnels")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableDocker &&
|
||||
!(host.showDockerInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostDockerClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<Container className="h-4 w-4" />
|
||||
<span className="flex-1">
|
||||
{t("hosts.openDocker")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -455,7 +563,7 @@ export function CommandPalette({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</ButtonGroup>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useState, useContext } from "react";
|
||||
import { Auth } from "@/ui/desktop/authentication/Auth.tsx";
|
||||
import { UpdateLog } from "@/ui/desktop/apps/dashboard/apps/UpdateLog.tsx";
|
||||
import { AlertManager } from "@/ui/desktop/apps/dashboard/apps/alerts/AlertManager.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
@@ -10,39 +9,28 @@ import {
|
||||
getUptime,
|
||||
getVersionInfo,
|
||||
getSSHHosts,
|
||||
getTunnelStatuses,
|
||||
getCredentials,
|
||||
getRecentActivity,
|
||||
resetRecentActivity,
|
||||
getServerMetricsById,
|
||||
registerMetricsViewer,
|
||||
sendMetricsHeartbeat,
|
||||
type RecentActivityItem,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
||||
import {
|
||||
ChartLine,
|
||||
Clock,
|
||||
Database,
|
||||
FastForward,
|
||||
History,
|
||||
Key,
|
||||
Network,
|
||||
Server,
|
||||
UserPlus,
|
||||
Settings,
|
||||
User,
|
||||
Loader2,
|
||||
Terminal,
|
||||
FolderOpen,
|
||||
Activity,
|
||||
Container,
|
||||
ArrowDownUp,
|
||||
} from "lucide-react";
|
||||
import { Status } from "@/components/ui/shadcn-io/status";
|
||||
import { BsLightning } from "react-icons/bs";
|
||||
import { Kbd } from "@/components/ui/kbd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Settings as SettingsIcon } from "lucide-react";
|
||||
import { ServerOverviewCard } from "@/ui/desktop/apps/dashboard/cards/ServerOverviewCard";
|
||||
import { RecentActivityCard } from "@/ui/desktop/apps/dashboard/cards/RecentActivityCard";
|
||||
import { QuickActionsCard } from "@/ui/desktop/apps/dashboard/cards/QuickActionsCard";
|
||||
import { ServerStatsCard } from "@/ui/desktop/apps/dashboard/cards/ServerStatsCard";
|
||||
import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
|
||||
import { useDashboardPreferences } from "@/ui/desktop/apps/dashboard/hooks/useDashboardPreferences";
|
||||
import { DashboardSettingsDialog } from "@/ui/desktop/apps/dashboard/components/DashboardSettingsDialog";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader";
|
||||
|
||||
interface DashboardProps {
|
||||
onSelectView: (view: string) => void;
|
||||
@@ -56,6 +44,7 @@ interface DashboardProps {
|
||||
isTopbarOpen: boolean;
|
||||
rightSidebarOpen?: boolean;
|
||||
rightSidebarWidth?: number;
|
||||
initialDbError?: string | null;
|
||||
}
|
||||
|
||||
export function Dashboard({
|
||||
@@ -63,16 +52,16 @@ export function Dashboard({
|
||||
authLoading,
|
||||
onAuthSuccess,
|
||||
isTopbarOpen,
|
||||
onSelectView,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 400,
|
||||
initialDbError = null,
|
||||
}: DashboardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [, setUsername] = useState<string | null>(null);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [dbError, setDbError] = useState<string | null>(null);
|
||||
const [dbError, setDbError] = useState<string | null>(initialDbError);
|
||||
|
||||
const [uptime, setUptime] = useState<string>("0d 0h 0m");
|
||||
const [versionStatus, setVersionStatus] = useState<
|
||||
@@ -92,16 +81,27 @@ export function Dashboard({
|
||||
Array<{ id: number; name: string; cpu: number | null; ram: number | null }>
|
||||
>([]);
|
||||
const [serverStatsLoading, setServerStatsLoading] = useState<boolean>(true);
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [viewerSessions, setViewerSessions] = useState<Map<number, string>>(
|
||||
new Map(),
|
||||
);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
|
||||
const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs();
|
||||
const {
|
||||
layout,
|
||||
loading: preferencesLoading,
|
||||
updateLayout,
|
||||
resetLayout,
|
||||
} = useDashboardPreferences(loggedIn);
|
||||
|
||||
let sidebarState: "expanded" | "collapsed" = "expanded";
|
||||
let sidebarAvailable = false;
|
||||
try {
|
||||
const sidebar = useSidebar();
|
||||
sidebarState = sidebar.state;
|
||||
} catch (error) {
|
||||
console.error("Dashboard operation failed:", error);
|
||||
}
|
||||
sidebarAvailable = true;
|
||||
} catch {}
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||
@@ -159,9 +159,18 @@ export function Dashboard({
|
||||
const uptimeInfo = await getUptime();
|
||||
setUptime(uptimeInfo.formatted);
|
||||
|
||||
const versionInfo = await getVersionInfo();
|
||||
setVersionText(`v${versionInfo.localVersion}`);
|
||||
setVersionStatus(versionInfo.status || "up_to_date");
|
||||
const updateCheckDisabled =
|
||||
localStorage.getItem("disableUpdateCheck") === "true";
|
||||
if (!updateCheckDisabled) {
|
||||
const versionInfo = await getVersionInfo();
|
||||
setVersionText(`v${versionInfo.localVersion}`);
|
||||
if (
|
||||
versionInfo.status === "up_to_date" ||
|
||||
versionInfo.status === "requires_update"
|
||||
) {
|
||||
setVersionStatus(versionInfo.status);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await getDatabaseHealth();
|
||||
@@ -206,6 +215,7 @@ export function Dashboard({
|
||||
setRecentActivityLoading(false);
|
||||
|
||||
setServerStatsLoading(true);
|
||||
const newViewerSessions = new Map<number, string>();
|
||||
const serversWithStats = await Promise.all(
|
||||
hosts
|
||||
.slice(0, 50)
|
||||
@@ -213,6 +223,7 @@ export function Dashboard({
|
||||
async (host: {
|
||||
id: number;
|
||||
name: string;
|
||||
authType?: string;
|
||||
statsConfig?: string | { metricsEnabled?: boolean };
|
||||
}) => {
|
||||
try {
|
||||
@@ -231,6 +242,33 @@ export function Dashboard({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (host.authType === "none") {
|
||||
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,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
newViewerSessions.set(host.id, existingSession);
|
||||
}
|
||||
|
||||
const metrics = await getServerMetricsById(host.id);
|
||||
return {
|
||||
id: host.id,
|
||||
@@ -249,6 +287,7 @@ export function Dashboard({
|
||||
},
|
||||
),
|
||||
);
|
||||
setViewerSessions(newViewerSessions);
|
||||
const validServerStats = serversWithStats.filter(
|
||||
(
|
||||
server,
|
||||
@@ -265,6 +304,8 @@ export function Dashboard({
|
||||
console.error("Failed to fetch dashboard data:", error);
|
||||
setRecentActivityLoading(false);
|
||||
setServerStatsLoading(false);
|
||||
} finally {
|
||||
setInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -274,6 +315,22 @@ export function Dashboard({
|
||||
return () => clearInterval(interval);
|
||||
}, [loggedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loggedIn || viewerSessions.size === 0) return;
|
||||
|
||||
const heartbeatInterval = setInterval(async () => {
|
||||
for (const [, sessionId] of viewerSessions) {
|
||||
try {
|
||||
await sendMetricsHeartbeat(sessionId);
|
||||
} catch (error) {
|
||||
console.error("Failed to send heartbeat:", error);
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(heartbeatInterval);
|
||||
}, [loggedIn, viewerSessions]);
|
||||
|
||||
const handleResetActivity = async () => {
|
||||
try {
|
||||
await resetRecentActivity();
|
||||
@@ -423,9 +480,15 @@ export function Dashboard({
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col relative z-10 w-full h-full min-w-0">
|
||||
<SimpleLoader
|
||||
visible={initialLoading}
|
||||
message={t("dashboard.loading")}
|
||||
/>
|
||||
<div className="flex flex-row items-center justify-between w-full px-3 mt-3 min-w-0 flex-wrap gap-2">
|
||||
<div className="text-2xl text-foreground font-semibold shrink-0">
|
||||
{t("dashboard.title")}
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<div className="text-2xl text-foreground font-semibold shrink-0">
|
||||
{t("dashboard.title")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-3 flex-wrap min-w-0">
|
||||
<div className="flex flex-col items-center gap-4 justify-center mr-5 min-w-0 shrink">
|
||||
@@ -478,367 +541,106 @@ export function Dashboard({
|
||||
>
|
||||
{t("dashboard.donate")}
|
||||
</Button>
|
||||
<Button
|
||||
className="font-semibold shrink-0 !bg-canvas"
|
||||
variant="outline"
|
||||
onClick={() => setSettingsDialogOpen(true)}
|
||||
>
|
||||
<SettingsIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="mt-3 p-0.25" />
|
||||
|
||||
<div className="flex flex-col flex-1 my-5 mx-5 gap-4 min-h-0 min-w-0">
|
||||
<div className="flex flex-row flex-1 gap-4 min-h-0 min-w-0">
|
||||
<div className="flex-1 min-w-0 border-2 border-edge rounded-md bg-elevated flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
|
||||
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden thin-scrollbar">
|
||||
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
|
||||
<Server className="mr-3" />
|
||||
{t("dashboard.serverOverview")}
|
||||
</p>
|
||||
<div className="bg-canvas w-full h-auto border-2 border-edge rounded-md px-3 py-3">
|
||||
<div className="flex flex-row items-center justify-between mb-3 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<History size={20} className="shrink-0" />
|
||||
<p className="ml-2 leading-none truncate">
|
||||
{t("dashboard.version")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<p className="leading-none text-muted-foreground">
|
||||
{versionText}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`ml-2 text-sm border-1 border-edge ${versionStatus === "up_to_date" ? "text-green-400" : "text-yellow-400"}`}
|
||||
>
|
||||
{versionStatus === "up_to_date"
|
||||
? t("dashboard.upToDate")
|
||||
: t("dashboard.updateAvailable")}
|
||||
</Button>
|
||||
<UpdateLog loggedIn={loggedIn} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between mb-5 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Clock size={20} className="shrink-0" />
|
||||
<p className="ml-2 leading-none truncate">
|
||||
{t("dashboard.uptime")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<p className="leading-none text-muted-foreground">
|
||||
{uptime}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Database size={20} className="shrink-0" />
|
||||
<p className="ml-2 leading-none truncate">
|
||||
{t("dashboard.database")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<p
|
||||
className={`leading-none ${dbHealth === "healthy" ? "text-green-400" : "text-red-400"}`}
|
||||
>
|
||||
{dbHealth === "healthy"
|
||||
? t("dashboard.healthy")
|
||||
: t("dashboard.error")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
||||
<div className="flex flex-row items-center justify-between bg-canvas w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
|
||||
<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")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
||||
{totalServers}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between bg-canvas w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<ArrowDownUp size={16} className="mr-3 shrink-0" />
|
||||
<p className="m-0 leading-none truncate">
|
||||
{t("dashboard.totalTunnels")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
||||
{totalTunnels}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
||||
<div className="flex flex-row items-center justify-between bg-canvas w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Key size={16} className="mr-3 shrink-0" />
|
||||
<p className="m-0 leading-none truncate">
|
||||
{t("dashboard.totalCredentials")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
||||
{totalCredentials}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 border-2 border-edge rounded-md bg-elevated flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
|
||||
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
|
||||
<div className="flex flex-row items-center justify-between mb-3 mt-1">
|
||||
<p className="text-xl font-semibold flex flex-row items-center">
|
||||
<Clock className="mr-3" />
|
||||
{t("dashboard.recentActivity")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-2 !border-edge h-7 !bg-canvas"
|
||||
onClick={handleResetActivity}
|
||||
>
|
||||
{t("dashboard.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden thin-scrollbar ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
||||
>
|
||||
{recentActivityLoading ? (
|
||||
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
|
||||
<Loader2 className="animate-spin mr-2" size={16} />
|
||||
<span>{t("dashboard.loadingRecentActivity")}</span>
|
||||
</div>
|
||||
) : recentActivity.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("dashboard.noRecentActivity")}
|
||||
</p>
|
||||
) : (
|
||||
recentActivity
|
||||
.filter((item, index, array) => {
|
||||
if (index === 0) return true;
|
||||
|
||||
const prevItem = array[index - 1];
|
||||
return !(
|
||||
item.hostId === prevItem.hostId &&
|
||||
item.type === prevItem.type
|
||||
);
|
||||
})
|
||||
.map((item) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="outline"
|
||||
className="border-2 !border-edge !bg-canvas min-w-0"
|
||||
onClick={() => handleActivityClick(item)}
|
||||
>
|
||||
{item.type === "terminal" ? (
|
||||
<Terminal size={20} className="shrink-0" />
|
||||
) : item.type === "file_manager" ? (
|
||||
<FolderOpen size={20} className="shrink-0" />
|
||||
) : item.type === "server_stats" ? (
|
||||
<Server size={20} className="shrink-0" />
|
||||
) : item.type === "tunnel" ? (
|
||||
<ArrowDownUp size={20} className="shrink-0" />
|
||||
) : item.type === "docker" ? (
|
||||
<Container size={20} className="shrink-0" />
|
||||
) : (
|
||||
<Terminal size={20} className="shrink-0" />
|
||||
)}
|
||||
<p className="truncate ml-2 font-semibold">
|
||||
{item.hostName}
|
||||
</p>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row flex-1 gap-4 min-h-0 min-w-0">
|
||||
<div className="flex-1 min-w-0 border-2 border-edge rounded-md bg-elevated flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
|
||||
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden thin-scrollbar">
|
||||
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
|
||||
<FastForward className="mr-3" />
|
||||
{t("dashboard.quickActions")}
|
||||
</p>
|
||||
<div className="grid gap-4 grid-cols-3 auto-rows-min overflow-y-auto overflow-x-hidden thin-scrollbar">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
|
||||
onClick={handleAddHost}
|
||||
>
|
||||
<div className="flex flex-col items-center w-full max-w-full">
|
||||
<Server
|
||||
className="shrink-0"
|
||||
style={{ width: "40px", height: "40px" }}
|
||||
<div className="flex flex-col flex-1 my-5 mx-5 gap-4 min-h-0 min-w-0 overflow-auto">
|
||||
{!preferencesLoading && layout && (
|
||||
<div
|
||||
className="grid gap-4"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(600px, 1fr))",
|
||||
gridAutoRows: "minmax(300px, 1fr)",
|
||||
minHeight: "100%",
|
||||
}}
|
||||
>
|
||||
{layout.cards
|
||||
.filter((card) => card.enabled)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((card) => {
|
||||
if (card.id === "server_overview") {
|
||||
return (
|
||||
<ServerOverviewCard
|
||||
key={card.id}
|
||||
loggedIn={loggedIn}
|
||||
versionText={versionText}
|
||||
versionStatus={versionStatus}
|
||||
uptime={uptime}
|
||||
dbHealth={dbHealth}
|
||||
totalServers={totalServers}
|
||||
totalTunnels={totalTunnels}
|
||||
totalCredentials={totalCredentials}
|
||||
/>
|
||||
<span
|
||||
className="font-semibold text-sm mt-2 text-center block"
|
||||
style={{
|
||||
wordWrap: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
hyphens: "auto",
|
||||
display: "block",
|
||||
whiteSpace: "normal",
|
||||
}}
|
||||
>
|
||||
{t("dashboard.addHost")}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
|
||||
onClick={handleAddCredential}
|
||||
>
|
||||
<div className="flex flex-col items-center w-full max-w-full">
|
||||
<Key
|
||||
className="shrink-0"
|
||||
style={{ width: "40px", height: "40px" }}
|
||||
);
|
||||
} else if (card.id === "recent_activity") {
|
||||
return (
|
||||
<RecentActivityCard
|
||||
key={card.id}
|
||||
activities={recentActivity}
|
||||
loading={recentActivityLoading}
|
||||
onReset={handleResetActivity}
|
||||
onActivityClick={handleActivityClick}
|
||||
/>
|
||||
<span
|
||||
className="font-semibold text-sm mt-2 text-center block"
|
||||
style={{
|
||||
wordWrap: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
hyphens: "auto",
|
||||
display: "block",
|
||||
whiteSpace: "normal",
|
||||
}}
|
||||
>
|
||||
{t("dashboard.addCredential")}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
|
||||
onClick={handleOpenAdminSettings}
|
||||
>
|
||||
<div className="flex flex-col items-center w-full max-w-full">
|
||||
<Settings
|
||||
className="shrink-0"
|
||||
style={{ width: "40px", height: "40px" }}
|
||||
/>
|
||||
<span
|
||||
className="font-semibold text-sm mt-2 text-center block"
|
||||
style={{
|
||||
wordWrap: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
hyphens: "auto",
|
||||
display: "block",
|
||||
whiteSpace: "normal",
|
||||
}}
|
||||
>
|
||||
{t("dashboard.adminSettings")}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
|
||||
onClick={handleOpenUserProfile}
|
||||
>
|
||||
<div className="flex flex-col items-center w-full max-w-full">
|
||||
<User
|
||||
className="shrink-0"
|
||||
style={{ width: "40px", height: "40px" }}
|
||||
);
|
||||
} else if (card.id === "network_graph") {
|
||||
return (
|
||||
<NetworkGraphCard
|
||||
key={card.id}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
rightSidebarWidth={rightSidebarWidth}
|
||||
/>
|
||||
<span
|
||||
className="font-semibold text-sm mt-2 text-center block"
|
||||
style={{
|
||||
wordWrap: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
hyphens: "auto",
|
||||
display: "block",
|
||||
whiteSpace: "normal",
|
||||
}}
|
||||
>
|
||||
{t("dashboard.userProfile")}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (card.id === "quick_actions") {
|
||||
return (
|
||||
<QuickActionsCard
|
||||
key={card.id}
|
||||
isAdmin={isAdmin}
|
||||
onAddHost={handleAddHost}
|
||||
onAddCredential={handleAddCredential}
|
||||
onOpenAdminSettings={handleOpenAdminSettings}
|
||||
onOpenUserProfile={handleOpenUserProfile}
|
||||
/>
|
||||
);
|
||||
} else if (card.id === "server_stats") {
|
||||
return (
|
||||
<ServerStatsCard
|
||||
key={card.id}
|
||||
serverStats={serverStats}
|
||||
loading={serverStatsLoading}
|
||||
onServerClick={handleServerStatClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 border-2 border-edge rounded-md bg-elevated flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
|
||||
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
|
||||
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
|
||||
<ChartLine className="mr-3" />
|
||||
{t("dashboard.serverStats")}
|
||||
</p>
|
||||
<div
|
||||
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden thin-scrollbar ${serverStatsLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
||||
>
|
||||
{serverStatsLoading ? (
|
||||
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
|
||||
<Loader2 className="animate-spin mr-2" size={16} />
|
||||
<span>{t("dashboard.loadingServerStats")}</span>
|
||||
</div>
|
||||
) : serverStats.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("dashboard.noServerData")}
|
||||
</p>
|
||||
) : (
|
||||
serverStats.map((server) => (
|
||||
<Button
|
||||
key={server.id}
|
||||
variant="outline"
|
||||
className="border-2 !border-edge bg-canvas h-auto p-3 min-w-0 !bg-canvas"
|
||||
onClick={() =>
|
||||
handleServerStatClick(server.id, server.name)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-row items-center mb-2">
|
||||
<Server size={20} className="shrink-0" />
|
||||
<p className="truncate ml-2 font-semibold">
|
||||
{server.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-row justify-start gap-4 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("dashboard.cpu")}:{" "}
|
||||
{server.cpu !== null
|
||||
? `${server.cpu}%`
|
||||
: t("dashboard.notAvailable")}
|
||||
</span>
|
||||
<span>
|
||||
{t("dashboard.ram")}:{" "}
|
||||
{server.ram !== null
|
||||
? `${server.ram}%`
|
||||
: t("dashboard.notAvailable")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertManager userId={userId} loggedIn={loggedIn} />
|
||||
|
||||
{layout && (
|
||||
<DashboardSettingsDialog
|
||||
open={settingsDialogOpen}
|
||||
onOpenChange={setSettingsDialogOpen}
|
||||
currentLayout={layout}
|
||||
onSave={updateLayout}
|
||||
onReset={resetLayout}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FastForward, Server, Key, Settings, User } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface QuickActionsCardProps {
|
||||
isAdmin: boolean;
|
||||
onAddHost: () => void;
|
||||
onAddCredential: () => void;
|
||||
onOpenAdminSettings: () => void;
|
||||
onOpenUserProfile: () => void;
|
||||
}
|
||||
|
||||
export function QuickActionsCard({
|
||||
isAdmin,
|
||||
onAddHost,
|
||||
onAddCredential,
|
||||
onOpenAdminSettings,
|
||||
onOpenUserProfile,
|
||||
}: QuickActionsCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20 !bg-elevated">
|
||||
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden thin-scrollbar">
|
||||
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
|
||||
<FastForward className="mr-3" />
|
||||
{t("dashboard.quickActions")}
|
||||
</p>
|
||||
<div className="grid gap-4 grid-cols-3 auto-rows-min overflow-y-auto overflow-x-hidden thin-scrollbar">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
|
||||
onClick={onAddHost}
|
||||
>
|
||||
<div className="flex flex-col items-center w-full max-w-full">
|
||||
<Server
|
||||
className="shrink-0"
|
||||
style={{ width: "40px", height: "40px" }}
|
||||
/>
|
||||
<span
|
||||
className="font-semibold text-sm mt-2 text-center block"
|
||||
style={{
|
||||
wordWrap: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
hyphens: "auto",
|
||||
display: "block",
|
||||
whiteSpace: "normal",
|
||||
}}
|
||||
>
|
||||
{t("dashboard.addHost")}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
|
||||
onClick={onAddCredential}
|
||||
>
|
||||
<div className="flex flex-col items-center w-full max-w-full">
|
||||
<Key
|
||||
className="shrink-0"
|
||||
style={{ width: "40px", height: "40px" }}
|
||||
/>
|
||||
<span
|
||||
className="font-semibold text-sm mt-2 text-center block"
|
||||
style={{
|
||||
wordWrap: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
hyphens: "auto",
|
||||
display: "block",
|
||||
whiteSpace: "normal",
|
||||
}}
|
||||
>
|
||||
{t("dashboard.addCredential")}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
|
||||
onClick={onOpenAdminSettings}
|
||||
>
|
||||
<div className="flex flex-col items-center w-full max-w-full">
|
||||
<Settings
|
||||
className="shrink-0"
|
||||
style={{ width: "40px", height: "40px" }}
|
||||
/>
|
||||
<span
|
||||
className="font-semibold text-sm mt-2 text-center block"
|
||||
style={{
|
||||
wordWrap: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
hyphens: "auto",
|
||||
display: "block",
|
||||
whiteSpace: "normal",
|
||||
}}
|
||||
>
|
||||
{t("dashboard.adminSettings")}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
|
||||
onClick={onOpenUserProfile}
|
||||
>
|
||||
<div className="flex flex-col items-center w-full max-w-full">
|
||||
<User
|
||||
className="shrink-0"
|
||||
style={{ width: "40px", height: "40px" }}
|
||||
/>
|
||||
<span
|
||||
className="font-semibold text-sm mt-2 text-center block"
|
||||
style={{
|
||||
wordWrap: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
hyphens: "auto",
|
||||
display: "block",
|
||||
whiteSpace: "normal",
|
||||
}}
|
||||
>
|
||||
{t("dashboard.userProfile")}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Clock,
|
||||
Loader2,
|
||||
Terminal,
|
||||
FolderOpen,
|
||||
Server,
|
||||
ArrowDownUp,
|
||||
Container,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { type RecentActivityItem } from "@/ui/main-axios";
|
||||
|
||||
interface RecentActivityCardProps {
|
||||
activities: RecentActivityItem[];
|
||||
loading: boolean;
|
||||
onReset: () => void;
|
||||
onActivityClick: (item: RecentActivityItem) => void;
|
||||
}
|
||||
|
||||
export function RecentActivityCard({
|
||||
activities,
|
||||
loading,
|
||||
onReset,
|
||||
onActivityClick,
|
||||
}: RecentActivityCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20 !bg-elevated">
|
||||
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
|
||||
<div className="flex flex-row items-center justify-between mb-3 mt-1">
|
||||
<p className="text-xl font-semibold flex flex-row items-center">
|
||||
<Clock className="mr-3" />
|
||||
{t("dashboard.recentActivity")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-2 !border-edge h-7 !bg-canvas"
|
||||
onClick={onReset}
|
||||
>
|
||||
{t("dashboard.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden thin-scrollbar ${loading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
|
||||
<Loader2 className="animate-spin mr-2" size={16} />
|
||||
<span>{t("dashboard.loadingRecentActivity")}</span>
|
||||
</div>
|
||||
) : activities.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("dashboard.noRecentActivity")}
|
||||
</p>
|
||||
) : (
|
||||
activities
|
||||
.filter((item, index, array) => {
|
||||
if (index === 0) return true;
|
||||
|
||||
const prevItem = array[index - 1];
|
||||
return !(
|
||||
item.hostId === prevItem.hostId && item.type === prevItem.type
|
||||
);
|
||||
})
|
||||
.map((item) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="outline"
|
||||
className="border-2 !border-edge min-w-0 !bg-canvas"
|
||||
onClick={() => onActivityClick(item)}
|
||||
>
|
||||
{item.type === "terminal" ? (
|
||||
<Terminal size={20} className="shrink-0" />
|
||||
) : item.type === "file_manager" ? (
|
||||
<FolderOpen size={20} className="shrink-0" />
|
||||
) : item.type === "server_stats" ? (
|
||||
<Server size={20} className="shrink-0" />
|
||||
) : item.type === "tunnel" ? (
|
||||
<ArrowDownUp size={20} className="shrink-0" />
|
||||
) : item.type === "docker" ? (
|
||||
<Container size={20} className="shrink-0" />
|
||||
) : (
|
||||
<Terminal size={20} className="shrink-0" />
|
||||
)}
|
||||
<p className="truncate ml-2 font-semibold">{item.hostName}</p>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Server,
|
||||
History,
|
||||
Clock,
|
||||
Database,
|
||||
Key,
|
||||
ArrowDownUp,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UpdateLog } from "@/ui/desktop/apps/dashboard/apps/UpdateLog";
|
||||
|
||||
interface ServerOverviewCardProps {
|
||||
loggedIn: boolean;
|
||||
versionText: string;
|
||||
versionStatus: "up_to_date" | "requires_update";
|
||||
uptime: string;
|
||||
dbHealth: "healthy" | "error";
|
||||
totalServers: number;
|
||||
totalTunnels: number;
|
||||
totalCredentials: number;
|
||||
}
|
||||
|
||||
export function ServerOverviewCard({
|
||||
loggedIn,
|
||||
versionText,
|
||||
versionStatus,
|
||||
uptime,
|
||||
dbHealth,
|
||||
totalServers,
|
||||
totalTunnels,
|
||||
totalCredentials,
|
||||
}: ServerOverviewCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20 !bg-elevated">
|
||||
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden thin-scrollbar">
|
||||
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
|
||||
<Server className="mr-3" />
|
||||
{t("dashboard.serverOverview")}
|
||||
</p>
|
||||
<div className="w-full h-auto border-2 border-edge rounded-md px-3 py-3 !bg-canvas">
|
||||
<div className="flex flex-row items-center justify-between mb-3 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<History size={20} className="shrink-0" />
|
||||
<p className="ml-2 leading-none truncate">
|
||||
{t("dashboard.version")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<p className="leading-none text-muted-foreground">
|
||||
{versionText}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`ml-2 text-sm border-1 border-edge ${versionStatus === "up_to_date" ? "text-green-400" : "text-yellow-400"}`}
|
||||
>
|
||||
{versionStatus === "up_to_date"
|
||||
? t("dashboard.upToDate")
|
||||
: t("dashboard.updateAvailable")}
|
||||
</Button>
|
||||
<UpdateLog loggedIn={loggedIn} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between mb-5 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Clock size={20} className="shrink-0" />
|
||||
<p className="ml-2 leading-none truncate">
|
||||
{t("dashboard.uptime")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<p className="leading-none text-muted-foreground">{uptime}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Database size={20} className="shrink-0" />
|
||||
<p className="ml-2 leading-none truncate">
|
||||
{t("dashboard.database")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<p
|
||||
className={`leading-none ${dbHealth === "healthy" ? "text-green-400" : "text-red-400"}`}
|
||||
>
|
||||
{dbHealth === "healthy"
|
||||
? t("dashboard.healthy")
|
||||
: t("dashboard.error")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
||||
<div className="flex flex-row items-center justify-between w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2 !bg-canvas">
|
||||
<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")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
||||
{totalServers}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2 !bg-canvas">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<ArrowDownUp size={16} className="mr-3 shrink-0" />
|
||||
<p className="m-0 leading-none truncate">
|
||||
{t("dashboard.totalTunnels")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
||||
{totalTunnels}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
||||
<div className="flex flex-row items-center justify-between w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2 !bg-canvas">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Key size={16} className="mr-3 shrink-0" />
|
||||
<p className="m-0 leading-none truncate">
|
||||
{t("dashboard.totalCredentials")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
||||
{totalCredentials}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChartLine, Loader2, Server } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ServerStat {
|
||||
id: number;
|
||||
name: string;
|
||||
cpu: number | null;
|
||||
ram: number | null;
|
||||
}
|
||||
|
||||
interface ServerStatsCardProps {
|
||||
serverStats: ServerStat[];
|
||||
loading: boolean;
|
||||
onServerClick: (serverId: number, serverName: string) => void;
|
||||
}
|
||||
|
||||
export function ServerStatsCard({
|
||||
serverStats,
|
||||
loading,
|
||||
onServerClick,
|
||||
}: ServerStatsCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20 !bg-elevated">
|
||||
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
|
||||
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
|
||||
<ChartLine className="mr-3" />
|
||||
{t("dashboard.serverStats")}
|
||||
</p>
|
||||
<div
|
||||
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden thin-scrollbar ${loading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
|
||||
<Loader2 className="animate-spin mr-2" size={16} />
|
||||
<span>{t("dashboard.loadingServerStats")}</span>
|
||||
</div>
|
||||
) : serverStats.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("dashboard.noServerData")}
|
||||
</p>
|
||||
) : (
|
||||
serverStats.map((server) => (
|
||||
<Button
|
||||
key={server.id}
|
||||
variant="outline"
|
||||
className="border-2 !border-edge h-auto p-3 min-w-0 !bg-canvas"
|
||||
onClick={() => onServerClick(server.id, server.name)}
|
||||
>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-row items-center mb-2">
|
||||
<Server size={20} className="shrink-0" />
|
||||
<p className="truncate ml-2 font-semibold">{server.name}</p>
|
||||
</div>
|
||||
<div className="flex flex-row justify-start gap-4 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("dashboard.cpu")}:{" "}
|
||||
{server.cpu !== null
|
||||
? `${server.cpu}%`
|
||||
: t("dashboard.notAvailable")}
|
||||
</span>
|
||||
<span>
|
||||
{t("dashboard.ram")}:{" "}
|
||||
{server.ram !== null
|
||||
? `${server.ram}%`
|
||||
: t("dashboard.notAvailable")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DashboardLayout } from "@/ui/main-axios";
|
||||
|
||||
interface DashboardSettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentLayout: DashboardLayout;
|
||||
onSave: (layout: DashboardLayout) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function DashboardSettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentLayout,
|
||||
onSave,
|
||||
onReset,
|
||||
}: DashboardSettingsDialogProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [layout, setLayout] = useState<DashboardLayout>(currentLayout);
|
||||
|
||||
useEffect(() => {
|
||||
setLayout(currentLayout);
|
||||
}, [currentLayout, open]);
|
||||
|
||||
const handleCardToggle = (cardId: string, enabled: boolean) => {
|
||||
setLayout((prev) => ({
|
||||
...prev,
|
||||
cards: prev.cards.map((card) =>
|
||||
card.id === cardId ? { ...card, enabled } : card,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(layout);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
onReset();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const cardLabels: Record<string, string> = {
|
||||
server_overview: t("dashboard.serverOverviewCard"),
|
||||
recent_activity: t("dashboard.recentActivityCard"),
|
||||
network_graph: t("dashboard.networkGraphCard"),
|
||||
quick_actions: t("dashboard.quickActionsCard"),
|
||||
server_stats: t("dashboard.serverStatsCard"),
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("dashboard.dashboardSettings")}</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{t("dashboard.customizeLayout")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold">
|
||||
{t("dashboard.enableDisableCards")}
|
||||
</Label>
|
||||
<div className="space-y-3">
|
||||
{layout.cards?.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="flex items-center space-x-3 border-2 border-edge rounded-md p-3"
|
||||
>
|
||||
<Checkbox
|
||||
id={card.id}
|
||||
checked={card.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleCardToggle(card.id, checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={card.id}
|
||||
className="text-sm font-normal cursor-pointer flex-1"
|
||||
>
|
||||
{cardLabels[card.id] || card.id}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
className="border-2 border-edge"
|
||||
>
|
||||
{t("dashboard.resetLayout")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="border-2 border-edge"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave}>{t("common.save")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
getDashboardPreferences,
|
||||
saveDashboardPreferences,
|
||||
type DashboardLayout,
|
||||
} from "@/ui/main-axios";
|
||||
|
||||
const DEFAULT_LAYOUT: DashboardLayout = {
|
||||
cards: [
|
||||
{ id: "server_overview", enabled: true, order: 1 },
|
||||
{ id: "recent_activity", enabled: true, order: 2 },
|
||||
{ id: "network_graph", enabled: false, order: 3 },
|
||||
{ id: "quick_actions", enabled: true, order: 4 },
|
||||
{ id: "server_stats", enabled: true, order: 5 },
|
||||
],
|
||||
};
|
||||
|
||||
export function useDashboardPreferences(enabled: boolean = true) {
|
||||
const [layout, setLayout] = useState<DashboardLayout | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saveTimeout, setSaveTimeout] = useState<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setLayout(DEFAULT_LAYOUT);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchPreferences = async () => {
|
||||
try {
|
||||
const preferences = await getDashboardPreferences();
|
||||
setLayout(preferences);
|
||||
} catch (error) {
|
||||
setLayout(DEFAULT_LAYOUT);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPreferences();
|
||||
}, [enabled]);
|
||||
|
||||
const updateLayout = useCallback(
|
||||
(newLayout: DashboardLayout) => {
|
||||
setLayout(newLayout);
|
||||
|
||||
if (saveTimeout) {
|
||||
clearTimeout(saveTimeout);
|
||||
}
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
await saveDashboardPreferences(newLayout);
|
||||
} catch (error) {
|
||||
console.error("Failed to save dashboard preferences:", error);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
setSaveTimeout(timeout);
|
||||
},
|
||||
[saveTimeout],
|
||||
);
|
||||
|
||||
const resetLayout = useCallback(async () => {
|
||||
setLayout(DEFAULT_LAYOUT);
|
||||
try {
|
||||
await saveDashboardPreferences(DEFAULT_LAYOUT);
|
||||
} catch (error) {
|
||||
console.error("Failed to reset dashboard preferences:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
layout,
|
||||
loading,
|
||||
updateLayout,
|
||||
resetLayout,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from "react";
|
||||
import { DockerManager } from "@/ui/desktop/apps/features/docker/DockerManager.tsx";
|
||||
import { FullScreenAppWrapper } from "@/ui/desktop/apps/FullScreenAppWrapper.tsx";
|
||||
|
||||
interface DockerAppProps {
|
||||
hostId?: string;
|
||||
}
|
||||
|
||||
const DockerApp: React.FC<DockerAppProps> = ({ hostId }) => {
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, loading) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading host...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">Host not found</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DockerManager
|
||||
hostConfig={hostConfig}
|
||||
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FullScreenAppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default DockerApp;
|
||||
@@ -17,17 +17,22 @@ import {
|
||||
validateDockerAvailability,
|
||||
keepaliveDockerSession,
|
||||
verifyDockerTOTP,
|
||||
verifyDockerWarpgate,
|
||||
logActivity,
|
||||
getSSHHosts,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert.tsx";
|
||||
import { ContainerList } from "./components/ContainerList.tsx";
|
||||
import { ContainerDetail } from "./components/ContainerDetail.tsx";
|
||||
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
|
||||
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
|
||||
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 { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "@/ui/desktop/navigation/connection-log/ConnectionLogContext.tsx";
|
||||
import { ConnectionLog } from "@/ui/desktop/navigation/connection-log/ConnectionLog.tsx";
|
||||
|
||||
interface DockerManagerProps {
|
||||
hostConfig?: SSHHost;
|
||||
@@ -44,7 +49,7 @@ interface TabData {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function DockerManager({
|
||||
function DockerManagerInner({
|
||||
hostConfig,
|
||||
title,
|
||||
isVisible = true,
|
||||
@@ -54,6 +59,12 @@ export function DockerManager({
|
||||
}: DockerManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const {
|
||||
addLog,
|
||||
setLogs,
|
||||
clearLogs,
|
||||
isExpanded: isConnectionLogExpanded,
|
||||
} = useConnectionLog();
|
||||
const { currentTab, removeTab } = useTabs() as {
|
||||
currentTab: number | null;
|
||||
removeTab: (tabId: number) => void;
|
||||
@@ -74,10 +85,18 @@ export function DockerManager({
|
||||
const [totpRequired, setTotpRequired] = React.useState(false);
|
||||
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
|
||||
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
|
||||
const [warpgateRequired, setWarpgateRequired] = React.useState(false);
|
||||
const [warpgateSessionId, setWarpgateSessionId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [warpgateUrl, setWarpgateUrl] = React.useState<string>("");
|
||||
const [warpgateSecurityKey, setWarpgateSecurityKey] =
|
||||
React.useState<string>("");
|
||||
const [showAuthDialog, setShowAuthDialog] = React.useState(false);
|
||||
const [authReason, setAuthReason] = React.useState<
|
||||
"no_keyboard" | "auth_failed" | "timeout"
|
||||
>("no_keyboard");
|
||||
const [hasConnectionError, setHasConnectionError] = React.useState(false);
|
||||
|
||||
const activityLoggedRef = React.useRef(false);
|
||||
const activityLoggingRef = React.useRef(false);
|
||||
@@ -171,6 +190,8 @@ export function DockerManager({
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
setHasConnectionError(false);
|
||||
clearLogs();
|
||||
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
try {
|
||||
@@ -183,6 +204,15 @@ export function DockerManager({
|
||||
socks5ProxyChain: currentHostConfig.socks5ProxyChain,
|
||||
});
|
||||
|
||||
if (result?.requires_warpgate) {
|
||||
setWarpgateRequired(true);
|
||||
setWarpgateSessionId(sid);
|
||||
setWarpgateUrl(result.url || "");
|
||||
setWarpgateSecurityKey(result.securityKey || "");
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpSessionId(sid);
|
||||
@@ -208,19 +238,33 @@ export function DockerManager({
|
||||
setIsValidating(false);
|
||||
|
||||
if (!validation.available) {
|
||||
toast.error(
|
||||
validation.error || "Docker is not available on this host",
|
||||
);
|
||||
setHasConnectionError(true);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "validation",
|
||||
message: validation.error || t("docker.error"),
|
||||
details: validation.code
|
||||
? `Error code: ${validation.code}`
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
logDockerActivity();
|
||||
setTimeout(() => clearLogs(), 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to connect to host",
|
||||
);
|
||||
} catch (error: any) {
|
||||
setIsConnecting(false);
|
||||
setIsValidating(false);
|
||||
onClose?.();
|
||||
setHasConnectionError(true);
|
||||
|
||||
if (error?.connectionLogs) {
|
||||
setLogs(error.connectionLogs);
|
||||
} else {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: error?.message || t("docker.connectionFailed"),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
@@ -317,16 +361,27 @@ export function DockerManager({
|
||||
setIsValidating(false);
|
||||
|
||||
if (!validation.available) {
|
||||
toast.error(
|
||||
validation.error || "Docker is not available on this host",
|
||||
);
|
||||
setHasConnectionError(true);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "validation",
|
||||
message: validation.error || t("docker.error"),
|
||||
details: validation.code
|
||||
? `Error code: ${validation.code}`
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
logDockerActivity();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("TOTP verification failed:", error);
|
||||
toast.error(t("docker.totpVerificationFailed"));
|
||||
setHasConnectionError(true);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "auth",
|
||||
message: t("docker.totpVerificationFailed"),
|
||||
});
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
@@ -342,6 +397,69 @@ export function DockerManager({
|
||||
}
|
||||
};
|
||||
|
||||
const handleWarpgateContinue = async () => {
|
||||
if (!warpgateSessionId) return;
|
||||
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
const result = await verifyDockerWarpgate(warpgateSessionId);
|
||||
|
||||
if (result?.status === "success") {
|
||||
setWarpgateRequired(false);
|
||||
setWarpgateUrl("");
|
||||
setWarpgateSecurityKey("");
|
||||
setSessionId(warpgateSessionId);
|
||||
setWarpgateSessionId(null);
|
||||
|
||||
setIsValidating(true);
|
||||
const validation = await validateDockerAvailability(warpgateSessionId);
|
||||
setDockerValidation(validation);
|
||||
setIsValidating(false);
|
||||
|
||||
if (!validation.available) {
|
||||
setHasConnectionError(true);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "validation",
|
||||
message: validation.error || t("docker.error"),
|
||||
details: validation.code
|
||||
? `Error code: ${validation.code}`
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
logDockerActivity();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Warpgate verification failed:", error);
|
||||
setHasConnectionError(true);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "auth",
|
||||
message: t("docker.warpgateVerificationFailed"),
|
||||
});
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWarpgateCancel = () => {
|
||||
setWarpgateRequired(false);
|
||||
setWarpgateSessionId(null);
|
||||
setWarpgateUrl("");
|
||||
setWarpgateSecurityKey("");
|
||||
setIsConnecting(false);
|
||||
if (currentTab !== null) {
|
||||
removeTab(currentTab);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWarpgateOpenUrl = () => {
|
||||
if (warpgateUrl) {
|
||||
window.open(warpgateUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthSubmit = async (credentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
@@ -367,6 +485,15 @@ export function DockerManager({
|
||||
socks5ProxyChain: currentHostConfig.socks5ProxyChain,
|
||||
});
|
||||
|
||||
if (result?.requires_warpgate) {
|
||||
setWarpgateRequired(true);
|
||||
setWarpgateSessionId(sid);
|
||||
setWarpgateUrl(result.url || "");
|
||||
setWarpgateSecurityKey(result.securityKey || "N/A");
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpSessionId(sid);
|
||||
@@ -390,15 +517,19 @@ export function DockerManager({
|
||||
setIsValidating(false);
|
||||
|
||||
if (!validation.available) {
|
||||
toast.error(validation.error || "Docker is not available on this host");
|
||||
setHasConnectionError(true);
|
||||
} else {
|
||||
logDockerActivity();
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to connect");
|
||||
setIsConnecting(false);
|
||||
setIsValidating(false);
|
||||
onClose?.();
|
||||
setHasConnectionError(true);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: error?.message || t("docker.connectionFailed"),
|
||||
});
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
@@ -431,7 +562,7 @@ export function DockerManager({
|
||||
|
||||
if (!currentHostConfig?.enableDocker) {
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
@@ -457,69 +588,61 @@ export function DockerManager({
|
||||
|
||||
if (isConnecting || isValidating) {
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-bold text-lg truncate">
|
||||
{currentHostConfig?.folder} / {title}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0 relative">
|
||||
<SimpleLoader
|
||||
visible={true}
|
||||
visible={true && !isConnectionLogExpanded}
|
||||
message={
|
||||
isValidating ? t("docker.validating") : t("docker.connecting")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ConnectionLog
|
||||
isConnecting={isConnecting}
|
||||
isConnected={!!sessionId && !!dockerValidation?.available}
|
||||
hasConnectionError={hasConnectionError}
|
||||
position={hasConnectionError ? "top" : "bottom"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (dockerValidation && !dockerValidation.available) {
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-bold text-lg truncate">
|
||||
{currentHostConfig?.folder} / {title}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0 p-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="font-semibold mb-2">{t("docker.error")}</div>
|
||||
<div>{dockerValidation.error}</div>
|
||||
{dockerValidation.code && (
|
||||
<div className="mt-2 text-xs opacity-70">
|
||||
{t("docker.errorCode", { code: dockerValidation.code })}
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
<ConnectionLog
|
||||
isConnecting={isConnecting}
|
||||
isConnected={!!sessionId && !!dockerValidation?.available}
|
||||
hasConnectionError={
|
||||
hasConnectionError ||
|
||||
(!!dockerValidation && !dockerValidation.available)
|
||||
}
|
||||
position={
|
||||
hasConnectionError ||
|
||||
(!!dockerValidation && !dockerValidation.available)
|
||||
? "top"
|
||||
: "bottom"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
<div
|
||||
className="h-full w-full flex flex-col"
|
||||
style={{
|
||||
visibility:
|
||||
(hasConnectionError ||
|
||||
(!!dockerValidation && !dockerValidation.available)) &&
|
||||
isConnectionLogExpanded
|
||||
? "hidden"
|
||||
: "visible",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="min-w-0">
|
||||
@@ -586,6 +709,14 @@ export function DockerManager({
|
||||
onSubmit={handleTotpSubmit}
|
||||
onCancel={handleTotpCancel}
|
||||
/>
|
||||
<WarpgateDialog
|
||||
isOpen={warpgateRequired}
|
||||
url={warpgateUrl}
|
||||
securityKey={warpgateSecurityKey}
|
||||
onContinue={handleWarpgateContinue}
|
||||
onCancel={handleWarpgateCancel}
|
||||
onOpenUrl={handleWarpgateOpenUrl}
|
||||
/>
|
||||
{currentHostConfig && (
|
||||
<SSHAuthDialog
|
||||
isOpen={showAuthDialog}
|
||||
@@ -600,6 +731,32 @@ export function DockerManager({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SimpleLoader
|
||||
visible={isConnecting && !isConnectionLogExpanded}
|
||||
message={t("docker.connecting")}
|
||||
/>
|
||||
<ConnectionLog
|
||||
isConnecting={isConnecting}
|
||||
isConnected={!!sessionId && !!dockerValidation?.available}
|
||||
hasConnectionError={
|
||||
hasConnectionError ||
|
||||
(!!dockerValidation && !dockerValidation.available)
|
||||
}
|
||||
position={
|
||||
hasConnectionError ||
|
||||
(!!dockerValidation && !dockerValidation.available)
|
||||
? "top"
|
||||
: "bottom"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DockerManager(props: DockerManagerProps): React.ReactElement {
|
||||
return (
|
||||
<ConnectionLogProvider>
|
||||
<DockerManagerInner {...props} />
|
||||
</ConnectionLogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ export function ContainerCard({
|
||||
>
|
||||
<CardHeader className="pb-2 px-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-base font-semibold truncate flex-1">
|
||||
<CardTitle className="text-base font-semibold truncate flex-1 min-w-0">
|
||||
{container.name.startsWith("/")
|
||||
? container.name.slice(1)
|
||||
: container.name}
|
||||
|
||||
@@ -17,10 +17,12 @@ import { Button } from "@/components/ui/button.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
|
||||
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
|
||||
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 { PermissionsDialog } from "./components/PermissionsDialog.tsx";
|
||||
import { CompressDialog } from "./components/CompressDialog.tsx";
|
||||
import { SudoPasswordDialog } from "./SudoPasswordDialog.tsx";
|
||||
import {
|
||||
Upload,
|
||||
FolderPlus,
|
||||
@@ -32,6 +34,12 @@ import {
|
||||
} from "lucide-react";
|
||||
import { TerminalWindow } from "./components/TerminalWindow.tsx";
|
||||
import type { SSHHost, FileItem } from "../../../types/index.js";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "@/ui/desktop/navigation/connection-log/ConnectionLogContext.tsx";
|
||||
import { ConnectionLog } from "@/ui/desktop/navigation/connection-log/ConnectionLog.tsx";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
import {
|
||||
listSSHFiles,
|
||||
uploadSSHFile,
|
||||
@@ -44,6 +52,7 @@ import {
|
||||
moveSSHItem,
|
||||
connectSSH,
|
||||
verifySSHTOTP,
|
||||
verifySSHWarpgate,
|
||||
getSSHStatus,
|
||||
keepSSHAlive,
|
||||
identifySSHSymlink,
|
||||
@@ -57,6 +66,7 @@ import {
|
||||
changeSSHPermissions,
|
||||
extractSSHArchive,
|
||||
compressSSHFiles,
|
||||
setSudoPassword,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import type { SidebarItem } from "./FileManagerSidebar.tsx";
|
||||
|
||||
@@ -94,6 +104,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
const { openWindow } = useWindowManager();
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const {
|
||||
addLog,
|
||||
clearLogs,
|
||||
isExpanded: isConnectionLogExpanded,
|
||||
} = useConnectionLog();
|
||||
|
||||
const [currentHost] = useState<SSHHost | null>(initialHost || null);
|
||||
const [currentPath, setCurrentPath] = useState(
|
||||
@@ -112,6 +127,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
const [totpRequired, setTotpRequired] = useState(false);
|
||||
const [totpSessionId, setTotpSessionId] = useState<string | null>(null);
|
||||
const [totpPrompt, setTotpPrompt] = useState<string>("");
|
||||
const [warpgateRequired, setWarpgateRequired] = useState(false);
|
||||
const [warpgateSessionId, setWarpgateSessionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [warpgateUrl, setWarpgateUrl] = useState<string>("");
|
||||
const [warpgateSecurityKey, setWarpgateSecurityKey] = useState<string>("");
|
||||
const [showAuthDialog, setShowAuthDialog] = useState(false);
|
||||
const [authDialogReason, setAuthDialogReason] = useState<
|
||||
"no_keyboard" | "auth_failed" | "timeout"
|
||||
@@ -119,6 +140,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
const [pinnedFiles, setPinnedFiles] = useState<Set<string>>(new Set());
|
||||
const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
|
||||
const [isClosing, setIsClosing] = useState<boolean>(false);
|
||||
const [hasConnectionError, setHasConnectionError] = useState<boolean>(false);
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
x: number;
|
||||
@@ -163,6 +185,13 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
[],
|
||||
);
|
||||
|
||||
const [sudoDialogOpen, setSudoDialogOpen] = useState(false);
|
||||
const [pendingSudoOperation, setPendingSudoOperation] = useState<
|
||||
| { type: "delete"; files: FileItem[] }
|
||||
| { type: "navigate"; path: string }
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const { selectedFiles, clearSelection, setSelection } = useFileSelection();
|
||||
|
||||
const { dragHandlers } = useDragAndDrop({
|
||||
@@ -208,14 +237,14 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
|
||||
const handleCloseWithError = useCallback(
|
||||
(errorMessage: string) => {
|
||||
if (isClosing) return;
|
||||
setIsClosing(true);
|
||||
toast.error(errorMessage);
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
setHasConnectionError(true);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: errorMessage,
|
||||
});
|
||||
},
|
||||
[isClosing, onClose],
|
||||
[addLog],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -324,6 +353,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
initialLoadDoneRef.current = false;
|
||||
setHasConnectionError(false);
|
||||
clearLogs();
|
||||
|
||||
const sessionId = currentHost.id.toString();
|
||||
|
||||
@@ -348,6 +379,15 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
socks5ProxyChain: currentHost.socks5ProxyChain,
|
||||
});
|
||||
|
||||
if (result?.requires_warpgate) {
|
||||
setWarpgateRequired(true);
|
||||
setWarpgateSessionId(sessionId);
|
||||
setWarpgateUrl(result.url || "");
|
||||
setWarpgateSecurityKey(result.securityKey || "N/A");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpSessionId(sessionId);
|
||||
@@ -380,8 +420,49 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
} catch (dirError: unknown) {
|
||||
console.error("Failed to load initial directory:", dirError);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
} catch (error: any) {
|
||||
console.error("SSH connection failed:", error);
|
||||
|
||||
if (error?.connectionLogs) {
|
||||
error.connectionLogs.forEach((log: any) => {
|
||||
addLog({
|
||||
type: log.type,
|
||||
stage: log.stage,
|
||||
message: log.message,
|
||||
details: log.details,
|
||||
});
|
||||
});
|
||||
if (error.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpSessionId(error.sessionId || currentHost.id.toString());
|
||||
setTotpPrompt(
|
||||
error.prompt || t("fileManager.verificationCodePrompt"),
|
||||
);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (error.requires_warpgate) {
|
||||
setWarpgateRequired(true);
|
||||
setWarpgateSessionId(error.sessionId || currentHost.id.toString());
|
||||
setWarpgateUrl(error.url || "");
|
||||
setWarpgateSecurityKey(error.securityKey || "N/A");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (error.status === "auth_required") {
|
||||
setAuthDialogReason(error.reason || "no_keyboard");
|
||||
setShowAuthDialog(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: error?.message || t("fileManager.failedToConnect"),
|
||||
});
|
||||
}
|
||||
|
||||
handleCloseWithError(
|
||||
t("fileManager.failedToConnect") + ": " + (error.message || error),
|
||||
);
|
||||
@@ -392,14 +473,14 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}
|
||||
|
||||
const loadDirectory = useCallback(
|
||||
async (path: string) => {
|
||||
async (path: string): Promise<boolean> => {
|
||||
if (!sshSessionId) {
|
||||
console.error("Cannot load directory: no SSH session ID");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isLoading && currentLoadingPathRef.current !== path) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
currentLoadingPathRef.current = path;
|
||||
@@ -411,7 +492,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
const response = await listSSHFiles(sshSessionId, path);
|
||||
|
||||
if (currentLoadingPathRef.current !== path) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const files = Array.isArray(response)
|
||||
@@ -420,29 +501,58 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
|
||||
setFiles(files);
|
||||
clearSelection();
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
if (currentLoadingPathRef.current === path) {
|
||||
const axiosError = error as {
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: {
|
||||
needsSudo?: boolean;
|
||||
error?: string;
|
||||
sudoFailed?: boolean;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
|
||||
if (axiosError.response?.data?.needsSudo) {
|
||||
if (!sudoDialogOpen) {
|
||||
setPendingSudoOperation({ type: "navigate", path });
|
||||
setSudoDialogOpen(true);
|
||||
}
|
||||
|
||||
if (axiosError.response.data.sudoFailed) {
|
||||
toast.error(t("fileManager.sudoAuthFailed"));
|
||||
} else {
|
||||
toast.error(t("fileManager.permissionDenied"));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
console.error("Failed to load directory:", error);
|
||||
|
||||
const errorMessage =
|
||||
axiosError.response?.data?.error ||
|
||||
axiosError.message ||
|
||||
String(error);
|
||||
|
||||
if (initialLoadDoneRef.current) {
|
||||
toast.error(
|
||||
t("fileManager.failedToLoadDirectory") +
|
||||
": " +
|
||||
(error.message || error),
|
||||
t("fileManager.failedToLoadDirectory") + ": " + errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
error.message?.includes("connection") ||
|
||||
error.message?.includes("SSH")
|
||||
errorMessage?.includes("connection") ||
|
||||
errorMessage?.includes("SSH")
|
||||
) {
|
||||
handleCloseWithError(
|
||||
t("fileManager.failedToLoadDirectory") +
|
||||
": " +
|
||||
(error.message || error),
|
||||
t("fileManager.failedToLoadDirectory") + ": " + errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (currentLoadingPathRef.current === path) {
|
||||
setIsLoading(false);
|
||||
@@ -450,7 +560,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}
|
||||
}
|
||||
},
|
||||
[sshSessionId, isLoading, clearSelection, t],
|
||||
[sshSessionId, isLoading, clearSelection, t, sudoDialogOpen],
|
||||
);
|
||||
|
||||
const debouncedLoadDirectory = useCallback(
|
||||
@@ -720,9 +830,18 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
handleRefreshDirectory();
|
||||
clearSelection();
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { needsSudo?: boolean; error?: string } };
|
||||
message?: string;
|
||||
};
|
||||
if (axiosError.response?.data?.needsSudo) {
|
||||
setPendingSudoOperation({ type: "delete", files });
|
||||
setSudoDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
error.message?.includes("connection") ||
|
||||
error.message?.includes("established")
|
||||
axiosError.message?.includes("connection") ||
|
||||
axiosError.message?.includes("established")
|
||||
) {
|
||||
toast.error(
|
||||
`SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`,
|
||||
@@ -737,6 +856,57 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSudoPasswordSubmit(password: string) {
|
||||
if (!sshSessionId || !pendingSudoOperation) return;
|
||||
|
||||
try {
|
||||
await setSudoPassword(sshSessionId, password);
|
||||
setSudoDialogOpen(false);
|
||||
|
||||
if (pendingSudoOperation.type === "delete") {
|
||||
for (const file of pendingSudoOperation.files) {
|
||||
await deleteSSHItem(
|
||||
sshSessionId,
|
||||
file.path,
|
||||
file.type === "directory",
|
||||
currentHost?.id,
|
||||
currentHost?.userId?.toString(),
|
||||
);
|
||||
}
|
||||
toast.success(
|
||||
t("fileManager.itemsDeletedSuccessfully", {
|
||||
count: pendingSudoOperation.files.length,
|
||||
}),
|
||||
);
|
||||
handleRefreshDirectory();
|
||||
clearSelection();
|
||||
} else if (pendingSudoOperation.type === "navigate") {
|
||||
const success = await loadDirectory(pendingSudoOperation.path);
|
||||
if (success) {
|
||||
setCurrentPath(pendingSudoOperation.path);
|
||||
setPendingSudoOperation(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingSudoOperation(null);
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { needsSudo?: boolean; sudoFailed?: boolean } };
|
||||
message?: string;
|
||||
};
|
||||
|
||||
if (axiosError.response?.data?.sudoFailed) {
|
||||
toast.error(t("fileManager.sudoAuthFailed"));
|
||||
setSudoDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(axiosError.message || t("fileManager.sudoOperationFailed"));
|
||||
setPendingSudoOperation(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateNewFolder() {
|
||||
const defaultName = generateUniqueName(
|
||||
t("fileManager.newFolderDefault"),
|
||||
@@ -1472,6 +1642,57 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
if (onClose) onClose();
|
||||
}
|
||||
|
||||
async function handleWarpgateContinue() {
|
||||
if (!warpgateSessionId) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const result = await verifySSHWarpgate(warpgateSessionId);
|
||||
|
||||
if (result?.status === "success") {
|
||||
setWarpgateRequired(false);
|
||||
setWarpgateUrl("");
|
||||
setWarpgateSecurityKey("");
|
||||
setSshSessionId(warpgateSessionId);
|
||||
setWarpgateSessionId(null);
|
||||
|
||||
try {
|
||||
const response = await listSSHFiles(warpgateSessionId, currentPath);
|
||||
const files = Array.isArray(response)
|
||||
? response
|
||||
: response?.files || [];
|
||||
setFiles(files);
|
||||
clearSelection();
|
||||
initialLoadDoneRef.current = true;
|
||||
toast.success(t("fileManager.connectedSuccessfully"));
|
||||
|
||||
logFileManagerActivity();
|
||||
} catch (dirError: unknown) {
|
||||
console.error("Failed to load initial directory:", dirError);
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("Warpgate verification failed:", error);
|
||||
toast.error(t("fileManager.warpgateVerificationFailed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleWarpgateCancel() {
|
||||
setWarpgateRequired(false);
|
||||
setWarpgateUrl("");
|
||||
setWarpgateSecurityKey("");
|
||||
setWarpgateSessionId(null);
|
||||
if (onClose) onClose();
|
||||
}
|
||||
|
||||
function handleWarpgateOpenUrl() {
|
||||
if (warpgateUrl) {
|
||||
window.open(warpgateUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAuthDialogSubmit(credentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
@@ -1505,6 +1726,15 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
socks5ProxyChain: currentHost.socks5ProxyChain,
|
||||
});
|
||||
|
||||
if (result?.requires_warpgate) {
|
||||
setWarpgateRequired(true);
|
||||
setWarpgateSessionId(sessionId);
|
||||
setWarpgateUrl(result.url || "");
|
||||
setWarpgateSecurityKey(result.securityKey || "N/A");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpSessionId(sessionId);
|
||||
@@ -1928,9 +2158,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}, [currentHost?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("Saving viewMode to localStorage:", viewMode);
|
||||
localStorage.setItem("fileManagerViewMode", viewMode);
|
||||
console.log("Saved value:", localStorage.getItem("fileManagerViewMode"));
|
||||
}, [viewMode]);
|
||||
|
||||
const filteredFiles = files.filter((file) =>
|
||||
@@ -1950,52 +2178,169 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-canvas">
|
||||
<div className="flex-shrink-0 border-b border-edge">
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-semibold text-foreground">
|
||||
{currentHost.name}
|
||||
</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{currentHost.ip}:{currentHost.port}
|
||||
</span>
|
||||
<div className="h-full flex flex-col bg-canvas relative">
|
||||
<div
|
||||
className="h-full w-full flex flex-col"
|
||||
style={{
|
||||
visibility: isConnectionLogExpanded ? "hidden" : "visible",
|
||||
}}
|
||||
>
|
||||
<div className="flex-shrink-0 border-b border-edge">
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-semibold text-foreground">
|
||||
{currentHost.name}
|
||||
</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{currentHost.ip}:{currentHost.port}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("fileManager.searchFiles")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8 w-48 h-9 bg-button border-edge"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex border border-edge rounded-md">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("grid")}
|
||||
className="rounded-r-none h-9"
|
||||
>
|
||||
<Grid3X3 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "list" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("list")}
|
||||
className="rounded-l-none h-9"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.onchange = (e) => {
|
||||
const files = (e.target as HTMLInputElement).files;
|
||||
if (files) handleFilesDropped(files);
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
className="h-9"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{t("fileManager.upload")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCreateNewFolder}
|
||||
className="h-9"
|
||||
>
|
||||
<FolderPlus className="w-4 h-4 mr-2" />
|
||||
{t("fileManager.newFolder")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCreateNewFile}
|
||||
className="h-9"
|
||||
>
|
||||
<FilePlus className="w-4 h-4 mr-2" />
|
||||
{t("fileManager.newFile")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefreshDirectory}
|
||||
className="h-9"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex" {...dragHandlers}>
|
||||
<div className="w-64 flex-shrink-0 h-full">
|
||||
<FileManagerSidebar
|
||||
currentHost={currentHost}
|
||||
currentPath={currentPath}
|
||||
onPathChange={setCurrentPath}
|
||||
onLoadDirectory={loadDirectory}
|
||||
onFileOpen={handleSidebarFileOpen}
|
||||
sshSessionId={sshSessionId}
|
||||
refreshTrigger={sidebarRefreshTrigger}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("fileManager.searchFiles")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8 w-48 h-9 bg-button border-edge"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 relative">
|
||||
<FileManagerGrid
|
||||
files={filteredFiles}
|
||||
selectedFiles={selectedFiles}
|
||||
onFileSelect={() => {}}
|
||||
onFileOpen={handleFileOpen}
|
||||
onSelectionChange={setSelection}
|
||||
currentPath={currentPath}
|
||||
isLoading={isLoading}
|
||||
onPathChange={setCurrentPath}
|
||||
onRefresh={handleRefreshDirectory}
|
||||
onUpload={handleFilesDropped}
|
||||
onDownload={(files) => files.forEach(handleDownloadFile)}
|
||||
onContextMenu={handleContextMenu}
|
||||
viewMode={viewMode}
|
||||
onRename={handleRenameConfirm}
|
||||
editingFile={editingFile}
|
||||
onStartEdit={handleStartEdit}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onDelete={handleDeleteFiles}
|
||||
onCopy={handleCopyFiles}
|
||||
onCut={handleCutFiles}
|
||||
onPaste={handlePasteFiles}
|
||||
onUndo={handleUndo}
|
||||
hasClipboard={!!clipboard}
|
||||
onFileDrop={handleFileDrop}
|
||||
onFileDiff={handleFileDiff}
|
||||
onSystemDragStart={handleFileDragStart}
|
||||
onSystemDragEnd={handleFileDragEnd}
|
||||
createIntent={createIntent}
|
||||
onConfirmCreate={handleConfirmCreate}
|
||||
onCancelCreate={handleCancelCreate}
|
||||
onNewFile={handleCreateNewFile}
|
||||
onNewFolder={handleCreateNewFolder}
|
||||
/>
|
||||
|
||||
<div className="flex border border-edge rounded-md">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("grid")}
|
||||
className="rounded-r-none h-9"
|
||||
>
|
||||
<Grid3X3 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "list" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("list")}
|
||||
className="rounded-l-none h-9"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
<FileManagerContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
files={contextMenu.files}
|
||||
isVisible={contextMenu.isVisible}
|
||||
onClose={() =>
|
||||
setContextMenu((prev) => ({ ...prev, isVisible: false }))
|
||||
}
|
||||
onDownload={(files) => files.forEach(handleDownloadFile)}
|
||||
onRename={handleRenameFile}
|
||||
onCopy={handleCopyFiles}
|
||||
onCut={handleCutFiles}
|
||||
onPaste={handlePasteFiles}
|
||||
onDelete={handleDeleteFiles}
|
||||
onUpload={() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
@@ -2005,137 +2350,27 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
className="h-9"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{t("fileManager.upload")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCreateNewFolder}
|
||||
className="h-9"
|
||||
>
|
||||
<FolderPlus className="w-4 h-4 mr-2" />
|
||||
{t("fileManager.newFolder")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCreateNewFile}
|
||||
className="h-9"
|
||||
>
|
||||
<FilePlus className="w-4 h-4 mr-2" />
|
||||
{t("fileManager.newFile")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefreshDirectory}
|
||||
className="h-9"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
onNewFolder={handleCreateNewFolder}
|
||||
onNewFile={handleCreateNewFile}
|
||||
onRefresh={handleRefreshDirectory}
|
||||
hasClipboard={!!clipboard}
|
||||
onDragToDesktop={() => handleDragToDesktop(contextMenu.files)}
|
||||
onOpenTerminal={(path) => handleOpenTerminal(path)}
|
||||
onRunExecutable={(file) => handleRunExecutable(file)}
|
||||
onPinFile={handlePinFile}
|
||||
onUnpinFile={handleUnpinFile}
|
||||
onAddShortcut={handleAddShortcut}
|
||||
isPinned={isPinnedFile}
|
||||
currentPath={currentPath}
|
||||
onProperties={handleOpenPermissionsDialog}
|
||||
onExtractArchive={handleExtractArchive}
|
||||
onCompress={handleOpenCompressDialog}
|
||||
onCopyPath={handleCopyPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex" {...dragHandlers}>
|
||||
<div className="w-64 flex-shrink-0 h-full">
|
||||
<FileManagerSidebar
|
||||
currentHost={currentHost}
|
||||
currentPath={currentPath}
|
||||
onPathChange={setCurrentPath}
|
||||
onLoadDirectory={loadDirectory}
|
||||
onFileOpen={handleSidebarFileOpen}
|
||||
sshSessionId={sshSessionId}
|
||||
refreshTrigger={sidebarRefreshTrigger}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative">
|
||||
<FileManagerGrid
|
||||
files={filteredFiles}
|
||||
selectedFiles={selectedFiles}
|
||||
onFileSelect={() => {}}
|
||||
onFileOpen={handleFileOpen}
|
||||
onSelectionChange={setSelection}
|
||||
currentPath={currentPath}
|
||||
isLoading={isLoading}
|
||||
onPathChange={setCurrentPath}
|
||||
onRefresh={handleRefreshDirectory}
|
||||
onUpload={handleFilesDropped}
|
||||
onDownload={(files) => files.forEach(handleDownloadFile)}
|
||||
onContextMenu={handleContextMenu}
|
||||
viewMode={viewMode}
|
||||
onRename={handleRenameConfirm}
|
||||
editingFile={editingFile}
|
||||
onStartEdit={handleStartEdit}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onDelete={handleDeleteFiles}
|
||||
onCopy={handleCopyFiles}
|
||||
onCut={handleCutFiles}
|
||||
onPaste={handlePasteFiles}
|
||||
onUndo={handleUndo}
|
||||
hasClipboard={!!clipboard}
|
||||
onFileDrop={handleFileDrop}
|
||||
onFileDiff={handleFileDiff}
|
||||
onSystemDragStart={handleFileDragStart}
|
||||
onSystemDragEnd={handleFileDragEnd}
|
||||
createIntent={createIntent}
|
||||
onConfirmCreate={handleConfirmCreate}
|
||||
onCancelCreate={handleCancelCreate}
|
||||
onNewFile={handleCreateNewFile}
|
||||
onNewFolder={handleCreateNewFolder}
|
||||
/>
|
||||
|
||||
<FileManagerContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
files={contextMenu.files}
|
||||
isVisible={contextMenu.isVisible}
|
||||
onClose={() =>
|
||||
setContextMenu((prev) => ({ ...prev, isVisible: false }))
|
||||
}
|
||||
onDownload={(files) => files.forEach(handleDownloadFile)}
|
||||
onRename={handleRenameFile}
|
||||
onCopy={handleCopyFiles}
|
||||
onCut={handleCutFiles}
|
||||
onPaste={handlePasteFiles}
|
||||
onDelete={handleDeleteFiles}
|
||||
onUpload={() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.onchange = (e) => {
|
||||
const files = (e.target as HTMLInputElement).files;
|
||||
if (files) handleFilesDropped(files);
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
onNewFolder={handleCreateNewFolder}
|
||||
onNewFile={handleCreateNewFile}
|
||||
onRefresh={handleRefreshDirectory}
|
||||
hasClipboard={!!clipboard}
|
||||
onDragToDesktop={() => handleDragToDesktop(contextMenu.files)}
|
||||
onOpenTerminal={(path) => handleOpenTerminal(path)}
|
||||
onRunExecutable={(file) => handleRunExecutable(file)}
|
||||
onPinFile={handlePinFile}
|
||||
onUnpinFile={handleUnpinFile}
|
||||
onAddShortcut={handleAddShortcut}
|
||||
isPinned={isPinnedFile}
|
||||
currentPath={currentPath}
|
||||
onProperties={handleOpenPermissionsDialog}
|
||||
onExtractArchive={handleExtractArchive}
|
||||
onCompress={handleOpenCompressDialog}
|
||||
onCopyPath={handleCopyPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CompressDialog
|
||||
open={compressDialogFiles.length > 0}
|
||||
onOpenChange={(open) => !open && setCompressDialogFiles([])}
|
||||
@@ -2150,6 +2385,15 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
onCancel={handleTotpCancel}
|
||||
/>
|
||||
|
||||
<WarpgateDialog
|
||||
isOpen={warpgateRequired}
|
||||
url={warpgateUrl}
|
||||
securityKey={warpgateSecurityKey}
|
||||
onContinue={handleWarpgateContinue}
|
||||
onCancel={handleWarpgateCancel}
|
||||
onOpenUrl={handleWarpgateOpenUrl}
|
||||
/>
|
||||
|
||||
{currentHost && (
|
||||
<SSHAuthDialog
|
||||
isOpen={showAuthDialog}
|
||||
@@ -2173,14 +2417,41 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}}
|
||||
onSave={handleSavePermissions}
|
||||
/>
|
||||
|
||||
<SudoPasswordDialog
|
||||
open={sudoDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setSudoDialogOpen(open);
|
||||
if (!open) setPendingSudoOperation(null);
|
||||
}}
|
||||
onSubmit={handleSudoPasswordSubmit}
|
||||
/>
|
||||
<SimpleLoader
|
||||
visible={(isReconnecting || isLoading) && !isConnectionLogExpanded}
|
||||
message={t("fileManager.connecting")}
|
||||
/>
|
||||
<ConnectionLog
|
||||
isConnecting={isReconnecting || isLoading}
|
||||
isConnected={!!sshSessionId}
|
||||
hasConnectionError={hasConnectionError}
|
||||
position={hasConnectionError ? "top" : "bottom"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileManager({ initialHost, onClose }: FileManagerProps) {
|
||||
function FileManagerInner({ initialHost, onClose }: FileManagerProps) {
|
||||
return (
|
||||
<WindowManager>
|
||||
<FileManagerContent initialHost={initialHost} onClose={onClose} />
|
||||
</WindowManager>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileManager(props: FileManagerProps) {
|
||||
return (
|
||||
<ConnectionLogProvider>
|
||||
<FileManagerInner {...props} />
|
||||
</ConnectionLogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
import { FileManager } from "@/ui/desktop/apps/features/file-manager/FileManager.tsx";
|
||||
import { FullScreenAppWrapper } from "@/ui/desktop/apps/FullScreenAppWrapper.tsx";
|
||||
|
||||
interface FileManagerAppProps {
|
||||
hostId?: string;
|
||||
}
|
||||
|
||||
const FileManagerApp: React.FC<FileManagerAppProps> = ({ hostId }) => {
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, loading) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading host...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">Host not found</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FileManager
|
||||
embedded={true}
|
||||
initialHost={hostConfig}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FullScreenAppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileManagerApp;
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
|
||||
interface SudoPasswordDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (password: string) => void;
|
||||
}
|
||||
|
||||
export function SudoPasswordDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: SudoPasswordDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPassword("");
|
||||
setLoading(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (!password.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
onSubmit(password);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("fileManager.sudoPasswordRequired")}</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{t("fileManager.enterSudoPassword")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="space-y-3">
|
||||
<PasswordInput
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("fileManager.sudoPassword")}
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!password.trim() || loading}>
|
||||
{loading ? t("common.loading") : t("common.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -332,11 +332,21 @@ export function FileViewer({
|
||||
const getImageDataUrl = (content: string, fileName: string): string => {
|
||||
const ext = fileName.split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
if (ext === "svg") {
|
||||
return `data:image/svg+xml;base64,${content}`;
|
||||
}
|
||||
const mimeTypes: Record<string, string> = {
|
||||
svg: "image/svg+xml",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
bmp: "image/bmp",
|
||||
ico: "image/x-icon",
|
||||
tiff: "image/tiff",
|
||||
tif: "image/tiff",
|
||||
};
|
||||
|
||||
return `data:image/*;base64,${content}`;
|
||||
const mimeType = mimeTypes[ext] || "image/png";
|
||||
return `data:${mimeType};base64,${content}`;
|
||||
};
|
||||
|
||||
const WARNING_SIZE = 50 * 1024 * 1024;
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
getSSHHosts,
|
||||
type ServerMetrics,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
|
||||
import { TOTPDialog } from "@/ui/desktop/navigation/dialogs/TOTPDialog.tsx";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@@ -33,8 +33,16 @@ import {
|
||||
ProcessesWidget,
|
||||
SystemWidget,
|
||||
LoginStatsWidget,
|
||||
PortsWidget,
|
||||
FirewallWidget,
|
||||
} from "./widgets";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
import { RefreshCcw, RefreshCw, RefreshCwOff } from "lucide-react";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "@/ui/desktop/navigation/connection-log/ConnectionLogContext.tsx";
|
||||
import { ConnectionLog } from "@/ui/desktop/navigation/connection-log/ConnectionLog.tsx";
|
||||
|
||||
interface QuickAction {
|
||||
name: string;
|
||||
@@ -70,7 +78,7 @@ interface ServerProps {
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function ServerStats({
|
||||
function ServerStatsInner({
|
||||
hostConfig,
|
||||
title,
|
||||
isVisible = true,
|
||||
@@ -79,6 +87,11 @@ export function ServerStats({
|
||||
}: ServerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const {
|
||||
addLog,
|
||||
clearLogs,
|
||||
isExpanded: isConnectionLogExpanded,
|
||||
} = useConnectionLog();
|
||||
const { addTab, tabs, currentTab, removeTab } = useTabs() as {
|
||||
addTab: (tab: { type: string; [key: string]: unknown }) => number;
|
||||
tabs: TabData[];
|
||||
@@ -107,6 +120,7 @@ export function ServerStats({
|
||||
const [viewerSessionId, setViewerSessionId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [hasConnectionError, setHasConnectionError] = React.useState(false);
|
||||
|
||||
const activityLoggedRef = React.useRef(false);
|
||||
const activityLoggingRef = React.useRef(false);
|
||||
@@ -265,6 +279,16 @@ export function ServerStats({
|
||||
<LoginStatsWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
);
|
||||
|
||||
case "ports":
|
||||
return (
|
||||
<PortsWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
);
|
||||
|
||||
case "firewall":
|
||||
return (
|
||||
<FirewallWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -389,6 +413,8 @@ export function ServerStats({
|
||||
setIsLoadingMetrics(true);
|
||||
}
|
||||
setShowStatsUI(true);
|
||||
setHasConnectionError(false);
|
||||
clearLogs();
|
||||
|
||||
try {
|
||||
if (!totpVerified) {
|
||||
@@ -396,6 +422,17 @@ export function ServerStats({
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (result?.connectionLogs) {
|
||||
result.connectionLogs.forEach((log: any) => {
|
||||
addLog({
|
||||
type: log.type,
|
||||
stage: log.stage,
|
||||
message: log.message,
|
||||
details: log.details,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (result.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpSessionId(result.sessionId || null);
|
||||
@@ -438,6 +475,7 @@ export function ServerStats({
|
||||
if (!hasExistingMetrics) {
|
||||
setIsLoadingMetrics(false);
|
||||
logServerActivity();
|
||||
setTimeout(() => clearLogs(), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,13 +496,27 @@ export function ServerStats({
|
||||
}
|
||||
}
|
||||
}, statsConfig.metricsInterval * 1000);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (!cancelled) {
|
||||
console.error("Failed to start metrics polling:", error);
|
||||
setIsLoadingMetrics(false);
|
||||
toast.error(t("serverStats.failedToFetchMetrics"));
|
||||
if (currentTab !== null) {
|
||||
removeTab(currentTab);
|
||||
setHasConnectionError(true);
|
||||
|
||||
if (error?.connectionLogs) {
|
||||
error.connectionLogs.forEach((log: any) => {
|
||||
addLog({
|
||||
type: log.type,
|
||||
stage: log.stage,
|
||||
message: log.message,
|
||||
details: log.details,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: error?.message || t("serverStats.connectionFailed"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -489,7 +541,9 @@ export function ServerStats({
|
||||
|
||||
debounceTimeout = setTimeout(() => {
|
||||
if (isActuallyVisible) {
|
||||
startMetrics();
|
||||
if (!hasConnectionError) {
|
||||
startMetrics();
|
||||
}
|
||||
} else {
|
||||
stopMetrics();
|
||||
}
|
||||
@@ -509,6 +563,7 @@ export function ServerStats({
|
||||
metricsEnabled,
|
||||
statsConfig.metricsInterval,
|
||||
totpVerified,
|
||||
hasConnectionError,
|
||||
]);
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
@@ -541,8 +596,16 @@ export function ServerStats({
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{!totpRequired && (
|
||||
<div
|
||||
className="h-full w-full flex flex-col"
|
||||
style={{
|
||||
visibility:
|
||||
hasConnectionError && isConnectionLogExpanded
|
||||
? "hidden"
|
||||
: "visible",
|
||||
}}
|
||||
>
|
||||
{!totpRequired && !isLoadingMetrics && (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="min-w-0">
|
||||
@@ -631,65 +694,18 @@ export function ServerStats({
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-foreground-secondary border-t-transparent rounded-full animate-spin"></div>
|
||||
{t("serverStats.refreshing")}
|
||||
<RefreshCw className="animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
t("serverStats.refreshStatus")
|
||||
<RefreshCw />
|
||||
)}
|
||||
</Button>
|
||||
{currentHostConfig?.enableFileManager && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="font-semibold"
|
||||
disabled={isFileManagerAlreadyOpen}
|
||||
title={
|
||||
isFileManagerAlreadyOpen
|
||||
? t("serverStats.fileManagerAlreadyOpen")
|
||||
: t("serverStats.openFileManager")
|
||||
}
|
||||
onClick={() => {
|
||||
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
|
||||
const titleBase =
|
||||
currentHostConfig?.name &&
|
||||
currentHostConfig.name.trim() !== ""
|
||||
? currentHostConfig.name.trim()
|
||||
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||
addTab({
|
||||
type: "file_manager",
|
||||
title: titleBase,
|
||||
hostConfig: currentHostConfig,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("nav.fileManager")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{currentHostConfig?.enableDocker && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="font-semibold"
|
||||
onClick={() => {
|
||||
const titleBase =
|
||||
currentHostConfig?.name &&
|
||||
currentHostConfig.name.trim() !== ""
|
||||
? currentHostConfig.name.trim()
|
||||
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||
addTab({
|
||||
type: "docker",
|
||||
title: titleBase,
|
||||
hostConfig: currentHostConfig,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("nav.docker")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!totpRequired && <Separator className="p-0.25 w-full" />}
|
||||
{!totpRequired && !isLoadingMetrics && (
|
||||
<Separator className="p-0.25 w-full" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 thin-scrollbar relative">
|
||||
{(metricsEnabled && showStatsUI) ||
|
||||
@@ -830,7 +846,7 @@ export function ServerStats({
|
||||
|
||||
{metricsEnabled && (
|
||||
<SimpleLoader
|
||||
visible={isLoadingMetrics && !metrics}
|
||||
visible={isLoadingMetrics && !metrics && !isConnectionLogExpanded}
|
||||
message={t("serverStats.connecting")}
|
||||
/>
|
||||
)}
|
||||
@@ -844,6 +860,20 @@ export function ServerStats({
|
||||
onCancel={handleTOTPCancel}
|
||||
backgroundColor="var(--bg-canvas)"
|
||||
/>
|
||||
<ConnectionLog
|
||||
isConnecting={isLoadingMetrics}
|
||||
isConnected={serverStatus === "online"}
|
||||
hasConnectionError={hasConnectionError}
|
||||
position={hasConnectionError ? "top" : "bottom"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServerStats(props: ServerProps): React.ReactElement {
|
||||
return (
|
||||
<ConnectionLogProvider>
|
||||
<ServerStatsInner {...props} />
|
||||
</ConnectionLogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
import { ServerStats } from "@/ui/desktop/apps/features/server-stats/ServerStats.tsx";
|
||||
import { FullScreenAppWrapper } from "@/ui/desktop/apps/FullScreenAppWrapper.tsx";
|
||||
|
||||
interface ServerStatsAppProps {
|
||||
hostId?: string;
|
||||
}
|
||||
|
||||
const ServerStatsApp: React.FC<ServerStatsAppProps> = ({ hostId }) => {
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, loading) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading host...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">Host not found</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ServerStats
|
||||
hostConfig={hostConfig}
|
||||
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FullScreenAppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerStatsApp;
|
||||
@@ -0,0 +1,212 @@
|
||||
import React from "react";
|
||||
import { Shield, ShieldOff, ShieldCheck, ChevronDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/ui/main-axios.ts";
|
||||
import type {
|
||||
FirewallMetrics,
|
||||
FirewallChain,
|
||||
FirewallRule,
|
||||
} from "@/types/stats-widgets";
|
||||
|
||||
interface FirewallWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
function RuleRow({ rule }: { rule: FirewallRule }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getTargetStyle = (target: string) => {
|
||||
switch (target.toUpperCase()) {
|
||||
case "ACCEPT":
|
||||
return "text-green-400";
|
||||
case "DROP":
|
||||
return "text-red-400";
|
||||
case "REJECT":
|
||||
return "text-orange-400";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
};
|
||||
|
||||
const getTargetLabel = (target: string) => {
|
||||
switch (target.toUpperCase()) {
|
||||
case "ACCEPT":
|
||||
return t("serverStats.firewall.accept");
|
||||
case "DROP":
|
||||
return t("serverStats.firewall.drop");
|
||||
case "REJECT":
|
||||
return t("serverStats.firewall.reject");
|
||||
default:
|
||||
return target;
|
||||
}
|
||||
};
|
||||
|
||||
const formatSource = () => {
|
||||
if (rule.interface) {
|
||||
return rule.interface;
|
||||
}
|
||||
if (rule.state) {
|
||||
return rule.state;
|
||||
}
|
||||
if (rule.source === "0.0.0.0/0") {
|
||||
return t("serverStats.firewall.anywhere");
|
||||
}
|
||||
return rule.source;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2 text-xs py-1.5 border-b border-edge/30 last:border-0">
|
||||
<div className={`font-medium ${getTargetStyle(rule.target)}`}>
|
||||
{getTargetLabel(rule.target)}
|
||||
</div>
|
||||
<div className="text-foreground-subtle font-mono">
|
||||
{rule.protocol.toUpperCase()}
|
||||
</div>
|
||||
<div className="text-foreground-subtle font-mono">
|
||||
{rule.dport || "-"}
|
||||
</div>
|
||||
<div className="text-foreground-subtle truncate" title={formatSource()}>
|
||||
{formatSource()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChainSection({ chain }: { chain: FirewallChain }) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = React.useState(true);
|
||||
|
||||
const getPolicyStyle = (policy: string) => {
|
||||
switch (policy.toUpperCase()) {
|
||||
case "ACCEPT":
|
||||
return "text-green-400";
|
||||
case "DROP":
|
||||
return "text-red-400";
|
||||
case "REJECT":
|
||||
return "text-orange-400";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 w-full py-1.5 hover:bg-elevated/30 rounded px-1 -mx-1 text-left"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 text-muted-foreground transition-transform ${
|
||||
isOpen ? "" : "-rotate-90"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{chain.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({t("serverStats.firewall.policy")}:{" "}
|
||||
<span className={getPolicyStyle(chain.policy)}>{chain.policy}</span>)
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{chain.rules.length} {t("serverStats.firewall.rules")}
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<>
|
||||
{chain.rules.length > 0 ? (
|
||||
<div className="mt-2 ml-5">
|
||||
<div className="grid grid-cols-4 gap-2 text-xs text-muted-foreground border-b border-edge/50 pb-1 mb-1">
|
||||
<div>{t("serverStats.firewall.action")}</div>
|
||||
<div>{t("serverStats.firewall.protocol")}</div>
|
||||
<div>{t("serverStats.firewall.port")}</div>
|
||||
<div>{t("serverStats.firewall.source")}</div>
|
||||
</div>
|
||||
<div className="max-h-32 overflow-y-auto thin-scrollbar">
|
||||
{chain.rules.map((rule, idx) => (
|
||||
<RuleRow key={idx} rule={rule} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground ml-5 mt-1">
|
||||
{t("serverStats.firewall.noRules")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FirewallWidget({ metrics }: FirewallWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const firewall = (metrics as ServerMetrics & { firewall?: FirewallMetrics })
|
||||
?.firewall;
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (!firewall || firewall.type === "none") {
|
||||
return <ShieldOff className="h-5 w-5 text-muted-foreground" />;
|
||||
}
|
||||
if (firewall.status === "active") {
|
||||
return <ShieldCheck className="h-5 w-5 text-green-400" />;
|
||||
}
|
||||
return <Shield className="h-5 w-5 text-orange-400" />;
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (!firewall || firewall.type === "none") {
|
||||
return t("serverStats.firewall.notDetected");
|
||||
}
|
||||
if (firewall.status === "active") {
|
||||
return t("serverStats.firewall.active");
|
||||
}
|
||||
return t("serverStats.firewall.inactive");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
{getStatusIcon()}
|
||||
<h3 className="font-semibold text-lg text-foreground">
|
||||
{t("serverStats.firewall.title")}
|
||||
</h3>
|
||||
{firewall && firewall.type !== "none" && (
|
||||
<span className="text-xs text-muted-foreground ml-auto bg-elevated/50 px-2 py-0.5 rounded capitalize">
|
||||
{firewall.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-3 flex-shrink-0">
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
firewall?.status === "active"
|
||||
? "text-green-400"
|
||||
: firewall?.status === "inactive"
|
||||
? "text-orange-400"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{getStatusText()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{firewall && firewall.chains.length > 0 ? (
|
||||
<div className="flex-1 overflow-y-auto thin-scrollbar space-y-2">
|
||||
{firewall.chains.map((chain) => (
|
||||
<ChainSection key={chain.name} chain={chain} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("serverStats.firewall.noData")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import React from "react";
|
||||
import { Network } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/ui/main-axios.ts";
|
||||
import type { PortsMetrics, ListeningPort } from "@/types/stats-widgets";
|
||||
|
||||
interface PortsWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
function PortRow({ port }: { port: ListeningPort }) {
|
||||
const formatAddress = (addr: string) => {
|
||||
if (addr === "0.0.0.0" || addr === "*" || addr === "::") {
|
||||
return "*";
|
||||
}
|
||||
return addr;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-5 gap-2 text-xs py-1.5 border-b border-edge/30 last:border-0">
|
||||
<div className="font-mono text-foreground-subtle">
|
||||
{port.protocol.toUpperCase()}
|
||||
</div>
|
||||
<div className="font-mono text-foreground">{port.localPort}</div>
|
||||
<div
|
||||
className="font-mono text-foreground-subtle truncate"
|
||||
title={formatAddress(port.localAddress)}
|
||||
>
|
||||
{formatAddress(port.localAddress)}
|
||||
</div>
|
||||
<div className="text-foreground-subtle">{port.state || "-"}</div>
|
||||
<div
|
||||
className="text-foreground-subtle truncate"
|
||||
title={port.process || "-"}
|
||||
>
|
||||
{port.process || (port.pid ? `PID:${port.pid}` : "-")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortsWidget({ metrics }: PortsWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const portsData = (metrics as ServerMetrics & { ports?: PortsMetrics })
|
||||
?.ports;
|
||||
|
||||
const tcpPorts = portsData?.ports.filter((p) => p.protocol === "tcp") || [];
|
||||
const udpPorts = portsData?.ports.filter((p) => p.protocol === "udp") || [];
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<Network className="h-5 w-5 text-cyan-400" />
|
||||
<h3 className="font-semibold text-lg text-foreground">
|
||||
{t("serverStats.ports.title")}
|
||||
</h3>
|
||||
{portsData && portsData.source !== "none" && (
|
||||
<span className="text-xs text-muted-foreground ml-auto bg-elevated/50 px-2 py-0.5 rounded">
|
||||
{portsData.source === "ss"
|
||||
? "Socket Stats"
|
||||
: portsData.source === "netstat"
|
||||
? "Netstat"
|
||||
: portsData.source}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-3 flex-shrink-0 text-sm">
|
||||
<span className="text-foreground-subtle">
|
||||
TCP:{" "}
|
||||
<span className="text-cyan-400 font-medium">{tcpPorts.length}</span>
|
||||
</span>
|
||||
<span className="text-foreground-subtle">
|
||||
UDP:{" "}
|
||||
<span className="text-cyan-400 font-medium">{udpPorts.length}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{portsData && portsData.ports.length > 0 ? (
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="grid grid-cols-5 gap-2 text-xs text-muted-foreground border-b border-edge/50 pb-1 mb-1 flex-shrink-0">
|
||||
<div>{t("serverStats.ports.protocol")}</div>
|
||||
<div>{t("serverStats.ports.port")}</div>
|
||||
<div>{t("serverStats.ports.address")}</div>
|
||||
<div>{t("serverStats.ports.state")}</div>
|
||||
<div>{t("serverStats.ports.process")}</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto thin-scrollbar">
|
||||
{portsData.ports.map((port, idx) => (
|
||||
<PortRow
|
||||
key={`${port.protocol}-${port.localPort}-${idx}`}
|
||||
port={port}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("serverStats.ports.noData")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,3 +6,5 @@ export { UptimeWidget } from "./UptimeWidget.tsx";
|
||||
export { ProcessesWidget } from "./ProcessesWidget.tsx";
|
||||
export { SystemWidget } from "./SystemWidget.tsx";
|
||||
export { LoginStatsWidget } from "./LoginStatsWidget.tsx";
|
||||
export { PortsWidget } from "./PortsWidget.tsx";
|
||||
export { FirewallWidget } from "./FirewallWidget.tsx";
|
||||
|
||||
@@ -12,7 +12,6 @@ import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getCookie,
|
||||
isElectron,
|
||||
@@ -21,8 +20,9 @@ import {
|
||||
deleteCommandFromHistory,
|
||||
getCommandHistory,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
|
||||
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
|
||||
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 {
|
||||
TERMINAL_THEMES,
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
@@ -37,6 +37,11 @@ import { useCommandHistory } from "@/ui/desktop/apps/features/terminal/command-h
|
||||
import { CommandAutocomplete } from "./command-history/CommandAutocomplete.tsx";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "@/ui/desktop/navigation/connection-log/ConnectionLogContext.tsx";
|
||||
import { ConnectionLog } from "@/ui/desktop/navigation/connection-log/ConnectionLog.tsx";
|
||||
|
||||
interface HostConfig {
|
||||
id?: number;
|
||||
@@ -72,7 +77,7 @@ interface SSHTerminalProps {
|
||||
executeCommand?: string;
|
||||
}
|
||||
|
||||
export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
function SSHTerminal(
|
||||
{
|
||||
hostConfig,
|
||||
@@ -99,6 +104,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const commandHistoryContext = useCommandHistory();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const { theme: appTheme } = useTheme();
|
||||
const { addLog, isExpanded: isConnectionLogExpanded } = useConnectionLog();
|
||||
|
||||
const config = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig };
|
||||
|
||||
@@ -126,7 +132,14 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isFitted, setIsFitted] = useState(false);
|
||||
const [, setConnectionError] = useState<string | null>(null);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const connectionErrorRef = useRef<string | null>(null);
|
||||
|
||||
const updateConnectionError = useCallback((error: string | null) => {
|
||||
connectionErrorRef.current = error;
|
||||
setConnectionError(error);
|
||||
}, []);
|
||||
|
||||
const [, setIsAuthenticated] = useState(false);
|
||||
const [totpRequired, setTotpRequired] = useState(false);
|
||||
const [totpPrompt, setTotpPrompt] = useState<string>("");
|
||||
@@ -137,6 +150,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
>("no_keyboard");
|
||||
const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] =
|
||||
useState(false);
|
||||
const [warpgateAuthRequired, setWarpgateAuthRequired] = useState(false);
|
||||
const [warpgateAuthUrl, setWarpgateAuthUrl] = useState<string>("");
|
||||
const [warpgateSecurityKey, setWarpgateSecurityKey] = useState<string>("");
|
||||
const warpgateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isVisibleRef = useRef<boolean>(false);
|
||||
const isFittingRef = useRef(false);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -146,8 +163,17 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const shouldNotReconnectRef = useRef(false);
|
||||
const isReconnectingRef = useRef(false);
|
||||
const isConnectingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
isUnmountingRef.current = false;
|
||||
shouldNotReconnectRef.current = false;
|
||||
isReconnectingRef.current = false;
|
||||
isConnectingRef.current = false;
|
||||
reconnectAttempts.current = 0;
|
||||
|
||||
return () => {};
|
||||
}, [hostConfig.id]);
|
||||
const connectionAttemptIdRef = useRef(0);
|
||||
const totpAttemptsRef = useRef(0);
|
||||
const totpTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const activityLoggedRef = useRef(false);
|
||||
@@ -401,6 +427,41 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
if (onClose) onClose();
|
||||
}
|
||||
|
||||
function handleWarpgateContinue() {
|
||||
if (webSocketRef.current) {
|
||||
if (warpgateTimeoutRef.current) {
|
||||
clearTimeout(warpgateTimeoutRef.current);
|
||||
warpgateTimeoutRef.current = null;
|
||||
}
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({
|
||||
type: "warpgate_auth_continue",
|
||||
data: {},
|
||||
}),
|
||||
);
|
||||
setWarpgateAuthRequired(false);
|
||||
setWarpgateAuthUrl("");
|
||||
setWarpgateSecurityKey("");
|
||||
}
|
||||
}
|
||||
|
||||
function handleWarpgateCancel() {
|
||||
if (warpgateTimeoutRef.current) {
|
||||
clearTimeout(warpgateTimeoutRef.current);
|
||||
warpgateTimeoutRef.current = null;
|
||||
}
|
||||
setWarpgateAuthRequired(false);
|
||||
setWarpgateAuthUrl("");
|
||||
setWarpgateSecurityKey("");
|
||||
if (onClose) onClose();
|
||||
}
|
||||
|
||||
function handleWarpgateOpenUrl() {
|
||||
if (warpgateAuthUrl) {
|
||||
window.open(warpgateAuthUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthDialogSubmit(credentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
@@ -476,6 +537,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
clearTimeout(totpTimeoutRef.current);
|
||||
totpTimeoutRef.current = null;
|
||||
}
|
||||
if (warpgateTimeoutRef.current) {
|
||||
clearTimeout(warpgateTimeoutRef.current);
|
||||
warpgateTimeoutRef.current = null;
|
||||
}
|
||||
webSocketRef.current?.close();
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
@@ -517,16 +582,21 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
shouldNotReconnectRef.current ||
|
||||
isReconnectingRef.current ||
|
||||
isConnectingRef.current ||
|
||||
wasDisconnectedBySSH.current
|
||||
wasDisconnectedBySSH.current ||
|
||||
reconnectTimeoutRef.current !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reconnectAttempts.current >= maxReconnectAttempts) {
|
||||
toast.error(t("terminal.maxReconnectAttemptsReached"));
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
updateConnectionError(t("terminal.maxReconnectAttemptsReached"));
|
||||
setIsConnecting(false);
|
||||
shouldNotReconnectRef.current = true;
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: t("terminal.maxReconnectAttemptsReached"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -538,12 +608,14 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
reconnectAttempts.current++;
|
||||
|
||||
toast.info(
|
||||
t("terminal.reconnecting", {
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "connection",
|
||||
message: t("terminal.reconnecting", {
|
||||
attempt: reconnectAttempts.current,
|
||||
max: maxReconnectAttempts,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const delay = Math.min(
|
||||
2000 * Math.pow(2, reconnectAttempts.current - 1),
|
||||
@@ -551,6 +623,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
);
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
reconnectTimeoutRef.current = null;
|
||||
|
||||
if (
|
||||
isUnmountingRef.current ||
|
||||
shouldNotReconnectRef.current ||
|
||||
@@ -569,7 +643,14 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
if (!jwtToken || jwtToken.trim() === "") {
|
||||
console.warn("Reconnection cancelled - no authentication token");
|
||||
isReconnectingRef.current = false;
|
||||
setConnectionError("Authentication required for reconnection");
|
||||
updateConnectionError(t("terminal.authenticationRequired"));
|
||||
setIsConnecting(false);
|
||||
shouldNotReconnectRef.current = true;
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "auth",
|
||||
message: t("terminal.authenticationRequired"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -594,6 +675,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
if (!isReconnectingRef.current) {
|
||||
reconnectAttempts.current = 0;
|
||||
shouldNotReconnectRef.current = false;
|
||||
}
|
||||
|
||||
const isDev =
|
||||
@@ -609,7 +691,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
console.error("No JWT token available for WebSocket connection");
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
setConnectionError("Authentication required");
|
||||
updateConnectionError("Authentication required");
|
||||
isConnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
@@ -627,7 +709,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
||||
return `${wsProtocol}${wsHost}/ssh/websocket/`;
|
||||
})()
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
|
||||
: `/ssh/websocket/`;
|
||||
|
||||
if (
|
||||
webSocketRef.current &&
|
||||
@@ -650,7 +732,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const ws = new WebSocket(wsUrl);
|
||||
webSocketRef.current = ws;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
setConnectionError(null);
|
||||
updateConnectionError(null);
|
||||
shouldNotReconnectRef.current = false;
|
||||
isReconnectingRef.current = false;
|
||||
setIsConnecting(true);
|
||||
@@ -665,21 +747,30 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
) {
|
||||
ws.addEventListener("open", () => {
|
||||
connectionTimeoutRef.current = setTimeout(() => {
|
||||
if (!isConnected && !totpRequired && !isPasswordPrompt) {
|
||||
if (
|
||||
!isConnected &&
|
||||
!totpRequired &&
|
||||
!isPasswordPrompt &&
|
||||
!connectionErrorRef.current
|
||||
) {
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
toast.error(t("terminal.connectionTimeout"));
|
||||
const timeoutMessage = t("terminal.connectionTimeout");
|
||||
updateConnectionError(timeoutMessage);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: timeoutMessage,
|
||||
});
|
||||
if (webSocketRef.current) {
|
||||
webSocketRef.current.close();
|
||||
}
|
||||
if (reconnectAttempts.current > 0) {
|
||||
attemptReconnection();
|
||||
} else {
|
||||
setIsConnecting(false);
|
||||
shouldNotReconnectRef.current = true;
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 35000);
|
||||
@@ -716,7 +807,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
terminal.write(outputData);
|
||||
const sudoPasswordPattern =
|
||||
/(?:\[sudo\] password for \S+:|sudo: a password is required)/;
|
||||
/(?:\[sudo\][^\n]*:\s*$|sudo:[^\n]*password[^\n]*required)/i;
|
||||
const passwordToFill =
|
||||
hostConfig.terminalConfig?.sudoPassword || hostConfig.password;
|
||||
if (
|
||||
@@ -746,6 +837,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
},
|
||||
t("common.confirm"),
|
||||
t("common.cancel"),
|
||||
{ confirmOnEnter: true },
|
||||
);
|
||||
setTimeout(() => {
|
||||
sudoPromptShownRef.current = false;
|
||||
@@ -765,21 +857,24 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
} else if (msg.type === "error") {
|
||||
const errorMessage = msg.message || t("terminal.unknownError");
|
||||
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: errorMessage,
|
||||
});
|
||||
|
||||
if (
|
||||
errorMessage.toLowerCase().includes("connection") ||
|
||||
errorMessage.toLowerCase().includes("timeout") ||
|
||||
errorMessage.toLowerCase().includes("network")
|
||||
) {
|
||||
toast.error(
|
||||
t("terminal.connectionError", { message: errorMessage }),
|
||||
);
|
||||
updateConnectionError(errorMessage);
|
||||
setIsConnected(false);
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
setIsConnecting(true);
|
||||
setIsConnecting(false);
|
||||
wasDisconnectedBySSH.current = false;
|
||||
attemptReconnection();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -792,28 +887,38 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
errorMessage.toLowerCase().includes("key"))) ||
|
||||
errorMessage.toLowerCase().includes("incorrect password")
|
||||
) {
|
||||
toast.error(t("terminal.authError", { message: errorMessage }));
|
||||
updateConnectionError(errorMessage);
|
||||
setIsConnecting(false);
|
||||
shouldNotReconnectRef.current = true;
|
||||
if (webSocketRef.current) {
|
||||
webSocketRef.current.close();
|
||||
}
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(t("terminal.error", { message: errorMessage }));
|
||||
updateConnectionError(errorMessage);
|
||||
setIsConnecting(false);
|
||||
} else if (msg.type === "connected") {
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
isConnectingRef.current = false;
|
||||
updateConnectionError(null);
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
if (reconnectAttempts.current > 0) {
|
||||
toast.success(t("terminal.reconnected"));
|
||||
addLog({
|
||||
type: "success",
|
||||
stage: "connection",
|
||||
message: t("terminal.reconnected"),
|
||||
});
|
||||
} else {
|
||||
addLog({
|
||||
type: "success",
|
||||
stage: "connection",
|
||||
message: t("terminal.connected"),
|
||||
});
|
||||
}
|
||||
reconnectAttempts.current = 0;
|
||||
isReconnectingRef.current = false;
|
||||
@@ -882,7 +987,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
onClose();
|
||||
}
|
||||
} else if (msg.type === "totp_required") {
|
||||
totpAttemptsRef.current = 0;
|
||||
setTotpRequired(true);
|
||||
setTotpPrompt(msg.prompt || t("terminal.totpCodeLabel"));
|
||||
setIsPasswordPrompt(false);
|
||||
@@ -895,20 +999,12 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
totpTimeoutRef.current = setTimeout(() => {
|
||||
setTotpRequired(false);
|
||||
toast.error(t("terminal.totpTimeout"));
|
||||
if (webSocketRef.current) {
|
||||
webSocketRef.current.close();
|
||||
}
|
||||
}, 180000);
|
||||
} else if (msg.type === "totp_retry") {
|
||||
totpAttemptsRef.current++;
|
||||
const attemptsRemaining =
|
||||
msg.attempts_remaining || 3 - totpAttemptsRef.current;
|
||||
toast.error(
|
||||
`Invalid code. ${attemptsRemaining} ${attemptsRemaining === 1 ? "attempt" : "attempts"} remaining.`,
|
||||
);
|
||||
} else if (msg.type === "password_required") {
|
||||
totpAttemptsRef.current = 0;
|
||||
setTotpRequired(true);
|
||||
setTotpPrompt(msg.prompt || t("common.password"));
|
||||
setIsPasswordPrompt(true);
|
||||
@@ -921,11 +1017,27 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
totpTimeoutRef.current = setTimeout(() => {
|
||||
setTotpRequired(false);
|
||||
toast.error(t("terminal.passwordTimeout"));
|
||||
if (webSocketRef.current) {
|
||||
webSocketRef.current.close();
|
||||
}
|
||||
}, 180000);
|
||||
} else if (msg.type === "warpgate_auth_required") {
|
||||
setWarpgateAuthRequired(true);
|
||||
setWarpgateAuthUrl(msg.url || "");
|
||||
setWarpgateSecurityKey(msg.securityKey || "N/A");
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
if (warpgateTimeoutRef.current) {
|
||||
clearTimeout(warpgateTimeoutRef.current);
|
||||
}
|
||||
warpgateTimeoutRef.current = setTimeout(() => {
|
||||
setWarpgateAuthRequired(false);
|
||||
if (webSocketRef.current) {
|
||||
webSocketRef.current.close();
|
||||
}
|
||||
}, 300000);
|
||||
} else if (msg.type === "keyboard_interactive_available") {
|
||||
setKeyboardInteractiveDetected(true);
|
||||
setIsConnecting(false);
|
||||
@@ -941,9 +1053,18 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
} else if (msg.type === "connection_log") {
|
||||
if (msg.data) {
|
||||
addLog({
|
||||
type: msg.data.level || "info",
|
||||
stage: msg.data.stage || "auth",
|
||||
message: msg.data.message,
|
||||
details: msg.data.details,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("terminal.messageParseError"));
|
||||
// Message parse errors are logged via backend
|
||||
}
|
||||
});
|
||||
|
||||
@@ -965,9 +1086,29 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
totpTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (event.code === 1006) {
|
||||
console.error(
|
||||
"[WebSocket] Abnormal closure detected - possible HTTPS/proxy issue",
|
||||
);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: t("terminal.websocketAbnormalClose"),
|
||||
});
|
||||
updateConnectionError(t("terminal.websocketAbnormalClose"));
|
||||
setIsConnecting(false);
|
||||
shouldNotReconnectRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.code === 1008) {
|
||||
console.error("WebSocket authentication failed:", event.reason);
|
||||
setConnectionError("Authentication failed - please re-login");
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "auth",
|
||||
message: "Authentication failed - please re-login",
|
||||
});
|
||||
updateConnectionError("Authentication failed - please re-login");
|
||||
setIsConnecting(false);
|
||||
shouldNotReconnectRef.current = true;
|
||||
|
||||
@@ -980,26 +1121,47 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(false);
|
||||
if (
|
||||
!isConnected &&
|
||||
event.wasClean &&
|
||||
(event.code === 1005 || event.code === 1000)
|
||||
) {
|
||||
console.error("[WebSocket] Connection rejected by server");
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: t("terminal.connectionRejected"),
|
||||
});
|
||||
updateConnectionError(t("terminal.connectionRejected"));
|
||||
setIsConnecting(false);
|
||||
shouldNotReconnectRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldAttemptReconnection =
|
||||
!wasDisconnectedBySSH.current &&
|
||||
!isUnmountingRef.current &&
|
||||
!shouldNotReconnectRef.current &&
|
||||
!isConnectingRef.current
|
||||
) {
|
||||
!isConnectingRef.current;
|
||||
|
||||
if (shouldAttemptReconnection) {
|
||||
wasDisconnectedBySSH.current = false;
|
||||
attemptReconnection();
|
||||
} else {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("error", () => {
|
||||
ws.addEventListener("error", (event) => {
|
||||
if (currentAttemptId !== connectionAttemptIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("[WebSocket] Error:", event);
|
||||
|
||||
setIsConnected(false);
|
||||
isConnectingRef.current = false;
|
||||
setConnectionError(t("terminal.websocketError"));
|
||||
updateConnectionError(t("terminal.websocketError"));
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
@@ -1009,15 +1171,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
clearTimeout(totpTimeoutRef.current);
|
||||
totpTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (
|
||||
!isUnmountingRef.current &&
|
||||
!shouldNotReconnectRef.current &&
|
||||
!isConnectingRef.current
|
||||
) {
|
||||
wasDisconnectedBySSH.current = false;
|
||||
attemptReconnection();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1160,7 +1313,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
fontSize: config.fontSize,
|
||||
fontFamily,
|
||||
allowTransparency: true,
|
||||
convertEol: true,
|
||||
convertEol: false,
|
||||
windowsMode: false,
|
||||
macOptionIsMeta: false,
|
||||
macOptionClickForcesSelection: false,
|
||||
@@ -1314,16 +1467,21 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
resizeObserver.observe(xtermRef.current);
|
||||
|
||||
return () => {
|
||||
isUnmountingRef.current = true;
|
||||
shouldNotReconnectRef.current = true;
|
||||
isReconnectingRef.current = false;
|
||||
setIsConnecting(false);
|
||||
isFittingRef.current = false;
|
||||
resizeObserver.disconnect();
|
||||
element?.removeEventListener("contextmenu", handleContextMenu);
|
||||
element?.removeEventListener("keydown", handleMacKeyboard, true);
|
||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
};
|
||||
}, [xtermRef, terminal, hostConfig, isDarkMode]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isUnmountingRef.current = true;
|
||||
shouldNotReconnectRef.current = true;
|
||||
isReconnectingRef.current = false;
|
||||
setIsConnecting(false);
|
||||
if (reconnectTimeoutRef.current)
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
if (connectionTimeoutRef.current)
|
||||
@@ -1335,7 +1493,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
webSocketRef.current?.close();
|
||||
};
|
||||
}, [xtermRef, terminal, hostConfig, isDarkMode]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal) return;
|
||||
@@ -1345,6 +1503,25 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.ctrlKey && e.altKey && !e.metaKey && !e.shiftKey) {
|
||||
const key = e.key.toLowerCase();
|
||||
const blockedKeys = ["w", "t", "n", "q"];
|
||||
if (blockedKeys.includes(key)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const ctrlCode = key.charCodeAt(0) - 96;
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({
|
||||
type: "input",
|
||||
data: String.fromCharCode(ctrlCode),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (showAutocompleteRef.current) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
@@ -1526,6 +1703,14 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
if (!terminal || !hostConfig || !isVisible) return;
|
||||
if (isConnected || isConnecting) return;
|
||||
|
||||
if (isReconnectingRef.current || reconnectTimeoutRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldNotReconnectRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (terminal.cols < 10 || terminal.rows < 3) {
|
||||
requestAnimationFrame(() => {
|
||||
if (terminal.cols > 0 && terminal.rows > 0) {
|
||||
@@ -1544,7 +1729,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
scheduleNotify(terminal.cols, terminal.rows);
|
||||
connectToHost(terminal.cols, terminal.rows);
|
||||
}
|
||||
}, [terminal, hostConfig, isVisible, isConnected, isConnecting]);
|
||||
// Note: Using hostConfig.id instead of hostConfig object to prevent
|
||||
// unnecessary reconnections when host properties are updated.
|
||||
// Only reconnect when switching to a different host.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [terminal, hostConfig.id, isVisible, isConnected, isConnecting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !fitAddonRef.current || !isVisible) return;
|
||||
@@ -1561,6 +1750,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
return () => clearTimeout(fitTimeoutId);
|
||||
}, [terminal, isVisible, splitScreen, isConnecting]);
|
||||
|
||||
const hasConnectionError = !!connectionError;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full relative" style={{ backgroundColor }}>
|
||||
<div
|
||||
@@ -1568,7 +1759,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
pointerEvents: isVisible ? "auto" : "none",
|
||||
visibility: isConnecting || !isFitted ? "hidden" : "visible",
|
||||
visibility:
|
||||
isConnected && isFitted && !connectionError
|
||||
? "visible"
|
||||
: "hidden",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (terminal && !splitScreen) {
|
||||
@@ -1577,6 +1771,19 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}}
|
||||
/>
|
||||
|
||||
<SimpleLoader
|
||||
visible={isConnecting && !isConnectionLogExpanded}
|
||||
message={t("terminal.connecting")}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
|
||||
<ConnectionLog
|
||||
isConnecting={isConnecting}
|
||||
isConnected={isConnected}
|
||||
hasConnectionError={hasConnectionError}
|
||||
position={hasConnectionError ? "top" : "bottom"}
|
||||
/>
|
||||
|
||||
<TOTPDialog
|
||||
isOpen={totpRequired}
|
||||
prompt={totpPrompt}
|
||||
@@ -1599,6 +1806,16 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
|
||||
<WarpgateDialog
|
||||
isOpen={warpgateAuthRequired}
|
||||
url={warpgateAuthUrl}
|
||||
securityKey={warpgateSecurityKey}
|
||||
onContinue={handleWarpgateContinue}
|
||||
onCancel={handleWarpgateCancel}
|
||||
onOpenUrl={handleWarpgateOpenUrl}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
|
||||
<CommandAutocomplete
|
||||
visible={showAutocomplete}
|
||||
suggestions={autocompleteSuggestions}
|
||||
@@ -1606,17 +1823,21 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
position={autocompletePosition}
|
||||
onSelect={handleAutocompleteSelect}
|
||||
/>
|
||||
|
||||
<SimpleLoader
|
||||
visible={isConnecting}
|
||||
message={t("terminal.connecting")}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
function Terminal(props, ref) {
|
||||
return (
|
||||
<ConnectionLogProvider>
|
||||
<TerminalInner {...props} ref={ref} />
|
||||
</ConnectionLogProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.innerHTML = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap');
|
||||
@@ -1655,7 +1876,6 @@ style.innerHTML = `
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Light theme scrollbars */
|
||||
.xterm .xterm-viewport::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background: transparent;
|
||||
@@ -1672,7 +1892,6 @@ style.innerHTML = `
|
||||
scrollbar-color: rgba(0,0,0,0.3) transparent;
|
||||
}
|
||||
|
||||
/* Dark theme scrollbars */
|
||||
.dark .xterm .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from "react";
|
||||
import { Terminal } from "@/ui/desktop/apps/features/terminal/Terminal.tsx";
|
||||
import { FullScreenAppWrapper } from "@/ui/desktop/apps/FullScreenAppWrapper.tsx";
|
||||
|
||||
interface TerminalAppProps {
|
||||
hostId?: string;
|
||||
}
|
||||
|
||||
const TerminalApp: React.FC<TerminalAppProps> = ({ hostId }) => {
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, loading) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading host...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">Host not found</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Terminal
|
||||
hostConfig={hostConfig}
|
||||
isVisible={true}
|
||||
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
|
||||
showTitle={false}
|
||||
splitScreen={false}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FullScreenAppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalApp;
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
import { TunnelManager } from "@/ui/desktop/apps/features/tunnel/TunnelManager.tsx";
|
||||
import { FullScreenAppWrapper } from "@/ui/desktop/apps/FullScreenAppWrapper.tsx";
|
||||
|
||||
interface TunnelAppProps {
|
||||
hostId?: string;
|
||||
}
|
||||
|
||||
const TunnelApp: React.FC<TunnelAppProps> = ({ hostId }) => {
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, loading) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading host...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">Host not found</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TunnelManager
|
||||
hostConfig={hostConfig}
|
||||
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FullScreenAppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default TunnelApp;
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
} from "../../../../../types";
|
||||
import { CredentialGeneralTab } from "./tabs/CredentialGeneralTab";
|
||||
import { CredentialAuthenticationTab } from "./tabs/CredentialAuthenticationTab";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
|
||||
export function CredentialEditor({
|
||||
editingCredential,
|
||||
@@ -59,6 +60,7 @@ export function CredentialEditor({
|
||||
const keyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [activeTab, setActiveTab] = useState("general");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [detectedPublicKeyType, setDetectedPublicKeyType] = useState<
|
||||
string | null
|
||||
@@ -168,6 +170,7 @@ export function CredentialEditor({
|
||||
resolver: zodResolver(formSchema) as unknown as Parameters<
|
||||
typeof useForm<FormData>
|
||||
>[0]["resolver"],
|
||||
mode: "all",
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
@@ -183,6 +186,45 @@ export function CredentialEditor({
|
||||
},
|
||||
});
|
||||
|
||||
const watchedFields = form.watch();
|
||||
|
||||
const isFormValid = React.useMemo(() => {
|
||||
const values = form.getValues();
|
||||
|
||||
if (!values.name || !values.username) return false;
|
||||
|
||||
if (authTab === "password") {
|
||||
return !!(values.password && values.password.trim() !== "");
|
||||
} else if (authTab === "key") {
|
||||
if (editingCredential) {
|
||||
return true;
|
||||
}
|
||||
return !!values.key;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [watchedFields, authTab, editingCredential]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateAuthFields = async () => {
|
||||
form.setValue("authType", authTab, { shouldValidate: true });
|
||||
|
||||
if (authTab === "password") {
|
||||
form.setValue("key", null, { shouldValidate: true });
|
||||
form.setValue("publicKey", "", { shouldValidate: true });
|
||||
form.setValue("keyPassword", "", { shouldValidate: true });
|
||||
form.setValue("keyType", "auto", { shouldValidate: true });
|
||||
} else if (authTab === "key") {
|
||||
form.setValue("password", "", { shouldValidate: true });
|
||||
}
|
||||
|
||||
await form.trigger();
|
||||
};
|
||||
|
||||
updateAuthFields();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [authTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingCredential && fullCredentialDetails) {
|
||||
const defaultAuthType = fullCredentialDetails.authType;
|
||||
@@ -331,6 +373,7 @@ export function CredentialEditor({
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
setFormError(null);
|
||||
|
||||
if (!data.name || data.name.trim() === "") {
|
||||
@@ -388,6 +431,8 @@ export function CredentialEditor({
|
||||
} else {
|
||||
toast.error(t("credentials.failedToSaveCredential"));
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -457,9 +502,18 @@ export function CredentialEditor({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex flex-col h-full min-h-0 w-full"
|
||||
className="flex-1 flex flex-col h-full min-h-0 w-full relative"
|
||||
key={editingCredential?.id || "new"}
|
||||
>
|
||||
<SimpleLoader
|
||||
visible={isSubmitting}
|
||||
message={
|
||||
editingCredential
|
||||
? t("credentials.updatingCredential")
|
||||
: t("credentials.savingCredential")
|
||||
}
|
||||
backgroundColor="var(--bg-base)"
|
||||
/>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit, handleFormError)}
|
||||
@@ -529,11 +583,18 @@ export function CredentialEditor({
|
||||
</ScrollArea>
|
||||
<footer className="shrink-0 w-full pb-0">
|
||||
<Separator className="p-0.25" />
|
||||
<Button className="translate-y-2" type="submit" variant="outline">
|
||||
{editingCredential
|
||||
? t("credentials.updateCredential")
|
||||
: t("credentials.addCredential")}
|
||||
</Button>
|
||||
{!isSubmitting && (
|
||||
<Button
|
||||
className="translate-y-2"
|
||||
type="submit"
|
||||
variant="outline"
|
||||
disabled={!isFormValid}
|
||||
>
|
||||
{editingCredential
|
||||
? t("credentials.updateCredential")
|
||||
: t("credentials.addCredential")}
|
||||
</Button>
|
||||
)}
|
||||
</footer>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -178,17 +178,6 @@ export function CredentialSelector({
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2.5">
|
||||
{value && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-left rounded-lg px-3 py-2 text-destructive hover:bg-destructive/10 transition-colors duration-200"
|
||||
onClick={handleClear}
|
||||
>
|
||||
{t("common.clear")}
|
||||
</Button>
|
||||
)}
|
||||
{filteredCredentials.map((credential) => (
|
||||
<Button
|
||||
key={credential.id}
|
||||
|
||||
@@ -758,15 +758,6 @@ export function CredentialsManager({
|
||||
)}
|
||||
{credential.authType}
|
||||
</Badge>
|
||||
{credential.authType === "key" &&
|
||||
credential.keyType && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
{credential.keyType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -112,10 +112,23 @@ export function HostManager({
|
||||
const handleTabChange = (value: string) => {
|
||||
if (activeTab === "add_host" && value !== "add_host") {
|
||||
setEditingHost(null);
|
||||
lastProcessedHostIdRef.current = undefined;
|
||||
|
||||
// Clear hostConfig from tab data when leaving add_host tab
|
||||
if (updateTab && currentTabId !== undefined) {
|
||||
updateTab(currentTabId, { hostConfig: null });
|
||||
}
|
||||
}
|
||||
if (activeTab === "add_credential" && value !== "add_credential") {
|
||||
setEditingCredential(null);
|
||||
}
|
||||
|
||||
// Clear editing state when switching TO add_host tab (to ensure fresh state)
|
||||
if (value === "add_host" && activeTab !== "add_host") {
|
||||
setEditingHost(null);
|
||||
lastProcessedHostIdRef.current = undefined;
|
||||
}
|
||||
|
||||
setActiveTab(value);
|
||||
|
||||
if (updateTab && currentTabId !== undefined) {
|
||||
|
||||
@@ -293,9 +293,18 @@ export function HostManagerEditor({
|
||||
tunnelConnections: z
|
||||
.array(
|
||||
z.object({
|
||||
tunnelType: z
|
||||
.enum(["local", "remote"])
|
||||
.default("remote")
|
||||
.optional(),
|
||||
sourcePort: z.coerce.number().min(1).max(65535),
|
||||
endpointPort: z.coerce.number().min(1).max(65535),
|
||||
endpointHost: z.string().min(1),
|
||||
endpointPassword: z.string().optional(),
|
||||
endpointKey: z.string().optional(),
|
||||
endpointKeyPassword: z.string().optional(),
|
||||
endpointAuthType: z.string().optional(),
|
||||
endpointKeyType: z.string().optional(),
|
||||
maxRetries: z.coerce.number().min(0).max(100).default(3),
|
||||
retryInterval: z.coerce.number().min(1).max(3600).default(10),
|
||||
autoStart: z.boolean().default(false),
|
||||
@@ -317,6 +326,8 @@ export function HostManagerEditor({
|
||||
"processes",
|
||||
"system",
|
||||
"login_stats",
|
||||
"ports",
|
||||
"firewall",
|
||||
]),
|
||||
)
|
||||
.default([
|
||||
@@ -327,6 +338,8 @@ export function HostManagerEditor({
|
||||
"uptime",
|
||||
"system",
|
||||
"login_stats",
|
||||
"ports",
|
||||
"firewall",
|
||||
]),
|
||||
statusCheckEnabled: z.boolean().default(true),
|
||||
statusCheckInterval: z.number().min(5).max(3600).default(30),
|
||||
@@ -342,6 +355,8 @@ export function HostManagerEditor({
|
||||
"uptime",
|
||||
"system",
|
||||
"login_stats",
|
||||
"ports",
|
||||
"firewall",
|
||||
],
|
||||
statusCheckEnabled: true,
|
||||
statusCheckInterval: 30,
|
||||
@@ -412,6 +427,11 @@ export function HostManagerEditor({
|
||||
)
|
||||
.optional(),
|
||||
enableDocker: z.boolean().default(false),
|
||||
showTerminalInSidebar: z.boolean().default(true),
|
||||
showFileManagerInSidebar: z.boolean().default(false),
|
||||
showTunnelInSidebar: z.boolean().default(false),
|
||||
showDockerInSidebar: z.boolean().default(false),
|
||||
showServerStatsInSidebar: z.boolean().default(false),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.authType === "none") {
|
||||
@@ -494,6 +514,11 @@ export function HostManagerEditor({
|
||||
enableTerminal: true,
|
||||
enableTunnel: true,
|
||||
enableFileManager: true,
|
||||
showTerminalInSidebar: true,
|
||||
showFileManagerInSidebar: false,
|
||||
showTunnelInSidebar: false,
|
||||
showDockerInSidebar: false,
|
||||
showServerStatsInSidebar: false,
|
||||
defaultPath: "/",
|
||||
tunnelConnections: [],
|
||||
jumpHosts: [],
|
||||
@@ -636,7 +661,10 @@ export function HostManagerEditor({
|
||||
enableFileManager: Boolean(cleanedHost.enableFileManager),
|
||||
defaultPath: cleanedHost.defaultPath || "/",
|
||||
tunnelConnections: Array.isArray(cleanedHost.tunnelConnections)
|
||||
? cleanedHost.tunnelConnections
|
||||
? cleanedHost.tunnelConnections.map((conn: any) => ({
|
||||
...conn,
|
||||
tunnelType: conn.tunnelType || "remote",
|
||||
}))
|
||||
: [],
|
||||
jumpHosts: Array.isArray(cleanedHost.jumpHosts)
|
||||
? cleanedHost.jumpHosts
|
||||
@@ -653,6 +681,13 @@ export function HostManagerEditor({
|
||||
)
|
||||
? cleanedHost.terminalConfig.environmentVariables
|
||||
: [],
|
||||
sudoPassword:
|
||||
cleanedHost.sudoPassword ||
|
||||
cleanedHost.terminalConfig?.sudoPassword ||
|
||||
"",
|
||||
sudoPasswordAutoFill:
|
||||
cleanedHost.terminalConfig?.sudoPasswordAutoFill ??
|
||||
Boolean(cleanedHost.sudoPassword),
|
||||
},
|
||||
forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive),
|
||||
notes: cleanedHost.notes || "",
|
||||
@@ -665,6 +700,11 @@ export function HostManagerEditor({
|
||||
? cleanedHost.socks5ProxyChain
|
||||
: [],
|
||||
enableDocker: Boolean(cleanedHost.enableDocker),
|
||||
showTerminalInSidebar: cleanedHost.showTerminalInSidebar ?? true,
|
||||
showFileManagerInSidebar: cleanedHost.showFileManagerInSidebar ?? false,
|
||||
showTunnelInSidebar: cleanedHost.showTunnelInSidebar ?? false,
|
||||
showDockerInSidebar: cleanedHost.showDockerInSidebar ?? false,
|
||||
showServerStatsInSidebar: cleanedHost.showServerStatsInSidebar ?? false,
|
||||
};
|
||||
|
||||
if (
|
||||
@@ -725,6 +765,11 @@ export function HostManagerEditor({
|
||||
terminalConfig: DEFAULT_TERMINAL_CONFIG,
|
||||
forceKeyboardInteractive: false,
|
||||
enableDocker: false,
|
||||
showTerminalInSidebar: true,
|
||||
showFileManagerInSidebar: false,
|
||||
showTunnelInSidebar: false,
|
||||
showDockerInSidebar: false,
|
||||
showServerStatsInSidebar: false,
|
||||
};
|
||||
|
||||
form.reset(defaultFormData as FormData);
|
||||
@@ -755,6 +800,13 @@ export function HostManagerEditor({
|
||||
...data,
|
||||
};
|
||||
|
||||
if (
|
||||
data.terminalConfig?.sudoPasswordAutoFill &&
|
||||
data.terminalConfig?.sudoPassword
|
||||
) {
|
||||
submitData.sudoPassword = data.terminalConfig.sudoPassword;
|
||||
}
|
||||
|
||||
if (data.authType !== "credential") {
|
||||
submitData.credentialId = undefined;
|
||||
}
|
||||
@@ -1067,7 +1119,6 @@ export function HostManagerEditor({
|
||||
}
|
||||
backgroundColor="var(--bg-base)"
|
||||
/>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit, handleFormError)}
|
||||
@@ -1202,12 +1253,12 @@ export function HostManagerEditor({
|
||||
</ScrollArea>
|
||||
<footer className="shrink-0 w-full pb-0">
|
||||
<Separator className="p-0.25" />
|
||||
{!editingHost?.isShared && (
|
||||
{!editingHost?.isShared && !isSubmitting && (
|
||||
<Button
|
||||
className="translate-y-2"
|
||||
type="submit"
|
||||
variant="outline"
|
||||
disabled={!isFormValid || isSubmitting}
|
||||
disabled={!isFormValid}
|
||||
>
|
||||
{editingHost
|
||||
? editingHost.id
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from "react";
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Badge } from "@/components/ui/badge.tsx";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
|
||||
@@ -15,6 +21,12 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip.tsx";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu.tsx";
|
||||
import {
|
||||
getSSHHosts,
|
||||
deleteSSHHost,
|
||||
@@ -25,9 +37,11 @@ import {
|
||||
getSSHFolders,
|
||||
updateFolderMetadata,
|
||||
deleteAllHostsInFolder,
|
||||
getServerStatusById,
|
||||
refreshServerPolling,
|
||||
isElectron,
|
||||
getConfiguredServerUrl,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useServerStatus } from "@/ui/contexts/ServerStatusContext";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
@@ -64,6 +78,7 @@ import {
|
||||
Users,
|
||||
ArrowDownUp,
|
||||
Container,
|
||||
Link,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
SSHHost,
|
||||
@@ -74,6 +89,8 @@ import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets.ts";
|
||||
import { FolderEditDialog } from "@/ui/desktop/apps/host-manager/dialogs/FolderEditDialog.tsx";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
|
||||
const INITIAL_HOSTS_PER_FOLDER = 12;
|
||||
|
||||
export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
@@ -94,9 +111,10 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
const [editingFolderAppearance, setEditingFolderAppearance] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [serverStatuses, setServerStatuses] = useState<
|
||||
Map<number, "online" | "offline" | "degraded">
|
||||
>(new Map());
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const { getStatus } = useServerStatus();
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -211,74 +229,6 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hosts.length === 0) return;
|
||||
|
||||
const statusIntervals: NodeJS.Timeout[] = [];
|
||||
const statusCancelled: boolean[] = [];
|
||||
|
||||
hosts.forEach((host, index) => {
|
||||
const statsConfig = (() => {
|
||||
try {
|
||||
return host.statsConfig
|
||||
? JSON.parse(host.statsConfig)
|
||||
: DEFAULT_STATS_CONFIG;
|
||||
} catch {
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
})();
|
||||
|
||||
const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
|
||||
|
||||
if (!shouldShowStatus) {
|
||||
setServerStatuses((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(host.id, "offline");
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await getServerStatusById(host.id);
|
||||
if (!statusCancelled[index]) {
|
||||
setServerStatuses((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(
|
||||
host.id,
|
||||
res?.status === "online" ? "online" : "offline",
|
||||
);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!statusCancelled[index]) {
|
||||
const err = error as { response?: { status?: number } };
|
||||
let status: "online" | "offline" | "degraded" = "offline";
|
||||
if (err?.response?.status === 504) {
|
||||
status = "degraded";
|
||||
}
|
||||
setServerStatuses((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(host.id, status);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchStatus();
|
||||
const intervalId = setInterval(fetchStatus, 10000);
|
||||
statusIntervals.push(intervalId);
|
||||
});
|
||||
|
||||
return () => {
|
||||
statusCancelled.fill(true);
|
||||
statusIntervals.forEach((interval) => clearInterval(interval));
|
||||
};
|
||||
}, [hosts]);
|
||||
|
||||
const getFolderIcon = (folderName: string) => {
|
||||
const metadata = folderMetadata.get(folderName);
|
||||
if (!metadata?.icon) return Folder;
|
||||
@@ -403,6 +353,44 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const copyFullScreenUrl = (host: SSHHost, appType: string) => {
|
||||
const baseUrl = isElectron()
|
||||
? getConfiguredServerUrl() || window.location.origin
|
||||
: window.location.origin;
|
||||
const url = `${baseUrl}?view=${appType}&hostId=${host.id}`;
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(url).then(
|
||||
() => {
|
||||
toast.success(t("hosts.fullScreenUrlCopied"));
|
||||
},
|
||||
() => {
|
||||
fallbackCopyTextToClipboard(url);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
fallbackCopyTextToClipboard(url);
|
||||
}
|
||||
};
|
||||
|
||||
const fallbackCopyTextToClipboard = (text: string) => {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.left = "-999999px";
|
||||
textArea.style.top = "-999999px";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
toast.success(t("hosts.fullScreenUrlCopied"));
|
||||
} catch (err) {
|
||||
toast.error(t("hosts.failedToCopyUrl"));
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
};
|
||||
|
||||
const handleRemoveFromFolder = async (host: SSHHost) => {
|
||||
confirmWithToast(
|
||||
t("hosts.confirmRemoveFromFolder", {
|
||||
@@ -818,6 +806,31 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
return sortedGrouped;
|
||||
}, [filteredAndSortedHosts]);
|
||||
|
||||
const toggleFolderExpansion = useCallback((folderName: string) => {
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(folderName)) {
|
||||
next.delete(folderName);
|
||||
} else {
|
||||
next.add(folderName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getVisibleHosts = useCallback(
|
||||
(folderName: string, allHosts: SSHHost[]) => {
|
||||
if (
|
||||
expandedFolders.has(folderName) ||
|
||||
allHosts.length <= INITIAL_HOSTS_PER_FOLDER
|
||||
) {
|
||||
return allHosts;
|
||||
}
|
||||
return allHosts.slice(0, INITIAL_HOSTS_PER_FOLDER);
|
||||
},
|
||||
[expandedFolders],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
@@ -844,16 +857,16 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t("hosts.sshHosts")}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t("hosts.hostsCount", { count: 0 })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t("hosts.sshHosts")}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t("hosts.hostsCount", { count: 0 })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -868,10 +881,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
{importing ? t("hosts.importing") : t("hosts.importJson")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
className="max-w-sm bg-popover text-popover-foreground border border-border shadow-lg"
|
||||
>
|
||||
<TooltipContent side="bottom" className="max-w-sm">
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-sm">
|
||||
{t("hosts.importJsonTitle")}
|
||||
@@ -882,7 +892,93 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadSample}
|
||||
>
|
||||
{t("hosts.downloadSample")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
window.open("https://docs.termix.site/json-import", "_blank");
|
||||
}}
|
||||
>
|
||||
{t("hosts.formatGuide")}
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-6 bg-border mx-2" />
|
||||
|
||||
<Button onClick={fetchHosts} variant="outline" size="sm">
|
||||
{t("hosts.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
id="json-import-input"
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleJsonImport}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<div className="text-center">
|
||||
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{t("hosts.noHosts")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{t("hosts.noHostsMessage")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t("hosts.sshHosts")}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t("hosts.hostsCount", { count: filteredAndSortedHosts.length })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="relative"
|
||||
onClick={() =>
|
||||
document.getElementById("json-import-input")?.click()
|
||||
}
|
||||
disabled={importing}
|
||||
>
|
||||
{importing ? t("hosts.importing") : t("hosts.importJson")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-sm">
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-sm">
|
||||
{t("hosts.importJsonTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hosts.importJsonDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleDownloadSample}>
|
||||
{t("hosts.downloadSample")}
|
||||
@@ -914,217 +1010,133 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<div className="text-center">
|
||||
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">{t("hosts.noHosts")}</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{t("hosts.noHostsMessage")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("placeholders.searchHosts")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t("hosts.sshHosts")}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t("hosts.hostsCount", { count: filteredAndSortedHosts.length })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="relative"
|
||||
onClick={() =>
|
||||
document.getElementById("json-import-input")?.click()
|
||||
}
|
||||
disabled={importing}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="space-y-2 pb-20">
|
||||
{Object.entries(hostsByFolder).map(([folder, folderHosts]) => (
|
||||
<div
|
||||
key={folder}
|
||||
className={`border rounded-md transition-all duration-200 ${
|
||||
dragOverFolder === folder
|
||||
? "border-blue-500 bg-blue-500/10"
|
||||
: ""
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={(e) => handleDragEnter(e, folder)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, folder)}
|
||||
>
|
||||
<Accordion
|
||||
type="multiple"
|
||||
defaultValue={Object.keys(hostsByFolder)}
|
||||
>
|
||||
{importing ? t("hosts.importing") : t("hosts.importJson")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
className="max-w-sm bg-popover text-popover-foreground border border-border shadow-lg"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-sm">
|
||||
{t("hosts.importJsonTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hosts.importJsonDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleDownloadSample}>
|
||||
{t("hosts.downloadSample")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
window.open("https://docs.termix.site/json-import", "_blank");
|
||||
}}
|
||||
>
|
||||
{t("hosts.formatGuide")}
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-6 bg-border mx-2" />
|
||||
|
||||
<Button onClick={fetchHosts} variant="outline" size="sm">
|
||||
{t("hosts.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
id="json-import-input"
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleJsonImport}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("placeholders.searchHosts")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="space-y-2 pb-20">
|
||||
{Object.entries(hostsByFolder).map(([folder, folderHosts]) => (
|
||||
<div
|
||||
key={folder}
|
||||
className={`border rounded-md transition-all duration-200 ${
|
||||
dragOverFolder === folder
|
||||
? "border-blue-500 bg-blue-500/10"
|
||||
: ""
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={(e) => handleDragEnter(e, folder)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, folder)}
|
||||
>
|
||||
<Accordion
|
||||
type="multiple"
|
||||
defaultValue={Object.keys(hostsByFolder)}
|
||||
>
|
||||
<AccordionItem value={folder} className="border-none">
|
||||
<AccordionTrigger className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{(() => {
|
||||
const FolderIcon = getFolderIcon(folder);
|
||||
const folderColor = getFolderColor(folder);
|
||||
return (
|
||||
<FolderIcon
|
||||
className="h-4 w-4"
|
||||
style={
|
||||
folderColor ? { color: folderColor } : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{editingFolder === folder ? (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Input
|
||||
value={editingFolderName}
|
||||
onChange={(e) =>
|
||||
setEditingFolderName(e.target.value)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleFolderRename(folder);
|
||||
if (e.key === "Escape") cancelFolderEdit();
|
||||
}}
|
||||
className="h-6 text-sm px-2 flex-1"
|
||||
autoFocus
|
||||
disabled={operationLoading}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleFolderRename(folder);
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
cancelFolderEdit();
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="font-medium cursor-pointer hover:text-blue-400 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (folder !== t("hosts.uncategorized")) {
|
||||
startFolderEdit(folder);
|
||||
<AccordionItem value={folder} className="border-none">
|
||||
<AccordionTrigger className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{(() => {
|
||||
const FolderIcon = getFolderIcon(folder);
|
||||
const folderColor = getFolderColor(folder);
|
||||
return (
|
||||
<FolderIcon
|
||||
className="h-4 w-4"
|
||||
style={
|
||||
folderColor ? { color: folderColor } : undefined
|
||||
}
|
||||
}}
|
||||
title={
|
||||
folder !== t("hosts.uncategorized")
|
||||
? t("hosts.clickToRenameFolder")
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{editingFolder === folder ? (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{folder}
|
||||
</span>
|
||||
{folder !== t("hosts.uncategorized") && (
|
||||
<Input
|
||||
value={editingFolderName}
|
||||
onChange={(e) =>
|
||||
setEditingFolderName(e.target.value)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter")
|
||||
handleFolderRename(folder);
|
||||
if (e.key === "Escape") cancelFolderEdit();
|
||||
}}
|
||||
className="h-6 text-sm px-2 flex-1"
|
||||
autoFocus
|
||||
disabled={operationLoading}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startFolderEdit(folder);
|
||||
handleFolderRename(folder);
|
||||
}}
|
||||
className="h-4 w-4 p-0 opacity-50 hover:opacity-100 transition-opacity"
|
||||
title={t("hosts.renameFolder")}
|
||||
className="h-6 w-6 p-0"
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{folderHosts.length}
|
||||
</Badge>
|
||||
{folder !== t("hosts.uncategorized") && (
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<TooltipProvider>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
cancelFolderEdit();
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="font-medium cursor-pointer hover:text-blue-400 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (folder !== t("hosts.uncategorized")) {
|
||||
startFolderEdit(folder);
|
||||
}
|
||||
}}
|
||||
title={
|
||||
folder !== t("hosts.uncategorized")
|
||||
? t("hosts.clickToRenameFolder")
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{folder}
|
||||
</span>
|
||||
{folder !== t("hosts.uncategorized") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startFolderEdit(folder);
|
||||
}}
|
||||
className="h-4 w-4 p-0 opacity-50 hover:opacity-100 transition-opacity"
|
||||
title={t("hosts.renameFolder")}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{folderHosts.length}
|
||||
</Badge>
|
||||
{folder !== t("hosts.uncategorized") && (
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -1143,8 +1155,6 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
{t("hosts.editFolderAppearance")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -1163,16 +1173,14 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
{t("hosts.deleteAllHostsInFolder")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="p-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{folderHosts.map((host) => (
|
||||
<TooltipProvider key={host.id}>
|
||||
<Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="p-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{getVisibleHosts(folder, folderHosts).map((host) => (
|
||||
<Tooltip key={host.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
draggable
|
||||
@@ -1201,9 +1209,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
const shouldShowStatus =
|
||||
statsConfig.statusCheckEnabled !==
|
||||
false;
|
||||
const serverStatus =
|
||||
serverStatuses.get(host.id) ||
|
||||
"degraded";
|
||||
const serverStatus = getStatus(host.id);
|
||||
|
||||
return shouldShowStatus ? (
|
||||
<Status
|
||||
@@ -1350,6 +1356,99 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
<p>{t("hosts.cloneHostTooltip")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-indigo-500 hover:text-indigo-700 hover:bg-indigo-500/10"
|
||||
>
|
||||
<Link className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t("hosts.copyFullScreenUrl")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
{host.enableTerminal && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyFullScreenUrl(
|
||||
host,
|
||||
"terminal",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
{t("hosts.copyTerminalUrl")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableFileManager && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyFullScreenUrl(
|
||||
host,
|
||||
"file-manager",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 mr-2" />
|
||||
{t("hosts.copyFileManagerUrl")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableTunnel && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyFullScreenUrl(
|
||||
host,
|
||||
"tunnel",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ArrowDownUp className="h-4 w-4 mr-2" />
|
||||
{t("hosts.copyTunnelUrl")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyFullScreenUrl(
|
||||
host,
|
||||
"server-stats",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Server className="h-4 w-4 mr-2" />
|
||||
{t("hosts.copyServerStatsUrl")}
|
||||
</DropdownMenuItem>
|
||||
{host.enableDocker && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyFullScreenUrl(
|
||||
host,
|
||||
"docker",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Container className="h-4 w-4 mr-2" />
|
||||
{t("hosts.copyDockerUrl")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1575,36 +1674,54 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
))}
|
||||
</div>
|
||||
{folderHosts.length > INITIAL_HOSTS_PER_FOLDER && (
|
||||
<div className="flex justify-center mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toggleFolderExpansion(folder)}
|
||||
className="text-xs"
|
||||
>
|
||||
{expandedFolders.has(folder)
|
||||
? t("common.showLess")
|
||||
: t("common.showMore", {
|
||||
count:
|
||||
folderHosts.length -
|
||||
INITIAL_HOSTS_PER_FOLDER,
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{editingFolderAppearance && (
|
||||
<FolderEditDialog
|
||||
folderName={editingFolderAppearance}
|
||||
currentColor={getFolderColor(editingFolderAppearance)}
|
||||
currentIcon={folderMetadata.get(editingFolderAppearance)?.icon}
|
||||
open={editingFolderAppearance !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingFolderAppearance(null);
|
||||
}}
|
||||
onSave={async (color, icon) => {
|
||||
await handleSaveFolderAppearance(
|
||||
editingFolderAppearance,
|
||||
color,
|
||||
icon,
|
||||
);
|
||||
setEditingFolderAppearance(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{editingFolderAppearance && (
|
||||
<FolderEditDialog
|
||||
folderName={editingFolderAppearance}
|
||||
currentColor={getFolderColor(editingFolderAppearance)}
|
||||
currentIcon={folderMetadata.get(editingFolderAppearance)?.icon}
|
||||
open={editingFolderAppearance !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingFolderAppearance(null);
|
||||
}}
|
||||
onSave={async (color, icon) => {
|
||||
await handleSaveFolderAppearance(
|
||||
editingFolderAppearance,
|
||||
color,
|
||||
icon,
|
||||
);
|
||||
setEditingFolderAppearance(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -611,6 +611,132 @@ export function HostGeneralTab({
|
||||
</Tabs>
|
||||
<Separator className="my-6" />
|
||||
<Accordion type="multiple" className="w-full">
|
||||
<AccordionItem value="sidebar-customization">
|
||||
<AccordionTrigger>{t("hosts.sidebarCustomization")}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t("hosts.sidebarCustomizationDesc")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{form.watch("enableTerminal") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="showTerminalInSidebar"
|
||||
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.showTerminalInSidebar")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("hosts.showTerminalInSidebarDesc")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{form.watch("enableFileManager") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="showFileManagerInSidebar"
|
||||
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.showFileManagerInSidebar")}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t("hosts.showFileManagerInSidebarDesc")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{form.watch("enableTunnel") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="showTunnelInSidebar"
|
||||
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.showTunnelInSidebar")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("hosts.showTunnelInSidebarDesc")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{form.watch("enableDocker") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="showDockerInSidebar"
|
||||
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.showDockerInSidebar")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("hosts.showDockerInSidebarDesc")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="showServerStatsInSidebar"
|
||||
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.showServerStatsInSidebar")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("hosts.showServerStatsInSidebarDesc")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="advanced-auth">
|
||||
<AccordionTrigger>{t("hosts.advancedAuthSettings")}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
|
||||
@@ -239,6 +239,8 @@ export function HostStatisticsTab({
|
||||
"processes",
|
||||
"system",
|
||||
"login_stats",
|
||||
"ports",
|
||||
"firewall",
|
||||
] as const
|
||||
).map((widget) => (
|
||||
<div key={widget} className="flex items-center space-x-2">
|
||||
@@ -266,6 +268,9 @@ export function HostStatisticsTab({
|
||||
{widget === "system" && t("serverStats.systemInfo")}
|
||||
{widget === "login_stats" &&
|
||||
t("serverStats.loginStats")}
|
||||
{widget === "ports" && t("serverStats.ports.title")}
|
||||
{widget === "firewall" &&
|
||||
t("serverStats.firewall.title")}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -66,7 +66,12 @@ export function HostTunnelTab({
|
||||
brew install hudochenkov/sshpass/sshpass
|
||||
</code>
|
||||
</div>
|
||||
<div>• {t("hosts.windows")}</div>
|
||||
<div>
|
||||
• {t("hosts.windows")}{" "}
|
||||
<code className="bg-muted px-1 rounded inline">
|
||||
sudo apt install sshpass
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -96,7 +101,18 @@ export function HostTunnelTab({
|
||||
</code>{" "}
|
||||
{t("hosts.permitRootLoginYes")}
|
||||
</div>
|
||||
<div className="mt-2">{t("hosts.editSshConfig")}</div>
|
||||
<div className="mt-2">
|
||||
{t("hosts.editSshConfig")}{" "}
|
||||
<code className="bg-muted px-1 rounded inline">
|
||||
{t("hosts.sshConfigPath")}
|
||||
</code>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
{t("hosts.restartSshService")}{" "}
|
||||
<code className="bg-muted px-1 rounded inline">
|
||||
{t("hosts.restartSshCommand")}
|
||||
</code>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="mt-3 flex justify-between">
|
||||
@@ -142,6 +158,57 @@ export function HostTunnelTab({
|
||||
{t("hosts.remove")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`tunnelConnections.${index}.tunnelType`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.tunnelType")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex gap-6">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value="local"
|
||||
checked={field.value === "local"}
|
||||
onChange={() => field.onChange("local")}
|
||||
className="w-4 h-4 text-primary border-input focus:ring-ring"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{t("hosts.tunnelTypeLocal")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hosts.tunnelTypeLocalDesc")}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value="remote"
|
||||
checked={field.value === "remote"}
|
||||
onChange={() =>
|
||||
field.onChange("remote")
|
||||
}
|
||||
className="w-4 h-4 text-primary border-input focus:ring-ring"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{t("hosts.tunnelTypeRemote")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hosts.tunnelTypeRemoteDesc")}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -254,16 +321,29 @@ export function HostTunnelTab({
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{t("hosts.tunnelForwardDescription", {
|
||||
sourcePort:
|
||||
form.watch(
|
||||
`tunnelConnections.${index}.sourcePort`,
|
||||
) || "22",
|
||||
endpointPort:
|
||||
form.watch(
|
||||
`tunnelConnections.${index}.endpointPort`,
|
||||
) || "224",
|
||||
})}
|
||||
{form.watch(
|
||||
`tunnelConnections.${index}.tunnelType`,
|
||||
) === "local"
|
||||
? t("hosts.tunnelForwardDescriptionLocal", {
|
||||
sourcePort:
|
||||
form.watch(
|
||||
`tunnelConnections.${index}.sourcePort`,
|
||||
) || "22",
|
||||
endpointPort:
|
||||
form.watch(
|
||||
`tunnelConnections.${index}.endpointPort`,
|
||||
) || "224",
|
||||
})
|
||||
: t("hosts.tunnelForwardDescriptionRemote", {
|
||||
sourcePort:
|
||||
form.watch(
|
||||
`tunnelConnections.${index}.sourcePort`,
|
||||
) || "22",
|
||||
endpointPort:
|
||||
form.watch(
|
||||
`tunnelConnections.${index}.endpointPort`,
|
||||
) || "224",
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-12 gap-4 mt-4">
|
||||
@@ -337,6 +417,7 @@ export function HostTunnelTab({
|
||||
field.onChange([
|
||||
...field.value,
|
||||
{
|
||||
tunnelType: "remote",
|
||||
sourcePort: 22,
|
||||
endpointPort: 224,
|
||||
endpointHost: "",
|
||||
|
||||
@@ -728,7 +728,6 @@ export function SSHToolsSidebar({
|
||||
toast.success(t("snippets.executeSuccess", { name: snippet.name }));
|
||||
}
|
||||
|
||||
// Remove focus from any active element in the sidebar to prevent accidental re-execution
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
@@ -392,8 +392,40 @@ export function Auth({
|
||||
setResetStep("newPassword");
|
||||
toast.success(t("messages.codeVerified"));
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
toast.error(error?.response?.data?.error || t("errors.failedVerifyCode"));
|
||||
const error = err as {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
code?: string;
|
||||
remainingTime?: number;
|
||||
remainingAttempts?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
const errorCode = error?.response?.data?.code;
|
||||
const remainingTime = error?.response?.data?.remainingTime;
|
||||
const remainingAttempts = error?.response?.data?.remainingAttempts;
|
||||
|
||||
let errorMessage =
|
||||
error?.response?.data?.error || t("errors.failedVerifyCode");
|
||||
|
||||
if (errorCode === "RESET_CODE_RATE_LIMITED") {
|
||||
if (remainingTime) {
|
||||
errorMessage = t("errors.resetCodeRateLimitedWithTime", {
|
||||
time: remainingTime,
|
||||
});
|
||||
} else {
|
||||
errorMessage = t("errors.resetCodeRateLimited");
|
||||
}
|
||||
} else if (
|
||||
remainingAttempts !== undefined &&
|
||||
remainingAttempts <= 2 &&
|
||||
remainingAttempts > 0
|
||||
) {
|
||||
errorMessage = `${errorMessage} (${remainingAttempts} ${t("auth.attemptsRemaining")})`;
|
||||
}
|
||||
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
@@ -514,10 +546,20 @@ export function Auth({
|
||||
} catch (err: unknown) {
|
||||
const error = err as {
|
||||
message?: string;
|
||||
response?: { data?: { code?: string; error?: string } };
|
||||
response?: {
|
||||
data?: {
|
||||
code?: string;
|
||||
error?: string;
|
||||
remainingTime?: number;
|
||||
remainingAttempts?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
const errorCode = error?.response?.data?.code;
|
||||
const errorMessage =
|
||||
const remainingTime = error?.response?.data?.remainingTime;
|
||||
const remainingAttempts = error?.response?.data?.remainingAttempts;
|
||||
|
||||
let errorMessage =
|
||||
error?.response?.data?.error ||
|
||||
error?.message ||
|
||||
t("errors.invalidTotpCode");
|
||||
@@ -528,7 +570,23 @@ export function Auth({
|
||||
setTotpTempToken("");
|
||||
setTab("login");
|
||||
toast.error(t("errors.sessionExpired"));
|
||||
} else if (errorCode === "TOTP_RATE_LIMITED") {
|
||||
if (remainingTime) {
|
||||
errorMessage = t("errors.totpRateLimitedWithTime", {
|
||||
time: remainingTime,
|
||||
});
|
||||
} else {
|
||||
errorMessage = t("errors.totpRateLimited");
|
||||
}
|
||||
toast.error(errorMessage);
|
||||
} else {
|
||||
if (
|
||||
remainingAttempts !== undefined &&
|
||||
remainingAttempts <= 2 &&
|
||||
remainingAttempts > 0
|
||||
) {
|
||||
errorMessage = `${errorMessage} (${remainingAttempts} ${t("auth.attemptsRemaining")})`;
|
||||
}
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
@@ -744,12 +802,28 @@ export function Auth({
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className={`w-[420px] max-w-full p-6 flex flex-col bg-canvas border-2 border-edge rounded-md overflow-y-auto thin-scrollbar my-2 animate-in fade-in zoom-in-95 duration-300 ${className || ""}`}
|
||||
style={{ maxHeight: "calc(100vh - 1rem)" }}
|
||||
className={`fixed inset-0 flex items-center justify-center ${className || ""}`}
|
||||
style={{
|
||||
background: "var(--bg-elevated)",
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 35px,
|
||||
${lineColor} 35px,
|
||||
${lineColor} 37px
|
||||
)`,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<div className="w-[420px] max-w-full p-8 flex flex-col backdrop-blur-sm bg-card/50 rounded-2xl shadow-xl border-2 border-edge overflow-y-auto thin-scrollbar my-2 animate-in fade-in zoom-in-95 duration-300">
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="text-center">
|
||||
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
{t("common.checkingAuthentication")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -957,7 +1031,13 @@ export function Auth({
|
||||
</div>
|
||||
)}
|
||||
{!webviewAuthSuccess && totpRequired && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<form
|
||||
className="flex flex-col gap-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleTOTPVerification();
|
||||
}}
|
||||
>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold mb-1">
|
||||
{t("auth.twoFactorAuth")}
|
||||
@@ -987,10 +1067,9 @@ export function Auth({
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
type="submit"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={totpLoading || totpCode.length < 6}
|
||||
onClick={handleTOTPVerification}
|
||||
>
|
||||
{totpLoading ? Spinner : t("auth.verifyCode")}
|
||||
</Button>
|
||||
@@ -1008,7 +1087,7 @@ export function Auth({
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!webviewAuthSuccess &&
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ServerStats as ServerView } from "@/ui/desktop/apps/features/server-sta
|
||||
import { FileManager } from "@/ui/desktop/apps/features/file-manager/FileManager.tsx";
|
||||
import { TunnelManager } from "@/ui/desktop/apps/features/tunnel/TunnelManager.tsx";
|
||||
import { DockerManager } from "@/ui/desktop/apps/features/docker/DockerManager.tsx";
|
||||
import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
} from "@/constants/terminal-themes";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
|
||||
import { SSHAuthDialog } from "@/ui/desktop/navigation/dialogs/SSHAuthDialog.tsx";
|
||||
|
||||
interface TabData {
|
||||
id: number;
|
||||
@@ -70,7 +71,8 @@ export function AppView({
|
||||
tab.type === "server_stats" ||
|
||||
tab.type === "file_manager" ||
|
||||
tab.type === "tunnel" ||
|
||||
tab.type === "docker",
|
||||
tab.type === "docker" ||
|
||||
tab.type === "network_graph",
|
||||
),
|
||||
[tabs],
|
||||
);
|
||||
@@ -226,7 +228,8 @@ export function AppView({
|
||||
const isFileManagerTab =
|
||||
mainTab.type === "file_manager" ||
|
||||
mainTab.type === "tunnel" ||
|
||||
mainTab.type === "docker";
|
||||
mainTab.type === "docker" ||
|
||||
mainTab.type === "network_graph";
|
||||
const newStyle = {
|
||||
position: "absolute" as const,
|
||||
top: isFileManagerTab ? 0 : 4,
|
||||
@@ -278,7 +281,8 @@ export function AppView({
|
||||
const isFileManagerTab =
|
||||
t.type === "file_manager" ||
|
||||
t.type === "tunnel" ||
|
||||
t.type === "docker";
|
||||
t.type === "docker" ||
|
||||
t.type === "network_graph";
|
||||
const standardStyle = {
|
||||
position: "absolute" as const,
|
||||
top: isFileManagerTab ? 0 : 4,
|
||||
@@ -312,7 +316,7 @@ export function AppView({
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...(t.hostConfig as any)?.terminalConfig,
|
||||
};
|
||||
// Auto-switch between termixDark and termixLight based on app theme
|
||||
|
||||
let themeColors;
|
||||
if (terminalConfig.theme === "termix") {
|
||||
themeColors = isDarkMode
|
||||
@@ -353,6 +357,13 @@ export function AppView({
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
embedded
|
||||
/>
|
||||
) : t.type === "network_graph" ? (
|
||||
<NetworkGraphCard
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
rightSidebarWidth={rightSidebarWidth}
|
||||
embedded={false}
|
||||
/>
|
||||
) : t.type === "tunnel" ? (
|
||||
<TunnelManager
|
||||
hostConfig={t.hostConfig}
|
||||
|
||||
@@ -2,13 +2,14 @@ import React, { useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { ChevronDown, ChevronUpIcon, Hammer } from "lucide-react";
|
||||
import { ChevronDown, ChevronUpIcon, Hammer, Zap } from "lucide-react";
|
||||
import { Tab } from "@/ui/desktop/navigation/tabs/Tab.tsx";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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";
|
||||
|
||||
interface TabData {
|
||||
id: number;
|
||||
@@ -61,6 +62,7 @@ export function TopNavbar({
|
||||
const [toolsSidebarOpen, setToolsSidebarOpen] = useState(false);
|
||||
const [commandHistoryTabActive, setCommandHistoryTabActive] = useState(false);
|
||||
const [splitScreenTabActive, setSplitScreenTabActive] = useState(false);
|
||||
const [quickConnectOpen, setQuickConnectOpen] = useState(false);
|
||||
const [rightSidebarWidth, setRightSidebarWidth] = useState<number>(() => {
|
||||
const saved = localStorage.getItem("rightSidebarWidth");
|
||||
const defaultWidth = 400;
|
||||
@@ -382,7 +384,8 @@ export function TopNavbar({
|
||||
((tab.type === "home" ||
|
||||
tab.type === "ssh_manager" ||
|
||||
tab.type === "admin" ||
|
||||
tab.type === "user_profile") &&
|
||||
tab.type === "user_profile" ||
|
||||
tab.type === "network_graph") &&
|
||||
isSplitScreenActive);
|
||||
const isHome = tab.type === "home";
|
||||
const disableClose = isHome;
|
||||
@@ -491,7 +494,8 @@ export function TopNavbar({
|
||||
isDocker ||
|
||||
isSshManager ||
|
||||
isAdmin ||
|
||||
isUserProfile
|
||||
isUserProfile ||
|
||||
tab.type === "network_graph"
|
||||
? () => handleTabClose(tab.id)
|
||||
: undefined
|
||||
}
|
||||
@@ -507,13 +511,15 @@ export function TopNavbar({
|
||||
isDocker ||
|
||||
isSshManager ||
|
||||
isAdmin ||
|
||||
isUserProfile
|
||||
isUserProfile ||
|
||||
tab.type === "network_graph"
|
||||
}
|
||||
disableActivate={disableActivate}
|
||||
disableSplit={disableSplit}
|
||||
disableClose={disableClose}
|
||||
isDragging={isDraggingThisTab}
|
||||
isDragOver={false}
|
||||
hostConfig={tab.hostConfig}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -532,6 +538,15 @@ export function TopNavbar({
|
||||
<Hammer className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setQuickConnectOpen(true)}
|
||||
className="w-[30px] h-[30px] border-edge"
|
||||
title={t("quickConnect.title")}
|
||||
>
|
||||
<Zap className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsTopbarOpen(false)}
|
||||
@@ -578,6 +593,11 @@ export function TopNavbar({
|
||||
setSplitScreenTabActive(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<QuickConnectDialog
|
||||
open={quickConnectOpen}
|
||||
onOpenChange={setQuickConnectOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function SimpleLoader({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex items-center justify-center z-50",
|
||||
"absolute inset-0 flex items-center justify-center z-[100]",
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundColor: backgroundColor || "var(--bg-base)" }}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { useConnectionLog } from "./ConnectionLogContext.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
Info,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ConnectionLogProps {
|
||||
isConnecting: boolean;
|
||||
isConnected: boolean;
|
||||
hasConnectionError: boolean;
|
||||
position: "top" | "bottom";
|
||||
}
|
||||
|
||||
export function ConnectionLog({
|
||||
isConnecting,
|
||||
isConnected,
|
||||
hasConnectionError,
|
||||
position,
|
||||
}: ConnectionLogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { logs, clearLogs, isExpanded, toggleExpanded, setIsExpanded } =
|
||||
useConnectionLog();
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
const lastLogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasConnectionError) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [hasConnectionError, setIsExpanded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected && !hasConnectionError && !isConnecting) {
|
||||
clearLogs();
|
||||
}
|
||||
}, [isConnected, hasConnectionError, isConnecting, clearLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && lastLogRef.current) {
|
||||
lastLogRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [logs, isExpanded]);
|
||||
|
||||
const shouldShow =
|
||||
isConnecting || hasConnectionError || (logs.length > 0 && !isConnected);
|
||||
|
||||
if (!shouldShow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const copyLogsToClipboard = async () => {
|
||||
const logsText = logs
|
||||
.map((log) => {
|
||||
const time = log.timestamp.toLocaleTimeString();
|
||||
return `[${time}] [${log.type.toUpperCase()}] ${log.message}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(logsText);
|
||||
toast.success(t("terminal.connectionLogCopied"));
|
||||
} catch (error) {
|
||||
toast.error(t("terminal.connectionLogCopyFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "info":
|
||||
return <Info className="h-4 w-4 text-blue-500" />;
|
||||
case "success":
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
||||
case "warning":
|
||||
return <AlertTriangle className="h-4 w-4 text-yellow-500" />;
|
||||
case "error":
|
||||
return <XCircle className="h-4 w-4 text-red-500" />;
|
||||
default:
|
||||
return <Info className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTextColor = (type: string) => {
|
||||
switch (type) {
|
||||
case "info":
|
||||
return "text-blue-400";
|
||||
case "success":
|
||||
return "text-green-400";
|
||||
case "warning":
|
||||
return "text-yellow-400";
|
||||
case "error":
|
||||
return "text-red-400";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
};
|
||||
|
||||
const borderClass =
|
||||
position === "bottom" && !isExpanded
|
||||
? "border-t-2 border-border"
|
||||
: "border-b-2 border-border";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute inset-0 z-[110] flex flex-col ${isExpanded || hasConnectionError ? "pointer-events-auto" : "pointer-events-none"} ${position === "top" ? "justify-start" : "justify-end"}`}
|
||||
>
|
||||
{(isExpanded || hasConnectionError) && (
|
||||
<div className="absolute inset-0 bg-bg-base pointer-events-auto" />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`relative z-10 bg-bg-subtle pointer-events-auto ${isExpanded ? "flex flex-col h-full" : ""} ${!isExpanded ? borderClass : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleExpanded}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{t("terminal.connectionLogTitle")} ({logs.length})
|
||||
</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{logs.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={copyLogsToClipboard}
|
||||
title={t("terminal.connectionLogCopy")}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="flex-1 h-0 overflow-y-auto overflow-x-hidden thin-scrollbar border-t-2 border-border bg-bg-base"
|
||||
>
|
||||
<div className="px-3 py-2">
|
||||
{logs.length === 0 ? (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
{isConnecting
|
||||
? t("terminal.connectionLogConnecting")
|
||||
: t("terminal.connectionLogEmpty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 font-mono text-xs">
|
||||
{logs.map((log, index) => (
|
||||
<div
|
||||
key={log.id}
|
||||
ref={index === logs.length - 1 ? lastLogRef : null}
|
||||
className="flex items-start gap-2"
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{log.timestamp.toLocaleTimeString()}
|
||||
</span>
|
||||
<div className="shrink-0">{getIcon(log.type)}</div>
|
||||
<span
|
||||
className={`flex-1 min-w-0 break-all whitespace-pre-wrap ${getTextColor(
|
||||
log.type,
|
||||
)}`}
|
||||
>
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from "react";
|
||||
import type { LogEntry } from "@/types/connection-log.ts";
|
||||
|
||||
interface ConnectionLogContextType {
|
||||
logs: LogEntry[];
|
||||
addLog: (entry: Omit<LogEntry, "id" | "timestamp">) => void;
|
||||
setLogs: (entries: Omit<LogEntry, "id" | "timestamp">[]) => void;
|
||||
clearLogs: () => void;
|
||||
isExpanded: boolean;
|
||||
toggleExpanded: () => void;
|
||||
setIsExpanded: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const ConnectionLogContext = createContext<
|
||||
ConnectionLogContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
export function ConnectionLogProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [logs, setLogsState] = useState<LogEntry[]>([]);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const addLog = useCallback((entry: Omit<LogEntry, "id" | "timestamp">) => {
|
||||
const newLog: LogEntry = {
|
||||
...entry,
|
||||
id: `${Date.now()}-${Math.random()}`,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setLogsState((prev) => [...prev, newLog]);
|
||||
|
||||
if (entry.type === "error" || entry.type === "warning") {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setLogs = useCallback(
|
||||
(entries: Omit<LogEntry, "id" | "timestamp">[]) => {
|
||||
const newLogs = entries.map((entry, index) => ({
|
||||
...entry,
|
||||
id: `${Date.now()}-${index}-${Math.random()}`,
|
||||
timestamp: new Date(),
|
||||
}));
|
||||
setLogsState(newLogs);
|
||||
|
||||
if (entries.some((e) => e.type === "error" || e.type === "warning")) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearLogs = useCallback(() => {
|
||||
setLogsState([]);
|
||||
setIsExpanded(false);
|
||||
}, []);
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConnectionLogContext.Provider
|
||||
value={{
|
||||
logs,
|
||||
addLog,
|
||||
setLogs,
|
||||
clearLogs,
|
||||
isExpanded,
|
||||
toggleExpanded,
|
||||
setIsExpanded,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ConnectionLogContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConnectionLog() {
|
||||
const context = useContext(ConnectionLogContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useConnectionLog must be used within ConnectionLogProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@/components/ui/form.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs.tsx";
|
||||
import { Switch } from "@/components/ui/switch.tsx";
|
||||
import { CredentialSelector } from "@/ui/desktop/apps/host-manager/credentials/CredentialSelector.tsx";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import { quickConnect, getCredentials } from "@/ui/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { githubLight } from "@uiw/codemirror-theme-github";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { useTheme } from "@/components/theme-provider.tsx";
|
||||
import type { SSHHost, Credential } from "@/types";
|
||||
|
||||
interface QuickConnectDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const keyTypeOptions = [
|
||||
{ value: "auto", label: "Auto Detect" },
|
||||
{ value: "ssh-rsa", label: "RSA" },
|
||||
{ value: "ssh-ed25519", label: "Ed25519" },
|
||||
{ value: "ecdsa-sha2-nistp256", label: "ECDSA NIST P-256" },
|
||||
{ value: "ecdsa-sha2-nistp384", label: "ECDSA NIST P-384" },
|
||||
{ value: "ecdsa-sha2-nistp521", label: "ECDSA NIST P-521" },
|
||||
{ value: "ssh-dss", label: "DSA" },
|
||||
{ value: "ssh-rsa-sha2-256", label: "RSA SHA2-256" },
|
||||
{ value: "ssh-rsa-sha2-512", label: "RSA SHA2-512" },
|
||||
];
|
||||
|
||||
export function QuickConnectDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: QuickConnectDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme: appTheme } = useTheme();
|
||||
const { addTab, setCurrentTab } = useTabs();
|
||||
const [authTab, setAuthTab] = useState<"password" | "key" | "credential">(
|
||||
"password",
|
||||
);
|
||||
const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">(
|
||||
"upload",
|
||||
);
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false);
|
||||
const keyTypeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const keyTypeDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
|
||||
const isDarkMode =
|
||||
appTheme === "dark" ||
|
||||
(appTheme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const editorTheme = isDarkMode ? oneDark : githubLight;
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
ip: z.string().min(1, t("quickConnect.ipAddress")),
|
||||
port: z.coerce.number().min(1).max(65535).default(22),
|
||||
username: z.string().min(1, t("quickConnect.username")),
|
||||
authType: z.enum(["password", "key", "credential"]),
|
||||
password: z.string().optional(),
|
||||
key: z.any().optional(),
|
||||
keyPassword: z.string().optional(),
|
||||
keyType: z
|
||||
.enum([
|
||||
"auto",
|
||||
"ssh-rsa",
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"ssh-dss",
|
||||
"ssh-rsa-sha2-256",
|
||||
"ssh-rsa-sha2-512",
|
||||
])
|
||||
.optional(),
|
||||
credentialId: z.number().optional().nullable(),
|
||||
overrideCredentialUsername: z.boolean().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.authType === "password") {
|
||||
if (
|
||||
!data.password ||
|
||||
(typeof data.password === "string" && data.password.trim() === "")
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("quickConnect.passwordRequired"),
|
||||
path: ["password"],
|
||||
});
|
||||
}
|
||||
} else if (data.authType === "key") {
|
||||
if (
|
||||
!data.key ||
|
||||
(typeof data.key === "string" && data.key.trim() === "")
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("quickConnect.keyRequired"),
|
||||
path: ["key"],
|
||||
});
|
||||
}
|
||||
if (!data.keyType) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("hosts.keyTypeRequired"),
|
||||
path: ["keyType"],
|
||||
});
|
||||
}
|
||||
} else if (data.authType === "credential") {
|
||||
if (!data.credentialId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("quickConnect.credentialRequired"),
|
||||
path: ["credentialId"],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema) as any,
|
||||
mode: "all",
|
||||
defaultValues: {
|
||||
ip: "",
|
||||
port: 22,
|
||||
username: "",
|
||||
authType: "password" as const,
|
||||
password: "",
|
||||
key: null,
|
||||
keyPassword: "",
|
||||
keyType: "auto" as const,
|
||||
credentialId: null,
|
||||
overrideCredentialUsername: false,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCredentials = async () => {
|
||||
try {
|
||||
const data = await getCredentials();
|
||||
setCredentials((data as Credential[]) || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch credentials:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (open) {
|
||||
fetchCredentials();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
form.setValue("authType", authTab, { shouldValidate: true });
|
||||
|
||||
if (authTab === "password") {
|
||||
form.setValue("key", null, { shouldValidate: true });
|
||||
form.setValue("keyPassword", "", { shouldValidate: true });
|
||||
form.setValue("keyType", "auto", { shouldValidate: true });
|
||||
form.setValue("credentialId", null, { shouldValidate: true });
|
||||
} else if (authTab === "key") {
|
||||
form.setValue("password", "", { shouldValidate: true });
|
||||
form.setValue("credentialId", null, { shouldValidate: true });
|
||||
} else if (authTab === "credential") {
|
||||
form.setValue("password", "", { shouldValidate: true });
|
||||
form.setValue("key", null, { shouldValidate: true });
|
||||
form.setValue("keyPassword", "", { shouldValidate: true });
|
||||
form.setValue("keyType", "auto", { shouldValidate: true });
|
||||
}
|
||||
}, [authTab, form]);
|
||||
|
||||
useEffect(() => {
|
||||
function onClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
keyTypeDropdownOpen &&
|
||||
keyTypeDropdownRef.current &&
|
||||
!keyTypeDropdownRef.current.contains(event.target as Node) &&
|
||||
keyTypeButtonRef.current &&
|
||||
!keyTypeButtonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setKeyTypeDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", onClickOutside);
|
||||
return () => document.removeEventListener("mousedown", onClickOutside);
|
||||
}, [keyTypeDropdownOpen]);
|
||||
|
||||
const handleConnect = async (connectionType: "terminal" | "file_manager") => {
|
||||
const formData = form.getValues();
|
||||
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
|
||||
try {
|
||||
let keyContent: string | undefined;
|
||||
if (formData.authType === "key" && formData.key) {
|
||||
if (formData.key instanceof File) {
|
||||
keyContent = await formData.key.text();
|
||||
} else if (typeof formData.key === "string") {
|
||||
keyContent = formData.key;
|
||||
}
|
||||
}
|
||||
|
||||
const hostConfig = await quickConnect({
|
||||
ip: formData.ip,
|
||||
port: formData.port,
|
||||
username: formData.username,
|
||||
authType: formData.authType,
|
||||
password:
|
||||
formData.authType === "password" ? formData.password : undefined,
|
||||
key: formData.authType === "key" ? keyContent : undefined,
|
||||
keyPassword:
|
||||
formData.authType === "key" ? formData.keyPassword : undefined,
|
||||
keyType: formData.authType === "key" ? formData.keyType : undefined,
|
||||
credentialId:
|
||||
formData.authType === "credential"
|
||||
? formData.credentialId
|
||||
: undefined,
|
||||
overrideCredentialUsername:
|
||||
formData.authType === "credential"
|
||||
? formData.overrideCredentialUsername
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const tabId = addTab({
|
||||
type: connectionType,
|
||||
title: `${formData.username}@${formData.ip}:${formData.port}`,
|
||||
hostConfig: hostConfig as SSHHost,
|
||||
});
|
||||
setCurrentTab(tabId);
|
||||
|
||||
form.reset();
|
||||
setAuthTab("password");
|
||||
setKeyInputMethod("upload");
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Quick connect failed:", error);
|
||||
toast.error(
|
||||
t("quickConnect.connectionFailed") +
|
||||
": " +
|
||||
(error instanceof Error ? error.message : String(error)),
|
||||
);
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px] bg-canvas border-2 border-edge max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("quickConnect.title")}</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{t("quickConnect.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ip"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-8">
|
||||
<FormLabel className="text-base font-semibold text-foreground">
|
||||
{t("quickConnect.ipAddress")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("placeholders.ipAddress")}
|
||||
{...field}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel className="text-base font-semibold text-foreground">
|
||||
{t("quickConnect.port")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t("placeholders.port")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => {
|
||||
const isCredentialAuth = authTab === "credential";
|
||||
const hasCredential = !!form.watch("credentialId");
|
||||
const overrideEnabled = !!form.watch(
|
||||
"overrideCredentialUsername",
|
||||
);
|
||||
const shouldDisable =
|
||||
isCredentialAuth && hasCredential && !overrideEnabled;
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel className="text-base font-semibold text-foreground">
|
||||
{t("quickConnect.username")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("placeholders.username")}
|
||||
disabled={shouldDisable}
|
||||
{...field}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("quickConnect.authentication")}
|
||||
</Label>
|
||||
<Tabs
|
||||
value={authTab}
|
||||
onValueChange={(value) =>
|
||||
setAuthTab(value as "password" | "key" | "credential")
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="inline-flex items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
|
||||
<TabsTrigger value="password">
|
||||
{t("hosts.password")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="key">{t("hosts.key")}</TabsTrigger>
|
||||
<TabsTrigger value="credential">
|
||||
{t("quickConnect.credential")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="password" className="mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("quickConnect.password")}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
placeholder={t("placeholders.password")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="key" className="mt-4">
|
||||
<Tabs
|
||||
value={keyInputMethod}
|
||||
onValueChange={(value) =>
|
||||
setKeyInputMethod(value as "upload" | "paste")
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="inline-flex items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
|
||||
<TabsTrigger value="upload">
|
||||
{t("quickConnect.uploadFile")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="paste">
|
||||
{t("quickConnect.pasteKey")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="upload" className="mt-4">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="key"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mb-4">
|
||||
<FormLabel>{t("quickConnect.key")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative inline-block">
|
||||
<input
|
||||
id="key-upload"
|
||||
type="file"
|
||||
accept=".pem,.key,.txt,.ppk"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
field.onChange(file || null);
|
||||
}}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="justify-start text-left"
|
||||
>
|
||||
<span
|
||||
className="truncate"
|
||||
title={
|
||||
(field.value as File)?.name ||
|
||||
t("hosts.upload")
|
||||
}
|
||||
>
|
||||
{field.value
|
||||
? (field.value as File).name
|
||||
: t("hosts.upload")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="paste" className="mt-4">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="key"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mb-4">
|
||||
<FormLabel>{t("quickConnect.key")}</FormLabel>
|
||||
<FormControl>
|
||||
<CodeMirror
|
||||
value={
|
||||
typeof field.value === "string"
|
||||
? field.value
|
||||
: ""
|
||||
}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
placeholder={t("placeholders.pastePrivateKey")}
|
||||
theme={editorTheme}
|
||||
className="border border-input rounded-md overflow-hidden"
|
||||
minHeight="120px"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: false,
|
||||
dropCursor: false,
|
||||
allowMultipleSelections: false,
|
||||
highlightSelectionMatches: false,
|
||||
}}
|
||||
extensions={[
|
||||
EditorView.theme({
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
scrollbarWidth: "thin",
|
||||
scrollbarColor:
|
||||
"var(--scrollbar-thumb) var(--scrollbar-track)",
|
||||
},
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keyPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("quickConnect.keyPassword")}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
placeholder={t("placeholders.keyPassword")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keyType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="relative">
|
||||
<FormLabel>{t("quickConnect.keyType")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Button
|
||||
ref={keyTypeButtonRef}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left rounded-md px-2 py-2 bg-canvas border border-input text-foreground"
|
||||
onClick={() =>
|
||||
setKeyTypeDropdownOpen((open) => !open)
|
||||
}
|
||||
>
|
||||
{keyTypeOptions.find(
|
||||
(opt) => opt.value === field.value,
|
||||
)?.label || t("quickConnect.autoDetect")}
|
||||
</Button>
|
||||
{keyTypeDropdownOpen && (
|
||||
<div
|
||||
ref={keyTypeDropdownRef}
|
||||
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-canvas border border-input rounded-md shadow-lg max-h-40 overflow-y-auto thin-scrollbar p-1"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-1 p-0">
|
||||
{keyTypeOptions.map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-canvas text-foreground hover:bg-surface-hover focus:bg-surface-hover focus:outline-none"
|
||||
onClick={() => {
|
||||
field.onChange(opt.value);
|
||||
setKeyTypeDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="credential" className="mt-4">
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="credentialId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<CredentialSelector
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
onCredentialSelect={(credential) => {
|
||||
if (
|
||||
credential &&
|
||||
!form.getValues("overrideCredentialUsername")
|
||||
) {
|
||||
form.setValue("username", credential.username);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("credentialId") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="overrideCredentialUsername"
|
||||
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("quickConnect.overrideUsername")}
|
||||
</FormLabel>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("quickConnect.overrideUsernameDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleConnect("terminal")}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
{t("quickConnect.connectTerminal")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleConnect("file_manager")}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
{t("quickConnect.connectFileManager")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+12
-7
@@ -5,13 +5,18 @@ import {
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
} from "@/components/ui/card.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs.tsx";
|
||||
import { Shield, AlertCircle, Upload } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
+7
-5
@@ -31,11 +31,13 @@ export function TOTPDialog({
|
||||
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 flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("terminal.totpRequired")}
|
||||
</h3>
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("terminal.totpRequired")}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { useState } 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 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface WarpgateDialogProps {
|
||||
isOpen: boolean;
|
||||
url: string;
|
||||
securityKey: string;
|
||||
onContinue: () => void;
|
||||
onCancel: () => void;
|
||||
onOpenUrl: () => void;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export function WarpgateDialog({
|
||||
isOpen,
|
||||
url,
|
||||
securityKey,
|
||||
onContinue,
|
||||
onCancel,
|
||||
onOpenUrl,
|
||||
backgroundColor,
|
||||
}: WarpgateDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleCopyUrl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
toast.success(t("common.copied"));
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} 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
|
||||
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-xl w-full mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("terminal.warpgateAuthRequired")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-base font-semibold mb-2 block">
|
||||
{t("terminal.warpgateSecurityKey")}
|
||||
</Label>
|
||||
<div className="bg-base border-2 border-accent rounded-md p-4 text-center">
|
||||
<div className="text-3xl font-mono font-bold tracking-wider text-primary">
|
||||
{securityKey}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="warpgateUrl" className="text-base font-semibold">
|
||||
{t("terminal.warpgateAuthUrl")}
|
||||
</Label>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Input
|
||||
id="warpgateUrl"
|
||||
type="text"
|
||||
value={url}
|
||||
readOnly
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopyUrl}
|
||||
title={t("common.copy")}
|
||||
>
|
||||
<Copy className={`w-4 h-4 ${copied ? "text-success" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row 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.warpgateOpenBrowser")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onContinue}
|
||||
className="flex-1"
|
||||
>
|
||||
{t("terminal.warpgateContinue")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="sm:w-auto"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,18 +18,17 @@ import {
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
|
||||
import { getServerStatusById, getSSHHosts } from "@/ui/main-axios";
|
||||
import { getSSHHosts } 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";
|
||||
|
||||
export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
const { addTab } = useTabs();
|
||||
const [host, setHost] = useState(initialHost);
|
||||
const { t } = useTranslation();
|
||||
const [serverStatus, setServerStatus] = useState<
|
||||
"online" | "offline" | "degraded"
|
||||
>("degraded");
|
||||
const [showTags, setShowTags] = useState<boolean>(() => {
|
||||
const saved = localStorage.getItem("showHostTags");
|
||||
return saved !== null ? saved === "true" : true;
|
||||
@@ -83,6 +82,9 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
|
||||
const shouldShowMetrics = statsConfig.metricsEnabled !== false;
|
||||
|
||||
// Use shared status context instead of individual polling
|
||||
const serverStatus = useHostStatus(host.id, shouldShowStatus);
|
||||
|
||||
const hasTunnelConnections = useMemo(() => {
|
||||
if (!host.tunnelConnections) return false;
|
||||
try {
|
||||
@@ -95,49 +97,20 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
}
|
||||
}, [host.tunnelConnections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowStatus) {
|
||||
setServerStatus("offline");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await getServerStatusById(host.id);
|
||||
if (!cancelled) {
|
||||
setServerStatus(res?.status === "online" ? "online" : "offline");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!cancelled) {
|
||||
const err = error as { response?: { status?: number } };
|
||||
if (err?.response?.status === 503) {
|
||||
setServerStatus("offline");
|
||||
} else if (err?.response?.status === 504) {
|
||||
setServerStatus("degraded");
|
||||
} else if (err?.response?.status === 404) {
|
||||
setServerStatus("offline");
|
||||
} else {
|
||||
setServerStatus("offline");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchStatus();
|
||||
const intervalId = window.setInterval(fetchStatus, 10000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
};
|
||||
}, [host.id, shouldShowStatus]);
|
||||
|
||||
const handleTerminalClick = () => {
|
||||
addTab({ type: "terminal", title, hostConfig: host });
|
||||
};
|
||||
|
||||
const visibleButtons = [
|
||||
host.enableTerminal && (host.showTerminalInSidebar ?? true),
|
||||
host.enableFileManager && (host.showFileManagerInSidebar ?? false),
|
||||
host.enableTunnel &&
|
||||
hasTunnelConnections &&
|
||||
(host.showTunnelInSidebar ?? false),
|
||||
host.enableDocker && (host.showDockerInSidebar ?? false),
|
||||
shouldShowMetrics && (host.showServerStatsInSidebar ?? false),
|
||||
].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -155,7 +128,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
</p>
|
||||
|
||||
<ButtonGroup className="flex-shrink-0">
|
||||
{host.enableTerminal && (
|
||||
{host.enableTerminal && (host.showTerminalInSidebar ?? true) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-edge"
|
||||
@@ -165,13 +138,65 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{host.enableFileManager &&
|
||||
(host.showFileManagerInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-edge"
|
||||
onClick={() =>
|
||||
addTab({ type: "file_manager", title, hostConfig: host })
|
||||
}
|
||||
>
|
||||
<FolderOpen />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{host.enableTunnel &&
|
||||
hasTunnelConnections &&
|
||||
(host.showTunnelInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-edge"
|
||||
onClick={() =>
|
||||
addTab({ type: "tunnel", title, hostConfig: host })
|
||||
}
|
||||
>
|
||||
<ArrowDownUp />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{host.enableDocker && (host.showDockerInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-edge"
|
||||
onClick={() =>
|
||||
addTab({ type: "docker", title, hostConfig: host })
|
||||
}
|
||||
>
|
||||
<Container />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{shouldShowMetrics && (host.showServerStatsInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-edge"
|
||||
onClick={() =>
|
||||
addTab({ type: "server_stats", title, hostConfig: host })
|
||||
}
|
||||
>
|
||||
<Server />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`!px-2 border-1 border-edge ${
|
||||
host.enableTerminal ? "rounded-tl-none rounded-bl-none" : ""
|
||||
}`}
|
||||
className={cn(
|
||||
"!px-2 border-1 border-edge",
|
||||
visibleButtons > 0 && "rounded-l-none border-l-0",
|
||||
)}
|
||||
>
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
@@ -182,40 +207,53 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
side="right"
|
||||
className="w-56 bg-canvas border-edge text-foreground"
|
||||
>
|
||||
{shouldShowMetrics && (
|
||||
{host.enableTerminal && !(host.showTerminalInSidebar ?? true) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "server_stats", title, hostConfig: host })
|
||||
}
|
||||
onClick={handleTerminalClick}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="flex-1">{t("hosts.openServerStats")}</span>
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span className="flex-1">{t("hosts.openTerminal")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableFileManager && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "file_manager", title, hostConfig: host })
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="flex-1">{t("hosts.openFileManager")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableTunnel && hasTunnelConnections && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "tunnel", title, hostConfig: host })
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<ArrowDownUp className="h-4 w-4" />
|
||||
<span className="flex-1">{t("hosts.openTunnels")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableDocker && (
|
||||
{shouldShowMetrics &&
|
||||
!(host.showServerStatsInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "server_stats", title, hostConfig: host })
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="flex-1">{t("hosts.openServerStats")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableFileManager &&
|
||||
!(host.showFileManagerInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "file_manager", title, hostConfig: host })
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="flex-1">{t("hosts.openFileManager")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableTunnel &&
|
||||
hasTunnelConnections &&
|
||||
!(host.showTunnelInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "tunnel", title, hostConfig: host })
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<ArrowDownUp className="h-4 w-4" />
|
||||
<span className="flex-1">{t("hosts.openTunnels")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableDocker && !(host.showDockerInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "docker", title, hostConfig: host })
|
||||
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
Server as ServerIcon,
|
||||
Folder as FolderIcon,
|
||||
User as UserIcon,
|
||||
Network,
|
||||
ArrowDownUp as TunnelIcon,
|
||||
Container as DockerIcon,
|
||||
Key,
|
||||
} from "lucide-react";
|
||||
import type { SSHHost } from "@/types";
|
||||
|
||||
interface TabProps {
|
||||
tabType: string;
|
||||
@@ -31,6 +34,7 @@ interface TabProps {
|
||||
isDragOver?: boolean;
|
||||
isValidDropTarget?: boolean;
|
||||
isHoveredDropTarget?: boolean;
|
||||
hostConfig?: SSHHost;
|
||||
}
|
||||
|
||||
export function Tab({
|
||||
@@ -50,9 +54,56 @@ export function Tab({
|
||||
isDragOver = false,
|
||||
isValidDropTarget = false,
|
||||
isHoveredDropTarget = false,
|
||||
hostConfig,
|
||||
}: TabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleCopyPassword = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!hostConfig) return;
|
||||
|
||||
const hasSshPassword =
|
||||
hostConfig.authType === "password" && hostConfig.password;
|
||||
const hasSudoPassword = hostConfig.sudoPassword;
|
||||
|
||||
if (!hasSshPassword && !hasSudoPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let passwordToCopy = "";
|
||||
|
||||
if (hasSshPassword) {
|
||||
passwordToCopy = hostConfig.password || "";
|
||||
} else if (hasSudoPassword) {
|
||||
passwordToCopy = hostConfig.sudoPassword;
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(passwordToCopy);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const hasPassword =
|
||||
hostConfig &&
|
||||
((hostConfig.authType === "password" && hostConfig.password) ||
|
||||
hostConfig.sudoPassword);
|
||||
|
||||
const getPasswordButtonTitle = () => {
|
||||
if (!hostConfig) return "";
|
||||
|
||||
const hasSshPassword =
|
||||
hostConfig.authType === "password" && hostConfig.password;
|
||||
const hasSudoPassword = hostConfig.sudoPassword;
|
||||
|
||||
if (hasSshPassword) {
|
||||
return t("nav.copyPassword");
|
||||
} else if (hasSudoPassword) {
|
||||
return t("nav.copySudoPassword");
|
||||
}
|
||||
return t("nav.noPasswordAvailable");
|
||||
};
|
||||
|
||||
const tabBaseClasses = cn(
|
||||
"relative flex items-center gap-1.5 px-3 w-full min-w-0",
|
||||
"rounded-t-lg border-t-2 border-l-2 border-r-2",
|
||||
@@ -175,6 +226,18 @@ export function Tab({
|
||||
{suffix && <span className="text-sm flex-shrink-0">{suffix}</span>}
|
||||
</div>
|
||||
|
||||
{hasPassword && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={handleCopyPassword}
|
||||
title={getPasswordButtonTitle()}
|
||||
>
|
||||
<Key className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canSplit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -288,5 +351,42 @@ export function Tab({
|
||||
);
|
||||
}
|
||||
|
||||
if (tabType === "network_graph") {
|
||||
const displayTitle = title || t("dashboard.networkGraph");
|
||||
const { base, suffix } = splitTitle(displayTitle);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(tabBaseClasses, "cursor-pointer")}
|
||||
onClick={!disableActivate ? onActivate : undefined}
|
||||
style={{
|
||||
marginBottom: "-2px",
|
||||
borderBottom: isActive ? "2px solid var(--foreground)" : "none",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
<Network className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate text-sm flex-1 min-w-0">{base}</span>
|
||||
{suffix && <span className="text-sm flex-shrink-0">{suffix}</span>}
|
||||
</div>
|
||||
|
||||
{canClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-6 w-6", disableClose && "opacity-50")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!disableClose && onClose) onClose();
|
||||
}}
|
||||
disabled={disableClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -17,12 +17,14 @@ import {
|
||||
Shield as AdminIcon,
|
||||
Network as SshManagerIcon,
|
||||
User as UserIcon,
|
||||
Network,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useTabs, type Tab } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function TabDropdown(): React.ReactElement {
|
||||
const { tabs, currentTab, setCurrentTab } = useTabs();
|
||||
const { tabs, currentTab, setCurrentTab, removeTab } = useTabs();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getTabIcon = (tabType: Tab["type"]) => {
|
||||
@@ -45,6 +47,8 @@ export function TabDropdown(): React.ReactElement {
|
||||
return <SshManagerIcon className="h-4 w-4" />;
|
||||
case "admin":
|
||||
return <AdminIcon className="h-4 w-4" />;
|
||||
case "network_graph":
|
||||
return <Network className="h-4 w-4" />;
|
||||
default:
|
||||
return <TerminalIcon className="h-4 w-4" />;
|
||||
}
|
||||
@@ -68,6 +72,8 @@ export function TabDropdown(): React.ReactElement {
|
||||
return tab.title || t("nav.sshManager");
|
||||
case "admin":
|
||||
return tab.title || t("nav.admin");
|
||||
case "network_graph":
|
||||
return tab.title || t("dashboard.networkGraph");
|
||||
case "terminal":
|
||||
default:
|
||||
return tab.title || t("nav.terminal");
|
||||
@@ -78,6 +84,16 @@ export function TabDropdown(): React.ReactElement {
|
||||
setCurrentTab(tabId);
|
||||
};
|
||||
|
||||
const handleCloseTab = (
|
||||
e: React.MouseEvent,
|
||||
tabId: number,
|
||||
tabType: Tab["type"],
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
if (tabType === "home") return;
|
||||
removeTab(tabId);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -95,6 +111,7 @@ export function TabDropdown(): React.ReactElement {
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === currentTab;
|
||||
const canClose = tab.type !== "home";
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={tab.id}
|
||||
@@ -107,8 +124,14 @@ export function TabDropdown(): React.ReactElement {
|
||||
>
|
||||
{getTabIcon(tab.type)}
|
||||
<span className="flex-1 truncate">{getTabDisplayTitle(tab)}</span>
|
||||
{isActive && (
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" />
|
||||
{canClose && (
|
||||
<button
|
||||
onClick={(e) => handleCloseTab(e, tab.id, tab.type)}
|
||||
className="ml-1 p-0.5 rounded hover:bg-hover-secondary flex-shrink-0 transition-colors"
|
||||
title={t("nav.closeTab", { defaultValue: "Close tab" })}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
@@ -30,6 +30,12 @@ export function ElectronVersionCheck({
|
||||
const lineColor = isDarkMode ? "#151517" : "#f9f9f9";
|
||||
|
||||
useEffect(() => {
|
||||
const updateCheckDisabled =
|
||||
localStorage.getItem("disableUpdateCheck") === "true";
|
||||
if (updateCheckDisabled) {
|
||||
onContinue();
|
||||
return;
|
||||
}
|
||||
if (isElectron()) {
|
||||
checkForUpdates();
|
||||
} else {
|
||||
|
||||
@@ -11,20 +11,27 @@ import { Globe } from "lucide-react";
|
||||
|
||||
const languages = [
|
||||
{ code: "en", name: "English", nativeName: "English" },
|
||||
{ code: "af", name: "Afrikaans", nativeName: "Afrikaans" },
|
||||
{ code: "ar", name: "Arabic", nativeName: "العربية" },
|
||||
{ code: "bn", name: "Bengali", nativeName: "বাংলা" },
|
||||
{ code: "bg", name: "Bulgarian", nativeName: "Български" },
|
||||
{ code: "ca", name: "Catalan", nativeName: "Català" },
|
||||
{ code: "zh", name: "Chinese", nativeName: "中文" },
|
||||
{ code: "cs", name: "Czech", nativeName: "Čeština" },
|
||||
{ code: "da", name: "Danish", nativeName: "Dansk" },
|
||||
{ code: "nl", name: "Dutch", nativeName: "Nederlands" },
|
||||
{ code: "fi", name: "Finnish", nativeName: "Suomi" },
|
||||
{ code: "fr", name: "French", nativeName: "Français" },
|
||||
{ code: "de", name: "German", nativeName: "Deutsch" },
|
||||
{ code: "el", name: "Greek", nativeName: "Ελληνικά" },
|
||||
{ code: "he", name: "Hebrew", nativeName: "עברית" },
|
||||
{ code: "hi", name: "Hindi", nativeName: "हिन्दी" },
|
||||
{ code: "hu", name: "Hungarian", nativeName: "Magyar" },
|
||||
{ code: "id", name: "Indonesian", nativeName: "Bahasa Indonesia" },
|
||||
{ code: "it", name: "Italian", nativeName: "Italiano" },
|
||||
{ code: "ja", name: "Japanese", nativeName: "日本語" },
|
||||
{ code: "ko", name: "Korean", nativeName: "한국어" },
|
||||
{ code: "no", name: "Norwegian", nativeName: "Norsk" },
|
||||
{ code: "pl", name: "Polish", nativeName: "Polski" },
|
||||
{
|
||||
code: "pt",
|
||||
@@ -33,13 +40,13 @@ const languages = [
|
||||
},
|
||||
{ code: "ro", name: "Romanian", nativeName: "Română" },
|
||||
{ code: "ru", name: "Russian", nativeName: "Русский" },
|
||||
{ code: "sr", name: "Serbian", nativeName: "Српски" },
|
||||
{ code: "es", name: "Spanish", nativeName: "Español" },
|
||||
{ code: "sv", name: "Swedish", nativeName: "Svenska" },
|
||||
{ code: "th", name: "Thai", nativeName: "ไทย" },
|
||||
{ code: "tr", name: "Turkish", nativeName: "Türkçe" },
|
||||
{ code: "uk", name: "Ukrainian", nativeName: "Українська" },
|
||||
{ code: "vi", name: "Vietnamese", nativeName: "Tiếng Việt" },
|
||||
{ code: "nb", name: "Norwegian", nativeName: "Norsk" },
|
||||
];
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
|
||||
@@ -133,6 +133,14 @@ export function UserProfile({
|
||||
const saved = localStorage.getItem("showHostTags");
|
||||
return saved !== null ? saved === "true" : true;
|
||||
});
|
||||
const [disableUpdateCheck, setDisableUpdateCheck] = useState<boolean>(
|
||||
localStorage.getItem("disableUpdateCheck") === "true",
|
||||
);
|
||||
const [commandPaletteShortcutEnabled, setCommandPaletteShortcutEnabled] =
|
||||
useState<boolean>(() => {
|
||||
const saved = localStorage.getItem("commandPaletteShortcutEnabled");
|
||||
return saved !== null ? saved === "true" : true;
|
||||
});
|
||||
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -212,6 +220,16 @@ export function UserProfile({
|
||||
window.dispatchEvent(new Event("showHostTagsChanged"));
|
||||
};
|
||||
|
||||
const handleDisableUpdateCheckToggle = (enabled: boolean) => {
|
||||
setDisableUpdateCheck(enabled);
|
||||
localStorage.setItem("disableUpdateCheck", enabled.toString());
|
||||
};
|
||||
|
||||
const handleCommandPaletteShortcutToggle = (enabled: boolean) => {
|
||||
setCommandPaletteShortcutEnabled(enabled);
|
||||
localStorage.setItem("commandPaletteShortcutEnabled", enabled.toString());
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setDeleteLoading(true);
|
||||
@@ -565,6 +583,20 @@ export function UserProfile({
|
||||
onCheckedChange={handleTerminalSyntaxHighlightingToggle}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.enableCommandPaletteShortcut")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.enableCommandPaletteShortcutDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={commandPaletteShortcutEnabled}
|
||||
onCheckedChange={handleCommandPaletteShortcutToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -613,6 +645,28 @@ export function UserProfile({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-edge bg-elevated p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.updateSettings")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.disableUpdateCheck")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.disableUpdateCheckDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={disableUpdateCheck}
|
||||
onCheckedChange={handleDisableUpdateCheckToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="space-y-4">
|
||||
|
||||
Reference in New Issue
Block a user