From d7bbad89c3cea165593c4f1e2fd341bda69fbd15 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 04:03:18 +0800 Subject: [PATCH 01/23] fix: Remove empty catch blocks and add error logging --- src/backend/database/database.ts | 8 ++- src/backend/database/routes/users.ts | 14 ++++- src/backend/ssh/file-manager.ts | 8 ++- src/backend/ssh/server-stats.ts | 53 +++++++++++++++---- src/backend/ssh/tunnel.ts | 16 ++++-- src/backend/starter.ts | 6 ++- src/backend/utils/auto-ssl-setup.ts | 6 ++- src/backend/utils/database-file-encryption.ts | 12 ++++- src/backend/utils/lazy-field-encryption.ts | 12 ++++- src/backend/utils/ssh-key-utils.ts | 24 +++++++-- src/backend/utils/system-crypto.ts | 26 +++++++-- src/ui/desktop/apps/dashboard/Dashboard.tsx | 8 ++- .../apps/host-manager/HostManagerEditor.tsx | 8 ++- src/ui/desktop/apps/terminal/Terminal.tsx | 20 +++++-- .../authentication/ElectronLoginForm.tsx | 12 +++-- .../authentication/ElectronServerConfig.tsx | 4 +- src/ui/desktop/user/UserProfile.tsx | 4 +- src/ui/hooks/useDragToSystemDesktop.ts | 4 +- src/ui/mobile/apps/terminal/Terminal.tsx | 12 +++-- .../mobile/apps/terminal/TerminalKeyboard.tsx | 4 +- src/ui/mobile/authentication/Auth.tsx | 4 +- 21 files changed, 213 insertions(+), 52 deletions(-) diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 661549b5..83280beb 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -1480,13 +1480,17 @@ app.get( if (status.hasUnencryptedDb) { try { unencryptedSize = fs.statSync(dbPath).size; - } catch {} + } catch (error) { + databaseLogger.debug("Operation failed, continuing", { error }); + } } if (status.hasEncryptedDb) { try { encryptedSize = fs.statSync(encryptedDbPath).size; - } catch {} + } catch (error) { + databaseLogger.debug("Operation failed, continuing", { error }); + } } res.json({ diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index c2b2ac03..e49c56ee 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -935,7 +935,9 @@ router.post("/login", async (req, res) => { if (kekSalt.length === 0) { await authManager.registerUser(userRecord.id, password); } - } catch {} + } catch (error) { + databaseLogger.debug("Operation failed, continuing", { error }); + } const dataUnlocked = await authManager.authenticateUser( userRecord.id, @@ -1016,7 +1018,15 @@ router.post("/logout", authenticateJWT, async (req, res) => { try { const payload = await authManager.verifyJWTToken(token); sessionId = payload?.sessionId; - } catch (error) {} + } catch (error) { + authLogger.debug( + "Token verification failed during logout (expected if token expired)", + { + operation: "logout_token_verify_failed", + userId, + }, + ); + } } await authManager.logoutUser(userId, sessionId); diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index bf30c2de..a32bc138 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -120,7 +120,9 @@ function cleanupSession(sessionId: string) { if (session) { try { session.client.end(); - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { error }); + } clearTimeout(session.timeout); delete sshSessions[sessionId]; } @@ -663,7 +665,9 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { delete pendingTOTPSessions[sessionId]; try { session.client.end(); - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { error }); + } fileLogger.warn("TOTP session timeout before code submission", { operation: "file_totp_verify", sessionId, diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index cc155e49..ce8f5deb 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -156,7 +156,9 @@ class SSHConnectionPool { if (!conn.inUse && now - conn.lastUsed > maxAge) { try { conn.client.end(); - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { error }); + } return false; } return true; @@ -176,7 +178,9 @@ class SSHConnectionPool { for (const conn of connections) { try { conn.client.end(); - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { error }); + } } } this.connections.clear(); @@ -214,7 +218,9 @@ class RequestQueue { if (request) { try { await request(); - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { error }); + } } } @@ -511,7 +517,14 @@ class PollingManager { data: metrics, timestamp: Date.now(), }); - } catch (error) {} + } catch (error) { + statsLogger.warn("Failed to collect metrics for host", { + operation: "metrics_poll_failed", + hostId: host.id, + hostName: host.name, + error: error instanceof Error ? error.message : String(error), + }); + } } stopPollingForHost(hostId: number): void { @@ -1212,7 +1225,12 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ txBytes: null, }); } - } catch (e) {} + } catch (e) { + statsLogger.debug("Failed to collect network interface stats", { + operation: "network_stats_failed", + error: e instanceof Error ? e.message : String(e), + }); + } let uptimeSeconds: number | null = null; let uptimeFormatted: string | null = null; @@ -1228,7 +1246,12 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ uptimeFormatted = `${days}d ${hours}h ${minutes}m`; } } - } catch (e) {} + } catch (e) { + statsLogger.debug("Failed to collect uptime", { + operation: "uptime_failed", + error: e instanceof Error ? e.message : String(e), + }); + } let totalProcesses: number | null = null; let runningProcesses: number | null = null; @@ -1270,7 +1293,12 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ ); totalProcesses = Number(procCount.stdout.trim()) - 1; runningProcesses = Number(runningCount.stdout.trim()); - } catch (e) {} + } catch (e) { + statsLogger.debug("Failed to collect process stats", { + operation: "process_stats_failed", + error: e instanceof Error ? e.message : String(e), + }); + } let hostname: string | null = null; let kernel: string | null = null; @@ -1286,7 +1314,12 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ hostname = hostnameOut.stdout.trim() || null; kernel = kernelOut.stdout.trim() || null; os = osOut.stdout.trim() || null; - } catch (e) {} + } catch (e) { + statsLogger.debug("Failed to collect system info", { + operation: "system_info_failed", + error: e instanceof Error ? e.message : String(e), + }); + } const result = { cpu: { percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet }, @@ -1365,7 +1398,9 @@ function tcpPing( settled = true; try { socket.destroy(); - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { error }); + } resolve(result); }; diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index 6262af86..58dd82f4 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -217,7 +217,9 @@ function cleanupTunnelResources( if (verification?.timeout) clearTimeout(verification.timeout); try { verification?.conn.end(); - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { error }); + } tunnelVerifications.delete(tunnelName); } @@ -282,7 +284,9 @@ function handleDisconnect( const verification = tunnelVerifications.get(tunnelName); if (verification?.timeout) clearTimeout(verification.timeout); verification?.conn.end(); - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { error }); + } tunnelVerifications.delete(tunnelName); } @@ -638,7 +642,9 @@ async function connectSSHTunnel( try { conn.end(); - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { error }); + } activeTunnels.delete(tunnelName); @@ -778,7 +784,9 @@ async function connectSSHTunnel( const verification = tunnelVerifications.get(tunnelName); if (verification?.timeout) clearTimeout(verification.timeout); verification?.conn.end(); - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { error }); + } tunnelVerifications.delete(tunnelName); } diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 4ab019a6..f7cd3b4f 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -21,7 +21,11 @@ import { systemLogger, versionLogger } from "./utils/logger.js"; if (persistentConfig.parsed) { Object.assign(process.env, persistentConfig.parsed); } - } catch {} + } catch (error) { + systemLogger.debug("Operation failed, continuing", { + error: error instanceof Error ? error.message : String(error), + }); + } let version = "unknown"; diff --git a/src/backend/utils/auto-ssl-setup.ts b/src/backend/utils/auto-ssl-setup.ts index e45ce2ec..6e441df3 100644 --- a/src/backend/utils/auto-ssl-setup.ts +++ b/src/backend/utils/auto-ssl-setup.ts @@ -233,7 +233,11 @@ IP.3 = 0.0.0.0 let envContent = ""; try { envContent = await fs.readFile(this.ENV_FILE, "utf8"); - } catch {} + } catch (error) { + systemLogger.debug("Operation failed, continuing", { + error: error instanceof Error ? error.message : String(error), + }); + } let updatedContent = envContent; let hasChanges = false; diff --git a/src/backend/utils/database-file-encryption.ts b/src/backend/utils/database-file-encryption.ts index b4afbe7a..8ffb2e1f 100644 --- a/src/backend/utils/database-file-encryption.ts +++ b/src/backend/utils/database-file-encryption.ts @@ -327,7 +327,11 @@ class DatabaseFileEncryption { fs.accessSync(envPath, fs.constants.R_OK); envFileReadable = true; } - } catch {} + } catch (error) { + databaseLogger.debug("Operation failed, continuing", { + error: error instanceof Error ? error.message : String(error), + }); + } databaseLogger.error( "Database decryption authentication failed - possible causes: wrong DATABASE_KEY, corrupted files, or interrupted write", @@ -628,7 +632,11 @@ class DatabaseFileEncryption { try { fs.accessSync(envPath, fs.constants.R_OK); result.environment.envFileReadable = true; - } catch {} + } catch (error) { + databaseLogger.debug("Operation failed, continuing", { + error: error instanceof Error ? error.message : String(error), + }); + } } if ( diff --git a/src/backend/utils/lazy-field-encryption.ts b/src/backend/utils/lazy-field-encryption.ts index 6be7b44d..8cd3a4d0 100644 --- a/src/backend/utils/lazy-field-encryption.ts +++ b/src/backend/utils/lazy-field-encryption.ts @@ -82,7 +82,11 @@ export class LazyFieldEncryption { legacyFieldName, ); return decrypted; - } catch {} + } catch (error) { + databaseLogger.debug("Operation failed, continuing", { + error: error instanceof Error ? error.message : String(error), + }); + } } const sensitiveFields = [ @@ -174,7 +178,11 @@ export class LazyFieldEncryption { wasPlaintext: false, wasLegacyEncryption: true, }; - } catch {} + } catch (error) { + databaseLogger.debug("Operation failed, continuing", { + error: error instanceof Error ? error.message : String(error), + }); + } } return { encrypted: fieldValue, diff --git a/src/backend/utils/ssh-key-utils.ts b/src/backend/utils/ssh-key-utils.ts index 8cd3d3d3..86aeec4b 100644 --- a/src/backend/utils/ssh-key-utils.ts +++ b/src/backend/utils/ssh-key-utils.ts @@ -84,7 +84,11 @@ function detectKeyTypeFromContent(keyContent: string): string { } else if (decodedString.includes("1.3.101.112")) { return "ssh-ed25519"; } - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { + error: error instanceof Error ? error.message : String(error), + }); + } if (content.length < 800) { return "ssh-ed25519"; @@ -140,7 +144,11 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string { } else if (decodedString.includes("1.3.101.112")) { return "ssh-ed25519"; } - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { + error: error instanceof Error ? error.message : String(error), + }); + } if (content.length < 400) { return "ssh-ed25519"; @@ -242,7 +250,11 @@ export function parseSSHKey( useSSH2 = true; } - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { + error: error instanceof Error ? error.message : String(error), + }); + } } if (!useSSH2) { @@ -268,7 +280,11 @@ export function parseSSHKey( success: true, }; } - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { + error: error instanceof Error ? error.message : String(error), + }); + } return { privateKey: privateKeyData, diff --git a/src/backend/utils/system-crypto.ts b/src/backend/utils/system-crypto.ts index 156d0b33..41aa2ff1 100644 --- a/src/backend/utils/system-crypto.ts +++ b/src/backend/utils/system-crypto.ts @@ -51,7 +51,15 @@ class SystemCrypto { }, ); } - } catch (fileError) {} + } catch (fileError) { + // OK: .env file not found or unreadable, will generate new JWT secret + databaseLogger.debug( + ".env file not accessible, will generate new JWT secret", + { + operation: "jwt_env_not_found", + }, + ); + } await this.generateAndGuideUser(); } catch (error) { @@ -102,7 +110,15 @@ class SystemCrypto { return; } else { } - } catch (fileError) {} + } catch (fileError) { + // OK: .env file not found or unreadable, will generate new database key + databaseLogger.debug( + ".env file not accessible, will generate new database key", + { + operation: "db_key_env_not_found", + }, + ); + } await this.generateAndGuideDatabaseKey(); } catch (error) { @@ -140,7 +156,11 @@ class SystemCrypto { process.env.INTERNAL_AUTH_TOKEN = tokenMatch[1]; return; } - } catch {} + } catch (error) { + databaseLogger.debug("Operation failed, continuing", { + error: error instanceof Error ? error.message : String(error), + }); + } await this.generateAndGuideInternalAuthToken(); } catch (error) { diff --git a/src/ui/desktop/apps/dashboard/Dashboard.tsx b/src/ui/desktop/apps/dashboard/Dashboard.tsx index dd6d3826..f38d5d95 100644 --- a/src/ui/desktop/apps/dashboard/Dashboard.tsx +++ b/src/ui/desktop/apps/dashboard/Dashboard.tsx @@ -91,7 +91,9 @@ export function Dashboard({ try { const sidebar = useSidebar(); sidebarState = sidebar.state; - } catch {} + } catch (error) { + console.error("Dashboard operation failed:", error); + } const topMarginPx = isTopbarOpen ? 74 : 26; const leftMarginPx = sidebarState === "collapsed" ? 26 : 8; @@ -173,7 +175,9 @@ export function Dashboard({ if (Array.isArray(tunnelConnections)) { totalTunnelsCount += tunnelConnections.length; } - } catch {} + } catch (error) { + console.error("Dashboard operation failed:", error); + } } } setTotalTunnels(totalTunnelsCount); diff --git a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx index e32ccf9b..ad48b4a0 100644 --- a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx +++ b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx @@ -167,7 +167,9 @@ export function HostManagerEditor({ setFolders(uniqueFolders); setSshConfigurations(uniqueConfigurations); - } catch {} + } catch (error) { + console.error("Host manager operation failed:", error); + } }; fetchData(); @@ -196,7 +198,9 @@ export function HostManagerEditor({ setFolders(uniqueFolders); setSshConfigurations(uniqueConfigurations); - } catch {} + } catch (error) { + console.error("Host manager operation failed:", error); + } }; window.addEventListener("credentials:changed", handleCredentialChange); diff --git a/src/ui/desktop/apps/terminal/Terminal.tsx b/src/ui/desktop/apps/terminal/Terminal.tsx index 4a35f9f8..02b61b80 100644 --- a/src/ui/desktop/apps/terminal/Terminal.tsx +++ b/src/ui/desktop/apps/terminal/Terminal.tsx @@ -189,7 +189,9 @@ export const Terminal = forwardRef( terminal as { refresh?: (start: number, end: number) => void } ).refresh(0, terminal.rows - 1); } - } catch {} + } catch (error) { + console.error("Terminal operation failed:", error); + } } function performFit() { @@ -331,7 +333,9 @@ export const Terminal = forwardRef( scheduleNotify(cols, rows); hardRefresh(); } - } catch {} + } catch (error) { + console.error("Terminal operation failed:", error); + } }, refresh: () => hardRefresh(), }), @@ -738,7 +742,9 @@ export const Terminal = forwardRef( await navigator.clipboard.writeText(text); return; } - } catch {} + } catch (error) { + console.error("Terminal operation failed:", error); + } const textarea = document.createElement("textarea"); textarea.value = text; textarea.style.position = "fixed"; @@ -758,7 +764,9 @@ export const Terminal = forwardRef( if (navigator.clipboard && navigator.clipboard.readText) { return await navigator.clipboard.readText(); } - } catch {} + } catch (error) { + console.error("Terminal operation failed:", error); + } return ""; } @@ -855,7 +863,9 @@ export const Terminal = forwardRef( const pasteText = await readTextFromClipboard(); if (pasteText) terminal.paste(pasteText); } - } catch {} + } catch (error) { + console.error("Terminal operation failed:", error); + } }; element?.addEventListener("contextmenu", handleContextMenu); diff --git a/src/ui/desktop/authentication/ElectronLoginForm.tsx b/src/ui/desktop/authentication/ElectronLoginForm.tsx index 7ab2dee8..3b418545 100644 --- a/src/ui/desktop/authentication/ElectronLoginForm.tsx +++ b/src/ui/desktop/authentication/ElectronLoginForm.tsx @@ -63,7 +63,9 @@ export function ElectronLoginForm({ } } } - } catch (err) {} + } catch (err) { + console.error("Authentication operation failed:", err); + } }; window.addEventListener("message", handleMessage); @@ -190,8 +192,12 @@ export function ElectronLoginForm({ ); } } - } catch (err) {} - } catch (err) {} + } catch (err) { + console.error("Authentication operation failed:", err); + } + } catch (err) { + console.error("Authentication operation failed:", err); + } }; const handleError = () => { diff --git a/src/ui/desktop/authentication/ElectronServerConfig.tsx b/src/ui/desktop/authentication/ElectronServerConfig.tsx index ef3b1632..4def9af3 100644 --- a/src/ui/desktop/authentication/ElectronServerConfig.tsx +++ b/src/ui/desktop/authentication/ElectronServerConfig.tsx @@ -37,7 +37,9 @@ export function ElectronServerConfig({ if (config?.serverUrl) { setServerUrl(config.serverUrl); } - } catch {} + } catch (error) { + console.error("Server config operation failed:", error); + } }; const handleSaveConfig = async () => { diff --git a/src/ui/desktop/user/UserProfile.tsx b/src/ui/desktop/user/UserProfile.tsx index 3f53d511..dfc9c709 100644 --- a/src/ui/desktop/user/UserProfile.tsx +++ b/src/ui/desktop/user/UserProfile.tsx @@ -54,7 +54,9 @@ async function handleLogout() { }, serverOrigin, ); - } catch (err) {} + } catch (err) { + console.error("User profile operation failed:", err); + } } } } diff --git a/src/ui/hooks/useDragToSystemDesktop.ts b/src/ui/hooks/useDragToSystemDesktop.ts index 0daa5b76..3fff9239 100644 --- a/src/ui/hooks/useDragToSystemDesktop.ts +++ b/src/ui/hooks/useDragToSystemDesktop.ts @@ -48,7 +48,9 @@ export function useDragToSystemDesktop({ sshSessionId }: UseDragToSystemProps) { store.put({ handle: dirHandle }, "lastSaveDir"); }; } - } catch {} + } catch (error) { + console.error("Drag operation failed:", error); + } }; const isFileSystemAPISupported = () => { diff --git a/src/ui/mobile/apps/terminal/Terminal.tsx b/src/ui/mobile/apps/terminal/Terminal.tsx index 461f2776..49e024fb 100644 --- a/src/ui/mobile/apps/terminal/Terminal.tsx +++ b/src/ui/mobile/apps/terminal/Terminal.tsx @@ -101,7 +101,9 @@ export const Terminal = forwardRef( terminal as { refresh?: (start: number, end: number) => void } ).refresh(0, terminal.rows - 1); } - } catch {} + } catch (error) { + console.error("Terminal operation failed:", error); + } } function performFit() { @@ -175,7 +177,9 @@ export const Terminal = forwardRef( scheduleNotify(cols, rows); hardRefresh(); } - } catch {} + } catch (error) { + console.error("Terminal operation failed:", error); + } }, refresh: () => hardRefresh(), }), @@ -225,7 +229,9 @@ export const Terminal = forwardRef( `\r\n[${msg.message || t("terminal.disconnected")}]`, ); } - } catch {} + } catch (error) { + console.error("Terminal operation failed:", error); + } }); ws.addEventListener("close", (event) => { diff --git a/src/ui/mobile/apps/terminal/TerminalKeyboard.tsx b/src/ui/mobile/apps/terminal/TerminalKeyboard.tsx index 8869c3d7..0afc06c2 100644 --- a/src/ui/mobile/apps/terminal/TerminalKeyboard.tsx +++ b/src/ui/mobile/apps/terminal/TerminalKeyboard.tsx @@ -110,7 +110,9 @@ export function TerminalKeyboard({ if (navigator.vibrate) { navigator.vibrate(20); } - } catch {} + } catch (error) { + console.error("Keyboard operation failed:", error); + } onSendInput(input); }, diff --git a/src/ui/mobile/authentication/Auth.tsx b/src/ui/mobile/authentication/Auth.tsx index 3751224b..3a32036f 100644 --- a/src/ui/mobile/authentication/Auth.tsx +++ b/src/ui/mobile/authentication/Auth.tsx @@ -52,7 +52,9 @@ function postJWTToWebView() { timestamp: Date.now(), }), ); - } catch (error) {} + } catch (error) { + console.error("Auth operation failed:", error); + } } interface AuthProps extends React.ComponentProps<"div"> { -- 2.49.1 From b43e98073fb62f16abf4d2a8fbac44be32587afb Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sat, 8 Nov 2025 15:23:14 -0600 Subject: [PATCH 02/23] fix: Several bug fixes for terminals, server stats, and general feature improvements --- src/backend/database/db/index.ts | 5 + src/backend/database/db/schema.ts | 3 + src/backend/database/routes/ssh.ts | 95 +++++++++++++- src/backend/database/routes/users.ts | 43 +++++++ src/backend/ssh/file-manager.ts | 2 +- src/backend/ssh/server-stats.ts | 31 ++++- src/backend/ssh/terminal.ts | 42 ++++++- src/locales/en/translation.json | 5 +- src/types/index.ts | 2 + .../apps/credentials/CredentialsManager.tsx | 3 + .../desktop/apps/file-manager/FileManager.tsx | 47 ++----- .../apps/host-manager/HostManagerEditor.tsx | 118 +++++++++++++----- src/ui/desktop/apps/terminal/Terminal.tsx | 25 +++- src/ui/desktop/authentication/Auth.tsx | 106 ++++++++-------- src/ui/main-axios.ts | 45 ++++--- src/ui/mobile/authentication/Auth.tsx | 82 ++++++------ 16 files changed, 445 insertions(+), 209 deletions(-) diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 8310ed6c..09d37809 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -456,6 +456,11 @@ const migrateSchema = () => { "credential_id", "INTEGER REFERENCES ssh_credentials(id)", ); + addColumnIfNotExists( + "ssh_data", + "override_credential_username", + "INTEGER", + ); addColumnIfNotExists("ssh_data", "autostart_password", "TEXT"); addColumnIfNotExists("ssh_data", "autostart_key", "TEXT"); diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 86af0d02..45528c01 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -72,6 +72,9 @@ export const sshData = sqliteTable("ssh_data", { autostartKeyPassword: text("autostart_key_password"), credentialId: integer("credential_id").references(() => sshCredentials.id), + overrideCredentialUsername: integer("override_credential_username", { + mode: "boolean", + }), enableTerminal: integer("enable_terminal", { mode: "boolean" }) .notNull() .default(true), diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 8e9cf570..b0e5067e 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -8,6 +8,7 @@ import { fileManagerRecent, fileManagerPinned, fileManagerShortcuts, + recentActivity, } from "../db/schema.js"; import { eq, and, desc, isNotNull, or } from "drizzle-orm"; import type { Request, Response } from "express"; @@ -225,6 +226,7 @@ router.post( authMethod, authType, credentialId, + overrideCredentialUsername, key, keyPassword, keyType, @@ -264,6 +266,7 @@ router.post( username, authType: effectiveAuthType, credentialId: credentialId || null, + overrideCredentialUsername: overrideCredentialUsername ? 1 : 0, pin: pin ? 1 : 0, enableTerminal: enableTerminal ? 1 : 0, enableTunnel: enableTunnel ? 1 : 0, @@ -323,6 +326,7 @@ router.post( : [] : [], pin: !!createdHost.pin, + overrideCredentialUsername: !!createdHost.overrideCredentialUsername, enableTerminal: !!createdHost.enableTerminal, enableTunnel: !!createdHost.enableTunnel, tunnelConnections: createdHost.tunnelConnections @@ -349,6 +353,27 @@ router.post( }, ); + try { + const fetch = (await import("node-fetch")).default; + const token = + req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", ""); + await fetch("http://localhost:30005/refresh", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token && { Cookie: `jwt=${token}` }), + }, + }); + } catch (refreshError) { + sshLogger.warn("Failed to refresh server stats polling", { + operation: "stats_refresh_after_create", + error: + refreshError instanceof Error + ? refreshError.message + : "Unknown error", + }); + } + res.json(resolvedHost); } catch (err) { sshLogger.error("Failed to save SSH host to database", err, { @@ -415,6 +440,7 @@ router.put( authMethod, authType, credentialId, + overrideCredentialUsername, key, keyPassword, keyType, @@ -455,6 +481,7 @@ router.put( username, authType: effectiveAuthType, credentialId: credentialId || null, + overrideCredentialUsername: overrideCredentialUsername ? 1 : 0, pin: pin ? 1 : 0, enableTerminal: enableTerminal ? 1 : 0, enableTunnel: enableTunnel ? 1 : 0, @@ -532,6 +559,7 @@ router.put( : [] : [], pin: !!updatedHost.pin, + overrideCredentialUsername: !!updatedHost.overrideCredentialUsername, enableTerminal: !!updatedHost.enableTerminal, enableTunnel: !!updatedHost.enableTunnel, tunnelConnections: updatedHost.tunnelConnections @@ -558,6 +586,27 @@ router.put( }, ); + try { + const fetch = (await import("node-fetch")).default; + const token = + req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", ""); + await fetch("http://localhost:30005/refresh", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token && { Cookie: `jwt=${token}` }), + }, + }); + } catch (refreshError) { + sshLogger.warn("Failed to refresh server stats polling", { + operation: "stats_refresh_after_update", + error: + refreshError instanceof Error + ? refreshError.message + : "Unknown error", + }); + } + res.json(resolvedHost); } catch (err) { sshLogger.error("Failed to update SSH host in database", err, { @@ -585,6 +634,18 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => { }); return res.status(400).json({ error: "Invalid userId" }); } + + if (!SimpleDBOps.isUserDataUnlocked(userId)) { + sshLogger.warn("User data not unlocked for SSH host fetch", { + operation: "host_fetch", + userId, + }); + return res.status(401).json({ + error: "Session expired - please log in again", + code: "SESSION_EXPIRED", + }); + } + try { const data = await SimpleDBOps.select( db.select().from(sshData).where(eq(sshData.userId, userId)), @@ -603,6 +664,7 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => { : [] : [], pin: !!row.pin, + overrideCredentialUsername: !!row.overrideCredentialUsername, enableTerminal: !!row.enableTerminal, enableTunnel: !!row.enableTunnel, tunnelConnections: row.tunnelConnections @@ -649,6 +711,19 @@ router.get( }); return res.status(400).json({ error: "Invalid userId or hostId" }); } + + if (!SimpleDBOps.isUserDataUnlocked(userId)) { + sshLogger.warn("User data not unlocked for SSH host fetch by ID", { + operation: "host_fetch_by_id", + hostId: parseInt(hostId), + userId, + }); + return res.status(401).json({ + error: "Session expired - please log in again", + code: "SESSION_EXPIRED", + }); + } + try { const data = await db .select() @@ -674,6 +749,7 @@ router.get( : [] : [], pin: !!host.pin, + overrideCredentialUsername: !!host.overrideCredentialUsername, enableTerminal: !!host.enableTerminal, enableTunnel: !!host.enableTunnel, tunnelConnections: host.tunnelConnections @@ -848,6 +924,15 @@ router.delete( ), ); + await db + .delete(recentActivity) + .where( + and( + eq(recentActivity.userId, userId), + eq(recentActivity.hostId, numericHostId), + ), + ); + await db .delete(sshData) .where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId))); @@ -1267,7 +1352,9 @@ async function resolveHostCredentials( const credential = credentials[0]; return { ...host, - username: credential.username, + username: host.overrideCredentialUsername + ? host.username + : credential.username, authType: credential.auth_type || credential.authType, password: credential.password, key: credential.key, @@ -1446,8 +1533,10 @@ router.post( username: hostData.username, password: hostData.authType === "password" ? hostData.password : null, authType: hostData.authType, - credentialId: - hostData.authType === "credential" ? hostData.credentialId : null, + credentialId: hostData.credentialId || null, + overrideCredentialUsername: hostData.overrideCredentialUsername + ? 1 + : 0, key: hostData.authType === "key" ? hostData.key : null, keyPassword: hostData.authType === "key" diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index c2b2ac03..4c1c8685 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -226,6 +226,16 @@ router.post("/create", async (req, res) => { }); } + try { + const { saveMemoryDatabaseToFile } = await import("../db/index.js"); + await saveMemoryDatabaseToFile(); + } catch (saveError) { + authLogger.error("Failed to persist user to disk", saveError, { + operation: "user_create_save_failed", + userId: id, + }); + } + authLogger.success( `Traditional user created: ${username} (is_admin: ${isFirstUser})`, { @@ -785,6 +795,16 @@ router.get("/oidc/callback", async (req, res) => { }); } + try { + const { saveMemoryDatabaseToFile } = await import("../db/index.js"); + await saveMemoryDatabaseToFile(); + } catch (saveError) { + authLogger.error("Failed to persist OIDC user to disk", saveError, { + operation: "oidc_user_create_save_failed", + userId: id, + }); + } + user = await db.select().from(users).where(eq(users.id, id)); } else { await db @@ -836,6 +856,9 @@ router.get("/oidc/callback", async (req, res) => { ? 30 * 24 * 60 * 60 * 1000 : 7 * 24 * 60 * 60 * 1000; + // Clear any existing JWT cookie first to prevent conflicts + res.clearCookie("jwt", authManager.getSecureCookieOptions(req)); + return res .cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge)) .redirect(redirectUrl.toString()); @@ -1653,6 +1676,16 @@ router.post("/make-admin", authenticateJWT, async (req, res) => { .set({ is_admin: true }) .where(eq(users.username, username)); + try { + const { saveMemoryDatabaseToFile } = await import("../db/index.js"); + await saveMemoryDatabaseToFile(); + } catch (saveError) { + authLogger.error("Failed to persist admin promotion to disk", saveError, { + operation: "make_admin_save_failed", + username, + }); + } + authLogger.success( `User ${username} made admin by ${adminUser[0].username}`, ); @@ -1702,6 +1735,16 @@ router.post("/remove-admin", authenticateJWT, async (req, res) => { .set({ is_admin: false }) .where(eq(users.username, username)); + try { + const { saveMemoryDatabaseToFile } = await import("../db/index.js"); + await saveMemoryDatabaseToFile(); + } catch (saveError) { + authLogger.error("Failed to persist admin removal to disk", saveError, { + operation: "remove_admin_save_failed", + username, + }); + } + authLogger.success( `Admin status removed from ${username} by ${adminUser[0].username}`, ); diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index bf30c2de..98f8a8a6 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -845,7 +845,7 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => { sshConn.lastActive = Date.now(); const escapedPath = sshPath.replace(/'/g, "'\"'\"'"); - sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => { + sshConn.client.exec(`command ls -la '${escapedPath}'`, (err, stream) => { if (err) { fileLogger.error("SSH listFiles error:", err); return res.status(500).json({ error: err.message }); diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index cc155e49..7a104a8f 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -455,6 +455,13 @@ class PollingManager { } } + if (!statsConfig.statusCheckEnabled && !statsConfig.metricsEnabled) { + this.pollingConfigs.delete(host.id); + this.statusStore.delete(host.id); + this.metricsStore.delete(host.id); + return; + } + const config: HostPollingConfig = { host, statsConfig, @@ -514,7 +521,7 @@ class PollingManager { } catch (error) {} } - stopPollingForHost(hostId: number): void { + stopPollingForHost(hostId: number, clearData = true): void { const config = this.pollingConfigs.get(hostId); if (config) { if (config.statusTimer) { @@ -524,8 +531,10 @@ class PollingManager { clearInterval(config.metricsTimer); } this.pollingConfigs.delete(hostId); - this.statusStore.delete(hostId); - this.metricsStore.delete(hostId); + if (clearData) { + this.statusStore.delete(hostId); + this.metricsStore.delete(hostId); + } } } @@ -554,11 +563,23 @@ class PollingManager { } async refreshHostPolling(userId: string): Promise { + const hosts = await fetchAllHosts(userId); + const currentHostIds = new Set(hosts.map((h) => h.id)); + for (const hostId of this.pollingConfigs.keys()) { - this.stopPollingForHost(hostId); + this.stopPollingForHost(hostId, false); } - await this.initializePolling(userId); + for (const hostId of this.statusStore.keys()) { + if (!currentHostIds.has(hostId)) { + this.statusStore.delete(hostId); + this.metricsStore.delete(hostId); + } + } + + for (const host of hosts) { + await this.startPollingForHost(host); + } } destroy(): void { diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index a040ee91..3e9930e2 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -152,6 +152,9 @@ wss.on("connection", async (ws: WebSocket, req) => { let totpPromptSent = false; let isKeyboardInteractive = false; let keyboardInteractiveResponded = false; + let isConnecting = false; + let isConnected = false; + let isCleaningUp = false; ws.on("close", () => { const userWs = userConnections.get(userId); @@ -417,10 +420,21 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } + if (isConnecting || isConnected) { + sshLogger.warn("Connection already in progress or established", { + operation: "ssh_connect", + hostId: id, + isConnecting, + isConnected, + }); + return; + } + + isConnecting = true; sshConn = new Client(); const connectionTimeout = setTimeout(() => { - if (sshConn) { + if (sshConn && isConnecting && !isConnected) { sshLogger.error("SSH connection timeout", undefined, { operation: "ssh_connect", hostId: id, @@ -433,7 +447,7 @@ wss.on("connection", async (ws: WebSocket, req) => { ); cleanupSSH(connectionTimeout); } - }, 60000); + }, 120000); let resolvedCredentials = { password, key, keyPassword, keyType, authType }; let authMethodNotAvailable = false; @@ -498,7 +512,9 @@ wss.on("connection", async (ws: WebSocket, req) => { sshConn.on("ready", () => { clearTimeout(connectionTimeout); - if (!sshConn) { + const conn = sshConn; + + if (!conn || isCleaningUp) { sshLogger.warn( "SSH connection was cleaned up before shell could be created", { @@ -507,6 +523,7 @@ wss.on("connection", async (ws: WebSocket, req) => { ip, port, username, + isCleaningUp, }, ); ws.send( @@ -519,7 +536,10 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } - sshConn.shell( + isConnecting = false; + isConnected = true; + + conn.shell( { rows: data.rows, cols: data.cols, @@ -836,9 +856,10 @@ wss.on("connection", async (ws: WebSocket, req) => { tryKeyboard: true, keepaliveInterval: 30000, keepaliveCountMax: 3, - readyTimeout: 60000, + readyTimeout: 120000, tcpKeepAlive: true, tcpKeepAliveInitialDelay: 30000, + timeout: 120000, env: { TERM: "xterm-256color", LANG: "en_US.UTF-8", @@ -982,6 +1003,11 @@ wss.on("connection", async (ws: WebSocket, req) => { } function cleanupSSH(timeoutId?: NodeJS.Timeout) { + if (isCleaningUp) { + return; + } + isCleaningUp = true; + if (timeoutId) { clearTimeout(timeoutId); } @@ -1019,6 +1045,12 @@ wss.on("connection", async (ws: WebSocket, req) => { isKeyboardInteractive = false; keyboardInteractiveResponded = false; keyboardInteractiveFinish = null; + isConnecting = false; + isConnected = false; + + setTimeout(() => { + isCleaningUp = false; + }, 100); } function setupPingInterval() { diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 8012ad05..deb430af 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -109,6 +109,7 @@ "orCreateNewFolder": "Or create new folder", "addTag": "Add tag", "saving": "Saving...", + "credentialId": "Credential ID", "overview": "Overview", "security": "Security", "usage": "Usage", @@ -782,7 +783,9 @@ "noneAuthDescription": "This authentication method will use keyboard-interactive authentication when connecting to the SSH server.", "noneAuthDetails": "Keyboard-interactive authentication allows the server to prompt you for credentials during connection. This is useful for servers that require multi-factor authentication or if you do not want to save credentials locally.", "forceKeyboardInteractive": "Force Keyboard-Interactive", - "forceKeyboardInteractiveDesc": "Forces the use of keyboard-interactive authentication. This is often required for servers that use Two-Factor Authentication (TOTP/2FA)." + "forceKeyboardInteractiveDesc": "Forces the use of keyboard-interactive authentication. This is often required for servers that use Two-Factor Authentication (TOTP/2FA).", + "overrideCredentialUsername": "Override Credential Username", + "overrideCredentialUsernameDesc": "Use a different username than the one stored in the credential. This allows you to use the same credential with different usernames." }, "terminal": { "title": "Terminal", diff --git a/src/types/index.ts b/src/types/index.ts index 027de232..c19b57e8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -26,6 +26,7 @@ export interface SSHHost { autostartKeyPassword?: string; credentialId?: number; + overrideCredentialUsername?: boolean; userId?: string; enableTerminal: boolean; enableTunnel: boolean; @@ -52,6 +53,7 @@ export interface SSHHostData { keyPassword?: string; keyType?: string; credentialId?: number | null; + overrideCredentialUsername?: boolean; enableTerminal?: boolean; enableTunnel?: boolean; enableFileManager?: boolean; diff --git a/src/ui/desktop/apps/credentials/CredentialsManager.tsx b/src/ui/desktop/apps/credentials/CredentialsManager.tsx index aedae3d5..7b3e02fa 100644 --- a/src/ui/desktop/apps/credentials/CredentialsManager.tsx +++ b/src/ui/desktop/apps/credentials/CredentialsManager.tsx @@ -640,6 +640,9 @@ export function CredentialsManager({

{credential.username}

+

+ ID: {credential.id} +

{credential.authType === "password" ? t("credentials.password") diff --git a/src/ui/desktop/apps/file-manager/FileManager.tsx b/src/ui/desktop/apps/file-manager/FileManager.tsx index 2070153b..dde80098 100644 --- a/src/ui/desktop/apps/file-manager/FileManager.tsx +++ b/src/ui/desktop/apps/file-manager/FileManager.tsx @@ -527,41 +527,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { const reader = new FileReader(); reader.onerror = () => reject(reader.error); - const isTextFile = - file.type.startsWith("text/") || - file.type === "application/json" || - file.type === "application/javascript" || - file.type === "application/xml" || - file.type === "image/svg+xml" || - file.name.match( - /\.(txt|json|js|ts|jsx|tsx|css|scss|less|html|htm|xml|svg|yaml|yml|md|markdown|mdown|mkdn|mdx|py|java|c|cpp|h|sh|bash|zsh|bat|ps1|toml|ini|conf|config|sql|vue|svelte)$/i, - ); - - if (isTextFile) { - reader.onload = () => { - if (reader.result) { - resolve(reader.result as string); - } else { - reject(new Error("Failed to read text file content")); + reader.onload = () => { + if (reader.result instanceof ArrayBuffer) { + const bytes = new Uint8Array(reader.result); + let binary = ""; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); } - }; - reader.readAsText(file); - } else { - reader.onload = () => { - if (reader.result instanceof ArrayBuffer) { - const bytes = new Uint8Array(reader.result); - let binary = ""; - for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]); - } - const base64 = btoa(binary); - resolve(base64); - } else { - reject(new Error("Failed to read binary file")); - } - }; - reader.readAsArrayBuffer(file); - } + const base64 = btoa(binary); + resolve(base64); + } else { + reject(new Error("Failed to read file")); + } + }; + reader.readAsArrayBuffer(file); }); await uploadSSHFile( diff --git a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx index e32ccf9b..a322148b 100644 --- a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx +++ b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx @@ -217,6 +217,7 @@ export function HostManagerEditor({ pin: z.boolean().default(false), authType: z.enum(["password", "key", "credential", "none"]), credentialId: z.number().optional().nullable(), + overrideCredentialUsername: z.boolean().optional(), password: z.string().optional(), key: z.any().optional().nullable(), keyPassword: z.string().optional(), @@ -389,6 +390,7 @@ export function HostManagerEditor({ pin: false, authType: "password" as const, credentialId: null, + overrideCredentialUsername: false, password: "", key: null, keyPassword: "", @@ -407,7 +409,8 @@ export function HostManagerEditor({ useEffect(() => { if (authTab === "credential") { const currentCredentialId = form.getValues("credentialId"); - if (currentCredentialId) { + const overrideUsername = form.getValues("overrideCredentialUsername"); + if (currentCredentialId && !overrideUsername) { const selectedCredential = credentials.find( (c) => c.id === currentCredentialId, ); @@ -464,6 +467,9 @@ export function HostManagerEditor({ pin: Boolean(cleanedHost.pin), authType: defaultAuthType as "password" | "key" | "credential" | "none", credentialId: null, + overrideCredentialUsername: Boolean( + cleanedHost.overrideCredentialUsername, + ), password: "", key: null, keyPassword: "", @@ -512,6 +518,7 @@ export function HostManagerEditor({ pin: false, authType: "password" as const, credentialId: null, + overrideCredentialUsername: false, password: "", key: null, keyPassword: "", @@ -574,6 +581,7 @@ export function HostManagerEditor({ tags: data.tags || [], pin: Boolean(data.pin), authType: data.authType, + overrideCredentialUsername: Boolean(data.overrideCredentialUsername), enableTerminal: Boolean(data.enableTerminal), enableTunnel: Boolean(data.enableTunnel), enableFileManager: Boolean(data.enableFileManager), @@ -882,17 +890,28 @@ export function HostManagerEditor({ ( - - {t("hosts.username")} - - - - - )} + render={({ field }) => { + const isCredentialAuth = authTab === "credential"; + const hasCredential = !!form.watch("credentialId"); + const overrideEnabled = !!form.watch( + "overrideCredentialUsername", + ); + const shouldDisable = + isCredentialAuth && hasCredential && !overrideEnabled; + + return ( + + {t("hosts.username")} + + + + + ); + }} /> @@ -1263,29 +1282,60 @@ export function HostManagerEditor({ - ( - - { - if (credential) { - form.setValue( - "username", - credential.username, - ); - } - }} - /> - - {t("hosts.credentialDescription")} - - +

+ ( + + { + if ( + credential && + !form.getValues( + "overrideCredentialUsername", + ) + ) { + form.setValue( + "username", + credential.username, + ); + } + }} + /> + + {t("hosts.credentialDescription")} + + + )} + /> + {form.watch("credentialId") && ( + ( + +
+ + {t("hosts.overrideCredentialUsername")} + + + {t("hosts.overrideCredentialUsernameDesc")} + +
+ + + +
+ )} + /> )} - /> +
diff --git a/src/ui/desktop/apps/terminal/Terminal.tsx b/src/ui/desktop/apps/terminal/Terminal.tsx index 4a35f9f8..a65baaaa 100644 --- a/src/ui/desktop/apps/terminal/Terminal.tsx +++ b/src/ui/desktop/apps/terminal/Terminal.tsx @@ -112,6 +112,7 @@ export const Terminal = forwardRef( const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] = useState(false); const isVisibleRef = useRef(false); + const isReadyRef = useRef(false); const isFittingRef = useRef(false); const reconnectTimeoutRef = useRef(null); const reconnectAttempts = useRef(0); @@ -157,6 +158,10 @@ export const Terminal = forwardRef( isVisibleRef.current = isVisible; }, [isVisible]); + useEffect(() => { + isReadyRef.current = isReady; + }, [isReady]); + useEffect(() => { const checkAuth = () => { const jwtToken = getCookie("jwt"); @@ -507,6 +512,9 @@ export const Terminal = forwardRef( }), ); terminal.onData((data) => { + if (data === "\x00" || data === "\u0000") { + return; + } ws.send(JSON.stringify({ type: "input", data })); }); @@ -915,15 +923,21 @@ export const Terminal = forwardRef( element?.addEventListener("keydown", handleMacKeyboard, true); - const resizeObserver = new ResizeObserver(() => { + const handleResize = () => { if (resizeTimeout.current) clearTimeout(resizeTimeout.current); resizeTimeout.current = setTimeout(() => { - if (!isVisibleRef.current || !isReady) return; + if (!isVisibleRef.current || !isReadyRef.current) return; performFit(); - }, 50); - }); + }, 100); + }; - resizeObserver.observe(xtermRef.current); + const resizeObserver = new ResizeObserver(handleResize); + + if (xtermRef.current) { + resizeObserver.observe(xtermRef.current); + } + + window.addEventListener("resize", handleResize); setVisible(true); @@ -936,6 +950,7 @@ export const Terminal = forwardRef( setIsReady(false); isFittingRef.current = false; resizeObserver.disconnect(); + window.removeEventListener("resize", handleResize); element?.removeEventListener("contextmenu", handleContextMenu); element?.removeEventListener("keydown", handleMacKeyboard, true); if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index 997d712b..51ccf418 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -558,65 +558,61 @@ export function Auth({ if (success) { setOidcLoading(true); - getUserInfo() - .then((meRes) => { - if (isInElectronWebView()) { - const token = getCookie("jwt") || localStorage.getItem("jwt"); - if (token) { - try { - window.parent.postMessage( - { - type: "AUTH_SUCCESS", - token: token, - source: "oidc_callback", - platform: "desktop", - timestamp: Date.now(), - }, - "*", - ); - setWebviewAuthSuccess(true); - setTimeout(() => window.location.reload(), 100); - setOidcLoading(false); - return; - } catch (e) { - console.error("Error posting auth success message:", e); + // Clear the success parameter first to prevent re-processing + window.history.replaceState({}, document.title, window.location.pathname); + + setTimeout(() => { + getUserInfo() + .then((meRes) => { + if (isInElectronWebView()) { + const token = getCookie("jwt") || localStorage.getItem("jwt"); + if (token) { + try { + window.parent.postMessage( + { + type: "AUTH_SUCCESS", + token: token, + source: "oidc_callback", + platform: "desktop", + timestamp: Date.now(), + }, + "*", + ); + setWebviewAuthSuccess(true); + setTimeout(() => window.location.reload(), 100); + setOidcLoading(false); + return; + } catch (e) { + console.error("Error posting auth success message:", e); + } } } - } - setInternalLoggedIn(true); - setLoggedIn(true); - setIsAdmin(!!meRes.is_admin); - setUsername(meRes.username || null); - setUserId(meRes.userId || null); - setDbError(null); - onAuthSuccess({ - isAdmin: !!meRes.is_admin, - username: meRes.username || null, - userId: meRes.userId || null, + setInternalLoggedIn(true); + setLoggedIn(true); + setIsAdmin(!!meRes.is_admin); + setUsername(meRes.username || null); + setUserId(meRes.userId || null); + setDbError(null); + onAuthSuccess({ + isAdmin: !!meRes.is_admin, + username: meRes.username || null, + userId: meRes.userId || null, + }); + setInternalLoggedIn(true); + }) + .catch((err) => { + console.error("Failed to get user info after OIDC callback:", err); + setInternalLoggedIn(false); + setLoggedIn(false); + setIsAdmin(false); + setUsername(null); + setUserId(null); + }) + .finally(() => { + setOidcLoading(false); }); - setInternalLoggedIn(true); - window.history.replaceState( - {}, - document.title, - window.location.pathname, - ); - }) - .catch(() => { - setInternalLoggedIn(false); - setLoggedIn(false); - setIsAdmin(false); - setUsername(null); - setUserId(null); - window.history.replaceState( - {}, - document.title, - window.location.pathname, - ); - }) - .finally(() => { - setOidcLoading(false); - }); + }, 200); } }, [ onAuthSuccess, diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index f27aa014..a524ed14 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -318,34 +318,37 @@ function createApiInstance( const errorMessage = (error.response?.data as Record) ?.error; const isSessionExpired = errorCode === "SESSION_EXPIRED"; + const isSessionNotFound = errorCode === "SESSION_NOT_FOUND"; const isInvalidToken = errorCode === "AUTH_REQUIRED" || errorMessage === "Invalid token" || errorMessage === "Authentication required"; - if (isElectron()) { - localStorage.removeItem("jwt"); - } else { - localStorage.removeItem("jwt"); - } + if (isSessionExpired || isSessionNotFound) { + if (isElectron()) { + localStorage.removeItem("jwt"); + } else { + localStorage.removeItem("jwt"); + } - if ( - (isSessionExpired || isInvalidToken) && - typeof window !== "undefined" - ) { + if (typeof window !== "undefined") { + console.warn("Session expired or not found - please log in again"); + + document.cookie = + "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; + + import("sonner").then(({ toast }) => { + toast.warning("Session expired. Please log in again."); + window.location.reload(); + }); + + setTimeout(() => window.location.reload(), 1000); + } + } else if (isInvalidToken && typeof window !== "undefined") { console.warn( - "Session expired or invalid token - please log in again", + "Authentication error - token may be invalid", + errorMessage, ); - - document.cookie = - "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; - - import("sonner").then(({ toast }) => { - toast.warning("Session expired. Please log in again."); - window.location.reload(); - }); - - setTimeout(() => window.location.reload(), 1000); } } @@ -792,6 +795,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise { keyType: hostData.authType === "key" ? hostData.keyType : null, credentialId: hostData.authType === "credential" ? hostData.credentialId : null, + overrideCredentialUsername: Boolean(hostData.overrideCredentialUsername), enableTerminal: Boolean(hostData.enableTerminal), enableTunnel: Boolean(hostData.enableTunnel), enableFileManager: Boolean(hostData.enableFileManager), @@ -855,6 +859,7 @@ export async function updateSSHHost( keyType: hostData.authType === "key" ? hostData.keyType : null, credentialId: hostData.authType === "credential" ? hostData.credentialId : null, + overrideCredentialUsername: Boolean(hostData.overrideCredentialUsername), enableTerminal: Boolean(hostData.enableTerminal), enableTunnel: Boolean(hostData.enableTunnel), enableFileManager: Boolean(hostData.enableFileManager), diff --git a/src/ui/mobile/authentication/Auth.tsx b/src/ui/mobile/authentication/Auth.tsx index 3751224b..792f72f7 100644 --- a/src/ui/mobile/authentication/Auth.tsx +++ b/src/ui/mobile/authentication/Auth.tsx @@ -504,55 +504,45 @@ export function Auth({ setOidcLoading(true); setError(null); - getUserInfo() - .then((meRes) => { - setIsAdmin(!!meRes.is_admin); - setUsername(meRes.username || null); - setUserId(meRes.userId || null); - setDbError(null); - postJWTToWebView(); + window.history.replaceState({}, document.title, window.location.pathname); - if (isReactNativeWebView()) { - setMobileAuthSuccess(true); + setTimeout(() => { + getUserInfo() + .then((meRes) => { + setIsAdmin(!!meRes.is_admin); + setUsername(meRes.username || null); + setUserId(meRes.userId || null); + setDbError(null); + postJWTToWebView(); + + if (isReactNativeWebView()) { + setMobileAuthSuccess(true); + setOidcLoading(false); + return; + } + + setLoggedIn(true); + onAuthSuccess({ + isAdmin: !!meRes.is_admin, + username: meRes.username || null, + userId: meRes.userId || null, + }); + + setInternalLoggedIn(true); + }) + .catch((err) => { + console.error("Failed to get user info after OIDC callback:", err); + setError(t("errors.failedUserInfo")); + setInternalLoggedIn(false); + setLoggedIn(false); + setIsAdmin(false); + setUsername(null); + setUserId(null); + }) + .finally(() => { setOidcLoading(false); - window.history.replaceState( - {}, - document.title, - window.location.pathname, - ); - return; - } - - setLoggedIn(true); - onAuthSuccess({ - isAdmin: !!meRes.is_admin, - username: meRes.username || null, - userId: meRes.userId || null, }); - - setInternalLoggedIn(true); - window.history.replaceState( - {}, - document.title, - window.location.pathname, - ); - }) - .catch(() => { - setError(t("errors.failedUserInfo")); - setInternalLoggedIn(false); - setLoggedIn(false); - setIsAdmin(false); - setUsername(null); - setUserId(null); - window.history.replaceState( - {}, - document.title, - window.location.pathname, - ); - }) - .finally(() => { - setOidcLoading(false); - }); + }, 200); } }, []); -- 2.49.1 From 398d5c0704a5d9314b6f15e929bdcf032e71f1d9 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 05:37:06 +0800 Subject: [PATCH 03/23] refactor: Modularize server stats widget collectors --- src/backend/ssh/server-stats.ts | 391 ++---------------- src/backend/ssh/widgets/common-utils.ts | 39 ++ src/backend/ssh/widgets/cpu-collector.ts | 83 ++++ src/backend/ssh/widgets/disk-collector.ts | 67 +++ .../ssh/widgets/login-stats-collector.ts | 117 ++++++ src/backend/ssh/widgets/memory-collector.ts | 41 ++ src/backend/ssh/widgets/network-collector.ts | 79 ++++ .../ssh/widgets/processes-collector.ts | 69 ++++ src/backend/ssh/widgets/system-collector.ts | 37 ++ src/backend/ssh/widgets/uptime-collector.ts | 35 ++ src/types/stats-widgets.ts | 13 +- .../apps/host-manager/HostManagerEditor.tsx | 15 +- src/ui/desktop/apps/server/Server.tsx | 6 + .../apps/server/widgets/LoginStatsWidget.tsx | 138 +++++++ src/ui/desktop/apps/server/widgets/index.ts | 1 + 15 files changed, 770 insertions(+), 361 deletions(-) create mode 100644 src/backend/ssh/widgets/common-utils.ts create mode 100644 src/backend/ssh/widgets/cpu-collector.ts create mode 100644 src/backend/ssh/widgets/disk-collector.ts create mode 100644 src/backend/ssh/widgets/login-stats-collector.ts create mode 100644 src/backend/ssh/widgets/memory-collector.ts create mode 100644 src/backend/ssh/widgets/network-collector.ts create mode 100644 src/backend/ssh/widgets/processes-collector.ts create mode 100644 src/backend/ssh/widgets/system-collector.ts create mode 100644 src/backend/ssh/widgets/uptime-collector.ts create mode 100644 src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index ce8f5deb..34582d6d 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -6,10 +6,18 @@ import { Client, type ConnectConfig } from "ssh2"; import { getDb } from "../database/db/index.js"; import { sshData, sshCredentials } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; -import { statsLogger } from "../utils/logger.js"; +import { statsLogger, sshLogger } from "../utils/logger.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { AuthManager } from "../utils/auth-manager.js"; import type { AuthenticatedRequest } from "../../types/index.js"; +import { collectCpuMetrics } from "./widgets/cpu-collector.js"; +import { collectMemoryMetrics } from "./widgets/memory-collector.js"; +import { collectDiskMetrics } from "./widgets/disk-collector.js"; +import { collectNetworkMetrics } from "./widgets/network-collector.js"; +import { collectUptimeMetrics } from "./widgets/uptime-collector.js"; +import { collectProcessesMetrics } from "./widgets/processes-collector.js"; +import { collectSystemMetrics } from "./widgets/system-collector.js"; +import { collectLoginStats } from "./widgets/login-stats-collector.js"; interface PooledConnection { client: Client; @@ -924,59 +932,6 @@ async function withSshConnection( } } -function execCommand( - client: Client, - command: string, -): Promise<{ - stdout: string; - stderr: string; - code: number | null; -}> { - return new Promise((resolve, reject) => { - client.exec(command, { pty: false }, (err, stream) => { - if (err) return reject(err); - let stdout = ""; - let stderr = ""; - let exitCode: number | null = null; - stream - .on("close", (code: number | undefined) => { - exitCode = typeof code === "number" ? code : null; - resolve({ stdout, stderr, code: exitCode }); - }) - .on("data", (data: Buffer) => { - stdout += data.toString("utf8"); - }) - .stderr.on("data", (data: Buffer) => { - stderr += data.toString("utf8"); - }); - }); - }); -} - -function parseCpuLine( - cpuLine: string, -): { total: number; idle: number } | undefined { - const parts = cpuLine.trim().split(/\s+/); - if (parts[0] !== "cpu") return undefined; - const nums = parts - .slice(1) - .map((n) => Number(n)) - .filter((n) => Number.isFinite(n)); - if (nums.length < 4) return undefined; - const idle = (nums[3] ?? 0) + (nums[4] ?? 0); - const total = nums.reduce((a, b) => a + b, 0); - return { total, idle }; -} - -function toFixedNum(n: number | null | undefined, digits = 2): number | null { - if (typeof n !== "number" || !Number.isFinite(n)) return null; - return Number(n.toFixed(digits)); -} - -function kibToGiB(kib: number): number { - return kib / (1024 * 1024); -} - async function collectMetrics(host: SSHHostWithCredentials): Promise<{ cpu: { percent: number | null; @@ -1039,318 +994,38 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ return requestQueue.queueRequest(host.id, async () => { try { return await withSshConnection(host, async (client) => { - let cpuPercent: number | null = null; - let cores: number | null = null; - let loadTriplet: [number, number, number] | null = null; + const cpu = await collectCpuMetrics(client); + const memory = await collectMemoryMetrics(client); + const disk = await collectDiskMetrics(client); + const network = await collectNetworkMetrics(client); + const uptime = await collectUptimeMetrics(client); + const processes = await collectProcessesMetrics(client); + const system = await collectSystemMetrics(client); + let login_stats = { + recentLogins: [], + failedLogins: [], + totalLogins: 0, + uniqueIPs: 0, + }; try { - const [stat1, loadAvgOut, coresOut] = await Promise.all([ - execCommand(client, "cat /proc/stat"), - execCommand(client, "cat /proc/loadavg"), - execCommand( - client, - "nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo", - ), - ]); - - await new Promise((r) => setTimeout(r, 500)); - const stat2 = await execCommand(client, "cat /proc/stat"); - - const cpuLine1 = ( - stat1.stdout.split("\n").find((l) => l.startsWith("cpu ")) || "" - ).trim(); - const cpuLine2 = ( - stat2.stdout.split("\n").find((l) => l.startsWith("cpu ")) || "" - ).trim(); - const a = parseCpuLine(cpuLine1); - const b = parseCpuLine(cpuLine2); - if (a && b) { - const totalDiff = b.total - a.total; - const idleDiff = b.idle - a.idle; - const used = totalDiff - idleDiff; - if (totalDiff > 0) - cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100)); - } - - const laParts = loadAvgOut.stdout.trim().split(/\s+/); - if (laParts.length >= 3) { - loadTriplet = [ - Number(laParts[0]), - Number(laParts[1]), - Number(laParts[2]), - ].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [ - number, - number, - number, - ]; - } - - const coresNum = Number((coresOut.stdout || "").trim()); - cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null; + login_stats = await collectLoginStats(client); } catch (e) { - cpuPercent = null; - cores = null; - loadTriplet = null; - } - - let memPercent: number | null = null; - let usedGiB: number | null = null; - let totalGiB: number | null = null; - try { - const memInfo = await execCommand(client, "cat /proc/meminfo"); - const lines = memInfo.stdout.split("\n"); - const getVal = (key: string) => { - const line = lines.find((l) => l.startsWith(key)); - if (!line) return null; - const m = line.match(/\d+/); - return m ? Number(m[0]) : null; - }; - const totalKb = getVal("MemTotal:"); - const availKb = getVal("MemAvailable:"); - if (totalKb && availKb && totalKb > 0) { - const usedKb = totalKb - availKb; - memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100)); - usedGiB = kibToGiB(usedKb); - totalGiB = kibToGiB(totalKb); - } - } catch (e) { - memPercent = null; - usedGiB = null; - totalGiB = null; - } - - let diskPercent: number | null = null; - let usedHuman: string | null = null; - let totalHuman: string | null = null; - let availableHuman: string | null = null; - try { - const [diskOutHuman, diskOutBytes] = await Promise.all([ - execCommand(client, "df -h -P / | tail -n +2"), - execCommand(client, "df -B1 -P / | tail -n +2"), - ]); - - const humanLine = - diskOutHuman.stdout - .split("\n") - .map((l) => l.trim()) - .filter(Boolean)[0] || ""; - const bytesLine = - diskOutBytes.stdout - .split("\n") - .map((l) => l.trim()) - .filter(Boolean)[0] || ""; - - const humanParts = humanLine.split(/\s+/); - const bytesParts = bytesLine.split(/\s+/); - - if (humanParts.length >= 6 && bytesParts.length >= 6) { - totalHuman = humanParts[1] || null; - usedHuman = humanParts[2] || null; - availableHuman = humanParts[3] || null; - - const totalBytes = Number(bytesParts[1]); - const usedBytes = Number(bytesParts[2]); - - if ( - Number.isFinite(totalBytes) && - Number.isFinite(usedBytes) && - totalBytes > 0 - ) { - diskPercent = Math.max( - 0, - Math.min(100, (usedBytes / totalBytes) * 100), - ); - } - } - } catch (e) { - diskPercent = null; - usedHuman = null; - totalHuman = null; - availableHuman = null; - } - - const interfaces: Array<{ - name: string; - ip: string; - state: string; - rxBytes: string | null; - txBytes: string | null; - }> = []; - try { - const ifconfigOut = await execCommand( - client, - "ip -o addr show | awk '{print $2,$4}' | grep -v '^lo'", - ); - const netStatOut = await execCommand( - client, - "ip -o link show | awk '{gsub(/:/, \"\", $2); print $2,$9}'", - ); - - const addrs = ifconfigOut.stdout - .split("\n") - .map((l) => l.trim()) - .filter(Boolean); - const states = netStatOut.stdout - .split("\n") - .map((l) => l.trim()) - .filter(Boolean); - - const ifMap = new Map(); - for (const line of addrs) { - const parts = line.split(/\s+/); - if (parts.length >= 2) { - const name = parts[0]; - const ip = parts[1].split("/")[0]; - if (!ifMap.has(name)) ifMap.set(name, { ip, state: "UNKNOWN" }); - } - } - for (const line of states) { - const parts = line.split(/\s+/); - if (parts.length >= 2) { - const name = parts[0]; - const state = parts[1]; - const existing = ifMap.get(name); - if (existing) { - existing.state = state; - } - } - } - - for (const [name, data] of ifMap.entries()) { - interfaces.push({ - name, - ip: data.ip, - state: data.state, - rxBytes: null, - txBytes: null, - }); - } - } catch (e) { - statsLogger.debug("Failed to collect network interface stats", { - operation: "network_stats_failed", - error: e instanceof Error ? e.message : String(e), - }); - } - - let uptimeSeconds: number | null = null; - let uptimeFormatted: string | null = null; - try { - const uptimeOut = await execCommand(client, "cat /proc/uptime"); - const uptimeParts = uptimeOut.stdout.trim().split(/\s+/); - if (uptimeParts.length >= 1) { - uptimeSeconds = Number(uptimeParts[0]); - if (Number.isFinite(uptimeSeconds)) { - const days = Math.floor(uptimeSeconds / 86400); - const hours = Math.floor((uptimeSeconds % 86400) / 3600); - const minutes = Math.floor((uptimeSeconds % 3600) / 60); - uptimeFormatted = `${days}d ${hours}h ${minutes}m`; - } - } - } catch (e) { - statsLogger.debug("Failed to collect uptime", { - operation: "uptime_failed", - error: e instanceof Error ? e.message : String(e), - }); - } - - let totalProcesses: number | null = null; - let runningProcesses: number | null = null; - const topProcesses: Array<{ - pid: string; - user: string; - cpu: string; - mem: string; - command: string; - }> = []; - try { - const psOut = await execCommand( - client, - "ps aux --sort=-%cpu | head -n 11", - ); - const psLines = psOut.stdout - .split("\n") - .map((l) => l.trim()) - .filter(Boolean); - if (psLines.length > 1) { - for (let i = 1; i < Math.min(psLines.length, 11); i++) { - const parts = psLines[i].split(/\s+/); - if (parts.length >= 11) { - topProcesses.push({ - pid: parts[1], - user: parts[0], - cpu: parts[2], - mem: parts[3], - command: parts.slice(10).join(" ").substring(0, 50), - }); - } - } - } - - const procCount = await execCommand(client, "ps aux | wc -l"); - const runningCount = await execCommand( - client, - "ps aux | grep -c ' R '", - ); - totalProcesses = Number(procCount.stdout.trim()) - 1; - runningProcesses = Number(runningCount.stdout.trim()); - } catch (e) { - statsLogger.debug("Failed to collect process stats", { - operation: "process_stats_failed", - error: e instanceof Error ? e.message : String(e), - }); - } - - let hostname: string | null = null; - let kernel: string | null = null; - let os: string | null = null; - try { - const hostnameOut = await execCommand(client, "hostname"); - const kernelOut = await execCommand(client, "uname -r"); - const osOut = await execCommand( - client, - "cat /etc/os-release | grep '^PRETTY_NAME=' | cut -d'\"' -f2", - ); - - hostname = hostnameOut.stdout.trim() || null; - kernel = kernelOut.stdout.trim() || null; - os = osOut.stdout.trim() || null; - } catch (e) { - statsLogger.debug("Failed to collect system info", { - operation: "system_info_failed", + statsLogger.debug("Failed to collect login stats", { + operation: "login_stats_failed", error: e instanceof Error ? e.message : String(e), }); } const result = { - cpu: { percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet }, - memory: { - percent: toFixedNum(memPercent, 0), - usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null, - totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null, - }, - disk: { - percent: toFixedNum(diskPercent, 0), - usedHuman, - totalHuman, - availableHuman, - }, - network: { - interfaces, - }, - uptime: { - seconds: uptimeSeconds, - formatted: uptimeFormatted, - }, - processes: { - total: totalProcesses, - running: runningProcesses, - top: topProcesses, - }, - system: { - hostname, - kernel, - os, - }, + cpu, + memory, + disk, + network, + uptime, + processes, + system, + login_stats, }; metricsCache.set(host.id, result); diff --git a/src/backend/ssh/widgets/common-utils.ts b/src/backend/ssh/widgets/common-utils.ts new file mode 100644 index 00000000..802c8571 --- /dev/null +++ b/src/backend/ssh/widgets/common-utils.ts @@ -0,0 +1,39 @@ +import type { Client } from "ssh2"; + +export function execCommand( + client: Client, + command: string, +): Promise<{ + stdout: string; + stderr: string; + code: number | null; +}> { + return new Promise((resolve, reject) => { + client.exec(command, { pty: false }, (err, stream) => { + if (err) return reject(err); + let stdout = ""; + let stderr = ""; + let exitCode: number | null = null; + stream + .on("close", (code: number | undefined) => { + exitCode = typeof code === "number" ? code : null; + resolve({ stdout, stderr, code: exitCode }); + }) + .on("data", (data: Buffer) => { + stdout += data.toString("utf8"); + }) + .stderr.on("data", (data: Buffer) => { + stderr += data.toString("utf8"); + }); + }); + }); +} + +export function toFixedNum(n: number | null | undefined, digits = 2): number | null { + if (typeof n !== "number" || !Number.isFinite(n)) return null; + return Number(n.toFixed(digits)); +} + +export function kibToGiB(kib: number): number { + return kib / (1024 * 1024); +} diff --git a/src/backend/ssh/widgets/cpu-collector.ts b/src/backend/ssh/widgets/cpu-collector.ts new file mode 100644 index 00000000..359ae6ad --- /dev/null +++ b/src/backend/ssh/widgets/cpu-collector.ts @@ -0,0 +1,83 @@ +import type { Client } from "ssh2"; +import { execCommand, toFixedNum } from "./common-utils.js"; + +function parseCpuLine( + cpuLine: string, +): { total: number; idle: number } | undefined { + const parts = cpuLine.trim().split(/\s+/); + if (parts[0] !== "cpu") return undefined; + const nums = parts + .slice(1) + .map((n) => Number(n)) + .filter((n) => Number.isFinite(n)); + if (nums.length < 4) return undefined; + const idle = (nums[3] ?? 0) + (nums[4] ?? 0); + const total = nums.reduce((a, b) => a + b, 0); + return { total, idle }; +} + +export async function collectCpuMetrics(client: Client): Promise<{ + percent: number | null; + cores: number | null; + load: [number, number, number] | null; +}> { + let cpuPercent: number | null = null; + let cores: number | null = null; + let loadTriplet: [number, number, number] | null = null; + + try { + const [stat1, loadAvgOut, coresOut] = await Promise.all([ + execCommand(client, "cat /proc/stat"), + execCommand(client, "cat /proc/loadavg"), + execCommand( + client, + "nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo", + ), + ]); + + await new Promise((r) => setTimeout(r, 500)); + const stat2 = await execCommand(client, "cat /proc/stat"); + + const cpuLine1 = ( + stat1.stdout.split("\n").find((l) => l.startsWith("cpu ")) || "" + ).trim(); + const cpuLine2 = ( + stat2.stdout.split("\n").find((l) => l.startsWith("cpu ")) || "" + ).trim(); + const a = parseCpuLine(cpuLine1); + const b = parseCpuLine(cpuLine2); + if (a && b) { + const totalDiff = b.total - a.total; + const idleDiff = b.idle - a.idle; + const used = totalDiff - idleDiff; + if (totalDiff > 0) + cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100)); + } + + const laParts = loadAvgOut.stdout.trim().split(/\s+/); + if (laParts.length >= 3) { + loadTriplet = [ + Number(laParts[0]), + Number(laParts[1]), + Number(laParts[2]), + ].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [ + number, + number, + number, + ]; + } + + const coresNum = Number((coresOut.stdout || "").trim()); + cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null; + } catch (e) { + cpuPercent = null; + cores = null; + loadTriplet = null; + } + + return { + percent: toFixedNum(cpuPercent, 0), + cores, + load: loadTriplet, + }; +} diff --git a/src/backend/ssh/widgets/disk-collector.ts b/src/backend/ssh/widgets/disk-collector.ts new file mode 100644 index 00000000..b221cee2 --- /dev/null +++ b/src/backend/ssh/widgets/disk-collector.ts @@ -0,0 +1,67 @@ +import type { Client } from "ssh2"; +import { execCommand, toFixedNum } from "./common-utils.js"; + +export async function collectDiskMetrics(client: Client): Promise<{ + percent: number | null; + usedHuman: string | null; + totalHuman: string | null; + availableHuman: string | null; +}> { + let diskPercent: number | null = null; + let usedHuman: string | null = null; + let totalHuman: string | null = null; + let availableHuman: string | null = null; + + try { + const [diskOutHuman, diskOutBytes] = await Promise.all([ + execCommand(client, "df -h -P / | tail -n +2"), + execCommand(client, "df -B1 -P / | tail -n +2"), + ]); + + const humanLine = + diskOutHuman.stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean)[0] || ""; + const bytesLine = + diskOutBytes.stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean)[0] || ""; + + const humanParts = humanLine.split(/\s+/); + const bytesParts = bytesLine.split(/\s+/); + + if (humanParts.length >= 6 && bytesParts.length >= 6) { + totalHuman = humanParts[1] || null; + usedHuman = humanParts[2] || null; + availableHuman = humanParts[3] || null; + + const totalBytes = Number(bytesParts[1]); + const usedBytes = Number(bytesParts[2]); + + if ( + Number.isFinite(totalBytes) && + Number.isFinite(usedBytes) && + totalBytes > 0 + ) { + diskPercent = Math.max( + 0, + Math.min(100, (usedBytes / totalBytes) * 100), + ); + } + } + } catch (e) { + diskPercent = null; + usedHuman = null; + totalHuman = null; + availableHuman = null; + } + + return { + percent: toFixedNum(diskPercent, 0), + usedHuman, + totalHuman, + availableHuman, + }; +} diff --git a/src/backend/ssh/widgets/login-stats-collector.ts b/src/backend/ssh/widgets/login-stats-collector.ts new file mode 100644 index 00000000..5147b146 --- /dev/null +++ b/src/backend/ssh/widgets/login-stats-collector.ts @@ -0,0 +1,117 @@ +import type { Client } from "ssh2"; +import { execCommand } from "./common-utils.js"; + +export interface LoginRecord { + user: string; + ip: string; + time: string; + status: "success" | "failed"; +} + +export interface LoginStats { + recentLogins: LoginRecord[]; + failedLogins: LoginRecord[]; + totalLogins: number; + uniqueIPs: number; +} + +export async function collectLoginStats(client: Client): Promise { + const recentLogins: LoginRecord[] = []; + const failedLogins: LoginRecord[] = []; + const ipSet = new Set(); + + try { + const lastOut = await execCommand( + client, + "last -n 20 -F -w | grep -v 'reboot' | grep -v 'wtmp' | head -20", + ); + + const lastLines = lastOut.stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + + for (const line of lastLines) { + const parts = line.split(/\s+/); + if (parts.length >= 10) { + const user = parts[0]; + const tty = parts[1]; + const ip = parts[2] === ":" || parts[2].startsWith(":") ? "local" : parts[2]; + + const timeStart = parts.indexOf(parts.find(p => /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/.test(p)) || ""); + if (timeStart > 0 && parts.length > timeStart + 4) { + const timeStr = parts.slice(timeStart, timeStart + 5).join(" "); + + if (user && user !== "wtmp" && tty !== "system") { + recentLogins.push({ + user, + ip, + time: new Date(timeStr).toISOString(), + status: "success", + }); + if (ip !== "local") { + ipSet.add(ip); + } + } + } + } + } + } catch (e) { + // Ignore errors + } + + try { + const failedOut = await execCommand( + client, + "grep 'Failed password' /var/log/auth.log 2>/dev/null | tail -10 || grep 'authentication failure' /var/log/secure 2>/dev/null | tail -10 || echo ''", + ); + + const failedLines = failedOut.stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + + for (const line of failedLines) { + let user = "unknown"; + let ip = "unknown"; + let timeStr = ""; + + const userMatch = line.match(/for (?:invalid user )?(\S+)/); + if (userMatch) { + user = userMatch[1]; + } + + const ipMatch = line.match(/from (\d+\.\d+\.\d+\.\d+)/); + if (ipMatch) { + ip = ipMatch[1]; + } + + const dateMatch = line.match(/^(\w+\s+\d+\s+\d+:\d+:\d+)/); + if (dateMatch) { + const currentYear = new Date().getFullYear(); + timeStr = `${currentYear} ${dateMatch[1]}`; + } + + if (user && ip) { + failedLogins.push({ + user, + ip, + time: timeStr ? new Date(timeStr).toISOString() : new Date().toISOString(), + status: "failed", + }); + if (ip !== "unknown") { + ipSet.add(ip); + } + } + } + } catch (e) { + // Ignore errors + } + + return { + recentLogins: recentLogins.slice(0, 10), + failedLogins: failedLogins.slice(0, 10), + totalLogins: recentLogins.length, + uniqueIPs: ipSet.size, + }; +} diff --git a/src/backend/ssh/widgets/memory-collector.ts b/src/backend/ssh/widgets/memory-collector.ts new file mode 100644 index 00000000..3dce5c64 --- /dev/null +++ b/src/backend/ssh/widgets/memory-collector.ts @@ -0,0 +1,41 @@ +import type { Client } from "ssh2"; +import { execCommand, toFixedNum, kibToGiB } from "./common-utils.js"; + +export async function collectMemoryMetrics(client: Client): Promise<{ + percent: number | null; + usedGiB: number | null; + totalGiB: number | null; +}> { + let memPercent: number | null = null; + let usedGiB: number | null = null; + let totalGiB: number | null = null; + + try { + const memInfo = await execCommand(client, "cat /proc/meminfo"); + const lines = memInfo.stdout.split("\n"); + const getVal = (key: string) => { + const line = lines.find((l) => l.startsWith(key)); + if (!line) return null; + const m = line.match(/\d+/); + return m ? Number(m[0]) : null; + }; + const totalKb = getVal("MemTotal:"); + const availKb = getVal("MemAvailable:"); + if (totalKb && availKb && totalKb > 0) { + const usedKb = totalKb - availKb; + memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100)); + usedGiB = kibToGiB(usedKb); + totalGiB = kibToGiB(totalKb); + } + } catch (e) { + memPercent = null; + usedGiB = null; + totalGiB = null; + } + + return { + percent: toFixedNum(memPercent, 0), + usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null, + totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null, + }; +} diff --git a/src/backend/ssh/widgets/network-collector.ts b/src/backend/ssh/widgets/network-collector.ts new file mode 100644 index 00000000..bd3a3bd9 --- /dev/null +++ b/src/backend/ssh/widgets/network-collector.ts @@ -0,0 +1,79 @@ +import type { Client } from "ssh2"; +import { execCommand } from "./common-utils.js"; +import { statsLogger } from "../../utils/logger.js"; + +export async function collectNetworkMetrics(client: Client): Promise<{ + interfaces: Array<{ + name: string; + ip: string; + state: string; + rxBytes: string | null; + txBytes: string | null; + }>; +}> { + const interfaces: Array<{ + name: string; + ip: string; + state: string; + rxBytes: string | null; + txBytes: string | null; + }> = []; + + try { + const ifconfigOut = await execCommand( + client, + "ip -o addr show | awk '{print $2,$4}' | grep -v '^lo'", + ); + const netStatOut = await execCommand( + client, + "ip -o link show | awk '{gsub(/:/, \"\", $2); print $2,$9}'", + ); + + const addrs = ifconfigOut.stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + const states = netStatOut.stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + + const ifMap = new Map(); + for (const line of addrs) { + const parts = line.split(/\s+/); + if (parts.length >= 2) { + const name = parts[0]; + const ip = parts[1].split("/")[0]; + if (!ifMap.has(name)) ifMap.set(name, { ip, state: "UNKNOWN" }); + } + } + for (const line of states) { + const parts = line.split(/\s+/); + if (parts.length >= 2) { + const name = parts[0]; + const state = parts[1]; + const existing = ifMap.get(name); + if (existing) { + existing.state = state; + } + } + } + + for (const [name, data] of ifMap.entries()) { + interfaces.push({ + name, + ip: data.ip, + state: data.state, + rxBytes: null, + txBytes: null, + }); + } + } catch (e) { + statsLogger.debug("Failed to collect network interface stats", { + operation: "network_stats_failed", + error: e instanceof Error ? e.message : String(e), + }); + } + + return { interfaces }; +} diff --git a/src/backend/ssh/widgets/processes-collector.ts b/src/backend/ssh/widgets/processes-collector.ts new file mode 100644 index 00000000..02f3ea11 --- /dev/null +++ b/src/backend/ssh/widgets/processes-collector.ts @@ -0,0 +1,69 @@ +import type { Client } from "ssh2"; +import { execCommand } from "./common-utils.js"; +import { statsLogger } from "../../utils/logger.js"; + +export async function collectProcessesMetrics(client: Client): Promise<{ + total: number | null; + running: number | null; + top: Array<{ + pid: string; + user: string; + cpu: string; + mem: string; + command: string; + }>; +}> { + let totalProcesses: number | null = null; + let runningProcesses: number | null = null; + const topProcesses: Array<{ + pid: string; + user: string; + cpu: string; + mem: string; + command: string; + }> = []; + + try { + const psOut = await execCommand( + client, + "ps aux --sort=-%cpu | head -n 11", + ); + const psLines = psOut.stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + if (psLines.length > 1) { + for (let i = 1; i < Math.min(psLines.length, 11); i++) { + const parts = psLines[i].split(/\s+/); + if (parts.length >= 11) { + topProcesses.push({ + pid: parts[1], + user: parts[0], + cpu: parts[2], + mem: parts[3], + command: parts.slice(10).join(" ").substring(0, 50), + }); + } + } + } + + const procCount = await execCommand(client, "ps aux | wc -l"); + const runningCount = await execCommand( + client, + "ps aux | grep -c ' R '", + ); + totalProcesses = Number(procCount.stdout.trim()) - 1; + runningProcesses = Number(runningCount.stdout.trim()); + } catch (e) { + statsLogger.debug("Failed to collect process stats", { + operation: "process_stats_failed", + error: e instanceof Error ? e.message : String(e), + }); + } + + return { + total: totalProcesses, + running: runningProcesses, + top: topProcesses, + }; +} diff --git a/src/backend/ssh/widgets/system-collector.ts b/src/backend/ssh/widgets/system-collector.ts new file mode 100644 index 00000000..e62c3ed0 --- /dev/null +++ b/src/backend/ssh/widgets/system-collector.ts @@ -0,0 +1,37 @@ +import type { Client } from "ssh2"; +import { execCommand } from "./common-utils.js"; +import { statsLogger } from "../../utils/logger.js"; + +export async function collectSystemMetrics(client: Client): Promise<{ + hostname: string | null; + kernel: string | null; + os: string | null; +}> { + let hostname: string | null = null; + let kernel: string | null = null; + let os: string | null = null; + + try { + const hostnameOut = await execCommand(client, "hostname"); + const kernelOut = await execCommand(client, "uname -r"); + const osOut = await execCommand( + client, + "cat /etc/os-release | grep '^PRETTY_NAME=' | cut -d'\"' -f2", + ); + + hostname = hostnameOut.stdout.trim() || null; + kernel = kernelOut.stdout.trim() || null; + os = osOut.stdout.trim() || null; + } catch (e) { + statsLogger.debug("Failed to collect system info", { + operation: "system_info_failed", + error: e instanceof Error ? e.message : String(e), + }); + } + + return { + hostname, + kernel, + os, + }; +} diff --git a/src/backend/ssh/widgets/uptime-collector.ts b/src/backend/ssh/widgets/uptime-collector.ts new file mode 100644 index 00000000..87e8dfcc --- /dev/null +++ b/src/backend/ssh/widgets/uptime-collector.ts @@ -0,0 +1,35 @@ +import type { Client } from "ssh2"; +import { execCommand } from "./common-utils.js"; +import { statsLogger } from "../../utils/logger.js"; + +export async function collectUptimeMetrics(client: Client): Promise<{ + seconds: number | null; + formatted: string | null; +}> { + let uptimeSeconds: number | null = null; + let uptimeFormatted: string | null = null; + + try { + const uptimeOut = await execCommand(client, "cat /proc/uptime"); + const uptimeParts = uptimeOut.stdout.trim().split(/\s+/); + if (uptimeParts.length >= 1) { + uptimeSeconds = Number(uptimeParts[0]); + if (Number.isFinite(uptimeSeconds)) { + const days = Math.floor(uptimeSeconds / 86400); + const hours = Math.floor((uptimeSeconds % 86400) / 3600); + const minutes = Math.floor((uptimeSeconds % 3600) / 60); + uptimeFormatted = `${days}d ${hours}h ${minutes}m`; + } + } + } catch (e) { + statsLogger.debug("Failed to collect uptime", { + operation: "uptime_failed", + error: e instanceof Error ? e.message : String(e), + }); + } + + return { + seconds: uptimeSeconds, + formatted: uptimeFormatted, + }; +} diff --git a/src/types/stats-widgets.ts b/src/types/stats-widgets.ts index eb450aa7..f7040ae4 100644 --- a/src/types/stats-widgets.ts +++ b/src/types/stats-widgets.ts @@ -5,7 +5,8 @@ export type WidgetType = | "network" | "uptime" | "processes" - | "system"; + | "system" + | "login_stats"; export interface StatsConfig { enabledWidgets: WidgetType[]; @@ -16,7 +17,15 @@ export interface StatsConfig { } export const DEFAULT_STATS_CONFIG: StatsConfig = { - enabledWidgets: ["cpu", "memory", "disk", "network", "uptime", "system"], + enabledWidgets: [ + "cpu", + "memory", + "disk", + "network", + "uptime", + "system", + "login_stats", + ], statusCheckEnabled: true, statusCheckInterval: 30, metricsEnabled: true, diff --git a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx index ad48b4a0..bbc1ad04 100644 --- a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx +++ b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx @@ -265,9 +265,18 @@ export function HostManagerEditor({ "uptime", "processes", "system", + "login_stats", ]), ) - .default(["cpu", "memory", "disk", "network", "uptime", "system"]), + .default([ + "cpu", + "memory", + "disk", + "network", + "uptime", + "system", + "login_stats", + ]), statusCheckEnabled: z.boolean().default(true), statusCheckInterval: z.number().min(5).max(3600).default(30), metricsEnabled: z.boolean().default(true), @@ -281,6 +290,7 @@ export function HostManagerEditor({ "network", "uptime", "system", + "login_stats", ], statusCheckEnabled: true, statusCheckInterval: 30, @@ -2611,6 +2621,7 @@ export function HostManagerEditor({ "uptime", "processes", "system", + "login_stats", ] as const ).map((widget) => (
))} diff --git a/src/ui/desktop/apps/server/Server.tsx b/src/ui/desktop/apps/server/Server.tsx index 09b37dd4..21879e0f 100644 --- a/src/ui/desktop/apps/server/Server.tsx +++ b/src/ui/desktop/apps/server/Server.tsx @@ -25,6 +25,7 @@ import { UptimeWidget, ProcessesWidget, SystemWidget, + LoginStatsWidget, } from "./widgets"; interface HostConfig { @@ -137,6 +138,11 @@ export function Server({ ); + case "login_stats": + return ( + + ); + default: return null; } diff --git a/src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx b/src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx new file mode 100644 index 00000000..61940581 --- /dev/null +++ b/src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx @@ -0,0 +1,138 @@ +import React from "react"; +import { UserCheck, UserX, MapPin, Activity } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +interface LoginRecord { + user: string; + ip: string; + time: string; + status: "success" | "failed"; +} + +interface LoginStatsMetrics { + recentLogins: LoginRecord[]; + failedLogins: LoginRecord[]; + totalLogins: number; + uniqueIPs: number; +} + +interface ServerMetrics { + login_stats?: LoginStatsMetrics; +} + +interface LoginStatsWidgetProps { + metrics: ServerMetrics | null; + metricsHistory: ServerMetrics[]; +} + +export function LoginStatsWidget({ + metrics, +}: LoginStatsWidgetProps) { + const { t } = useTranslation(); + + const loginStats = metrics?.login_stats; + const recentLogins = loginStats?.recentLogins || []; + const failedLogins = loginStats?.failedLogins || []; + const totalLogins = loginStats?.totalLogins || 0; + const uniqueIPs = loginStats?.uniqueIPs || 0; + + return ( +
+
+ +

+ SSH Login Statistics +

+
+ +
+
+
+
+ + Total Logins +
+
{totalLogins}
+
+
+
+ + Unique IPs +
+
{uniqueIPs}
+
+
+ +
+
+
+ + + Recent Successful Logins + +
+ {recentLogins.length === 0 ? ( +
+ No recent login data +
+ ) : ( +
+ {recentLogins.slice(0, 5).map((login, idx) => ( +
+
+ + {login.user} + + from + + {login.ip} + +
+ + {new Date(login.time).toLocaleString()} + +
+ ))} +
+ )} +
+ + {failedLogins.length > 0 && ( +
+
+ + + Recent Failed Attempts + +
+
+ {failedLogins.slice(0, 3).map((login, idx) => ( +
+
+ + {login.user} + + from + + {login.ip} + +
+ + {new Date(login.time).toLocaleString()} + +
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/src/ui/desktop/apps/server/widgets/index.ts b/src/ui/desktop/apps/server/widgets/index.ts index 2d227299..b72f8a11 100644 --- a/src/ui/desktop/apps/server/widgets/index.ts +++ b/src/ui/desktop/apps/server/widgets/index.ts @@ -5,3 +5,4 @@ export { NetworkWidget } from "./NetworkWidget"; export { UptimeWidget } from "./UptimeWidget"; export { ProcessesWidget } from "./ProcessesWidget"; export { SystemWidget } from "./SystemWidget"; +export { LoginStatsWidget } from "./LoginStatsWidget"; -- 2.49.1 From 62a77347777dc11ebfea97e51b344b5f30cd3905 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 06:13:27 +0800 Subject: [PATCH 04/23] feat: Add i18n support for terminal customization and login stats - Add comprehensive terminal customization translations (60+ keys) for appearance, behavior, and advanced settings across all 4 languages - Add SSH login statistics translations - Update HostManagerEditor to use i18n for all terminal customization UI elements - Update LoginStatsWidget to use i18n for all UI text - Add missing logger imports in backend files for improved debugging --- package-lock.json | 60 +++------ src/backend/database/routes/users.ts | 2 +- src/backend/ssh/file-manager.ts | 2 +- src/backend/ssh/tunnel.ts | 2 +- src/backend/utils/ssh-key-utils.ts | 1 + src/locales/de/translation.json | 72 +++++++++- src/locales/en/translation.json | 72 +++++++++- src/locales/ru/translation.json | 72 +++++++++- src/locales/zh/translation.json | 72 +++++++++- .../apps/host-manager/HostManagerEditor.tsx | 123 +++++++++--------- .../apps/server/widgets/LoginStatsWidget.tsx | 16 +-- 11 files changed, 373 insertions(+), 121 deletions(-) diff --git a/package-lock.json b/package-lock.json index 61e5b7c6..a00f83c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -154,7 +154,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -440,7 +439,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.19.1.tgz", "integrity": "sha512-q6NenYkEy2fn9+JyjIxMWcNjzTL/IhwqfzOut1/G3PrIFkrbl4AL7Wkse5tLrQUUyqGoAKU5+Pi5jnnXxH5HGw==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", @@ -489,7 +487,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", @@ -516,7 +513,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", @@ -544,7 +540,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", @@ -745,7 +740,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -822,7 +816,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "license": "MIT", - "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -844,7 +837,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -1172,7 +1164,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1559,6 +1550,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1580,6 +1572,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -2536,8 +2529,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.3.0.tgz", "integrity": "sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@lezer/cpp": { "version": "1.1.3", @@ -2577,7 +2569,6 @@ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", "license": "MIT", - "peer": true, "dependencies": { "@lezer/common": "^1.3.0" } @@ -2609,7 +2600,6 @@ "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", "license": "MIT", - "peer": true, "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", @@ -2632,7 +2622,6 @@ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", "license": "MIT", - "peer": true, "dependencies": { "@lezer/common": "^1.0.0" } @@ -4856,7 +4845,6 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -5014,7 +5002,6 @@ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -5137,7 +5124,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz", "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -5180,7 +5166,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -5191,7 +5176,6 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5359,7 +5343,6 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -5736,8 +5719,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/7zip-bin": { "version": "5.2.0", @@ -5772,7 +5754,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6195,7 +6176,6 @@ "integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -6330,7 +6310,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -7334,7 +7313,6 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -7411,7 +7389,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -7870,7 +7849,6 @@ "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.0.12", "builder-util": "26.0.11", @@ -7968,7 +7946,8 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz", "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==", - "license": "(MPL-2.0 OR Apache-2.0)" + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true }, "node_modules/dot-prop": { "version": "5.3.0", @@ -8320,6 +8299,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -8340,6 +8320,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -8355,6 +8336,7 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", + "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -8365,6 +8347,7 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -8627,7 +8610,6 @@ "integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10226,7 +10208,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -11768,6 +11749,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -13795,6 +13777,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -13812,6 +13795,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -14263,7 +14247,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14273,7 +14256,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14300,7 +14282,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -14448,7 +14429,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -14657,8 +14637,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -15979,6 +15958,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -16019,6 +15999,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -16033,6 +16014,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -16137,7 +16119,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16343,7 +16324,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16756,7 +16736,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -16848,7 +16827,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index e49c56ee..7f21cbae 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -22,7 +22,7 @@ import { nanoid } from "nanoid"; import speakeasy from "speakeasy"; import QRCode from "qrcode"; import type { Request, Response } from "express"; -import { authLogger } from "../../utils/logger.js"; +import { authLogger, databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js"; diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index a32bc138..70b0e22d 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -6,7 +6,7 @@ import { Client as SSHClient } from "ssh2"; import { getDb } from "../database/db/index.js"; import { sshCredentials, sshData } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; -import { fileLogger } from "../utils/logger.js"; +import { fileLogger, sshLogger } from "../utils/logger.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { AuthManager } from "../utils/auth-manager.js"; import type { AuthenticatedRequest } from "../../types/index.js"; diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index 58dd82f4..9d584ea2 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -15,7 +15,7 @@ import type { ErrorType, } from "../../types/index.js"; import { CONNECTION_STATES } from "../../types/index.js"; -import { tunnelLogger } from "../utils/logger.js"; +import { tunnelLogger, sshLogger } from "../utils/logger.js"; import { SystemCrypto } from "../utils/system-crypto.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { DataCrypto } from "../utils/data-crypto.js"; diff --git a/src/backend/utils/ssh-key-utils.ts b/src/backend/utils/ssh-key-utils.ts index 86aeec4b..132d3391 100644 --- a/src/backend/utils/ssh-key-utils.ts +++ b/src/backend/utils/ssh-key-utils.ts @@ -1,4 +1,5 @@ import ssh2Pkg from "ssh2"; +import { sshLogger } from "./logger.js"; const ssh2Utils = ssh2Pkg.utils; function detectKeyTypeFromContent(keyContent: string): string { diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 37fff37f..2c9c4207 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -706,6 +706,69 @@ "statusMonitoring": "Status", "metricsMonitoring": "Metriken", "terminalCustomizationNotice": "Hinweis: Terminal-Anpassungen funktionieren nur in der Desktop-Website-Version. Mobile und Electron-Apps verwenden die Standard-Terminaleinstellungen des Systems.", + "terminalCustomization": "Terminal-Anpassung", + "appearance": "Aussehen", + "behavior": "Verhalten", + "advanced": "Erweitert", + "themePreview": "Themen-Vorschau", + "theme": "Thema", + "selectTheme": "Thema auswählen", + "chooseColorTheme": "Wählen Sie ein Farbthema für das Terminal", + "fontFamily": "Schriftfamilie", + "selectFont": "Schriftart auswählen", + "selectFontDesc": "Wählen Sie die im Terminal zu verwendende Schriftart", + "fontSize": "Schriftgröße", + "fontSizeValue": "Schriftgröße: {{value}}px", + "adjustFontSize": "Terminal-Schriftgröße anpassen", + "letterSpacing": "Zeichenabstand", + "letterSpacingValue": "Zeichenabstand: {{value}}px", + "adjustLetterSpacing": "Abstand zwischen Zeichen anpassen", + "lineHeight": "Zeilenhöhe", + "lineHeightValue": "Zeilenhöhe: {{value}}", + "adjustLineHeight": "Abstand zwischen Zeilen anpassen", + "cursorStyle": "Cursor-Stil", + "selectCursorStyle": "Cursor-Stil auswählen", + "cursorStyleBlock": "Block", + "cursorStyleUnderline": "Unterstrich", + "cursorStyleBar": "Balken", + "chooseCursorAppearance": "Cursor-Erscheinungsbild wählen", + "cursorBlink": "Cursor-Blinken", + "enableCursorBlink": "Cursor-Blinkanimation aktivieren", + "scrollbackBuffer": "Rückwärts-Puffer", + "scrollbackBufferValue": "Rückwärts-Puffer: {{value}} Zeilen", + "scrollbackBufferDesc": "Anzahl der Zeilen im Rückwärtsverlauf", + "bellStyle": "Signalton-Stil", + "selectBellStyle": "Signalton-Stil auswählen", + "bellStyleNone": "Keine", + "bellStyleSound": "Ton", + "bellStyleVisual": "Visuell", + "bellStyleBoth": "Beides", + "bellStyleDesc": "Behandlung des Terminal-Signaltons (BEL-Zeichen, \\x07). Programme lösen dies aus, wenn Aufgaben abgeschlossen werden, Fehler auftreten oder für Benachrichtigungen. \"Ton\" spielt einen akustischen Signalton ab, \"Visuell\" lässt den Bildschirm kurz aufblinken, \"Beides\" macht beides, \"Keine\" deaktiviert Signalton-Benachrichtigungen.", + "rightClickSelectsWord": "Rechtsklick wählt Wort", + "rightClickSelectsWordDesc": "Rechtsklick wählt das Wort unter dem Cursor aus", + "fastScrollModifier": "Schnellscroll-Modifikator", + "selectModifier": "Modifikator auswählen", + "modifierAlt": "Alt", + "modifierCtrl": "Strg", + "modifierShift": "Umschalt", + "fastScrollModifierDesc": "Modifikatortaste für schnelles Scrollen", + "fastScrollSensitivity": "Schnellscroll-Empfindlichkeit", + "fastScrollSensitivityValue": "Schnellscroll-Empfindlichkeit: {{value}}", + "fastScrollSensitivityDesc": "Scroll-Geschwindigkeitsmultiplikator bei gedrücktem Modifikator", + "minimumContrastRatio": "Minimales Kontrastverhältnis", + "minimumContrastRatioValue": "Minimales Kontrastverhältnis: {{value}}", + "minimumContrastRatioDesc": "Farben automatisch für bessere Lesbarkeit anpassen", + "sshAgentForwarding": "SSH-Agent-Weiterleitung", + "sshAgentForwardingDesc": "SSH-Authentifizierungsagent an Remote-Host weiterleiten", + "backspaceMode": "Rücktaste-Modus", + "selectBackspaceMode": "Rücktaste-Modus auswählen", + "backspaceModeNormal": "Normal (DEL)", + "backspaceModeControlH": "Control-H (^H)", + "backspaceModeDesc": "Rücktasten-Verhalten für Kompatibilität", + "startupSnippet": "Start-Snippet", + "selectSnippet": "Snippet auswählen", + "searchSnippets": "Snippets durchsuchen...", + "snippetNone": "Keine", "noneAuthTitle": "Keyboard-Interactive-Authentifizierung", "noneAuthDescription": "Diese Authentifizierungsmethode verwendet beim Herstellen der Verbindung zum SSH-Server die Keyboard-Interactive-Authentifizierung.", "noneAuthDetails": "Keyboard-Interactive-Authentifizierung ermöglicht dem Server, Sie während der Verbindung zur Eingabe von Anmeldeinformationen aufzufordern. Dies ist nützlich für Server, die eine Multi-Faktor-Authentifizierung oder eine dynamische Passworteingabe erfordern.", @@ -1119,7 +1182,14 @@ "noInterfacesFound": "Keine Netzwerkschnittstellen gefunden", "totalProcesses": "Gesamtprozesse", "running": "läuft", - "noProcessesFound": "Keine Prozesse gefunden" + "noProcessesFound": "Keine Prozesse gefunden", + "loginStats": "SSH-Anmeldestatistiken", + "totalLogins": "Gesamtanmeldungen", + "uniqueIPs": "Eindeutige IPs", + "recentSuccessfulLogins": "Letzte erfolgreiche Anmeldungen", + "recentFailedAttempts": "Letzte fehlgeschlagene Versuche", + "noRecentLoginData": "Keine aktuellen Anmeldedaten", + "from": "von" }, "auth": { "loginTitle": "Melden Sie sich bei Termix an", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 8012ad05..853d5ae6 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -778,6 +778,69 @@ "statusMonitoring": "Status", "metricsMonitoring": "Metrics", "terminalCustomizationNotice": "Note: Terminal customizations only work on desktop (website and Electron app). Mobile apps and mobile website use system default terminal settings.", + "terminalCustomization": "Terminal Customization", + "appearance": "Appearance", + "behavior": "Behavior", + "advanced": "Advanced", + "themePreview": "Theme Preview", + "theme": "Theme", + "selectTheme": "Select theme", + "chooseColorTheme": "Choose a color theme for the terminal", + "fontFamily": "Font Family", + "selectFont": "Select font", + "selectFontDesc": "Select the font to use in the terminal", + "fontSize": "Font Size", + "fontSizeValue": "Font Size: {{value}}px", + "adjustFontSize": "Adjust the terminal font size", + "letterSpacing": "Letter Spacing", + "letterSpacingValue": "Letter Spacing: {{value}}px", + "adjustLetterSpacing": "Adjust spacing between characters", + "lineHeight": "Line Height", + "lineHeightValue": "Line Height: {{value}}", + "adjustLineHeight": "Adjust spacing between lines", + "cursorStyle": "Cursor Style", + "selectCursorStyle": "Select cursor style", + "cursorStyleBlock": "Block", + "cursorStyleUnderline": "Underline", + "cursorStyleBar": "Bar", + "chooseCursorAppearance": "Choose the cursor appearance", + "cursorBlink": "Cursor Blink", + "enableCursorBlink": "Enable cursor blinking animation", + "scrollbackBuffer": "Scrollback Buffer", + "scrollbackBufferValue": "Scrollback Buffer: {{value}} lines", + "scrollbackBufferDesc": "Number of lines to keep in scrollback history", + "bellStyle": "Bell Style", + "selectBellStyle": "Select bell style", + "bellStyleNone": "None", + "bellStyleSound": "Sound", + "bellStyleVisual": "Visual", + "bellStyleBoth": "Both", + "bellStyleDesc": "How to handle terminal bell (BEL character, \\x07). Programs trigger this when completing tasks, encountering errors, or for notifications. \"Sound\" plays an audio beep, \"Visual\" flashes the screen briefly, \"Both\" does both, \"None\" disables bell alerts.", + "rightClickSelectsWord": "Right Click Selects Word", + "rightClickSelectsWordDesc": "Right-clicking selects the word under cursor", + "fastScrollModifier": "Fast Scroll Modifier", + "selectModifier": "Select modifier", + "modifierAlt": "Alt", + "modifierCtrl": "Ctrl", + "modifierShift": "Shift", + "fastScrollModifierDesc": "Modifier key for fast scrolling", + "fastScrollSensitivity": "Fast Scroll Sensitivity", + "fastScrollSensitivityValue": "Fast Scroll Sensitivity: {{value}}", + "fastScrollSensitivityDesc": "Scroll speed multiplier when modifier is held", + "minimumContrastRatio": "Minimum Contrast Ratio", + "minimumContrastRatioValue": "Minimum Contrast Ratio: {{value}}", + "minimumContrastRatioDesc": "Automatically adjust colors for better readability", + "sshAgentForwarding": "SSH Agent Forwarding", + "sshAgentForwardingDesc": "Forward SSH authentication agent to remote host", + "backspaceMode": "Backspace Mode", + "selectBackspaceMode": "Select backspace mode", + "backspaceModeNormal": "Normal (DEL)", + "backspaceModeControlH": "Control-H (^H)", + "backspaceModeDesc": "Backspace key behavior for compatibility", + "startupSnippet": "Startup Snippet", + "selectSnippet": "Select snippet", + "searchSnippets": "Search snippets...", + "snippetNone": "None", "noneAuthTitle": "Keyboard-Interactive Authentication", "noneAuthDescription": "This authentication method will use keyboard-interactive authentication when connecting to the SSH server.", "noneAuthDetails": "Keyboard-interactive authentication allows the server to prompt you for credentials during connection. This is useful for servers that require multi-factor authentication or if you do not want to save credentials locally.", @@ -1228,7 +1291,14 @@ "noInterfacesFound": "No network interfaces found", "totalProcesses": "Total Processes", "running": "Running", - "noProcessesFound": "No processes found" + "noProcessesFound": "No processes found", + "loginStats": "SSH Login Statistics", + "totalLogins": "Total Logins", + "uniqueIPs": "Unique IPs", + "recentSuccessfulLogins": "Recent Successful Logins", + "recentFailedAttempts": "Recent Failed Attempts", + "noRecentLoginData": "No recent login data", + "from": "from" }, "auth": { "loginTitle": "Login to Termix", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 4a757ac7..1f3fb9bd 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -766,6 +766,69 @@ "statusMonitoring": "Статус", "metricsMonitoring": "Метрики", "terminalCustomizationNotice": "Примечание: Настройки терминала работают только на рабочем столе (веб-сайт и Electron-приложение). Мобильные приложения и мобильный веб-сайт используют системные настройки терминала по умолчанию.", + "terminalCustomization": "Настройка терминала", + "appearance": "Внешний вид", + "behavior": "Поведение", + "advanced": "Расширенные", + "themePreview": "Предпросмотр темы", + "theme": "Тема", + "selectTheme": "Выбрать тему", + "chooseColorTheme": "Выберите цветовую тему для терминала", + "fontFamily": "Семейство шрифтов", + "selectFont": "Выбрать шрифт", + "selectFontDesc": "Выберите шрифт для использования в терминале", + "fontSize": "Размер шрифта", + "fontSizeValue": "Размер шрифта: {{value}}px", + "adjustFontSize": "Настроить размер шрифта терминала", + "letterSpacing": "Межбуквенный интервал", + "letterSpacingValue": "Межбуквенный интервал: {{value}}px", + "adjustLetterSpacing": "Настроить расстояние между символами", + "lineHeight": "Высота строки", + "lineHeightValue": "Высота строки: {{value}}", + "adjustLineHeight": "Настроить расстояние между строками", + "cursorStyle": "Стиль курсора", + "selectCursorStyle": "Выбрать стиль курсора", + "cursorStyleBlock": "Блок", + "cursorStyleUnderline": "Подчеркивание", + "cursorStyleBar": "Полоса", + "chooseCursorAppearance": "Выбрать внешний вид курсора", + "cursorBlink": "Мигание курсора", + "enableCursorBlink": "Включить анимацию мигания курсора", + "scrollbackBuffer": "Буфер прокрутки", + "scrollbackBufferValue": "Буфер прокрутки: {{value}} строк", + "scrollbackBufferDesc": "Количество строк для хранения в истории прокрутки", + "bellStyle": "Стиль звонка", + "selectBellStyle": "Выбрать стиль звонка", + "bellStyleNone": "Нет", + "bellStyleSound": "Звук", + "bellStyleVisual": "Визуальный", + "bellStyleBoth": "Оба", + "bellStyleDesc": "Как обрабатывать звонок терминала (символ BEL, \\x07). Программы вызывают его при завершении задач, возникновении ошибок или для уведомлений. \"Звук\" воспроизводит звуковой сигнал, \"Визуальный\" кратковременно мигает экран, \"Оба\" делает и то, и другое, \"Нет\" отключает звуковые оповещения.", + "rightClickSelectsWord": "Правый клик выбирает слово", + "rightClickSelectsWordDesc": "Правый клик выбирает слово под курсором", + "fastScrollModifier": "Модификатор быстрой прокрутки", + "selectModifier": "Выбрать модификатор", + "modifierAlt": "Alt", + "modifierCtrl": "Ctrl", + "modifierShift": "Shift", + "fastScrollModifierDesc": "Клавиша-модификатор для быстрой прокрутки", + "fastScrollSensitivity": "Чувствительность быстрой прокрутки", + "fastScrollSensitivityValue": "Чувствительность быстрой прокрутки: {{value}}", + "fastScrollSensitivityDesc": "Множитель скорости прокрутки при удержании модификатора", + "minimumContrastRatio": "Минимальная контрастность", + "minimumContrastRatioValue": "Минимальная контрастность: {{value}}", + "minimumContrastRatioDesc": "Автоматически настраивать цвета для лучшей читаемости", + "sshAgentForwarding": "Переадресация SSH-агента", + "sshAgentForwardingDesc": "Переадресовать агент SSH-аутентификации на удаленный хост", + "backspaceMode": "Режим Backspace", + "selectBackspaceMode": "Выбрать режим Backspace", + "backspaceModeNormal": "Обычный (DEL)", + "backspaceModeControlH": "Control-H (^H)", + "backspaceModeDesc": "Поведение клавиши Backspace для совместимости", + "startupSnippet": "Сниппет запуска", + "selectSnippet": "Выбрать сниппет", + "searchSnippets": "Поиск сниппетов...", + "snippetNone": "Нет", "noneAuthTitle": "Интерактивная аутентификация по клавиатуре", "noneAuthDescription": "Этот метод аутентификации будет использовать интерактивную аутентификацию по клавиатуре при подключении к SSH-серверу.", "noneAuthDetails": "Интерактивная аутентификация по клавиатуре позволяет серверу запрашивать у вас учетные данные во время подключения. Это полезно для серверов, которые требуют многофакторную аутентификацию или динамический ввод пароля." @@ -1215,7 +1278,14 @@ "noInterfacesFound": "Сетевые интерфейсы не найдены", "totalProcesses": "Всего процессов", "running": "Запущено", - "noProcessesFound": "Процессы не найдены" + "noProcessesFound": "Процессы не найдены", + "loginStats": "Статистика входов SSH", + "totalLogins": "Всего входов", + "uniqueIPs": "Уникальные IP", + "recentSuccessfulLogins": "Последние успешные входы", + "recentFailedAttempts": "Последние неудачные попытки", + "noRecentLoginData": "Нет данных о недавних входах", + "from": "с" }, "auth": { "loginTitle": "Вход в Termix", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index e9c7c14e..bac2f366 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -790,6 +790,69 @@ "statusMonitoring": "状态", "metricsMonitoring": "指标", "terminalCustomizationNotice": "注意:终端自定义仅在桌面网站版本中有效。移动和 Electron 应用程序使用系统默认终端设置。", + "terminalCustomization": "终端自定义", + "appearance": "外观", + "behavior": "行为", + "advanced": "高级", + "themePreview": "主题预览", + "theme": "主题", + "selectTheme": "选择主题", + "chooseColorTheme": "选择终端的颜色主题", + "fontFamily": "字体系列", + "selectFont": "选择字体", + "selectFontDesc": "选择终端使用的字体", + "fontSize": "字体大小", + "fontSizeValue": "字体大小:{{value}}px", + "adjustFontSize": "调整终端字体大小", + "letterSpacing": "字母间距", + "letterSpacingValue": "字母间距:{{value}}px", + "adjustLetterSpacing": "调整字符之间的间距", + "lineHeight": "行高", + "lineHeightValue": "行高:{{value}}", + "adjustLineHeight": "调整行之间的间距", + "cursorStyle": "光标样式", + "selectCursorStyle": "选择光标样式", + "cursorStyleBlock": "块状", + "cursorStyleUnderline": "下划线", + "cursorStyleBar": "竖线", + "chooseCursorAppearance": "选择光标外观", + "cursorBlink": "光标闪烁", + "enableCursorBlink": "启用光标闪烁动画", + "scrollbackBuffer": "回滚缓冲区", + "scrollbackBufferValue": "回滚缓冲区:{{value}} 行", + "scrollbackBufferDesc": "保留在回滚历史记录中的行数", + "bellStyle": "铃声样式", + "selectBellStyle": "选择铃声样式", + "bellStyleNone": "无", + "bellStyleSound": "声音", + "bellStyleVisual": "视觉", + "bellStyleBoth": "两者", + "bellStyleDesc": "如何处理终端铃声(BEL字符,\\x07)。程序在完成任务、遇到错误或通知时会触发此功能。\"声音\"播放音频提示音,\"视觉\"短暂闪烁屏幕,\"两者\"同时执行,\"无\"禁用铃声提醒。", + "rightClickSelectsWord": "右键选择单词", + "rightClickSelectsWordDesc": "右键单击选择光标下的单词", + "fastScrollModifier": "快速滚动修饰键", + "selectModifier": "选择修饰键", + "modifierAlt": "Alt", + "modifierCtrl": "Ctrl", + "modifierShift": "Shift", + "fastScrollModifierDesc": "快速滚动的修饰键", + "fastScrollSensitivity": "快速滚动灵敏度", + "fastScrollSensitivityValue": "快速滚动灵敏度:{{value}}", + "fastScrollSensitivityDesc": "按住修饰键时的滚动速度倍数", + "minimumContrastRatio": "最小对比度", + "minimumContrastRatioValue": "最小对比度:{{value}}", + "minimumContrastRatioDesc": "自动调整颜色以获得更好的可读性", + "sshAgentForwarding": "SSH 代理转发", + "sshAgentForwardingDesc": "将 SSH 身份验证代理转发到远程主机", + "backspaceMode": "退格模式", + "selectBackspaceMode": "选择退格模式", + "backspaceModeNormal": "正常 (DEL)", + "backspaceModeControlH": "Control-H (^H)", + "backspaceModeDesc": "退格键行为兼容性", + "startupSnippet": "启动代码片段", + "selectSnippet": "选择代码片段", + "searchSnippets": "搜索代码片段...", + "snippetNone": "无", "noneAuthTitle": "键盘交互式认证", "noneAuthDescription": "此认证方法在连接到 SSH 服务器时将使用键盘交互式认证。", "noneAuthDetails": "键盘交互式认证允许服务器在连接期间提示您输入凭据。这对于需要多因素认证或动态密码输入的服务器很有用。", @@ -1199,7 +1262,14 @@ "noInterfacesFound": "未找到网络接口", "totalProcesses": "总进程数", "running": "运行中", - "noProcessesFound": "未找到进程" + "noProcessesFound": "未找到进程", + "loginStats": "SSH 登录统计", + "totalLogins": "总登录次数", + "uniqueIPs": "唯一 IP 数", + "recentSuccessfulLogins": "最近成功登录", + "recentFailedAttempts": "最近失败尝试", + "noRecentLoginData": "无最近登录数据", + "from": "来自" }, "auth": { "loginTitle": "登录 Termix", diff --git a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx index bbc1ad04..3c7803a2 100644 --- a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx +++ b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx @@ -1363,15 +1363,15 @@ export function HostManagerEditor({

- Terminal Customization + {t("hosts.terminalCustomization")}

- Appearance + {t("hosts.appearance")}
( - Theme + {t("hosts.theme")} - Choose a color theme for the terminal + {t("hosts.chooseColorTheme")} )} @@ -1427,14 +1427,14 @@ export function HostManagerEditor({ name="terminalConfig.fontFamily" render={({ field }) => ( - Font Family + {t("hosts.fontFamily")} - Select the font to use in the terminal + {t("hosts.selectFontDesc")} )} @@ -1460,7 +1460,7 @@ export function HostManagerEditor({ name="terminalConfig.fontSize" render={({ field }) => ( - Font Size: {field.value}px + {t("hosts.fontSizeValue", { value: field.value })} - Adjust the terminal font size + {t("hosts.adjustFontSize")} )} @@ -1485,7 +1485,7 @@ export function HostManagerEditor({ render={({ field }) => ( - Letter Spacing: {field.value}px + {t("hosts.letterSpacingValue", { value: field.value })} - Adjust spacing between characters + {t("hosts.adjustLetterSpacing")} )} @@ -1510,7 +1510,7 @@ export function HostManagerEditor({ name="terminalConfig.lineHeight" render={({ field }) => ( - Line Height: {field.value} + {t("hosts.lineHeightValue", { value: field.value })} - Adjust spacing between lines + {t("hosts.adjustLineHeight")} )} @@ -1534,26 +1534,26 @@ export function HostManagerEditor({ name="terminalConfig.cursorStyle" render={({ field }) => ( - Cursor Style + {t("hosts.cursorStyle")} - Choose the cursor appearance + {t("hosts.chooseCursorAppearance")} )} @@ -1565,9 +1565,9 @@ export function HostManagerEditor({ render={({ field }) => (
- Cursor Blink + {t("hosts.cursorBlink")} - Enable cursor blinking animation + {t("hosts.enableCursorBlink")}
@@ -1583,7 +1583,7 @@ export function HostManagerEditor({ - Behavior + {t("hosts.behavior")} ( - Scrollback Buffer: {field.value} lines + {t("hosts.scrollbackBufferValue", { value: field.value })} - Number of lines to keep in scrollback history + {t("hosts.scrollbackBufferDesc")} )} @@ -1616,30 +1616,25 @@ export function HostManagerEditor({ name="terminalConfig.bellStyle" render={({ field }) => ( - Bell Style + {t("hosts.bellStyle")} - How to handle terminal bell (BEL character, - \x07). Programs trigger this when completing - tasks, encountering errors, or for - notifications. "Sound" plays an audio beep, - "Visual" flashes the screen briefly, "Both" does - both, "None" disables bell alerts. + {t("hosts.bellStyleDesc")} )} @@ -1651,9 +1646,9 @@ export function HostManagerEditor({ render={({ field }) => (
- Right Click Selects Word + {t("hosts.rightClickSelectsWord")} - Right-clicking selects the word under cursor + {t("hosts.rightClickSelectsWordDesc")}
@@ -1671,24 +1666,24 @@ export function HostManagerEditor({ name="terminalConfig.fastScrollModifier" render={({ field }) => ( - Fast Scroll Modifier + {t("hosts.fastScrollModifier")} - Modifier key for fast scrolling + {t("hosts.fastScrollModifierDesc")} )} @@ -1700,7 +1695,7 @@ export function HostManagerEditor({ render={({ field }) => ( - Fast Scroll Sensitivity: {field.value} + {t("hosts.fastScrollSensitivityValue", { value: field.value })} - Scroll speed multiplier when modifier is held + {t("hosts.fastScrollSensitivityDesc")} )} @@ -1726,7 +1721,7 @@ export function HostManagerEditor({ render={({ field }) => ( - Minimum Contrast Ratio: {field.value} + {t("hosts.minimumContrastRatioValue", { value: field.value })} - Automatically adjust colors for better - readability + {t("hosts.minimumContrastRatioDesc")} )} @@ -1750,7 +1744,7 @@ export function HostManagerEditor({
- Advanced + {t("hosts.advanced")} (
- SSH Agent Forwarding + {t("hosts.sshAgentForwarding")} - Forward SSH authentication agent to remote - host + {t("hosts.sshAgentForwardingDesc")}
@@ -1779,27 +1772,27 @@ export function HostManagerEditor({ name="terminalConfig.backspaceMode" render={({ field }) => ( - Backspace Mode + {t("hosts.backspaceMode")} - Backspace key behavior for compatibility + {t("hosts.backspaceModeDesc")} )} @@ -1810,7 +1803,7 @@ export function HostManagerEditor({ name="terminalConfig.startupSnippetId" render={({ field }) => ( - Startup Snippet + {t("hosts.startupSnippet")} setSnippetSearch(e.target.value) @@ -1839,7 +1832,7 @@ export function HostManagerEditor({ />
- None + {t("hosts.snippetNone")} {snippets .filter((snippet) => snippet.name @@ -2662,7 +2655,7 @@ export function HostManagerEditor({ {widget === "system" && t("serverStats.systemInfo")} {widget === "login_stats" && - "SSH Login Statistics"} + t("serverStats.loginStats")}
))} diff --git a/src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx b/src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx index 61940581..ecea0082 100644 --- a/src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx +++ b/src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx @@ -41,7 +41,7 @@ export function LoginStatsWidget({

- SSH Login Statistics + {t("serverStats.loginStats")}

@@ -50,14 +50,14 @@ export function LoginStatsWidget({
- Total Logins + {t("serverStats.totalLogins")}
{totalLogins}
- Unique IPs + {t("serverStats.uniqueIPs")}
{uniqueIPs}
@@ -68,12 +68,12 @@ export function LoginStatsWidget({
- Recent Successful Logins + {t("serverStats.recentSuccessfulLogins")}
{recentLogins.length === 0 ? (
- No recent login data + {t("serverStats.noRecentLoginData")}
) : (
@@ -86,7 +86,7 @@ export function LoginStatsWidget({ {login.user} - from + {t("serverStats.from")} {login.ip} @@ -105,7 +105,7 @@ export function LoginStatsWidget({
- Recent Failed Attempts + {t("serverStats.recentFailedAttempts")}
@@ -118,7 +118,7 @@ export function LoginStatsWidget({ {login.user} - from + {t("serverStats.from")} {login.ip} -- 2.49.1 From e6a0f20ccd4fe067365a994194345ad55265209b Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 06:29:34 +0800 Subject: [PATCH 05/23] feat: Add keyboard shortcut enhancements with Kbd component - Add shadcn kbd component for displaying keyboard shortcuts - Enhance file manager context menu to display shortcuts with Kbd component - Add 5 new keyboard shortcuts to file manager: - Ctrl+D: Download selected files - Ctrl+N: Create new file - Ctrl+Shift+N: Create new folder - Ctrl+U: Upload files - Enter: Open/run selected file - Add keyboard shortcut hints to command palette footer - Create helper function to parse and render keyboard shortcuts --- src/components/ui/kbd.tsx | 28 +++++++++++ .../apps/command-palette/CommandPalette.tsx | 15 ++++++ .../desktop/apps/file-manager/FileManager.tsx | 2 + .../file-manager/FileManagerContextMenu.tsx | 21 +++++++-- .../apps/file-manager/FileManagerGrid.tsx | 46 +++++++++++++++++++ 5 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 src/components/ui/kbd.tsx diff --git a/src/components/ui/kbd.tsx b/src/components/ui/kbd.tsx new file mode 100644 index 00000000..253c69f3 --- /dev/null +++ b/src/components/ui/kbd.tsx @@ -0,0 +1,28 @@ +import { cn } from "@/lib/utils" + +function Kbd({ className, ...props }: React.ComponentProps<"kbd">) { + return ( + + ) +} + +function KbdGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +export { Kbd, KbdGroup } diff --git a/src/ui/desktop/apps/command-palette/CommandPalette.tsx b/src/ui/desktop/apps/command-palette/CommandPalette.tsx index fd898db0..1833aa1d 100644 --- a/src/ui/desktop/apps/command-palette/CommandPalette.tsx +++ b/src/ui/desktop/apps/command-palette/CommandPalette.tsx @@ -8,6 +8,7 @@ import { } from "@/components/ui/command.tsx"; import React, { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; +import { Kbd, KbdGroup } from "@/components/ui/kbd"; import { Key, Server, @@ -377,6 +378,20 @@ export function CommandPalette({ +
+
+ Press + + Shift + Shift + + to toggle +
+
+ Close + Esc +
+
); diff --git a/src/ui/desktop/apps/file-manager/FileManager.tsx b/src/ui/desktop/apps/file-manager/FileManager.tsx index 2070153b..72a56006 100644 --- a/src/ui/desktop/apps/file-manager/FileManager.tsx +++ b/src/ui/desktop/apps/file-manager/FileManager.tsx @@ -1928,6 +1928,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { createIntent={createIntent} onConfirmCreate={handleConfirmCreate} onCancelCreate={handleCancelCreate} + onNewFile={handleCreateNewFile} + onNewFolder={handleCreateNewFolder} /> 0 && index < filteredMenuItems.length - 1; }); + const renderShortcut = (shortcut: string) => { + const keys = shortcut.split("+"); + if (keys.length === 1) { + return {keys[0]}; + } + return ( + + {keys.map((key, index) => ( + {key} + ))} + + ); + }; + return ( <>
@@ -470,9 +485,9 @@ export function FileManagerContextMenu({ {item.label}
{item.shortcut && ( - - {item.shortcut} - +
+ {renderShortcut(item.shortcut)} +
)} ); diff --git a/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx b/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx index 786afa12..4f500ce8 100644 --- a/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx +++ b/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx @@ -92,6 +92,8 @@ interface FileManagerGridProps { createIntent?: CreateIntent | null; onConfirmCreate?: (name: string) => void; onCancelCreate?: () => void; + onNewFile?: () => void; + onNewFolder?: () => void; } const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => { @@ -192,6 +194,8 @@ export function FileManagerGrid({ createIntent, onConfirmCreate, onCancelCreate, + onNewFile, + onNewFolder, }: FileManagerGridProps) { const { t } = useTranslation(); const gridRef = useRef(null); @@ -772,6 +776,42 @@ export function FileManagerGrid({ onUndo(); } break; + case "d": + case "D": + if ( + (event.ctrlKey || event.metaKey) && + selectedFiles.length > 0 && + onDownload + ) { + event.preventDefault(); + onDownload(selectedFiles); + } + break; + case "n": + case "N": + if (event.ctrlKey || event.metaKey) { + event.preventDefault(); + if (event.shiftKey && onNewFolder) { + onNewFolder(); + } else if (!event.shiftKey && onNewFile) { + onNewFile(); + } + } + break; + case "u": + case "U": + if ((event.ctrlKey || event.metaKey) && onUpload) { + event.preventDefault(); + const input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + input.onchange = (e) => { + const files = (e.target as HTMLInputElement).files; + if (files) onUpload(files); + }; + input.click(); + } + break; case "Delete": if (selectedFiles.length > 0 && onDelete) { onDelete(selectedFiles); @@ -783,6 +823,12 @@ export function FileManagerGrid({ onStartEdit(selectedFiles[0]); } break; + case "Enter": + if (selectedFiles.length === 1) { + event.preventDefault(); + onFileOpen(selectedFiles[0]); + } + break; case "y": case "Y": if (event.ctrlKey || event.metaKey) { -- 2.49.1 From a2761c1ebf4a24412d17197b0e6d9de4d591929f Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 06:35:43 +0800 Subject: [PATCH 06/23] feat: Add i18n support for command palette - Add commandPalette translation section with 22 keys to all 4 languages - Update CommandPalette component to use i18n for all UI text - Translate search placeholder, group headings, menu items, and shortcut hints - Support multilingual command palette interface --- src/locales/de/translation.json | 23 ++++++++ src/locales/en/translation.json | 23 ++++++++ src/locales/ru/translation.json | 23 ++++++++ src/locales/zh/translation.json | 23 ++++++++ .../apps/command-palette/CommandPalette.tsx | 52 ++++++++++--------- 5 files changed, 119 insertions(+), 25 deletions(-) diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 2c9c4207..3d9e65d6 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -1571,5 +1571,28 @@ "cpu": "CPU", "ram": "RAM", "notAvailable": "Nicht verfügbar" + }, + "commandPalette": { + "searchPlaceholder": "Nach Hosts oder Schnellaktionen suchen...", + "recentActivity": "Kürzliche Aktivität", + "navigation": "Navigation", + "addHost": "Host hinzufügen", + "addCredential": "Anmeldedaten hinzufügen", + "adminSettings": "Admin-Einstellungen", + "userProfile": "Benutzerprofil", + "updateLog": "Aktualisierungsprotokoll", + "hosts": "Hosts", + "openServerDetails": "Serverdetails öffnen", + "openFileManager": "Dateimanager öffnen", + "edit": "Bearbeiten", + "links": "Links", + "github": "GitHub", + "support": "Support", + "discord": "Discord", + "donate": "Spenden", + "press": "Drücken Sie", + "toToggle": "zum Umschalten", + "close": "Schließen", + "hostManager": "Host-Manager" } } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 853d5ae6..213bdc06 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1689,5 +1689,28 @@ "cpu": "CPU", "ram": "RAM", "notAvailable": "N/A" + }, + "commandPalette": { + "searchPlaceholder": "Search for hosts or quick actions...", + "recentActivity": "Recent Activity", + "navigation": "Navigation", + "addHost": "Add Host", + "addCredential": "Add Credential", + "adminSettings": "Admin Settings", + "userProfile": "User Profile", + "updateLog": "Update Log", + "hosts": "Hosts", + "openServerDetails": "Open Server Details", + "openFileManager": "Open File Manager", + "edit": "Edit", + "links": "Links", + "github": "GitHub", + "support": "Support", + "discord": "Discord", + "donate": "Donate", + "press": "Press", + "toToggle": "to toggle", + "close": "Close", + "hostManager": "Host Manager" } } diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 1f3fb9bd..f641414e 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -1657,5 +1657,28 @@ "cpu": "CPU", "ram": "RAM", "notAvailable": "N/A" + }, + "commandPalette": { + "searchPlaceholder": "Поиск хостов или быстрых действий...", + "recentActivity": "Недавняя активность", + "navigation": "Навигация", + "addHost": "Добавить хост", + "addCredential": "Добавить учетные данные", + "adminSettings": "Настройки администратора", + "userProfile": "Профиль пользователя", + "updateLog": "Журнал обновлений", + "hosts": "Хосты", + "openServerDetails": "Открыть детали сервера", + "openFileManager": "Открыть файловый менеджер", + "edit": "Редактировать", + "links": "Ссылки", + "github": "GitHub", + "support": "Поддержка", + "discord": "Discord", + "donate": "Пожертвовать", + "press": "Нажмите", + "toToggle": "для переключения", + "close": "Закрыть", + "hostManager": "Менеджер хостов" } } diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index bac2f366..bdeb9d57 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -1582,5 +1582,28 @@ "cpu": "CPU", "ram": "内存", "notAvailable": "不可用" + }, + "commandPalette": { + "searchPlaceholder": "搜索主机或快速操作...", + "recentActivity": "最近活动", + "navigation": "导航", + "addHost": "添加主机", + "addCredential": "添加凭据", + "adminSettings": "管理员设置", + "userProfile": "用户资料", + "updateLog": "更新日志", + "hosts": "主机", + "openServerDetails": "打开服务器详情", + "openFileManager": "打开文件管理器", + "edit": "编辑", + "links": "链接", + "github": "GitHub", + "support": "支持", + "discord": "Discord", + "donate": "捐赠", + "press": "按下", + "toToggle": "来切换", + "close": "关闭", + "hostManager": "主机管理器" } } diff --git a/src/ui/desktop/apps/command-palette/CommandPalette.tsx b/src/ui/desktop/apps/command-palette/CommandPalette.tsx index 1833aa1d..64871062 100644 --- a/src/ui/desktop/apps/command-palette/CommandPalette.tsx +++ b/src/ui/desktop/apps/command-palette/CommandPalette.tsx @@ -20,6 +20,7 @@ import { Pencil, EllipsisVertical, } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { BiMoney, BiSupport } from "react-icons/bi"; import { BsDiscord } from "react-icons/bs"; import { GrUpdate } from "react-icons/gr"; @@ -64,6 +65,7 @@ export function CommandPalette({ isOpen: boolean; setIsOpen: (isOpen: boolean) => void; }) { + const { t } = useTranslation(); const inputRef = useRef(null); const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs(); const [recentActivity, setRecentActivity] = useState( @@ -91,7 +93,7 @@ export function CommandPalette({ } else { const id = addTab({ type: "ssh_manager", - title: "Host Manager", + title: t("commandPalette.hostManager"), initialTab: "add_host", }); setCurrentTab(id); @@ -107,7 +109,7 @@ export function CommandPalette({ } else { const id = addTab({ type: "ssh_manager", - title: "Host Manager", + title: t("commandPalette.hostManager"), initialTab: "add_credential", }); setCurrentTab(id); @@ -120,7 +122,7 @@ export function CommandPalette({ if (adminTab) { setCurrentTab(adminTab.id); } else { - const id = addTab({ type: "admin", title: "Admin Settings" }); + const id = addTab({ type: "admin", title: t("commandPalette.adminSettings") }); setCurrentTab(id); } setIsOpen(false); @@ -131,7 +133,7 @@ export function CommandPalette({ if (userProfileTab) { setCurrentTab(userProfileTab.id); } else { - const id = addTab({ type: "user_profile", title: "User Profile" }); + const id = addTab({ type: "user_profile", title: t("commandPalette.userProfile") }); setCurrentTab(id); } setIsOpen(false); @@ -214,7 +216,7 @@ export function CommandPalette({ : `${host.username}@${host.ip}:${host.port}`; addTab({ type: "ssh_manager", - title: "Host Manager", + title: t("commandPalette.hostManager"), hostConfig: host, initialTab: "add_host", }); @@ -235,7 +237,7 @@ export function CommandPalette({ > {recentActivity.length > 0 && ( <> - + {recentActivity.map((item, index) => ( )} - + - Add Host + {t("commandPalette.addHost")} - Add Credential + {t("commandPalette.addCredential")} - Admin Settings + {t("commandPalette.adminSettings")} - User Profile + {t("commandPalette.userProfile")} - Update Log + {t("commandPalette.updateLog")} - + {hosts.map((host, index) => { const title = host.name?.trim() ? host.name @@ -329,7 +331,7 @@ export function CommandPalette({ className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" > - Open Server Details + {t("commandPalette.openServerDetails")} { @@ -339,7 +341,7 @@ export function CommandPalette({ className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" > - Open File Manager + {t("commandPalette.openFileManager")} { @@ -349,7 +351,7 @@ export function CommandPalette({ className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" > - Edit + {t("commandPalette.edit")} @@ -359,36 +361,36 @@ export function CommandPalette({ })} - + - GitHub + {t("commandPalette.github")} - Support + {t("commandPalette.support")} - Discord + {t("commandPalette.discord")} - Donate + {t("commandPalette.donate")}
- Press + {t("commandPalette.press")} Shift Shift - to toggle + {t("commandPalette.toToggle")}
- Close + {t("commandPalette.close")} Esc
-- 2.49.1 From f311a7a5ad1acaa19fef078aac9eff76df45d0e2 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 06:48:31 +0800 Subject: [PATCH 07/23] feat: Add smooth transitions and animations to UI - Add fade-in/fade-out transition to command palette (200ms) - Add scale animation to command palette on open/close - Add smooth popup animation to context menu (150ms) - Add visual feedback for file selection with ring effect - Add hover scale effect to file grid items - Add transition-all to list view items for consistent behavior - Zero JavaScript overhead, pure CSS transitions - All animations under 200ms for instant feel --- .../apps/command-palette/CommandPalette.tsx | 10 +++++--- .../file-manager/FileManagerContextMenu.tsx | 25 +++++++++++++++---- .../apps/file-manager/FileManagerGrid.tsx | 8 +++--- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/ui/desktop/apps/command-palette/CommandPalette.tsx b/src/ui/desktop/apps/command-palette/CommandPalette.tsx index 64871062..2c1da720 100644 --- a/src/ui/desktop/apps/command-palette/CommandPalette.tsx +++ b/src/ui/desktop/apps/command-palette/CommandPalette.tsx @@ -226,13 +226,17 @@ export function CommandPalette({ return (
setIsOpen(false)} > e.stopPropagation()} > { - if (!isVisible) return; + if (!isVisible) { + setIsMounted(false); + return; + } + + setIsMounted(true); const adjustPosition = () => { const menuWidth = 200; @@ -183,8 +189,6 @@ export function FileManagerContextMenu({ }; }, [isVisible, x, y, onClose]); - if (!isVisible) return null; - const isFileContext = files.length > 0; const isSingleFile = files.length === 1; const isMultipleFiles = files.length > 1; @@ -440,13 +444,24 @@ export function FileManagerContextMenu({ ); }; + if (!isVisible && !isMounted) return null; + return ( <> -
+
f.path === file.path) && @@ -1138,8 +1139,9 @@ export function FileManagerGrid({ draggable={true} className={cn( "flex items-center gap-3 p-2 rounded cursor-pointer", + "transition-all duration-150 ease-out", "hover:bg-accent hover:text-accent-foreground", - isSelected && "bg-primary/20", + isSelected && "bg-primary/20 ring-2 ring-primary/20", dragState.target?.path === file.path && "bg-muted border-primary border-dashed relative z-10", dragState.files.some((f) => f.path === file.path) && -- 2.49.1 From cd1afc90783bd153324f2a5920459f2d1e2ce2bf Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 06:55:42 +0800 Subject: [PATCH 08/23] feat: Add button active state and dashboard card animations - Add active:scale-95 to all buttons for tactile click feedback - Add hover border effect to dashboard cards (150ms transition) - Add pulse animation to dashboard loading states - Pure CSS transitions with zero JavaScript overhead - Improves enterprise-level feel of UI --- src/components/ui/button.tsx | 2 +- src/ui/desktop/apps/dashboard/Dashboard.tsx | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 26ee717b..d97fef14 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-95", { variants: { variant: { diff --git a/src/ui/desktop/apps/dashboard/Dashboard.tsx b/src/ui/desktop/apps/dashboard/Dashboard.tsx index f38d5d95..69e2962e 100644 --- a/src/ui/desktop/apps/dashboard/Dashboard.tsx +++ b/src/ui/desktop/apps/dashboard/Dashboard.tsx @@ -529,7 +529,7 @@ export function Dashboard({
-
+

@@ -549,7 +549,7 @@ export function Dashboard({ className={`grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-x-hidden ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`} > {recentActivityLoading ? ( -

+
{t("dashboard.loadingRecentActivity")}
@@ -581,7 +581,7 @@ export function Dashboard({
-
+

@@ -645,7 +645,7 @@ export function Dashboard({

-
+

@@ -655,7 +655,7 @@ export function Dashboard({ className={`grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-x-hidden ${serverStatsLoading ? "overflow-y-hidden" : "overflow-y-auto"}`} > {serverStatsLoading ? ( -

+
{t("dashboard.loadingServerStats")}
-- 2.49.1 From fe127e045ff72821eb2c120443eb55249480ea90 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 08:11:29 +0800 Subject: [PATCH 09/23] feat: Add smooth macOS-style page transitions - Add fullscreen crossfade transition for login/logout (300ms fade-out + 400ms fade-in) - Add slide-in-from-right animation for all page switches (Dashboard, Terminal, SSH Manager, Admin, Profile) - Fix TypeScript compilation by adding esModuleInterop to tsconfig.node.json - Pass handleLogout from DesktopApp to LeftSidebar for consistent transition behavior All page transitions now use Tailwind animate-in utilities with 300ms duration for smooth, native-feeling UX --- src/ui/desktop/DesktopApp.tsx | 73 +++++++++++++++++++---- src/ui/desktop/navigation/LeftSidebar.tsx | 4 +- tsconfig.node.json | 1 + 3 files changed, 64 insertions(+), 14 deletions(-) diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index b7bcb6de..093ba6d5 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -23,6 +23,8 @@ function AppContent() { const saved = localStorage.getItem("topNavbarOpen"); return saved !== null ? JSON.parse(saved) : true; }); + const [isTransitioning, setIsTransitioning] = useState(false); + const [transitionPhase, setTransitionPhase] = useState<'idle' | 'fadeOut' | 'fadeIn'>('idle'); const { currentTab, tabs } = useTabs(); const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); @@ -98,13 +100,44 @@ function AppContent() { username: string | null; userId: string | null; }) => { - setIsAuthenticated(true); - setIsAdmin(authData.isAdmin); - setUsername(authData.username); + setIsTransitioning(true); + setTransitionPhase('fadeOut'); + + setTimeout(() => { + setIsAuthenticated(true); + setIsAdmin(authData.isAdmin); + setUsername(authData.username); + setTransitionPhase('fadeIn'); + + setTimeout(() => { + setIsTransitioning(false); + setTransitionPhase('idle'); + }, 400); + }, 300); }, [], ); + const handleLogout = useCallback(async () => { + setIsTransitioning(true); + setTransitionPhase('fadeOut'); + + setTimeout(async () => { + try { + const { logoutUser, isElectron } = await import("@/ui/main-axios.ts"); + await logoutUser(); + + if (isElectron()) { + localStorage.removeItem("jwt"); + } + } catch (error) { + console.error("Logout failed:", error); + } + + window.location.reload(); + }, 300); + }, []); + const currentTabData = tabs.find((tab) => tab.id === currentTab); const showTerminalView = currentTabData?.type === "terminal" || @@ -135,20 +168,25 @@ function AppContent() { {isAuthenticated && ( + onSelectView={handleSelectView} + disabled={!isAuthenticated || authLoading} + isAdmin={isAdmin} + username={username} + onLogout={handleLogout} + >
- + {showTerminalView && ( +
+ +
+ )}
{showHome && ( -
+
+
+
)} {showProfile && ( -
+
)} @@ -189,6 +227,15 @@ function AppContent() { /> )} + + {isTransitioning && ( +
+ )} + void; } async function handleLogout() { @@ -87,6 +88,7 @@ export function LeftSidebar({ isAdmin, username, children, + onLogout, }: SidebarProps): React.ReactElement { const { t } = useTranslation(); @@ -486,7 +488,7 @@ export function LeftSidebar({ )} {t("common.logout")} diff --git a/tsconfig.node.json b/tsconfig.node.json index 4e53dcad..2edfb287 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -8,6 +8,7 @@ "moduleResolution": "nodenext", "verbatimModuleSyntax": true, "moduleDetection": "force", + "esModuleInterop": true, "noEmit": false, "outDir": "./dist/backend", "strict": false, -- 2.49.1 From aa94851f4a299eed7ca8a902be424713fbd51dfc Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 08:15:02 +0800 Subject: [PATCH 10/23] fix: Add key prop to force animation re-trigger on tab switch Each page container now has key={currentTab} to ensure React unmounts and remounts the element on every tab switch, properly triggering the slide-in animation --- src/ui/desktop/DesktopApp.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index 093ba6d5..35228c70 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -179,14 +179,14 @@ function AppContent() { style={{ display: showTerminalView ? "block" : "none" }} > {showTerminalView && ( -
+
)}
{showHome && ( -
+
+
+
)} {showProfile && ( -
+
)} -- 2.49.1 From 28406d060c0df25213f85755046814e0d662284d Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 08:17:39 +0800 Subject: [PATCH 11/23] revert: Remove page transition animations Page switching animations were not noticeable enough and felt unnecessary. Keep only the login/logout fullscreen crossfade transitions which provide clear visual feedback for authentication state changes --- src/ui/desktop/DesktopApp.tsx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index 35228c70..743ed13a 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -178,15 +178,11 @@ function AppContent() { className="h-screen w-full visible pointer-events-auto static overflow-hidden" style={{ display: showTerminalView ? "block" : "none" }} > - {showTerminalView && ( -
- -
- )} +
{showHome && ( -
+
+
+
)} {showProfile && ( -
+
)} -- 2.49.1 From f1bf12bb98b95f1d16df2ce8f45dda920e3ee277 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 08:19:42 +0800 Subject: [PATCH 12/23] feat: Add ripple effect to login/logout transitions Add three-layer expanding ripple animation during fadeOut phase: - Ripples expand from screen center using primary theme color - Each layer has staggered delay (0ms, 150ms, 300ms) for wave effect - Ripples fade out as they expand to create elegant visual feedback - Uses pure CSS keyframe animation, no external libraries Total animation: 800ms ripple + 300ms screen fade --- src/ui/desktop/DesktopApp.tsx | 41 ++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index 743ed13a..5d83275a 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -229,7 +229,46 @@ function AppContent() { className={`fixed inset-0 bg-background z-[20000] transition-opacity duration-300 ${ transitionPhase === 'fadeOut' ? 'opacity-100' : 'opacity-0' }`} - /> + > + {transitionPhase === 'fadeOut' && ( + <> +
+
+
+
+
+ + + )} +
)} Date: Sun, 9 Nov 2025 08:21:56 +0800 Subject: [PATCH 13/23] feat: Add smooth TERMIX logo animation to transitions Changes: - Extend transition duration from 300ms/400ms to 800ms/600ms for more elegant feel - Reduce ripple intensity from /20,/15,/10 to /8,/5 for subtlety - Slow down ripple animation from 0.8s to 2s with cubic-bezier easing - Add centered TERMIX logo with monospace font and subtitle - Logo fades in from 80% scale, holds, then fades out at 110% scale - Total effect: 1.2s logo animation synced with 2s ripple waves Creates a premium, branded transition experience --- src/ui/desktop/DesktopApp.tsx | 57 ++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index 5d83275a..dd776f84 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -112,8 +112,8 @@ function AppContent() { setTimeout(() => { setIsTransitioning(false); setTransitionPhase('idle'); - }, 400); - }, 300); + }, 600); + }, 800); }, [], ); @@ -135,7 +135,7 @@ function AppContent() { } window.location.reload(); - }, 300); + }, 800); }, []); const currentTabData = tabs.find((tab) => tab.id === currentTab); @@ -226,38 +226,47 @@ function AppContent() { {isTransitioning && (
{transitionPhase === 'fadeOut' && ( <>
-
-
-
+
+
+ TERMIX +
+
+ SSH TERMINAL MANAGER +
+
)} -- 2.49.1 From 7c5fddb07fe1a05d7359d865c74c31f4c8f2be51 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 08:23:56 +0800 Subject: [PATCH 14/23] feat: Enhance transition animation with premium details Timing adjustments: - Extend fadeOut from 800ms to 1200ms - Extend fadeIn from 600ms to 800ms - Slow background fade to 700ms for elegance Visual enhancements: - Add 4-layer ripple waves (10%, 7%, 5%, 3% opacity) with staggered delays - Ripple animation extended to 2.5s with refined opacity curve - Logo blur effect: starts at 8px, sharpens to 0px, exits at 4px - Logo glow effect: triple-layer text-shadow using primary theme color - Increase logo size from text-6xl to text-7xl - Subtitle delayed fade-in from bottom with smooth slide animation Creates a cinematic, polished brand experience --- src/ui/desktop/DesktopApp.tsx | 107 +++++++++++++++++++++++++++------- 1 file changed, 87 insertions(+), 20 deletions(-) diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index dd776f84..bd652536 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -112,8 +112,8 @@ function AppContent() { setTimeout(() => { setIsTransitioning(false); setTransitionPhase('idle'); - }, 600); - }, 800); + }, 800); + }, 1200); }, [], ); @@ -135,7 +135,7 @@ function AppContent() { } window.location.reload(); - }, 800); + }, 1200); }, []); const currentTabData = tabs.find((tab) => tab.id === currentTab); @@ -226,34 +226,52 @@ function AppContent() { {isTransitioning && (
{transitionPhase === 'fadeOut' && ( <> -
-
+
+
+
-
+
TERMIX
-
+
SSH TERMINAL MANAGER
@@ -263,10 +281,13 @@ function AppContent() { 0% { width: 0; height: 0; - opacity: 0.8; + opacity: 1; } - 50% { - opacity: 0.4; + 30% { + opacity: 0.6; + } + 70% { + opacity: 0.3; } 100% { width: 200vmax; @@ -277,19 +298,65 @@ function AppContent() { @keyframes logoFade { 0% { opacity: 0; - transform: scale(0.8); + transform: scale(0.85); + filter: blur(8px); } - 20% { + 25% { opacity: 1; transform: scale(1); + filter: blur(0px); } - 80% { + 75% { opacity: 1; transform: scale(1); + filter: blur(0px); } 100% { opacity: 0; - transform: scale(1.1); + transform: scale(1.05); + filter: blur(4px); + } + } + @keyframes logoGlow { + 0% { + color: hsl(var(--primary)); + text-shadow: none; + } + 25% { + color: hsl(var(--primary)); + text-shadow: + 0 0 20px hsla(var(--primary), 0.3), + 0 0 40px hsla(var(--primary), 0.2), + 0 0 60px hsla(var(--primary), 0.1); + } + 75% { + color: hsl(var(--primary)); + text-shadow: + 0 0 20px hsla(var(--primary), 0.3), + 0 0 40px hsla(var(--primary), 0.2), + 0 0 60px hsla(var(--primary), 0.1); + } + 100% { + color: hsl(var(--primary)); + text-shadow: none; + } + } + @keyframes subtitleFade { + 0%, 30% { + opacity: 0; + transform: translateY(10px); + } + 50% { + opacity: 1; + transform: translateY(0); + } + 75% { + opacity: 1; + transform: translateY(0); + } + 100% { + opacity: 0; + transform: translateY(-5px); } } `} -- 2.49.1 From 3a8e3a1607e0ee57e99c4c6fd8d4c74da4fb2c5c Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 08:29:42 +0800 Subject: [PATCH 15/23] feat: Redesign login page with split-screen cinematic layout Major redesign of authentication page: Left Side (40% width): - Full-height gradient background using primary theme color - Large TERMIX logo with glow effect - Subtitle and tagline - Infinite animated ripple waves (3 layers) - Hidden on mobile, shows brand identity Right Side (60% width): - Centered glassmorphism card with backdrop blur - Refined tab switcher with pill-style active state - Enlarged title with gradient text effect - Added welcome subtitles for better UX - Card slides in from bottom on load - All existing functionality preserved Visual enhancements: - Tab navigation: segmented control style in muted container - Active tab: white background with subtle shadow - Smooth 200ms transitions on all interactions - Card: rounded-2xl, shadow-xl, semi-transparent border Creates premium, modern login experience matching transition animations --- src/ui/desktop/authentication/Auth.tsx | 102 ++++++++++++++++++++----- 1 file changed, 83 insertions(+), 19 deletions(-) diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index 997d712b..446931c3 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -683,7 +683,7 @@ export function Auth({ if (showServerConfig === null && !isInElectronWebView()) { return (
@@ -697,7 +697,7 @@ export function Auth({ if (showServerConfig && !isInElectronWebView()) { return (
@@ -722,7 +722,7 @@ export function Auth({ ) { return (
@@ -755,7 +755,7 @@ export function Auth({ if (dbHealthChecking && !dbConnectionFailed) { return (
@@ -774,7 +774,7 @@ export function Auth({ if (dbConnectionFailed) { return (
@@ -834,10 +834,61 @@ export function Auth({ return (
+ {/* Split Screen Layout */} +
+ + {/* Left Side - Brand Showcase */} +
+ {/* Animated Ripples Background */} +
+
+
+
+
+ + {/* Logo and Branding */} +
+
+ TERMIX +
+
+ SSH TERMINAL MANAGER +
+
+ Secure, powerful, and intuitive SSH connection management for modern teams +
+
+ + +
+ + {/* Right Side - Auth Form */} +
+
{isInElectronWebView() && !webviewAuthSuccess && ( @@ -948,15 +999,16 @@ export function Auth({ return ( <> -
+ {/* Tab Navigation */} +
{passwordLoginAllowed && ( )}
-
-

+ + {/* Page Title */} +
+

{tab === "login" ? t("auth.loginTitle") : tab === "signup" @@ -1022,6 +1076,13 @@ export function Auth({ ? t("auth.loginWithExternal") : t("auth.forgotPassword")}

+

+ {tab === "login" + ? "Welcome back to TERMIX" + : tab === "signup" + ? "Create your TERMIX account" + : "Continue with external provider"} +

{tab === "external" || tab === "reset" ? ( @@ -1339,6 +1400,9 @@ export function Auth({ })()} )} +

+
+
); } -- 2.49.1 From fe9e392b2cc77038d16062c48bbc7b317ef6dbc7 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 08:46:58 +0800 Subject: [PATCH 16/23] feat: Update login page theme colors and add i18n support - Changed login page gradient from blue to match dark theme colors - Updated ripple effects to use theme primary color - Added i18n translation keys for login page (auth.tagline, auth.description, auth.welcomeBack, auth.createAccount, auth.continueExternal) - Updated all language files (en, zh, de, ru, pt-BR) with new translations - Fixed TypeScript compilation issues by clearing build cache --- src/components/ui/button.tsx | 2 +- src/components/ui/input.tsx | 2 +- src/components/ui/tabs.tsx | 10 +++++-- src/locales/de/translation.json | 5 ++++ src/locales/en/translation.json | 5 ++++ src/locales/pt-BR/translation.json | 5 ++++ src/locales/ru/translation.json | 5 ++++ src/locales/zh/translation.json | 5 ++++ src/ui/desktop/authentication/Auth.tsx | 32 ++++++++++++--------- src/ui/desktop/navigation/SSHAuthDialog.tsx | 4 +-- src/ui/desktop/navigation/TOTPDialog.tsx | 4 +-- 11 files changed, 57 insertions(+), 22 deletions(-) diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index d97fef14..fbdf6b8d 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-95", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-100 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-95", { variants: { variant: { diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index b1a060f5..ba0f8921 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { type={type} data-slot="input" className={cn( - "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] duration-200 outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className, diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 46295c2f..12b4fc5c 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -40,7 +40,7 @@ function TabsTrigger({ ); diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 3d9e65d6..c1d6639b 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -1192,6 +1192,11 @@ "from": "von" }, "auth": { + "tagline": "SSH TERMINAL MANAGER", + "description": "Sichere, leistungsstarke und intuitive SSH-Verbindungsverwaltung", + "welcomeBack": "Willkommen zurück bei TERMIX", + "createAccount": "Erstellen Sie Ihr TERMIX-Konto", + "continueExternal": "Mit externem Anbieter fortfahren", "loginTitle": "Melden Sie sich bei Termix an", "registerTitle": "Benutzerkonto erstellen", "loginButton": "Anmelden", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 213bdc06..28296a66 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1301,6 +1301,11 @@ "from": "from" }, "auth": { + "tagline": "SSH TERMINAL MANAGER", + "description": "Secure, powerful, and intuitive SSH connection management", + "welcomeBack": "Welcome back to TERMIX", + "createAccount": "Create your TERMIX account", + "continueExternal": "Continue with external provider", "loginTitle": "Login to Termix", "registerTitle": "Create Account", "loginButton": "Login", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index c656e9cb..2af8e80e 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -1143,6 +1143,11 @@ "available": "Disponível" }, "auth": { + "tagline": "GERENCIADOR DE TERMINAL SSH", + "description": "Gerenciamento de conexão SSH seguro, poderoso e intuitivo", + "welcomeBack": "Bem-vindo de volta ao TERMIX", + "createAccount": "Crie sua conta TERMIX", + "continueExternal": "Continuar com provedor externo", "loginTitle": "Entrar no Termix", "registerTitle": "Criar Conta", "loginButton": "Entrar", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index f641414e..add3f212 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -1288,6 +1288,11 @@ "from": "с" }, "auth": { + "tagline": "SSH ТЕРМИНАЛ МЕНЕДЖЕР", + "description": "Безопасное, мощное и интуитивное управление SSH-соединениями", + "welcomeBack": "Добро пожаловать обратно в TERMIX", + "createAccount": "Создайте вашу учетную запись TERMIX", + "continueExternal": "Продолжить с внешним провайдером", "loginTitle": "Вход в Termix", "registerTitle": "Создать учетную запись", "loginButton": "Войти", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index bdeb9d57..3495f971 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -1272,6 +1272,11 @@ "from": "来自" }, "auth": { + "tagline": "SSH 终端管理器", + "description": "安全、强大、直观的 SSH 连接管理", + "welcomeBack": "欢迎回到 TERMIX", + "createAccount": "创建您的 TERMIX 账户", + "continueExternal": "使用外部提供商继续", "loginTitle": "登录 Termix", "registerTitle": "创建账户", "loginButton": "登录", diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index 446931c3..17d6081c 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -841,30 +841,34 @@ export function Auth({
{/* Left Side - Brand Showcase */} -
+
{/* Animated Ripples Background */}
-
-
-
+
+
+
{/* Logo and Branding */}
TERMIX
-
- SSH TERMINAL MANAGER +
+ {t("auth.tagline") || "SSH TERMINAL MANAGER"}
-
- Secure, powerful, and intuitive SSH connection management for modern teams +
+ {t("auth.description") || "Secure, powerful, and intuitive SSH connection management"}
@@ -1067,7 +1071,7 @@ export function Auth({ {/* Page Title */}
-

+

{tab === "login" ? t("auth.loginTitle") : tab === "signup" @@ -1078,10 +1082,10 @@ export function Auth({

{tab === "login" - ? "Welcome back to TERMIX" + ? t("auth.welcomeBack") : tab === "signup" - ? "Create your TERMIX account" - : "Continue with external provider"} + ? t("auth.createAccount") + : t("auth.continueExternal")}

diff --git a/src/ui/desktop/navigation/SSHAuthDialog.tsx b/src/ui/desktop/navigation/SSHAuthDialog.tsx index fb254288..2afda788 100644 --- a/src/ui/desktop/navigation/SSHAuthDialog.tsx +++ b/src/ui/desktop/navigation/SSHAuthDialog.tsx @@ -137,10 +137,10 @@ export function SSHAuthDialog({ return (
- + diff --git a/src/ui/desktop/navigation/TOTPDialog.tsx b/src/ui/desktop/navigation/TOTPDialog.tsx index 1a444404..d0527b6b 100644 --- a/src/ui/desktop/navigation/TOTPDialog.tsx +++ b/src/ui/desktop/navigation/TOTPDialog.tsx @@ -25,12 +25,12 @@ export function TOTPDialog({ if (!isOpen) return null; return ( -
+
-
+

-- 2.49.1 From a6fbd00a5897fccfc9e46789af8abe378d10b3e9 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 09:04:19 +0800 Subject: [PATCH 17/23] refactor: Use shadcn Tabs component and fix modal styling - Replace custom tab navigation with shadcn Tabs component - Restore border-2 border-dark-border for modal consistency - Remove circular icon from login success message - Simplify authentication success display --- src/ui/desktop/authentication/Auth.tsx | 112 ++++++++----------------- 1 file changed, 36 insertions(+), 76 deletions(-) diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index 17d6081c..4cd3673c 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -5,6 +5,7 @@ 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, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs.tsx"; import { useTranslation } from "react-i18next"; import { LanguageSwitcher } from "@/ui/desktop/user/LanguageSwitcher.tsx"; import { toast } from "sonner"; @@ -890,7 +891,7 @@ export function Auth({ {/* Right Side - Auth Form */}
-
{isInElectronWebView() && !webviewAuthSuccess && ( @@ -902,21 +903,6 @@ export function Auth({ )} {isInElectronWebView() && webviewAuthSuccess && (
-
- - - -

{t("messages.loginSuccess")} @@ -1004,70 +990,44 @@ export function Auth({ return ( <> {/* Tab Navigation */} -
- {passwordLoginAllowed && ( - - )} - {(passwordLoginAllowed || firstUser) && - registrationAllowed && ( - + )} - {oidcConfigured && ( - - )} -
+ {oidcConfigured && ( + + {t("auth.external")} + + )} + + {/* Page Title */}
-- 2.49.1 From 4294a0f91909ae4247319044ba825016fce493bd Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 09:06:06 +0800 Subject: [PATCH 18/23] refactor: Remove ripple effects and gradient from login page - Remove animated ripple background effects - Remove gradient background, use solid color (bg-dark-bg-darker) - Remove text-shadow glow effect from logo - Simplify brand showcase to clean, minimal design --- src/ui/desktop/authentication/Auth.tsx | 33 +++----------------------- 1 file changed, 3 insertions(+), 30 deletions(-) diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index 4cd3673c..b68c62d0 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -842,25 +842,13 @@ export function Auth({
{/* Left Side - Brand Showcase */} -
- {/* Animated Ripples Background */} -
-
-
-
-
- +
{/* Logo and Branding */} -
+
TERMIX @@ -872,21 +860,6 @@ export function Auth({ {t("auth.description") || "Secure, powerful, and intuitive SSH connection management"}
- -
{/* Right Side - Auth Form */} -- 2.49.1 From 5a888cb13a50ac9acbdd5805b0e84c526d4aeb3b Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 09:07:52 +0800 Subject: [PATCH 19/23] feat: Add decorative slash and remove subtitle from login page - Add decorative slash divider with gradient lines below TERMIX logo - Remove subtitle text (welcomeBack and createAccount) - Simplify page title to show only the main heading --- src/ui/desktop/authentication/Auth.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index b68c62d0..5266bab7 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -853,6 +853,12 @@ export function Auth({ > TERMIX
+ {/* Decorative Slash */} +
+
+
/
+
+
{t("auth.tagline") || "SSH TERMINAL MANAGER"}
@@ -1004,7 +1010,7 @@ export function Auth({ {/* Page Title */}
-

+

{tab === "login" ? t("auth.loginTitle") : tab === "signup" @@ -1013,13 +1019,6 @@ export function Auth({ ? t("auth.loginWithExternal") : t("auth.forgotPassword")}

-

- {tab === "login" - ? t("auth.welcomeBack") - : tab === "signup" - ? t("auth.createAccount") - : t("auth.continueExternal")} -

{tab === "external" || tab === "reset" ? ( -- 2.49.1 From 10e31092c97f7d20f577b39ec4e923fcb6b908c3 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 09:11:28 +0800 Subject: [PATCH 20/23] feat: Add diagonal line pattern background to login page - Replace decorative slash with subtle diagonal line pattern background - Use repeating-linear-gradient at 45deg angle - Set very low opacity (0.03) for subtle effect - Pattern uses theme primary color --- src/ui/desktop/authentication/Auth.tsx | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index 5266bab7..7d08f968 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -842,9 +842,22 @@ export function Auth({
{/* Left Side - Brand Showcase */} -
+
+ {/* Diagonal Lines Background */} +
{/* Logo and Branding */} -
+
TERMIX
- {/* Decorative Slash */} -
-
-
/
-
-
{t("auth.tagline") || "SSH TERMINAL MANAGER"}
-- 2.49.1 From f8d07c155233f4f1bc009e229b8d2cf03c06c0b8 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 09:14:08 +0800 Subject: [PATCH 21/23] fix: Display diagonal line pattern on login background - Combine background color and pattern in single style attribute - Use white semi-transparent lines (rgba 0.03 opacity) - 45deg angle, 35px spacing, 2px width - Remove separate overlay div to ensure pattern visibility --- src/ui/desktop/authentication/Auth.tsx | 29 +++++++++++++------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index 7d08f968..43a8381b 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -842,22 +842,21 @@ export function Auth({
{/* Left Side - Brand Showcase */} -
- {/* Diagonal Lines Background */} -
+
{/* Logo and Branding */} -
+
Date: Sun, 9 Nov 2025 09:16:11 +0800 Subject: [PATCH 22/23] security: Fix user enumeration vulnerability in login - Unify error messages for invalid username and incorrect password - Both return 401 status with 'Invalid username or password' - Prevent attackers from enumerating valid usernames - Maintain detailed logging for debugging purposes - Changed from 404 'User not found' to generic auth failure message --- src/backend/database/routes/users.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 7f21cbae..92ea8746 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -896,11 +896,11 @@ router.post("/login", async (req, res) => { .where(eq(users.username, username)); if (!user || user.length === 0) { - authLogger.warn(`User not found: ${username}`, { + authLogger.warn(`Login failed: user not found`, { operation: "user_login", username, }); - return res.status(404).json({ error: "User not found" }); + return res.status(401).json({ error: "Invalid username or password" }); } const userRecord = user[0]; @@ -918,12 +918,12 @@ router.post("/login", async (req, res) => { const isMatch = await bcrypt.compare(password, userRecord.password_hash); if (!isMatch) { - authLogger.warn(`Incorrect password for user: ${username}`, { + authLogger.warn(`Login failed: incorrect password`, { operation: "user_login", username, userId: userRecord.id, }); - return res.status(401).json({ error: "Incorrect password" }); + return res.status(401).json({ error: "Invalid username or password" }); } try { -- 2.49.1 From 9a0933bf2fc229c846875a9f7b102edc06d6e4ff Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 09:20:28 +0800 Subject: [PATCH 23/23] security: Add login rate limiting to prevent brute force attacks - Implement LoginRateLimiter with IP and username-based tracking - Block after 5 failed attempts within 15 minutes - Lock account/IP for 15 minutes after threshold - Automatic cleanup of expired entries every 5 minutes - Track remaining attempts in logs for monitoring - Return 429 status with remaining time on rate limit - Reset counters on successful login - Dual protection: both IP-based and username-based limits --- src/backend/database/routes/users.ts | 27 +++++ src/backend/utils/login-rate-limiter.ts | 146 ++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 src/backend/utils/login-rate-limiter.ts diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 92ea8746..bbabf4bc 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -27,6 +27,7 @@ import { AuthManager } from "../../utils/auth-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js"; import { parseUserAgent } from "../../utils/user-agent-parser.js"; +import { loginRateLimiter } from "../../utils/login-rate-limiter.js"; const authManager = AuthManager.getInstance(); @@ -862,6 +863,7 @@ router.get("/oidc/callback", async (req, res) => { // POST /users/login router.post("/login", async (req, res) => { const { username, password } = req.body; + const clientIp = req.ip || req.socket.remoteAddress || "unknown"; if (!isNonEmptyString(username) || !isNonEmptyString(password)) { authLogger.warn("Invalid traditional login attempt", { @@ -872,6 +874,21 @@ router.post("/login", async (req, res) => { return res.status(400).json({ error: "Invalid username or password" }); } + // Check rate limiting + const lockStatus = loginRateLimiter.isLocked(clientIp, username); + if (lockStatus.locked) { + authLogger.warn("Login attempt blocked due to rate limiting", { + operation: "user_login_blocked", + username, + ip: clientIp, + remainingTime: lockStatus.remainingTime, + }); + return res.status(429).json({ + error: "Too many login attempts. Please try again later.", + remainingTime: lockStatus.remainingTime, + }); + } + try { const row = db.$client .prepare("SELECT value FROM settings WHERE key = 'allow_password_login'") @@ -896,9 +913,12 @@ router.post("/login", async (req, res) => { .where(eq(users.username, username)); if (!user || user.length === 0) { + loginRateLimiter.recordFailedAttempt(clientIp, username); authLogger.warn(`Login failed: user not found`, { operation: "user_login", username, + ip: clientIp, + remainingAttempts: loginRateLimiter.getRemainingAttempts(clientIp, username), }); return res.status(401).json({ error: "Invalid username or password" }); } @@ -918,10 +938,13 @@ router.post("/login", async (req, res) => { const isMatch = await bcrypt.compare(password, userRecord.password_hash); if (!isMatch) { + loginRateLimiter.recordFailedAttempt(clientIp, username); authLogger.warn(`Login failed: incorrect password`, { operation: "user_login", username, userId: userRecord.id, + ip: clientIp, + remainingAttempts: loginRateLimiter.getRemainingAttempts(clientIp, username), }); return res.status(401).json({ error: "Invalid username or password" }); } @@ -965,6 +988,9 @@ router.post("/login", async (req, res) => { deviceInfo: deviceInfo.deviceInfo, }); + // Reset rate limiter on successful login + loginRateLimiter.resetAttempts(clientIp, username); + authLogger.success(`User logged in successfully: ${username}`, { operation: "user_login_success", username, @@ -972,6 +998,7 @@ router.post("/login", async (req, res) => { dataUnlocked: true, deviceType: deviceInfo.type, deviceInfo: deviceInfo.deviceInfo, + ip: clientIp, }); const response: Record = { diff --git a/src/backend/utils/login-rate-limiter.ts b/src/backend/utils/login-rate-limiter.ts new file mode 100644 index 00000000..4e5ed704 --- /dev/null +++ b/src/backend/utils/login-rate-limiter.ts @@ -0,0 +1,146 @@ +interface LoginAttempt { + count: number; + firstAttempt: number; + lockedUntil?: number; +} + +class LoginRateLimiter { + private ipAttempts = new Map(); + private usernameAttempts = new Map(); + + private readonly MAX_ATTEMPTS = 5; + private readonly WINDOW_MS = 15 * 60 * 1000; // 15 minutes + private readonly LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes + + // Clean up old entries periodically + constructor() { + setInterval(() => this.cleanup(), 5 * 60 * 1000); // Clean every 5 minutes + } + + private cleanup(): void { + const now = Date.now(); + + // Clean IP attempts + for (const [ip, attempt] of this.ipAttempts.entries()) { + if (attempt.lockedUntil && attempt.lockedUntil < now) { + this.ipAttempts.delete(ip); + } else if (!attempt.lockedUntil && (now - attempt.firstAttempt) > this.WINDOW_MS) { + this.ipAttempts.delete(ip); + } + } + + // Clean username attempts + for (const [username, attempt] of this.usernameAttempts.entries()) { + if (attempt.lockedUntil && attempt.lockedUntil < now) { + this.usernameAttempts.delete(username); + } else if (!attempt.lockedUntil && (now - attempt.firstAttempt) > this.WINDOW_MS) { + this.usernameAttempts.delete(username); + } + } + } + + recordFailedAttempt(ip: string, username?: string): void { + const now = Date.now(); + + // Record IP attempt + const ipAttempt = this.ipAttempts.get(ip); + if (!ipAttempt) { + this.ipAttempts.set(ip, { + count: 1, + firstAttempt: now, + }); + } else if ((now - ipAttempt.firstAttempt) > this.WINDOW_MS) { + // Reset if outside window + this.ipAttempts.set(ip, { + count: 1, + firstAttempt: now, + }); + } else { + ipAttempt.count++; + if (ipAttempt.count >= this.MAX_ATTEMPTS) { + ipAttempt.lockedUntil = now + this.LOCKOUT_MS; + } + } + + // Record username attempt if provided + if (username) { + const userAttempt = this.usernameAttempts.get(username); + if (!userAttempt) { + this.usernameAttempts.set(username, { + count: 1, + firstAttempt: now, + }); + } else if ((now - userAttempt.firstAttempt) > this.WINDOW_MS) { + // Reset if outside window + this.usernameAttempts.set(username, { + count: 1, + firstAttempt: now, + }); + } else { + userAttempt.count++; + if (userAttempt.count >= this.MAX_ATTEMPTS) { + userAttempt.lockedUntil = now + this.LOCKOUT_MS; + } + } + } + } + + resetAttempts(ip: string, username?: string): void { + this.ipAttempts.delete(ip); + if (username) { + this.usernameAttempts.delete(username); + } + } + + isLocked(ip: string, username?: string): { locked: boolean; remainingTime?: number } { + const now = Date.now(); + + // Check IP lockout + const ipAttempt = this.ipAttempts.get(ip); + if (ipAttempt?.lockedUntil && ipAttempt.lockedUntil > now) { + return { + locked: true, + remainingTime: Math.ceil((ipAttempt.lockedUntil - now) / 1000), + }; + } + + // Check username lockout + if (username) { + const userAttempt = this.usernameAttempts.get(username); + if (userAttempt?.lockedUntil && userAttempt.lockedUntil > now) { + return { + locked: true, + remainingTime: Math.ceil((userAttempt.lockedUntil - now) / 1000), + }; + } + } + + return { locked: false }; + } + + getRemainingAttempts(ip: string, username?: string): number { + const now = Date.now(); + let minRemaining = this.MAX_ATTEMPTS; + + // Check IP attempts + const ipAttempt = this.ipAttempts.get(ip); + if (ipAttempt && (now - ipAttempt.firstAttempt) <= this.WINDOW_MS) { + const ipRemaining = Math.max(0, this.MAX_ATTEMPTS - ipAttempt.count); + minRemaining = Math.min(minRemaining, ipRemaining); + } + + // Check username attempts + if (username) { + const userAttempt = this.usernameAttempts.get(username); + if (userAttempt && (now - userAttempt.firstAttempt) <= this.WINDOW_MS) { + const userRemaining = Math.max(0, this.MAX_ATTEMPTS - userAttempt.count); + minRemaining = Math.min(minRemaining, userRemaining); + } + } + + return minRemaining; + } +} + +// Export singleton instance +export const loginRateLimiter = new LoginRateLimiter(); -- 2.49.1