Files
ProxmoxVE-Local/src/app/_components/Terminal.tsx
CanbiZ 03e31d66a7 Refactor type usage and improve data normalization
Updated several components to use explicit TypeScript types for better type safety. Normalized appriseUrls to always be an array in auto-sync settings API. Improved handling of optional server_id in BackupsTab and adjusted IP detection logic in InstalledScriptsTab. Removed unnecessary eslint-disable comments and improved code clarity in various places.
2025-11-28 12:47:09 +01:00

691 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;
isBackup?: boolean;
containerId?: string;
storage?: string;
backupStorage?: string;
}
interface TerminalMessage {
type: 'start' | 'output' | 'error' | 'end';
data: string;
timestamp: number;
}
export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, isBackup = false, containerId, storage, backupStorage }: 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,
isBackup,
containerId,
storage,
backupStorage
};
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]);
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>
);
}