* 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:
Luke Gustafson
2026-01-24 19:49:42 -06:00
committed by GitHub
parent 366667ca92
commit 536dc35258
169 changed files with 79905 additions and 47362 deletions
+206
View File
@@ -0,0 +1,206 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
useRef,
} from "react";
import { getAllServerStatuses, getSSHHosts } from "@/ui/main-axios";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
type StatusValue = "online" | "offline" | "degraded";
interface ServerStatusEntry {
status: StatusValue;
lastChecked: string;
}
interface ServerStatusContextType {
statuses: Map<number, ServerStatusEntry>;
isLoading: boolean;
refreshStatuses: () => Promise<void>;
getStatus: (hostId: number) => StatusValue;
}
const ServerStatusContext = createContext<ServerStatusContextType | null>(null);
const POLL_INTERVAL = 30000;
export function ServerStatusProvider({
children,
isAuthenticated = false,
}: {
children: React.ReactNode;
isAuthenticated?: boolean;
}) {
const [statuses, setStatuses] = useState<Map<number, ServerStatusEntry>>(
new Map(),
);
const [isLoading, setIsLoading] = useState(false);
const [enabledHostIds, setEnabledHostIds] = useState<Set<number>>(new Set());
const mountedRef = useRef(true);
const enabledHostIdsRef = useRef(enabledHostIds);
useEffect(() => {
enabledHostIdsRef.current = enabledHostIds;
}, [enabledHostIds]);
const fetchEnabledHosts = useCallback(async () => {
if (!isAuthenticated) {
return new Set<number>();
}
try {
const hosts = await getSSHHosts();
const enabled = new Set<number>();
hosts.forEach((host) => {
const statsConfig = (() => {
try {
return host.statsConfig
? JSON.parse(host.statsConfig)
: DEFAULT_STATS_CONFIG;
} catch {
return DEFAULT_STATS_CONFIG;
}
})();
if (statsConfig.statusCheckEnabled !== false) {
enabled.add(host.id);
}
});
setEnabledHostIds(enabled);
return enabled;
} catch (error) {
return new Set<number>();
}
}, [isAuthenticated]);
const refreshStatuses = useCallback(async () => {
if (!mountedRef.current || !isAuthenticated) return;
setIsLoading(true);
try {
const data = await getAllServerStatuses();
if (!mountedRef.current) return;
const newStatuses = new Map<number, ServerStatusEntry>();
const now = new Date().toISOString();
if (data && typeof data === "object") {
Object.entries(data).forEach(([idStr, statusData]) => {
const id = parseInt(idStr, 10);
if (!isNaN(id)) {
const status =
statusData?.status === "online" ? "online" : "offline";
newStatuses.set(id, {
status,
lastChecked: statusData?.lastChecked || now,
});
}
});
}
setStatuses(newStatuses);
} catch (error) {
if (mountedRef.current) {
setStatuses((prev) => {
const updated = new Map(prev);
enabledHostIdsRef.current.forEach((id) => {
const existing = updated.get(id);
updated.set(id, {
status: "degraded",
lastChecked: existing?.lastChecked || new Date().toISOString(),
});
});
return updated;
});
}
} finally {
if (mountedRef.current) {
setIsLoading(false);
}
}
}, [isAuthenticated]);
const getStatus = useCallback(
(hostId: number): StatusValue => {
if (!enabledHostIds.has(hostId)) {
return "offline";
}
return statuses.get(hostId)?.status || "degraded";
},
[statuses, enabledHostIds],
);
useEffect(() => {
mountedRef.current = true;
const init = async () => {
await fetchEnabledHosts();
await refreshStatuses();
};
init();
const intervalId = setInterval(refreshStatuses, POLL_INTERVAL);
return () => {
mountedRef.current = false;
clearInterval(intervalId);
};
}, [fetchEnabledHosts, refreshStatuses]);
useEffect(() => {
const handleHostsChanged = async () => {
await fetchEnabledHosts();
await refreshStatuses();
};
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
window.addEventListener("hosts:refresh", handleHostsChanged);
return () => {
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
window.removeEventListener("hosts:refresh", handleHostsChanged);
};
}, [fetchEnabledHosts, refreshStatuses]);
return (
<ServerStatusContext.Provider
value={{
statuses,
isLoading,
refreshStatuses,
getStatus,
}}
>
{children}
</ServerStatusContext.Provider>
);
}
export function useServerStatus() {
const context = useContext(ServerStatusContext);
if (!context) {
throw new Error(
"useServerStatus must be used within a ServerStatusProvider",
);
}
return context;
}
export function useHostStatus(
hostId: number,
statusCheckEnabled: boolean = true,
) {
const { getStatus } = useServerStatus();
if (!statusCheckEnabled) {
return "offline" as StatusValue;
}
return getStatus(hostId);
}
+138 -15
View File
@@ -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>
);
};
+12
View File
@@ -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;
+26 -1
View File
@@ -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>
);
})}
+183 -381
View File
@@ -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();
}
+91 -12
View File
@@ -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 &&
+16 -5
View File
@@ -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}
+24 -4
View File
@@ -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>
);
}
@@ -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";
@@ -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>
);
}
+114 -76
View File
@@ -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 })
+100
View File
@@ -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;
}
+26 -3
View File
@@ -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 {
+8 -1
View File
@@ -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() {
+54
View File
@@ -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">
+356 -36
View File
@@ -64,6 +64,7 @@ import {
dashboardLogger,
type LogContext,
} from "../lib/frontend-logger.js";
import { dbHealthMonitor } from "../lib/db-health-monitor.js";
interface FileManagerOperation {
name: string;
@@ -78,6 +79,10 @@ export type ServerStatus = {
lastChecked: string;
};
export type SSHHostWithStatus = SSHHost & {
status: "online" | "offline" | "unknown";
};
interface CpuMetrics {
percent: number | null;
cores: number | null;
@@ -113,6 +118,8 @@ interface AuthResponse {
is_oidc?: boolean;
totp_enabled?: boolean;
data_unlocked?: boolean;
requires_totp?: boolean;
temp_token?: string;
}
interface UserInfo {
@@ -268,6 +275,8 @@ export function getCookie(name: string): string | undefined {
}
}
let userWasAuthenticated = false;
function createApiInstance(
baseURL: string,
serviceName: string = "API",
@@ -279,12 +288,12 @@ function createApiInstance(
withCredentials: true,
});
instance.interceptors.request.use((config) => {
instance.interceptors.request.use((config: AxiosRequestConfig) => {
const startTime = performance.now();
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
(config as Record<string, unknown>).startTime = startTime;
(config as Record<string, unknown>).requestId = requestId;
(config as any).startTime = startTime;
(config as any).requestId = requestId;
const method = config.method?.toUpperCase() || "UNKNOWN";
const url = config.url || "UNKNOWN";
@@ -309,6 +318,7 @@ function createApiInstance(
const token = localStorage.getItem("jwt");
if (token) {
config.headers["Authorization"] = `Bearer ${token}`;
userWasAuthenticated = true;
}
}
@@ -328,15 +338,24 @@ function createApiInstance(
config.headers["User-Agent"] = `Termix-Mobile/${platform}`;
}
if (!isElectron()) {
const token = document.cookie
.split("; ")
.find((row) => row.startsWith("jwt="));
if (token) {
userWasAuthenticated = true;
}
}
return config;
});
instance.interceptors.response.use(
(response) => {
(response: AxiosResponse) => {
const endTime = performance.now();
const startTime = (response.config as Record<string, unknown>).startTime;
const requestId = (response.config as Record<string, unknown>).requestId;
const responseTime = Math.round(endTime - startTime);
const startTime = (response.config as any).startTime;
const requestId = (response.config as any).requestId;
const responseTime = Math.round(endTime - (startTime || endTime));
const method = response.config.method?.toUpperCase() || "UNKNOWN";
const url = response.config.url || "UNKNOWN";
@@ -368,14 +387,14 @@ function createApiInstance(
logger.warn(`🐌 Slow request: ${responseTime}ms`, context);
}
dbHealthMonitor.reportDatabaseSuccess();
return response;
},
(error: AxiosError) => {
(error: AxiosErrorExtended) => {
const endTime = performance.now();
const startTime = (error.config as Record<string, unknown> | undefined)
?.startTime;
const requestId = (error.config as Record<string, unknown> | undefined)
?.requestId;
const startTime = error.config?.startTime;
const requestId = error.config?.requestId;
const responseTime = startTime
? Math.round(endTime - startTime)
: undefined;
@@ -385,11 +404,11 @@ function createApiInstance(
const fullUrl = error.config ? `${error.config.baseURL}${url}` : url;
const status = error.response?.status;
const message =
(error.response?.data as Record<string, unknown>)?.error ||
(error.response?.data as { error?: string })?.error ||
(error as Error).message ||
"Unknown error";
const errorCode =
(error.response?.data as Record<string, unknown>)?.code || error.code;
(error.response?.data as { code?: string })?.code || error.code;
const context: LogContext = {
requestId,
@@ -431,9 +450,12 @@ function createApiInstance(
const isInvalidToken =
errorCode === "AUTH_REQUIRED" ||
errorMessage === "Invalid token" ||
errorMessage === "Authentication required";
errorMessage === "Authentication required" ||
errorMessage === "Missing authentication token";
if (isSessionExpired || isSessionNotFound || isInvalidToken) {
const wasAuthenticated = userWasAuthenticated;
localStorage.removeItem("jwt");
if (isElectron()) {
@@ -449,7 +471,14 @@ function createApiInstance(
console.warn("Session expired - please log in again");
toast.warning("Session expired. Please log in again.");
}
dbHealthMonitor.reportDatabaseError(error, wasAuthenticated);
userWasAuthenticated = false;
}
} else {
const wasAuthenticated = !!localStorage.getItem("jwt");
dbHealthMonitor.reportDatabaseError(error, wasAuthenticated);
}
return Promise.reject(error);
@@ -486,6 +515,19 @@ export interface ServerConfig {
lastUpdated: string;
}
interface AxiosRequestConfigExtended extends AxiosRequestConfig {
startTime?: number;
requestId?: string;
}
interface AxiosResponseExtended extends AxiosResponse {
config: AxiosRequestConfigExtended;
}
interface AxiosErrorExtended extends AxiosError {
config?: AxiosRequestConfigExtended;
}
export async function getServerConfig(): Promise<ServerConfig | null> {
if (!isElectron()) return null;
@@ -537,6 +579,23 @@ export async function saveServerConfig(config: ServerConfig): Promise<boolean> {
}
}
export function getConfiguredServerUrl(): string | null {
return configuredServerUrl;
}
interface AxiosRequestConfigExtended extends AxiosRequestConfig {
startTime?: number;
requestId?: string;
}
interface AxiosResponseExtended extends AxiosResponse {
config: AxiosRequestConfigExtended;
}
interface AxiosErrorExtended extends AxiosError {
config?: AxiosRequestConfigExtended;
}
export async function testServerConnection(
serverUrl: string,
): Promise<{ success: boolean; error?: string }> {
@@ -635,8 +694,8 @@ function initializeApiInstances() {
// Authentication API (port 30001)
authApi = createApiInstance(getApiUrl("", 30001), "AUTH");
// Homepage API (port 30006)
homepageApi = createApiInstance(getApiUrl("", 30006), "HOMEPAGE");
// Dashboard API (port 30006)
dashboardApi = createApiInstance(getApiUrl("", 30006), "DASHBOARD");
// RBAC API (port 30001)
rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC");
@@ -660,8 +719,8 @@ export let statsApi: AxiosInstance;
// Authentication API (port 30001)
export let authApi: AxiosInstance;
// Homepage API (port 30006)
export let homepageApi: AxiosInstance;
// Dashboard API (port 30006)
export let dashboardApi: AxiosInstance;
// RBAC API (port 30001)
export let rbacApi: AxiosInstance;
@@ -881,12 +940,20 @@ function handleApiError(error: unknown, operation: string): never {
// SSH HOST MANAGEMENT
// ============================================================================
export async function getSSHHosts(): Promise<SSHHost[]> {
export async function getSSHHosts(): Promise<SSHHostWithStatus[]> {
try {
const response = await sshHostApi.get("/db/host");
return response.data;
const hostsResponse = await sshHostApi.get("/db/host");
const hosts: SSHHost[] = hostsResponse.data;
const statusesResponse = await getAllServerStatuses();
const statuses = statusesResponse || {};
return hosts.map((host) => ({
...host,
status: statuses[host.id]?.status || "unknown",
}));
} catch (error) {
handleApiError(error, "fetch SSH hosts");
throw handleApiError(error, "fetch SSH hosts");
}
}
@@ -912,6 +979,11 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
enableTunnel: Boolean(hostData.enableTunnel),
enableFileManager: Boolean(hostData.enableFileManager),
enableDocker: Boolean(hostData.enableDocker),
showTerminalInSidebar: Boolean(hostData.showTerminalInSidebar),
showFileManagerInSidebar: Boolean(hostData.showFileManagerInSidebar),
showTunnelInSidebar: Boolean(hostData.showTunnelInSidebar),
showDockerInSidebar: Boolean(hostData.showDockerInSidebar),
showServerStatsInSidebar: Boolean(hostData.showServerStatsInSidebar),
defaultPath: hostData.defaultPath || "/",
tunnelConnections: hostData.tunnelConnections || [],
jumpHosts: hostData.jumpHosts || [],
@@ -986,6 +1058,11 @@ export async function updateSSHHost(
enableTunnel: Boolean(hostData.enableTunnel),
enableFileManager: Boolean(hostData.enableFileManager),
enableDocker: Boolean(hostData.enableDocker),
showTerminalInSidebar: Boolean(hostData.showTerminalInSidebar),
showFileManagerInSidebar: Boolean(hostData.showFileManagerInSidebar),
showTunnelInSidebar: Boolean(hostData.showTunnelInSidebar),
showDockerInSidebar: Boolean(hostData.showDockerInSidebar),
showServerStatsInSidebar: Boolean(hostData.showServerStatsInSidebar),
defaultPath: hostData.defaultPath || "/",
tunnelConnections: hostData.tunnelConnections || [],
jumpHosts: hostData.jumpHosts || [],
@@ -1321,6 +1398,7 @@ export async function connectSSH(
socks5Username?: string;
socks5Password?: string;
socks5ProxyChain?: unknown;
jumpHosts?: any[];
},
): Promise<Record<string, unknown>> {
try {
@@ -1329,7 +1407,34 @@ export async function connectSSH(
...config,
});
return response.data;
} catch (error) {
} catch (error: any) {
// Preserve connection logs from error response
if (error?.response?.data?.connectionLogs) {
const errorWithLogs = new Error(
error?.response?.data?.error ||
error?.response?.data?.message ||
error.message,
);
(errorWithLogs as any).connectionLogs =
error.response.data.connectionLogs;
// Also preserve other fields like requires_totp
if (error.response.data.requires_totp) {
(errorWithLogs as any).requires_totp = true;
(errorWithLogs as any).sessionId = error.response.data.sessionId;
(errorWithLogs as any).prompt = error.response.data.prompt;
}
if (error.response.data.requires_warpgate) {
(errorWithLogs as any).requires_warpgate = true;
(errorWithLogs as any).sessionId = error.response.data.sessionId;
(errorWithLogs as any).url = error.response.data.url;
(errorWithLogs as any).securityKey = error.response.data.securityKey;
}
if (error.response.data.status === "auth_required") {
(errorWithLogs as any).status = "auth_required";
(errorWithLogs as any).reason = error.response.data.reason;
}
throw errorWithLogs;
}
handleApiError(error, "connect SSH");
}
}
@@ -1362,6 +1467,96 @@ export async function verifySSHTOTP(
}
}
export async function verifySSHWarpgate(
sessionId: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/ssh/connect-warpgate", {
sessionId,
});
return response.data;
} catch (error) {
handleApiError(error, "verify SSH Warpgate");
}
}
/**
* @openapi
* /ssh/quick-connect:
* post:
* summary: Create a temporary SSH connection without saving to database
* description: Returns a temporary host configuration for immediate use
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - ip
* - port
* - username
* - authType
* properties:
* ip:
* type: string
* description: SSH server IP or hostname
* port:
* type: number
* description: SSH server port
* username:
* type: string
* description: SSH username
* authType:
* type: string
* enum: [password, key, credential]
* description: Authentication method
* password:
* type: string
* description: Password (required if authType is password)
* key:
* type: string
* description: SSH private key (required if authType is key)
* keyPassword:
* type: string
* description: SSH key password (optional)
* keyType:
* type: string
* description: SSH key type
* credentialId:
* type: number
* description: Credential ID (required if authType is credential)
* overrideCredentialUsername:
* type: boolean
* description: Use provided username instead of credential username
* responses:
* 200:
* description: Temporary host configuration created successfully
* content:
* application/json:
* schema:
* type: object
* description: SSHHost object
* 400:
* description: Invalid request data
* 401:
* description: Unauthorized
* 500:
* description: Server error
*/
export async function quickConnect(
data: Record<string, unknown>,
): Promise<SSHHost> {
try {
const response = await authApi.post("/ssh/quick-connect", data);
return response.data;
} catch (error) {
throw handleApiError(error, "quick connect");
}
}
export async function getSSHStatus(
sessionId: string,
): Promise<{ connected: boolean }> {
@@ -1584,6 +1779,20 @@ export async function deleteSSHItem(
}
}
export async function setSudoPassword(
sessionId: string,
password: string,
): Promise<void> {
try {
await fileManagerApi.post("/sudo-password", {
sessionId,
password,
});
} catch (error) {
handleApiError(error, "set sudo password");
}
}
export async function copySSHItem(
sessionId: string,
sourcePath: string,
@@ -1987,11 +2196,21 @@ export async function startMetricsPolling(hostId: number): Promise<{
sessionId?: string;
prompt?: string;
viewerSessionId?: string;
connectionLogs?: any[];
}> {
try {
const response = await statsApi.post(`/metrics/start/${hostId}`);
return response.data;
} catch (error) {
} catch (error: any) {
// Preserve connection logs from error response
if (error?.response?.data?.connectionLogs) {
const errorWithLogs = new Error(
error?.response?.data?.error || error.message,
);
(errorWithLogs as any).connectionLogs =
error.response.data.connectionLogs;
throw errorWithLogs;
}
handleApiError(error, "start metrics polling");
throw error;
}
@@ -2147,9 +2366,12 @@ export async function loginUser(
username: response.data.username,
requires_totp: response.data.requires_totp,
temp_token: response.data.temp_token,
is_oidc: response.data.is_oidc,
totp_enabled: response.data.totp_enabled,
data_unlocked: response.data.data_unlocked,
};
} catch (error) {
handleApiError(error, "login user");
throw handleApiError(error, "login user");
}
}
@@ -2441,6 +2663,28 @@ export async function updatePasswordLoginAllowed(
}
}
export async function getPasswordResetAllowed(): Promise<boolean> {
try {
const response = await authApi.get("/users/password-reset-allowed");
return response.data.allowed;
} catch (error) {
handleApiError(error, "get password reset allowed");
}
}
export async function updatePasswordResetAllowed(
allowed: boolean,
): Promise<{ allowed: boolean }> {
try {
const response = await authApi.patch("/users/password-reset-allowed", {
allowed,
});
return response.data;
} catch (error) {
handleApiError(error, "update password reset allowed");
}
}
export async function updateOIDCConfig(
config: Record<string, unknown>,
): Promise<Record<string, unknown>> {
@@ -3044,14 +3288,32 @@ export async function executeSnippet(
}
}
export async function reorderSnippets(
snippets: Array<{ id: number; order: number; folder?: string }>,
): Promise<{ success: boolean; updated: number }> {
// ============================================================================
// MISCELLANEOUS API CALLS
// ============================================================================
export interface NetworkTopologyData {
nodes: any[];
edges: any[];
}
export async function getNetworkTopology(): Promise<NetworkTopologyData | null> {
try {
const response = await authApi.put("/snippets/reorder", { snippets });
const response = await authApi.get("/network-topology/");
return response.data;
} catch (error) {
throw handleApiError(error, "reorder snippets");
throw handleApiError(error, "fetch network topology");
}
}
export async function saveNetworkTopology(
topology: NetworkTopologyData,
): Promise<{ success: boolean }> {
try {
const response = await authApi.post("/network-topology/", { topology });
return response.data;
} catch (error) {
throw handleApiError(error, "save network topology");
}
}
@@ -3120,8 +3382,19 @@ export async function deleteSnippetFolder(
}
}
export async function reorderSnippets(
updates: Array<{ id: number; order: number; folder?: string }>,
): Promise<{ success: boolean }> {
try {
const response = await authApi.post("/snippets/reorder", { updates });
return response.data;
} catch (error) {
throw handleApiError(error, "reorder snippets");
}
}
// ============================================================================
// HOMEPAGE API
// DASHBOARD API
// ============================================================================
export interface UptimeInfo {
@@ -3141,7 +3414,7 @@ export interface RecentActivityItem {
export async function getUptime(): Promise<UptimeInfo> {
try {
const response = await homepageApi.get("/uptime");
const response = await dashboardApi.get("/uptime");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch uptime");
@@ -3152,7 +3425,7 @@ export async function getRecentActivity(
limit?: number,
): Promise<RecentActivityItem[]> {
try {
const response = await homepageApi.get("/activity/recent", {
const response = await dashboardApi.get("/activity/recent", {
params: { limit },
});
return response.data;
@@ -3167,7 +3440,7 @@ export async function logActivity(
hostName: string,
): Promise<{ message: string; id: number | string }> {
try {
const response = await homepageApi.post("/activity/log", {
const response = await dashboardApi.post("/activity/log", {
type,
hostId,
hostName,
@@ -3180,7 +3453,7 @@ export async function logActivity(
export async function resetRecentActivity(): Promise<{ message: string }> {
try {
const response = await homepageApi.delete("/activity/reset");
const response = await dashboardApi.delete("/activity/reset");
return response.data;
} catch (error) {
throw handleApiError(error, "reset recent activity");
@@ -3447,6 +3720,10 @@ export async function connectDockerSession(
isPassword?: boolean;
status?: string;
reason?: string;
connectionLogs?: any[];
requires_warpgate?: boolean;
url?: string;
securityKey?: string;
}> {
try {
const response = await dockerApi.post("/ssh/connect", {
@@ -3462,6 +3739,20 @@ export async function connectDockerSession(
if (error.response?.data?.requires_totp) {
return error.response.data;
}
if (error.response?.data?.requires_warpgate) {
return error.response.data;
}
// Preserve connection logs from error response
if (error?.response?.data?.connectionLogs) {
const errorWithLogs = new Error(
error?.response?.data?.error ||
error?.response?.data?.message ||
error.message,
);
(errorWithLogs as any).connectionLogs =
error.response.data.connectionLogs;
throw errorWithLogs;
}
throw handleApiError(error, "connect to Docker SSH session");
}
}
@@ -3481,6 +3772,19 @@ export async function verifyDockerTOTP(
}
}
export async function verifyDockerWarpgate(
sessionId: string,
): Promise<{ status: string; message: string }> {
try {
const response = await dockerApi.post("/ssh/connect-warpgate", {
sessionId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "verify Docker Warpgate");
}
}
export async function disconnectDockerSession(
sessionId: string,
): Promise<{ success: boolean; message: string }> {
@@ -3697,3 +4001,19 @@ export async function getContainerStats(
throw handleApiError(error, "get container stats");
}
}
export interface DashboardLayout {
cards: Array<{ id: string; enabled: boolean; order: number }>;
}
export async function getDashboardPreferences(): Promise<DashboardLayout> {
const response = await dashboardApi.get("/dashboard/preferences");
return response.data;
}
export async function saveDashboardPreferences(
layout: DashboardLayout,
): Promise<{ success: boolean }> {
const response = await dashboardApi.post("/dashboard/preferences", layout);
return response.data;
}
+1 -3
View File
@@ -284,7 +284,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
'"Caskaydia Cove Nerd Font Mono", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
theme: themeColors,
allowTransparency: true,
convertEol: true,
convertEol: false,
windowsMode: false,
macOptionIsMeta: false,
macOptionClickForcesSelection: false,
@@ -489,7 +489,6 @@ style.innerHTML = `
font-display: swap;
}
/* Light theme scrollbars */
.xterm .xterm-viewport::-webkit-scrollbar {
width: 8px;
background: transparent;
@@ -506,7 +505,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);
}
+62 -4
View File
@@ -333,9 +333,40 @@ export function Auth({
setResetStep("newPassword");
toast.success(t("messages.codeVerified"));
} catch (err: unknown) {
const error = err as { response?: { data?: { error?: string } } };
const errorMessage =
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");
}
toast.error(errorMessage);
} else if (
remainingAttempts !== undefined &&
remainingAttempts <= 2 &&
remainingAttempts > 0
) {
errorMessage = `${errorMessage} (${remainingAttempts} ${t("auth.attemptsRemaining")})`;
}
setError(errorMessage);
} finally {
setResetLoading(false);
@@ -448,10 +479,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");
@@ -462,7 +503,24 @@ 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");
}
setError(errorMessage);
toast.error(errorMessage);
} else {
if (
remainingAttempts !== undefined &&
remainingAttempts <= 2 &&
remainingAttempts > 0
) {
errorMessage = `${errorMessage} (${remainingAttempts} ${t("auth.attemptsRemaining")})`;
}
setError(errorMessage);
}
} finally {