UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

443 lines (442 loc) 22 kB
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 { Icon } from '@iconify/react'; import Box from '@mui/material/Box'; import Checkbox from '@mui/material/Checkbox'; import FormControl from '@mui/material/FormControl'; import FormControlLabel from '@mui/material/FormControlLabel'; import InputLabel from '@mui/material/InputLabel'; import ListItemText from '@mui/material/ListItemText'; 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, { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { labelSelectorToQuery } from '../../../lib/k8s'; import { clusterFetch } from '../../../lib/k8s/api/v2/fetch'; import { makeUrl } from '../../../lib/k8s/api/v2/makeUrl'; 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 StatefulSet from '../../../lib/k8s/statefulSet'; import { EventStatus, HeadlampEventType, useEventCallback, } from '../../../redux/headlampEventSlice'; import { Activity } from '../../activity/Activity'; import ActionButton from '../ActionButton'; import { LogViewer } from '../LogViewer'; import { LightTooltip } from '../Tooltip'; import { ALL_SEVERITIES, filterLogsBySeverity } from './logSeverityFilter'; // Workload kinds whose pods can be aggregated by the Logs activity. Exported so // plugin authors can check before calling launchWorkloadLogs(). export const LOGGABLE_WORKLOAD_KINDS = new Set([ 'Deployment', 'ReplicaSet', 'DaemonSet', 'StatefulSet', ]); // Kind + apiGroup check via KubeObject.isClassOf — cross-bundle safe, unlike // instanceof, which breaks when plugins ship their own class definitions. function isLoggableWorkload(item) { return (!!item && (Deployment.isClassOf(item) || ReplicaSet.isClassOf(item) || DaemonSet.isClassOf(item) || StatefulSet.isClassOf(item))); } // Styled component for consistent padding in form controls const PaddedFormControlLabel = styled(FormControlLabel)(({ theme }) => ({ margin: 0, paddingTop: theme.spacing(2), paddingRight: theme.spacing(2), })); function LogsButtonContent({ item }) { 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 [selectedSeverities, setSelectedSeverities] = useState(() => { try { const stored = localStorage.getItem('headlamp.logs.severityFilter'); if (stored) { const parsed = JSON.parse(stored); if (Array.isArray(parsed) && parsed.length > 0) { // Normalize and validate against ALL_SEVERITIES const validSeverities = parsed.filter(s => ALL_SEVERITIES.includes(s)); if (validSeverities.length > 0) { return validSeverities; } } } } catch { // ignore parse errors } return [...ALL_SEVERITIES]; }); const xtermRef = React.useRef(null); const processLogsTimerRef = useRef(null); const processedLogsRef = useRef(null); const selectedSeveritiesRef = useRef(selectedSeverities); useEffect(() => { selectedSeveritiesRef.current = selectedSeverities; }, [selectedSeverities]); // Re-render xterm when selectedSeverities changes without restarting the stream useEffect(() => { if (xtermRef.current && logs.logs.length > 0) { xtermRef.current.clear(); const filtered = filterLogsBySeverity(logs.logs, selectedSeverities); xtermRef.current.write(filtered.join('').replaceAll('\n', '\r\n')); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedSeverities]); 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 (isLoggableWorkload(item)) { try { const labelSelector = labelSelectorToQuery(item.spec.selector); if (!labelSelector) { throw new Error(t('translation|No label selectors found for this {{type}}', { type: item.kind.toLowerCase(), })); } const queryParams = { labelSelector }; const response = await clusterFetch(makeUrl(`/api/v1/namespaces/${item.metadata.namespace}/pods`, queryParams), { cluster: item.cluster }).then(it => it.json()); 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 onMount() { if (isLoggableWorkload(item)) { try { const fetchedPods = await getRelatedPods(); if (fetchedPods.length > 0) { setPods(fetchedPods); setSelectedPodIndex('all'); setSelectedContainer(fetchedPods[0].spec.containers[0].name); } 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, }); } } } useEffect(() => { onMount(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // 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((logsData) => { const allLogs = []; Object.entries(logsData).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); }); // Apply severity filter before rendering const filteredLogs = filterLogsBySeverity(allLogs, selectedSeverities); if (xtermRef.current) { xtermRef.current.clear(); xtermRef.current.write(filteredLogs.join('').replaceAll('\n', '\r\n')); } // Store RAW logs in state for downloads, not filtered setLogs({ logs: allLogs, lastLineShown: allLogs.length - 1, }); }, [selectedSeverities]); // Need selectedSeverities to re-filter // Function to fetch and aggregate logs from all pods const fetchAllPodsLogs = React.useCallback((pods, container) => { clearLogs(); setAllPodLogs({}); const cleanups = []; pods.forEach(pod => { const cleanup = pod.getLogs(container, ({ logs: newLogs }) => { const podName = pod.getName(); setAllPodLogs(current => ({ ...current, [podName]: newLogs, })); }, { tailLines: lines, showPrevious, showTimestamps, follow, onReconnectStop: () => { setShowReconnectButton(true); }, }); cleanups.push(cleanup); }); return () => cleanups.forEach(cleanup => cleanup()); }, [clearLogs, lines, showPrevious, showTimestamps, follow]); // Effect for fetching and updating logs React.useEffect(() => { if (!selectedContainer) { return; } let cleanup = null; let isSubscribed = true; // Handle paused logs state - avoid fetching new logs if (!follow && logs.logs.length > 0) { return; } if (selectedPodIndex === 'all') { cleanup = fetchAllPodsLogs(pods, selectedContainer); } else { clearLogs(); setAllPodLogs({}); // Clear aggregated logs when switching pods const pod = pods[selectedPodIndex]; if (pod) { let lastLogLength = 0; cleanup = pod.getLogs(selectedContainer, ({ logs: 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); // Apply severity filter to the new chunk using the ref to avoid effect restart const chunk = newLogs.slice(startIdx, endIdx); const filteredChunk = filterLogsBySeverity(chunk, selectedSeveritiesRef.current); const filteredContent = filteredChunk.join('').replaceAll('\n', '\r\n'); terminalRef.write(filteredContent); 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, // Keep raw logs for downloads lastLineShown: endIdx - 1, })); }); return current; } } return { logs: newLogs, // Keep raw logs lastLineShown: newLogs.length - 1, }; }); }, { tailLines: lines, showPrevious, showTimestamps, follow, onReconnectStop: () => { if (isSubscribed) { setShowReconnectButton(true); } }, }); } } return () => { isSubscribed = false; if (cleanup) { cleanup(); } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ selectedPodIndex, selectedContainer, lines, showPrevious, showTimestamps, follow, clearLogs, pods, ]); // Effect to write paused logs message without triggering resubscription React.useEffect(() => { 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'); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [follow, t]); // Effect to process logs when allPodLogs changes - only for "All Pods" mode // Debounced to avoid expensive O(N log N) sort on every incoming log chunk React.useEffect(() => { if (selectedPodIndex === 'all' && Object.keys(allPodLogs).length > 0 && allPodLogs !== processedLogsRef.current) { if (processLogsTimerRef.current) { clearTimeout(processLogsTimerRef.current); processLogsTimerRef.current = null; } processLogsTimerRef.current = setTimeout(() => { processLogsTimerRef.current = null; processedLogsRef.current = allPodLogs; processAllLogs(allPodLogs); }, 250); } return () => { if (processLogsTimerRef.current) { clearTimeout(processLogsTimerRef.current); processLogsTimerRef.current = null; } }; }, [allPodLogs, selectedPodIndex, processAllLogs]); const topActions = [ _jsxs(Box, { sx: { display: 'flex', flexWrap: 'wrap', 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" })] })] }), _jsxs(FormControl, { sx: { minWidth: 140 }, children: [_jsx(InputLabel, { children: t('translation|Severity') }), _jsx(Select, { multiple: true, value: selectedSeverities, onChange: event => { const value = event.target.value; if (value.length > 0) { setSelectedSeverities(value); localStorage.setItem('headlamp.logs.severityFilter', JSON.stringify(value)); } }, label: t('translation|Severity'), renderValue: selected => selected.length === ALL_SEVERITIES.length ? t('translation|All') : selected.map(s => s.toUpperCase()).join(', '), children: ALL_SEVERITIES.map(severity => (_jsxs(MenuItem, { value: severity, children: [_jsx(Checkbox, { checked: selectedSeverities.includes(severity), size: "small" }), _jsx(ListItemText, { primary: severity.toUpperCase() })] }, severity))) })] }), _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, { size: "small", sx: { transform: 'scale(0.8)' }, checked: showPrevious, onChange: handlePreviousChange }) }) }), _jsx(LightTooltip, { title: t('translation|Show timestamps in the logs.'), children: _jsx(PaddedFormControlLabel, { control: _jsx(Switch, { checked: showTimestamps, onChange: handleTimestampsChange, size: "small", sx: { transform: 'scale(0.8)' } }), label: t('translation|Timestamps'), disabled: false }) }), _jsx(LightTooltip, { title: t('translation|Follow logs in real-time.'), children: _jsx(PaddedFormControlLabel, { control: _jsx(Switch, { checked: follow, onChange: handleFollowChange, size: "small", sx: { transform: 'scale(0.8)' } }), label: t('translation|Follow'), disabled: false }) })] }, "container-controls"), ]; return (_jsx(LogViewer, { noDialog: true, title: item?.getName() || '', downloadName: `${item?.getName()}_${selectedPodIndex === 'all' ? 'all_pods' : pods[selectedPodIndex]?.getName()}`, open: true, onClose: () => { }, logs: logs.logs, topActions: topActions, xtermRef: xtermRef, handleReconnect: handleReconnect, showReconnectButton: showReconnectButton })); } export function launchWorkloadLogs(item, dispatchHeadlampEvent) { if (!isLoggableWorkload(item)) { return; } Activity.launch({ id: 'logs-' + item.metadata.uid, title: 'Logs: ' + item.metadata.name, icon: _jsx(Icon, { icon: "mdi:file-document-box-outline", width: "100%", height: "100%" }), cluster: item.cluster, location: 'full', content: _jsx(LogsButtonContent, { item: item }), }); dispatchHeadlampEvent?.({ type: HeadlampEventType.LOGS, data: { status: EventStatus.OPENED, }, }); } export function LogsButton({ item }) { const { t } = useTranslation(); const dispatchHeadlampEvent = useEventCallback(); const onClick = () => { if (!item) return; launchWorkloadLogs(item, dispatchHeadlampEvent); }; return (_jsx(_Fragment, { children: isLoggableWorkload(item) && (_jsx(ActionButton, { icon: "mdi:file-document-box-outline", onClick: onClick, description: t('translation|Show logs') })) })); }