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>; |   displayedFields?: Array<string>; | ||||||
|   enableInfiniteScrolling?: boolean; |   enableInfiniteScrolling?: boolean; | ||||||
|   enableLogDetails: boolean; |   enableLogDetails: boolean; | ||||||
|  |   fontSize?: ('default' | 'small'); | ||||||
|   isFilterLabelActive?: unknown; |   isFilterLabelActive?: unknown; | ||||||
|   logLineMenuCustomItems?: unknown; |   logLineMenuCustomItems?: unknown; | ||||||
|   logRowMenuIconsAfter?: unknown; |   logRowMenuIconsAfter?: unknown; | ||||||
|  |  | ||||||
|  | @ -72,6 +72,7 @@ export const ControlledLogRows = forwardRef<HTMLDivElement | null, ControlledLog | ||||||
|         displayedFields={[]} |         displayedFields={[]} | ||||||
|         dedupStrategy={dedupStrategy} |         dedupStrategy={dedupStrategy} | ||||||
|         enableLogDetails={false} |         enableLogDetails={false} | ||||||
|  |         fontSize="default" | ||||||
|         hasUnescapedContent={hasUnescapedContent} |         hasUnescapedContent={hasUnescapedContent} | ||||||
|         logOptionsStorageKey={logOptionsStorageKey} |         logOptionsStorageKey={logOptionsStorageKey} | ||||||
|         logs={deduplicatedRows ?? []} |         logs={deduplicatedRows ?? []} | ||||||
|  |  | ||||||
|  | @ -74,7 +74,7 @@ export interface Props { | ||||||
|   renderPreview?: boolean; |   renderPreview?: boolean; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type PopoverStateType = { | export type PopoverStateType = { | ||||||
|   selection: string; |   selection: string; | ||||||
|   selectedRow: LogRowModel | null; |   selectedRow: LogRowModel | null; | ||||||
|   popoverMenuCoordinates: { x: number; y: number }; |   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 { usePrevious } from 'react-use'; | ||||||
| import { ListChildComponentProps, ListOnItemsRenderedProps } from 'react-window'; | import { ListChildComponentProps, ListOnItemsRenderedProps } from 'react-window'; | ||||||
| 
 | 
 | ||||||
|  | @ -26,7 +26,7 @@ interface Props { | ||||||
|   handleOverflow: (index: number, id: string, height?: number) => void; |   handleOverflow: (index: number, id: string, height?: number) => void; | ||||||
|   loadMore?: (range: AbsoluteTimeRange) => void; |   loadMore?: (range: AbsoluteTimeRange) => void; | ||||||
|   logs: LogListModel[]; |   logs: LogListModel[]; | ||||||
|   onClick: (log: LogListModel) => void; |   onClick: (e: MouseEvent<HTMLElement>, log: LogListModel) => void; | ||||||
|   scrollElement: HTMLDivElement | null; |   scrollElement: HTMLDivElement | null; | ||||||
|   setInitialScrollPosition: () => void; |   setInitialScrollPosition: () => void; | ||||||
|   showTime: boolean; |   showTime: boolean; | ||||||
|  |  | ||||||
|  | @ -7,6 +7,7 @@ import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; | ||||||
| import { createLogLine } from '../__mocks__/logRow'; | import { createLogLine } from '../__mocks__/logRow'; | ||||||
| 
 | 
 | ||||||
| import { getStyles, LogLine, Props } from './LogLine'; | import { getStyles, LogLine, Props } from './LogLine'; | ||||||
|  | import { LogListFontSize } from './LogList'; | ||||||
| import { LogListContextProvider } from './LogListContext'; | import { LogListContextProvider } from './LogListContext'; | ||||||
| import { defaultProps } from './__mocks__/LogListContext'; | import { defaultProps } from './__mocks__/LogListContext'; | ||||||
| import { LogListModel } from './processing'; | import { LogListModel } from './processing'; | ||||||
|  | @ -27,12 +28,14 @@ const contextProps = { | ||||||
|   sortOrder: LogsSortOrder.Ascending, |   sortOrder: LogsSortOrder.Ascending, | ||||||
|   wrapLogMessage: false, |   wrapLogMessage: false, | ||||||
| }; | }; | ||||||
|  | const fontSizes: LogListFontSize[] = ['default', 'small']; | ||||||
| 
 | 
 | ||||||
| describe('LogLine', () => { | describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => { | ||||||
|   let log: LogListModel, defaultProps: Props; |   let log: LogListModel, defaultProps: Props; | ||||||
|   beforeEach(() => { |   beforeEach(() => { | ||||||
|     log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` }); |     log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` }); | ||||||
|     contextProps.logs = [log]; |     contextProps.logs = [log]; | ||||||
|  |     contextProps.fontSize = fontSize; | ||||||
|     defaultProps = { |     defaultProps = { | ||||||
|       displayedFields: [], |       displayedFields: [], | ||||||
|       index: 0, |       index: 0, | ||||||
|  | @ -46,26 +49,42 @@ describe('LogLine', () => { | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   test('Renders a log line', () => { |   test('Renders a log line', () => { | ||||||
|     render(<LogLine {...defaultProps} />); |     render( | ||||||
|  |       <LogListContextProvider {...contextProps}> | ||||||
|  |         <LogLine {...defaultProps} /> | ||||||
|  |       </LogListContextProvider> | ||||||
|  |     ); | ||||||
|     expect(screen.getByText(log.timestamp)).toBeInTheDocument(); |     expect(screen.getByText(log.timestamp)).toBeInTheDocument(); | ||||||
|     expect(screen.getByText('log message 1')).toBeInTheDocument(); |     expect(screen.getByText('log message 1')).toBeInTheDocument(); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   test('Renders a log line with no timestamp', () => { |   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.queryByText(log.timestamp)).not.toBeInTheDocument(); | ||||||
|     expect(screen.getByText('log message 1')).toBeInTheDocument(); |     expect(screen.getByText('log message 1')).toBeInTheDocument(); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   test('Renders a log line with displayed fields', () => { |   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.getByText(log.timestamp)).toBeInTheDocument(); | ||||||
|     expect(screen.queryByText(log.body)).not.toBeInTheDocument(); |     expect(screen.queryByText(log.body)).not.toBeInTheDocument(); | ||||||
|     expect(screen.getByText('luna')).toBeInTheDocument(); |     expect(screen.getByText('luna')).toBeInTheDocument(); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   test('Renders a log line with body displayed fields', () => { |   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.timestamp)).toBeInTheDocument(); | ||||||
|     expect(screen.getByText('log message 1')).toBeInTheDocument(); |     expect(screen.getByText('log message 1')).toBeInTheDocument(); | ||||||
|     expect(screen.getByText('luna')).toBeInTheDocument(); |     expect(screen.getByText('luna')).toBeInTheDocument(); | ||||||
|  | @ -143,7 +162,11 @@ describe('LogLine', () => { | ||||||
| 
 | 
 | ||||||
|   describe('Log line menu', () => { |   describe('Log line menu', () => { | ||||||
|     test('Renders a log line menu', async () => { |     test('Renders a log line menu', async () => { | ||||||
|       render(<LogLine {...defaultProps} />); |       render( | ||||||
|  |         <LogListContextProvider {...contextProps}> | ||||||
|  |           <LogLine {...defaultProps} /> | ||||||
|  |         </LogListContextProvider> | ||||||
|  |       ); | ||||||
|       expect(screen.queryByText('Copy log line')).not.toBeInTheDocument(); |       expect(screen.queryByText('Copy log line')).not.toBeInTheDocument(); | ||||||
|       await userEvent.click(screen.getByLabelText('Log menu')); |       await userEvent.click(screen.getByLabelText('Log menu')); | ||||||
|       expect(screen.getByText('Copy log line')).toBeInTheDocument(); |       expect(screen.getByText('Copy log line')).toBeInTheDocument(); | ||||||
|  | @ -156,7 +179,11 @@ describe('LogLine', () => { | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     test('Highlights relevant tokens in the log line', () => { |     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('place')).toBeInTheDocument(); | ||||||
|       expect(screen.getByText('1ms')).toBeInTheDocument(); |       expect(screen.getByText('1ms')).toBeInTheDocument(); | ||||||
|       expect(screen.getByText('3 KB')).toBeInTheDocument(); |       expect(screen.getByText('3 KB')).toBeInTheDocument(); | ||||||
|  | @ -196,7 +223,11 @@ describe('LogLine', () => { | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     test('Logs are not collapsed by default', () => { |     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 less')).not.toBeInTheDocument(); | ||||||
|       expect(screen.queryByText('show more')).not.toBeInTheDocument(); |       expect(screen.queryByText('show more')).not.toBeInTheDocument(); | ||||||
|     }); |     }); | ||||||
|  | @ -204,11 +235,13 @@ describe('LogLine', () => { | ||||||
|     test('Logs are not collapsible when unwrapped', () => { |     test('Logs are not collapsible when unwrapped', () => { | ||||||
|       log.collapsed = true; |       log.collapsed = true; | ||||||
|       render( |       render( | ||||||
|         <LogLine |         <LogListContextProvider {...contextProps}> | ||||||
|           {...defaultProps} |           <LogLine | ||||||
|           // Unwrapped logs
 |             {...defaultProps} | ||||||
|           wrapLogMessage={false} |             // Unwrapped logs
 | ||||||
|         /> |             wrapLogMessage={false} | ||||||
|  |           /> | ||||||
|  |         </LogListContextProvider> | ||||||
|       ); |       ); | ||||||
|       expect(screen.queryByText('show less')).not.toBeInTheDocument(); |       expect(screen.queryByText('show less')).not.toBeInTheDocument(); | ||||||
|       expect(screen.queryByText('show more')).not.toBeInTheDocument(); |       expect(screen.queryByText('show more')).not.toBeInTheDocument(); | ||||||
|  | @ -216,7 +249,11 @@ describe('LogLine', () => { | ||||||
| 
 | 
 | ||||||
|     test('Long logs can be collapsed and expanded', async () => { |     test('Long logs can be collapsed and expanded', async () => { | ||||||
|       log.collapsed = true; |       log.collapsed = true; | ||||||
|       render(<LogLine {...defaultProps} log={log} />); |       render( | ||||||
|  |         <LogListContextProvider {...contextProps}> | ||||||
|  |           <LogLine {...defaultProps} log={log} /> | ||||||
|  |         </LogListContextProvider> | ||||||
|  |       ); | ||||||
|       expect(screen.getByText('show more')).toBeVisible(); |       expect(screen.getByText('show more')).toBeVisible(); | ||||||
|       await userEvent.click(screen.getByText('show more')); |       await userEvent.click(screen.getByText('show more')); | ||||||
|       expect(await screen.findByText('show less')).toBeInTheDocument(); |       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 () => { |     test('When the collapsed state changes invokes a callback to update virtualized sizes', async () => { | ||||||
|       log.collapsed = true; |       log.collapsed = true; | ||||||
|       const onOverflow = jest.fn(); |       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 more')); | ||||||
|       await userEvent.click(await screen.findByText('show less')); |       await userEvent.click(await screen.findByText('show less')); | ||||||
|       expect(onOverflow).toHaveBeenCalledTimes(2); |       expect(onOverflow).toHaveBeenCalledTimes(2); | ||||||
|  | @ -239,7 +280,11 @@ describe('LogLine', () => { | ||||||
|       expect(screen.getByText('show more')).toBeVisible(); |       expect(screen.getByText('show more')).toBeVisible(); | ||||||
| 
 | 
 | ||||||
|       log.collapsed = undefined; |       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 more')).not.toBeInTheDocument(); | ||||||
|       expect(screen.queryByText('show less')).not.toBeInTheDocument(); |       expect(screen.queryByText('show less')).not.toBeInTheDocument(); | ||||||
|  | @ -247,10 +292,18 @@ describe('LogLine', () => { | ||||||
| 
 | 
 | ||||||
|     test('Syncs the collapsed state with wrapping changes', async () => { |     test('Syncs the collapsed state with wrapping changes', async () => { | ||||||
|       log.collapsed = true; |       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(); |       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 more')).not.toBeInTheDocument(); | ||||||
|       expect(screen.queryByText('show less')).not.toBeInTheDocument(); |       expect(screen.queryByText('show less')).not.toBeInTheDocument(); | ||||||
|  |  | ||||||
|  | @ -1,5 +1,5 @@ | ||||||
| import { css } from '@emotion/css'; | 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 tinycolor from 'tinycolor2'; | ||||||
| 
 | 
 | ||||||
| import { GrafanaTheme2, LogsDedupStrategy } from '@grafana/data'; | import { GrafanaTheme2, LogsDedupStrategy } from '@grafana/data'; | ||||||
|  | @ -17,7 +17,7 @@ import { | ||||||
|   hasUnderOrOverflow, |   hasUnderOrOverflow, | ||||||
|   getLineHeight, |   getLineHeight, | ||||||
|   LogFieldDimension, |   LogFieldDimension, | ||||||
|   TRUNCATION_LINE_COUNT, |   getTruncationLineCount, | ||||||
| } from './virtualization'; | } from './virtualization'; | ||||||
| 
 | 
 | ||||||
| export interface Props { | export interface Props { | ||||||
|  | @ -27,7 +27,7 @@ export interface Props { | ||||||
|   showTime: boolean; |   showTime: boolean; | ||||||
|   style: CSSProperties; |   style: CSSProperties; | ||||||
|   styles: LogLineStyles; |   styles: LogLineStyles; | ||||||
|   onClick: (log: LogListModel) => void; |   onClick: (e: MouseEvent<HTMLElement>, log: LogListModel) => void; | ||||||
|   onOverflow?: (index: number, id: string, height?: number) => void; |   onOverflow?: (index: number, id: string, height?: number) => void; | ||||||
|   variant?: 'infinite-scroll'; |   variant?: 'infinite-scroll'; | ||||||
|   wrapLogMessage: boolean; |   wrapLogMessage: boolean; | ||||||
|  | @ -45,8 +45,15 @@ export const LogLine = ({ | ||||||
|   variant, |   variant, | ||||||
|   wrapLogMessage, |   wrapLogMessage, | ||||||
| }: Props) => { | }: Props) => { | ||||||
|   const { detailsDisplayed, dedupStrategy, enableLogDetails, hasLogsWithErrors, hasSampledLogs, onLogLineHover } = |   const { | ||||||
|     useLogListContext(); |     detailsDisplayed, | ||||||
|  |     dedupStrategy, | ||||||
|  |     enableLogDetails, | ||||||
|  |     fontSize, | ||||||
|  |     hasLogsWithErrors, | ||||||
|  |     hasSampledLogs, | ||||||
|  |     onLogLineHover, | ||||||
|  |   } = useLogListContext(); | ||||||
|   const [collapsed, setCollapsed] = useState<boolean | undefined>( |   const [collapsed, setCollapsed] = useState<boolean | undefined>( | ||||||
|     wrapLogMessage && log.collapsed !== undefined ? log.collapsed : undefined |     wrapLogMessage && log.collapsed !== undefined ? log.collapsed : undefined | ||||||
|   ); |   ); | ||||||
|  | @ -85,16 +92,19 @@ export const LogLine = ({ | ||||||
|   }, [collapsed, index, log, onOverflow]); |   }, [collapsed, index, log, onOverflow]); | ||||||
| 
 | 
 | ||||||
|   const { t } = useTranslate(); |   const { t } = useTranslate(); | ||||||
|   const handleClick = useCallback(() => { |   const handleClick = useCallback( | ||||||
|     onClick(log); |     (e: MouseEvent<HTMLElement>) => { | ||||||
|   }, [log, onClick]); |       onClick(e, log); | ||||||
|  |     }, | ||||||
|  |     [log, onClick] | ||||||
|  |   ); | ||||||
| 
 | 
 | ||||||
|   const detailsShown = detailsDisplayed(log); |   const detailsShown = detailsDisplayed(log); | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <div style={style}> |     <div style={style}> | ||||||
|       <div |       <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} |         ref={onOverflow ? logLineRef : undefined} | ||||||
|         onMouseEnter={handleMouseOver} |         onMouseEnter={handleMouseOver} | ||||||
|         onFocus={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 */} |         {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */} | ||||||
|         <div |         <div | ||||||
|           className={`${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`} ${collapsed === true ? styles.collapsedLogLine : ''} ${enableLogDetails ? styles.clickable : ''}`} |           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} |           onClick={handleClick} | ||||||
|         > |         > | ||||||
|           <Log |           <Log | ||||||
|  | @ -192,7 +203,7 @@ interface LogProps { | ||||||
|   wrapLogMessage: boolean; |   wrapLogMessage: boolean; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const Log = ({ displayedFields, log, showTime, styles, wrapLogMessage }: LogProps) => { | const Log = memo(({ displayedFields, log, showTime, styles, wrapLogMessage }: LogProps) => { | ||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
|       {showTime && <span className={`${styles.timestamp} level-${log.logLevel} field`}>{log.timestamp}</span>} |       {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 LogLineBody = ({ log }: { log: LogListModel }) => { | ||||||
|   const { syntaxHighlighting } = useLogListContext(); |   const { syntaxHighlighting } = useLogListContext(); | ||||||
|  | @ -266,6 +279,7 @@ export const getStyles = (theme: GrafanaTheme2) => { | ||||||
|       flexDirection: 'row', |       flexDirection: 'row', | ||||||
|       fontFamily: theme.typography.fontFamilyMonospace, |       fontFamily: theme.typography.fontFamilyMonospace, | ||||||
|       fontSize: theme.typography.fontSize, |       fontSize: theme.typography.fontSize, | ||||||
|  |       lineHeight: theme.typography.body.lineHeight, | ||||||
|       wordBreak: 'break-all', |       wordBreak: 'break-all', | ||||||
|       '&:hover': { |       '&:hover': { | ||||||
|         background: theme.isDark ? `hsla(0, 0%, 0%, 0.3)` : `hsla(0, 0%, 0%, 0.1)`, |         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, |         color: theme.colors.text.primary, | ||||||
|       }, |       }, | ||||||
|     }), |     }), | ||||||
|  |     fontSizeSmall: css({ | ||||||
|  |       fontSize: theme.typography.bodySmall.fontSize, | ||||||
|  |       lineHeight: theme.typography.bodySmall.lineHeight, | ||||||
|  |     }), | ||||||
|     detailsDisplayed: css({ |     detailsDisplayed: css({ | ||||||
|       background: theme.isDark ? `hsla(0, 0%, 0%, 0.5)` : `hsla(0, 0%, 0%, 0.1)`, |       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({ |     collapsedLogLine: css({ | ||||||
|       maxHeight: `${TRUNCATION_LINE_COUNT * getLineHeight()}px`, |  | ||||||
|       overflow: 'hidden', |       overflow: 'hidden', | ||||||
|     }), |     }), | ||||||
|     expandCollapseControl: css({ |     expandCollapseControl: css({ | ||||||
|  |  | ||||||
|  | @ -12,6 +12,7 @@ import { getLogRowStyles } from '../getLogRowStyles'; | ||||||
| 
 | 
 | ||||||
| import { useLogListContext } from './LogListContext'; | import { useLogListContext } from './LogListContext'; | ||||||
| import { LogListModel } from './processing'; | import { LogListModel } from './processing'; | ||||||
|  | import { LOG_LIST_MIN_WIDTH } from './virtualization'; | ||||||
| 
 | 
 | ||||||
| interface Props { | interface Props { | ||||||
|   containerElement: HTMLDivElement; |   containerElement: HTMLDivElement; | ||||||
|  | @ -51,44 +52,50 @@ export const LogLineDetails = ({ containerElement, getFieldLinks, logs, onResize | ||||||
|     onResize(); |     onResize(); | ||||||
|   }, [onResize, setDetailsWidth]); |   }, [onResize, setDetailsWidth]); | ||||||
| 
 | 
 | ||||||
|  |   const maxWidth = containerElement.clientWidth - LOG_LIST_MIN_WIDTH; | ||||||
|  | 
 | ||||||
|   return ( |   return ( | ||||||
|     <Resizable |     <Resizable | ||||||
|       onResize={handleResize} |       onResize={handleResize} | ||||||
|       handleClasses={{ left: dragStyles.dragHandleVertical }} |       handleClasses={{ left: dragStyles.dragHandleVertical }} | ||||||
|       defaultSize={{ width: detailsWidth, height: containerElement.clientHeight }} |       defaultSize={{ width: detailsWidth, height: containerElement.clientHeight }} | ||||||
|  |       size={{ width: detailsWidth, height: containerElement.clientHeight }} | ||||||
|       enable={{ left: true }} |       enable={{ left: true }} | ||||||
|       minWidth={40} |       minWidth={40} | ||||||
|  |       maxWidth={maxWidth} | ||||||
|     > |     > | ||||||
|       <div className={styles.container} ref={containerRef}> |       <div className={styles.container} ref={containerRef}> | ||||||
|         <IconButton |         <div className={styles.scrollContainer}> | ||||||
|           name="times" |           <IconButton | ||||||
|           className={styles.closeIcon} |             name="times" | ||||||
|           aria-label={t('logs.log-details.close', 'Close log details')} |             className={styles.closeIcon} | ||||||
|           onClick={closeDetails} |             aria-label={t('logs.log-details.close', 'Close log details')} | ||||||
|         /> |             onClick={closeDetails} | ||||||
|         <table width="100%"> |           /> | ||||||
|           <tbody> |           <table width="100%"> | ||||||
|             <LogDetails |             <tbody> | ||||||
|               getRows={getRows} |               <LogDetails | ||||||
|               mode="sidebar" |                 getRows={getRows} | ||||||
|               row={showDetails[0]} |                 mode="sidebar" | ||||||
|               showDuplicates={false} |                 row={showDetails[0]} | ||||||
|               styles={logRowsStyles} |                 showDuplicates={false} | ||||||
|               wrapLogMessage={wrapLogMessage} |                 styles={logRowsStyles} | ||||||
|               onPinLine={onPinLine} |                 wrapLogMessage={wrapLogMessage} | ||||||
|               getFieldLinks={getFieldLinks} |                 onPinLine={onPinLine} | ||||||
|               onClickFilterLabel={onClickFilterLabel} |                 getFieldLinks={getFieldLinks} | ||||||
|               onClickFilterOutLabel={onClickFilterOutLabel} |                 onClickFilterLabel={onClickFilterLabel} | ||||||
|               onClickShowField={onClickShowField} |                 onClickFilterOutLabel={onClickFilterOutLabel} | ||||||
|               onClickHideField={onClickHideField} |                 onClickShowField={onClickShowField} | ||||||
|               hasError={showDetails[0].hasError} |                 onClickHideField={onClickHideField} | ||||||
|               displayedFields={displayedFields} |                 hasError={showDetails[0].hasError} | ||||||
|               app={app} |                 displayedFields={displayedFields} | ||||||
|               isFilterLabelActive={isLabelFilterActive} |                 app={app} | ||||||
|               pinLineButtonTooltipTitle={pinLineButtonTooltipTitle} |                 isFilterLabelActive={isLabelFilterActive} | ||||||
|             /> |                 pinLineButtonTooltipTitle={pinLineButtonTooltipTitle} | ||||||
|           </tbody> |               /> | ||||||
|         </table> |             </tbody> | ||||||
|  |           </table> | ||||||
|  |         </div> | ||||||
|       </div> |       </div> | ||||||
|     </Resizable> |     </Resizable> | ||||||
|   ); |   ); | ||||||
|  | @ -96,6 +103,10 @@ export const LogLineDetails = ({ containerElement, getFieldLinks, logs, onResize | ||||||
| 
 | 
 | ||||||
| const getStyles = (theme: GrafanaTheme2) => ({ | const getStyles = (theme: GrafanaTheme2) => ({ | ||||||
|   container: css({ |   container: css({ | ||||||
|  |     overflow: 'auto', | ||||||
|  |     height: '100%', | ||||||
|  |   }), | ||||||
|  |   scrollContainer: css({ | ||||||
|     overflow: 'auto', |     overflow: 'auto', | ||||||
|     position: 'relative', |     position: 'relative', | ||||||
|     height: '100%', |     height: '100%', | ||||||
|  |  | ||||||
|  | @ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; | ||||||
| 
 | 
 | ||||||
| import { CoreApp, getDefaultTimeRange, LogRowModel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; | import { CoreApp, getDefaultTimeRange, LogRowModel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; | ||||||
| 
 | 
 | ||||||
|  | import { disablePopoverMenu, enablePopoverMenu, isPopoverMenuDisabled } from '../../utils'; | ||||||
| import { createLogRow } from '../__mocks__/logRow'; | import { createLogRow } from '../__mocks__/logRow'; | ||||||
| 
 | 
 | ||||||
| import { LogList, Props } from './LogList'; | import { LogList, Props } from './LogList'; | ||||||
|  | @ -11,9 +12,23 @@ jest.mock('@grafana/runtime', () => { | ||||||
|   return { |   return { | ||||||
|     ...jest.requireActual('@grafana/runtime'), |     ...jest.requireActual('@grafana/runtime'), | ||||||
|     usePluginLinks: jest.fn().mockReturnValue({ links: [] }), |     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', () => { | describe('LogList', () => { | ||||||
|   let logs: LogRowModel[], defaultProps: Props; |   let logs: LogRowModel[], defaultProps: Props; | ||||||
|   beforeEach(() => { |   beforeEach(() => { | ||||||
|  | @ -26,7 +41,7 @@ describe('LogList', () => { | ||||||
|       containerElement: document.createElement('div'), |       containerElement: document.createElement('div'), | ||||||
|       dedupStrategy: LogsDedupStrategy.none, |       dedupStrategy: LogsDedupStrategy.none, | ||||||
|       displayedFields: [], |       displayedFields: [], | ||||||
|       enableLogDetails: false, |       enableLogDetails: true, | ||||||
|       logs, |       logs, | ||||||
|       showControls: false, |       showControls: false, | ||||||
|       showTime: false, |       showTime: false, | ||||||
|  | @ -114,4 +129,107 @@ describe('LogList', () => { | ||||||
| 
 | 
 | ||||||
|     spy.mockRestore(); |     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 { css } from '@emotion/css'; | ||||||
| import { debounce } from 'lodash'; | import { debounce } from 'lodash'; | ||||||
| import { Grammar } from 'prismjs'; | 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 { VariableSizeList } from 'react-window'; | ||||||
| 
 | 
 | ||||||
| import { | import { | ||||||
|  | @ -10,6 +10,7 @@ import { | ||||||
|   DataFrame, |   DataFrame, | ||||||
|   EventBus, |   EventBus, | ||||||
|   EventBusSrv, |   EventBusSrv, | ||||||
|  |   GrafanaTheme2, | ||||||
|   LogLevel, |   LogLevel, | ||||||
|   LogRowModel, |   LogRowModel, | ||||||
|   LogsDedupStrategy, |   LogsDedupStrategy, | ||||||
|  | @ -18,7 +19,9 @@ import { | ||||||
|   store, |   store, | ||||||
|   TimeRange, |   TimeRange, | ||||||
| } from '@grafana/data'; | } 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 { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; | ||||||
| 
 | 
 | ||||||
| import { InfiniteScroll } from './InfiniteScroll'; | import { InfiniteScroll } from './InfiniteScroll'; | ||||||
|  | @ -28,6 +31,7 @@ import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu'; | ||||||
| import { LogListContextProvider, LogListState, useLogListContext } from './LogListContext'; | import { LogListContextProvider, LogListState, useLogListContext } from './LogListContext'; | ||||||
| import { LogListControls } from './LogListControls'; | import { LogListControls } from './LogListControls'; | ||||||
| import { preProcessLogs, LogListModel } from './processing'; | import { preProcessLogs, LogListModel } from './processing'; | ||||||
|  | import { usePopoverMenu } from './usePopoverMenu'; | ||||||
| import { | import { | ||||||
|   calculateFieldDimensions, |   calculateFieldDimensions, | ||||||
|   getLogLineSize, |   getLogLineSize, | ||||||
|  | @ -46,6 +50,7 @@ export interface Props { | ||||||
|   enableLogDetails: boolean; |   enableLogDetails: boolean; | ||||||
|   eventBus?: EventBus; |   eventBus?: EventBus; | ||||||
|   filterLevels?: LogLevel[]; |   filterLevels?: LogLevel[]; | ||||||
|  |   fontSize?: LogListFontSize; | ||||||
|   getFieldLinks?: GetFieldLinksFn; |   getFieldLinks?: GetFieldLinksFn; | ||||||
|   getRowContextQuery?: GetRowContextQueryFn; |   getRowContextQuery?: GetRowContextQueryFn; | ||||||
|   grammar?: Grammar; |   grammar?: Grammar; | ||||||
|  | @ -82,6 +87,8 @@ export interface Props { | ||||||
|   wrapLogMessage: boolean; |   wrapLogMessage: boolean; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | export type LogListFontSize = 'default' | 'small'; | ||||||
|  | 
 | ||||||
| export type LogListControlOptions = LogListState; | export type LogListControlOptions = LogListState; | ||||||
| 
 | 
 | ||||||
| type LogListComponentProps = Omit< | type LogListComponentProps = Omit< | ||||||
|  | @ -105,6 +112,8 @@ export const LogList = ({ | ||||||
|   enableLogDetails, |   enableLogDetails, | ||||||
|   eventBus, |   eventBus, | ||||||
|   filterLevels, |   filterLevels, | ||||||
|  |   logOptionsStorageKey, | ||||||
|  |   fontSize = logOptionsStorageKey ? (store.get(`${logOptionsStorageKey}.fontSize`) ?? 'default') : 'default', | ||||||
|   getFieldLinks, |   getFieldLinks, | ||||||
|   getRowContextQuery, |   getRowContextQuery, | ||||||
|   grammar, |   grammar, | ||||||
|  | @ -113,7 +122,6 @@ export const LogList = ({ | ||||||
|   loading, |   loading, | ||||||
|   loadMore, |   loadMore, | ||||||
|   logLineMenuCustomItems, |   logLineMenuCustomItems, | ||||||
|   logOptionsStorageKey, |  | ||||||
|   logs, |   logs, | ||||||
|   logsMeta, |   logsMeta, | ||||||
|   logSupportsContext, |   logSupportsContext, | ||||||
|  | @ -148,6 +156,7 @@ export const LogList = ({ | ||||||
|       displayedFields={displayedFields} |       displayedFields={displayedFields} | ||||||
|       enableLogDetails={enableLogDetails} |       enableLogDetails={enableLogDetails} | ||||||
|       filterLevels={filterLevels} |       filterLevels={filterLevels} | ||||||
|  |       fontSize={fontSize} | ||||||
|       getRowContextQuery={getRowContextQuery} |       getRowContextQuery={getRowContextQuery} | ||||||
|       isLabelFilterActive={isLabelFilterActive} |       isLabelFilterActive={isLabelFilterActive} | ||||||
|       logs={logs} |       logs={logs} | ||||||
|  | @ -211,9 +220,12 @@ const LogListComponent = ({ | ||||||
|     displayedFields, |     displayedFields, | ||||||
|     dedupStrategy, |     dedupStrategy, | ||||||
|     filterLevels, |     filterLevels, | ||||||
|  |     fontSize, | ||||||
|     forceEscape, |     forceEscape, | ||||||
|     hasLogsWithErrors, |     hasLogsWithErrors, | ||||||
|     hasSampledLogs, |     hasSampledLogs, | ||||||
|  |     onClickFilterString, | ||||||
|  |     onClickFilterOutString, | ||||||
|     permalinkedLogId, |     permalinkedLogId, | ||||||
|     showDetails, |     showDetails, | ||||||
|     showTime, |     showTime, | ||||||
|  | @ -236,8 +248,18 @@ const LogListComponent = ({ | ||||||
|     () => (wrapLogMessage ? [] : calculateFieldDimensions(processedLogs, displayedFields)), |     () => (wrapLogMessage ? [] : calculateFieldDimensions(processedLogs, displayedFields)), | ||||||
|     [displayedFields, processedLogs, wrapLogMessage] |     [displayedFields, processedLogs, wrapLogMessage] | ||||||
|   ); |   ); | ||||||
|   const styles = getStyles(dimensions, { showTime }); |   const styles = getStyles(dimensions, { showTime }, theme); | ||||||
|   const widthContainer = wrapperRef.current ?? containerElement; |   const widthContainer = wrapperRef.current ?? containerElement; | ||||||
|  |   const { | ||||||
|  |     closePopoverMenu, | ||||||
|  |     handleTextSelection, | ||||||
|  |     onDisableCancel, | ||||||
|  |     onDisableConfirm, | ||||||
|  |     onDisablePopoverMenu, | ||||||
|  |     popoverState, | ||||||
|  |     showDisablePopoverOptions, | ||||||
|  |   } = usePopoverMenu(wrapperRef.current); | ||||||
|  |   const { t } = useTranslate(); | ||||||
| 
 | 
 | ||||||
|   const debouncedResetAfterIndex = useMemo(() => { |   const debouncedResetAfterIndex = useMemo(() => { | ||||||
|     return debounce((index: number) => { |     return debounce((index: number) => { | ||||||
|  | @ -247,8 +269,8 @@ const LogListComponent = ({ | ||||||
|   }, []); |   }, []); | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     initVirtualization(theme); |     initVirtualization(theme, fontSize); | ||||||
|   }, [theme]); |   }, [fontSize, theme]); | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     const subscription = eventBus.subscribe(ScrollToLogsEvent, (e: ScrollToLogsEvent) => |     const subscription = eventBus.subscribe(ScrollToLogsEvent, (e: ScrollToLogsEvent) => | ||||||
|  | @ -299,12 +321,15 @@ const LogListComponent = ({ | ||||||
|   const handleOverflow = useCallback( |   const handleOverflow = useCallback( | ||||||
|     (index: number, id: string, height?: number) => { |     (index: number, id: string, height?: number) => { | ||||||
|       if (height !== undefined) { |       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; |       overflowIndexRef.current = index < overflowIndexRef.current ? index : overflowIndexRef.current; | ||||||
|       debouncedResetAfterIndex(overflowIndexRef.current); |       debouncedResetAfterIndex(overflowIndexRef.current); | ||||||
|     }, |     }, | ||||||
|     [debouncedResetAfterIndex, widthContainer] |     [debouncedResetAfterIndex, fontSize, widthContainer] | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|   const handleScrollPosition = useCallback(() => { |   const handleScrollPosition = useCallback(() => { | ||||||
|  | @ -324,14 +349,14 @@ const LogListComponent = ({ | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   const handleLogLineClick = useCallback( |   const handleLogLineClick = useCallback( | ||||||
|     (log: LogListModel) => { |     (e: MouseEvent<HTMLElement>, log: LogListModel) => { | ||||||
|       // Let people select text
 |       if (handleTextSelection(e, log)) { | ||||||
|       if (document.getSelection()?.toString()) { |         // Event handled by the parent.
 | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
|       toggleDetails(log); |       toggleDetails(log); | ||||||
|     }, |     }, | ||||||
|     [toggleDetails] |     [handleTextSelection, toggleDetails] | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|   const handleLogDetailsResize = useCallback(() => { |   const handleLogDetailsResize = useCallback(() => { | ||||||
|  | @ -347,6 +372,39 @@ const LogListComponent = ({ | ||||||
|   return ( |   return ( | ||||||
|     <div className={styles.logListContainer}> |     <div className={styles.logListContainer}> | ||||||
|       <div className={styles.logListWrapper} ref={wrapperRef}> |       <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 |         <InfiniteScroll | ||||||
|           displayedFields={displayedFields} |           displayedFields={displayedFields} | ||||||
|           handleOverflow={handleOverflow} |           handleOverflow={handleOverflow} | ||||||
|  | @ -367,6 +425,7 @@ const LogListComponent = ({ | ||||||
|               height={listHeight} |               height={listHeight} | ||||||
|               itemCount={itemCount} |               itemCount={itemCount} | ||||||
|               itemSize={getLogLineSize.bind(null, filteredLogs, widthContainer, displayedFields, { |               itemSize={getLogLineSize.bind(null, filteredLogs, widthContainer, displayedFields, { | ||||||
|  |                 fontSize, | ||||||
|                 hasLogsWithErrors, |                 hasLogsWithErrors, | ||||||
|                 hasSampledLogs, |                 hasSampledLogs, | ||||||
|                 showDuplicates: dedupStrategy !== LogsDedupStrategy.none, |                 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); |   const columns = showTime ? dimensions : dimensions.filter((_, index) => index > 0); | ||||||
|   return { |   return { | ||||||
|     logList: css({ |     logList: css({ | ||||||
|  | @ -411,9 +470,21 @@ function getStyles(dimensions: LogFieldDimension[], { showTime }: { showTime: bo | ||||||
|     }), |     }), | ||||||
|     logListContainer: css({ |     logListContainer: css({ | ||||||
|       display: 'flex', |       display: 'flex', | ||||||
|  |       // Minimum width to prevent rendering issues and a sausage-like logs panel.
 | ||||||
|  |       minWidth: theme.spacing(35), | ||||||
|     }), |     }), | ||||||
|     logListWrapper: css({ |     logListWrapper: css({ | ||||||
|       width: '100%', |       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 { | import { | ||||||
|   createContext, |   createContext, | ||||||
|   Dispatch, |   Dispatch, | ||||||
|  | @ -26,7 +27,9 @@ import { PopoverContent } from '@grafana/ui'; | ||||||
| import { DownloadFormat, checkLogsError, checkLogsSampled, downloadLogs as download } from '../../utils'; | import { DownloadFormat, checkLogsError, checkLogsSampled, downloadLogs as download } from '../../utils'; | ||||||
| 
 | 
 | ||||||
| import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu'; | import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu'; | ||||||
|  | import { LogListFontSize } from './LogList'; | ||||||
| import { LogListModel } from './processing'; | import { LogListModel } from './processing'; | ||||||
|  | import { LOG_LIST_MIN_WIDTH } from './virtualization'; | ||||||
| 
 | 
 | ||||||
| export interface LogListContextData extends Omit<Props, 'containerElement' | 'logs' | 'logsMeta' | 'showControls'> { | export interface LogListContextData extends Omit<Props, 'containerElement' | 'logs' | 'logsMeta' | 'showControls'> { | ||||||
|   closeDetails: () => void; |   closeDetails: () => void; | ||||||
|  | @ -42,6 +45,7 @@ export interface LogListContextData extends Omit<Props, 'containerElement' | 'lo | ||||||
|   setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => void; |   setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => void; | ||||||
|   setDetailsWidth: (width: number) => void; |   setDetailsWidth: (width: number) => void; | ||||||
|   setFilterLevels: (filterLevels: LogLevel[]) => void; |   setFilterLevels: (filterLevels: LogLevel[]) => void; | ||||||
|  |   setFontSize: (size: LogListFontSize) => void; | ||||||
|   setForceEscape: (forceEscape: boolean) => void; |   setForceEscape: (forceEscape: boolean) => void; | ||||||
|   setLogListState: Dispatch<SetStateAction<LogListState>>; |   setLogListState: Dispatch<SetStateAction<LogListState>>; | ||||||
|   setPinnedLogs: (pinnedlogs: string[]) => void; |   setPinnedLogs: (pinnedlogs: string[]) => void; | ||||||
|  | @ -65,10 +69,12 @@ export const LogListContext = createContext<LogListContextData>({ | ||||||
|   downloadLogs: () => {}, |   downloadLogs: () => {}, | ||||||
|   enableLogDetails: false, |   enableLogDetails: false, | ||||||
|   filterLevels: [], |   filterLevels: [], | ||||||
|  |   fontSize: 'default', | ||||||
|   hasUnescapedContent: false, |   hasUnescapedContent: false, | ||||||
|   setDedupStrategy: () => {}, |   setDedupStrategy: () => {}, | ||||||
|   setDetailsWidth: () => {}, |   setDetailsWidth: () => {}, | ||||||
|   setFilterLevels: () => {}, |   setFilterLevels: () => {}, | ||||||
|  |   setFontSize: () => {}, | ||||||
|   setForceEscape: () => {}, |   setForceEscape: () => {}, | ||||||
|   setLogListState: () => {}, |   setLogListState: () => {}, | ||||||
|   setPinnedLogs: () => {}, |   setPinnedLogs: () => {}, | ||||||
|  | @ -108,6 +114,7 @@ export const useLogIsPermalinked = (log: LogListModel) => { | ||||||
| export type LogListState = Pick< | export type LogListState = Pick< | ||||||
|   LogListContextData, |   LogListContextData, | ||||||
|   | 'dedupStrategy' |   | 'dedupStrategy' | ||||||
|  |   | 'fontSize' | ||||||
|   | 'forceEscape' |   | 'forceEscape' | ||||||
|   | 'filterLevels' |   | 'filterLevels' | ||||||
|   | 'hasUnescapedContent' |   | 'hasUnescapedContent' | ||||||
|  | @ -123,11 +130,13 @@ export type LogListState = Pick< | ||||||
| export interface Props { | export interface Props { | ||||||
|   app: CoreApp; |   app: CoreApp; | ||||||
|   children?: ReactNode; |   children?: ReactNode; | ||||||
|  |   // Only ControlledLogRows can send an undefined containerElement. See LogList.tsx
 | ||||||
|   containerElement?: HTMLDivElement; |   containerElement?: HTMLDivElement; | ||||||
|   dedupStrategy: LogsDedupStrategy; |   dedupStrategy: LogsDedupStrategy; | ||||||
|   displayedFields: string[]; |   displayedFields: string[]; | ||||||
|   enableLogDetails: boolean; |   enableLogDetails: boolean; | ||||||
|   filterLevels?: LogLevel[]; |   filterLevels?: LogLevel[]; | ||||||
|  |   fontSize: LogListFontSize; | ||||||
|   forceEscape?: boolean; |   forceEscape?: boolean; | ||||||
|   hasUnescapedContent?: boolean; |   hasUnescapedContent?: boolean; | ||||||
|   getRowContextQuery?: GetRowContextQueryFn; |   getRowContextQuery?: GetRowContextQueryFn; | ||||||
|  | @ -169,6 +178,7 @@ export const LogListContextProvider = ({ | ||||||
|   dedupStrategy, |   dedupStrategy, | ||||||
|   displayedFields, |   displayedFields, | ||||||
|   filterLevels, |   filterLevels, | ||||||
|  |   fontSize, | ||||||
|   forceEscape = false, |   forceEscape = false, | ||||||
|   hasUnescapedContent, |   hasUnescapedContent, | ||||||
|   isLabelFilterActive, |   isLabelFilterActive, | ||||||
|  | @ -205,6 +215,7 @@ export const LogListContextProvider = ({ | ||||||
|     dedupStrategy, |     dedupStrategy, | ||||||
|     filterLevels: |     filterLevels: | ||||||
|       filterLevels ?? (logOptionsStorageKey ? store.getObject(`${logOptionsStorageKey}.filterLevels`, []) : []), |       filterLevels ?? (logOptionsStorageKey ? store.getObject(`${logOptionsStorageKey}.filterLevels`, []) : []), | ||||||
|  |     fontSize, | ||||||
|     forceEscape, |     forceEscape, | ||||||
|     hasUnescapedContent, |     hasUnescapedContent, | ||||||
|     pinnedLogs, |     pinnedLogs, | ||||||
|  | @ -216,6 +227,7 @@ export const LogListContextProvider = ({ | ||||||
|     wrapLogMessage, |     wrapLogMessage, | ||||||
|   }); |   }); | ||||||
|   const [showDetails, setShowDetails] = useState<LogListModel[]>([]); |   const [showDetails, setShowDetails] = useState<LogListModel[]>([]); | ||||||
|  |   const [detailsWidth, setDetailsWidthState] = useState(getDetailsWidth(containerElement, logOptionsStorageKey)); | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     // Props are updated in the context only of the panel is being externally controlled.
 |     // Props are updated in the context only of the panel is being externally controlled.
 | ||||||
|  | @ -254,6 +266,10 @@ export const LogListContextProvider = ({ | ||||||
|     } |     } | ||||||
|   }, [filterLevels, logListState]); |   }, [filterLevels, logListState]); | ||||||
| 
 | 
 | ||||||
|  |   useEffect(() => { | ||||||
|  |     setLogListState((logListState) => ({ ...logListState, fontSize })); | ||||||
|  |   }, [fontSize]); | ||||||
|  | 
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     if (logListState.hasUnescapedContent !== hasUnescapedContent) { |     if (logListState.hasUnescapedContent !== hasUnescapedContent) { | ||||||
|       setLogListState({ ...logListState, hasUnescapedContent }); |       setLogListState({ ...logListState, hasUnescapedContent }); | ||||||
|  | @ -278,6 +294,17 @@ export const LogListContextProvider = ({ | ||||||
|     } |     } | ||||||
|   }, [logs, showDetails]); |   }, [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( |   const detailsDisplayed = useCallback( | ||||||
|     (log: LogListModel) => !!showDetails.find((shownLog) => shownLog.uid === log.uid), |     (log: LogListModel) => !!showDetails.find((shownLog) => shownLog.uid === log.uid), | ||||||
|     [showDetails] |     [showDetails] | ||||||
|  | @ -291,6 +318,16 @@ export const LogListContextProvider = ({ | ||||||
|     [logListState, onLogOptionsChange] |     [logListState, onLogOptionsChange] | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|  |   const setFontSize = useCallback( | ||||||
|  |     (fontSize: LogListFontSize) => { | ||||||
|  |       if (logOptionsStorageKey) { | ||||||
|  |         store.set(`${logOptionsStorageKey}.fontSize`, fontSize); | ||||||
|  |       } | ||||||
|  |       setLogListState((logListState) => ({ ...logListState, fontSize })); | ||||||
|  |     }, | ||||||
|  |     [logOptionsStorageKey] | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|   const setForceEscape = useCallback( |   const setForceEscape = useCallback( | ||||||
|     (forceEscape: boolean) => { |     (forceEscape: boolean) => { | ||||||
|       setLogListState({ ...logListState, forceEscape }); |       setLogListState({ ...logListState, forceEscape }); | ||||||
|  | @ -413,22 +450,24 @@ export const LogListContextProvider = ({ | ||||||
| 
 | 
 | ||||||
|   const setDetailsWidth = useCallback( |   const setDetailsWidth = useCallback( | ||||||
|     (width: number) => { |     (width: number) => { | ||||||
|       if (!logOptionsStorageKey) { |       if (!logOptionsStorageKey || !containerElement) { | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
|  | 
 | ||||||
|  |       const maxWidth = containerElement.clientWidth - LOG_LIST_MIN_WIDTH; | ||||||
|  |       if (width > maxWidth) { | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|       store.set(`${logOptionsStorageKey}.detailsWidth`, width); |       store.set(`${logOptionsStorageKey}.detailsWidth`, width); | ||||||
|  |       setDetailsWidthState(width); | ||||||
|     }, |     }, | ||||||
|     [logOptionsStorageKey] |     [containerElement, logOptionsStorageKey] | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|   const hasLogsWithErrors = useMemo(() => logs.some((log) => !!checkLogsError(log)), [logs]); |   const hasLogsWithErrors = useMemo(() => logs.some((log) => !!checkLogsError(log)), [logs]); | ||||||
|   const hasSampledLogs = useMemo(() => logs.some((log) => !!checkLogsSampled(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 ( |   return ( | ||||||
|     <LogListContext.Provider |     <LogListContext.Provider | ||||||
|       value={{ |       value={{ | ||||||
|  | @ -436,11 +475,12 @@ export const LogListContextProvider = ({ | ||||||
|         closeDetails, |         closeDetails, | ||||||
|         detailsDisplayed, |         detailsDisplayed, | ||||||
|         dedupStrategy: logListState.dedupStrategy, |         dedupStrategy: logListState.dedupStrategy, | ||||||
|         detailsWidth: detailsWidth || defaultWidth, |         detailsWidth, | ||||||
|         displayedFields, |         displayedFields, | ||||||
|         downloadLogs, |         downloadLogs, | ||||||
|         enableLogDetails, |         enableLogDetails, | ||||||
|         filterLevels: logListState.filterLevels, |         filterLevels: logListState.filterLevels, | ||||||
|  |         fontSize: logListState.fontSize, | ||||||
|         forceEscape: logListState.forceEscape, |         forceEscape: logListState.forceEscape, | ||||||
|         hasLogsWithErrors, |         hasLogsWithErrors, | ||||||
|         hasSampledLogs, |         hasSampledLogs, | ||||||
|  | @ -467,6 +507,7 @@ export const LogListContextProvider = ({ | ||||||
|         setDedupStrategy, |         setDedupStrategy, | ||||||
|         setDetailsWidth, |         setDetailsWidth, | ||||||
|         setFilterLevels, |         setFilterLevels, | ||||||
|  |         setFontSize, | ||||||
|         setForceEscape, |         setForceEscape, | ||||||
|         setLogListState, |         setLogListState, | ||||||
|         setPinnedLogs, |         setPinnedLogs, | ||||||
|  | @ -502,3 +543,26 @@ export function isDedupStrategy(value: unknown): value is LogsDedupStrategy { | ||||||
|     value === LogsDedupStrategy.signature |     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 userEvent from '@testing-library/user-event'; | ||||||
| 
 | 
 | ||||||
| import { CoreApp, EventBusSrv, LogLevel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; | import { CoreApp, EventBusSrv, LogLevel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; | ||||||
|  | import { config } from '@grafana/runtime'; | ||||||
| 
 | 
 | ||||||
| import { downloadLogs } from '../../utils'; | import { downloadLogs } from '../../utils'; | ||||||
| import { createLogRow } from '../__mocks__/logRow'; | import { createLogRow } from '../__mocks__/logRow'; | ||||||
| 
 | 
 | ||||||
|  | import { LogListFontSize } from './LogList'; | ||||||
| import { LogListContextProvider } from './LogListContext'; | import { LogListContextProvider } from './LogListContext'; | ||||||
| import { LogListControls } from './LogListControls'; | import { LogListControls } from './LogListControls'; | ||||||
| import { ScrollToLogsEvent } from './virtualization'; | import { ScrollToLogsEvent } from './virtualization'; | ||||||
| 
 | 
 | ||||||
| jest.mock('../../utils'); | jest.mock('../../utils'); | ||||||
| 
 | 
 | ||||||
|  | const fontSize: LogListFontSize = 'default'; | ||||||
| const contextProps = { | const contextProps = { | ||||||
|   app: CoreApp.Unknown, |   app: CoreApp.Unknown, | ||||||
|   containerElement: document.createElement('div'), |   containerElement: document.createElement('div'), | ||||||
|   dedupStrategy: LogsDedupStrategy.exact, |   dedupStrategy: LogsDedupStrategy.exact, | ||||||
|   displayedFields: [], |   displayedFields: [], | ||||||
|   enableLogDetails: false, |   enableLogDetails: false, | ||||||
|  |   fontSize, | ||||||
|   logs: [], |   logs: [], | ||||||
|   showControls: true, |   showControls: true, | ||||||
|   showTime: false, |   showTime: false, | ||||||
|  | @ -237,6 +241,24 @@ describe('LogListControls', () => { | ||||||
|     expect(screen.getByLabelText('Collapse JSON logs')); |     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([ |   test.each([ | ||||||
|     ['txt', 'text'], |     ['txt', 'text'], | ||||||
|     ['json', 'json'], |     ['json', 'json'], | ||||||
|  |  | ||||||
|  | @ -43,11 +43,13 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props) | ||||||
|     dedupStrategy, |     dedupStrategy, | ||||||
|     downloadLogs, |     downloadLogs, | ||||||
|     filterLevels, |     filterLevels, | ||||||
|  |     fontSize, | ||||||
|     forceEscape, |     forceEscape, | ||||||
|     hasUnescapedContent, |     hasUnescapedContent, | ||||||
|     prettifyJSON, |     prettifyJSON, | ||||||
|     setDedupStrategy, |     setDedupStrategy, | ||||||
|     setFilterLevels, |     setFilterLevels, | ||||||
|  |     setFontSize, | ||||||
|     setForceEscape, |     setForceEscape, | ||||||
|     setPrettifyJSON, |     setPrettifyJSON, | ||||||
|     setShowTime, |     setShowTime, | ||||||
|  | @ -99,6 +101,14 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props) | ||||||
|     [filterLevels, setFilterLevels] |     [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(() => { |   const onShowTimestampsClick = useCallback(() => { | ||||||
|     reportInteraction('logs_log_list_controls_show_time_clicked', { |     reportInteraction('logs_log_list_controls_show_time_clicked', { | ||||||
|       show_time: !showTime, |       show_time: !showTime, | ||||||
|  | @ -324,6 +334,20 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props) | ||||||
|                   size="lg" |                   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 && ( |               {hasUnescapedContent && ( | ||||||
|                 <IconButton |                 <IconButton | ||||||
|                   name="enter" |                   name="enter" | ||||||
|  |  | ||||||
|  | @ -16,10 +16,12 @@ export const LogListContext = createContext<LogListContextData>({ | ||||||
|   downloadLogs: () => {}, |   downloadLogs: () => {}, | ||||||
|   enableLogDetails: false, |   enableLogDetails: false, | ||||||
|   filterLevels: [], |   filterLevels: [], | ||||||
|  |   fontSize: 'default', | ||||||
|   hasUnescapedContent: false, |   hasUnescapedContent: false, | ||||||
|   setDedupStrategy: () => {}, |   setDedupStrategy: () => {}, | ||||||
|   setDetailsWidth: () => {}, |   setDetailsWidth: () => {}, | ||||||
|   setFilterLevels: () => {}, |   setFilterLevels: () => {}, | ||||||
|  |   setFontSize: () => {}, | ||||||
|   setForceEscape: () => {}, |   setForceEscape: () => {}, | ||||||
|   setLogListState: () => {}, |   setLogListState: () => {}, | ||||||
|   setPinnedLogs: () => {}, |   setPinnedLogs: () => {}, | ||||||
|  | @ -59,6 +61,7 @@ export const useLogIsPermalinked = (log: LogListModel) => { | ||||||
| export const defaultValue: LogListContextData = { | export const defaultValue: LogListContextData = { | ||||||
|   setDedupStrategy: jest.fn(), |   setDedupStrategy: jest.fn(), | ||||||
|   setFilterLevels: jest.fn(), |   setFilterLevels: jest.fn(), | ||||||
|  |   setFontSize: jest.fn(), | ||||||
|   setForceEscape: jest.fn(), |   setForceEscape: jest.fn(), | ||||||
|   setLogListState: jest.fn(), |   setLogListState: jest.fn(), | ||||||
|   setPinnedLogs: jest.fn(), |   setPinnedLogs: jest.fn(), | ||||||
|  | @ -74,6 +77,7 @@ export const defaultValue: LogListContextData = { | ||||||
|   downloadLogs: jest.fn(), |   downloadLogs: jest.fn(), | ||||||
|   enableLogDetails: false, |   enableLogDetails: false, | ||||||
|   filterLevels: [], |   filterLevels: [], | ||||||
|  |   fontSize: 'default', | ||||||
|   setDetailsWidth: jest.fn(), |   setDetailsWidth: jest.fn(), | ||||||
|   showDetails: [], |   showDetails: [], | ||||||
|   toggleDetails: jest.fn(), |   toggleDetails: jest.fn(), | ||||||
|  | @ -92,6 +96,7 @@ export const defaultProps: Props = { | ||||||
|   displayedFields: [], |   displayedFields: [], | ||||||
|   enableLogDetails: false, |   enableLogDetails: false, | ||||||
|   filterLevels: [], |   filterLevels: [], | ||||||
|  |   fontSize: 'default', | ||||||
|   getRowContextQuery: jest.fn(), |   getRowContextQuery: jest.fn(), | ||||||
|   logSupportsContext: jest.fn(), |   logSupportsContext: jest.fn(), | ||||||
|   logs: [], |   logs: [], | ||||||
|  | @ -157,6 +162,7 @@ export const LogListContextProvider = ({ | ||||||
|         pinnedLogs, |         pinnedLogs, | ||||||
|         setDedupStrategy: jest.fn(), |         setDedupStrategy: jest.fn(), | ||||||
|         setFilterLevels: jest.fn(), |         setFilterLevels: jest.fn(), | ||||||
|  |         setFontSize: jest.fn(), | ||||||
|         setForceEscape: jest.fn(), |         setForceEscape: jest.fn(), | ||||||
|         setLogListState: jest.fn(), |         setLogListState: jest.fn(), | ||||||
|         setPinnedLogs: 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 { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; | ||||||
| import { createLogLine, createLogRow } from '../__mocks__/logRow'; | import { createLogLine, createLogRow } from '../__mocks__/logRow'; | ||||||
| 
 | 
 | ||||||
|  | import { LogListFontSize } from './LogList'; | ||||||
| import { LogListModel, preProcessLogs } from './processing'; | import { LogListModel, preProcessLogs } from './processing'; | ||||||
| import { getTruncationLength, init } from './virtualization'; | import { getTruncationLength, init } from './virtualization'; | ||||||
| 
 | 
 | ||||||
| describe('preProcessLogs', () => { | describe('preProcessLogs', () => { | ||||||
|   let logFmtLog: LogRowModel, nginxLog: LogRowModel, jsonLog: LogRowModel; |   let logFmtLog: LogRowModel, nginxLog: LogRowModel, jsonLog: LogRowModel; | ||||||
|   let processedLogs: LogListModel[]; |   let processedLogs: LogListModel[]; | ||||||
|  |   const fontSizes: LogListFontSize[] = ['default', 'small']; | ||||||
| 
 | 
 | ||||||
|   beforeEach(() => { |   beforeEach(() => { | ||||||
|     const getFieldLinks = jest.fn().mockImplementationOnce((field: Field) => ({ |     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); |     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; |     let longLog: LogListModel, entry: string, container: HTMLDivElement; | ||||||
|     beforeEach(() => { |     beforeEach(() => { | ||||||
|       init(createTheme()); |       init(createTheme(), fontSize); | ||||||
|       container = document.createElement('div'); |       container = document.createElement('div'); | ||||||
|       jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(200); |       jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(200); | ||||||
|       entry = new Array(2 * getTruncationLength(null)).fill('e').join(''); |       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 { createLogLine } from '../__mocks__/logRow'; | ||||||
| 
 | 
 | ||||||
| import { LogListModel } from './processing'; | 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 PADDING_BOTTOM = 6; | ||||||
| const LINE_HEIGHT = getLineHeight(); | const LINE_HEIGHT = getLineHeight(); | ||||||
|  | @ -14,12 +21,13 @@ const THREE_LINES_HEIGHT = 3 * LINE_HEIGHT + PADDING_BOTTOM; | ||||||
| let LETTER_WIDTH: number; | let LETTER_WIDTH: number; | ||||||
| let CONTAINER_SIZE = 200; | let CONTAINER_SIZE = 200; | ||||||
| let TWO_LINES_OF_CHARACTERS: number; | let TWO_LINES_OF_CHARACTERS: number; | ||||||
| const defaultOptions = { | const defaultOptions: DisplayOptions = { | ||||||
|   wrap: false, |   wrap: false, | ||||||
|   showTime: false, |   showTime: false, | ||||||
|   showDuplicates: false, |   showDuplicates: false, | ||||||
|   hasLogsWithErrors: false, |   hasLogsWithErrors: false, | ||||||
|   hasSampledLogs: false, |   hasSampledLogs: false, | ||||||
|  |   fontSize: 'default', | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| describe('Virtualization', () => { | describe('Virtualization', () => { | ||||||
|  | @ -28,7 +36,7 @@ describe('Virtualization', () => { | ||||||
|     log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` }); |     log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` }); | ||||||
|     container = document.createElement('div'); |     container = document.createElement('div'); | ||||||
|     jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(CONTAINER_SIZE); |     jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(CONTAINER_SIZE); | ||||||
|     init(createTheme()); |     init(createTheme(), 'default'); | ||||||
|     LETTER_WIDTH = measureTextWidth('e'); |     LETTER_WIDTH = measureTextWidth('e'); | ||||||
|     TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5; |     TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5; | ||||||
|   }); |   }); | ||||||
|  | @ -56,17 +64,11 @@ describe('Virtualization', () => { | ||||||
|       log.collapsed = true; |       log.collapsed = true; | ||||||
|       jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(10); |       jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(10); | ||||||
|       const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, showTime: true }, 0); |       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) => { |     test.each([true, false])('Measures a log line with controls %s and displayed time %s', (showTime: boolean) => { | ||||||
|       const size = getLogLineSize( |       const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, showTime }, 0); | ||||||
|         [log], |  | ||||||
|         container, |  | ||||||
|         [], |  | ||||||
|         { wrap: true, showTime, showDuplicates: false, hasLogsWithErrors: false, hasSampledLogs: false }, |  | ||||||
|         0 |  | ||||||
|       ); |  | ||||||
|       expect(size).toBe(SINGLE_LINE_HEIGHT); |       expect(size).toBe(SINGLE_LINE_HEIGHT); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  | @ -147,4 +149,33 @@ describe('Virtualization', () => { | ||||||
|       expect(size).toBe(TWO_LINES_HEIGHT); |       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 { BusEventWithPayload, GrafanaTheme2 } from '@grafana/data'; | ||||||
| 
 | 
 | ||||||
| import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; | import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; | ||||||
| 
 | 
 | ||||||
|  | import { LogListFontSize } from './LogList'; | ||||||
| import { LogListModel } from './processing'; | import { LogListModel } from './processing'; | ||||||
| 
 | 
 | ||||||
| let ctx: CanvasRenderingContext2D | null = null; | let ctx: CanvasRenderingContext2D | null = null; | ||||||
|  | @ -11,13 +14,27 @@ let lineHeight = 22; | ||||||
| let measurementMode: 'canvas' | 'dom' = 'canvas'; | let measurementMode: 'canvas' | 'dom' = 'canvas'; | ||||||
| const iconWidth = 24; | 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
 | // 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 FIELD_GAP_MULTIPLIER = 1.5; | ||||||
| 
 | 
 | ||||||
| export const getLineHeight = () => lineHeight; | export const getLineHeight = () => lineHeight; | ||||||
| 
 | 
 | ||||||
| export function init(theme: GrafanaTheme2) { | export function init(theme: GrafanaTheme2, fontSize: LogListFontSize) { | ||||||
|   const font = `${theme.typography.fontSize}px ${theme.typography.fontFamilyMonospace}`; |   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; |   const letterSpacing = theme.typography.body.letterSpacing; | ||||||
| 
 | 
 | ||||||
|   initDOMmeasurement(font, letterSpacing); |   initDOMmeasurement(font, letterSpacing); | ||||||
|  | @ -25,7 +42,6 @@ export function init(theme: GrafanaTheme2) { | ||||||
| 
 | 
 | ||||||
|   gridSize = theme.spacing.gridSize; |   gridSize = theme.spacing.gridSize; | ||||||
|   paddingBottom = gridSize * 0.75; |   paddingBottom = gridSize * 0.75; | ||||||
|   lineHeight = theme.typography.fontSize * theme.typography.body.lineHeight; |  | ||||||
| 
 | 
 | ||||||
|   widthMap = new Map<number, number>(); |   widthMap = new Map<number, number>(); | ||||||
|   resetLogLineSizes(); |   resetLogLineSizes(); | ||||||
|  | @ -115,7 +131,7 @@ export function measureTextHeight(text: string, maxWidth: number, beforeWidth = | ||||||
|   if (textLines.length === 1 && text.length < firstLineCharsLength) { |   if (textLines.length === 1 && text.length < firstLineCharsLength) { | ||||||
|     return { |     return { | ||||||
|       lines: 1, |       lines: 1, | ||||||
|       height: lineHeight + paddingBottom, |       height: getLineHeight() + paddingBottom, | ||||||
|     }; |     }; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -127,7 +143,11 @@ export function measureTextHeight(text: string, maxWidth: number, beforeWidth = | ||||||
|       let delta = 0; |       let delta = 0; | ||||||
|       do { |       do { | ||||||
|         testLogLine = textLine.substring(start, start + logLineCharsLength - delta); |         testLogLine = textLine.substring(start, start + logLineCharsLength - delta); | ||||||
|         width = measureTextWidth(testLogLine); |         let measuredLine = testLogLine; | ||||||
|  |         if (logLines > 0) { | ||||||
|  |           measuredLine.trimStart(); | ||||||
|  |         } | ||||||
|  |         width = measureTextWidth(measuredLine); | ||||||
|         delta += 1; |         delta += 1; | ||||||
|       } while (width >= availableWidth); |       } while (width >= availableWidth); | ||||||
|       if (beforeWidth) { |       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 { |   return { | ||||||
|     lines: logLines, |     lines: logLines, | ||||||
|  | @ -146,7 +166,8 @@ export function measureTextHeight(text: string, maxWidth: number, beforeWidth = | ||||||
|   }; |   }; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| interface DisplayOptions { | export interface DisplayOptions { | ||||||
|  |   fontSize: LogListFontSize; | ||||||
|   hasLogsWithErrors?: boolean; |   hasLogsWithErrors?: boolean; | ||||||
|   hasSampledLogs?: boolean; |   hasSampledLogs?: boolean; | ||||||
|   showDuplicates: boolean; |   showDuplicates: boolean; | ||||||
|  | @ -158,7 +179,7 @@ export function getLogLineSize( | ||||||
|   logs: LogListModel[], |   logs: LogListModel[], | ||||||
|   container: HTMLDivElement | null, |   container: HTMLDivElement | null, | ||||||
|   displayedFields: string[], |   displayedFields: string[], | ||||||
|   { hasLogsWithErrors, hasSampledLogs, showDuplicates, showTime, wrap }: DisplayOptions, |   { fontSize, hasLogsWithErrors, hasSampledLogs, showDuplicates, showTime, wrap }: DisplayOptions, | ||||||
|   index: number |   index: number | ||||||
| ) { | ) { | ||||||
|   if (!container) { |   if (!container) { | ||||||
|  | @ -166,15 +187,15 @@ export function getLogLineSize( | ||||||
|   } |   } | ||||||
|   // !logs[index] means the line is not yet loaded by infinite scrolling
 |   // !logs[index] means the line is not yet loaded by infinite scrolling
 | ||||||
|   if (!wrap || !logs[index]) { |   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
 |   // If a long line is collapsed, we show the line count + an extra line for the expand/collapse control
 | ||||||
|   logs[index].updateCollapsedState(displayedFields, container); |   logs[index].updateCollapsedState(displayedFields, container); | ||||||
|   if (logs[index].collapsed) { |   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) { |   if (storedSize) { | ||||||
|     return storedSize; |     return storedSize; | ||||||
|   } |   } | ||||||
|  | @ -205,12 +226,12 @@ export function getLogLineSize( | ||||||
|     textToMeasure = logs[index].getDisplayedFieldValue(field) + textToMeasure; |     textToMeasure = logs[index].getDisplayedFieldValue(field) + textToMeasure; | ||||||
|   } |   } | ||||||
|   if (!displayedFields.length) { |   if (!displayedFields.length) { | ||||||
|     textToMeasure += logs[index].body; |     textToMeasure += ansicolor.strip(logs[index].body); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   const { height } = measureTextHeight(textToMeasure, getLogContainerWidth(container), optionsWidth); |   const { height } = measureTextHeight(textToMeasure, getLogContainerWidth(container), optionsWidth); | ||||||
|   // When the log is collapsed, add an extra line for the expand/collapse control
 |   // 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 { | export interface LogFieldDimension { | ||||||
|  | @ -263,10 +284,10 @@ export const calculateFieldDimensions = (logs: LogListModel[], displayedFields: | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| // 2/3 of the viewport height
 | // 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) { | export function getTruncationLength(container: HTMLDivElement | null) { | ||||||
|   const availableWidth = container ? getLogContainerWidth(container) : window.innerWidth; |   const availableWidth = container ? getLogContainerWidth(container) : window.innerWidth; | ||||||
|   return (availableWidth / measureTextWidth('e')) * TRUNCATION_LINE_COUNT; |   return (availableWidth / measureTextWidth('e')) * getTruncationLineCount(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function hasUnderOrOverflow( | export function hasUnderOrOverflow( | ||||||
|  | @ -315,13 +336,13 @@ export function resetLogLineSizes() { | ||||||
|   logLineSizesMap = new Map<string, number>(); |   logLineSizesMap = new Map<string, number>(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function storeLogLineSize(id: string, container: HTMLDivElement, height: number) { | export function storeLogLineSize(id: string, container: HTMLDivElement, height: number, fontSize: LogListFontSize) { | ||||||
|   const key = `${id}_${getLogContainerWidth(container)}`; |   const key = `${id}_${getLogContainerWidth(container)}_${fontSize}`; | ||||||
|   logLineSizesMap.set(key, height); |   logLineSizesMap.set(key, height); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function retrieveLogLineSize(id: string, container: HTMLDivElement) { | export function retrieveLogLineSize(id: string, container: HTMLDivElement, fontSize: LogListFontSize) { | ||||||
|   const key = `${id}_${getLogContainerWidth(container)}`; |   const key = `${id}_${getLogContainerWidth(container)}_${fontSize}`; | ||||||
|   return logLineSizesMap.get(key); |   return logLineSizesMap.get(key); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -151,6 +151,7 @@ export const LogsPanel = ({ | ||||||
|     logLineMenuCustomItems, |     logLineMenuCustomItems, | ||||||
|     enableInfiniteScrolling, |     enableInfiniteScrolling, | ||||||
|     onNewLogsReceived, |     onNewLogsReceived, | ||||||
|  |     fontSize, | ||||||
|     ...options |     ...options | ||||||
|   }, |   }, | ||||||
|   id, |   id, | ||||||
|  | @ -535,6 +536,7 @@ export const LogsPanel = ({ | ||||||
|               dedupStrategy={dedupStrategy} |               dedupStrategy={dedupStrategy} | ||||||
|               displayedFields={displayedFields} |               displayedFields={displayedFields} | ||||||
|               enableLogDetails={enableLogDetails} |               enableLogDetails={enableLogDetails} | ||||||
|  |               fontSize={fontSize} | ||||||
|               getFieldLinks={getFieldLinks} |               getFieldLinks={getFieldLinks} | ||||||
|               isLabelFilterActive={isIsFilterLabelActive(isFilterLabelActive) ? isFilterLabelActive : undefined} |               isLabelFilterActive={isIsFilterLabelActive(isFilterLabelActive) ? isFilterLabelActive : undefined} | ||||||
|               initialScrollPosition={initialScrollPosition} |               initialScrollPosition={initialScrollPosition} | ||||||
|  | @ -551,6 +553,10 @@ export const LogsPanel = ({ | ||||||
|               onClickFilterOutLabel={ |               onClickFilterOutLabel={ | ||||||
|                 isOnClickFilterOutLabel(onClickFilterOutLabel) ? onClickFilterOutLabel : defaultOnClickFilterOutLabel |                 isOnClickFilterOutLabel(onClickFilterOutLabel) ? onClickFilterOutLabel : defaultOnClickFilterOutLabel | ||||||
|               } |               } | ||||||
|  |               onClickFilterString={isOnClickFilterString(onClickFilterString) ? onClickFilterString : undefined} | ||||||
|  |               onClickFilterOutString={ | ||||||
|  |                 isOnClickFilterOutString(onClickFilterOutString) ? onClickFilterOutString : undefined | ||||||
|  |               } | ||||||
|               onClickShowField={displayedFields !== undefined ? onClickShowField : undefined} |               onClickShowField={displayedFields !== undefined ? onClickShowField : undefined} | ||||||
|               onClickHideField={displayedFields !== undefined ? onClickHideField : undefined} |               onClickHideField={displayedFields !== undefined ? onClickHideField : undefined} | ||||||
|               onLogLineHover={onLogRowHover} |               onLogLineHover={onLogRowHover} | ||||||
|  |  | ||||||
|  | @ -54,7 +54,34 @@ export const plugin = new PanelPlugin<Options>(LogsPanel) | ||||||
|         name: 'Enable infinite scrolling', |         name: 'Enable infinite scrolling', | ||||||
|         description: 'Experimental. Request more results by scrolling to the bottom of the logs list.', |         description: 'Experimental. Request more results by scrolling to the bottom of the logs list.', | ||||||
|         defaultValue: false, |         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({ |       .addRadio({ | ||||||
|         path: 'dedupStrategy', |         path: 'dedupStrategy', | ||||||
|         name: 'Deduplication', |         name: 'Deduplication', | ||||||
|  |  | ||||||
|  | @ -38,6 +38,7 @@ composableKinds: PanelCfg: { | ||||||
| 					sortOrder:                common.LogsSortOrder | 					sortOrder:                common.LogsSortOrder | ||||||
| 					dedupStrategy:            common.LogsDedupStrategy | 					dedupStrategy:            common.LogsDedupStrategy | ||||||
| 					enableInfiniteScrolling?: bool | 					enableInfiniteScrolling?: bool | ||||||
|  | 					fontSize?:                "default" | "small"                  @cuetsy(kind="enum", memberNames="default|small") | ||||||
| 					// TODO: figure out how to define callbacks | 					// TODO: figure out how to define callbacks | ||||||
| 					onClickFilterLabel?:     _ | 					onClickFilterLabel?:     _ | ||||||
| 					onClickFilterOutLabel?:  _ | 					onClickFilterOutLabel?:  _ | ||||||
|  |  | ||||||
|  | @ -16,6 +16,7 @@ export interface Options { | ||||||
|   displayedFields?: Array<string>; |   displayedFields?: Array<string>; | ||||||
|   enableInfiniteScrolling?: boolean; |   enableInfiniteScrolling?: boolean; | ||||||
|   enableLogDetails: boolean; |   enableLogDetails: boolean; | ||||||
|  |   fontSize?: ('default' | 'small'); | ||||||
|   isFilterLabelActive?: unknown; |   isFilterLabelActive?: unknown; | ||||||
|   logLineMenuCustomItems?: unknown; |   logLineMenuCustomItems?: unknown; | ||||||
|   logRowMenuIconsAfter?: unknown; |   logRowMenuIconsAfter?: unknown; | ||||||
|  |  | ||||||
|  | @ -7715,6 +7715,8 @@ | ||||||
|       }, |       }, | ||||||
|       "enable-highlighting": "Enable highlighting", |       "enable-highlighting": "Enable highlighting", | ||||||
|       "escape-newlines": "Fix incorrectly escaped newline and tab sequences in log lines", |       "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-timestamps": "Hide timestamps", | ||||||
|       "hide-unique-labels": "Hide unique labels", |       "hide-unique-labels": "Hide unique labels", | ||||||
|       "newest-first": "Sorted by newest logs first - Click to show oldest first", |       "newest-first": "Sorted by newest logs first - Click to show oldest first", | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue