@kinvolk/headlamp-plugin
Version:
The needed infrastructure for building Headlamp plugins.
345 lines (344 loc) • 16 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 Button from '@mui/material/Button';
import DialogContent from '@mui/material/DialogContent';
import Grid from '@mui/material/Grid';
import InputBase from '@mui/material/InputBase';
import Paper from '@mui/material/Paper';
import { FitAddon } from '@xterm/addon-fit';
import { SearchAddon } from '@xterm/addon-search';
import { Terminal as XTerminal } from '@xterm/xterm';
import _ from 'lodash';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useShortcut } from '../../lib/useShortcut';
import ActionButton from './ActionButton';
import { Dialog } from './Dialog';
export function LogViewer(props) {
const { logs, title = '', downloadName = 'log', xtermRef: outXtermRef, onClose, topActions = [], handleReconnect, showReconnectButton = false, ...other } = props;
const { t } = useTranslation();
const xtermRef = React.useRef(null);
const fitAddonRef = React.useRef(null);
const searchAddonRef = React.useRef(null);
const [terminalContainerRef, setTerminalContainerRef] = React.useState(null);
const [showSearch, setShowSearch] = React.useState(false);
useShortcut('LOG_VIEWER_SEARCH', () => {
setShowSearch(true);
});
function downloadLog() {
// Cuts off the last 5 digits of the timestamp to remove the milliseconds
const time = new Date().toISOString().replace(/:/g, '-').slice(0, -5);
const element = document.createElement('a');
const file = new Blob(logs, { type: 'text/plain' });
element.href = URL.createObjectURL(file);
element.download = `${downloadName}_${time}.txt`;
// Required for FireFox
document.body.appendChild(element);
element.click();
}
React.useEffect(() => {
if (!terminalContainerRef || !!xtermRef.current) {
return;
}
fitAddonRef.current = new FitAddon();
searchAddonRef.current = new SearchAddon();
xtermRef.current = new XTerminal({
cursorStyle: 'bar',
scrollback: 10000,
rows: 30, // initial rows before fit
lineHeight: 1.21,
allowProposedApi: true,
});
if (!!outXtermRef) {
outXtermRef.current = xtermRef.current;
}
xtermRef.current.loadAddon(fitAddonRef.current);
xtermRef.current.loadAddon(searchAddonRef.current);
enableCopyPasteInXterm(xtermRef.current);
xtermRef.current.open(terminalContainerRef);
fitAddonRef.current.fit();
xtermRef.current?.write(getJointLogs());
const pageResizeHandler = () => {
fitAddonRef.current.fit();
console.debug('resize');
};
window.addEventListener('resize', pageResizeHandler);
return function cleanup() {
window.removeEventListener('resize', pageResizeHandler);
xtermRef.current?.dispose();
searchAddonRef.current?.dispose();
xtermRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [terminalContainerRef, xtermRef.current]);
React.useEffect(() => {
if (!xtermRef.current) {
return;
}
// We're delegating to external xterm ref.
if (!!outXtermRef) {
return;
}
xtermRef.current?.clear();
xtermRef.current?.write(getJointLogs());
return function cleanup() { };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [logs, xtermRef]);
function getJointLogs() {
return logs?.join('').replaceAll('\n', '\r\n');
}
const content = (_jsxs(DialogContent, { sx: theme => ({
height: '80%',
minHeight: '80%',
display: 'flex',
flexDirection: 'column',
'& .xterm ': {
height: '100vh', // So the terminal doesn't stay shrunk when shrinking vertically and maximizing again.
'& .xterm-viewport': {
width: 'initial !important', // BugFix: https://github.com/xtermjs/xterm.js/issues/3564#issuecomment-1004417440
},
},
'& #xterm-container': {
overflow: 'hidden',
width: '100%',
height: '100%',
'& .terminal.xterm': {
padding: theme.spacing(1),
},
},
}), children: [_jsxs(Grid, { container: true, justifyContent: "space-between", alignItems: "center", wrap: "nowrap", children: [_jsx(Grid, { item: true, container: true, spacing: 1, children: topActions.map((component, i) => (_jsx(Grid, { item: true, children: component }, i))) }), _jsx(Grid, { item: true, xs: true, children: _jsx(ActionButton, { description: t('translation|Find'), onClick: () => setShowSearch(show => !show), icon: "mdi:magnify" }) }), _jsx(Grid, { item: true, xs: true, children: _jsx(ActionButton, { description: t('translation|Clear'), onClick: () => clearPodLogs(xtermRef), icon: "mdi:broom" }) }), _jsx(Grid, { item: true, xs: true, children: _jsx(ActionButton, { description: t('Download'), onClick: downloadLog, icon: "mdi:file-download-outline" }) })] }), _jsxs(Box, { sx: theme => ({
paddingTop: theme.spacing(1),
flex: 1,
width: '100%',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column-reverse',
position: 'relative',
}), children: [showReconnectButton && (_jsx(Button, { onClick: handleReconnect, color: "info", variant: "contained", children: t('translation|Reconnect') })), _jsx("div", { id: "xterm-container", ref: ref => setTerminalContainerRef(ref), style: { flex: 1, display: 'flex', flexDirection: 'column-reverse' } }), _jsx(SearchPopover, { open: showSearch, onClose: () => setShowSearch(false), searchAddonRef: searchAddonRef })] })] }));
if (props.noDialog) {
return content;
}
return (_jsx(Dialog, { title: title, onFullScreenToggled: () => {
setTimeout(() => {
fitAddonRef.current.fit();
}, 1);
}, withFullScreen: true, onClose: onClose, ...other, children: content }));
}
// clears logs for pod
function clearPodLogs(xtermRef) {
xtermRef.current?.clear();
// keeping this comment if logs dont print after clear
// xtermRef.current?.write(getJointLogs());
}
function enableCopyPasteInXterm(xterm) {
xterm.attachCustomKeyEventHandler(arg => {
if (arg.ctrlKey && arg.code === 'KeyC' && arg.type === 'keydown') {
const selection = xterm.getSelection();
if (selection) {
return false;
}
}
if (arg.ctrlKey && arg.code === 'KeyV' && arg.type === 'keydown') {
return false;
}
return true;
});
}
export function SearchPopover(props) {
const { searchAddonRef, open, onClose } = props;
const [searchResult, setSearchResult] = React.useState(undefined);
const [searchText, setSearchText] = React.useState('');
const [caseSensitiveChecked, setCaseSensitiveChecked] = React.useState(false);
const [wholeWordMatchChecked, setWholeWordMatchChecked] = React.useState(false);
const [regexChecked, setRegexChecked] = React.useState(false);
const { t } = useTranslation(['translation']);
const focusedRef = React.useCallback((node) => {
if (open && !!node) {
node.focus();
node.select();
}
}, [open]);
const randomId = _.uniqueId('search-input-');
const searchAddonTextDecorationOptions = {
matchBackground: '#6d402a',
activeMatchBackground: '#515c6a',
matchOverviewRuler: '#f00',
activeMatchColorOverviewRuler: '#515c6a',
};
useEffect(() => {
if (!open) {
searchAddonRef.current?.clearDecorations();
searchAddonRef.current?.clearActiveDecoration();
return;
}
try {
searchAddonRef.current?.findNext(searchText, {
regex: regexChecked,
caseSensitive: caseSensitiveChecked,
wholeWord: wholeWordMatchChecked,
decorations: searchAddonTextDecorationOptions,
});
}
catch (e) {
// Catch invalid regular expression error
console.error('Error searching logs: ', e);
searchAddonRef.current?.findNext('');
}
searchAddonRef.current?.onDidChangeResults(args => {
setSearchResult(args);
});
return function cleanup() {
// eslint-disable-next-line react-hooks/exhaustive-deps
searchAddonRef.current?.findNext('');
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchText, caseSensitiveChecked, wholeWordMatchChecked, regexChecked, open]);
const handleFindNext = () => {
searchAddonRef.current?.findNext(searchText, {
regex: regexChecked,
caseSensitive: caseSensitiveChecked,
wholeWord: wholeWordMatchChecked,
decorations: searchAddonTextDecorationOptions,
});
};
const handleFindPrevious = () => {
searchAddonRef.current?.findPrevious(searchText, {
regex: regexChecked,
caseSensitive: caseSensitiveChecked,
wholeWord: wholeWordMatchChecked,
decorations: searchAddonTextDecorationOptions,
});
};
const handleClose = () => {
onClose();
};
const onSearchTextChange = (event) => {
setSearchText(event.target.value);
};
const handleInputKeyDown = (event) => {
if (event.key === 'Enter') {
if (event.shiftKey) {
handleFindPrevious();
}
else {
handleFindNext();
}
}
};
const baseGray = '#cccccc';
const grayText = {
color: baseGray,
};
const redText = {
color: '#f48771',
};
const searchResults = () => {
let color = grayText;
let msg = '';
if (!searchText) {
msg = t('translation|No results');
}
else if (!searchResult) {
msg = t('translation|Too many matches');
color = redText;
}
else {
if (searchResult.resultCount === 0) {
msg = t('translation|No results');
color = redText;
}
else {
msg = t('translation|{{ currentIndex }} of {{ totalResults }}', {
currentIndex: searchResult?.resultIndex !== undefined ? searchResult?.resultIndex + 1 : '?',
totalResults: searchResult?.resultCount === undefined ? '999+' : searchResult?.resultCount,
});
}
}
return (_jsx(Box, { component: "span", sx: color, children: msg }));
};
return !open ? (_jsx(_Fragment, {})) : (_jsxs(Paper, { sx: theme => {
//@todo: This style should match the theme being used.
return {
position: 'absolute',
background: '#252526',
top: 8,
right: 15,
padding: '4px 8px',
zIndex: theme.zIndex.modal,
marginRight: '7px',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
borderLeft: `2px solid #555`,
'& .SearchTextArea': {
background: '#3c3c3c',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
padding: '1px 4px 2px 0',
width: 240,
'& .MuiInputBase-root': {
color: baseGray,
fontSize: '0.85rem',
border: '1px solid rgba(0,0,0,0)',
'&.Mui-focused': {
border: `1px solid #007fd4`,
},
'&>input': {
padding: '2px 4px',
},
},
'& .MuiIconButton-root': {
margin: '0 1px',
padding: theme.spacing(0.5),
fontSize: '1.05rem',
color: baseGray,
borderRadius: 4,
'&.checked': {
background: '#245779',
},
},
},
'& .search-results': {
width: 70,
marginLeft: 8,
fontSize: '0.8rem',
},
'& .search-actions': {
'& .MuiIconButton-root': {
padding: 2,
fontSize: '1.05rem',
color: baseGray,
'&.Mui-disabled': {
color: '#767677',
},
},
},
};
}, children: [_jsxs(Box, { className: "SearchTextArea", children: [_jsx(InputBase, { value: searchText, onChange: onSearchTextChange, placeholder: t('translation|Find'), inputProps: { autoComplete: 'off', type: 'text', name: randomId, id: randomId }, onKeyDown: handleInputKeyDown, inputRef: focusedRef }), _jsx(ActionButton, { icon: "mdi:format-letter-case", onClick: () => setCaseSensitiveChecked(!caseSensitiveChecked), description: t('translation|Match case'), iconButtonProps: {
className: caseSensitiveChecked ? 'checked' : '',
} }), _jsx(ActionButton, { icon: "mdi:format-letter-matches", onClick: () => setWholeWordMatchChecked(!wholeWordMatchChecked), description: t('translation|Match whole word'), iconButtonProps: {
className: wholeWordMatchChecked ? 'checked' : '',
} }), _jsx(ActionButton, { icon: "mdi:regex", onClick: () => setRegexChecked(!regexChecked), description: t('translation|Use regular expression'), iconButtonProps: {
className: regexChecked ? 'checked' : '',
} })] }), _jsx("div", { className: "search-results", children: searchResults() }), _jsxs("div", { className: "search-actions", children: [_jsx(ActionButton, { icon: "mdi:arrow-up", onClick: handleFindPrevious, description: t('translation|Previous Match (Shift+Enter)'), iconButtonProps: {
disabled: !searchResult?.resultCount && searchResult?.resultCount !== undefined,
} }), _jsx(ActionButton, { icon: "mdi:arrow-down", onClick: handleFindNext, description: t('translation|Next Match (Enter)'), iconButtonProps: {
disabled: !searchResult?.resultCount && searchResult?.resultCount !== undefined,
} }), _jsx(ActionButton, { icon: "mdi:close", onClick: handleClose, description: t('translation|Close') })] })] }));
}