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.
This commit is contained in:
Michel Roegl-Brunner
2025-10-14 10:19:52 +02:00
committed by GitHub
parent a5b67b183b
commit 546d7290ee
6 changed files with 295 additions and 11 deletions

144
server.js
View File

@@ -51,6 +51,7 @@ const handle = app.getRequestHandler();
* @property {string} [mode] * @property {string} [mode]
* @property {ServerInfo} [server] * @property {ServerInfo} [server]
* @property {boolean} [isUpdate] * @property {boolean} [isUpdate]
* @property {boolean} [isShell]
* @property {string} [containerId] * @property {string} [containerId]
*/ */
@@ -207,13 +208,15 @@ class ScriptExecutionHandler {
* @param {WebSocketMessage} message * @param {WebSocketMessage} message
*/ */
async handleMessage(ws, 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) { switch (action) {
case 'start': case 'start':
if (scriptPath && executionId) { if (scriptPath && executionId) {
if (isUpdate && containerId) { if (isUpdate && containerId) {
await this.startUpdateExecution(ws, containerId, executionId, mode, server); await this.startUpdateExecution(ws, containerId, executionId, mode, server);
} else if (isShell && containerId) {
await this.startShellExecution(ws, containerId, executionId, mode, server);
} else { } else {
await this.startScriptExecution(ws, scriptPath, executionId, mode, server); 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 // TerminalHandler removed - not used by current application

View File

@@ -20,6 +20,10 @@ interface InstalledScript {
server_ip: string | null; server_ip: string | null;
server_user: string | null; server_user: string | null;
server_password: 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; server_color: string | null;
installation_date: string; installation_date: string;
status: 'in_progress' | 'success' | 'failed'; 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 [sortField, setSortField] = useState<'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date'>('server_name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null); 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<number | null>(null); const [editingScriptId, setEditingScriptId] = useState<number | null>(null);
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' }); const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' });
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
@@ -340,7 +345,7 @@ export function InstalledScriptsTab() {
containerStatusMutation.mutate({ serverIds }); containerStatusMutation.mutate({ serverIds });
} }
}, 500); }, 500);
}, []); // Remove containerStatusMutation from dependencies to prevent loops }, [containerStatusMutation]);
// Run cleanup when component mounts and scripts are loaded (only once) // Run cleanup when component mounts and scripts are loaded (only once)
useEffect(() => { useEffect(() => {
@@ -356,7 +361,7 @@ export function InstalledScriptsTab() {
console.log('Status check triggered - scripts length:', scripts.length); console.log('Status check triggered - scripts length:', scripts.length);
fetchContainerStatuses(); fetchContainerStatuses();
} }
}, [scripts.length]); // Remove fetchContainerStatuses from dependencies }, [scripts.length, fetchContainerStatuses]);
// Cleanup timeout on unmount // Cleanup timeout on unmount
useEffect(() => { useEffect(() => {
@@ -526,13 +531,17 @@ export function InstalledScriptsTab() {
onConfirm: () => { onConfirm: () => {
// Get server info if it's SSH mode // Get server info if it's SSH mode
let server = null; let server = null;
if (script.server_id && script.server_user && script.server_password) { if (script.server_id && script.server_user) {
server = { server = {
id: script.server_id, id: script.server_id,
name: script.server_name, name: script.server_name,
ip: script.server_ip, ip: script.server_ip,
user: script.server_user, 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); 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) => { const handleEditScript = (script: InstalledScript) => {
setEditingScriptId(script.id); setEditingScriptId(script.id);
setEditFormData({ setEditFormData({
@@ -662,7 +756,7 @@ export function InstalledScriptsTab() {
<div className="space-y-6"> <div className="space-y-6">
{/* Update Terminal */} {/* Update Terminal */}
{updatingScript && ( {updatingScript && (
<div className="mb-8"> <div className="mb-8" data-terminal="update">
<Terminal <Terminal
scriptPath={`update-${updatingScript.containerId}`} scriptPath={`update-${updatingScript.containerId}`}
onClose={handleCloseUpdateTerminal} onClose={handleCloseUpdateTerminal}
@@ -674,6 +768,20 @@ export function InstalledScriptsTab() {
</div> </div>
)} )}
{/* Shell Terminal */}
{openingShell && (
<div className="mb-8" data-terminal="shell">
<Terminal
scriptPath={`shell-${openingShell.containerId}`}
onClose={handleCloseShellTerminal}
mode={openingShell.server ? 'ssh' : 'local'}
server={openingShell.server}
isShell={true}
containerId={openingShell.containerId}
/>
</div>
)}
{/* Header with Stats */} {/* Header with Stats */}
<div className="bg-card rounded-lg shadow p-6"> <div className="bg-card rounded-lg shadow p-6">
<h2 className="text-2xl font-bold text-foreground mb-4">Installed Scripts</h2> <h2 className="text-2xl font-bold text-foreground mb-4">Installed Scripts</h2>
@@ -995,6 +1103,7 @@ export function InstalledScriptsTab() {
onSave={handleSaveEdit} onSave={handleSaveEdit}
onCancel={handleCancelEdit} onCancel={handleCancelEdit}
onUpdate={() => handleUpdateScript(script)} onUpdate={() => handleUpdateScript(script)}
onShell={() => handleOpenShell(script)}
onDelete={() => handleDeleteScript(Number(script.id))} onDelete={() => handleDeleteScript(Number(script.id))}
isUpdating={updateScriptMutation.isPending} isUpdating={updateScriptMutation.isPending}
isDeleting={deleteScriptMutation.isPending} isDeleting={deleteScriptMutation.isPending}
@@ -1203,6 +1312,17 @@ export function InstalledScriptsTab() {
Update Update
</Button> </Button>
)} )}
{/* Shell button - only show for SSH scripts with container_id */}
{script.container_id && script.execution_mode === 'ssh' && (
<Button
onClick={() => handleOpenShell(script)}
variant="secondary"
size="sm"
disabled={containerStatuses.get(script.id) === 'stopped'}
>
Shell
</Button>
)}
{/* Container Control Buttons - only show for SSH scripts with container_id */} {/* Container Control Buttons - only show for SSH scripts with container_id */}
{script.container_id && script.execution_mode === 'ssh' && ( {script.container_id && script.execution_mode === 'ssh' && (
<> <>

View File

@@ -104,9 +104,6 @@ export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHK
keyType = 'ECDSA'; keyType = 'ECDSA';
} else if (keyLine.includes('OPENSSH PRIVATE KEY')) { } else if (keyLine.includes('OPENSSH PRIVATE KEY')) {
// For OpenSSH format keys, try to detect type from the key content // 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 // 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 // We'll default to "OpenSSH" for now since we can't reliably detect the type
keyType = 'OpenSSH'; keyType = 'OpenSSH';

View File

@@ -14,6 +14,10 @@ interface InstalledScript {
server_ip: string | null; server_ip: string | null;
server_user: string | null; server_user: string | null;
server_password: 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; server_color: string | null;
installation_date: string; installation_date: string;
status: 'in_progress' | 'success' | 'failed'; status: 'in_progress' | 'success' | 'failed';
@@ -31,6 +35,7 @@ interface ScriptInstallationCardProps {
onSave: () => void; onSave: () => void;
onCancel: () => void; onCancel: () => void;
onUpdate: () => void; onUpdate: () => void;
onShell: () => void;
onDelete: () => void; onDelete: () => void;
isUpdating: boolean; isUpdating: boolean;
isDeleting: boolean; isDeleting: boolean;
@@ -50,6 +55,7 @@ export function ScriptInstallationCard({
onSave, onSave,
onCancel, onCancel,
onUpdate, onUpdate,
onShell,
onDelete, onDelete,
isUpdating, isUpdating,
isDeleting, isDeleting,
@@ -203,6 +209,18 @@ export function ScriptInstallationCard({
Update Update
</Button> </Button>
)} )}
{/* Shell button - only show for SSH scripts with container_id */}
{script.container_id && script.execution_mode === 'ssh' && (
<Button
onClick={onShell}
variant="secondary"
size="sm"
className="flex-1 min-w-0"
disabled={containerStatus === 'stopped'}
>
Shell
</Button>
)}
{/* Container Control Buttons - only show for SSH scripts with container_id */} {/* Container Control Buttons - only show for SSH scripts with container_id */}
{script.container_id && script.execution_mode === 'ssh' && ( {script.container_id && script.execution_mode === 'ssh' && (
<> <>

View File

@@ -11,6 +11,7 @@ interface TerminalProps {
mode?: 'local' | 'ssh'; mode?: 'local' | 'ssh';
server?: any; server?: any;
isUpdate?: boolean; isUpdate?: boolean;
isShell?: boolean;
containerId?: string; containerId?: string;
} }
@@ -20,7 +21,7 @@ interface TerminalMessage {
timestamp: number; 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 [isConnected, setIsConnected] = useState(false);
const [isRunning, setIsRunning] = useState(false); const [isRunning, setIsRunning] = useState(false);
const [isClient, setIsClient] = useState(false); const [isClient, setIsClient] = useState(false);
@@ -332,6 +333,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
mode, mode,
server, server,
isUpdate, isUpdate,
isShell,
containerId containerId
}; };
ws.send(JSON.stringify(message)); ws.send(JSON.stringify(message));
@@ -372,7 +374,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
wsRef.current.close(); 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 = () => { const startScript = () => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
@@ -388,6 +390,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
mode, mode,
server, server,
isUpdate, isUpdate,
isShell,
containerId containerId
})); }));
} }

View File

@@ -180,6 +180,10 @@ class DatabaseService {
s.ip as server_ip, s.ip as server_ip,
s.user as server_user, s.user as server_user,
s.password as server_password, 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 s.color as server_color
FROM installed_scripts inst FROM installed_scripts inst
LEFT JOIN servers s ON inst.server_id = s.id LEFT JOIN servers s ON inst.server_id = s.id