Files
coolify/resources/js/terminal.js

584 lines
24 KiB
JavaScript

import { Terminal } from '@xterm/xterm';
import '@xterm/xterm/css/xterm.css';
import { FitAddon } from '@xterm/addon-fit';
export function initializeTerminalComponent() {
function terminalData() {
return {
fullscreen: false,
terminalActive: false,
message: '(connection closed)',
term: null,
fitAddon: null,
socket: null,
commandBuffer: '',
pendingWrites: 0,
paused: false,
MAX_PENDING_WRITES: 5,
keepAliveInterval: null,
reconnectInterval: null,
// Enhanced connection management
connectionState: 'disconnected', // 'connecting', 'connected', 'disconnected', 'reconnecting'
reconnectAttempts: 0,
maxReconnectAttempts: 10,
baseReconnectDelay: 1000,
maxReconnectDelay: 30000,
connectionTimeout: 10000,
connectionTimeoutId: null,
lastPingTime: null,
pingTimeout: 35000, // 5 seconds longer than ping interval
pingTimeoutId: null,
heartbeatMissed: 0,
maxHeartbeatMisses: 3,
// Resize handling
resizeObserver: null,
resizeTimeout: null,
init() {
this.setupTerminal();
// Add a small delay for initial connection to ensure everything is ready
setTimeout(() => {
this.initializeWebSocket();
}, 100);
this.setupTerminalEventListeners();
this.$wire.on('send-back-command', (command) => {
this.sendCommandWhenReady({ command: command });
});
this.$wire.on('terminal-should-focus', () => {
// Wait for terminal to be ready, then focus
const focusWhenReady = () => {
if (this.terminalActive && this.term) {
this.term.focus();
} else {
setTimeout(focusWhenReady, 100);
}
};
focusWhenReady();
});
this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000);
this.$watch('terminalActive', (active) => {
if (!active && this.keepAliveInterval) {
clearInterval(this.keepAliveInterval);
}
this.$nextTick(() => {
if (active) {
this.$refs.terminalWrapper.style.display = 'block';
this.resizeTerminal();
// Start observing terminal wrapper for resize changes
if (this.resizeObserver && this.$refs.terminalWrapper) {
this.resizeObserver.observe(this.$refs.terminalWrapper);
}
} else {
this.$refs.terminalWrapper.style.display = 'none';
// Stop observing when terminal is inactive
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
}
});
});
['livewire:navigated', 'beforeunload'].forEach((event) => {
document.addEventListener(event, () => {
this.cleanup();
}, { once: true });
});
window.onresize = () => {
this.resizeTerminal()
};
// Set up ResizeObserver for more reliable terminal resizing
if (window.ResizeObserver) {
this.resizeObserver = new ResizeObserver(() => {
// Debounce resize calls to avoid performance issues
clearTimeout(this.resizeTimeout);
this.resizeTimeout = setTimeout(() => {
this.resizeTerminal();
}, 50);
});
}
},
cleanup() {
this.checkIfProcessIsRunningAndKillIt();
this.clearAllTimers();
this.connectionState = 'disconnected';
if (this.socket) {
this.socket.close(1000, 'Client cleanup');
}
// Clean up resize observer
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
// Clear resize timeout
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout);
}
},
clearAllTimers() {
[this.keepAliveInterval, this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout]
.forEach(timer => timer && clearInterval(timer));
this.keepAliveInterval = null;
this.reconnectInterval = null;
this.connectionTimeoutId = null;
this.pingTimeoutId = null;
this.resizeTimeout = null;
},
resetTerminal() {
if (this.term) {
this.$wire.dispatch('error', 'Terminal websocket connection lost.');
this.term.reset();
this.term.clear();
this.pendingWrites = 0;
this.paused = false;
this.commandBuffer = '';
// Notify parent component that terminal disconnected
this.$wire.dispatch('terminalDisconnected');
// Force a refresh
this.$nextTick(() => {
this.resizeTerminal();
this.term.focus();
});
}
},
setupTerminal() {
const terminalElement = document.getElementById('terminal');
if (terminalElement) {
this.term = new Terminal({
cols: 80,
rows: 30,
fontFamily: '"Fira Code", courier-new, courier, monospace, "Powerline Extra Symbols"',
cursorBlink: true,
rendererType: 'canvas',
convertEol: true,
disableStdin: false
});
this.fitAddon = new FitAddon();
this.term.loadAddon(this.fitAddon);
this.$nextTick(() => {
this.resizeTerminal();
});
}
},
initializeWebSocket() {
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) {
console.log('[Terminal] WebSocket already connecting/connected, skipping');
return; // Already connecting or connected
}
this.connectionState = 'connecting';
this.clearAllTimers();
// Ensure terminal config is available
if (!window.terminalConfig) {
console.warn('[Terminal] Terminal config not available, using defaults');
window.terminalConfig = {};
}
const predefined = window.terminalConfig
const connectionString = {
protocol: window.location.protocol === 'https:' ? 'wss' : 'ws',
host: window.location.hostname,
port: ":6002",
path: '/terminal/ws'
}
if (!window.location.port) {
connectionString.port = ''
}
if (predefined.host) {
connectionString.host = predefined.host
}
if (predefined.port) {
connectionString.port = `:${predefined.port}`
}
if (predefined.protocol) {
connectionString.protocol = predefined.protocol
}
const url = `${connectionString.protocol}://${connectionString.host}${connectionString.port}${connectionString.path}`
console.log(`[Terminal] Attempting connection to: ${url}`);
try {
this.socket = new WebSocket(url);
// Set connection timeout - increased for initial connection
const timeoutMs = this.reconnectAttempts === 0 ? 15000 : this.connectionTimeout;
this.connectionTimeoutId = setTimeout(() => {
if (this.connectionState === 'connecting') {
console.error(`[Terminal] Connection timeout after ${timeoutMs}ms`);
this.socket.close();
this.handleConnectionError('Connection timeout');
}
}, timeoutMs);
this.socket.onopen = this.handleSocketOpen.bind(this);
this.socket.onmessage = this.handleSocketMessage.bind(this);
this.socket.onerror = this.handleSocketError.bind(this);
this.socket.onclose = this.handleSocketClose.bind(this);
} catch (error) {
console.error('[Terminal] Failed to create WebSocket:', error);
this.handleConnectionError(`Failed to create WebSocket connection: ${error.message}`);
}
},
handleSocketOpen() {
console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.');
this.connectionState = 'connected';
this.reconnectAttempts = 0;
this.heartbeatMissed = 0;
this.lastPingTime = Date.now();
// Clear connection timeout
if (this.connectionTimeoutId) {
clearTimeout(this.connectionTimeoutId);
this.connectionTimeoutId = null;
}
// Start ping timeout monitoring
this.resetPingTimeout();
// Notify that WebSocket is ready for auto-connection
this.dispatchEvent('terminal-websocket-ready');
},
handleSocketError(error) {
console.error('[Terminal] WebSocket error:', error);
console.error('[Terminal] WebSocket state:', this.socket ? this.socket.readyState : 'No socket');
console.error('[Terminal] Connection attempt:', this.reconnectAttempts + 1);
this.handleConnectionError('WebSocket error occurred');
},
handleSocketClose(event) {
console.warn(`[Terminal] WebSocket connection closed. Code: ${event.code}, Reason: ${event.reason || 'No reason provided'}`);
console.log('[Terminal] Was clean close:', event.code === 1000);
console.log('[Terminal] Connection attempt:', this.reconnectAttempts + 1);
this.connectionState = 'disconnected';
this.clearAllTimers();
// Only reset terminal and reconnect if it wasn't a clean close
if (event.code !== 1000) {
// Don't show terminal reset message on first connection attempt
if (this.reconnectAttempts > 0) {
this.resetTerminal();
this.message = '(connection closed)';
this.terminalActive = false;
}
this.scheduleReconnect();
}
},
handleConnectionError(reason) {
console.error(`[Terminal] Connection error: ${reason} (attempt ${this.reconnectAttempts + 1})`);
this.connectionState = 'disconnected';
// Only dispatch error to UI after a few failed attempts to avoid immediate error on page load
if (this.reconnectAttempts >= 2) {
this.$wire.dispatch('error', `Terminal connection error: ${reason}`);
}
this.scheduleReconnect();
},
scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('[Terminal] Max reconnection attempts reached');
this.message = '(connection failed - max retries exceeded)';
return;
}
this.connectionState = 'reconnecting';
// Exponential backoff with jitter
const delay = Math.min(
this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts) + Math.random() * 1000,
this.maxReconnectDelay
);
console.warn(`[Terminal] Scheduling reconnect attempt ${this.reconnectAttempts + 1} in ${delay}ms`);
this.reconnectInterval = setTimeout(() => {
this.reconnectAttempts++;
this.initializeWebSocket();
}, delay);
},
sendMessage(message) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message));
} else {
console.warn('[Terminal] WebSocket not ready, message not sent:', message);
}
},
sendCommandWhenReady(message) {
if (this.isWebSocketReady()) {
this.sendMessage(message);
}
},
handleSocketMessage(event) {
// Handle pong responses
if (event.data === 'pong') {
this.heartbeatMissed = 0;
this.lastPingTime = Date.now();
this.resetPingTimeout();
return;
}
if (event.data === 'pty-ready') {
if (!this.term._initialized) {
this.term.open(document.getElementById('terminal'));
this.term._initialized = true;
} else {
this.term.reset();
}
this.terminalActive = true;
this.term.focus();
document.querySelector('.xterm-viewport').classList.add('scrollbar', 'rounded-sm');
// Initial resize after terminal is ready
this.resizeTerminal();
// Additional resize after a short delay to ensure proper sizing
setTimeout(() => {
this.resizeTerminal();
}, 200);
// Ensure terminal gets focus after connection with multiple attempts
setTimeout(() => {
this.term.focus();
}, 100);
setTimeout(() => {
this.term.focus();
}, 500);
// Notify parent component that terminal is connected
this.$wire.dispatch('terminalConnected');
} else if (event.data === 'unprocessable') {
if (this.term) this.term.reset();
this.terminalActive = false;
this.message = '(sorry, something went wrong, please try again)';
// Notify parent component that terminal connection failed
this.$wire.dispatch('terminalDisconnected');
} else if (event.data === 'pty-exited') {
this.terminalActive = false;
this.term.reset();
this.commandBuffer = '';
// Notify parent component that terminal disconnected
this.$wire.dispatch('terminalDisconnected');
} else {
try {
this.pendingWrites++;
this.term.write(event.data, (err) => {
if (err) {
console.error('[Terminal] Write error:', err);
}
this.flowControlCallback();
});
} catch (error) {
console.error('[Terminal] Write operation failed:', error);
this.pendingWrites = Math.max(0, this.pendingWrites - 1);
}
}
},
flowControlCallback() {
this.pendingWrites = Math.max(0, this.pendingWrites - 1);
if (this.pendingWrites > this.MAX_PENDING_WRITES && !this.paused) {
this.paused = true;
this.sendMessage({ pause: true });
return;
}
if (this.pendingWrites <= Math.floor(this.MAX_PENDING_WRITES / 2) && this.paused) {
this.paused = false;
this.sendMessage({ resume: true });
return;
}
},
setupTerminalEventListeners() {
if (!this.term) return;
this.term.onData((data) => {
this.sendMessage({ message: data });
if (data === '\r') {
this.commandBuffer = '';
} else {
this.commandBuffer += data;
}
});
// Copy and paste functionality
this.term.attachCustomKeyEventHandler((arg) => {
if (arg.ctrlKey && arg.code === "KeyV" && arg.type === "keydown") {
return false;
}
if (arg.ctrlKey && arg.code === "KeyC" && arg.type === "keydown") {
const selection = this.term.getSelection();
if (selection) {
navigator.clipboard.writeText(selection);
return false;
}
}
return true;
});
},
keepAlive() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.sendMessage({ ping: true });
} else if (this.connectionState === 'disconnected') {
// Attempt to reconnect if we're disconnected
this.initializeWebSocket();
}
},
resetPingTimeout() {
if (this.pingTimeoutId) {
clearTimeout(this.pingTimeoutId);
}
this.pingTimeoutId = setTimeout(() => {
this.heartbeatMissed++;
console.warn(`[Terminal] Ping timeout - missed ${this.heartbeatMissed}/${this.maxHeartbeatMisses}`);
if (this.heartbeatMissed >= this.maxHeartbeatMisses) {
console.error('[Terminal] Too many missed heartbeats, closing connection');
this.socket.close(1001, 'Heartbeat timeout');
}
}, this.pingTimeout);
},
checkIfProcessIsRunningAndKillIt() {
this.sendMessage({ checkActive: 'force' });
},
makeFullscreen() {
this.fullscreen = !this.fullscreen;
this.$nextTick(() => {
// Force a layout reflow to ensure DOM changes are applied
this.$refs.terminalWrapper.offsetHeight;
// Add a small delay to ensure CSS transitions complete
setTimeout(() => {
this.resizeTerminal();
}, 100);
});
},
resizeTerminal() {
if (!this.terminalActive || !this.term || !this.fitAddon) return;
try {
// Force a refresh of the fit addon dimensions
this.fitAddon.fit();
// Get fresh dimensions after fit
const wrapperHeight = this.$refs.terminalWrapper.clientHeight;
const wrapperWidth = this.$refs.terminalWrapper.clientWidth;
// Account for terminal container padding (px-2 py-1 = 8px left/right, 4px top/bottom)
const horizontalPadding = 16; // 8px * 2 (left + right)
const verticalPadding = 8; // 4px * 2 (top + bottom)
const height = wrapperHeight - verticalPadding;
const width = wrapperWidth - horizontalPadding;
// Check if dimensions are valid
if (height <= 0 || width <= 0) {
console.warn('[Terminal] Invalid wrapper dimensions, retrying...', { height, width });
setTimeout(() => this.resizeTerminal(), 100);
return;
}
const charSize = this.term._core._renderService._charSizeService;
if (!charSize.height || !charSize.width) {
// Fallback values if char size not available yet
console.warn('[Terminal] Character size not available, retrying...');
setTimeout(() => this.resizeTerminal(), 100);
return;
}
// Calculate new dimensions with padding considerations
const rows = Math.floor(height / charSize.height) - 1;
const cols = Math.floor(width / charSize.width) - 1;
if (rows > 0 && cols > 0) {
// Check if dimensions actually changed to avoid unnecessary resizes
const currentCols = this.term.cols;
const currentRows = this.term.rows;
if (cols !== currentCols || rows !== currentRows) {
this.term.resize(cols, rows);
this.sendMessage({
resize: { cols: cols, rows: rows }
});
}
} else {
console.warn('[Terminal] Invalid calculated dimensions:', { rows, cols, height, width, charSize });
}
} catch (error) {
console.error('[Terminal] Resize error:', error);
}
},
// Utility method to get connection status for debugging
getConnectionStatus() {
return {
state: this.connectionState,
readyState: this.socket ? this.socket.readyState : 'No socket',
reconnectAttempts: this.reconnectAttempts,
pendingWrites: this.pendingWrites,
paused: this.paused,
lastPingTime: this.lastPingTime,
heartbeatMissed: this.heartbeatMissed
};
},
// Helper method to dispatch custom events
dispatchEvent(eventName, detail = null) {
const event = new CustomEvent(eventName, {
detail: detail,
bubbles: true
});
this.$el.dispatchEvent(event);
},
// Check if WebSocket is ready for commands
isWebSocketReady() {
return this.connectionState === 'connected' &&
this.socket &&
this.socket.readyState === WebSocket.OPEN;
}
};
}
window.Alpine.data('terminalData', terminalData);
}