UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

489 lines (488 loc) 25.2 kB
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 }), }, ] })); }