Files
ProxmoxVE-Local/src/app/_components/Terminal.tsx
Michel Roegl-Brunner 6c2868f8b9 chore: replace emojis with Lucide icons (#68)
- Replace all emojis with Lucide React icons for better accessibility and consistency
- Update page header: rocket emoji → Rocket icon
- Update tab navigation: package, hard drive, folder open icons
- Update terminal controls: Play, Square, Trash2, X icons
- Update filter bar: Package, Monitor, Wrench, Server, FileText, Calendar icons
- Update version display: Check icon for up-to-date status
- Replace emoji prefixes in terminal and error messages with text labels
- Remove unused icon imports
2025-10-07 16:27:28 +02:00

379 lines
12 KiB
TypeScript

'use client';
import { useEffect, useRef, useState } from 'react';
import '@xterm/xterm/css/xterm.css';
import { Button } from './ui/button';
import { Play, Square, Trash2, X } from 'lucide-react';
interface TerminalProps {
scriptPath: string;
onClose: () => void;
mode?: 'local' | 'ssh';
server?: any;
isUpdate?: boolean;
containerId?: string;
}
interface TerminalMessage {
type: 'start' | 'output' | 'error' | 'end';
data: string;
timestamp: number;
}
export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, containerId }: TerminalProps) {
const [isConnected, setIsConnected] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const [isClient, setIsClient] = useState(false);
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<any>(null);
const fitAddonRef = useRef<any>(null);
const wsRef = useRef<WebSocket | null>(null);
const [executionId] = 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';
// Ensure we're on the client side
useEffect(() => {
setIsClient(true);
}, []);
useEffect(() => {
// Only initialize on client side
if (!isClient || !terminalRef.current || xtermRef.current) return;
// Use setTimeout to ensure DOM is fully ready
const initTerminal = async () => {
if (!terminalRef.current || 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');
const terminal = new XTerm({
theme: {
background: '#000000',
foreground: '#00ff00',
cursor: '#00ff00',
},
fontSize: 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: ' ()[]{}\'"`<>|',
});
// Add addons
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
// Open terminal
terminal.open(terminalRef.current);
// Fit after a small delay to ensure proper sizing
setTimeout(() => {
fitAddon.fit();
}, 100);
// Store references
xtermRef.current = terminal;
fitAddonRef.current = fitAddon;
// Handle terminal input
terminal.onData((data) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
action: 'input',
executionId,
input: data
}));
}
});
// Handle terminal resize
const handleResize = () => {
if (fitAddonRef.current) {
fitAddonRef.current.fit();
}
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
terminal.dispose();
};
};
// Initialize with a small delay
const timeoutId = setTimeout(() => {
void initTerminal();
}, 50);
return () => {
clearTimeout(timeoutId);
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
fitAddonRef.current = null;
}
};
}, [executionId, isClient]);
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;
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;
// Send start message immediately after connection
const message = {
action: 'start',
scriptPath,
executionId,
mode,
server,
isUpdate,
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, executionId, mode, server, isUpdate, containerId]);
const handleMessage = (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':
xtermRef.current.writeln(`${prefix}[SUCCESS] ${message.data}`);
setIsRunning(false);
break;
}
};
const startScript = () => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
action: 'start',
scriptPath,
executionId,
mode,
server,
isUpdate,
containerId
}));
}
};
const stopScript = () => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
action: 'stop',
executionId
}));
}
};
const clearOutput = () => {
if (xtermRef.current) {
xtermRef.current.clear();
}
};
// 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-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} {mode === 'ssh' && server && `(SSH: ${server.name})`}
</span>
</div>
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span className="text-muted-foreground text-xs">
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
</div>
{/* Terminal Output */}
<div
ref={terminalRef}
className="h-[32rem] w-full max-w-4xl mx-auto"
style={{ minHeight: '512px' }}
/>
{/* Terminal Controls */}
<div className="bg-muted px-4 py-2 flex items-center justify-between border-t border-border">
<div className="flex space-x-2">
<Button
onClick={startScript}
disabled={!isConnected || isRunning}
variant="default"
size="sm"
className={isConnected && !isRunning ? 'bg-green-600 hover:bg-green-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}
>
<Play className="h-4 w-4 mr-1" />
Start
</Button>
<Button
onClick={stopScript}
disabled={!isRunning}
variant="default"
size="sm"
className={isRunning ? 'bg-red-600 hover:bg-red-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}
>
<Square className="h-4 w-4 mr-1" />
Stop
</Button>
<Button
onClick={clearOutput}
variant="secondary"
size="sm"
className="bg-secondary text-secondary-foreground hover:bg-secondary/80"
>
<Trash2 className="h-4 w-4 mr-1" />
Clear
</Button>
</div>
<Button
onClick={onClose}
variant="secondary"
size="sm"
className="bg-gray-600 text-white hover:bg-gray-700"
>
<X className="h-4 w-4 mr-1" />
Close
</Button>
</div>
</div>
);
}