* 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
+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;
}