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 {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