UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

336 lines (335 loc) 15.7 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 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 { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; 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); useHotkeys('ctrl+shift+f', () => { 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; }; }, [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() { }; }, [logs, xtermRef]); function getJointLogs() { return logs?.join('').replaceAll('\n', '\r\n'); } return (_jsx(Dialog, { title: title, onFullScreenToggled: () => { setTimeout(() => { fitAddonRef.current.fit(); }, 1); }, withFullScreen: true, onClose: onClose, ...other, children: _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: "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 })] })] }) })); } // 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.log('Error searching logs: ', e); searchAddonRef.current?.findNext(''); } searchAddonRef.current?.onDidChangeResults(args => { setSearchResult(args); }); return function cleanup() { searchAddonRef.current?.findNext(''); }; }, [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, 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') })] })] })); }