nexus-dashboard/components/TerminalClient.tsx
2026-02-01 18:42:22 +00:00

220 lines
6.6 KiB
TypeScript

'use client';
import { useEffect, useRef, useState } from 'react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { io, Socket } from 'socket.io-client';
import '@xterm/xterm/css/xterm.css';
interface TerminalClientProps {
vmid: string;
token: string;
}
export default function TerminalClient({ vmid, token }: TerminalClientProps) {
const terminalRef = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting');
const [error, setError] = useState<string | null>(null);
const termRef = useRef<Terminal | null>(null);
const socketRef = useRef<Socket | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
useEffect(() => {
if (!terminalRef.current) return;
// Initialize Terminal
const term = new Terminal({
theme: {
background: '#1a1b26',
foreground: '#c0caf5',
cursor: '#c0caf5',
cursorAccent: '#1a1b26',
selectionBackground: '#33467C',
black: '#15161E',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6',
brightBlack: '#414868',
brightRed: '#f7768e',
brightGreen: '#9ece6a',
brightYellow: '#e0af68',
brightBlue: '#7aa2f7',
brightMagenta: '#bb9af7',
brightCyan: '#7dcfff',
brightWhite: '#c0caf5',
},
fontSize: 14,
fontFamily: 'Menlo, Monaco, Courier New, monospace',
cursorBlink: true,
cursorStyle: 'block',
scrollback: 10000,
allowProposedApi: true,
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(terminalRef.current);
// Give the terminal time to render before fitting
setTimeout(() => {
fitAddon.fit();
console.log(`[Terminal] Terminal fitted to: ${term.cols}x${term.rows}`);
}, 50);
termRef.current = term;
fitAddonRef.current = fitAddon;
let handleResize: (() => void) | null = null;
// Wait a bit for terminal to be properly sized, then connect
setTimeout(() => {
// Connect to WebSocket server via Nginx proxy
console.log('[Terminal] Connecting to WebSocket server...');
console.log(`[Terminal] Initial terminal size: ${term.cols}x${term.rows}`);
const socket = io({
auth: { token },
query: {
vmid,
cols: term.cols,
rows: term.rows
},
transports: ['websocket', 'polling'],
path: '/socket.io/',
});
socketRef.current = socket;
socket.on('connect', () => {
console.log('[Terminal] Connected to WebSocket');
setStatus('connecting');
});
socket.on('status', (newStatus: string) => {
console.log('[Terminal] Status:', newStatus);
if (newStatus === 'connected') {
setStatus('connected');
setError(null);
// Send initial terminal size when SSH is ready
if (fitAddon) {
fitAddon.fit();
const rows = term.rows;
const cols = term.cols;
console.log(`[Terminal] Sending initial size: ${cols}x${rows}`);
socket.emit('resize', { rows, cols });
}
} else if (newStatus === 'disconnected') {
setStatus('disconnected');
}
});
socket.on('data', (data: string) => {
term.write(data);
});
socket.on('error', (err: string) => {
console.error('[Terminal] Error:', err);
setError(err);
setStatus('error');
});
socket.on('disconnect', () => {
console.log('[Terminal] Disconnected from WebSocket');
setStatus('disconnected');
});
// Terminal → WebSocket
term.onData((data) => {
if (socket.connected) {
socket.emit('data', data);
}
});
// Handle terminal resize
handleResize = () => {
if (fitAddon && socket.connected) {
fitAddon.fit();
socket.emit('resize', {
rows: term.rows,
cols: term.cols,
});
}
};
window.addEventListener('resize', handleResize);
// Fit terminal after a short delay
setTimeout(handleResize, 100);
}, 100); // Wait 100ms for terminal to render
// Cleanup
return () => {
if (handleResize) {
window.removeEventListener('resize', handleResize);
}
if (socketRef.current) {
socketRef.current.disconnect();
}
term.dispose();
};
}, [vmid, token]);
return (
<div className="fixed inset-0 z-50 bg-background flex flex-col">
{/* Header */}
<div className="bg-background-elevated px-4 py-3 flex justify-between items-center border-b border-border flex-shrink-0">
<div className="flex items-center gap-3">
<h1 className="text-foreground font-bold text-lg">VM {vmid} Console</h1>
<div className="flex items-center gap-2">
{status === 'connecting' && (
<div className="flex items-center gap-2 text-warning text-sm">
<div className="w-2 h-2 bg-warning rounded-full animate-pulse"></div>
<span>Connecting...</span>
</div>
)}
{status === 'connected' && (
<div className="flex items-center gap-2 text-success text-sm">
<div className="w-2 h-2 bg-success rounded-full"></div>
<span>Connected</span>
</div>
)}
{status === 'disconnected' && (
<div className="flex items-center gap-2 text-danger text-sm">
<div className="w-2 h-2 bg-danger rounded-full"></div>
<span>Disconnected</span>
</div>
)}
{status === 'error' && error && (
<div className="flex items-center gap-2 text-danger text-sm">
<div className="w-2 h-2 bg-danger rounded-full"></div>
<span>{error}</span>
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
<a
href="/"
className="bg-foreground hover:bg-foreground/90 text-foreground px-3 py-1 rounded text-sm transition-colors"
>
Back to Dashboard
</a>
</div>
</div>
{/* Terminal */}
<div className="flex-1 bg-[#1a1b26] overflow-hidden">
<div
ref={terminalRef}
className="w-full h-full"
/>
</div>
</div>
);
}