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>