From 03dcd25a3213255930b14ce0e0285c593ce14933 Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Thu, 27 Feb 2025 10:31:55 +0000 Subject: [PATCH] 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 --- public/app/features/explore/Logs/Logs.tsx | 2 + .../features/explore/Logs/LogsNavigation.tsx | 2 +- .../logs/components/panel/InfiniteScroll.tsx | 28 +++++- .../logs/components/panel/LogLine.tsx | 86 +++++++++++++++--- .../logs/components/panel/LogLineMessage.tsx | 9 +- .../logs/components/panel/LogList.tsx | 56 ++++++++++-- .../logs/components/panel/processing.ts | 89 ++++++++++++++++--- .../logs/components/panel/virtualization.ts | 24 ++++- .../app/plugins/panel/logs-new/LogsPanel.tsx | 1 + 9 files changed, 250 insertions(+), 47 deletions(-) diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index feefbb1bee7..24bc46790a5 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -1070,8 +1070,10 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { { return { navContainer: css({ maxHeight: navContainerHeight, - width: oldestLogsFirst ? '58px' : 'auto', + width: oldestLogsFirst && !config.featureToggles.newLogsPanel ? '58px' : 'auto', display: 'flex', flexDirection: 'column', justifyContent: config.featureToggles.logsInfiniteScrolling diff --git a/public/app/features/logs/components/panel/InfiniteScroll.tsx b/public/app/features/logs/components/panel/InfiniteScroll.tsx index 4f0e7a9584b..3790f5f0b0f 100644 --- a/public/app/features/logs/components/panel/InfiniteScroll.tsx +++ b/public/app/features/logs/components/panel/InfiniteScroll.tsx @@ -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(null); const countRef = useRef(0); const lastLogOfPage = useRef([]); + 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 ( - + {getMessageFromInfiniteLoaderState(infiniteLoaderState, sortOrder)} ); } return ( ); }, - [handleOverflow, infiniteLoaderState, logs, onLoadMore, showTime, sortOrder, wrapLogMessage] + [ + displayedFields, + handleOverflow, + infiniteLoaderState, + logs, + onLoadMore, + showTime, + sortOrder, + styles, + wrapLogMessage, + ] ); const onItemsRendered = useCallback( diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index bba06ff1ab0..d484675d424 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -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(null); useEffect(() => { @@ -34,16 +45,59 @@ export const LogLine = ({ index, log, style, onOverflow, showTime, variant, wrap }, [index, log.uid, onOverflow, style.height]); return ( -
-
- {showTime && {log.timestamp}} - {log.logLevel && {log.logLevel}} - {log.body} +
+
+
); }; +interface LogProps { + displayedFields: string[]; + log: LogListModel; + showTime: boolean; + styles: ReturnType; +} + +const Log = ({ displayedFields, log, showTime, styles }: LogProps) => { + return ( + <> + {showTime && {log.timestamp}} + {log.displayLevel} + {displayedFields.length > 0 ? ( + displayedFields.map((field) => ( + + {getDisplayedFieldValue(field, log)} + + )) + ) : ( + {log.body} + )} + + ); +}; + +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; 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, + }, }), }; }; diff --git a/public/app/features/logs/components/panel/LogLineMessage.tsx b/public/app/features/logs/components/panel/LogLineMessage.tsx index 2bdff1c03f5..9d2bfba0eed 100644 --- a/public/app/features/logs/components/panel/LogLineMessage.tsx +++ b/public/app/features/logs/components/panel/LogLineMessage.tsx @@ -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 (
{onClick ? ( diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 9e42cb73214..abc48cbe9ce 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -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>; + 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(null); const widthRef = useRef(containerElement.clientWidth); const scrollRef = useRef(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 ( {({ getItemKey, itemCount, onItemsRendered, Renderer }) => ( 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); diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts index 77f525614b5..2d62db31370 100644 --- a/public/app/features/logs/components/panel/processing.ts +++ b/public/app/features/logs/components/panel/processing.ts @@ -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 = {}; + 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; +}; diff --git a/public/app/features/logs/components/panel/virtualization.ts b/public/app/features/logs/components/panel/virtualization.ts index 201cc4c82ed..a367b41c050 100644 --- a/public/app/features/logs/components/panel/virtualization.ts +++ b/public/app/features/logs/components/panel/virtualization.ts @@ -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; } diff --git a/public/app/plugins/panel/logs-new/LogsPanel.tsx b/public/app/plugins/panel/logs-new/LogsPanel.tsx index 44dea0e5f54..9b0b26b4c23 100644 --- a/public/app/plugins/panel/logs-new/LogsPanel.tsx +++ b/public/app/plugins/panel/logs-new/LogsPanel.tsx @@ -102,6 +102,7 @@ export const LogsPanel = ({