frontend/src/lib/terminal.ts
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
export interface TerminalOptions {
contextName: string;
namespace: string;
podName: string;
containerName: string;
}
export class PodTerminal {
private terminal: Terminal | null = null;
private fitAddon: FitAddon | null = null;
private ws: WebSocket | null = null;
private container: HTMLElement | null = null;
private options: TerminalOptions;
private isReady: boolean = false;
private resizeHandler: (() => void) | null = null;
private reconnectAttempts: number = 0;
private maxReconnectAttempts: number = 3;
private connectTimeout: NodeJS.Timeout | null = null;
private pingInterval: NodeJS.Timeout | null = null;
constructor(options: TerminalOptions) {
this.options = options;
}
/**
* Initializes the terminal and connects to the pod
* @param containerId The ID of the HTML element to mount the terminal in
* @returns Promise that resolves when terminal is initialized
*/
public async initialize(containerId: string): Promise<void> {
try {
// Get container element
this.container = document.getElementById(containerId);
if (!this.container) {
throw new Error(`Terminal container element with ID "${containerId}" not found`);
}
// Create terminal instance
this.terminal = new Terminal({
cursorBlink: true,
theme: {
background: '#1e1e1e',
foreground: '#e0e0e0',
cursor: '#ffffff',
selection: 'rgba(255, 255, 255, 0.3)',
black: '#000000',
red: '#e06c75',
green: '#98c379',
yellow: '#e5c07b',
blue: '#61afef',
magenta: '#c678dd',
cyan: '#56b6c2',
white: '#d0d0d0',
brightBlack: '#808080',
brightRed: '#f44747',
brightGreen: '#b5cea8',
brightYellow: '#dcdcaa',
brightBlue: '#569cd6',
brightMagenta: '#c586c0',
brightCyan: '#9cdcfe',
brightWhite: '#ffffff'
},
fontSize: 14,
fontFamily: 'Consolas, "Courier New", monospace',
scrollback: 1000,
convertEol: true
});
// Create fit addon
this.fitAddon = new FitAddon();
this.terminal.loadAddon(this.fitAddon);
// Open terminal in container
this.terminal.open(this.container);
// Show connecting message
this.terminal.writeln('Connecting to pod terminal...');
this.terminal.writeln('');
// Set up terminal input handler before connecting
this.terminal.onData((data) => {
// Send terminal input to the WebSocket
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(data);
}
});
// Connect to WebSocket
await this.connect();
// Fit terminal to container
this.fit();
// Focus terminal
this.terminal.focus();
// Set up resize handler
this.resizeHandler = this.fit.bind(this);
window.addEventListener('resize', this.resizeHandler);
// Mark as ready
this.isReady = true;
} catch (error) {
console.error('Error initializing terminal:', error);
if (this.terminal) {
this.terminal.writeln(`\r\nError initializing terminal: ${error}`);
this.terminal.writeln('Please check your connection and try again.');
}
throw error;
}
}
/**
* Connects to the pod terminal WebSocket
*/
private async connect(): Promise<void> {
if (!this.terminal) {
throw new Error('Terminal not initialized');
}
return new Promise<void>((resolve, reject) => {
try {
// Close any existing connection
this.closeConnection();
// Determine the correct WebSocket URL based on the environment
let wsUrl: string;
// Check if we're running in a Wails app
if (typeof window !== 'undefined' && 'runtime' in window) {
// This is a Wails app, use the backend URL directly
wsUrl = `ws://127.0.0.1:8081/api/pods/exec?` + new URLSearchParams({
contextName: this.options.contextName,
namespace: this.options.namespace,
pod: this.options.podName,
container: this.options.containerName || ''
}).toString();
} else if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
// Local development
wsUrl = `ws://${window.location.host}/api/pods/exec?` + new URLSearchParams({
contextName: this.options.contextName,
namespace: this.options.namespace,
pod: this.options.podName,
container: this.options.containerName || ''
}).toString();
} else {
// Production or other environment
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/api/pods/exec?` + new URLSearchParams({
contextName: this.options.contextName,
namespace: this.options.namespace,
pod: this.options.podName,
container: this.options.containerName || ''
}).toString();
}
console.log(`Connecting to terminal WebSocket: ${wsUrl}`);
if (this.terminal) {
this.terminal.writeln(`Connecting to: ${this.options.podName}/${this.options.containerName || 'default'}`);
}
// Set a timeout for the connection
this.connectTimeout = setTimeout(() => {
if (this.ws && this.ws.readyState !== WebSocket.OPEN) {
console.error('WebSocket connection timeout');
if (this.terminal) {
this.terminal.writeln('\r\nConnection timeout. Server might be unavailable.');
}
// Force close the connection
if (this.ws) {
this.ws.close();
}
reject(new Error('WebSocket connection timeout'));
}
}, 15000); // 15 second timeout
// Create WebSocket connection
this.ws = new WebSocket(wsUrl);
// Set up event handlers
this.ws.onopen = () => {
console.log('Terminal WebSocket connection established');
// Clear the connection timeout
if (this.connectTimeout) {
clearTimeout(this.connectTimeout);
this.connectTimeout = null;
}
this.reconnectAttempts = 0;
if (this.terminal) {
// Clear the terminal
this.terminal.clear();
// Write welcome message
this.terminal.writeln('Connected to pod terminal.');
this.terminal.writeln('');
// Enable terminal input
this.terminal.options.disableStdin = false;
// Send initial terminal size
this.fit();
// Set up ping interval to keep connection alive
this.pingInterval = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
// Send a special ping message (type 2)
const pingMessage = new Uint8Array([2]);
this.ws.send(pingMessage);
}
}, 30000); // 30 seconds
}
resolve();
};
this.ws.onmessage = (event) => {
// Handle different message types
if (event.data instanceof Blob) {
// Handle binary data
const reader = new FileReader();
reader.onload = () => {
if (this.terminal && reader.result) {
const data = new Uint8Array(reader.result as ArrayBuffer);
this.terminal.write(data);
}
};
reader.readAsArrayBuffer(event.data);
} else {
// Handle text data
if (this.terminal) {
this.terminal.write(event.data);
}
}
};
this.ws.onerror = (error) => {
console.error('Terminal WebSocket error:', error);
// Log detailed error information
console.log('WebSocket readyState:', this.ws ? this.ws.readyState : 'null');
console.log('WebSocket URL:', wsUrl);
console.log('Error event:', error);
if (this.terminal) {
this.terminal.writeln('\r\nConnection error. Please check your network and authentication.');
this.terminal.writeln(`Error details: WebSocket connection failed`);
}
// Don't reject immediately, let onclose handle it
};
this.ws.onclose = (event) => {
console.log(`Terminal WebSocket connection closed: ${event.code} ${event.reason}`);
// Clear timeouts and intervals
if (this.connectTimeout) {
clearTimeout(this.connectTimeout);
this.connectTimeout = null;
}
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
if (this.terminal) {
// Disable terminal input
this.terminal.options.disableStdin = true;
// Show connection closed message
this.terminal.writeln('\r\n');
this.terminal.writeln('Connection closed.');
if (event.code !== 1000 && event.code !== 1001) {
this.terminal.writeln(`Reason: ${event.reason || 'Server disconnected'} (Code: ${event.code})`);
this.terminal.writeln('');
// Try to auto-reconnect a few times
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
this.terminal.writeln(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
setTimeout(() => {
this.connect().catch(err => {
console.error('Reconnect failed:', err);
if (this.terminal) {
this.terminal.writeln('Reconnect failed. Press the "Refresh" button to try again.');
}
reject(err);
});
}, 2000);
} else {
this.terminal.writeln('Press the "Refresh" button to reconnect.');
reject(new Error(`WebSocket closed: ${event.code} ${event.reason}`));
}
} else {
resolve();
}
} else {
reject(new Error(`WebSocket closed: ${event.code} ${event.reason}`));
}
};
} catch (error) {
console.error('Error setting up terminal WebSocket:', error);
if (this.terminal) {
this.terminal.writeln(`\r\nError setting up connection: ${error}`);
}
reject(error);
}
});
}
/**
* Fits the terminal to its container
*/
private fit(): void {
if (this.fitAddon && this.terminal) {
try {
this.fitAddon.fit();
// Send terminal size to server
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
const cols = this.terminal.cols;
const rows = this.terminal.rows;
// Send resize message as binary data with a specific format:
// byte 1 = message type (1 for resize)
// byte 2 = rows (uint8)
// byte 3 = cols (uint8)
const resizeMessage = new Uint8Array([1, rows & 0xFF, cols & 0xFF]);
this.ws.send(resizeMessage);
}
} catch (error) {
console.error('Error resizing terminal:', error);
}
}
}
/**
* Closes the WebSocket connection
*/
private closeConnection(): void {
// Clear any existing timeouts and intervals
if (this.connectTimeout) {
clearTimeout(this.connectTimeout);
this.connectTimeout = null;
}
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
if (this.ws) {
try {
console.log(`Closing WebSocket connection, current state: ${this.ws.readyState}`);
// Only close if not already closing/closed
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
// For CONNECTING state, we need to set up onclose handler first
if (this.ws.readyState === WebSocket.CONNECTING) {
const oldOnClose = this.ws.onclose;
this.ws.onclose = (event) => {
console.log('Connection closed while in CONNECTING state');
if (oldOnClose) oldOnClose.call(this.ws, event);
};
}
this.ws.close(1000, 'User disconnected');
}
} catch (error) {
console.error('Error closing terminal WebSocket:', error);
}
this.ws = null;
}
}
/**
* Updates terminal options and reconnects
* @param options New terminal options
*/
public async updateOptions(options: Partial<TerminalOptions>): Promise<void> {
// Update options
this.options = { ...this.options, ...options };
// Reconnect with new options
if (this.isReady) {
this.reconnectAttempts = 0;
if (this.terminal) {
this.terminal.writeln('\r\nUpdating connection...');
}
await this.connect();
}
}
/**
* Refreshes the terminal connection
*/
public async refresh(): Promise<void> {
if (this.terminal) {
this.reconnectAttempts = 0;
this.terminal.writeln('\r\nRefreshing connection...');
await this.connect();
this.fit();
this.terminal.focus();
}
}
/**
* Disposes the terminal and cleans up resources
*/
public dispose(): void {
// Remove resize event listener
if (this.resizeHandler) {
window.removeEventListener('resize', this.resizeHandler);
this.resizeHandler = null;
}
// Clear timeouts and intervals
if (this.connectTimeout) {
clearTimeout(this.connectTimeout);
this.connectTimeout = null;
}
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
// Close WebSocket connection
this.closeConnection();
// Dispose terminal
if (this.terminal) {
try {
this.terminal.dispose();
} catch (error) {
console.error('Error disposing terminal:', error);
}
this.terminal = null;
}
// Reset state
this.fitAddon = null;
this.isReady = false;
this.container = null;
}
}