@kinvolk/headlamp-plugin
Version:
The needed infrastructure for building Headlamp plugins.
489 lines (488 loc) • 25.2 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs } 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 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 Switch from '@mui/material/Switch';
import { styled } from '@mui/system';
import _ from 'lodash';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useParams } from 'react-router-dom';
import { getDefaultContainer } from '../../helpers/podContainer';
import Pod from '../../lib/k8s/pod';
import { DefaultHeaderAction } from '../../redux/actionButtonsSlice';
import { EventStatus, HeadlampEventType, useEventCallback } from '../../redux/headlampEventSlice';
import { Activity } from '../activity/Activity';
import ActionButton from '../common/ActionButton';
import Link from '../common/Link';
import { LogViewer } from '../common/LogViewer';
import { ConditionsSection, ContainersSection, DetailsGrid, VolumeSection, } from '../common/Resource';
import AuthVisible from '../common/Resource/AuthVisible';
import { ALL_SEVERITIES, filterLogsBySeverity, } from '../common/Resource/logSeverityFilter';
import SectionBox from '../common/SectionBox';
import SimpleTable from '../common/SimpleTable';
import Terminal from '../common/Terminal';
import LightTooltip from '../common/Tooltip/TooltipLight';
import { useLocalStorageState } from '../globalSearch/useLocalStorageState';
import { colorizePrettifiedLog } from './jsonHandling';
import { makePodStatusLabel } from './List';
import { PodDebugAction } from './PodDebugAction';
const PaddedFormControlLabel = styled(FormControlLabel)(({ theme }) => ({
margin: 0,
paddingTop: theme.spacing(2),
paddingRight: theme.spacing(1),
}));
export function PodLogViewer(props) {
const { item, onClose, open, ...other } = props;
const [container, setContainer] = React.useState(() => getDefaultContainer(item));
const [showPrevious, setShowPrevious] = React.useState(false);
const [showTimestamps, setShowTimestamps] = useLocalStorageState('headlamp.logs.showTimestamps', true);
const [follow, setFollow] = React.useState(true);
const [prettifyLogs, setPrettifyLogs] = React.useState(false);
const [formatJsonValues, setFormatJsonValues] = React.useState(false);
const [hasJsonLogs, setHasJsonLogs] = React.useState(false);
const [lines, setLines] = React.useState(100);
const [logs, setLogs] = React.useState({
logs: [],
lastLineShown: -1,
});
const [showReconnectButton, setShowReconnectButton] = React.useState(false);
const [cancelLogsStream, setCancelLogsStream] = React.useState(null);
const xtermRef = React.useRef(null);
const { t } = useTranslation();
const [selectedSeverities, setSelectedSeverities] = useLocalStorageState('headlamp.logs.severityFilter', [...ALL_SEVERITIES]);
const selectedSeveritiesRef = React.useRef(selectedSeverities);
React.useEffect(() => {
selectedSeveritiesRef.current = selectedSeverities;
}, [selectedSeverities]);
// Re-render xterm when selectedSeverities changes
React.useEffect(() => {
if (xtermRef.current && logs.logs.length > 0) {
xtermRef.current.clear();
const displayLogs = logs.logs.map(logEntry => {
if (prettifyLogs && hasJsonLogs) {
return colorizePrettifiedLog(logEntry);
}
return logEntry;
});
const filteredLogs = filterLogsBySeverity(displayLogs, selectedSeverities);
xtermRef.current.write(filteredLogs.join('').replaceAll('\n', '\r\n'));
// Update lastLineShown just in case, though it shouldn't be strictly necessary here
setLogs(current => ({ ...current, lastLineShown: current.logs.length - 1 }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSeverities]);
const options = { leading: true, trailing: true, maxWait: 1000 };
function setLogsDebounced({ logs: logLines, hasJsonLogs, }) {
setHasJsonLogs(hasJsonLogs);
setLogs(current => {
if (current.lastLineShown >= logLines.length) {
// Full re-render
const displayLogs = logLines.map(logEntry => {
if (prettifyLogs && hasJsonLogs) {
return colorizePrettifiedLog(logEntry);
}
return logEntry;
});
const filteredLogs = filterLogsBySeverity(displayLogs, selectedSeveritiesRef.current);
xtermRef.current?.clear();
xtermRef.current?.write(filteredLogs.join('').replaceAll('\n', '\r\n'));
}
else {
// Incremental write: slice raw lines first, then format and filter
const newRawLines = logLines.slice(current.lastLineShown + 1);
const displayLogs = newRawLines.map(logEntry => {
if (prettifyLogs && hasJsonLogs) {
return colorizePrettifiedLog(logEntry);
}
return logEntry;
});
const filteredLogs = filterLogsBySeverity(displayLogs, selectedSeveritiesRef.current);
if (filteredLogs.length > 0) {
xtermRef.current?.write(filteredLogs.join('').replaceAll('\n', '\r\n'));
}
}
return {
logs: logLines,
lastLineShown: logLines.length - 1,
};
});
// If we stopped following the logs and we have logs already,
// then we don't need to fetch them again.
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;
}
}
const debouncedSetState = _.debounce(setLogsDebounced, 500, options);
React.useEffect(() => {
const next = getDefaultContainer(item);
if (next && !container) {
setContainer(next);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [item?.status]);
React.useEffect(() => {
let callback = null;
if (props.open) {
xtermRef.current?.clear();
setLogs({ logs: [], lastLineShown: -1 });
setHasJsonLogs(false);
callback = item.getLogs(container, debouncedSetState, {
tailLines: lines,
showPrevious,
showTimestamps,
follow,
prettifyLogs,
formatJsonValues,
/**
* When the connection is lost, show the reconnect button.
* This will stop the current log stream.
*/
onReconnectStop: () => {
setShowReconnectButton(true);
},
});
}
return function cleanup() {
if (callback) {
callback();
}
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[container, lines, open, showPrevious, showTimestamps, follow, prettifyLogs, formatJsonValues]);
function handleContainerChange(event) {
setContainer(event.target.value);
setHasJsonLogs(false);
}
function handleLinesChange(event) {
setLines(event.target.value);
}
function handlePreviousChange() {
setShowPrevious(previous => !previous);
}
function hasContainerRestarted() {
const cont = item?.status?.containerStatuses?.find((c) => c.name === container);
if (!cont) {
return false;
}
return cont.restartCount > 0;
}
function handleTimestampsChange() {
setShowTimestamps(prev => !prev);
}
function handleFollowChange() {
setFollow(follow => !follow);
}
function handlePrettifyChange() {
setPrettifyLogs(prettify => !prettify);
}
function handleFormatJsonValuesChange() {
setFormatJsonValues(format => !format);
}
/**
* Handle the reconnect button being clicked.
* This will start a new log stream and hide the reconnect button.
*/
function handleReconnect() {
// If there's an existing log stream, cancel it
if (cancelLogsStream) {
cancelLogsStream();
}
// Start a new log stream
const newCancelLogsStream = item.getLogs(container, debouncedSetState, {
tailLines: lines,
showPrevious,
showTimestamps,
follow,
prettifyLogs,
formatJsonValues,
/**
* When the connection is lost, show the reconnect button.
* This will stop the current log stream.
*/
onReconnectStop: () => {
setShowReconnectButton(true);
},
});
// Set the cancelLogsStream function to the new one
setCancelLogsStream(() => newCancelLogsStream);
// Hide the reconnect button
setShowReconnectButton(false);
}
return (_jsx(LogViewer, { title: t('glossary|Logs: {{ itemName }}', { itemName: item.getName() }), downloadName: `${item.getName()}_${container}`, open: open, onClose: onClose, logs: logs.logs, xtermRef: xtermRef, handleReconnect: handleReconnect, showReconnectButton: showReconnectButton, topActions: [
_jsxs(FormControl, { sx: { minWidth: '11rem' }, children: [_jsx(InputLabel, { shrink: true, id: "container-name-chooser-label", children: t('glossary|Container') }), _jsxs(Select, { labelId: "container-name-chooser-label", id: "container-name-chooser", value: container, onChange: handleContainerChange, children: [item?.spec?.containers && (_jsx(MenuItem, { disabled: true, value: "", children: t('glossary|Containers') })), item?.spec?.containers.map(({ name }) => (_jsx(MenuItem, { value: name, children: name }, name))), item?.spec?.initContainers && (_jsx(MenuItem, { disabled: true, value: "", children: t('translation|Init Containers') })), item.spec.initContainers?.map(({ name }) => (_jsx(MenuItem, { value: name, children: name }, `init_container_${name}`))), item?.spec?.ephemeralContainers && (_jsx(MenuItem, { disabled: true, value: "", children: t('glossary|Ephemeral Containers') })), item.spec.ephemeralContainers?.map(({ name }) => (_jsx(MenuItem, { value: name, children: name }, `eph_container_${name}`)))] })] }),
_jsxs(FormControl, { sx: { minWidth: '6rem' }, children: [_jsx(InputLabel, { shrink: true, id: "container-lines-chooser-label", children: t('translation|Lines') }), _jsxs(Select, { labelId: "container-lines-chooser-label", id: "container-lines-chooser", 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()
? 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|Previous'), disabled: !hasContainerRestarted(), control: _jsx(Switch, { checked: showPrevious, onChange: handlePreviousChange, name: "checkPrevious", color: "primary", size: "small", sx: { transform: 'scale(0.8)' } }) }) }),
_jsx(LightTooltip, { title: t('translation|Show timestamps in the logs.'), children: _jsx(PaddedFormControlLabel, { label: t('translation|Timestamps'), control: _jsx(Switch, { checked: showTimestamps, onChange: handleTimestampsChange, name: "checkTimestamps", color: "primary", size: "small", sx: { transform: 'scale(0.8)' } }) }) }),
_jsx(LightTooltip, { title: t('translation|Follow logs in real-time.'), children: _jsx(PaddedFormControlLabel, { label: t('translation|Follow'), control: _jsx(Switch, { checked: follow, onChange: handleFollowChange, name: "follow", color: "primary", size: "small", sx: { transform: 'scale(0.8)' } }) }) }),
_jsxs(FormControl, { sx: { minWidth: '9rem' }, children: [_jsx(InputLabel, { shrink: true, id: "severity-filter-label", children: t('translation|Severity') }), _jsx(Select, { labelId: "severity-filter-label", id: "severity-filter", multiple: true, value: selectedSeverities, onChange: event => {
const value = event.target.value;
if (value.length > 0) {
setSelectedSeverities(() => value);
}
}, 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))) })] }),
hasJsonLogs && (_jsx(PaddedFormControlLabel, { label: t('translation|Prettify'), control: _jsx(Switch, { checked: prettifyLogs, onChange: handlePrettifyChange, name: "prettifyLogs", color: "primary", size: "small", sx: { transform: 'scale(0.8)' } }) })),
hasJsonLogs && (_jsx(LightTooltip, { title: t('translation|Show JSON values in plain text by removing escape characters.'), children: _jsx(PaddedFormControlLabel, { label: t('translation|Format'), control: _jsx(Switch, { checked: formatJsonValues, onChange: handleFormatJsonValuesChange, name: "formatJsonValues", color: "primary", size: "small", sx: { transform: 'scale(0.8)' } }) }) })),
].filter(Boolean), ...other }));
}
export function VolumeDetails(props) {
const { volumes } = props;
const { t } = useTranslation();
if (!volumes) {
return null;
}
return (_jsx(SectionBox, { title: t('translation|Volumes'), children: _jsx(SimpleTable, { columns: [
{
label: t('translation|Name'),
getter: data => data.name,
},
{
label: t('translation|Type'),
getter: data => Object.keys(data)[1],
},
], data: volumes, reflectInURL: "volumes" }) }));
}
function TolerationsSection(props) {
const { tolerations } = props;
const { t } = useTranslation(['glossary', 'translation']);
return (_jsx(SectionBox, { title: t('Tolerations'), children: _jsx(SimpleTable, { data: tolerations, columns: [
{
label: t('translation|Key'),
getter: data => data.key,
},
{
label: t('translation|Value'),
getter: data => data.value,
},
{
label: t('translation|Operator'),
getter: data => data.operator,
gridTemplate: '0.5fr',
},
{
label: t('translation|Effect'),
getter: data => data.effect,
},
{
label: t('Seconds'),
getter: data => data.tolerationSeconds,
gridTemplate: '0.5fr',
},
] }) }));
}
export default function PodDetails(props) {
const params = useParams();
const { name = params.name, namespace = params.namespace, cluster } = props;
const { t } = useTranslation('glossary');
const dispatchHeadlampEvent = useEventCallback();
const lastAutoLaunchedPodLogs = React.useRef(null);
const lastAutoLaunchedPodExec = React.useRef(null);
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
const autoLaunchView = queryParams.get('view');
const [podItem, setPodItem] = React.useState(null);
const launchLogs = React.useCallback((item) => {
Activity.launch({
id: 'logs-' + item.metadata.uid,
title: t('Logs: {{ itemName }}', { itemName: item.metadata.name }),
cluster: item.cluster,
icon: _jsx(Icon, { icon: "mdi:file-document-box-outline", width: "100%", height: "100%" }),
location: 'full',
content: _jsx(PodLogViewer, { noDialog: true, open: true, item: item, onClose: () => { } }),
});
dispatchHeadlampEvent({
type: HeadlampEventType.LOGS,
data: {
status: EventStatus.OPENED,
},
});
}, [t, dispatchHeadlampEvent]);
const launchTerminal = React.useCallback((item) => {
const activityId = 'terminal-' + item.metadata.uid;
Activity.launch({
id: activityId,
title: item.metadata.name,
cluster: item.cluster,
icon: _jsx(Icon, { icon: "mdi:console", width: "100%", height: "100%" }),
location: 'full',
content: (_jsx(Terminal, { noDialog: true, open: true, item: item, onClose: () => Activity.close(activityId), isAttach: false })),
});
dispatchHeadlampEvent({
type: HeadlampEventType.TERMINAL,
data: {
resource: item,
status: EventStatus.OPENED,
},
});
}, [dispatchHeadlampEvent]);
React.useEffect(() => {
if (autoLaunchView !== 'logs') {
lastAutoLaunchedPodLogs.current = null;
return;
}
if (podItem &&
autoLaunchView === 'logs' &&
lastAutoLaunchedPodLogs.current !== podItem.metadata.uid) {
lastAutoLaunchedPodLogs.current = podItem.metadata.uid;
launchLogs(podItem);
}
}, [podItem, launchLogs, autoLaunchView]);
React.useEffect(() => {
if (autoLaunchView !== 'exec') {
lastAutoLaunchedPodExec.current = null;
return;
}
if (podItem &&
autoLaunchView === 'exec' &&
lastAutoLaunchedPodExec.current !== podItem.metadata.uid) {
lastAutoLaunchedPodExec.current = podItem.metadata.uid;
launchTerminal(podItem);
}
}, [podItem, launchTerminal, autoLaunchView]);
function prepareExtraInfo(item) {
let extraInfo = [];
if (item) {
extraInfo = [
{
name: t('State'),
value: makePodStatusLabel(item, false),
},
{
name: t('Node'),
value: item.spec.nodeName ? (_jsx(Link, { routeName: "node", params: { name: item.spec.nodeName }, activeCluster: item.cluster, children: item.spec.nodeName })) : (''),
},
{
name: t('Service Account'),
value: !!item.spec.serviceAccountName || !!item.spec.serviceAccount ? (_jsx(Link, { routeName: "serviceAccount", params: {
namespace: item.metadata.namespace,
name: item.spec.serviceAccountName || item.spec.serviceAccount,
}, activeCluster: item.cluster, children: item.spec.serviceAccountName || item.spec.serviceAccount })) : (''),
},
// Show Host IP only if Host IPs doesn't exist or is empty
...(item.status.hostIPs && item.status.hostIPs.length > 0
? []
: [
{
name: t('Host IP'),
value: item.status.hostIP ?? '',
},
]),
// Always include Host IPs, but hide if empty
{
name: t('Host IPs'),
value: item.status.hostIPs
? item.status.hostIPs.map((ipObj) => ipObj.ip).join(', ')
: '',
hideLabel: !item.status.hostIPs || item.status.hostIPs.length === 0,
},
// Show Pod IP only if Pod IPs doesn't exist or is empty
...(item.status.podIPs && item.status.podIPs.length > 0
? []
: [
{
name: t('Pod IP'),
value: item.status.podIP ?? '',
},
]),
// Always include Pod IPs, but hide if empty
{
name: t('Pod IPs'),
value: item.status.podIPs
? item.status.podIPs.map((ipObj) => ipObj.ip).join(', ')
: '',
hideLabel: !item.status.podIPs || item.status.podIPs.length === 0,
},
{
name: t('QoS Class'),
value: item.status.qosClass,
},
{
name: t('Priority'),
value: item.spec.priority,
},
];
}
return extraInfo;
}
return (_jsx(DetailsGrid, { resourceType: Pod, name: name, namespace: namespace, cluster: cluster, withEvents: true, onResourceUpdate: item => {
setPodItem(item);
}, actions: item => item && [
{
id: DefaultHeaderAction.POD_LOGS,
action: (_jsx(AuthVisible, { item: item, authVerb: "get", subresource: "log", children: _jsx(ActionButton, { description: t('Show Logs'), icon: "mdi:file-document-box-outline", onClick: () => launchLogs(item) }) })),
},
{
id: DefaultHeaderAction.POD_TERMINAL,
action: (_jsx(AuthVisible, { item: item, authVerb: "create", subresource: "exec", children: _jsx(ActionButton, { description: t('Terminal / Exec'), icon: "mdi:console", onClick: () => launchTerminal(item) }) })),
},
{
id: DefaultHeaderAction.POD_DEBUG,
action: _jsx(PodDebugAction, { item: item }),
},
{
id: DefaultHeaderAction.POD_ATTACH,
action: (_jsx(AuthVisible, { item: item, authVerb: "get", subresource: "attach", children: _jsx(ActionButton, { description: t('Attach'), icon: "mdi:connection", onClick: () => {
dispatchHeadlampEvent({
type: HeadlampEventType.POD_ATTACH,
data: {
resource: item,
status: EventStatus.OPENED,
},
});
Activity.launch({
id: 'attach-' + item.metadata.uid,
title: item.metadata.name,
cluster: item.cluster,
icon: _jsx(Icon, { icon: "mdi:console", width: "100%", height: "100%" }),
location: 'full',
content: _jsx(Terminal, { noDialog: true, open: true, item: item, onClose: () => { }, isAttach: true }),
});
} }) })),
},
], extraInfo: item => prepareExtraInfo(item), extraSections: item => item && [
{
id: 'headlamp.pod-tolerations',
section: _jsx(TolerationsSection, { tolerations: item?.spec?.tolerations || [] }),
},
{
id: 'headlamp.pod-conditions',
section: _jsx(ConditionsSection, { resource: item?.jsonData }),
},
{
id: 'headlamp.pod-containers',
section: _jsx(ContainersSection, { resource: item }),
},
{
id: 'headlamp.pod-volumes',
section: _jsx(VolumeSection, { resource: item?.jsonData }),
},
] }));
}