diff --git a/public/app/features/logs/components/LogLabels.tsx b/public/app/features/logs/components/LogLabels.tsx index 8b86a900408..da8107663fb 100644 --- a/public/app/features/logs/components/LogLabels.tsx +++ b/public/app/features/logs/components/LogLabels.tsx @@ -68,6 +68,7 @@ export const LogLabels = memo( size="sm" fill="outline" variant="secondary" + className={styles.button} aria-label={t('logs.log-labels.expand', 'Expand labels')} onClick={() => { setDisplayAll(true); @@ -83,6 +84,7 @@ export const LogLabels = memo( size="sm" fill="outline" variant="secondary" + className={styles.button} aria-label={t('logs.log-labels.collapse', 'Collapse labels')} onClick={() => { setDisplayAll(false); @@ -137,7 +139,7 @@ LogLabel.displayName = 'LogLabel'; const getStyles = (theme: GrafanaTheme2) => { return { logsLabels: css({ - display: 'flex', + display: 'inline-flex', flexWrap: 'wrap', fontSize: theme.typography.size.xs, alignItems: 'center', @@ -161,5 +163,8 @@ const getStyles = (theme: GrafanaTheme2) => { textOverflow: 'ellipsis', overflow: 'hidden', }), + button: css({ + height: theme.spacing(2.75), + }), }; }; diff --git a/public/app/features/logs/components/panel/LogLine.test.tsx b/public/app/features/logs/components/panel/LogLine.test.tsx index 5b765c49343..b79bf06e2e0 100644 --- a/public/app/features/logs/components/panel/LogLine.test.tsx +++ b/public/app/features/logs/components/panel/LogLine.test.tsx @@ -613,4 +613,34 @@ describe('getGridTemplateColumns', () => { ) ).toBe('23px 4px 4px 20px'); }); + + test('Gets the template columns with unique labels', () => { + expect( + getGridTemplateColumns( + [ + { + field: 'timestamp', + width: 23, + }, + { + field: 'level', + width: 4, + }, + { + field: 'unique-labels', + width: 0, + }, + { + field: 'field', + width: 4, + }, + { + field: LOG_LINE_BODY_FIELD_NAME, + width: 20, + }, + ], + ['field'] + ) + ).toBe('23px 4px max-content 4px 20px'); + }); }); diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index 0c95184fca0..62a19356407 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -1,5 +1,15 @@ import { css } from '@emotion/css'; -import { CSSProperties, memo, useCallback, useEffect, useMemo, useRef, useState, MouseEvent } from 'react'; +import { + CSSProperties, + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + MouseEvent, + useLayoutEffect, +} from 'react'; import Highlighter from 'react-highlight-words'; import tinycolor from 'tinycolor2'; @@ -8,6 +18,7 @@ import { t } from '@grafana/i18n'; import { Button, Icon, Tooltip } from '@grafana/ui'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; +import { LogLabels } from '../LogLabels'; import { LogMessageAnsi } from '../LogMessageAnsi'; import { HighlightedLogRenderer } from './HighlightedLogRenderer'; @@ -18,7 +29,7 @@ import { useLogListSearchContext } from './LogListSearchContext'; import { LogListModel } from './processing'; import { FIELD_GAP_MULTIPLIER, - hasUnderOrOverflow, + getLogLineDOMHeight, LogFieldDimension, LogLineVirtualization, DEFAULT_LINE_HEIGHT, @@ -108,6 +119,7 @@ const LogLineComponent = memo( fontSize, hasLogsWithErrors, hasSampledLogs, + showUniqueLabels, timestampResolution, onLogLineHover, } = useLogListContext(); @@ -118,16 +130,41 @@ const LogLineComponent = memo( const pinned = useLogIsPinned(log); const permalinked = useLogIsPermalinked(log); - useEffect(() => { - if (!onOverflow || !logLineRef.current || !virtualization || !height || !wrapLogMessage) { + const handleLogLineResize = useCallback(() => { + if (!onOverflow || !logLineRef.current || !virtualization || !height) { return; } const calculatedHeight = typeof height === 'number' ? height : undefined; - const actualHeight = hasUnderOrOverflow(virtualization, logLineRef.current, calculatedHeight, log.collapsed); + const actualHeight = getLogLineDOMHeight(virtualization, logLineRef.current, calculatedHeight, log.collapsed); if (actualHeight) { onOverflow(index, log.uid, actualHeight); } - }); + }, [height, index, log.collapsed, log.uid, onOverflow, virtualization]); + + useLayoutEffect(() => { + handleLogLineResize(); + }, [handleLogLineResize, detailsMode]); + + useLayoutEffect(() => { + if (!logLineRef.current) { + return; + } + let frameId: number; + const handleResize = () => { + if (frameId) { + cancelAnimationFrame(frameId); + } + frameId = requestAnimationFrame(() => handleLogLineResize()); + }; + const observer = new ResizeObserver(handleResize); + observer.observe(logLineRef.current); + return () => { + observer.disconnect(); + if (frameId) { + cancelAnimationFrame(frameId); + } + }; + }, [handleLogLineResize]); useEffect(() => { if (!wrapLogMessage) { @@ -157,16 +194,6 @@ const LogLineComponent = memo( [log, onClick] ); - const handleLogDetailsResize = useCallback(() => { - if (!onOverflow || !logLineRef.current || !virtualization) { - return; - } - const actualHeight = hasUnderOrOverflow(virtualization, logLineRef.current, undefined, log.collapsed); - if (actualHeight) { - onOverflow(index, log.uid, actualHeight); - } - }, [index, log.collapsed, log.uid, onOverflow, virtualization]); - const detailsShown = detailsDisplayed(log); return ( @@ -233,6 +260,7 @@ const LogLineComponent = memo( displayedFields={displayedFields} log={log} showTime={showTime} + showUniqueLabels={showUniqueLabels} styles={styles} timestampResolution={timestampResolution} wrapLogMessage={wrapLogMessage} @@ -269,7 +297,7 @@ const LogLineComponent = memo( @@ -287,33 +315,53 @@ interface LogProps { displayedFields: string[]; log: LogListModel; showTime: boolean; + showUniqueLabels?: boolean; styles: LogLineStyles; timestampResolution: LogLineTimestampResolution; wrapLogMessage: boolean; } -const Log = memo(({ displayedFields, log, showTime, styles, timestampResolution, wrapLogMessage }: LogProps) => { - return ( - <> - {showTime && ( - - {timestampResolution === 'ms' ? log.timestamp : log.timestampNs} - - )} - { - // When logs are unwrapped, we want an empty column space to align with other log lines. - } - {(log.displayLevel || !wrapLogMessage) && ( - {log.displayLevel} - )} - {displayedFields.length > 0 ? ( - - ) : ( - - )} - - ); -}); +const Log = memo( + ({ displayedFields, log, showTime, showUniqueLabels, styles, timestampResolution, wrapLogMessage }: LogProps) => { + const handleLabelsToggle = useCallback( + (expanded: boolean) => { + log.uniqueLabelsExpanded = expanded; + }, + [log] + ); + return ( + <> + {showTime && ( + + {timestampResolution === 'ms' ? log.timestamp : log.timestampNs} + + )} + { + // When logs are unwrapped, we want an empty column space to align with other log lines. + } + {(log.displayLevel || !wrapLogMessage) && ( + {log.displayLevel} + )} + {showUniqueLabels && log.uniqueLabels && ( + + + + )} + {displayedFields.length > 0 ? ( + + ) : ( + + )} + + ); + } +); Log.displayName = 'Log'; const DisplayedFields = ({ @@ -402,9 +450,11 @@ const LogLineBody = ({ log, styles }: { log: LogListModel; styles: LogLineStyles }; export function getGridTemplateColumns(dimensions: LogFieldDimension[], displayedFields: string[]) { - const columns = dimensions.map((dimension) => dimension.width).join('px '); + const columns = dimensions + .map((dimension) => (dimension.width > 0 ? `${dimension.width}px` : 'max-content')) + .join(' '); const logLineWidth = displayedFields.length > 0 ? '' : ' 1fr'; - return `${columns}px${logLineWidth}`; + return `${columns}${logLineWidth}`; } export type LogLineStyles = ReturnType; diff --git a/public/app/features/logs/components/panel/LogLineDetails.test.tsx b/public/app/features/logs/components/panel/LogLineDetails.test.tsx index c3156bbf80c..d6fff7bcfc1 100644 --- a/public/app/features/logs/components/panel/LogLineDetails.test.tsx +++ b/public/app/features/logs/components/panel/LogLineDetails.test.tsx @@ -64,7 +64,6 @@ const setup = ( containerElement: document.createElement('div'), focusLogLine: jest.fn(), logs, - onResize: jest.fn(), timeRange: getDefaultTimeRange(), timeZone: 'browser', showControls: true, @@ -613,7 +612,6 @@ describe('LogLineDetails', () => { timeRange: getDefaultTimeRange(), timeZone: 'browser', showControls: true, - onResize: jest.fn(), }; const contextData: LogListContextData = { diff --git a/public/app/features/logs/components/panel/LogLineDetails.tsx b/public/app/features/logs/components/panel/LogLineDetails.tsx index 31251be7b46..2a7b63005f9 100644 --- a/public/app/features/logs/components/panel/LogLineDetails.tsx +++ b/public/app/features/logs/components/panel/LogLineDetails.tsx @@ -19,14 +19,13 @@ export interface Props { logs: LogListModel[]; timeRange: TimeRange; timeZone: string; - onResize(): void; showControls: boolean; } export type LogLineDetailsMode = 'inline' | 'sidebar'; export const LogLineDetails = memo( - ({ containerElement, focusLogLine, logs, timeRange, timeZone, onResize, showControls }: Props) => { + ({ containerElement, focusLogLine, logs, timeRange, timeZone, showControls }: Props) => { const { detailsWidth, noInteractions, setDetailsWidth } = useLogListContext(); const styles = useStyles2(getStyles, 'sidebar', showControls); const dragStyles = useStyles2(getDragStyles); @@ -36,8 +35,7 @@ export const LogLineDetails = memo( if (containerRef.current) { setDetailsWidth(containerRef.current.clientWidth); } - onResize(); - }, [onResize, setDetailsWidth]); + }, [setDetailsWidth]); const reportResize = useCallback(() => { if (containerRef.current && !noInteractions) { @@ -71,13 +69,17 @@ LogLineDetails.displayName = 'LogLineDetails'; const LogLineDetailsTabs = memo( ({ focusLogLine, logs, timeRange, timeZone }: Pick) => { - const { app, closeDetails, noInteractions, showDetails, toggleDetails } = useLogListContext(); + const { app, closeDetails, noInteractions, showDetails, toggleDetails, wrapLogMessage } = useLogListContext(); const [currentLog, setCurrentLog] = useState(showDetails[0]); const previousShowDetails = usePrevious(showDetails); const styles = useStyles2(getStyles, 'sidebar'); useEffect(() => { - focusLogLine(currentLog); + // When wrapping is enabled and details is in sidebar mode, the logs panel width changes and the + // user may lose focus of the log line, so we scroll to it. + if (wrapLogMessage) { + focusLogLine(currentLog); + } if (!noInteractions) { reportInteraction('logs_log_line_details_displayed', { mode: 'sidebar', @@ -164,11 +166,8 @@ export const InlineLogLineDetails = memo(({ logs, log, onResize, timeRange, time }, [app, noInteractions]); useEffect(() => { - function handleResize() { - onResize(); - } - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); }, [onResize]); const saveScroll = useCallback(() => { diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 0916541c4fc..d2e43f0512a 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -79,6 +79,7 @@ export interface Props { setDisplayedFields?: (displayedFields: string[]) => void; showControls: boolean; showTime: boolean; + showUniqueLabels?: boolean; sortOrder: LogsSortOrder; timeRange: TimeRange; timestampResolution?: LogLineTimestampResolution; @@ -148,6 +149,7 @@ export const LogList = ({ setDisplayedFields, showControls, showTime, + showUniqueLabels, sortOrder, syntaxHighlighting = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.syntaxHighlighting`, true) : true, timeRange, @@ -192,6 +194,7 @@ export const LogList = ({ setDisplayedFields={setDisplayedFields} showControls={showControls} showTime={showTime} + showUniqueLabels={showUniqueLabels} sortOrder={sortOrder} syntaxHighlighting={syntaxHighlighting} timestampResolution={timestampResolution} @@ -247,6 +250,7 @@ const LogListComponent = ({ prettifyJSON, showDetails, showTime, + showUniqueLabels, sortOrder, timestampResolution, toggleDetails, @@ -264,8 +268,13 @@ const LogListComponent = ({ () => wrapLogMessage ? [] - : virtualization.calculateFieldDimensions(processedLogs, displayedFields, timestampResolution), - [displayedFields, processedLogs, timestampResolution, virtualization, wrapLogMessage] + : virtualization.calculateFieldDimensions( + processedLogs, + displayedFields, + timestampResolution, + showUniqueLabels + ), + [displayedFields, processedLogs, showUniqueLabels, timestampResolution, virtualization, wrapLogMessage] ); const styles = useStyles2(getStyles, dimensions, displayedFields, { showTime }); const widthContainer = wrapperRef.current ?? containerElement; @@ -295,11 +304,13 @@ const LogListComponent = ({ [filterLogs, levelFilteredLogs, matchingUids] ); + // When log lines report size discrepancies, we debounce the calculation reset to give time to + // use the smallest log index to reset the heights. const debouncedResetAfterIndex = useMemo(() => { return debounce((index: number) => { listRef.current?.resetAfterIndex(index); overflowIndexRef.current = Infinity; - }, 25); + }, 0); }, []); const debouncedScrollToItem = useMemo(() => { @@ -340,17 +351,17 @@ const LogListComponent = ({ }, [wrapLogMessage, showDetails, displayedFields, dedupStrategy]); useLayoutEffect(() => { - if (widthRef.current !== widthContainer.clientWidth) { - widthRef.current = widthContainer.clientWidth; - debouncedResetAfterIndex(0); - } - }); - - useLayoutEffect(() => { - const handleResize = debounce(() => { + const handleResize = (entry: ResizeObserverEntry) => { setListHeight(getListHeight(containerElement, app, searchVisible)); - }, 50); - const observer = new ResizeObserver(() => handleResize()); + if (widthRef.current !== entry.contentRect.width) { + widthRef.current = entry.contentRect.width; + } + }; + const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => { + if (entries.length) { + handleResize(entries[0]); + } + }); observer.observe(containerElement); return () => observer.disconnect(); }, [app, containerElement, searchVisible]); @@ -396,10 +407,6 @@ const LogListComponent = ({ [handleTextSelection, toggleDetails] ); - const handleLogDetailsResize = useCallback(() => { - debouncedResetAfterIndex(0); - }, [debouncedResetAfterIndex]); - const focusLogLine = useCallback( (log: LogListModel) => { const index = filteredLogs.findIndex((filteredLog) => filteredLog.uid === log.uid); @@ -425,7 +432,6 @@ const LogListComponent = ({ logs={filteredLogs} timeRange={timeRange} timeZone={timeZone} - onResize={handleLogDetailsResize} showControls={showControls} /> )} diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts index dbb060a744c..0c8559d5f47 100644 --- a/public/app/features/logs/components/panel/processing.ts +++ b/public/app/features/logs/components/panel/processing.ts @@ -53,6 +53,7 @@ export class LogListModel implements LogRowModel { timeUtc: string; uid: string; uniqueLabels: Labels | undefined; + uniqueLabelsExpanded = false; private _body: string | undefined = undefined; private _currentSearch: string | undefined = undefined; diff --git a/public/app/features/logs/components/panel/virtualization.ts b/public/app/features/logs/components/panel/virtualization.ts index 52835fbb8c4..fde63fc3422 100644 --- a/public/app/features/logs/components/panel/virtualization.ts +++ b/public/app/features/logs/components/panel/virtualization.ts @@ -182,7 +182,8 @@ export class LogLineVirtualization { calculateFieldDimensions = ( logs: LogListModel[], displayedFields: string[] = [], - timestampResolution: LogLineTimestampResolution + timestampResolution: LogLineTimestampResolution, + showUniqueLabels?: boolean ) => { if (!logs.length) { return []; @@ -214,6 +215,12 @@ export class LogLineVirtualization { width: levelWidth, }, ]; + if (showUniqueLabels) { + dimensions.push({ + field: 'unique-labels', + width: 0, + }); + } for (const field in fieldWidths) { dimensions.push({ field, @@ -319,7 +326,7 @@ export interface LogFieldDimension { width: number; } -export function hasUnderOrOverflow( +export function getLogLineDOMHeight( virtualization: LogLineVirtualization, element: HTMLDivElement, calculatedHeight?: number, diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index 4fb86cbf427..80bedeef841 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -610,6 +610,7 @@ export const LogsPanel = ({ setDisplayedFields={setDisplayedFieldsFn} showControls={Boolean(showControls)} showTime={showTime} + showUniqueLabels={showLabels} sortOrder={sortOrder} logOptionsStorageKey={storageKey} syntaxHighlighting={syntaxHighlighting} diff --git a/public/app/plugins/panel/logs/module.tsx b/public/app/plugins/panel/logs/module.tsx index 40ab939b347..e73604b13ea 100644 --- a/public/app/plugins/panel/logs/module.tsx +++ b/public/app/plugins/panel/logs/module.tsx @@ -9,30 +9,29 @@ import { LogsPanelSuggestionsSupplier } from './suggestions'; export const plugin = new PanelPlugin(LogsPanel) .setPanelOptions((builder, context) => { const category = [t('logs.category-logs', 'Logs')]; - builder.addBooleanSwitch({ - path: 'showTime', - name: t('logs.name-time', 'Show timestamps'), - category, - description: '', - defaultValue: false, - }); + builder + .addBooleanSwitch({ + path: 'showTime', + name: t('logs.name-time', 'Show timestamps'), + category, + description: '', + defaultValue: false, + }) + .addBooleanSwitch({ + path: 'showLabels', + name: t('logs.name-unique-labels', 'Unique labels'), + category, + description: '', + }); if (!config.featureToggles.newLogsPanel) { - builder - .addBooleanSwitch({ - path: 'showLabels', - name: t('logs.name-unique-labels', 'Unique labels'), - category, - description: '', - defaultValue: false, - }) - .addBooleanSwitch({ - path: 'showCommonLabels', - name: t('logs.name-common-labels', 'Common labels'), - category, - description: '', - defaultValue: false, - }); + builder.addBooleanSwitch({ + path: 'showCommonLabels', + name: t('logs.name-common-labels', 'Common labels'), + category, + description: '', + defaultValue: false, + }); } else if (context.options?.showTime) { builder.addRadio({ path: 'timestampResolution',