summary history files

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;
  }
}