@kinvolk/headlamp-plugin
Version:
The needed infrastructure for building Headlamp plugins.
337 lines (336 loc) • 16.6 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
/*
* Copyright 2025 The Kubernetes Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Box from '@mui/material/Box';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import { styled } from '@mui/material/styles';
import Switch from '@mui/material/Switch';
import { useSnackbar } from 'notistack';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { request } from '../../../lib/k8s/apiProxy';
import DaemonSet from '../../../lib/k8s/daemonSet';
import Deployment from '../../../lib/k8s/deployment';
import Pod from '../../../lib/k8s/pod';
import ReplicaSet from '../../../lib/k8s/replicaSet';
import ActionButton from '../ActionButton';
import { LogViewer } from '../LogViewer';
import { LightTooltip } from '../Tooltip';
// Styled component for consistent padding in form controls
const PaddedFormControlLabel = styled(FormControlLabel)(({ theme }) => ({
margin: 0,
paddingTop: theme.spacing(2),
paddingRight: theme.spacing(2),
}));
export function LogsButton({ item }) {
const [showLogs, setShowLogs] = useState(false);
const [pods, setPods] = useState([]);
const [selectedPodIndex, setSelectedPodIndex] = useState('all');
const [selectedContainer, setSelectedContainer] = useState('');
const [logs, setLogs] = useState({
logs: [],
lastLineShown: -1,
});
const [allPodLogs, setAllPodLogs] = useState({});
const [showTimestamps, setShowTimestamps] = useState(true);
const [follow, setFollow] = useState(true);
const [lines, setLines] = useState(100);
const [showPrevious, setShowPrevious] = React.useState(false);
const [showReconnectButton, setShowReconnectButton] = useState(false);
const xtermRef = React.useRef(null);
const { t } = useTranslation(['glossary', 'translation']);
const { enqueueSnackbar } = useSnackbar();
const clearLogs = React.useCallback(() => {
if (xtermRef.current) {
xtermRef.current.clear();
}
setLogs({ logs: [], lastLineShown: -1 });
}, []);
// Fetch related pods.
async function getRelatedPods() {
if (item instanceof Deployment || item instanceof ReplicaSet || item instanceof DaemonSet) {
try {
let labelSelector = '';
const selector = item.spec.selector;
if (selector.matchLabels) {
labelSelector = Object.entries(selector.matchLabels)
.map(([key, value]) => `${key}=${value}`)
.join(',');
}
if (!labelSelector) {
const resourceType = item instanceof Deployment
? 'deployment'
: item instanceof ReplicaSet
? 'replicaset'
: 'daemonset';
throw new Error(t('translation|No label selectors found for this {{type}}', { type: resourceType }));
}
const response = await request(`/api/v1/namespaces/${item.metadata.namespace}/pods?labelSelector=${labelSelector}`, { method: 'GET' });
if (!response?.items) {
throw new Error(t('translation|Invalid response from server'));
}
return response.items.map((podData) => new Pod(podData));
}
catch (error) {
console.error('Error in getRelatedPods:', error);
throw new Error(error instanceof Error ? error.message : t('translation|Failed to fetch related pods'));
}
}
return [];
}
// Event handlers for log viewing options
function handleLinesChange(event) {
setLines(event.target.value);
}
function handleTimestampsChange() {
setShowTimestamps(prev => !prev);
}
function handleFollowChange() {
setFollow(prev => !prev);
}
function handlePreviousChange() {
setShowPrevious(previous => !previous);
}
// Handler for initial logs button click
async function handleClick() {
if (item instanceof Deployment || item instanceof ReplicaSet || item instanceof DaemonSet) {
try {
const fetchedPods = await getRelatedPods();
if (fetchedPods.length > 0) {
setPods(fetchedPods);
setSelectedPodIndex('all');
setSelectedContainer(fetchedPods[0].spec.containers[0].name);
setShowLogs(true);
}
else {
enqueueSnackbar(t('translation|No pods found for this workload'), {
variant: 'warning',
autoHideDuration: 3000,
});
}
}
catch (error) {
console.error('Error fetching pods:', error);
enqueueSnackbar(t('translation|Failed to fetch pods: {{error}}', {
error: error instanceof Error ? error.message : t('translation|Unknown error'),
}), {
variant: 'error',
autoHideDuration: 5000,
});
}
}
}
// Handler for closing the logs viewer
function handleClose() {
setShowLogs(false);
setPods([]);
setSelectedPodIndex('all');
setSelectedContainer('');
setLogs({ logs: [], lastLineShown: -1 });
}
// Get containers for the selected pod
const containers = React.useMemo(() => {
if (!pods.length)
return [];
if (selectedPodIndex === 'all')
return pods[0]?.spec?.containers?.map(container => container.name) || [];
const selectedPod = pods[selectedPodIndex];
return selectedPod?.spec?.containers?.map(container => container.name) || [];
}, [pods, selectedPodIndex]);
// Check if a container has been restarted
function hasContainerRestarted(podName, containerName) {
if (!podName)
return false;
const pod = pods.find(p => p.getName() === podName);
const cont = pod?.status?.containerStatuses?.find((c) => c.name === containerName);
if (!cont) {
return false;
}
return cont.restartCount > 0;
}
// Handler for reconnecting to logs stream
function handleReconnect() {
if (pods.length && selectedContainer) {
setShowReconnectButton(false);
setLogs({ logs: [], lastLineShown: -1 });
}
}
// Function to process and display all logs
const processAllLogs = React.useCallback(() => {
const allLogs = [];
Object.entries(allPodLogs).forEach(([podName, podLogs]) => {
podLogs.forEach(log => {
allLogs.push(`[${podName}] ${log}`);
});
});
// Sort logs by timestamp
allLogs.sort((a, b) => {
const timestampA = a.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)?.[0] || '';
const timestampB = b.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)?.[0] || '';
return timestampA.localeCompare(timestampB);
});
if (xtermRef.current) {
xtermRef.current.clear();
xtermRef.current.write(allLogs.join('').replaceAll('\n', '\r\n'));
}
setLogs({
logs: allLogs,
lastLineShown: allLogs.length - 1,
});
}, [allPodLogs]);
// Function to fetch and aggregate logs from all pods
function fetchAllPodsLogs(pods, container) {
clearLogs();
setAllPodLogs({});
const cleanups = [];
pods.forEach(pod => {
const cleanup = pod.getLogs(container, (newLogs) => {
const podName = pod.getName();
setAllPodLogs(current => {
const updated = {
...current,
[podName]: newLogs,
};
return updated;
});
}, {
tailLines: lines,
showPrevious,
showTimestamps,
follow,
onReconnectStop: () => {
setShowReconnectButton(true);
},
});
cleanups.push(cleanup);
});
return () => cleanups.forEach(cleanup => cleanup());
}
// Effect for fetching and updating logs
React.useEffect(() => {
let cleanup = null;
let isSubscribed = true;
if (showLogs && selectedContainer) {
clearLogs();
setAllPodLogs({}); // Clear aggregated logs when switching pods
// Handle paused logs state
if (!follow && logs.logs.length > 0) {
xtermRef.current?.write('\n\n' +
t('translation|Logs are paused. Click the follow button to resume following them.') +
'\r\n');
return;
}
if (selectedPodIndex === 'all') {
cleanup = fetchAllPodsLogs(pods, selectedContainer);
}
else {
const pod = pods[selectedPodIndex];
if (pod) {
let lastLogLength = 0;
cleanup = pod.getLogs(selectedContainer, (newLogs) => {
if (!isSubscribed)
return;
setLogs(current => {
const terminalRef = xtermRef.current;
if (!terminalRef)
return current;
// Only process new logs in chunks for better performance
if (newLogs.length > lastLogLength) {
const CHUNK_SIZE = 1000; // Process 1000 lines at a time
const startIdx = lastLogLength;
const endIdx = Math.min(startIdx + CHUNK_SIZE, newLogs.length);
// Process only the new chunk of logs
const newLogContent = newLogs
.slice(startIdx, endIdx)
.join('')
.replaceAll('\n', '\r\n');
terminalRef.write(newLogContent);
lastLogLength = endIdx;
// If there are more logs to process, schedule them for the next frame
if (endIdx < newLogs.length) {
requestAnimationFrame(() => {
setLogs(current => ({
...current,
logs: newLogs,
lastLineShown: endIdx - 1,
}));
});
return current;
}
}
return {
logs: newLogs,
lastLineShown: newLogs.length - 1,
};
});
}, {
tailLines: lines,
showPrevious,
showTimestamps,
follow,
onReconnectStop: () => {
if (isSubscribed) {
setShowReconnectButton(true);
}
},
});
}
}
}
return () => {
isSubscribed = false;
if (cleanup) {
cleanup();
}
};
}, [
selectedPodIndex,
selectedContainer,
showLogs,
lines,
showTimestamps,
follow,
clearLogs,
t,
pods,
]);
// Effect to process logs when allPodLogs changes - only for "All Pods" mode
React.useEffect(() => {
if (selectedPodIndex === 'all' && showLogs && Object.keys(allPodLogs).length > 0) {
processAllLogs();
}
}, [allPodLogs, selectedPodIndex, showLogs, processAllLogs]);
const topActions = [
_jsxs(Box, { sx: { display: 'flex', gap: 2, alignItems: 'center', width: '100%' }, children: [_jsxs(FormControl, { sx: { minWidth: 200 }, children: [_jsx(InputLabel, { children: t('translation|Select Pod') }), _jsxs(Select, { value: selectedPodIndex, onChange: event => {
setSelectedPodIndex(event.target.value);
clearLogs();
}, label: t('translation|Select Pod'), children: [_jsx(MenuItem, { value: "all", children: t('translation|All Pods') }), pods.map((pod, index) => (_jsx(MenuItem, { value: index, children: pod.getName() }, pod.getName())))] })] }), _jsxs(FormControl, { sx: { minWidth: 200 }, children: [_jsx(InputLabel, { children: t('translation|Container') }), _jsx(Select, { value: selectedContainer, onChange: event => {
setSelectedContainer(event.target.value);
clearLogs();
}, label: t('translation|Container'), children: containers.map(container => (_jsxs(MenuItem, { value: container, children: [container, hasContainerRestarted(pods[selectedPodIndex === 'all' ? 0 : selectedPodIndex]?.getName(), container) && ` (${t('translation|Restarted')})`] }, container))) })] }), _jsxs(FormControl, { sx: { minWidth: 120 }, children: [_jsx(InputLabel, { children: "Lines" }), _jsxs(Select, { value: lines, onChange: handleLinesChange, children: [[100, 1000, 2500].map(i => (_jsx(MenuItem, { value: i, children: i }, i))), _jsx(MenuItem, { value: -1, children: "All" })] })] }), _jsx(LightTooltip, { title: hasContainerRestarted(selectedPodIndex === 'all'
? pods[0]?.getName()
: pods[selectedPodIndex]?.getName(), selectedContainer)
? t('translation|Show logs for previous instances of this container.')
: t('translation|You can only select this option for containers that have been restarted.'), children: _jsx(PaddedFormControlLabel, { label: t('translation|Show previous'), disabled: !hasContainerRestarted(selectedPodIndex === 'all'
? pods[0]?.getName()
: pods[selectedPodIndex]?.getName(), selectedContainer), control: _jsx(Switch, { checked: showPrevious, onChange: handlePreviousChange }) }) }), _jsx(FormControlLabel, { control: _jsx(Switch, { checked: showTimestamps, onChange: handleTimestampsChange, size: "small" }), label: t('translation|Timestamps') }), _jsx(FormControlLabel, { control: _jsx(Switch, { checked: follow, onChange: handleFollowChange, size: "small" }), label: t('translation|Follow') })] }, "container-controls"),
];
return (_jsxs(_Fragment, { children: [(item instanceof Deployment || item instanceof ReplicaSet || item instanceof DaemonSet) && (_jsx(ActionButton, { icon: "mdi:file-document-box-outline", onClick: handleClick, description: t('translation|Show logs') })), showLogs && (_jsx(LogViewer, { title: item?.getName() || '', downloadName: `${item?.getName()}_${selectedPodIndex === 'all' ? 'all_pods' : pods[selectedPodIndex]?.getName()}`, open: showLogs, onClose: handleClose, logs: logs.logs, topActions: topActions, xtermRef: xtermRef, handleReconnect: handleReconnect, showReconnectButton: showReconnectButton }))] }));
}