mirror of https://github.com/grafana/grafana.git
				
				
				
			New Logs Panel: font size selector and Log Details size improvments (#106376)
* LogList: create font size option * LogList: prevent option fontSize bouncing * LogListContext: fix stored container size bigger than container * LogList: render smaller font size * virtualization: adjust to variable font size * virtualization: strip white characters of at the start successive long lines * LogList: add font size to log size cache * LogList: use getters instead of fixed constants * LogLine: prevent unnecessary overflow calls * virtualization: strip ansi color codes before measuring * LogListDetails: adjust size on resize and give logs panel a min width * LogsPanel: add showControls as a dashboard option * virtualization: update test * virtualization: add small test case * processing: update font size * LogListControls: update test * Extract translations * Logs Panel: enable controls by default * LogListContext: update mock * ControlledLogRows: add missing prop * LogLine: remove height ref * LogList: dont touch the debounced function on successive calls * LogLine: update test * LogsPanel: make controls default to false again * LogsPanel: make controls default to false again * LogLineDetails: fix height resizing and make close button sticky * LogLine: memo log component * LogLineDetails: fix close button position * New Logs Panel: Add Popover Menu support (#106394) * LogList: add popover menu support * LogList: test popover menu * Chore: remove unnecessary optional chain op * LogLinedDetails: fix close button position with and without scroll
This commit is contained in:
		
							parent
							
								
									7c3f7b9e8b
								
							
						
					
					
						commit
						db83b4ef17
					
				|  | @ -18,6 +18,7 @@ export interface Options { | |||
|   displayedFields?: Array<string>; | ||||
|   enableInfiniteScrolling?: boolean; | ||||
|   enableLogDetails: boolean; | ||||
|   fontSize?: ('default' | 'small'); | ||||
|   isFilterLabelActive?: unknown; | ||||
|   logLineMenuCustomItems?: unknown; | ||||
|   logRowMenuIconsAfter?: unknown; | ||||
|  |  | |||
|  | @ -72,6 +72,7 @@ export const ControlledLogRows = forwardRef<HTMLDivElement | null, ControlledLog | |||
|         displayedFields={[]} | ||||
|         dedupStrategy={dedupStrategy} | ||||
|         enableLogDetails={false} | ||||
|         fontSize="default" | ||||
|         hasUnescapedContent={hasUnescapedContent} | ||||
|         logOptionsStorageKey={logOptionsStorageKey} | ||||
|         logs={deduplicatedRows ?? []} | ||||
|  |  | |||
|  | @ -74,7 +74,7 @@ export interface Props { | |||
|   renderPreview?: boolean; | ||||
| } | ||||
| 
 | ||||
| type PopoverStateType = { | ||||
| export type PopoverStateType = { | ||||
|   selection: string; | ||||
|   selectedRow: LogRowModel | null; | ||||
|   popoverMenuCoordinates: { x: number; y: number }; | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; | ||||
| import { ReactNode, useCallback, useEffect, useRef, useState, MouseEvent } from 'react'; | ||||
| import { usePrevious } from 'react-use'; | ||||
| import { ListChildComponentProps, ListOnItemsRenderedProps } from 'react-window'; | ||||
| 
 | ||||
|  | @ -26,7 +26,7 @@ interface Props { | |||
|   handleOverflow: (index: number, id: string, height?: number) => void; | ||||
|   loadMore?: (range: AbsoluteTimeRange) => void; | ||||
|   logs: LogListModel[]; | ||||
|   onClick: (log: LogListModel) => void; | ||||
|   onClick: (e: MouseEvent<HTMLElement>, log: LogListModel) => void; | ||||
|   scrollElement: HTMLDivElement | null; | ||||
|   setInitialScrollPosition: () => void; | ||||
|   showTime: boolean; | ||||
|  |  | |||
|  | @ -7,6 +7,7 @@ import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; | |||
| import { createLogLine } from '../__mocks__/logRow'; | ||||
| 
 | ||||
| import { getStyles, LogLine, Props } from './LogLine'; | ||||
| import { LogListFontSize } from './LogList'; | ||||
| import { LogListContextProvider } from './LogListContext'; | ||||
| import { defaultProps } from './__mocks__/LogListContext'; | ||||
| import { LogListModel } from './processing'; | ||||
|  | @ -27,12 +28,14 @@ const contextProps = { | |||
|   sortOrder: LogsSortOrder.Ascending, | ||||
|   wrapLogMessage: false, | ||||
| }; | ||||
| const fontSizes: LogListFontSize[] = ['default', 'small']; | ||||
| 
 | ||||
| describe('LogLine', () => { | ||||
| describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => { | ||||
|   let log: LogListModel, defaultProps: Props; | ||||
|   beforeEach(() => { | ||||
|     log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` }); | ||||
|     contextProps.logs = [log]; | ||||
|     contextProps.fontSize = fontSize; | ||||
|     defaultProps = { | ||||
|       displayedFields: [], | ||||
|       index: 0, | ||||
|  | @ -46,26 +49,42 @@ describe('LogLine', () => { | |||
|   }); | ||||
| 
 | ||||
|   test('Renders a log line', () => { | ||||
|     render(<LogLine {...defaultProps} />); | ||||
|     render( | ||||
|       <LogListContextProvider {...contextProps}> | ||||
|         <LogLine {...defaultProps} /> | ||||
|       </LogListContextProvider> | ||||
|     ); | ||||
|     expect(screen.getByText(log.timestamp)).toBeInTheDocument(); | ||||
|     expect(screen.getByText('log message 1')).toBeInTheDocument(); | ||||
|   }); | ||||
| 
 | ||||
|   test('Renders a log line with no timestamp', () => { | ||||
|     render(<LogLine {...defaultProps} showTime={false} />); | ||||
|     render( | ||||
|       <LogListContextProvider {...contextProps}> | ||||
|         <LogLine {...defaultProps} showTime={false} /> | ||||
|       </LogListContextProvider> | ||||
|     ); | ||||
|     expect(screen.queryByText(log.timestamp)).not.toBeInTheDocument(); | ||||
|     expect(screen.getByText('log message 1')).toBeInTheDocument(); | ||||
|   }); | ||||
| 
 | ||||
|   test('Renders a log line with displayed fields', () => { | ||||
|     render(<LogLine {...defaultProps} displayedFields={['place']} />); | ||||
|     render( | ||||
|       <LogListContextProvider {...contextProps}> | ||||
|         <LogLine {...defaultProps} displayedFields={['place']} /> | ||||
|       </LogListContextProvider> | ||||
|     ); | ||||
|     expect(screen.getByText(log.timestamp)).toBeInTheDocument(); | ||||
|     expect(screen.queryByText(log.body)).not.toBeInTheDocument(); | ||||
|     expect(screen.getByText('luna')).toBeInTheDocument(); | ||||
|   }); | ||||
| 
 | ||||
|   test('Renders a log line with body displayed fields', () => { | ||||
|     render(<LogLine {...defaultProps} displayedFields={['place', LOG_LINE_BODY_FIELD_NAME]} />); | ||||
|     render( | ||||
|       <LogListContextProvider {...contextProps}> | ||||
|         <LogLine {...defaultProps} displayedFields={['place', LOG_LINE_BODY_FIELD_NAME]} /> | ||||
|       </LogListContextProvider> | ||||
|     ); | ||||
|     expect(screen.getByText(log.timestamp)).toBeInTheDocument(); | ||||
|     expect(screen.getByText('log message 1')).toBeInTheDocument(); | ||||
|     expect(screen.getByText('luna')).toBeInTheDocument(); | ||||
|  | @ -143,7 +162,11 @@ describe('LogLine', () => { | |||
| 
 | ||||
|   describe('Log line menu', () => { | ||||
|     test('Renders a log line menu', async () => { | ||||
|       render(<LogLine {...defaultProps} />); | ||||
|       render( | ||||
|         <LogListContextProvider {...contextProps}> | ||||
|           <LogLine {...defaultProps} /> | ||||
|         </LogListContextProvider> | ||||
|       ); | ||||
|       expect(screen.queryByText('Copy log line')).not.toBeInTheDocument(); | ||||
|       await userEvent.click(screen.getByLabelText('Log menu')); | ||||
|       expect(screen.getByText('Copy log line')).toBeInTheDocument(); | ||||
|  | @ -156,7 +179,11 @@ describe('LogLine', () => { | |||
|     }); | ||||
| 
 | ||||
|     test('Highlights relevant tokens in the log line', () => { | ||||
|       render(<LogLine {...defaultProps} log={log} />); | ||||
|       render( | ||||
|         <LogListContextProvider {...contextProps}> | ||||
|           <LogLine {...defaultProps} log={log} /> | ||||
|         </LogListContextProvider> | ||||
|       ); | ||||
|       expect(screen.getByText('place')).toBeInTheDocument(); | ||||
|       expect(screen.getByText('1ms')).toBeInTheDocument(); | ||||
|       expect(screen.getByText('3 KB')).toBeInTheDocument(); | ||||
|  | @ -196,7 +223,11 @@ describe('LogLine', () => { | |||
|     }); | ||||
| 
 | ||||
|     test('Logs are not collapsed by default', () => { | ||||
|       render(<LogLine {...defaultProps} />); | ||||
|       render( | ||||
|         <LogListContextProvider {...contextProps}> | ||||
|           <LogLine {...defaultProps} /> | ||||
|         </LogListContextProvider> | ||||
|       ); | ||||
|       expect(screen.queryByText('show less')).not.toBeInTheDocument(); | ||||
|       expect(screen.queryByText('show more')).not.toBeInTheDocument(); | ||||
|     }); | ||||
|  | @ -204,11 +235,13 @@ describe('LogLine', () => { | |||
|     test('Logs are not collapsible when unwrapped', () => { | ||||
|       log.collapsed = true; | ||||
|       render( | ||||
|         <LogLine | ||||
|           {...defaultProps} | ||||
|           // Unwrapped logs
 | ||||
|           wrapLogMessage={false} | ||||
|         /> | ||||
|         <LogListContextProvider {...contextProps}> | ||||
|           <LogLine | ||||
|             {...defaultProps} | ||||
|             // Unwrapped logs
 | ||||
|             wrapLogMessage={false} | ||||
|           /> | ||||
|         </LogListContextProvider> | ||||
|       ); | ||||
|       expect(screen.queryByText('show less')).not.toBeInTheDocument(); | ||||
|       expect(screen.queryByText('show more')).not.toBeInTheDocument(); | ||||
|  | @ -216,7 +249,11 @@ describe('LogLine', () => { | |||
| 
 | ||||
|     test('Long logs can be collapsed and expanded', async () => { | ||||
|       log.collapsed = true; | ||||
|       render(<LogLine {...defaultProps} log={log} />); | ||||
|       render( | ||||
|         <LogListContextProvider {...contextProps}> | ||||
|           <LogLine {...defaultProps} log={log} /> | ||||
|         </LogListContextProvider> | ||||
|       ); | ||||
|       expect(screen.getByText('show more')).toBeVisible(); | ||||
|       await userEvent.click(screen.getByText('show more')); | ||||
|       expect(await screen.findByText('show less')).toBeInTheDocument(); | ||||
|  | @ -227,7 +264,11 @@ describe('LogLine', () => { | |||
|     test('When the collapsed state changes invokes a callback to update virtualized sizes', async () => { | ||||
|       log.collapsed = true; | ||||
|       const onOverflow = jest.fn(); | ||||
|       render(<LogLine {...defaultProps} onOverflow={onOverflow} log={log} />); | ||||
|       render( | ||||
|         <LogListContextProvider {...contextProps}> | ||||
|           <LogLine {...defaultProps} onOverflow={onOverflow} log={log} /> | ||||
|         </LogListContextProvider> | ||||
|       ); | ||||
|       await userEvent.click(await screen.findByText('show more')); | ||||
|       await userEvent.click(await screen.findByText('show less')); | ||||
|       expect(onOverflow).toHaveBeenCalledTimes(2); | ||||
|  | @ -239,7 +280,11 @@ describe('LogLine', () => { | |||
|       expect(screen.getByText('show more')).toBeVisible(); | ||||
| 
 | ||||
|       log.collapsed = undefined; | ||||
|       rerender(<LogLine {...defaultProps} log={log} />); | ||||
|       rerender( | ||||
|         <LogListContextProvider {...contextProps}> | ||||
|           <LogLine {...defaultProps} log={log} /> | ||||
|         </LogListContextProvider> | ||||
|       ); | ||||
| 
 | ||||
|       expect(screen.queryByText('show more')).not.toBeInTheDocument(); | ||||
|       expect(screen.queryByText('show less')).not.toBeInTheDocument(); | ||||
|  | @ -247,10 +292,18 @@ describe('LogLine', () => { | |||
| 
 | ||||
|     test('Syncs the collapsed state with wrapping changes', async () => { | ||||
|       log.collapsed = true; | ||||
|       const { rerender } = render(<LogLine {...defaultProps} log={log} />); | ||||
|       const { rerender } = render( | ||||
|         <LogListContextProvider {...contextProps}> | ||||
|           <LogLine {...defaultProps} log={log} /> | ||||
|         </LogListContextProvider> | ||||
|       ); | ||||
|       expect(screen.getByText('show more')).toBeVisible(); | ||||
| 
 | ||||
|       rerender(<LogLine {...defaultProps} log={log} wrapLogMessage={false} />); | ||||
|       rerender( | ||||
|         <LogListContextProvider {...contextProps}> | ||||
|           <LogLine {...defaultProps} log={log} wrapLogMessage={false} /> | ||||
|         </LogListContextProvider> | ||||
|       ); | ||||
| 
 | ||||
|       expect(screen.queryByText('show more')).not.toBeInTheDocument(); | ||||
|       expect(screen.queryByText('show less')).not.toBeInTheDocument(); | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| import { css } from '@emotion/css'; | ||||
| import { CSSProperties, useCallback, useEffect, useRef, useState } from 'react'; | ||||
| import { CSSProperties, memo, useCallback, useEffect, useRef, useState, MouseEvent } from 'react'; | ||||
| import tinycolor from 'tinycolor2'; | ||||
| 
 | ||||
| import { GrafanaTheme2, LogsDedupStrategy } from '@grafana/data'; | ||||
|  | @ -17,7 +17,7 @@ import { | |||
|   hasUnderOrOverflow, | ||||
|   getLineHeight, | ||||
|   LogFieldDimension, | ||||
|   TRUNCATION_LINE_COUNT, | ||||
|   getTruncationLineCount, | ||||
| } from './virtualization'; | ||||
| 
 | ||||
| export interface Props { | ||||
|  | @ -27,7 +27,7 @@ export interface Props { | |||
|   showTime: boolean; | ||||
|   style: CSSProperties; | ||||
|   styles: LogLineStyles; | ||||
|   onClick: (log: LogListModel) => void; | ||||
|   onClick: (e: MouseEvent<HTMLElement>, log: LogListModel) => void; | ||||
|   onOverflow?: (index: number, id: string, height?: number) => void; | ||||
|   variant?: 'infinite-scroll'; | ||||
|   wrapLogMessage: boolean; | ||||
|  | @ -45,8 +45,15 @@ export const LogLine = ({ | |||
|   variant, | ||||
|   wrapLogMessage, | ||||
| }: Props) => { | ||||
|   const { detailsDisplayed, dedupStrategy, enableLogDetails, hasLogsWithErrors, hasSampledLogs, onLogLineHover } = | ||||
|     useLogListContext(); | ||||
|   const { | ||||
|     detailsDisplayed, | ||||
|     dedupStrategy, | ||||
|     enableLogDetails, | ||||
|     fontSize, | ||||
|     hasLogsWithErrors, | ||||
|     hasSampledLogs, | ||||
|     onLogLineHover, | ||||
|   } = useLogListContext(); | ||||
|   const [collapsed, setCollapsed] = useState<boolean | undefined>( | ||||
|     wrapLogMessage && log.collapsed !== undefined ? log.collapsed : undefined | ||||
|   ); | ||||
|  | @ -85,16 +92,19 @@ export const LogLine = ({ | |||
|   }, [collapsed, index, log, onOverflow]); | ||||
| 
 | ||||
|   const { t } = useTranslate(); | ||||
|   const handleClick = useCallback(() => { | ||||
|     onClick(log); | ||||
|   }, [log, onClick]); | ||||
|   const handleClick = useCallback( | ||||
|     (e: MouseEvent<HTMLElement>) => { | ||||
|       onClick(e, log); | ||||
|     }, | ||||
|     [log, onClick] | ||||
|   ); | ||||
| 
 | ||||
|   const detailsShown = detailsDisplayed(log); | ||||
| 
 | ||||
|   return ( | ||||
|     <div style={style}> | ||||
|       <div | ||||
|         className={`${styles.logLine} ${variant ?? ''} ${pinned ? styles.pinnedLogLine : ''} ${permalinked ? styles.permalinkedLogLine : ''} ${detailsShown ? styles.detailsDisplayed : ''}`} | ||||
|         className={`${styles.logLine} ${variant ?? ''} ${pinned ? styles.pinnedLogLine : ''} ${permalinked ? styles.permalinkedLogLine : ''} ${detailsShown ? styles.detailsDisplayed : ''} ${fontSize === 'small' ? styles.fontSizeSmall : ''}`} | ||||
|         ref={onOverflow ? logLineRef : undefined} | ||||
|         onMouseEnter={handleMouseOver} | ||||
|         onFocus={handleMouseOver} | ||||
|  | @ -143,6 +153,7 @@ export const LogLine = ({ | |||
|         {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */} | ||||
|         <div | ||||
|           className={`${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`} ${collapsed === true ? styles.collapsedLogLine : ''} ${enableLogDetails ? styles.clickable : ''}`} | ||||
|           style={collapsed ? { maxHeight: `${getTruncationLineCount() * getLineHeight()}px` } : undefined} | ||||
|           onClick={handleClick} | ||||
|         > | ||||
|           <Log | ||||
|  | @ -192,7 +203,7 @@ interface LogProps { | |||
|   wrapLogMessage: boolean; | ||||
| } | ||||
| 
 | ||||
| const Log = ({ displayedFields, log, showTime, styles, wrapLogMessage }: LogProps) => { | ||||
| const Log = memo(({ displayedFields, log, showTime, styles, wrapLogMessage }: LogProps) => { | ||||
|   return ( | ||||
|     <> | ||||
|       {showTime && <span className={`${styles.timestamp} level-${log.logLevel} field`}>{log.timestamp}</span>} | ||||
|  | @ -217,7 +228,9 @@ const Log = ({ displayedFields, log, showTime, styles, wrapLogMessage }: LogProp | |||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| }); | ||||
| 
 | ||||
| Log.displayName = 'Log'; | ||||
| 
 | ||||
| const LogLineBody = ({ log }: { log: LogListModel }) => { | ||||
|   const { syntaxHighlighting } = useLogListContext(); | ||||
|  | @ -266,6 +279,7 @@ export const getStyles = (theme: GrafanaTheme2) => { | |||
|       flexDirection: 'row', | ||||
|       fontFamily: theme.typography.fontFamilyMonospace, | ||||
|       fontSize: theme.typography.fontSize, | ||||
|       lineHeight: theme.typography.body.lineHeight, | ||||
|       wordBreak: 'break-all', | ||||
|       '&:hover': { | ||||
|         background: theme.isDark ? `hsla(0, 0%, 0%, 0.3)` : `hsla(0, 0%, 0%, 0.1)`, | ||||
|  | @ -316,6 +330,10 @@ export const getStyles = (theme: GrafanaTheme2) => { | |||
|         color: theme.colors.text.primary, | ||||
|       }, | ||||
|     }), | ||||
|     fontSizeSmall: css({ | ||||
|       fontSize: theme.typography.bodySmall.fontSize, | ||||
|       lineHeight: theme.typography.bodySmall.lineHeight, | ||||
|     }), | ||||
|     detailsDisplayed: css({ | ||||
|       background: theme.isDark ? `hsla(0, 0%, 0%, 0.5)` : `hsla(0, 0%, 0%, 0.1)`, | ||||
|     }), | ||||
|  | @ -415,7 +433,6 @@ export const getStyles = (theme: GrafanaTheme2) => { | |||
|       }, | ||||
|     }), | ||||
|     collapsedLogLine: css({ | ||||
|       maxHeight: `${TRUNCATION_LINE_COUNT * getLineHeight()}px`, | ||||
|       overflow: 'hidden', | ||||
|     }), | ||||
|     expandCollapseControl: css({ | ||||
|  |  | |||
|  | @ -12,6 +12,7 @@ import { getLogRowStyles } from '../getLogRowStyles'; | |||
| 
 | ||||
| import { useLogListContext } from './LogListContext'; | ||||
| import { LogListModel } from './processing'; | ||||
| import { LOG_LIST_MIN_WIDTH } from './virtualization'; | ||||
| 
 | ||||
| interface Props { | ||||
|   containerElement: HTMLDivElement; | ||||
|  | @ -51,44 +52,50 @@ export const LogLineDetails = ({ containerElement, getFieldLinks, logs, onResize | |||
|     onResize(); | ||||
|   }, [onResize, setDetailsWidth]); | ||||
| 
 | ||||
|   const maxWidth = containerElement.clientWidth - LOG_LIST_MIN_WIDTH; | ||||
| 
 | ||||
|   return ( | ||||
|     <Resizable | ||||
|       onResize={handleResize} | ||||
|       handleClasses={{ left: dragStyles.dragHandleVertical }} | ||||
|       defaultSize={{ width: detailsWidth, height: containerElement.clientHeight }} | ||||
|       size={{ width: detailsWidth, height: containerElement.clientHeight }} | ||||
|       enable={{ left: true }} | ||||
|       minWidth={40} | ||||
|       maxWidth={maxWidth} | ||||
|     > | ||||
|       <div className={styles.container} ref={containerRef}> | ||||
|         <IconButton | ||||
|           name="times" | ||||
|           className={styles.closeIcon} | ||||
|           aria-label={t('logs.log-details.close', 'Close log details')} | ||||
|           onClick={closeDetails} | ||||
|         /> | ||||
|         <table width="100%"> | ||||
|           <tbody> | ||||
|             <LogDetails | ||||
|               getRows={getRows} | ||||
|               mode="sidebar" | ||||
|               row={showDetails[0]} | ||||
|               showDuplicates={false} | ||||
|               styles={logRowsStyles} | ||||
|               wrapLogMessage={wrapLogMessage} | ||||
|               onPinLine={onPinLine} | ||||
|               getFieldLinks={getFieldLinks} | ||||
|               onClickFilterLabel={onClickFilterLabel} | ||||
|               onClickFilterOutLabel={onClickFilterOutLabel} | ||||
|               onClickShowField={onClickShowField} | ||||
|               onClickHideField={onClickHideField} | ||||
|               hasError={showDetails[0].hasError} | ||||
|               displayedFields={displayedFields} | ||||
|               app={app} | ||||
|               isFilterLabelActive={isLabelFilterActive} | ||||
|               pinLineButtonTooltipTitle={pinLineButtonTooltipTitle} | ||||
|             /> | ||||
|           </tbody> | ||||
|         </table> | ||||
|         <div className={styles.scrollContainer}> | ||||
|           <IconButton | ||||
|             name="times" | ||||
|             className={styles.closeIcon} | ||||
|             aria-label={t('logs.log-details.close', 'Close log details')} | ||||
|             onClick={closeDetails} | ||||
|           /> | ||||
|           <table width="100%"> | ||||
|             <tbody> | ||||
|               <LogDetails | ||||
|                 getRows={getRows} | ||||
|                 mode="sidebar" | ||||
|                 row={showDetails[0]} | ||||
|                 showDuplicates={false} | ||||
|                 styles={logRowsStyles} | ||||
|                 wrapLogMessage={wrapLogMessage} | ||||
|                 onPinLine={onPinLine} | ||||
|                 getFieldLinks={getFieldLinks} | ||||
|                 onClickFilterLabel={onClickFilterLabel} | ||||
|                 onClickFilterOutLabel={onClickFilterOutLabel} | ||||
|                 onClickShowField={onClickShowField} | ||||
|                 onClickHideField={onClickHideField} | ||||
|                 hasError={showDetails[0].hasError} | ||||
|                 displayedFields={displayedFields} | ||||
|                 app={app} | ||||
|                 isFilterLabelActive={isLabelFilterActive} | ||||
|                 pinLineButtonTooltipTitle={pinLineButtonTooltipTitle} | ||||
|               /> | ||||
|             </tbody> | ||||
|           </table> | ||||
|         </div> | ||||
|       </div> | ||||
|     </Resizable> | ||||
|   ); | ||||
|  | @ -96,6 +103,10 @@ export const LogLineDetails = ({ containerElement, getFieldLinks, logs, onResize | |||
| 
 | ||||
| const getStyles = (theme: GrafanaTheme2) => ({ | ||||
|   container: css({ | ||||
|     overflow: 'auto', | ||||
|     height: '100%', | ||||
|   }), | ||||
|   scrollContainer: css({ | ||||
|     overflow: 'auto', | ||||
|     position: 'relative', | ||||
|     height: '100%', | ||||
|  |  | |||
|  | @ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; | |||
| 
 | ||||
| import { CoreApp, getDefaultTimeRange, LogRowModel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; | ||||
| 
 | ||||
| import { disablePopoverMenu, enablePopoverMenu, isPopoverMenuDisabled } from '../../utils'; | ||||
| import { createLogRow } from '../__mocks__/logRow'; | ||||
| 
 | ||||
| import { LogList, Props } from './LogList'; | ||||
|  | @ -11,9 +12,23 @@ jest.mock('@grafana/runtime', () => { | |||
|   return { | ||||
|     ...jest.requireActual('@grafana/runtime'), | ||||
|     usePluginLinks: jest.fn().mockReturnValue({ links: [] }), | ||||
|     config: { | ||||
|       ...jest.requireActual('@grafana/runtime').config, | ||||
|       featureToggles: { | ||||
|         ...jest.requireActual('@grafana/runtime').config.featureToggles, | ||||
|         logRowsPopoverMenu: true, | ||||
|       }, | ||||
|     }, | ||||
|   }; | ||||
| }); | ||||
| 
 | ||||
| jest.mock('../../utils', () => ({ | ||||
|   ...jest.requireActual('../../utils'), | ||||
|   isPopoverMenuDisabled: jest.fn(), | ||||
|   disablePopoverMenu: jest.fn(), | ||||
|   enablePopoverMenu: jest.fn(), | ||||
| })); | ||||
| 
 | ||||
| describe('LogList', () => { | ||||
|   let logs: LogRowModel[], defaultProps: Props; | ||||
|   beforeEach(() => { | ||||
|  | @ -26,7 +41,7 @@ describe('LogList', () => { | |||
|       containerElement: document.createElement('div'), | ||||
|       dedupStrategy: LogsDedupStrategy.none, | ||||
|       displayedFields: [], | ||||
|       enableLogDetails: false, | ||||
|       enableLogDetails: true, | ||||
|       logs, | ||||
|       showControls: false, | ||||
|       showTime: false, | ||||
|  | @ -114,4 +129,107 @@ describe('LogList', () => { | |||
| 
 | ||||
|     spy.mockRestore(); | ||||
|   }); | ||||
| 
 | ||||
|   describe('Popover menu', () => { | ||||
|     function setup(overrides: Partial<Props> = {}) { | ||||
|       return render( | ||||
|         <LogList {...defaultProps} onClickFilterString={jest.fn()} onClickFilterOutString={jest.fn()} {...overrides} /> | ||||
|       ); | ||||
|     } | ||||
|     let orgGetSelection: () => Selection | null; | ||||
|     beforeEach(() => { | ||||
|       jest.mocked(isPopoverMenuDisabled).mockReturnValue(false); | ||||
|     }); | ||||
|     beforeAll(() => { | ||||
|       orgGetSelection = document.getSelection; | ||||
|       jest.spyOn(document, 'getSelection').mockReturnValue({ | ||||
|         toString: () => 'selected log line', | ||||
|         removeAllRanges: () => {}, | ||||
|         addRange: (range: Range) => {}, | ||||
|       } as Selection); | ||||
|     }); | ||||
|     afterAll(() => { | ||||
|       document.getSelection = orgGetSelection; | ||||
|     }); | ||||
|     it('Does not appear in the document', () => { | ||||
|       setup(); | ||||
|       expect(screen.queryByText('Copy selection')).not.toBeInTheDocument(); | ||||
|     }); | ||||
|     it('Appears after selecting text', async () => { | ||||
|       setup(); | ||||
|       await userEvent.click(screen.getByText('log message 1')); | ||||
|       expect(screen.getByText('Copy selection')).toBeInTheDocument(); | ||||
|       expect(screen.getByText('Add as line contains filter')).toBeInTheDocument(); | ||||
|       expect(screen.getByText('Add as line does not contain filter')).toBeInTheDocument(); | ||||
|     }); | ||||
|     it('Can be disabled', async () => { | ||||
|       setup(); | ||||
|       await userEvent.click(screen.getByText('log message 1')); | ||||
|       await userEvent.click(screen.getByText('Disable menu')); | ||||
|       await userEvent.click(screen.getByText('Confirm')); | ||||
|       expect(disablePopoverMenu).toHaveBeenCalledTimes(1); | ||||
|     }); | ||||
|     it('Does not appear when disabled', async () => { | ||||
|       jest.mocked(isPopoverMenuDisabled).mockReturnValue(true); | ||||
|       setup(); | ||||
|       await userEvent.click(screen.getByText('log message 1')); | ||||
|       expect(screen.queryByText('Copy selection')).not.toBeInTheDocument(); | ||||
|     }); | ||||
|     it('Can be re-enabled', async () => { | ||||
|       jest.mocked(isPopoverMenuDisabled).mockReturnValue(true); | ||||
|       const user = userEvent.setup(); | ||||
|       setup(); | ||||
|       await user.keyboard('[AltLeft>]'); // Press Alt (without releasing it)
 | ||||
|       await user.click(screen.getByText('log message 1')); | ||||
|       expect(enablePopoverMenu).toHaveBeenCalledTimes(1); | ||||
|     }); | ||||
|     it('Does not appear when the props are not defined', async () => { | ||||
|       setup({ | ||||
|         onClickFilterOutString: undefined, | ||||
|         onClickFilterString: undefined, | ||||
|       }); | ||||
|       await userEvent.click(screen.getByText('log message 1')); | ||||
|       expect(screen.queryByText('Copy selection')).not.toBeInTheDocument(); | ||||
|     }); | ||||
|     it('Appears after selecting text', async () => { | ||||
|       const onClickFilterOutString = jest.fn(); | ||||
|       const onClickFilterString = jest.fn(); | ||||
|       setup({ | ||||
|         onClickFilterOutString, | ||||
|         onClickFilterString, | ||||
|       }); | ||||
|       await userEvent.click(screen.getByText('log message 1')); | ||||
|       expect(screen.getByText('Copy selection')).toBeInTheDocument(); | ||||
|       await userEvent.click(screen.getByText('Add as line contains filter')); | ||||
| 
 | ||||
|       await userEvent.click(screen.getByText('log message 1')); | ||||
|       expect(screen.getByText('Copy selection')).toBeInTheDocument(); | ||||
|       await userEvent.click(screen.getByText('Add as line does not contain filter')); | ||||
| 
 | ||||
|       expect(onClickFilterOutString).toHaveBeenCalledTimes(1); | ||||
|       expect(onClickFilterString).toHaveBeenCalledTimes(1); | ||||
|     }); | ||||
|     describe('Interacting with log details', () => { | ||||
|       it('Allows text selection even if the popover menu is not available', async () => { | ||||
|         setup({ | ||||
|           onClickFilterOutString: undefined, | ||||
|           onClickFilterString: undefined, | ||||
|         }); | ||||
|         await userEvent.click(screen.getByText('log message 1')); | ||||
|         expect(screen.queryByText('Copy selection')).not.toBeInTheDocument(); | ||||
|         expect(screen.queryByText(/details/)).not.toBeInTheDocument(); | ||||
|       }); | ||||
| 
 | ||||
|       it('Displays Log Details if there is no text selection', async () => { | ||||
|         jest.spyOn(document, 'getSelection').mockReturnValue(null); | ||||
|         setup({ | ||||
|           onClickFilterOutString: undefined, | ||||
|           onClickFilterString: undefined, | ||||
|         }); | ||||
|         await userEvent.click(screen.getByText('log message 1')); | ||||
|         expect(screen.queryByText('Copy selection')).not.toBeInTheDocument(); | ||||
|         expect(screen.getByText(/Fields/)).toBeInTheDocument(); | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -1,7 +1,7 @@ | |||
| import { css } from '@emotion/css'; | ||||
| import { debounce } from 'lodash'; | ||||
| import { Grammar } from 'prismjs'; | ||||
| import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; | ||||
| import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, MouseEvent } from 'react'; | ||||
| import { VariableSizeList } from 'react-window'; | ||||
| 
 | ||||
| import { | ||||
|  | @ -10,6 +10,7 @@ import { | |||
|   DataFrame, | ||||
|   EventBus, | ||||
|   EventBusSrv, | ||||
|   GrafanaTheme2, | ||||
|   LogLevel, | ||||
|   LogRowModel, | ||||
|   LogsDedupStrategy, | ||||
|  | @ -18,7 +19,9 @@ import { | |||
|   store, | ||||
|   TimeRange, | ||||
| } from '@grafana/data'; | ||||
| import { PopoverContent, useTheme2 } from '@grafana/ui'; | ||||
| import { Trans, useTranslate } from '@grafana/i18n'; | ||||
| import { ConfirmModal, Icon, PopoverContent, useTheme2 } from '@grafana/ui'; | ||||
| import { PopoverMenu } from 'app/features/explore/Logs/PopoverMenu'; | ||||
| import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; | ||||
| 
 | ||||
| import { InfiniteScroll } from './InfiniteScroll'; | ||||
|  | @ -28,6 +31,7 @@ import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu'; | |||
| import { LogListContextProvider, LogListState, useLogListContext } from './LogListContext'; | ||||
| import { LogListControls } from './LogListControls'; | ||||
| import { preProcessLogs, LogListModel } from './processing'; | ||||
| import { usePopoverMenu } from './usePopoverMenu'; | ||||
| import { | ||||
|   calculateFieldDimensions, | ||||
|   getLogLineSize, | ||||
|  | @ -46,6 +50,7 @@ export interface Props { | |||
|   enableLogDetails: boolean; | ||||
|   eventBus?: EventBus; | ||||
|   filterLevels?: LogLevel[]; | ||||
|   fontSize?: LogListFontSize; | ||||
|   getFieldLinks?: GetFieldLinksFn; | ||||
|   getRowContextQuery?: GetRowContextQueryFn; | ||||
|   grammar?: Grammar; | ||||
|  | @ -82,6 +87,8 @@ export interface Props { | |||
|   wrapLogMessage: boolean; | ||||
| } | ||||
| 
 | ||||
| export type LogListFontSize = 'default' | 'small'; | ||||
| 
 | ||||
| export type LogListControlOptions = LogListState; | ||||
| 
 | ||||
| type LogListComponentProps = Omit< | ||||
|  | @ -105,6 +112,8 @@ export const LogList = ({ | |||
|   enableLogDetails, | ||||
|   eventBus, | ||||
|   filterLevels, | ||||
|   logOptionsStorageKey, | ||||
|   fontSize = logOptionsStorageKey ? (store.get(`${logOptionsStorageKey}.fontSize`) ?? 'default') : 'default', | ||||
|   getFieldLinks, | ||||
|   getRowContextQuery, | ||||
|   grammar, | ||||
|  | @ -113,7 +122,6 @@ export const LogList = ({ | |||
|   loading, | ||||
|   loadMore, | ||||
|   logLineMenuCustomItems, | ||||
|   logOptionsStorageKey, | ||||
|   logs, | ||||
|   logsMeta, | ||||
|   logSupportsContext, | ||||
|  | @ -148,6 +156,7 @@ export const LogList = ({ | |||
|       displayedFields={displayedFields} | ||||
|       enableLogDetails={enableLogDetails} | ||||
|       filterLevels={filterLevels} | ||||
|       fontSize={fontSize} | ||||
|       getRowContextQuery={getRowContextQuery} | ||||
|       isLabelFilterActive={isLabelFilterActive} | ||||
|       logs={logs} | ||||
|  | @ -211,9 +220,12 @@ const LogListComponent = ({ | |||
|     displayedFields, | ||||
|     dedupStrategy, | ||||
|     filterLevels, | ||||
|     fontSize, | ||||
|     forceEscape, | ||||
|     hasLogsWithErrors, | ||||
|     hasSampledLogs, | ||||
|     onClickFilterString, | ||||
|     onClickFilterOutString, | ||||
|     permalinkedLogId, | ||||
|     showDetails, | ||||
|     showTime, | ||||
|  | @ -236,8 +248,18 @@ const LogListComponent = ({ | |||
|     () => (wrapLogMessage ? [] : calculateFieldDimensions(processedLogs, displayedFields)), | ||||
|     [displayedFields, processedLogs, wrapLogMessage] | ||||
|   ); | ||||
|   const styles = getStyles(dimensions, { showTime }); | ||||
|   const styles = getStyles(dimensions, { showTime }, theme); | ||||
|   const widthContainer = wrapperRef.current ?? containerElement; | ||||
|   const { | ||||
|     closePopoverMenu, | ||||
|     handleTextSelection, | ||||
|     onDisableCancel, | ||||
|     onDisableConfirm, | ||||
|     onDisablePopoverMenu, | ||||
|     popoverState, | ||||
|     showDisablePopoverOptions, | ||||
|   } = usePopoverMenu(wrapperRef.current); | ||||
|   const { t } = useTranslate(); | ||||
| 
 | ||||
|   const debouncedResetAfterIndex = useMemo(() => { | ||||
|     return debounce((index: number) => { | ||||
|  | @ -247,8 +269,8 @@ const LogListComponent = ({ | |||
|   }, []); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     initVirtualization(theme); | ||||
|   }, [theme]); | ||||
|     initVirtualization(theme, fontSize); | ||||
|   }, [fontSize, theme]); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     const subscription = eventBus.subscribe(ScrollToLogsEvent, (e: ScrollToLogsEvent) => | ||||
|  | @ -299,12 +321,15 @@ const LogListComponent = ({ | |||
|   const handleOverflow = useCallback( | ||||
|     (index: number, id: string, height?: number) => { | ||||
|       if (height !== undefined) { | ||||
|         storeLogLineSize(id, widthContainer, height); | ||||
|         storeLogLineSize(id, widthContainer, height, fontSize); | ||||
|       } | ||||
|       if (index === overflowIndexRef.current) { | ||||
|         return; | ||||
|       } | ||||
|       overflowIndexRef.current = index < overflowIndexRef.current ? index : overflowIndexRef.current; | ||||
|       debouncedResetAfterIndex(overflowIndexRef.current); | ||||
|     }, | ||||
|     [debouncedResetAfterIndex, widthContainer] | ||||
|     [debouncedResetAfterIndex, fontSize, widthContainer] | ||||
|   ); | ||||
| 
 | ||||
|   const handleScrollPosition = useCallback(() => { | ||||
|  | @ -324,14 +349,14 @@ const LogListComponent = ({ | |||
|   } | ||||
| 
 | ||||
|   const handleLogLineClick = useCallback( | ||||
|     (log: LogListModel) => { | ||||
|       // Let people select text
 | ||||
|       if (document.getSelection()?.toString()) { | ||||
|     (e: MouseEvent<HTMLElement>, log: LogListModel) => { | ||||
|       if (handleTextSelection(e, log)) { | ||||
|         // Event handled by the parent.
 | ||||
|         return; | ||||
|       } | ||||
|       toggleDetails(log); | ||||
|     }, | ||||
|     [toggleDetails] | ||||
|     [handleTextSelection, toggleDetails] | ||||
|   ); | ||||
| 
 | ||||
|   const handleLogDetailsResize = useCallback(() => { | ||||
|  | @ -347,6 +372,39 @@ const LogListComponent = ({ | |||
|   return ( | ||||
|     <div className={styles.logListContainer}> | ||||
|       <div className={styles.logListWrapper} ref={wrapperRef}> | ||||
|         {popoverState.selection && popoverState.selectedRow && ( | ||||
|           <PopoverMenu | ||||
|             close={closePopoverMenu} | ||||
|             row={popoverState.selectedRow} | ||||
|             selection={popoverState.selection} | ||||
|             {...popoverState.popoverMenuCoordinates} | ||||
|             onClickFilterString={onClickFilterString} | ||||
|             onClickFilterOutString={onClickFilterOutString} | ||||
|             onDisable={onDisablePopoverMenu} | ||||
|           /> | ||||
|         )} | ||||
|         {showDisablePopoverOptions && ( | ||||
|           <ConfirmModal | ||||
|             isOpen | ||||
|             title={t('logs.log-rows.disable-popover.title', 'Disable menu')} | ||||
|             body={ | ||||
|               <> | ||||
|                 <Trans i18nKey="logs.log-rows.disable-popover.message"> | ||||
|                   You are about to disable the logs filter menu. To re-enable it, select text in a log line while | ||||
|                   holding the alt key. | ||||
|                 </Trans> | ||||
|                 <div className={styles.shortcut}> | ||||
|                   <Icon name="keyboard" /> | ||||
|                   <Trans i18nKey="logs.log-rows.disable-popover-message.shortcut">alt+select to enable again</Trans> | ||||
|                 </div> | ||||
|               </> | ||||
|             } | ||||
|             confirmText={t('logs.log-rows.disable-popover.confirm', 'Confirm')} | ||||
|             icon="exclamation-triangle" | ||||
|             onConfirm={onDisableConfirm} | ||||
|             onDismiss={onDisableCancel} | ||||
|           /> | ||||
|         )} | ||||
|         <InfiniteScroll | ||||
|           displayedFields={displayedFields} | ||||
|           handleOverflow={handleOverflow} | ||||
|  | @ -367,6 +425,7 @@ const LogListComponent = ({ | |||
|               height={listHeight} | ||||
|               itemCount={itemCount} | ||||
|               itemSize={getLogLineSize.bind(null, filteredLogs, widthContainer, displayedFields, { | ||||
|                 fontSize, | ||||
|                 hasLogsWithErrors, | ||||
|                 hasSampledLogs, | ||||
|                 showDuplicates: dedupStrategy !== LogsDedupStrategy.none, | ||||
|  | @ -400,7 +459,7 @@ const LogListComponent = ({ | |||
|   ); | ||||
| }; | ||||
| 
 | ||||
| function getStyles(dimensions: LogFieldDimension[], { showTime }: { showTime: boolean }) { | ||||
| function getStyles(dimensions: LogFieldDimension[], { showTime }: { showTime: boolean }, theme: GrafanaTheme2) { | ||||
|   const columns = showTime ? dimensions : dimensions.filter((_, index) => index > 0); | ||||
|   return { | ||||
|     logList: css({ | ||||
|  | @ -411,9 +470,21 @@ function getStyles(dimensions: LogFieldDimension[], { showTime }: { showTime: bo | |||
|     }), | ||||
|     logListContainer: css({ | ||||
|       display: 'flex', | ||||
|       // Minimum width to prevent rendering issues and a sausage-like logs panel.
 | ||||
|       minWidth: theme.spacing(35), | ||||
|     }), | ||||
|     logListWrapper: css({ | ||||
|       width: '100%', | ||||
|       position: 'relative', | ||||
|     }), | ||||
|     shortcut: css({ | ||||
|       display: 'inline-flex', | ||||
|       alignItems: 'center', | ||||
|       gap: theme.spacing(1), | ||||
|       color: theme.colors.text.secondary, | ||||
|       opacity: 0.7, | ||||
|       fontSize: theme.typography.bodySmall.fontSize, | ||||
|       marginTop: theme.spacing(1), | ||||
|     }), | ||||
|   }; | ||||
| } | ||||
|  |  | |||
|  | @ -1,3 +1,4 @@ | |||
| import { debounce } from 'lodash'; | ||||
| import { | ||||
|   createContext, | ||||
|   Dispatch, | ||||
|  | @ -26,7 +27,9 @@ import { PopoverContent } from '@grafana/ui'; | |||
| import { DownloadFormat, checkLogsError, checkLogsSampled, downloadLogs as download } from '../../utils'; | ||||
| 
 | ||||
| import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu'; | ||||
| import { LogListFontSize } from './LogList'; | ||||
| import { LogListModel } from './processing'; | ||||
| import { LOG_LIST_MIN_WIDTH } from './virtualization'; | ||||
| 
 | ||||
| export interface LogListContextData extends Omit<Props, 'containerElement' | 'logs' | 'logsMeta' | 'showControls'> { | ||||
|   closeDetails: () => void; | ||||
|  | @ -42,6 +45,7 @@ export interface LogListContextData extends Omit<Props, 'containerElement' | 'lo | |||
|   setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => void; | ||||
|   setDetailsWidth: (width: number) => void; | ||||
|   setFilterLevels: (filterLevels: LogLevel[]) => void; | ||||
|   setFontSize: (size: LogListFontSize) => void; | ||||
|   setForceEscape: (forceEscape: boolean) => void; | ||||
|   setLogListState: Dispatch<SetStateAction<LogListState>>; | ||||
|   setPinnedLogs: (pinnedlogs: string[]) => void; | ||||
|  | @ -65,10 +69,12 @@ export const LogListContext = createContext<LogListContextData>({ | |||
|   downloadLogs: () => {}, | ||||
|   enableLogDetails: false, | ||||
|   filterLevels: [], | ||||
|   fontSize: 'default', | ||||
|   hasUnescapedContent: false, | ||||
|   setDedupStrategy: () => {}, | ||||
|   setDetailsWidth: () => {}, | ||||
|   setFilterLevels: () => {}, | ||||
|   setFontSize: () => {}, | ||||
|   setForceEscape: () => {}, | ||||
|   setLogListState: () => {}, | ||||
|   setPinnedLogs: () => {}, | ||||
|  | @ -108,6 +114,7 @@ export const useLogIsPermalinked = (log: LogListModel) => { | |||
| export type LogListState = Pick< | ||||
|   LogListContextData, | ||||
|   | 'dedupStrategy' | ||||
|   | 'fontSize' | ||||
|   | 'forceEscape' | ||||
|   | 'filterLevels' | ||||
|   | 'hasUnescapedContent' | ||||
|  | @ -123,11 +130,13 @@ export type LogListState = Pick< | |||
| export interface Props { | ||||
|   app: CoreApp; | ||||
|   children?: ReactNode; | ||||
|   // Only ControlledLogRows can send an undefined containerElement. See LogList.tsx
 | ||||
|   containerElement?: HTMLDivElement; | ||||
|   dedupStrategy: LogsDedupStrategy; | ||||
|   displayedFields: string[]; | ||||
|   enableLogDetails: boolean; | ||||
|   filterLevels?: LogLevel[]; | ||||
|   fontSize: LogListFontSize; | ||||
|   forceEscape?: boolean; | ||||
|   hasUnescapedContent?: boolean; | ||||
|   getRowContextQuery?: GetRowContextQueryFn; | ||||
|  | @ -169,6 +178,7 @@ export const LogListContextProvider = ({ | |||
|   dedupStrategy, | ||||
|   displayedFields, | ||||
|   filterLevels, | ||||
|   fontSize, | ||||
|   forceEscape = false, | ||||
|   hasUnescapedContent, | ||||
|   isLabelFilterActive, | ||||
|  | @ -205,6 +215,7 @@ export const LogListContextProvider = ({ | |||
|     dedupStrategy, | ||||
|     filterLevels: | ||||
|       filterLevels ?? (logOptionsStorageKey ? store.getObject(`${logOptionsStorageKey}.filterLevels`, []) : []), | ||||
|     fontSize, | ||||
|     forceEscape, | ||||
|     hasUnescapedContent, | ||||
|     pinnedLogs, | ||||
|  | @ -216,6 +227,7 @@ export const LogListContextProvider = ({ | |||
|     wrapLogMessage, | ||||
|   }); | ||||
|   const [showDetails, setShowDetails] = useState<LogListModel[]>([]); | ||||
|   const [detailsWidth, setDetailsWidthState] = useState(getDetailsWidth(containerElement, logOptionsStorageKey)); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     // Props are updated in the context only of the panel is being externally controlled.
 | ||||
|  | @ -254,6 +266,10 @@ export const LogListContextProvider = ({ | |||
|     } | ||||
|   }, [filterLevels, logListState]); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     setLogListState((logListState) => ({ ...logListState, fontSize })); | ||||
|   }, [fontSize]); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (logListState.hasUnescapedContent !== hasUnescapedContent) { | ||||
|       setLogListState({ ...logListState, hasUnescapedContent }); | ||||
|  | @ -278,6 +294,17 @@ export const LogListContextProvider = ({ | |||
|     } | ||||
|   }, [logs, showDetails]); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     const handleResize = debounce(() => { | ||||
|       setDetailsWidthState((detailsWidth) => getDetailsWidth(containerElement, logOptionsStorageKey, detailsWidth)); | ||||
|     }, 50); | ||||
|     handleResize(); | ||||
|     window.addEventListener('resize', handleResize); | ||||
|     return () => { | ||||
|       window.removeEventListener('resize', handleResize); | ||||
|     }; | ||||
|   }, [containerElement, logOptionsStorageKey]); | ||||
| 
 | ||||
|   const detailsDisplayed = useCallback( | ||||
|     (log: LogListModel) => !!showDetails.find((shownLog) => shownLog.uid === log.uid), | ||||
|     [showDetails] | ||||
|  | @ -291,6 +318,16 @@ export const LogListContextProvider = ({ | |||
|     [logListState, onLogOptionsChange] | ||||
|   ); | ||||
| 
 | ||||
|   const setFontSize = useCallback( | ||||
|     (fontSize: LogListFontSize) => { | ||||
|       if (logOptionsStorageKey) { | ||||
|         store.set(`${logOptionsStorageKey}.fontSize`, fontSize); | ||||
|       } | ||||
|       setLogListState((logListState) => ({ ...logListState, fontSize })); | ||||
|     }, | ||||
|     [logOptionsStorageKey] | ||||
|   ); | ||||
| 
 | ||||
|   const setForceEscape = useCallback( | ||||
|     (forceEscape: boolean) => { | ||||
|       setLogListState({ ...logListState, forceEscape }); | ||||
|  | @ -413,22 +450,24 @@ export const LogListContextProvider = ({ | |||
| 
 | ||||
|   const setDetailsWidth = useCallback( | ||||
|     (width: number) => { | ||||
|       if (!logOptionsStorageKey) { | ||||
|       if (!logOptionsStorageKey || !containerElement) { | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       const maxWidth = containerElement.clientWidth - LOG_LIST_MIN_WIDTH; | ||||
|       if (width > maxWidth) { | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       store.set(`${logOptionsStorageKey}.detailsWidth`, width); | ||||
|       setDetailsWidthState(width); | ||||
|     }, | ||||
|     [logOptionsStorageKey] | ||||
|     [containerElement, logOptionsStorageKey] | ||||
|   ); | ||||
| 
 | ||||
|   const hasLogsWithErrors = useMemo(() => logs.some((log) => !!checkLogsError(log)), [logs]); | ||||
|   const hasSampledLogs = useMemo(() => logs.some((log) => !!checkLogsSampled(log)), [logs]); | ||||
| 
 | ||||
|   const defaultWidth = (containerElement?.clientWidth ?? 0) * 0.4; | ||||
|   const detailsWidth = logOptionsStorageKey | ||||
|     ? parseInt(store.get(`${logOptionsStorageKey}.detailsWidth`), 10) | ||||
|     : defaultWidth; | ||||
| 
 | ||||
|   return ( | ||||
|     <LogListContext.Provider | ||||
|       value={{ | ||||
|  | @ -436,11 +475,12 @@ export const LogListContextProvider = ({ | |||
|         closeDetails, | ||||
|         detailsDisplayed, | ||||
|         dedupStrategy: logListState.dedupStrategy, | ||||
|         detailsWidth: detailsWidth || defaultWidth, | ||||
|         detailsWidth, | ||||
|         displayedFields, | ||||
|         downloadLogs, | ||||
|         enableLogDetails, | ||||
|         filterLevels: logListState.filterLevels, | ||||
|         fontSize: logListState.fontSize, | ||||
|         forceEscape: logListState.forceEscape, | ||||
|         hasLogsWithErrors, | ||||
|         hasSampledLogs, | ||||
|  | @ -467,6 +507,7 @@ export const LogListContextProvider = ({ | |||
|         setDedupStrategy, | ||||
|         setDetailsWidth, | ||||
|         setFilterLevels, | ||||
|         setFontSize, | ||||
|         setForceEscape, | ||||
|         setLogListState, | ||||
|         setPinnedLogs, | ||||
|  | @ -502,3 +543,26 @@ export function isDedupStrategy(value: unknown): value is LogsDedupStrategy { | |||
|     value === LogsDedupStrategy.signature | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| // Only ControlledLogRows can send an undefined containerElement. See LogList.tsx
 | ||||
| function getDetailsWidth( | ||||
|   containerElement: HTMLDivElement | undefined, | ||||
|   logOptionsStorageKey?: string, | ||||
|   currentWidth?: number | ||||
| ) { | ||||
|   if (!containerElement) { | ||||
|     return 0; | ||||
|   } | ||||
|   const defaultWidth = containerElement.clientWidth * 0.4; | ||||
|   const detailsWidth = | ||||
|     currentWidth || | ||||
|     (logOptionsStorageKey ? parseInt(store.get(`${logOptionsStorageKey}.detailsWidth`), 10) : defaultWidth); | ||||
| 
 | ||||
|   const maxWidth = containerElement.clientWidth - LOG_LIST_MIN_WIDTH; | ||||
| 
 | ||||
|   // The user might have resized the screen.
 | ||||
|   if (detailsWidth >= containerElement.clientWidth || detailsWidth > maxWidth) { | ||||
|     return currentWidth ?? defaultWidth; | ||||
|   } | ||||
|   return detailsWidth; | ||||
| } | ||||
|  |  | |||
|  | @ -2,22 +2,26 @@ import { render, screen } from '@testing-library/react'; | |||
| import userEvent from '@testing-library/user-event'; | ||||
| 
 | ||||
| import { CoreApp, EventBusSrv, LogLevel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; | ||||
| import { config } from '@grafana/runtime'; | ||||
| 
 | ||||
| import { downloadLogs } from '../../utils'; | ||||
| import { createLogRow } from '../__mocks__/logRow'; | ||||
| 
 | ||||
| import { LogListFontSize } from './LogList'; | ||||
| import { LogListContextProvider } from './LogListContext'; | ||||
| import { LogListControls } from './LogListControls'; | ||||
| import { ScrollToLogsEvent } from './virtualization'; | ||||
| 
 | ||||
| jest.mock('../../utils'); | ||||
| 
 | ||||
| const fontSize: LogListFontSize = 'default'; | ||||
| const contextProps = { | ||||
|   app: CoreApp.Unknown, | ||||
|   containerElement: document.createElement('div'), | ||||
|   dedupStrategy: LogsDedupStrategy.exact, | ||||
|   displayedFields: [], | ||||
|   enableLogDetails: false, | ||||
|   fontSize, | ||||
|   logs: [], | ||||
|   showControls: true, | ||||
|   showTime: false, | ||||
|  | @ -237,6 +241,24 @@ describe('LogListControls', () => { | |||
|     expect(screen.getByLabelText('Collapse JSON logs')); | ||||
|   }); | ||||
| 
 | ||||
|   test('Controls font size', async () => { | ||||
|     const originalValue = config.featureToggles.newLogsPanel; | ||||
|     config.featureToggles.newLogsPanel = true; | ||||
| 
 | ||||
|     render( | ||||
|       <LogListContextProvider {...contextProps}> | ||||
|         <LogListControls eventBus={new EventBusSrv()} /> | ||||
|       </LogListContextProvider> | ||||
|     ); | ||||
|     await userEvent.click(screen.getByLabelText('Use small font size')); | ||||
|     await screen.findByLabelText('Use default font size'); | ||||
| 
 | ||||
|     await userEvent.click(screen.getByLabelText('Use default font size')); | ||||
|     await screen.findByLabelText('Use small font size'); | ||||
| 
 | ||||
|     config.featureToggles.newLogsPanel = originalValue; | ||||
|   }); | ||||
| 
 | ||||
|   test.each([ | ||||
|     ['txt', 'text'], | ||||
|     ['json', 'json'], | ||||
|  |  | |||
|  | @ -43,11 +43,13 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props) | |||
|     dedupStrategy, | ||||
|     downloadLogs, | ||||
|     filterLevels, | ||||
|     fontSize, | ||||
|     forceEscape, | ||||
|     hasUnescapedContent, | ||||
|     prettifyJSON, | ||||
|     setDedupStrategy, | ||||
|     setFilterLevels, | ||||
|     setFontSize, | ||||
|     setForceEscape, | ||||
|     setPrettifyJSON, | ||||
|     setShowTime, | ||||
|  | @ -99,6 +101,14 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props) | |||
|     [filterLevels, setFilterLevels] | ||||
|   ); | ||||
| 
 | ||||
|   const onFontSizeClick = useCallback(() => { | ||||
|     const newSize = fontSize === 'default' ? 'small' : 'default'; | ||||
|     reportInteraction('logs_log_list_controls_font_size_clicked', { | ||||
|       size: newSize, | ||||
|     }); | ||||
|     setFontSize(newSize); | ||||
|   }, [fontSize, setFontSize]); | ||||
| 
 | ||||
|   const onShowTimestampsClick = useCallback(() => { | ||||
|     reportInteraction('logs_log_list_controls_show_time_clicked', { | ||||
|       show_time: !showTime, | ||||
|  | @ -324,6 +334,20 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props) | |||
|                   size="lg" | ||||
|                 /> | ||||
|               )} | ||||
|               {config.featureToggles.newLogsPanel && ( | ||||
|                 <IconButton | ||||
|                   name="text-fields" | ||||
|                   className={fontSize === 'small' ? styles.controlButtonActive : styles.controlButton} | ||||
|                   aria-pressed={Boolean(fontSize)} | ||||
|                   onClick={onFontSizeClick} | ||||
|                   tooltip={ | ||||
|                     fontSize === 'default' | ||||
|                       ? t('logs.logs-controls.font-size-default', 'Use small font size') | ||||
|                       : t('logs.logs-controls.font-size-small', 'Use default font size') | ||||
|                   } | ||||
|                   size="lg" | ||||
|                 /> | ||||
|               )} | ||||
|               {hasUnescapedContent && ( | ||||
|                 <IconButton | ||||
|                   name="enter" | ||||
|  |  | |||
|  | @ -16,10 +16,12 @@ export const LogListContext = createContext<LogListContextData>({ | |||
|   downloadLogs: () => {}, | ||||
|   enableLogDetails: false, | ||||
|   filterLevels: [], | ||||
|   fontSize: 'default', | ||||
|   hasUnescapedContent: false, | ||||
|   setDedupStrategy: () => {}, | ||||
|   setDetailsWidth: () => {}, | ||||
|   setFilterLevels: () => {}, | ||||
|   setFontSize: () => {}, | ||||
|   setForceEscape: () => {}, | ||||
|   setLogListState: () => {}, | ||||
|   setPinnedLogs: () => {}, | ||||
|  | @ -59,6 +61,7 @@ export const useLogIsPermalinked = (log: LogListModel) => { | |||
| export const defaultValue: LogListContextData = { | ||||
|   setDedupStrategy: jest.fn(), | ||||
|   setFilterLevels: jest.fn(), | ||||
|   setFontSize: jest.fn(), | ||||
|   setForceEscape: jest.fn(), | ||||
|   setLogListState: jest.fn(), | ||||
|   setPinnedLogs: jest.fn(), | ||||
|  | @ -74,6 +77,7 @@ export const defaultValue: LogListContextData = { | |||
|   downloadLogs: jest.fn(), | ||||
|   enableLogDetails: false, | ||||
|   filterLevels: [], | ||||
|   fontSize: 'default', | ||||
|   setDetailsWidth: jest.fn(), | ||||
|   showDetails: [], | ||||
|   toggleDetails: jest.fn(), | ||||
|  | @ -92,6 +96,7 @@ export const defaultProps: Props = { | |||
|   displayedFields: [], | ||||
|   enableLogDetails: false, | ||||
|   filterLevels: [], | ||||
|   fontSize: 'default', | ||||
|   getRowContextQuery: jest.fn(), | ||||
|   logSupportsContext: jest.fn(), | ||||
|   logs: [], | ||||
|  | @ -157,6 +162,7 @@ export const LogListContextProvider = ({ | |||
|         pinnedLogs, | ||||
|         setDedupStrategy: jest.fn(), | ||||
|         setFilterLevels: jest.fn(), | ||||
|         setFontSize: jest.fn(), | ||||
|         setForceEscape: jest.fn(), | ||||
|         setLogListState: jest.fn(), | ||||
|         setPinnedLogs: jest.fn(), | ||||
|  |  | |||
|  | @ -3,12 +3,14 @@ import { createTheme, Field, FieldType, LogLevel, LogRowModel, LogsSortOrder, to | |||
| import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; | ||||
| import { createLogLine, createLogRow } from '../__mocks__/logRow'; | ||||
| 
 | ||||
| import { LogListFontSize } from './LogList'; | ||||
| import { LogListModel, preProcessLogs } from './processing'; | ||||
| import { getTruncationLength, init } from './virtualization'; | ||||
| 
 | ||||
| describe('preProcessLogs', () => { | ||||
|   let logFmtLog: LogRowModel, nginxLog: LogRowModel, jsonLog: LogRowModel; | ||||
|   let processedLogs: LogListModel[]; | ||||
|   const fontSizes: LogListFontSize[] = ['default', 'small']; | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     const getFieldLinks = jest.fn().mockImplementationOnce((field: Field) => ({ | ||||
|  | @ -165,10 +167,10 @@ describe('preProcessLogs', () => { | |||
|     expect(processedLogs[2].getDisplayedFieldValue(LOG_LINE_BODY_FIELD_NAME)).toBe(processedLogs[2].body); | ||||
|   }); | ||||
| 
 | ||||
|   describe('Collapsible log lines', () => { | ||||
|   describe.each(fontSizes)('Collapsible log lines', (fontSize: LogListFontSize) => { | ||||
|     let longLog: LogListModel, entry: string, container: HTMLDivElement; | ||||
|     beforeEach(() => { | ||||
|       init(createTheme()); | ||||
|       init(createTheme(), fontSize); | ||||
|       container = document.createElement('div'); | ||||
|       jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(200); | ||||
|       entry = new Array(2 * getTruncationLength(null)).fill('e').join(''); | ||||
|  |  | |||
|  | @ -0,0 +1,115 @@ | |||
| import { useCallback, useRef, useState, MouseEvent } from 'react'; | ||||
| 
 | ||||
| import { config } from '@grafana/runtime'; | ||||
| 
 | ||||
| import { disablePopoverMenu, enablePopoverMenu, isPopoverMenuDisabled, targetIsElement } from '../../utils'; | ||||
| import { PopoverStateType } from '../LogRows'; | ||||
| 
 | ||||
| import { useLogListContext } from './LogListContext'; | ||||
| import { LogListModel } from './processing'; | ||||
| 
 | ||||
| export const usePopoverMenu = (containerElement: HTMLDivElement | null) => { | ||||
|   const [popoverState, setPopoverState] = useState<PopoverStateType>({ | ||||
|     selection: '', | ||||
|     selectedRow: null, | ||||
|     popoverMenuCoordinates: { x: 0, y: 0 }, | ||||
|   }); | ||||
|   const [showDisablePopoverOptions, setShowDisablePopoverOptions] = useState(false); | ||||
|   const handleDeselectionRef = useRef<((e: Event) => void) | null>(null); | ||||
|   const { onClickFilterOutString, onClickFilterString } = useLogListContext(); | ||||
| 
 | ||||
|   const popoverMenuSupported = useCallback(() => { | ||||
|     if (!config.featureToggles.logRowsPopoverMenu || isPopoverMenuDisabled()) { | ||||
|       return false; | ||||
|     } | ||||
|     return Boolean(onClickFilterOutString || onClickFilterString); | ||||
|   }, [onClickFilterOutString, onClickFilterString]); | ||||
| 
 | ||||
|   const closePopoverMenu = useCallback(() => { | ||||
|     if (handleDeselectionRef.current) { | ||||
|       document.removeEventListener('click', handleDeselectionRef.current); | ||||
|       document.removeEventListener('contextmenu', handleDeselectionRef.current); | ||||
|       handleDeselectionRef.current = null; | ||||
|     } | ||||
|     setPopoverState({ | ||||
|       selection: '', | ||||
|       popoverMenuCoordinates: { x: 0, y: 0 }, | ||||
|       selectedRow: null, | ||||
|     }); | ||||
|   }, []); | ||||
| 
 | ||||
|   const handleDeselection = useCallback( | ||||
|     (e: Event) => { | ||||
|       if (targetIsElement(e.target) && !containerElement?.contains(e.target)) { | ||||
|         // The mouseup event comes from outside the log rows, close the menu.
 | ||||
|         closePopoverMenu(); | ||||
|         return; | ||||
|       } | ||||
|       if (document.getSelection()?.toString()) { | ||||
|         return; | ||||
|       } | ||||
|       closePopoverMenu(); | ||||
|     }, | ||||
|     [closePopoverMenu, containerElement] | ||||
|   ); | ||||
| 
 | ||||
|   const handleTextSelection = useCallback( | ||||
|     (e: MouseEvent<HTMLElement>, row: LogListModel): boolean => { | ||||
|       const selection = document.getSelection()?.toString(); | ||||
|       if (!selection) { | ||||
|         return false; | ||||
|       } | ||||
|       if (e.altKey) { | ||||
|         enablePopoverMenu(); | ||||
|       } | ||||
|       if (popoverMenuSupported() === false) { | ||||
|         // This signals onRowClick inside LogRow to skip the event because the user is selecting text
 | ||||
|         return selection ? true : false; | ||||
|       } | ||||
| 
 | ||||
|       if (!containerElement) { | ||||
|         return false; | ||||
|       } | ||||
| 
 | ||||
|       const MENU_WIDTH = 270; | ||||
|       const MENU_HEIGHT = 105; | ||||
|       const x = e.clientX + MENU_WIDTH > window.innerWidth ? window.innerWidth - MENU_WIDTH : e.clientX; | ||||
|       const y = e.clientY + MENU_HEIGHT > window.innerHeight ? window.innerHeight - MENU_HEIGHT : e.clientY; | ||||
| 
 | ||||
|       setPopoverState({ | ||||
|         selection, | ||||
|         popoverMenuCoordinates: { x, y }, | ||||
|         selectedRow: row, | ||||
|       }); | ||||
|       handleDeselectionRef.current = handleDeselection; | ||||
|       document.addEventListener('click', handleDeselection); | ||||
|       document.addEventListener('contextmenu', handleDeselection); | ||||
|       return true; | ||||
|     }, | ||||
|     [containerElement, handleDeselection, popoverMenuSupported] | ||||
|   ); | ||||
| 
 | ||||
|   const onDisablePopoverMenu = useCallback(() => { | ||||
|     closePopoverMenu(); | ||||
|     setShowDisablePopoverOptions(true); | ||||
|   }, [closePopoverMenu]); | ||||
| 
 | ||||
|   const onDisableCancel = useCallback(() => { | ||||
|     setShowDisablePopoverOptions(false); | ||||
|   }, []); | ||||
| 
 | ||||
|   const onDisableConfirm = useCallback(() => { | ||||
|     disablePopoverMenu(); | ||||
|     setShowDisablePopoverOptions(false); | ||||
|   }, []); | ||||
| 
 | ||||
|   return { | ||||
|     closePopoverMenu, | ||||
|     handleTextSelection, | ||||
|     onDisableCancel, | ||||
|     onDisableConfirm, | ||||
|     onDisablePopoverMenu, | ||||
|     popoverState, | ||||
|     showDisablePopoverOptions, | ||||
|   }; | ||||
| }; | ||||
|  | @ -4,7 +4,14 @@ import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; | |||
| import { createLogLine } from '../__mocks__/logRow'; | ||||
| 
 | ||||
| import { LogListModel } from './processing'; | ||||
| import { getLineHeight, getLogLineSize, init, measureTextWidth, TRUNCATION_LINE_COUNT } from './virtualization'; | ||||
| import { | ||||
|   getLineHeight, | ||||
|   getLogLineSize, | ||||
|   init, | ||||
|   measureTextWidth, | ||||
|   getTruncationLineCount, | ||||
|   DisplayOptions, | ||||
| } from './virtualization'; | ||||
| 
 | ||||
| const PADDING_BOTTOM = 6; | ||||
| const LINE_HEIGHT = getLineHeight(); | ||||
|  | @ -14,12 +21,13 @@ const THREE_LINES_HEIGHT = 3 * LINE_HEIGHT + PADDING_BOTTOM; | |||
| let LETTER_WIDTH: number; | ||||
| let CONTAINER_SIZE = 200; | ||||
| let TWO_LINES_OF_CHARACTERS: number; | ||||
| const defaultOptions = { | ||||
| const defaultOptions: DisplayOptions = { | ||||
|   wrap: false, | ||||
|   showTime: false, | ||||
|   showDuplicates: false, | ||||
|   hasLogsWithErrors: false, | ||||
|   hasSampledLogs: false, | ||||
|   fontSize: 'default', | ||||
| }; | ||||
| 
 | ||||
| describe('Virtualization', () => { | ||||
|  | @ -28,7 +36,7 @@ describe('Virtualization', () => { | |||
|     log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` }); | ||||
|     container = document.createElement('div'); | ||||
|     jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(CONTAINER_SIZE); | ||||
|     init(createTheme()); | ||||
|     init(createTheme(), 'default'); | ||||
|     LETTER_WIDTH = measureTextWidth('e'); | ||||
|     TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5; | ||||
|   }); | ||||
|  | @ -56,17 +64,11 @@ describe('Virtualization', () => { | |||
|       log.collapsed = true; | ||||
|       jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(10); | ||||
|       const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, showTime: true }, 0); | ||||
|       expect(size).toBe((TRUNCATION_LINE_COUNT + 1) * LINE_HEIGHT); | ||||
|       expect(size).toBe((getTruncationLineCount() + 1) * LINE_HEIGHT); | ||||
|     }); | ||||
| 
 | ||||
|     test.each([true, false])('Measures a log line with controls %s and displayed time %s', (showTime: boolean) => { | ||||
|       const size = getLogLineSize( | ||||
|         [log], | ||||
|         container, | ||||
|         [], | ||||
|         { wrap: true, showTime, showDuplicates: false, hasLogsWithErrors: false, hasSampledLogs: false }, | ||||
|         0 | ||||
|       ); | ||||
|       const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, showTime }, 0); | ||||
|       expect(size).toBe(SINGLE_LINE_HEIGHT); | ||||
|     }); | ||||
| 
 | ||||
|  | @ -147,4 +149,33 @@ describe('Virtualization', () => { | |||
|       expect(size).toBe(TWO_LINES_HEIGHT); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('With small font size', () => { | ||||
|     beforeEach(() => { | ||||
|       init(createTheme(), 'small'); | ||||
|       LETTER_WIDTH = measureTextWidth('e'); | ||||
|       TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5; | ||||
|     }); | ||||
| 
 | ||||
|     test('Measures a multi-line log line with displayed fields', () => { | ||||
|       const SMALL_LINE_HEIGHT = getLineHeight(); | ||||
|       const SMALL_THREE_LINES_HEIGHT = 3 * SMALL_LINE_HEIGHT + PADDING_BOTTOM; | ||||
| 
 | ||||
|       log = createLogLine({ | ||||
|         labels: { place: 'very very long value for the displayed field that causes a new line' }, | ||||
|         entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join(''), | ||||
|         logLevel: undefined, | ||||
|       }); | ||||
| 
 | ||||
|       const size = getLogLineSize( | ||||
|         [log], | ||||
|         container, | ||||
|         ['place', LOG_LINE_BODY_FIELD_NAME], | ||||
|         { ...defaultOptions, wrap: true }, | ||||
|         0 | ||||
|       ); | ||||
|       // Two lines for the log and one extra for the displayed fields
 | ||||
|       expect(size).toBe(SMALL_THREE_LINES_HEIGHT); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -1,7 +1,10 @@ | |||
| import ansicolor from 'ansicolor'; | ||||
| 
 | ||||
| import { BusEventWithPayload, GrafanaTheme2 } from '@grafana/data'; | ||||
| 
 | ||||
| import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; | ||||
| 
 | ||||
| import { LogListFontSize } from './LogList'; | ||||
| import { LogListModel } from './processing'; | ||||
| 
 | ||||
| let ctx: CanvasRenderingContext2D | null = null; | ||||
|  | @ -11,13 +14,27 @@ let lineHeight = 22; | |||
| let measurementMode: 'canvas' | 'dom' = 'canvas'; | ||||
| const iconWidth = 24; | ||||
| 
 | ||||
| export const LOG_LIST_MIN_WIDTH = 35 * gridSize; | ||||
| 
 | ||||
| // Controls the space between fields in the log line, timestamp, level, displayed fields, and log line body
 | ||||
| export const FIELD_GAP_MULTIPLIER = 1.5; | ||||
| 
 | ||||
| export const getLineHeight = () => lineHeight; | ||||
| 
 | ||||
| export function init(theme: GrafanaTheme2) { | ||||
|   const font = `${theme.typography.fontSize}px ${theme.typography.fontFamilyMonospace}`; | ||||
| export function init(theme: GrafanaTheme2, fontSize: LogListFontSize) { | ||||
|   let fontSizePx = theme.typography.fontSize; | ||||
| 
 | ||||
|   if (fontSize === 'default') { | ||||
|     lineHeight = theme.typography.fontSize * theme.typography.body.lineHeight; | ||||
|   } else { | ||||
|     fontSizePx = | ||||
|       typeof theme.typography.bodySmall.fontSize === 'string' && theme.typography.bodySmall.fontSize.includes('rem') | ||||
|         ? theme.typography.fontSize * parseFloat(theme.typography.bodySmall.fontSize) | ||||
|         : parseInt(theme.typography.bodySmall.fontSize, 10); | ||||
|     lineHeight = fontSizePx * theme.typography.bodySmall.lineHeight; | ||||
|   } | ||||
| 
 | ||||
|   const font = `${fontSizePx}px ${theme.typography.fontFamilyMonospace}`; | ||||
|   const letterSpacing = theme.typography.body.letterSpacing; | ||||
| 
 | ||||
|   initDOMmeasurement(font, letterSpacing); | ||||
|  | @ -25,7 +42,6 @@ export function init(theme: GrafanaTheme2) { | |||
| 
 | ||||
|   gridSize = theme.spacing.gridSize; | ||||
|   paddingBottom = gridSize * 0.75; | ||||
|   lineHeight = theme.typography.fontSize * theme.typography.body.lineHeight; | ||||
| 
 | ||||
|   widthMap = new Map<number, number>(); | ||||
|   resetLogLineSizes(); | ||||
|  | @ -115,7 +131,7 @@ export function measureTextHeight(text: string, maxWidth: number, beforeWidth = | |||
|   if (textLines.length === 1 && text.length < firstLineCharsLength) { | ||||
|     return { | ||||
|       lines: 1, | ||||
|       height: lineHeight + paddingBottom, | ||||
|       height: getLineHeight() + paddingBottom, | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|  | @ -127,7 +143,11 @@ export function measureTextHeight(text: string, maxWidth: number, beforeWidth = | |||
|       let delta = 0; | ||||
|       do { | ||||
|         testLogLine = textLine.substring(start, start + logLineCharsLength - delta); | ||||
|         width = measureTextWidth(testLogLine); | ||||
|         let measuredLine = testLogLine; | ||||
|         if (logLines > 0) { | ||||
|           measuredLine.trimStart(); | ||||
|         } | ||||
|         width = measureTextWidth(measuredLine); | ||||
|         delta += 1; | ||||
|       } while (width >= availableWidth); | ||||
|       if (beforeWidth) { | ||||
|  | @ -138,7 +158,7 @@ export function measureTextHeight(text: string, maxWidth: number, beforeWidth = | |||
|     } | ||||
|   } | ||||
| 
 | ||||
|   const height = logLines * lineHeight + paddingBottom; | ||||
|   const height = logLines * getLineHeight() + paddingBottom; | ||||
| 
 | ||||
|   return { | ||||
|     lines: logLines, | ||||
|  | @ -146,7 +166,8 @@ export function measureTextHeight(text: string, maxWidth: number, beforeWidth = | |||
|   }; | ||||
| } | ||||
| 
 | ||||
| interface DisplayOptions { | ||||
| export interface DisplayOptions { | ||||
|   fontSize: LogListFontSize; | ||||
|   hasLogsWithErrors?: boolean; | ||||
|   hasSampledLogs?: boolean; | ||||
|   showDuplicates: boolean; | ||||
|  | @ -158,7 +179,7 @@ export function getLogLineSize( | |||
|   logs: LogListModel[], | ||||
|   container: HTMLDivElement | null, | ||||
|   displayedFields: string[], | ||||
|   { hasLogsWithErrors, hasSampledLogs, showDuplicates, showTime, wrap }: DisplayOptions, | ||||
|   { fontSize, hasLogsWithErrors, hasSampledLogs, showDuplicates, showTime, wrap }: DisplayOptions, | ||||
|   index: number | ||||
| ) { | ||||
|   if (!container) { | ||||
|  | @ -166,15 +187,15 @@ export function getLogLineSize( | |||
|   } | ||||
|   // !logs[index] means the line is not yet loaded by infinite scrolling
 | ||||
|   if (!wrap || !logs[index]) { | ||||
|     return lineHeight + paddingBottom; | ||||
|     return getLineHeight() + paddingBottom; | ||||
|   } | ||||
|   // If a long line is collapsed, we show the line count + an extra line for the expand/collapse control
 | ||||
|   logs[index].updateCollapsedState(displayedFields, container); | ||||
|   if (logs[index].collapsed) { | ||||
|     return (TRUNCATION_LINE_COUNT + 1) * lineHeight; | ||||
|     return (getTruncationLineCount() + 1) * getLineHeight(); | ||||
|   } | ||||
| 
 | ||||
|   const storedSize = retrieveLogLineSize(logs[index].uid, container); | ||||
|   const storedSize = retrieveLogLineSize(logs[index].uid, container, fontSize); | ||||
|   if (storedSize) { | ||||
|     return storedSize; | ||||
|   } | ||||
|  | @ -205,12 +226,12 @@ export function getLogLineSize( | |||
|     textToMeasure = logs[index].getDisplayedFieldValue(field) + textToMeasure; | ||||
|   } | ||||
|   if (!displayedFields.length) { | ||||
|     textToMeasure += logs[index].body; | ||||
|     textToMeasure += ansicolor.strip(logs[index].body); | ||||
|   } | ||||
| 
 | ||||
|   const { height } = measureTextHeight(textToMeasure, getLogContainerWidth(container), optionsWidth); | ||||
|   // When the log is collapsed, add an extra line for the expand/collapse control
 | ||||
|   return logs[index].collapsed === false ? height + lineHeight : height; | ||||
|   return logs[index].collapsed === false ? height + getLineHeight() : height; | ||||
| } | ||||
| 
 | ||||
| export interface LogFieldDimension { | ||||
|  | @ -263,10 +284,10 @@ export const calculateFieldDimensions = (logs: LogListModel[], displayedFields: | |||
| }; | ||||
| 
 | ||||
| // 2/3 of the viewport height
 | ||||
| export const TRUNCATION_LINE_COUNT = Math.round(window.innerHeight / getLineHeight() / 1.5); | ||||
| export const getTruncationLineCount = () => Math.round(window.innerHeight / getLineHeight() / 1.5); | ||||
| export function getTruncationLength(container: HTMLDivElement | null) { | ||||
|   const availableWidth = container ? getLogContainerWidth(container) : window.innerWidth; | ||||
|   return (availableWidth / measureTextWidth('e')) * TRUNCATION_LINE_COUNT; | ||||
|   return (availableWidth / measureTextWidth('e')) * getTruncationLineCount(); | ||||
| } | ||||
| 
 | ||||
| export function hasUnderOrOverflow( | ||||
|  | @ -315,13 +336,13 @@ export function resetLogLineSizes() { | |||
|   logLineSizesMap = new Map<string, number>(); | ||||
| } | ||||
| 
 | ||||
| export function storeLogLineSize(id: string, container: HTMLDivElement, height: number) { | ||||
|   const key = `${id}_${getLogContainerWidth(container)}`; | ||||
| export function storeLogLineSize(id: string, container: HTMLDivElement, height: number, fontSize: LogListFontSize) { | ||||
|   const key = `${id}_${getLogContainerWidth(container)}_${fontSize}`; | ||||
|   logLineSizesMap.set(key, height); | ||||
| } | ||||
| 
 | ||||
| export function retrieveLogLineSize(id: string, container: HTMLDivElement) { | ||||
|   const key = `${id}_${getLogContainerWidth(container)}`; | ||||
| export function retrieveLogLineSize(id: string, container: HTMLDivElement, fontSize: LogListFontSize) { | ||||
|   const key = `${id}_${getLogContainerWidth(container)}_${fontSize}`; | ||||
|   return logLineSizesMap.get(key); | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -151,6 +151,7 @@ export const LogsPanel = ({ | |||
|     logLineMenuCustomItems, | ||||
|     enableInfiniteScrolling, | ||||
|     onNewLogsReceived, | ||||
|     fontSize, | ||||
|     ...options | ||||
|   }, | ||||
|   id, | ||||
|  | @ -535,6 +536,7 @@ export const LogsPanel = ({ | |||
|               dedupStrategy={dedupStrategy} | ||||
|               displayedFields={displayedFields} | ||||
|               enableLogDetails={enableLogDetails} | ||||
|               fontSize={fontSize} | ||||
|               getFieldLinks={getFieldLinks} | ||||
|               isLabelFilterActive={isIsFilterLabelActive(isFilterLabelActive) ? isFilterLabelActive : undefined} | ||||
|               initialScrollPosition={initialScrollPosition} | ||||
|  | @ -551,6 +553,10 @@ export const LogsPanel = ({ | |||
|               onClickFilterOutLabel={ | ||||
|                 isOnClickFilterOutLabel(onClickFilterOutLabel) ? onClickFilterOutLabel : defaultOnClickFilterOutLabel | ||||
|               } | ||||
|               onClickFilterString={isOnClickFilterString(onClickFilterString) ? onClickFilterString : undefined} | ||||
|               onClickFilterOutString={ | ||||
|                 isOnClickFilterOutString(onClickFilterOutString) ? onClickFilterOutString : undefined | ||||
|               } | ||||
|               onClickShowField={displayedFields !== undefined ? onClickShowField : undefined} | ||||
|               onClickHideField={displayedFields !== undefined ? onClickHideField : undefined} | ||||
|               onLogLineHover={onLogRowHover} | ||||
|  |  | |||
|  | @ -54,7 +54,34 @@ export const plugin = new PanelPlugin<Options>(LogsPanel) | |||
|         name: 'Enable infinite scrolling', | ||||
|         description: 'Experimental. Request more results by scrolling to the bottom of the logs list.', | ||||
|         defaultValue: false, | ||||
|       }) | ||||
|       }); | ||||
| 
 | ||||
|     if (config.featureToggles.newLogsPanel) { | ||||
|       builder | ||||
|         .addBooleanSwitch({ | ||||
|           path: 'showControls', | ||||
|           name: 'Show controls', | ||||
|           description: 'Display controls to jump to the last or first log line, and filters by log level', | ||||
|           defaultValue: false, | ||||
|         }) | ||||
|         .addRadio({ | ||||
|           path: 'fontSize', | ||||
|           name: 'Font size', | ||||
|           description: '', | ||||
|           settings: { | ||||
|             options: [ | ||||
|               { value: 'default', label: 'Default' }, | ||||
|               { | ||||
|                 value: 'small', | ||||
|                 label: 'Small', | ||||
|               }, | ||||
|             ], | ||||
|           }, | ||||
|           defaultValue: 'default', | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     builder | ||||
|       .addRadio({ | ||||
|         path: 'dedupStrategy', | ||||
|         name: 'Deduplication', | ||||
|  |  | |||
|  | @ -38,6 +38,7 @@ composableKinds: PanelCfg: { | |||
| 					sortOrder:                common.LogsSortOrder | ||||
| 					dedupStrategy:            common.LogsDedupStrategy | ||||
| 					enableInfiniteScrolling?: bool | ||||
| 					fontSize?:                "default" | "small"                  @cuetsy(kind="enum", memberNames="default|small") | ||||
| 					// TODO: figure out how to define callbacks | ||||
| 					onClickFilterLabel?:     _ | ||||
| 					onClickFilterOutLabel?:  _ | ||||
|  |  | |||
|  | @ -16,6 +16,7 @@ export interface Options { | |||
|   displayedFields?: Array<string>; | ||||
|   enableInfiniteScrolling?: boolean; | ||||
|   enableLogDetails: boolean; | ||||
|   fontSize?: ('default' | 'small'); | ||||
|   isFilterLabelActive?: unknown; | ||||
|   logLineMenuCustomItems?: unknown; | ||||
|   logRowMenuIconsAfter?: unknown; | ||||
|  |  | |||
|  | @ -7715,6 +7715,8 @@ | |||
|       }, | ||||
|       "enable-highlighting": "Enable highlighting", | ||||
|       "escape-newlines": "Fix incorrectly escaped newline and tab sequences in log lines", | ||||
|       "font-size-default": "Use small font size", | ||||
|       "font-size-small": "Use default font size", | ||||
|       "hide-timestamps": "Hide timestamps", | ||||
|       "hide-unique-labels": "Hide unique labels", | ||||
|       "newest-first": "Sorted by newest logs first - Click to show oldest first", | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue