220 lines
6.6 KiB
TypeScript
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>
|
|
);
|
|
}
|