Skip to content

Commit 8d9f119

Browse files
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.
1 parent 8efff60 commit 8d9f119

File tree

4 files changed

+270
-4
lines changed

4 files changed

+270
-4
lines changed

server.js

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,13 +207,15 @@ class ScriptExecutionHandler {
207207
* @param {WebSocketMessage} message
208208
*/
209209
async handleMessage(ws, message) {
210-
const { action, scriptPath, executionId, input, mode, server, isUpdate, containerId } = message;
210+
const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, containerId } = message;
211211

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

714855
// TerminalHandler removed - not used by current application

src/app/_components/InstalledScriptsTab.tsx

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export function InstalledScriptsTab() {
3535
const [sortField, setSortField] = useState<'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date'>('server_name');
3636
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
3737
const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null);
38+
const [openingShell, setOpeningShell] = useState<{ id: number; containerId: string; server?: any } | null>(null);
3839
const [editingScriptId, setEditingScriptId] = useState<number | null>(null);
3940
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' });
4041
const [showAddForm, setShowAddForm] = useState(false);
@@ -550,6 +551,87 @@ export function InstalledScriptsTab() {
550551
setUpdatingScript(null);
551552
};
552553

554+
const handleOpenShell = (script: InstalledScript) => {
555+
if (!script.container_id) {
556+
setErrorModal({
557+
isOpen: true,
558+
title: 'Shell Access Failed',
559+
message: 'No Container ID available for this script',
560+
details: 'This script does not have a valid container ID and cannot be accessed via shell.'
561+
});
562+
return;
563+
}
564+
565+
// Get server info if it's SSH mode
566+
let server = null;
567+
if (script.server_id && script.server_user && script.server_password) {
568+
server = {
569+
id: script.server_id,
570+
name: script.server_name,
571+
ip: script.server_ip,
572+
user: script.server_user,
573+
password: script.server_password
574+
};
575+
}
576+
577+
setOpeningShell({
578+
id: script.id,
579+
containerId: script.container_id!,
580+
server: server
581+
});
582+
};
583+
584+
const handleCloseShellTerminal = () => {
585+
setOpeningShell(null);
586+
};
587+
588+
// Auto-scroll to terminals when they open
589+
useEffect(() => {
590+
if (openingShell) {
591+
// Small delay to ensure the terminal is rendered
592+
setTimeout(() => {
593+
const terminalElement = document.querySelector('[data-terminal="shell"]');
594+
if (terminalElement) {
595+
// Scroll to the terminal with smooth animation
596+
terminalElement.scrollIntoView({
597+
behavior: 'smooth',
598+
block: 'start',
599+
inline: 'nearest'
600+
});
601+
602+
// Add a subtle highlight effect
603+
terminalElement.classList.add('animate-pulse');
604+
setTimeout(() => {
605+
terminalElement.classList.remove('animate-pulse');
606+
}, 2000);
607+
}
608+
}, 200);
609+
}
610+
}, [openingShell]);
611+
612+
useEffect(() => {
613+
if (updatingScript) {
614+
// Small delay to ensure the terminal is rendered
615+
setTimeout(() => {
616+
const terminalElement = document.querySelector('[data-terminal="update"]');
617+
if (terminalElement) {
618+
// Scroll to the terminal with smooth animation
619+
terminalElement.scrollIntoView({
620+
behavior: 'smooth',
621+
block: 'start',
622+
inline: 'nearest'
623+
});
624+
625+
// Add a subtle highlight effect
626+
terminalElement.classList.add('animate-pulse');
627+
setTimeout(() => {
628+
terminalElement.classList.remove('animate-pulse');
629+
}, 2000);
630+
}
631+
}, 200);
632+
}
633+
}, [updatingScript]);
634+
553635
const handleEditScript = (script: InstalledScript) => {
554636
setEditingScriptId(script.id);
555637
setEditFormData({
@@ -662,7 +744,7 @@ export function InstalledScriptsTab() {
662744
<div className="space-y-6">
663745
{/* Update Terminal */}
664746
{updatingScript && (
665-
<div className="mb-8">
747+
<div className="mb-8" data-terminal="update">
666748
<Terminal
667749
scriptPath={`update-${updatingScript.containerId}`}
668750
onClose={handleCloseUpdateTerminal}
@@ -674,6 +756,20 @@ export function InstalledScriptsTab() {
674756
</div>
675757
)}
676758

759+
{/* Shell Terminal */}
760+
{openingShell && (
761+
<div className="mb-8" data-terminal="shell">
762+
<Terminal
763+
scriptPath={`shell-${openingShell.containerId}`}
764+
onClose={handleCloseShellTerminal}
765+
mode={openingShell.server ? 'ssh' : 'local'}
766+
server={openingShell.server}
767+
isShell={true}
768+
containerId={openingShell.containerId}
769+
/>
770+
</div>
771+
)}
772+
677773
{/* Header with Stats */}
678774
<div className="bg-card rounded-lg shadow p-6">
679775
<h2 className="text-2xl font-bold text-foreground mb-4">Installed Scripts</h2>
@@ -995,6 +1091,7 @@ export function InstalledScriptsTab() {
9951091
onSave={handleSaveEdit}
9961092
onCancel={handleCancelEdit}
9971093
onUpdate={() => handleUpdateScript(script)}
1094+
onShell={() => handleOpenShell(script)}
9981095
onDelete={() => handleDeleteScript(Number(script.id))}
9991096
isUpdating={updateScriptMutation.isPending}
10001097
isDeleting={deleteScriptMutation.isPending}
@@ -1203,6 +1300,17 @@ export function InstalledScriptsTab() {
12031300
Update
12041301
</Button>
12051302
)}
1303+
{/* Shell button - only show for SSH scripts with container_id */}
1304+
{script.container_id && script.execution_mode === 'ssh' && (
1305+
<Button
1306+
onClick={() => handleOpenShell(script)}
1307+
variant="secondary"
1308+
size="sm"
1309+
disabled={containerStatuses.get(script.id) === 'stopped'}
1310+
>
1311+
Shell
1312+
</Button>
1313+
)}
12061314
{/* Container Control Buttons - only show for SSH scripts with container_id */}
12071315
{script.container_id && script.execution_mode === 'ssh' && (
12081316
<>

src/app/_components/ScriptInstallationCard.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ interface ScriptInstallationCardProps {
3131
onSave: () => void;
3232
onCancel: () => void;
3333
onUpdate: () => void;
34+
onShell: () => void;
3435
onDelete: () => void;
3536
isUpdating: boolean;
3637
isDeleting: boolean;
@@ -50,6 +51,7 @@ export function ScriptInstallationCard({
5051
onSave,
5152
onCancel,
5253
onUpdate,
54+
onShell,
5355
onDelete,
5456
isUpdating,
5557
isDeleting,
@@ -203,6 +205,18 @@ export function ScriptInstallationCard({
203205
Update
204206
</Button>
205207
)}
208+
{/* Shell button - only show for SSH scripts with container_id */}
209+
{script.container_id && script.execution_mode === 'ssh' && (
210+
<Button
211+
onClick={onShell}
212+
variant="secondary"
213+
size="sm"
214+
className="flex-1 min-w-0"
215+
disabled={containerStatus === 'stopped'}
216+
>
217+
Shell
218+
</Button>
219+
)}
206220
{/* Container Control Buttons - only show for SSH scripts with container_id */}
207221
{script.container_id && script.execution_mode === 'ssh' && (
208222
<>

src/app/_components/Terminal.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface TerminalProps {
1111
mode?: 'local' | 'ssh';
1212
server?: any;
1313
isUpdate?: boolean;
14+
isShell?: boolean;
1415
containerId?: string;
1516
}
1617

@@ -20,7 +21,7 @@ interface TerminalMessage {
2021
timestamp: number;
2122
}
2223

23-
export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, containerId }: TerminalProps) {
24+
export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, containerId }: TerminalProps) {
2425
const [isConnected, setIsConnected] = useState(false);
2526
const [isRunning, setIsRunning] = useState(false);
2627
const [isClient, setIsClient] = useState(false);
@@ -332,6 +333,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
332333
mode,
333334
server,
334335
isUpdate,
336+
isShell,
335337
containerId
336338
};
337339
ws.send(JSON.stringify(message));
@@ -372,7 +374,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
372374
wsRef.current.close();
373375
}
374376
};
375-
}, [scriptPath, mode, server, isUpdate, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
377+
}, [scriptPath, mode, server, isUpdate, isShell, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
376378

377379
const startScript = () => {
378380
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
@@ -388,6 +390,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
388390
mode,
389391
server,
390392
isUpdate,
393+
isShell,
391394
containerId
392395
}));
393396
}

0 commit comments

Comments
 (0)