summary history files

frontend/src/components/BottomTabs.vue
<script lang="ts">
import { defineComponent, PropType, ref, watch, nextTick } from 'vue';
import { KubernetesCluster } from '../types/kubernetes';
import { formatResourceDescription } from '../lib/lib';
import { PodTerminal, TerminalOptions } from '../lib/terminal';
import { logsService } from '../lib/logs';
import { type KubernetesResourceKind, getResourceKind, isPod, isDeployment, isStatefulSet, isConfigMap } from '../lib/resources';
import { kubernetesService } from '../lib/kubernetes';

export default defineComponent({
  name: 'BottomTabs',

  props: {
    selectedResource: {
      type: Object as PropType<any>,
      required: true
    },
    selectedCluster: {
      type: Object as PropType<KubernetesCluster | null>,
      required: true
    },
    selectedTab: {
      type: String as PropType<'describe' | 'logs' | 'terminal'>,
      required: true
    }
  },

  emits: ['tab-changed'],

  data() {
    return {
      logs: '',
      description: '',
      loading: false,
      error: '',
      isPod: false,
      isDeployment: false,
      isStatefulSet: false,
      isConfigMap: false,
      tailLines: 100,
      selectedContainer: '',
      containers: [] as string[],
      panelHeight: 300, // Default height in pixels
      isResizing: false,
      startY: 0,
      startHeight: 0,
      // Terminal-related properties
      podTerminal: null as PodTerminal | null,
      terminalError: '',
      // Reference to the logs container
      logsContainerRef: null as HTMLElement | null,
      resourceKind: null as KubernetesResourceKind,
    };
  },

  watch: {
    selectedResource: {
      immediate: true,
      handler(newResource, oldResource) {
        // Only reload if the resource actually changed
        if (JSON.stringify(newResource?.metadata?.uid) !== JSON.stringify(oldResource?.metadata?.uid)) {
          this.getResourceKind();
          this.determineResourceType();
          this.extractContainers();

          // If we're on the logs tab, we need to reconnect
          if (this.selectedTab === 'logs') {
            this.reconnectLogsWebSocket();
          } else if (this.selectedTab === 'describe') {
            this.generateDescription();
          } else if (this.selectedTab === 'terminal') {
            // If we're on the terminal tab but the new resource isn't a pod,
            // switch to the describe tab
            if (!this.isPod) {
              this.$emit('tab-changed', 'describe');
            } else {
              // Otherwise, initialize the terminal for the new pod
              this.initTerminal();
            }
          }
        }
      }
    },
    selectedTab: {
      handler(newTab, oldTab) {
        if (newTab === 'logs' && oldTab !== 'logs') {
          this.reconnectLogsWebSocket();
        } else if (newTab !== 'logs' && oldTab === 'logs') {
          this.closeLogsWebSocket();
          // If switching to describe tab, regenerate the description
          if (newTab === 'describe') {
            this.generateDescription();
          }
        } else if (newTab === 'describe' && oldTab !== 'describe') {
          // Always regenerate description when switching to describe tab
          this.generateDescription();
        } else if (newTab === 'terminal' && oldTab !== 'terminal') {
          // Initialize terminal when switching to terminal tab
          this.$nextTick(() => {
            this.initTerminal();
          });
        } else if (newTab !== 'terminal' && oldTab === 'terminal') {
          // Dispose terminal when switching away from terminal tab
          this.disposePodTerminal();
        }
      }
    },
    selectedContainer() {
      if (this.selectedTab === 'logs') {
        this.reconnectLogsWebSocket();
      } else if (this.selectedTab === 'terminal' && this.podTerminal) {
        // If the terminal tab is active and container changes, refresh the terminal
        this.refreshTerminal();
      }
    },
    // Watch logs changes to scroll to bottom
    logs() {
      if (this.selectedTab === 'logs') {
        this.scrollToBottom();
      }
    }
  },

  created() {
    this.getResourceKind();
    this.determineResourceType();
    this.extractContainers();
  },

  mounted() {
    // Add event listeners for mouse events to handle resizing
    document.addEventListener('mousemove', this.onMouseMove);
    document.addEventListener('mouseup', this.onMouseUp);

    // Try to load saved height from localStorage
    const savedHeight = localStorage.getItem('bottomTabsHeight');
    if (savedHeight) {
      this.panelHeight = parseInt(savedHeight, 10);
    }
  },

  beforeUnmount() {
    this.closeLogsWebSocket();
    this.disposePodTerminal();

    // Remove event listeners
    document.removeEventListener('mousemove', this.onMouseMove);
    document.removeEventListener('mouseup', this.onMouseUp);
  },

  methods: {
    displayTerminal(): boolean {
      if (!this.isPod) {
        return false;
      }
      return true;
    },
    displayLogs(): boolean {
      if (this.isDeployment) {
        return true;
      } else if (this.isPod) {
        return true;
      } else if (this.isStatefulSet) {
        return true;
      } else {
        return false;
      }
    },
    getResourceKind(): void {
      if (!this.selectedResource) {
        this.resourceKind = null;
        return;
      }
      this.resourceKind = getResourceKind(this.selectedResource);
    },
    determineResourceType() {
      // Determine if the selected resource is a pod or deployment
      if (!this.selectedResource) {
        this.isPod = false;
        this.isDeployment = false;
        this.isStatefulSet = false;
        this.isConfigMap = false;
        return;
      }

      this.isPod = isPod(this.selectedResource);
      this.isDeployment = isDeployment(this.selectedResource);
      this.isStatefulSet = isStatefulSet(this.selectedResource);
      this.isConfigMap = isConfigMap(this.selectedResource);
    },

    extractContainers() {
      // Use the logs service to extract containers
      this.containers = logsService.extractContainers(this.selectedResource);

      // Only set selectedContainer if containers exist and it's not already set to a valid container
      if (this.containers.length > 0) {
        if (!this.containers.includes(this.selectedContainer)) {
          this.selectedContainer = this.containers[0];
        }
      } else {
        this.selectedContainer = '';
      }
    },

    async reconnectLogsWebSocket() {
      if (!this.selectedResource || !this.selectedCluster) {
        this.logs = 'No resource selected';
        return;
      }

      // Create options object for logs service
      const options = {
        selectedResource: this.selectedResource,
        selectedCluster: this.selectedCluster,
        selectedContainer: this.selectedContainer,
        tailLines: this.tailLines
      };

      // Use the logs service to reconnect with callbacks
      await logsService.reconnectLogsWebSocket(options, {
        onMessage: (newLogs) => {
          // Append new log lines
          this.logs += newLogs;
        },
        onError: (error) => {
          // Only show error if we're still on the logs tab
          if (this.selectedTab === 'logs') {
            this.error = error;
          }
        },
        onOpen: () => {
          this.logs = '';
          this.error = '';
        }
      });
    },

    closeLogsWebSocket() {
      // Use the logs service to close the WebSocket
      return logsService.closeLogsWebSocket();
    },

    async generateDescription(): void {
      if (!this.selectedResource) {
        this.description = 'No resource selected';
        return;
      }

      if (!this.selectedCluster) {
        this.description = 'No cluster selected';
        return;
      }

      this.loading = true;
      this.error = '';


      const context = this.selectedCluster.contextName;
      const name = this.selectedResource.metadata.name;

      let namespace: string | undefined;
      try {
        namespace = this.selectedResource.metadata.namespace;
        if (!namespace) {
          namespace = this.selectedResource.metadata.name;
        }
        if (!namespace) {
          throw new Error("Unable to get namespace for this resource");
        }
      } catch (error) {
        this.error = error;
        this.loading = false;
        return;
      }

      try {
        let response;
        switch (this.resourceKind) {
          case 'Pod':
            response = await kubernetesService.describePod(context, namespace, name);
            break;
          case 'ConfigMap':
            response = await kubernetesService.describeConfigMap(context, namespace, name);
            break;
          case 'Deployment':
            response = await kubernetesService.describeDeployment(context, namespace, name);
            break;
          case 'StatefulSet':
            response = await kubernetesService.describeStatefulSet(context, namespace, name);
            break;
          case 'Ingress':
            response = await kubernetesService.describeIngress(context, namespace, name);
            break;
          case 'Namespace':
            response = await kubernetesService.describeNamespace(context, namespace);
            break;
          case 'Secret':
            response = await kubernetesService.describeSecret(context, namespace, name);
            break;
          case 'PersistentVolumeClaim':
            response = await kubernetesService.describePersistentVolumeClaim(context, namespace, name);
            break;
          case 'PersistentVolume':
            response = await kubernetesService.describePersistentVolume(context, namespace, name);
            break;
          default:
            throw new Error(this.resourceKind + " is an unsupported resource type");
        }

        if (response.success && response.data) {
          this.description = response.data;
        } else {
          throw new Error(response.msg || "failed to get description");
        }
      } catch (error) {
        console.error('Error generating description:', error);
        this.error = `Error generating description: ${error}`;
        return;
      } finally {
        this.loading = false;
      }
    },

    changeTab(tab: 'describe' | 'logs' | 'terminal') {
      // Clear any previous errors when changing tabs
      this.error = '';

      // If switching to describe tab, make sure we regenerate the description
      if (tab === 'describe' && this.selectedTab !== 'describe') {
        this.$nextTick(() => {
          this.generateDescription();
        });
      }

      this.$emit('tab-changed', tab);
    },

    updateTailLines(event: Event) {
      const target = event.target as HTMLSelectElement;
      this.tailLines = parseInt(target.value, 10);

      if (this.selectedTab === 'logs') {
        this.reconnectLogsWebSocket();
      }
    },

    clearLogs() {
      this.logs = '';
      logsService.clearLogs();
    },

    // Scroll logs to bottom - using a direct DOM approach
    scrollToBottom() {
      // Use requestAnimationFrame to ensure we're scrolling after the browser has rendered
      requestAnimationFrame(() => {
        const logsContainer = document.querySelector('.logs-container');
        if (logsContainer) {
          logsContainer.scrollTop = logsContainer.scrollHeight;

          // Double-check with a timeout to ensure it really scrolled
          setTimeout(() => {
            if (logsContainer) {
              logsContainer.scrollTop = logsContainer.scrollHeight;
            }
          }, 100);
        }
      });
    },

    // Resizing methods
    startResize(event: MouseEvent) {
      this.isResizing = true;
      this.startY = event.clientY;
      this.startHeight = this.panelHeight;

      // Prevent text selection during resize
      event.preventDefault();
    },

    onMouseMove(event: MouseEvent) {
      if (!this.isResizing) return;

      // Calculate new height (resize from top)
      const deltaY = this.startY - event.clientY;
      const newHeight = Math.max(100, this.startHeight + deltaY); // Minimum height of 100px

      this.panelHeight = newHeight;
    },

    onMouseUp() {
      if (this.isResizing) {
        this.isResizing = false;

        // Save the height to localStorage
        localStorage.setItem('bottomTabsHeight', this.panelHeight.toString());
      }
    },

    // Terminal methods
    initTerminal() {
      if (!this.isPod || !this.selectedResource || !this.selectedCluster) {
        this.terminalError = 'Terminal is only available for pods';
        return;
      }

      this.terminalError = '';

      // Dispose any existing terminal
      this.disposePodTerminal();

      try {
        // Create terminal options
        const options: TerminalOptions = {
          contextName: this.selectedCluster.contextName,
          namespace: this.selectedResource.metadata.namespace,
          podName: this.selectedResource.metadata.name,
          containerName: this.selectedContainer || ''
        };

        // Create new terminal instance
        this.podTerminal = new PodTerminal(options);

        // Initialize terminal
        this.$nextTick(async () => {
          try {
            await this.podTerminal?.initialize('terminal-container');
          } catch (error) {
            console.error('Error initializing pod terminal:', error);
            this.terminalError = `Error initializing terminal: ${error}`;
            this.disposePodTerminal();
          }
        });
      } catch (error) {
        console.error('Error setting up pod terminal:', error);
        this.terminalError = `Error setting up terminal: ${error}`;
      }
    },

    disposePodTerminal() {
      if (this.podTerminal) {
        this.podTerminal.dispose();
        this.podTerminal = null;
      }
    },

    refreshTerminal() {
      if (this.podTerminal) {
        this.terminalError = '';

        try {
          // Update options if container changed
          this.podTerminal.updateOptions({
            containerName: this.selectedContainer || ''
          });

          // Refresh the terminal
          this.podTerminal.refresh();
        } catch (error) {
          console.error('Error refreshing terminal:', error);
          this.terminalError = `Error refreshing terminal: ${error}`;
        }
      } else {
        // Initialize a new terminal if none exists
        this.initTerminal();
      }
    }
  }
});
</script>

<template>
  <div class="bottom-tabs" :style="{ height: `${panelHeight}px` }">
    <div class="resize-handle" @mousedown="startResize"></div>

    <div class="tabs-header">
      <div class="tabs">
        <button 
          :class="{ active: selectedTab === 'describe' }" 
          @click="changeTab('describe')"
        >
          Describe
        </button>
        <button 
          v-if="displayLogs()"
          :class="{ active: selectedTab === 'logs' }" 
          @click="changeTab('logs')"
        >
          Logs
        </button>
        <button 
          v-if="displayTerminal()"
          :class="{ active: selectedTab === 'terminal' }" 
          @click="changeTab('terminal')"
        >
          Terminal
        </button>
      </div>

      <!-- Logs tab actions -->
      <div class="tabs-actions" v-if="selectedTab === 'logs'">
        <select 
          v-if="containers.length > 1"
          v-model="selectedContainer"
          title="Select container"
        >
          <option v-for="container in containers" :key="container" :value="container">
            {{ container }}
          </option>
        </select>

        <select 
          v-model="tailLines" 
          @change="updateTailLines"
          title="Number of lines to show"
        >
          <option value="10">10 lines</option>
          <option value="50">50 lines</option>
          <option value="100">100 lines</option>
          <option value="500">500 lines</option>
          <option value="1000">1000 lines</option>
        </select>

        <button 
          @click="clearLogs"
          title="Clear logs"
        >
          Clear
        </button>
      </div>

      <!-- Terminal tab actions - positioned in the same place as logs actions -->
      <div class="tabs-actions" v-if="selectedTab === 'terminal'">
        <select 
          v-if="containers.length > 1"
          v-model="selectedContainer"
          @change="refreshTerminal"
          title="Select container"
        >
          <option v-for="container in containers" :key="container" :value="container">
            {{ container }}
          </option>
        </select>

        <button 
          @click="refreshTerminal"
          title="Refresh terminal"
        >
          Refresh
        </button>
      </div>
    </div>

    <div class="tab-content">
      <div v-if="loading" class="loading">
        Loading...
      </div>

      <div v-else-if="error" class="error">
        {{ error }}
      </div>

      <div v-else-if="selectedTab === 'describe'" class="describe-container">
        <pre class="left-aligned">{{ description }}</pre>
      </div>

      <div 
        v-else-if="selectedTab === 'logs'" 
        class="logs-container"
      >
        <pre class="left-aligned terminal-style">{{ logs }}</pre>
      </div>

      <div v-else-if="selectedTab === 'terminal'" class="terminal-container">
        <div v-if="!isPod" class="error">
          Terminal is only available for pods
        </div>
        <div v-else-if="terminalError" class="error">
          {{ terminalError }}
        </div>
        <div v-else id="terminal-container" class="left-aligned terminal-style"></div>
      </div>
    </div>
  </div>
</template>

<style src="@/assets/css/BottomTabs.css" scoped></style>