From 546d7290ee2a5664c52057469501092a8740f2bf Mon Sep 17 00:00:00 2001 From: Michel Roegl-Brunner <73236783+michelroegl-brunner@users.noreply.github.com> Date: Tue, 14 Oct 2025 10:19:52 +0200 Subject: [PATCH] feat: Add Shell button for interactive LXC container access (#144) * feat: Add Shell button for interactive LXC container access - Add Shell button to ScriptInstallationCard for SSH scripts with container_id - Implement shell state management in InstalledScriptsTab - Add shell execution methods in server.js (local and SSH) - Add isShell prop to Terminal component - Implement smooth scrolling to terminal when opened - Add highlight effect for better UX - Shell sessions are interactive (no auto-commands like update) The Shell button provides direct interactive access to LXC containers without automatically sending update commands, allowing users to manually execute commands in the container shell. * fix: Include SSH authentication fields in installed scripts data - Add SSH key fields (auth_type, ssh_key, ssh_key_passphrase, ssh_port) to database query - Update InstalledScript interface to include SSH authentication fields - Fix server data construction in handleOpenShell and handleUpdateScript - Now properly supports SSH key authentication for shell and update operations This fixes the issue where SSH key authentication was not being used even when configured in server settings, as the installed scripts data was missing the SSH authentication fields. * fix: Resolve TypeScript and ESLint build errors - Replace logical OR (||) with nullish coalescing (??) operators - Remove unnecessary type assertion for container_id - Add missing dependencies to useEffect and useCallback hooks - Remove unused variable in SSHKeyInput component - Add isShell property to WebSocketMessage type definition - Fix ServerInfo type to allow null in shell execution methods All TypeScript and ESLint errors resolved, build now passes successfully. --- server.js | 144 +++++++++++++++++- src/app/_components/InstalledScriptsTab.tsx | 130 +++++++++++++++- src/app/_components/SSHKeyInput.tsx | 3 - .../_components/ScriptInstallationCard.tsx | 18 +++ src/app/_components/Terminal.tsx | 7 +- src/server/database.js | 4 + 6 files changed, 295 insertions(+), 11 deletions(-) diff --git a/server.js b/server.js index 4daf897..ab89406 100644 --- a/server.js +++ b/server.js @@ -51,6 +51,7 @@ const handle = app.getRequestHandler(); * @property {string} [mode] * @property {ServerInfo} [server] * @property {boolean} [isUpdate] + * @property {boolean} [isShell] * @property {string} [containerId] */ @@ -207,13 +208,15 @@ class ScriptExecutionHandler { * @param {WebSocketMessage} message */ async handleMessage(ws, message) { - const { action, scriptPath, executionId, input, mode, server, isUpdate, containerId } = message; + const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, containerId } = message; switch (action) { case 'start': if (scriptPath && executionId) { if (isUpdate && containerId) { await this.startUpdateExecution(ws, containerId, executionId, mode, server); + } else if (isShell && containerId) { + await this.startShellExecution(ws, containerId, executionId, mode, server); } else { await this.startScriptExecution(ws, scriptPath, executionId, mode, server); } @@ -709,6 +712,145 @@ class ScriptExecutionHandler { }); } } + + /** + * Start shell execution + * @param {ExtendedWebSocket} ws + * @param {string} containerId + * @param {string} executionId + * @param {string} mode + * @param {ServerInfo|null} server + */ + async startShellExecution(ws, containerId, executionId, mode = 'local', server = null) { + try { + + // Send start message + this.sendMessage(ws, { + type: 'start', + data: `Starting shell session for container ${containerId}...`, + timestamp: Date.now() + }); + + if (mode === 'ssh' && server) { + await this.startSSHShellExecution(ws, containerId, executionId, server); + } else { + await this.startLocalShellExecution(ws, containerId, executionId); + } + + } catch (error) { + this.sendMessage(ws, { + type: 'error', + data: `Failed to start shell: ${error instanceof Error ? error.message : String(error)}`, + timestamp: Date.now() + }); + } + } + + /** + * Start local shell execution + * @param {ExtendedWebSocket} ws + * @param {string} containerId + * @param {string} executionId + */ + async startLocalShellExecution(ws, containerId, executionId) { + const { spawn } = await import('node-pty'); + + // Create a shell process that will run pct enter + const childProcess = spawn('bash', ['-c', `pct enter ${containerId}`], { + name: 'xterm-color', + cols: 80, + rows: 24, + cwd: process.cwd(), + env: process.env + }); + + // Store the execution + this.activeExecutions.set(executionId, { + process: childProcess, + ws + }); + + // Handle pty data + childProcess.onData((data) => { + this.sendMessage(ws, { + type: 'output', + data: data.toString(), + timestamp: Date.now() + }); + }); + + // Note: No automatic command is sent - user can type commands interactively + + // Handle process exit + childProcess.onExit((e) => { + this.sendMessage(ws, { + type: 'end', + data: `Shell session ended with exit code: ${e.exitCode}`, + timestamp: Date.now() + }); + + this.activeExecutions.delete(executionId); + }); + } + + /** + * Start SSH shell execution + * @param {ExtendedWebSocket} ws + * @param {string} containerId + * @param {string} executionId + * @param {ServerInfo} server + */ + async startSSHShellExecution(ws, containerId, executionId, server) { + const sshService = getSSHExecutionService(); + + try { + const execution = await sshService.executeCommand( + server, + `pct enter ${containerId}`, + /** @param {string} data */ + (data) => { + this.sendMessage(ws, { + type: 'output', + data: data, + timestamp: Date.now() + }); + }, + /** @param {string} error */ + (error) => { + this.sendMessage(ws, { + type: 'error', + data: error, + timestamp: Date.now() + }); + }, + /** @param {number} code */ + (code) => { + this.sendMessage(ws, { + type: 'end', + data: `Shell session ended with exit code: ${code}`, + timestamp: Date.now() + }); + + this.activeExecutions.delete(executionId); + } + ); + + // Store the execution + this.activeExecutions.set(executionId, { + process: /** @type {any} */ (execution).process, + ws + }); + + // Note: No automatic command is sent - user can type commands interactively + + } catch (error) { + this.sendMessage(ws, { + type: 'error', + data: `SSH shell execution failed: ${error instanceof Error ? error.message : String(error)}`, + timestamp: Date.now() + }); + } + } } // TerminalHandler removed - not used by current application diff --git a/src/app/_components/InstalledScriptsTab.tsx b/src/app/_components/InstalledScriptsTab.tsx index 6387e3d..12f1477 100644 --- a/src/app/_components/InstalledScriptsTab.tsx +++ b/src/app/_components/InstalledScriptsTab.tsx @@ -20,6 +20,10 @@ interface InstalledScript { server_ip: string | null; server_user: string | null; server_password: string | null; + server_auth_type: string | null; + server_ssh_key: string | null; + server_ssh_key_passphrase: string | null; + server_ssh_port: number | null; server_color: string | null; installation_date: string; status: 'in_progress' | 'success' | 'failed'; @@ -35,6 +39,7 @@ export function InstalledScriptsTab() { const [sortField, setSortField] = useState<'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date'>('server_name'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null); + const [openingShell, setOpeningShell] = useState<{ id: number; containerId: string; server?: any } | null>(null); const [editingScriptId, setEditingScriptId] = useState(null); const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' }); const [showAddForm, setShowAddForm] = useState(false); @@ -340,7 +345,7 @@ export function InstalledScriptsTab() { containerStatusMutation.mutate({ serverIds }); } }, 500); - }, []); // Remove containerStatusMutation from dependencies to prevent loops + }, [containerStatusMutation]); // Run cleanup when component mounts and scripts are loaded (only once) useEffect(() => { @@ -356,7 +361,7 @@ export function InstalledScriptsTab() { console.log('Status check triggered - scripts length:', scripts.length); fetchContainerStatuses(); } - }, [scripts.length]); // Remove fetchContainerStatuses from dependencies + }, [scripts.length, fetchContainerStatuses]); // Cleanup timeout on unmount useEffect(() => { @@ -526,13 +531,17 @@ export function InstalledScriptsTab() { onConfirm: () => { // Get server info if it's SSH mode let server = null; - if (script.server_id && script.server_user && script.server_password) { + if (script.server_id && script.server_user) { server = { id: script.server_id, name: script.server_name, ip: script.server_ip, user: script.server_user, - password: script.server_password + password: script.server_password, + auth_type: script.server_auth_type ?? 'password', + ssh_key: script.server_ssh_key, + ssh_key_passphrase: script.server_ssh_key_passphrase, + ssh_port: script.server_ssh_port ?? 22 }; } @@ -550,6 +559,91 @@ export function InstalledScriptsTab() { setUpdatingScript(null); }; + const handleOpenShell = (script: InstalledScript) => { + if (!script.container_id) { + setErrorModal({ + isOpen: true, + title: 'Shell Access Failed', + message: 'No Container ID available for this script', + details: 'This script does not have a valid container ID and cannot be accessed via shell.' + }); + return; + } + + // Get server info if it's SSH mode + let server = null; + if (script.server_id && script.server_user) { + server = { + id: script.server_id, + name: script.server_name, + ip: script.server_ip, + user: script.server_user, + password: script.server_password, + auth_type: script.server_auth_type ?? 'password', + ssh_key: script.server_ssh_key, + ssh_key_passphrase: script.server_ssh_key_passphrase, + ssh_port: script.server_ssh_port ?? 22 + }; + } + + setOpeningShell({ + id: script.id, + containerId: script.container_id, + server: server + }); + }; + + const handleCloseShellTerminal = () => { + setOpeningShell(null); + }; + + // Auto-scroll to terminals when they open + useEffect(() => { + if (openingShell) { + // Small delay to ensure the terminal is rendered + setTimeout(() => { + const terminalElement = document.querySelector('[data-terminal="shell"]'); + if (terminalElement) { + // Scroll to the terminal with smooth animation + terminalElement.scrollIntoView({ + behavior: 'smooth', + block: 'start', + inline: 'nearest' + }); + + // Add a subtle highlight effect + terminalElement.classList.add('animate-pulse'); + setTimeout(() => { + terminalElement.classList.remove('animate-pulse'); + }, 2000); + } + }, 200); + } + }, [openingShell]); + + useEffect(() => { + if (updatingScript) { + // Small delay to ensure the terminal is rendered + setTimeout(() => { + const terminalElement = document.querySelector('[data-terminal="update"]'); + if (terminalElement) { + // Scroll to the terminal with smooth animation + terminalElement.scrollIntoView({ + behavior: 'smooth', + block: 'start', + inline: 'nearest' + }); + + // Add a subtle highlight effect + terminalElement.classList.add('animate-pulse'); + setTimeout(() => { + terminalElement.classList.remove('animate-pulse'); + }, 2000); + } + }, 200); + } + }, [updatingScript]); + const handleEditScript = (script: InstalledScript) => { setEditingScriptId(script.id); setEditFormData({ @@ -662,7 +756,7 @@ export function InstalledScriptsTab() {
{/* Update Terminal */} {updatingScript && ( -
+
)} + {/* Shell Terminal */} + {openingShell && ( +
+ +
+ )} + {/* Header with Stats */}

Installed Scripts

@@ -995,6 +1103,7 @@ export function InstalledScriptsTab() { onSave={handleSaveEdit} onCancel={handleCancelEdit} onUpdate={() => handleUpdateScript(script)} + onShell={() => handleOpenShell(script)} onDelete={() => handleDeleteScript(Number(script.id))} isUpdating={updateScriptMutation.isPending} isDeleting={deleteScriptMutation.isPending} @@ -1203,6 +1312,17 @@ export function InstalledScriptsTab() { Update )} + {/* Shell button - only show for SSH scripts with container_id */} + {script.container_id && script.execution_mode === 'ssh' && ( + + )} {/* Container Control Buttons - only show for SSH scripts with container_id */} {script.container_id && script.execution_mode === 'ssh' && ( <> diff --git a/src/app/_components/SSHKeyInput.tsx b/src/app/_components/SSHKeyInput.tsx index bd2f74a..93bd595 100644 --- a/src/app/_components/SSHKeyInput.tsx +++ b/src/app/_components/SSHKeyInput.tsx @@ -104,9 +104,6 @@ export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHK keyType = 'ECDSA'; } else if (keyLine.includes('OPENSSH PRIVATE KEY')) { // For OpenSSH format keys, try to detect type from the key content - // Look for common patterns in the base64 content - const base64Content = keyContent.replace(/-----BEGIN.*?-----/, '').replace(/-----END.*?-----/, '').replace(/\s/g, ''); - // This is a heuristic - OpenSSH ED25519 keys typically start with specific patterns // We'll default to "OpenSSH" for now since we can't reliably detect the type keyType = 'OpenSSH'; diff --git a/src/app/_components/ScriptInstallationCard.tsx b/src/app/_components/ScriptInstallationCard.tsx index 8c49e94..37350d5 100644 --- a/src/app/_components/ScriptInstallationCard.tsx +++ b/src/app/_components/ScriptInstallationCard.tsx @@ -14,6 +14,10 @@ interface InstalledScript { server_ip: string | null; server_user: string | null; server_password: string | null; + server_auth_type: string | null; + server_ssh_key: string | null; + server_ssh_key_passphrase: string | null; + server_ssh_port: number | null; server_color: string | null; installation_date: string; status: 'in_progress' | 'success' | 'failed'; @@ -31,6 +35,7 @@ interface ScriptInstallationCardProps { onSave: () => void; onCancel: () => void; onUpdate: () => void; + onShell: () => void; onDelete: () => void; isUpdating: boolean; isDeleting: boolean; @@ -50,6 +55,7 @@ export function ScriptInstallationCard({ onSave, onCancel, onUpdate, + onShell, onDelete, isUpdating, isDeleting, @@ -203,6 +209,18 @@ export function ScriptInstallationCard({ Update )} + {/* Shell button - only show for SSH scripts with container_id */} + {script.container_id && script.execution_mode === 'ssh' && ( + + )} {/* Container Control Buttons - only show for SSH scripts with container_id */} {script.container_id && script.execution_mode === 'ssh' && ( <> diff --git a/src/app/_components/Terminal.tsx b/src/app/_components/Terminal.tsx index db1d855..b18e108 100644 --- a/src/app/_components/Terminal.tsx +++ b/src/app/_components/Terminal.tsx @@ -11,6 +11,7 @@ interface TerminalProps { mode?: 'local' | 'ssh'; server?: any; isUpdate?: boolean; + isShell?: boolean; containerId?: string; } @@ -20,7 +21,7 @@ interface TerminalMessage { timestamp: number; } -export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, containerId }: TerminalProps) { +export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, containerId }: TerminalProps) { const [isConnected, setIsConnected] = useState(false); const [isRunning, setIsRunning] = useState(false); const [isClient, setIsClient] = useState(false); @@ -332,6 +333,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate mode, server, isUpdate, + isShell, containerId }; ws.send(JSON.stringify(message)); @@ -372,7 +374,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate wsRef.current.close(); } }; - }, [scriptPath, mode, server, isUpdate, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps + }, [scriptPath, mode, server, isUpdate, isShell, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps const startScript = () => { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) { @@ -388,6 +390,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate mode, server, isUpdate, + isShell, containerId })); } diff --git a/src/server/database.js b/src/server/database.js index cf100d2..bca0659 100644 --- a/src/server/database.js +++ b/src/server/database.js @@ -180,6 +180,10 @@ class DatabaseService { s.ip as server_ip, s.user as server_user, s.password as server_password, + s.auth_type as server_auth_type, + s.ssh_key as server_ssh_key, + s.ssh_key_passphrase as server_ssh_key_passphrase, + s.ssh_port as server_ssh_port, s.color as server_color FROM installed_scripts inst LEFT JOIN servers s ON inst.server_id = s.id