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