New Logs Panel: Displayed fields support (#100643)

* LogList: add displayedFields and getFieldLinks props

* Render displayed fields

* LogLine: rename function

* Refactor log dimensions

* Generate styles in parent component

* Log List: implement tabular unwrapped logs

* Rename class

* Log line: center fields

* Parametrize field gap

* Virtualization: update measurement to support displayed fields

* Shorten visible level

* Do not calculate dimensions when logs are wrapped

* Logs Navigation: fix width when flag is enabled

* Pass styles to LogLineMessage

* Formatting

* Fix unwrapped logs when showTime is off

* LogLine: update css selectors for fields
This commit is contained in:
Matias Chomicki
2025-02-27 12:31:55 +02:00
committed by GitHub
parent 58457d41d3
commit 03dcd25a32
9 changed files with 250 additions and 47 deletions
@@ -1070,8 +1070,10 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
<LogList
app={CoreApp.Explore}
containerElement={logsContainerRef.current}
displayedFields={displayedFields}
eventBus={eventBus}
forceEscape={forceEscape}
getFieldLinks={getFieldLinks}
loadMore={loadMoreLogs}
logs={dedupedRows}
showTime={showTime}
@@ -235,7 +235,7 @@ const getStyles = (theme: GrafanaTheme2, oldestLogsFirst: boolean) => {
return {
navContainer: css({
maxHeight: navContainerHeight,
width: oldestLogsFirst ? '58px' : 'auto',
width: oldestLogsFirst && !config.featureToggles.newLogsPanel ? '58px' : 'auto',
display: 'flex',
flexDirection: 'column',
justifyContent: config.featureToggles.logsInfiniteScrolling
@@ -4,12 +4,12 @@ import { ListChildComponentProps, ListOnItemsRenderedProps } from 'react-window'
import { AbsoluteTimeRange, LogsSortOrder, TimeRange } from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { Spinner } from '@grafana/ui';
import { Spinner, useTheme2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { canScrollBottom, getVisibleRange, ScrollDirection, shouldLoadMore } from '../InfiniteScroll';
import { LogLine } from './LogLine';
import { getStyles, LogLine } from './LogLine';
import { LogLineMessage } from './LogLineMessage';
import { LogListModel } from './processing';
@@ -22,6 +22,7 @@ interface ChildrenProps {
interface Props {
children: (props: ChildrenProps) => ReactNode;
displayedFields: string[];
handleOverflow: (index: number, id: string, height: number) => void;
loadMore?: (range: AbsoluteTimeRange) => void;
logs: LogListModel[];
@@ -38,6 +39,7 @@ type InfiniteLoaderState = 'idle' | 'out-of-bounds' | 'pre-scroll' | 'loading';
export const InfiniteScroll = ({
children,
displayedFields,
handleOverflow,
loadMore,
logs,
@@ -57,6 +59,8 @@ export const InfiniteScroll = ({
const lastEvent = useRef<Event | WheelEvent | null>(null);
const countRef = useRef(0);
const lastLogOfPage = useRef<string[]>([]);
const theme = useTheme2();
const styles = getStyles(theme);
useEffect(() => {
// Logs have not changed, ignore effect
@@ -132,24 +136,40 @@ export const InfiniteScroll = ({
({ index, style }: ListChildComponentProps) => {
if (!logs[index] && infiniteLoaderState !== 'idle') {
return (
<LogLineMessage style={style} onClick={infiniteLoaderState === 'pre-scroll' ? onLoadMore : undefined}>
<LogLineMessage
style={style}
styles={styles}
onClick={infiniteLoaderState === 'pre-scroll' ? onLoadMore : undefined}
>
{getMessageFromInfiniteLoaderState(infiniteLoaderState, sortOrder)}
</LogLineMessage>
);
}
return (
<LogLine
displayedFields={displayedFields}
index={index}
log={logs[index]}
showTime={showTime}
style={style}
styles={styles}
variant={getLogLineVariant(logs, index, lastLogOfPage.current)}
wrapLogMessage={wrapLogMessage}
onOverflow={handleOverflow}
/>
);
},
[handleOverflow, infiniteLoaderState, logs, onLoadMore, showTime, sortOrder, wrapLogMessage]
[
displayedFields,
handleOverflow,
infiniteLoaderState,
logs,
onLoadMore,
showTime,
sortOrder,
styles,
wrapLogMessage,
]
);
const onItemsRendered = useCallback(
@@ -2,24 +2,35 @@ import { css } from '@emotion/css';
import { CSSProperties, useEffect, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useTheme2 } from '@grafana/ui';
import { LogListModel } from './processing';
import { hasUnderOrOverflow } from './virtualization';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { LogFieldDimension, LogListModel } from './processing';
import { FIELD_GAP_MULTIPLIER, hasUnderOrOverflow } from './virtualization';
interface Props {
displayedFields: string[];
index: number;
log: LogListModel;
showTime: boolean;
style: CSSProperties;
styles: LogLineStyles;
onOverflow?: (index: number, id: string, height: number) => void;
variant?: 'infinite-scroll';
wrapLogMessage: boolean;
}
export const LogLine = ({ index, log, style, onOverflow, showTime, variant, wrapLogMessage }: Props) => {
const theme = useTheme2();
const styles = getStyles(theme);
export const LogLine = ({
displayedFields,
index,
log,
style,
styles,
onOverflow,
showTime,
variant,
wrapLogMessage,
}: Props) => {
const logLineRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
@@ -34,16 +45,59 @@ export const LogLine = ({ index, log, style, onOverflow, showTime, variant, wrap
}, [index, log.uid, onOverflow, style.height]);
return (
<div style={style} className={`${styles.logLine} ${variant}`} ref={onOverflow ? logLineRef : undefined}>
<div className={wrapLogMessage ? styles.wrappedLogLine : styles.unwrappedLogLine}>
{showTime && <span className={`${styles.timestamp} level-${log.logLevel}`}>{log.timestamp}</span>}
{log.logLevel && <span className={`${styles.level} level-${log.logLevel}`}>{log.logLevel}</span>}
{log.body}
<div style={style} className={`${styles.logLine} ${variant ?? ''}`} ref={onOverflow ? logLineRef : undefined}>
<div className={`${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`}`}>
<Log displayedFields={displayedFields} log={log} showTime={showTime} styles={styles} />
</div>
</div>
);
};
interface LogProps {
displayedFields: string[];
log: LogListModel;
showTime: boolean;
styles: ReturnType<typeof getStyles>;
}
const Log = ({ displayedFields, log, showTime, styles }: LogProps) => {
return (
<>
{showTime && <span className={`${styles.timestamp} level-${log.logLevel} field`}>{log.timestamp}</span>}
<span className={`${styles.level} level-${log.logLevel} field`}>{log.displayLevel}</span>
{displayedFields.length > 0 ? (
displayedFields.map((field) => (
<span className="field" title={field}>
{getDisplayedFieldValue(field, log)}
</span>
))
) : (
<span className="field">{log.body}</span>
)}
</>
);
};
export function getDisplayedFieldValue(fieldName: string, log: LogListModel): string {
if (fieldName === LOG_LINE_BODY_FIELD_NAME) {
return log.body;
}
if (log.labels[fieldName] != null) {
return log.labels[fieldName];
}
const field = log.fields.find((field) => {
return field.keys[0] === fieldName;
});
return field ? field.values.toString() : '';
}
export function getGridTemplateColumns(dimensions: LogFieldDimension[]) {
const columns = dimensions.map((dimension) => dimension.width).join('px ');
return `${columns}px 1fr`;
}
export type LogLineStyles = ReturnType<typeof getStyles>;
export const getStyles = (theme: GrafanaTheme2) => {
const colors = {
critical: '#B877D9',
@@ -82,7 +136,6 @@ export const getStyles = (theme: GrafanaTheme2) => {
timestamp: css({
color: theme.colors.text.secondary,
display: 'inline-block',
marginRight: theme.spacing(1),
'&.level-critical': {
color: colors.critical,
},
@@ -103,7 +156,6 @@ export const getStyles = (theme: GrafanaTheme2) => {
color: theme.colors.text.secondary,
fontWeight: theme.typography.fontWeightBold,
display: 'inline-block',
marginRight: theme.spacing(1),
'&.level-critical': {
color: colors.critical,
},
@@ -129,12 +181,20 @@ export const getStyles = (theme: GrafanaTheme2) => {
outline: 'solid 1px red',
}),
unwrappedLogLine: css({
display: 'grid',
gridColumnGap: theme.spacing(FIELD_GAP_MULTIPLIER),
whiteSpace: 'pre',
paddingBottom: theme.spacing(0.75),
}),
wrappedLogLine: css({
whiteSpace: 'pre-wrap',
paddingBottom: theme.spacing(0.75),
'& .field': {
marginRight: theme.spacing(FIELD_GAP_MULTIPLIER),
},
'& .field:last-child': {
marginRight: 0,
},
}),
};
};
@@ -1,18 +1,15 @@
import { CSSProperties, ReactNode } from 'react';
import { useTheme2 } from '@grafana/ui';
import { getStyles } from './LogLine';
import { LogLineStyles } from './LogLine';
interface Props {
children: ReactNode;
onClick?: () => void;
style: CSSProperties;
styles: LogLineStyles;
}
export const LogLineMessage = ({ children, onClick, style }: Props) => {
const theme = useTheme2();
const styles = getStyles(theme);
export const LogLineMessage = ({ children, onClick, style, styles }: Props) => {
return (
<div style={style} className={`${styles.logLine} ${styles.logLineMessage}`}>
{onClick ? (
@@ -1,12 +1,24 @@
import { css } from '@emotion/css';
import { debounce } from 'lodash';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { VariableSizeList } from 'react-window';
import { AbsoluteTimeRange, CoreApp, EventBus, LogRowModel, LogsSortOrder, TimeRange } from '@grafana/data';
import {
AbsoluteTimeRange,
CoreApp,
DataFrame,
EventBus,
Field,
LinkModel,
LogRowModel,
LogsSortOrder,
TimeRange,
} from '@grafana/data';
import { useTheme2 } from '@grafana/ui';
import { InfiniteScroll } from './InfiniteScroll';
import { preProcessLogs, LogListModel } from './processing';
import { getGridTemplateColumns } from './LogLine';
import { preProcessLogs, LogListModel, calculateFieldDimensions, LogFieldDimension } from './processing';
import {
getLogLineSize,
init as initVirtualization,
@@ -15,14 +27,18 @@ import {
storeLogLineSize,
} from './virtualization';
export type GetFieldLinksFn = (field: Field, rowIndex: number, dataFrame: DataFrame) => Array<LinkModel<Field>>;
interface Props {
app: CoreApp;
logs: LogRowModel[];
containerElement: HTMLDivElement;
displayedFields: string[];
eventBus: EventBus;
forceEscape?: boolean;
getFieldLinks?: GetFieldLinksFn;
initialScrollPosition?: 'top' | 'bottom';
loadMore?: (range: AbsoluteTimeRange) => void;
logs: LogRowModel[];
showTime: boolean;
sortOrder: LogsSortOrder;
timeRange: TimeRange;
@@ -33,8 +49,10 @@ interface Props {
export const LogList = ({
app,
containerElement,
displayedFields = [],
eventBus,
forceEscape = false,
getFieldLinks,
initialScrollPosition = 'top',
loadMore,
logs,
@@ -52,6 +70,11 @@ export const LogList = ({
const listRef = useRef<VariableSizeList | null>(null);
const widthRef = useRef(containerElement.clientWidth);
const scrollRef = useRef<HTMLDivElement | null>(null);
const dimensions = useMemo(
() => (wrapLogMessage ? [] : calculateFieldDimensions(processedLogs, displayedFields)),
[displayedFields, processedLogs, wrapLogMessage]
);
const styles = getStyles(dimensions, { showTime });
useEffect(() => {
initVirtualization(theme);
@@ -65,9 +88,11 @@ export const LogList = ({
}, [eventBus, logs.length]);
useEffect(() => {
setProcessedLogs(preProcessLogs(logs, { wrap: wrapLogMessage, escape: forceEscape, order: sortOrder, timeZone }));
setProcessedLogs(
preProcessLogs(logs, { getFieldLinks, wrap: wrapLogMessage, escape: forceEscape, order: sortOrder, timeZone })
);
listRef.current?.resetAfterIndex(0);
}, [forceEscape, logs, sortOrder, timeZone, wrapLogMessage]);
}, [forceEscape, getFieldLinks, logs, sortOrder, timeZone, wrapLogMessage]);
useEffect(() => {
const handleResize = debounce(() => {
@@ -110,6 +135,7 @@ export const LogList = ({
return (
<InfiniteScroll
displayedFields={displayedFields}
handleOverflow={handleOverflow}
logs={processedLogs}
loadMore={loadMore}
@@ -123,9 +149,13 @@ export const LogList = ({
>
{({ getItemKey, itemCount, onItemsRendered, Renderer }) => (
<VariableSizeList
className={styles.logList}
height={listHeight}
itemCount={itemCount}
itemSize={getLogLineSize.bind(null, processedLogs, containerElement, { wrap: wrapLogMessage, showTime })}
itemSize={getLogLineSize.bind(null, processedLogs, containerElement, displayedFields, {
wrap: wrapLogMessage,
showTime,
})}
itemKey={getItemKey}
layout="vertical"
onItemsRendered={onItemsRendered}
@@ -141,6 +171,18 @@ export const LogList = ({
);
};
function getStyles(dimensions: LogFieldDimension[], { showTime }: { showTime: boolean }) {
const columns = showTime ? dimensions : dimensions.filter((_, index) => index > 0);
return {
logList: css({
'& .unwrapped-log-line': {
display: 'grid',
gridTemplateColumns: getGridTemplateColumns(columns),
},
}),
};
}
function handleScrollToEvent(event: ScrollToLogsEvent, logsCount: number, list: VariableSizeList | null) {
if (event.payload.scrollTo === 'top') {
list?.scrollTo(0);
@@ -1,22 +1,28 @@
import { dateTimeFormat, LogRowModel, LogsSortOrder } from '@grafana/data';
import { dateTimeFormat, LogLevel, LogRowModel, LogsSortOrder } from '@grafana/data';
import { escapeUnescapedString, sortLogRows } from '../../utils';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { FieldDef, getAllFields } from '../logParser';
import { getDisplayedFieldValue } from './LogLine';
import { GetFieldLinksFn } from './LogList';
import { measureTextWidth } from './virtualization';
export interface LogListModel extends LogRowModel {
body: string;
displayLevel: string;
fields: FieldDef[];
timestamp: string;
dimensions: LogDimensions;
}
export interface LogDimensions {
timestampWidth: number;
levelWidth: number;
export interface LogFieldDimension {
field: string;
width: number;
}
interface PreProcessOptions {
escape: boolean;
getFieldLinks?: GetFieldLinksFn;
order: LogsSortOrder;
timeZone: string;
wrap: boolean;
@@ -24,19 +30,23 @@ interface PreProcessOptions {
export const preProcessLogs = (
logs: LogRowModel[],
{ escape, order, timeZone, wrap }: PreProcessOptions
{ escape, getFieldLinks, order, timeZone, wrap }: PreProcessOptions
): LogListModel[] => {
const orderedLogs = sortLogRows(logs, order);
return orderedLogs.map((log) => preProcessLog(log, { wrap, escape, timeZone, expanded: false }));
return orderedLogs.map((log) => preProcessLog(log, { escape, expanded: false, getFieldLinks, timeZone, wrap }));
};
interface PreProcessLogOptions {
escape: boolean;
expanded: boolean; // Not yet implemented
getFieldLinks?: GetFieldLinksFn;
timeZone: string;
wrap: boolean;
}
const preProcessLog = (log: LogRowModel, { escape, expanded, timeZone, wrap }: PreProcessLogOptions): LogListModel => {
const preProcessLog = (
log: LogRowModel,
{ escape, expanded, getFieldLinks, timeZone, wrap }: PreProcessLogOptions
): LogListModel => {
let body = log.entry;
const timestamp = dateTimeFormat(log.timeEpochMs, {
timeZone,
@@ -54,10 +64,65 @@ const preProcessLog = (log: LogRowModel, { escape, expanded, timeZone, wrap }: P
return {
...log,
body,
displayLevel: logLevelToDisplayLevel(log.logLevel),
fields: getAllFields(log, getFieldLinks),
timestamp,
dimensions: {
timestampWidth: measureTextWidth(timestamp),
levelWidth: measureTextWidth(log.logLevel),
},
};
};
function logLevelToDisplayLevel(level = '') {
switch (level) {
case LogLevel.critical:
return 'crit';
case LogLevel.warning:
return 'warn';
case LogLevel.unknown:
return '';
default:
return level;
}
}
export const calculateFieldDimensions = (logs: LogListModel[], displayedFields: string[] = []) => {
if (!logs.length) {
return [];
}
let timestampWidth = 0;
let levelWidth = 0;
const fieldWidths: Record<string, number> = {};
for (let i = 0; i < logs.length; i++) {
let width = measureTextWidth(logs[i].timestamp);
if (width > timestampWidth) {
timestampWidth = Math.round(width);
}
width = measureTextWidth(logs[i].displayLevel);
if (width > levelWidth) {
levelWidth = Math.round(width);
}
for (const field of displayedFields) {
width = measureTextWidth(getDisplayedFieldValue(field, logs[i]));
fieldWidths[field] = !fieldWidths[field] || width > fieldWidths[field] ? Math.round(width) : fieldWidths[field];
}
}
const dimensions: LogFieldDimension[] = [
{
field: 'timestamp',
width: timestampWidth,
},
{
field: 'level',
width: levelWidth,
},
];
for (const field in fieldWidths) {
// Skip the log line when it's a displayed field
if (field === LOG_LINE_BODY_FIELD_NAME) {
continue;
}
dimensions.push({
field,
width: fieldWidths[field],
});
}
return dimensions;
};
@@ -1,5 +1,6 @@
import { BusEventWithPayload, GrafanaTheme2 } from '@grafana/data';
import { getDisplayedFieldValue } from './LogLine';
import { LogListModel } from './processing';
let ctx: CanvasRenderingContext2D | null = null;
@@ -8,6 +9,9 @@ let paddingBottom = gridSize * 0.75;
let lineHeight = 22;
let measurementMode: 'canvas' | 'dom' = 'canvas';
// 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 function init(theme: GrafanaTheme2) {
const font = `${theme.typography.fontSize}px ${theme.typography.fontFamilyMonospace}`;
const letterSpacing = theme.typography.body.letterSpacing;
@@ -146,6 +150,7 @@ interface DisplayOptions {
export function getLogLineSize(
logs: LogListModel[],
container: HTMLDivElement | null,
displayedFields: string[],
{ wrap, showTime }: DisplayOptions,
index: number
) {
@@ -160,15 +165,26 @@ export function getLogLineSize(
if (storedSize) {
return storedSize;
}
const gap = gridSize;
let textToMeasure = '';
const gap = gridSize * FIELD_GAP_MULTIPLIER;
let optionsWidth = 0;
if (showTime) {
optionsWidth += logs[index].dimensions.timestampWidth + gap;
optionsWidth += gap;
textToMeasure += logs[index].timestamp;
}
if (logs[index].logLevel) {
optionsWidth += logs[index].dimensions.levelWidth + gap;
optionsWidth += gap;
textToMeasure += logs[index].logLevel;
}
const { height } = measureTextHeight(logs[index].body, getLogContainerWidth(container), optionsWidth);
for (const field of displayedFields) {
textToMeasure = getDisplayedFieldValue(field, logs[index]) + textToMeasure;
}
if (!displayedFields.length) {
textToMeasure += logs[index].body;
}
const { height } = measureTextHeight(textToMeasure, getLogContainerWidth(container), optionsWidth);
return height;
}
@@ -102,6 +102,7 @@ export const LogsPanel = ({
<LogList
app={CoreApp.Dashboard}
containerElement={logsContainer}
displayedFields={[]}
eventBus={eventBus}
initialScrollPosition={initialScrollPosition}
logs={logs}