Logs Panel: Add unique labels support for dashboards (#111124)

* New logs panel: add basic show unique labels support

* LogList: switch to useMeasure to detect overflow

* LogList: remove debounce from size calculations reset

* Update test

* LogLine: keep unique labels expanded state between re-renders

* Remove hardcoded true

* LogListModel: add new property

* LogLine: switch to custom resize observer

* Revert removed changes

* Imports order

* Add missing function call in effect

* LogList: use improved debouncing for overflow

* LogLine: refactor resize listeners

* LogLine: mix observer with animation frame

* Prettier

* LogLabels: make button smaller

* LogLine: fix unwrapped unique labels

* Prettier

* LogListContext: sync mode only if not empty

* Revert "LogListContext: sync mode only if not empty"

This reverts commit 2b78249b35.

* module: remove default value
This commit is contained in:
Matias Chomicki
2025-09-17 15:26:42 +00:00
committed by GitHub
parent 91008cc82e
commit aaccd22a45
10 changed files with 193 additions and 97 deletions
@@ -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),
}),
};
};
@@ -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');
});
});
@@ -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(
<InlineLogLineDetails
logs={logs}
log={log}
onResize={handleLogDetailsResize}
onResize={handleLogLineResize}
timeRange={timeRange}
timeZone={timeZone}
/>
@@ -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 && (
<span className={`${styles.timestamp} level-${log.logLevel} field`}>
{timestampResolution === 'ms' ? log.timestamp : log.timestampNs}
</span>
)}
{
// When logs are unwrapped, we want an empty column space to align with other log lines.
}
{(log.displayLevel || !wrapLogMessage) && (
<span className={`${styles.level} level-${log.logLevel} field`}>{log.displayLevel}</span>
)}
{displayedFields.length > 0 ? (
<DisplayedFields displayedFields={displayedFields} log={log} styles={styles} />
) : (
<LogLineBody log={log} styles={styles} />
)}
</>
);
});
const Log = memo(
({ displayedFields, log, showTime, showUniqueLabels, styles, timestampResolution, wrapLogMessage }: LogProps) => {
const handleLabelsToggle = useCallback(
(expanded: boolean) => {
log.uniqueLabelsExpanded = expanded;
},
[log]
);
return (
<>
{showTime && (
<span className={`${styles.timestamp} level-${log.logLevel} field`}>
{timestampResolution === 'ms' ? log.timestamp : log.timestampNs}
</span>
)}
{
// When logs are unwrapped, we want an empty column space to align with other log lines.
}
{(log.displayLevel || !wrapLogMessage) && (
<span className={`${styles.level} level-${log.logLevel} field`}>{log.displayLevel}</span>
)}
{showUniqueLabels && log.uniqueLabels && (
<span className="field">
<LogLabels
addTooltip={true}
displayAll={log.uniqueLabelsExpanded}
displayMax={5}
labels={log.uniqueLabels}
onDisplayMaxToggle={handleLabelsToggle}
/>
</span>
)}
{displayedFields.length > 0 ? (
<DisplayedFields displayedFields={displayedFields} log={log} styles={styles} />
) : (
<LogLineBody log={log} styles={styles} />
)}
</>
);
}
);
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<typeof getStyles>;
@@ -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 = {
@@ -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<Props, 'focusLogLine' | 'logs' | 'timeRange' | 'timeZone'>) => {
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(() => {
@@ -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}
/>
)}
@@ -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;
@@ -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,
@@ -610,6 +610,7 @@ export const LogsPanel = ({
setDisplayedFields={setDisplayedFieldsFn}
showControls={Boolean(showControls)}
showTime={showTime}
showUniqueLabels={showLabels}
sortOrder={sortOrder}
logOptionsStorageKey={storageKey}
syntaxHighlighting={syntaxHighlighting}
+21 -22
View File
@@ -9,30 +9,29 @@ import { LogsPanelSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<Options>(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',