Skip to content

Commit 546d729

Browse files
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.
1 parent a5b67b1 commit 546d729

File tree

6 files changed

+295
-11
lines changed

6 files changed

+295
-11
lines changed

server.js

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const handle = app.getRequestHandler();
5151
* @property {string} [mode]
5252
* @property {ServerInfo} [server]
5353
* @property {boolean} [isUpdate]
54+
* @property {boolean} [isShell]
5455
* @property {string} [containerId]
5556
*/
5657

@@ -207,13 +208,15 @@ class ScriptExecutionHandler {
207208
* @param {WebSocketMessage} message
208209
*/
209210
async handleMessage(ws, message) {
210-
const { action, scriptPath, executionId, input, mode, server, isUpdate, containerId } = message;
211+
const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, containerId } = message;
211212

212213
switch (action) {
213214
case 'start':
214215
if (scriptPath && executionId) {
215216
if (isUpdate && containerId) {
216217
await this.startUpdateExecution(ws, containerId, executionId, mode, server);
218+
} else if (isShell && containerId) {
219+
await this.startShellExecution(ws, containerId, executionId, mode, server);
217220
} else {
218221
await this.startScriptExecution(ws, scriptPath, executionId, mode, server);
219222
}
@@ -709,6 +712,145 @@ class ScriptExecutionHandler {
709712
});
710713
}
711714
}
715+
716+
/**
717+
* Start shell execution
718+
* @param {ExtendedWebSocket} ws
719+
* @param {string} containerId
720+
* @param {string} executionId
721+
* @param {string} mode
722+
* @param {ServerInfo|null} server
723+
*/
724+
async startShellExecution(ws, containerId, executionId, mode = 'local', server = null) {
725+
try {
726+
727+
// Send start message
728+
this.sendMessage(ws, {
729+
type: 'start',
730+
data: `Starting shell session for container ${containerId}...`,
731+
timestamp: Date.now()
732+
});
733+
734+
if (mode === 'ssh' && server) {
735+
await this.startSSHShellExecution(ws, containerId, executionId, server);
736+
} else {
737+
await this.startLocalShellExecution(ws, containerId, executionId);
738+
}
739+
740+
} catch (error) {
741+
this.sendMessage(ws, {
742+
type: 'error',
743+
data: `Failed to start shell: ${error instanceof Error ? error.message : String(error)}`,
744+
timestamp: Date.now()
745+
});
746+
}
747+
}
748+
749+
/**
750+
* Start local shell execution
751+
* @param {ExtendedWebSocket} ws
752+
* @param {string} containerId
753+
* @param {string} executionId
754+
*/
755+
async startLocalShellExecution(ws, containerId, executionId) {
756+
const { spawn } = await import('node-pty');
757+
758+
// Create a shell process that will run pct enter
759+
const childProcess = spawn('bash', ['-c', `pct enter ${containerId}`], {
760+
name: 'xterm-color',
761+
cols: 80,
762+
rows: 24,
763+
cwd: process.cwd(),
764+
env: process.env
765+
});
766+
767+
// Store the execution
768+
this.activeExecutions.set(executionId, {
769+
process: childProcess,
770+
ws
771+
});
772+
773+
// Handle pty data
774+
childProcess.onData((data) => {
775+
this.sendMessage(ws, {
776+
type: 'output',
777+
data: data.toString(),
778+
timestamp: Date.now()
779+
});
780+
});
781+
782+
// Note: No automatic command is sent - user can type commands interactively
783+
784+
// Handle process exit
785+
childProcess.onExit((e) => {
786+
this.sendMessage(ws, {
787+
type: 'end',
788+
data: `Shell session ended with exit code: ${e.exitCode}`,
789+
timestamp: Date.now()
790+
});
791+
792+
this.activeExecutions.delete(executionId);
793+
});
794+
}
795+
796+
/**
797+
* Start SSH shell execution
798+
* @param {ExtendedWebSocket} ws
799+
* @param {string} containerId
800+
* @param {string} executionId
801+
* @param {ServerInfo} server
802+
*/
803+
async startSSHShellExecution(ws, containerId, executionId, server) {
804+
const sshService = getSSHExecutionService();
805+
806+
try {
807+
const execution = await sshService.executeCommand(
808+
server,
809+
`pct enter ${containerId}`,
810+
/** @param {string} data */
811+
(data) => {
812+
this.sendMessage(ws, {
813+
type: 'output',
814+
data: data,
815+
timestamp: Date.now()
816+
});
817+
},
818+
/** @param {string} error */
819+
(error) => {
820+
this.sendMessage(ws, {
821+
type: 'error',
822+
data: error,
823+
timestamp: Date.now()
824+
});
825+
},
826+
/** @param {number} code */
827+
(code) => {
828+
this.sendMessage(ws, {
829+
type: 'end',
830+
data: `Shell session ended with exit code: ${code}`,
831+
timestamp: Date.now()
832+
});
833+
834+
this.activeExecutions.delete(executionId);
835+
}
836+
);
837+
838+
// Store the execution
839+
this.activeExecutions.set(executionId, {
840+
process: /** @type {any} */ (execution).process,
841+
ws
842+
});
843+
844+
// Note: No automatic command is sent - user can type commands interactively
845+
846+
} catch (error) {
847+
this.sendMessage(ws, {
848+
type: 'error',
849+
data: `SSH shell execution failed: ${error instanceof Error ? error.message : String(error)}`,
850+
timestamp: Date.now()
851+
});
852+
}
853+
}
712854
}
713855

714856
// TerminalHandler removed - not used by current application

src/app/_components/InstalledScriptsTab.tsx

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ interface InstalledScript {
2020
server_ip: string | null;
2121
server_user: string | null;
2222
server_password: string | null;
23+
server_auth_type: string | null;
24+
server_ssh_key: string | null;
25+
server_ssh_key_passphrase: string | null;
26+
server_ssh_port: number | null;
2327
server_color: string | null;
2428
installation_date: string;
2529
status: 'in_progress' | 'success' | 'failed';
@@ -35,6 +39,7 @@ export function InstalledScriptsTab() {
3539
const [sortField, setSortField] = useState<'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date'>('server_name');
3640
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
3741
const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null);
42+
const [openingShell, setOpeningShell] = useState<{ id: number; containerId: string; server?: any } | null>(null);
3843
const [editingScriptId, setEditingScriptId] = useState<number | null>(null);
3944
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' });
4045
const [showAddForm, setShowAddForm] = useState(false);
@@ -340,7 +345,7 @@ export function InstalledScriptsTab() {
340345
containerStatusMutation.mutate({ serverIds });
341346
}
342347
}, 500);
343-
}, []); // Remove containerStatusMutation from dependencies to prevent loops
348+
}, [containerStatusMutation]);
344349

345350
// Run cleanup when component mounts and scripts are loaded (only once)
346351
useEffect(() => {
@@ -356,7 +361,7 @@ export function InstalledScriptsTab() {
356361
console.log('Status check triggered - scripts length:', scripts.length);
357362
fetchContainerStatuses();
358363
}
359-
}, [scripts.length]); // Remove fetchContainerStatuses from dependencies
364+
}, [scripts.length, fetchContainerStatuses]);
360365

361366
// Cleanup timeout on unmount
362367
useEffect(() => {
@@ -526,13 +531,17 @@ export function InstalledScriptsTab() {
526531
onConfirm: () => {
527532
// Get server info if it's SSH mode
528533
let server = null;
529-
if (script.server_id && script.server_user && script.server_password) {
534+
if (script.server_id && script.server_user) {
530535
server = {
531536
id: script.server_id,
532537
name: script.server_name,
533538
ip: script.server_ip,
534539
user: script.server_user,
535-
password: script.server_password
540+
password: script.server_password,
541+
auth_type: script.server_auth_type ?? 'password',
542+
ssh_key: script.server_ssh_key,
543+
ssh_key_passphrase: script.server_ssh_key_passphrase,
544+
ssh_port: script.server_ssh_port ?? 22
536545
};
537546
}
538547

@@ -550,6 +559,91 @@ export function InstalledScriptsTab() {
550559
setUpdatingScript(null);
551560
};
552561

562+
const handleOpenShell = (script: InstalledScript) => {
563+
if (!script.container_id) {
564+
setErrorModal({
565+
isOpen: true,
566+
title: 'Shell Access Failed',
567+
message: 'No Container ID available for this script',
568+
details: 'This script does not have a valid container ID and cannot be accessed via shell.'
569+
});
570+
return;
571+
}
572+
573+
// Get server info if it's SSH mode
574+
let server = null;
575+
if (script.server_id && script.server_user) {
576+
server = {
577+
id: script.server_id,
578+
name: script.server_name,
579+
ip: script.server_ip,
580+
user: script.server_user,
581+
password: script.server_password,
582+
auth_type: script.server_auth_type ?? 'password',
583+
ssh_key: script.server_ssh_key,
584+
ssh_key_passphrase: script.server_ssh_key_passphrase,
585+
ssh_port: script.server_ssh_port ?? 22
586+
};
587+
}
588+
589+
setOpeningShell({
590+
id: script.id,
591+
containerId: script.container_id,
592+
server: server
593+
});
594+
};
595+
596+
const handleCloseShellTerminal = () => {
597+
setOpeningShell(null);
598+
};
599+
600+
// Auto-scroll to terminals when they open
601+
useEffect(() => {
602+
if (openingShell) {
603+
// Small delay to ensure the terminal is rendered
604+
setTimeout(() => {
605+
const terminalElement = document.querySelector('[data-terminal="shell"]');
606+
if (terminalElement) {
607+
// Scroll to the terminal with smooth animation
608+
terminalElement.scrollIntoView({
609+
behavior: 'smooth',
610+
block: 'start',
611+
inline: 'nearest'
612+
});
613+
614+
// Add a subtle highlight effect
615+
terminalElement.classList.add('animate-pulse');
616+
setTimeout(() => {
617+
terminalElement.classList.remove('animate-pulse');
618+
}, 2000);
619+
}
620+
}, 200);
621+
}
622+
}, [openingShell]);
623+
624+
useEffect(() => {
625+
if (updatingScript) {
626+
// Small delay to ensure the terminal is rendered
627+
setTimeout(() => {
628+
const terminalElement = document.querySelector('[data-terminal="update"]');
629+
if (terminalElement) {
630+
// Scroll to the terminal with smooth animation
631+
terminalElement.scrollIntoView({
632+
behavior: 'smooth',
633+
block: 'start',
634+
inline: 'nearest'
635+
});
636+
637+
// Add a subtle highlight effect
638+
terminalElement.classList.add('animate-pulse');
639+
setTimeout(() => {
640+
terminalElement.classList.remove('animate-pulse');
641+
}, 2000);
642+
}
643+
}, 200);
644+
}
645+
}, [updatingScript]);
646+
553647
const handleEditScript = (script: InstalledScript) => {
554648
setEditingScriptId(script.id);
555649
setEditFormData({
@@ -662,7 +756,7 @@ export function InstalledScriptsTab() {
662756
<div className="space-y-6">
663757
{/* Update Terminal */}
664758
{updatingScript && (
665-
<div className="mb-8">
759+
<div className="mb-8" data-terminal="update">
666760
<Terminal
667761
scriptPath={`update-${updatingScript.containerId}`}
668762
onClose={handleCloseUpdateTerminal}
@@ -674,6 +768,20 @@ export function InstalledScriptsTab() {
674768
</div>
675769
)}
676770

771+
{/* Shell Terminal */}
772+
{openingShell && (
773+
<div className="mb-8" data-terminal="shell">
774+
<Terminal
775+
scriptPath={`shell-${openingShell.containerId}`}
776+
onClose={handleCloseShellTerminal}
777+
mode={openingShell.server ? 'ssh' : 'local'}
778+
server={openingShell.server}
779+
isShell={true}
780+
containerId={openingShell.containerId}
781+
/>
782+
</div>
783+
)}
784+
677785
{/* Header with Stats */}
678786
<div className="bg-card rounded-lg shadow p-6">
679787
<h2 className="text-2xl font-bold text-foreground mb-4">Installed Scripts</h2>
@@ -995,6 +1103,7 @@ export function InstalledScriptsTab() {
9951103
onSave={handleSaveEdit}
9961104
onCancel={handleCancelEdit}
9971105
onUpdate={() => handleUpdateScript(script)}
1106+
onShell={() => handleOpenShell(script)}
9981107
onDelete={() => handleDeleteScript(Number(script.id))}
9991108
isUpdating={updateScriptMutation.isPending}
10001109
isDeleting={deleteScriptMutation.isPending}
@@ -1203,6 +1312,17 @@ export function InstalledScriptsTab() {
12031312
Update
12041313
</Button>
12051314
)}
1315+
{/* Shell button - only show for SSH scripts with container_id */}
1316+
{script.container_id && script.execution_mode === 'ssh' && (
1317+
<Button
1318+
onClick={() => handleOpenShell(script)}
1319+
variant="secondary"
1320+
size="sm"
1321+
disabled={containerStatuses.get(script.id) === 'stopped'}
1322+
>
1323+
Shell
1324+
</Button>
1325+
)}
12061326
{/* Container Control Buttons - only show for SSH scripts with container_id */}
12071327
{script.container_id && script.execution_mode === 'ssh' && (
12081328
<>

src/app/_components/SSHKeyInput.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,6 @@ export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHK
104104
keyType = 'ECDSA';
105105
} else if (keyLine.includes('OPENSSH PRIVATE KEY')) {
106106
// For OpenSSH format keys, try to detect type from the key content
107-
// Look for common patterns in the base64 content
108-
const base64Content = keyContent.replace(/-----BEGIN.*?-----/, '').replace(/-----END.*?-----/, '').replace(/\s/g, '');
109-
110107
// This is a heuristic - OpenSSH ED25519 keys typically start with specific patterns
111108
// We'll default to "OpenSSH" for now since we can't reliably detect the type
112109
keyType = 'OpenSSH';

0 commit comments

Comments
 (0)