Files
ProxmoxVE-Local/src/app/_components/Terminal.tsx
Michel Roegl-Brunner 546d7290ee 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.
2025-10-14 10:19:52 +02:00

685 lines
24 KiB
TypeScript

'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import '@xterm/xterm/css/xterm.css';
import { Button } from './ui/button';
import { Play, Square, Trash2, X, Send, Keyboard, ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
interface TerminalProps {
scriptPath: string;
onClose: () => void;
mode?: 'local' | 'ssh';
server?: any;
isUpdate?: boolean;
isShell?: boolean;
containerId?: string;
}
interface TerminalMessage {
type: 'start' | 'output' | 'error' | 'end';
data: string;
timestamp: number;
}
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);
const [mobileInput, setMobileInput] = useState('');
const [showMobileInput, setShowMobileInput] = useState(false);
const [lastInputSent, setLastInputSent] = useState<string | null>(null);
const [isMobile, setIsMobile] = useState(false);
const [isStopped, setIsStopped] = useState(false);
const [isTerminalReady, setIsTerminalReady] = useState(false);
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<any>(null);
const fitAddonRef = useRef<any>(null);
const wsRef = useRef<WebSocket | null>(null);
const inputHandlerRef = useRef<((data: string) => void) | null>(null);
const [executionId, setExecutionId] = useState(() => `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
const isConnectingRef = useRef<boolean>(false);
const hasConnectedRef = useRef<boolean>(false);
const scriptName = scriptPath.split('/').pop() ?? scriptPath.split('\\').pop() ?? 'Unknown Script';
const handleMessage = useCallback((message: TerminalMessage) => {
if (!xtermRef.current) return;
const timestamp = new Date(message.timestamp).toLocaleTimeString();
const prefix = `[${timestamp}] `;
switch (message.type) {
case 'start':
xtermRef.current.writeln(`${prefix}[START] ${message.data}`);
setIsRunning(true);
break;
case 'output':
// Write directly to terminal - xterm.js handles ANSI codes natively
xtermRef.current.write(message.data);
break;
case 'error':
// Check if this looks like ANSI terminal output (contains escape codes)
if (message.data.includes('\x1B[') || message.data.includes('\u001b[')) {
// This is likely terminal output sent to stderr, treat it as normal output
xtermRef.current.write(message.data);
} else if (message.data.includes('TERM environment variable not set')) {
// This is a common warning, treat as normal output
xtermRef.current.write(message.data);
} else if (message.data.includes('exit code') && message.data.includes('clear')) {
// This is a script error, show it with error prefix
xtermRef.current.writeln(`${prefix}[ERROR] ${message.data}`);
} else {
// This is a real error, show it with error prefix
xtermRef.current.writeln(`${prefix}[ERROR] ${message.data}`);
}
break;
case 'end':
setIsRunning(false);
// Check if this is an LXC creation script
const isLxcCreation = scriptPath.includes('ct/') ||
scriptPath.includes('create_lxc') ||
(containerId != null) ||
scriptName.includes('lxc') ||
scriptName.includes('container');
if (isLxcCreation && message.data.includes('SSH script execution finished with code: 0')) {
// Display prominent LXC creation completion message
xtermRef.current.writeln('');
xtermRef.current.writeln('#########################################');
xtermRef.current.writeln('########## LXC CREATION FINISHED ########');
xtermRef.current.writeln('#########################################');
xtermRef.current.writeln('');
} else {
xtermRef.current.writeln(`${prefix}${message.data}`);
}
break;
}
}, [scriptPath, containerId, scriptName]);
// Ensure we're on the client side
useEffect(() => {
setIsClient(true);
// Detect mobile on mount
setIsMobile(window.innerWidth < 768);
}, []);
useEffect(() => {
// Only initialize on client side
if (!isClient || !terminalRef.current || xtermRef.current) return;
// Store ref value to avoid stale closure
const terminalElement = terminalRef.current;
// Use setTimeout to ensure DOM is fully ready
const initTerminal = async () => {
if (!terminalElement || xtermRef.current) return;
// Dynamically import xterm modules to avoid SSR issues
const { Terminal: XTerm } = await import('@xterm/xterm');
const { FitAddon } = await import('@xterm/addon-fit');
const { WebLinksAddon } = await import('@xterm/addon-web-links');
// Use the mobile state
const terminal = new XTerm({
theme: {
background: '#0d1117',
foreground: '#e6edf3',
cursor: '#58a6ff',
cursorAccent: '#0d1117',
// Let ANSI colors work naturally - only define basic colors
black: '#484f58',
red: '#f85149',
green: '#3fb950',
yellow: '#d29922',
blue: '#58a6ff',
magenta: '#bc8cff',
cyan: '#39d353',
white: '#b1bac4',
brightBlack: '#6e7681',
brightRed: '#ff7b72',
brightGreen: '#56d364',
brightYellow: '#e3b341',
brightBlue: '#79c0ff',
brightMagenta: '#d2a8ff',
brightCyan: '#56d364',
brightWhite: '#f0f6fc',
},
fontSize: isMobile ? 7 : 14,
fontFamily: 'JetBrains Mono, Fira Code, Cascadia Code, Monaco, Menlo, Ubuntu Mono, monospace',
cursorBlink: true,
cursorStyle: 'block',
scrollback: 1000,
tabStopWidth: 4,
allowTransparency: false,
convertEol: true,
disableStdin: false,
macOptionIsMeta: false,
rightClickSelectsWord: false,
wordSeparator: ' ()[]{}\'"`<>|',
// Better ANSI handling
allowProposedApi: true,
// Force proper terminal behavior for interactive applications
// Use smaller dimensions on mobile but ensure proper fit
cols: isMobile ? 45 : 80,
rows: isMobile ? 18 : 24,
});
// Add addons
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
// Enable better ANSI handling
terminal.options.allowProposedApi = true;
// Open terminal
terminal.open(terminalElement);
// Ensure proper terminal rendering
setTimeout(() => {
terminal.refresh(0, terminal.rows - 1);
// Ensure cursor is properly positioned
terminal.focus();
// Force focus on the terminal element
terminalElement.focus();
terminalElement.click();
// Add click handler to ensure terminal stays focused
const focusHandler = () => {
terminal.focus();
terminalElement.focus();
};
terminalElement.addEventListener('click', focusHandler);
// Store the handler for cleanup
(terminalElement as any).focusHandler = focusHandler;
}, 100);
// Fit after a small delay to ensure proper sizing
setTimeout(() => {
fitAddon.fit();
// Force fit multiple times for mobile to ensure proper sizing
if (isMobile) {
setTimeout(() => {
fitAddon.fit();
setTimeout(() => {
fitAddon.fit();
}, 200);
}, 300);
}
}, 100);
// Add resize listener for mobile responsiveness
const handleResize = () => {
if (fitAddonRef.current) {
setTimeout(() => {
fitAddonRef.current.fit();
}, 50);
}
};
window.addEventListener('resize', handleResize);
// Store the handler for cleanup
(terminalElement as any).resizeHandler = handleResize;
// Store references
xtermRef.current = terminal;
fitAddonRef.current = fitAddon;
// Mark terminal as ready
setIsTerminalReady(true);
return () => {
terminal.dispose();
};
};
// Initialize with a small delay
const timeoutId = setTimeout(() => {
void initTerminal();
}, 50);
return () => {
clearTimeout(timeoutId);
if (terminalElement && (terminalElement as any).resizeHandler) {
window.removeEventListener('resize', (terminalElement as any).resizeHandler as (this: Window, ev: UIEvent) => any);
}
if (terminalElement && (terminalElement as any).focusHandler) {
terminalElement.removeEventListener('click', (terminalElement as any).focusHandler as (this: HTMLDivElement, ev: PointerEvent) => any);
}
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
fitAddonRef.current = null;
setIsTerminalReady(false);
}
};
}, [isClient, isMobile]);
// Handle terminal input with current executionId
useEffect(() => {
if (!isTerminalReady || !xtermRef.current) {
return;
}
const terminal = xtermRef.current;
const handleData = (data: string) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const message = {
action: 'input',
executionId,
input: data
};
wsRef.current.send(JSON.stringify(message));
}
};
// Store the handler reference
inputHandlerRef.current = handleData;
terminal.onData(handleData);
return () => {
// Clear the handler reference
inputHandlerRef.current = null;
};
}, [executionId, isTerminalReady]); // Depend on terminal ready state
useEffect(() => {
// Prevent multiple connections in React Strict Mode
if (hasConnectedRef.current || isConnectingRef.current || (wsRef.current && wsRef.current.readyState === WebSocket.OPEN)) {
return;
}
// Close any existing connection first
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
isConnectingRef.current = true;
const isInitialConnection = !hasConnectedRef.current;
hasConnectedRef.current = true;
// Small delay to prevent rapid reconnection
const connectWithDelay = () => {
// Connect to WebSocket
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/script-execution`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setIsConnected(true);
isConnectingRef.current = false;
// Only auto-start on initial connection, not on reconnections
if (isInitialConnection && !isRunning) {
// Generate a new execution ID for the initial run
const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
setExecutionId(newExecutionId);
const message = {
action: 'start',
scriptPath,
executionId: newExecutionId,
mode,
server,
isUpdate,
isShell,
containerId
};
ws.send(JSON.stringify(message));
}
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data as string) as TerminalMessage;
handleMessage(message);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
ws.onclose = (_event) => {
setIsConnected(false);
setIsRunning(false);
isConnectingRef.current = false;
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
console.error('WebSocket readyState:', ws.readyState);
setIsConnected(false);
isConnectingRef.current = false;
};
};
// Add small delay to prevent rapid reconnection
const timeoutId = setTimeout(connectWithDelay, 100);
return () => {
clearTimeout(timeoutId);
isConnectingRef.current = false;
hasConnectedRef.current = false;
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) {
wsRef.current.close();
}
};
}, [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) {
// Generate a new execution ID for each script run
const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
setExecutionId(newExecutionId);
setIsStopped(false);
wsRef.current.send(JSON.stringify({
action: 'start',
scriptPath,
executionId: newExecutionId,
mode,
server,
isUpdate,
isShell,
containerId
}));
}
};
const stopScript = () => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
setIsStopped(true);
setIsRunning(false);
wsRef.current.send(JSON.stringify({
action: 'stop',
executionId
}));
}
};
const clearOutput = () => {
if (xtermRef.current) {
xtermRef.current.clear();
}
};
const sendInput = (input: string) => {
setLastInputSent(input);
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const message = {
action: 'input',
executionId,
input: input
};
wsRef.current.send(JSON.stringify(message));
// Clear the feedback after 2 seconds
setTimeout(() => setLastInputSent(null), 2000);
}
};
const handleMobileInput = (input: string) => {
sendInput(input);
setMobileInput('');
};
const handleEnterKey = () => {
sendInput('\r');
};
// Don't render on server side
if (!isClient) {
return (
<div className="bg-card rounded-lg border border-border overflow-hidden">
<div className="bg-muted px-4 py-2 flex items-center justify-between border-b border-border">
<div className="flex items-center space-x-2">
<div className="flex space-x-1">
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
</div>
<span className="text-foreground font-mono text-sm ml-2">
{scriptName}
</span>
</div>
</div>
<div className="h-96 w-full flex items-center justify-center">
<div className="text-muted-foreground">Loading terminal...</div>
</div>
</div>
);
}
return (
<div className="bg-card rounded-lg border border-border overflow-hidden">
{/* Terminal Header */}
<div className="bg-muted px-2 sm:px-4 py-2 flex items-center justify-between border-b border-border">
<div className="flex items-center space-x-2 min-w-0 flex-1">
<div className="flex space-x-1 flex-shrink-0">
<div className="w-2 h-2 sm:w-3 sm:h-3 bg-red-500 rounded-full"></div>
<div className="w-2 h-2 sm:w-3 sm:h-3 bg-yellow-500 rounded-full"></div>
<div className="w-2 h-2 sm:w-3 sm:h-3 bg-green-500 rounded-full"></div>
</div>
<span className="text-foreground font-mono text-xs sm:text-sm ml-1 sm:ml-2 truncate">
{scriptName} {mode === 'ssh' && server && `(SSH: ${server.name})`}
</span>
</div>
<div className="flex items-center space-x-1 sm:space-x-2 flex-shrink-0">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span className="text-muted-foreground text-xs hidden sm:inline">
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
</div>
{/* Terminal Output */}
<div
ref={terminalRef}
className={`h-[16rem] sm:h-[24rem] lg:h-[32rem] w-full max-w-4xl mx-auto ${isMobile ? 'mobile-terminal' : ''}`}
style={{
minHeight: '256px'
}}
/>
{/* Mobile Input Controls - Only show on mobile */}
<div className="block sm:hidden bg-muted/50 px-2 py-3 border-t border-border">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">Mobile Input</span>
{lastInputSent && (
<span className="text-xs text-green-500 bg-green-500/10 px-2 py-1 rounded">
Sent: {lastInputSent === '\r' ? 'Enter' :
lastInputSent === ' ' ? 'Space' :
lastInputSent === '\b' ? 'Backspace' :
lastInputSent === '\x1b[A' ? 'Up' :
lastInputSent === '\x1b[B' ? 'Down' :
lastInputSent === '\x1b[C' ? 'Right' :
lastInputSent === '\x1b[D' ? 'Left' :
lastInputSent}
</span>
)}
</div>
<Button
onClick={() => setShowMobileInput(!showMobileInput)}
variant="ghost"
size="sm"
className="text-xs"
>
<Keyboard className="h-4 w-4 mr-1" />
{showMobileInput ? 'Hide' : 'Show'} Input
</Button>
</div>
{showMobileInput && (
<div className="space-y-3">
{/* Navigation Buttons */}
<div className="grid grid-cols-2 gap-2">
<Button
onClick={() => sendInput('\x1b[A')}
variant="outline"
size="sm"
className="text-sm flex items-center justify-center gap-2"
disabled={!isConnected}
>
<ChevronUp className="h-4 w-4" />
Up
</Button>
<Button
onClick={() => sendInput('\x1b[B')}
variant="outline"
size="sm"
className="text-sm flex items-center justify-center gap-2"
disabled={!isConnected}
>
<ChevronDown className="h-4 w-4" />
Down
</Button>
</div>
{/* Left/Right Navigation Buttons */}
<div className="grid grid-cols-2 gap-2">
<Button
onClick={() => sendInput('\x1b[D')}
variant="outline"
size="sm"
className="text-sm flex items-center justify-center gap-2"
disabled={!isConnected}
>
<ChevronLeft className="h-4 w-4" />
Left
</Button>
<Button
onClick={() => sendInput('\x1b[C')}
variant="outline"
size="sm"
className="text-sm flex items-center justify-center gap-2"
disabled={!isConnected}
>
<ChevronRight className="h-4 w-4" />
Right
</Button>
</div>
{/* Action Buttons */}
<div className="grid grid-cols-3 gap-2">
<Button
onClick={handleEnterKey}
variant="outline"
size="sm"
className="text-sm"
disabled={!isConnected}
>
Enter
</Button>
<Button
onClick={() => sendInput(' ')}
variant="outline"
size="sm"
className="text-sm"
disabled={!isConnected}
>
Space
</Button>
<Button
onClick={() => sendInput('\b')}
variant="outline"
size="sm"
className="text-sm"
disabled={!isConnected}
>
Backspace
</Button>
</div>
{/* Custom Input */}
<div className="flex gap-2">
<input
type="text"
value={mobileInput}
onChange={(e) => setMobileInput(e.target.value)}
placeholder="Type command..."
className="flex-1 px-3 py-2 text-sm border border-border rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleMobileInput(mobileInput);
}
}}
disabled={!isConnected}
/>
<Button
onClick={() => handleMobileInput(mobileInput)}
variant="default"
size="sm"
disabled={!isConnected || !mobileInput.trim()}
className="px-3"
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
{/* Terminal Controls */}
<div className="bg-muted px-2 sm:px-4 py-2 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 border-t border-border">
<div className="flex flex-wrap gap-1 sm:gap-2">
<Button
onClick={startScript}
disabled={!isConnected || (isRunning && !isStopped)}
variant="default"
size="sm"
className={`text-xs sm:text-sm ${isConnected && (!isRunning || isStopped) ? 'bg-green-600 hover:bg-green-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}`}
>
<Play className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
<span className="hidden sm:inline">Start</span>
<span className="sm:hidden"></span>
</Button>
<Button
onClick={stopScript}
disabled={!isRunning}
variant="default"
size="sm"
className={`text-xs sm:text-sm ${isRunning ? 'bg-red-600 hover:bg-red-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}`}
>
<Square className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
<span className="hidden sm:inline">Stop</span>
<span className="sm:hidden"></span>
</Button>
<Button
onClick={clearOutput}
variant="secondary"
size="sm"
className="text-xs sm:text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
>
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
<span className="hidden sm:inline">Clear</span>
<span className="sm:hidden">🗑</span>
</Button>
</div>
<Button
onClick={onClose}
variant="secondary"
size="sm"
className="text-xs sm:text-sm bg-gray-600 text-white hover:bg-gray-700 w-full sm:w-auto"
>
<X className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
Close
</Button>
</div>
</div>
);
}