From b839090de5c1824885a6f6087bc15b99bec2549b Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Tue, 15 Jul 2025 18:17:20 +0200 Subject: [PATCH] New logs Panel: Log details improvements + default details value update (#108065) * LogLineDetails: prettify json field values * processing: prettify json according to wrap status * LogLineDetailsField: better column width for keys * LogsPanel: let app have automatic details mode * Fix prop name * LogLineDetails: memoize * LogLineDisplayedFields: add icons to organize * Displayed fields: add cursor move * LogLineDetailsFields: improve key columns * Remove test * Translations * Formatting * LogLineDetailsFields: minor refactor and fix * LogLineDetails: remember inline scroll position * LogLineDetails: test displayed fields reorg * Update mock * LogLineDetailsComponent: add missing disable actions * Log details copy button: gap = 0 * LogLineDetailsDisplayedFields: use fixed size for inline fields --- .../logs/components/LogDetailsRow.tsx | 1 + .../components/panel/LogLineDetails.test.tsx | 24 ++++ .../logs/components/panel/LogLineDetails.tsx | 23 +++- .../panel/LogLineDetailsComponent.tsx | 9 +- .../panel/LogLineDetailsDisplayedFields.tsx | 127 +++++++++++++----- .../components/panel/LogLineDetailsFields.tsx | 47 +++++-- .../logs/components/panel/LogListContext.tsx | 19 ++- .../panel/__mocks__/LogListContext.tsx | 6 + .../logs/components/panel/processing.ts | 2 +- public/app/plugins/panel/logs/LogsPanel.tsx | 5 +- public/app/plugins/panel/logs/module.tsx | 1 - public/locales/en-US/grafana.json | 2 + 12 files changed, 207 insertions(+), 59 deletions(-) diff --git a/public/app/features/logs/components/LogDetailsRow.tsx b/public/app/features/logs/components/LogDetailsRow.tsx index d672ec49be9..b0138e2e867 100644 --- a/public/app/features/logs/components/LogDetailsRow.tsx +++ b/public/app/features/logs/components/LogDetailsRow.tsx @@ -83,6 +83,7 @@ const getStyles = memoizeOne((theme: GrafanaTheme2) => { }), copyButton: css({ '& > button': { + gap: 0, color: theme.colors.text.secondary, padding: 0, justifyContent: 'center', diff --git a/public/app/features/logs/components/panel/LogLineDetails.test.tsx b/public/app/features/logs/components/panel/LogLineDetails.test.tsx index 05c9287e7c3..fd4e5be7af3 100644 --- a/public/app/features/logs/components/panel/LogLineDetails.test.tsx +++ b/public/app/features/logs/components/panel/LogLineDetails.test.tsx @@ -490,5 +490,29 @@ describe('LogLineDetails', () => { expect(onClickHideField).toHaveBeenCalledWith('key1'); }); + + test('Exposes buttons to reorder displayed fields', async () => { + const setDisplayedFields = jest.fn(); + const onClickHideField = jest.fn(); + setup( + undefined, + { labels: { key1: 'label1', key2: 'label2' } }, + { displayedFields: ['key1', 'key2', 'key3'], setDisplayedFields, onClickHideField } + ); + + await userEvent.click(screen.getByText('Organize displayed fields')); + + expect(screen.getAllByLabelText('Remove field')).toHaveLength(3); + expect(screen.getAllByLabelText('Move down')).toHaveLength(3); + expect(screen.getAllByLabelText('Move up')).toHaveLength(3); + + await userEvent.click(screen.getAllByLabelText('Move down')[0]); + + expect(setDisplayedFields).toHaveBeenCalledWith(['key2', 'key1', 'key3']); + + await userEvent.click(screen.getAllByLabelText('Move up')[2]); + + expect(setDisplayedFields).toHaveBeenCalledWith(['key1', 'key3', 'key2']); + }); }); }); diff --git a/public/app/features/logs/components/panel/LogLineDetails.tsx b/public/app/features/logs/components/panel/LogLineDetails.tsx index dbd2990ea6b..fee8143396f 100644 --- a/public/app/features/logs/components/panel/LogLineDetails.tsx +++ b/public/app/features/logs/components/panel/LogLineDetails.tsx @@ -1,12 +1,12 @@ import { css } from '@emotion/css'; import { Resizable } from 're-resizable'; -import { useCallback, useEffect, useRef } from 'react'; +import { memo, useCallback, useEffect, useRef } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { getDragStyles, useStyles2 } from '@grafana/ui'; import { LogLineDetailsComponent } from './LogLineDetailsComponent'; -import { useLogListContext } from './LogListContext'; +import { getDetailsScrollPosition, saveDetailsScrollPosition, useLogListContext } from './LogListContext'; import { LogListModel } from './processing'; import { LOG_LIST_MIN_WIDTH } from './virtualization'; @@ -67,9 +67,21 @@ export interface InlineLogLineDetailsProps { logs: LogListModel[]; } -export const InlineLogLineDetails = ({ logs }: InlineLogLineDetailsProps) => { +export const InlineLogLineDetails = memo(({ logs }: InlineLogLineDetailsProps) => { const { showDetails } = useLogListContext(); const styles = useStyles2(getStyles, 'inline'); + const scrollRef = useRef(null); + + const saveScroll = useCallback(() => { + saveDetailsScrollPosition(showDetails[0], scrollRef.current?.scrollTop ?? 0); + }, [showDetails]); + + useEffect(() => { + if (!scrollRef.current) { + return; + } + scrollRef.current.scrollTop = getDetailsScrollPosition(showDetails[0]); + }, [showDetails]); if (!showDetails.length) { return null; @@ -78,13 +90,14 @@ export const InlineLogLineDetails = ({ logs }: InlineLogLineDetailsProps) => { return (
-
+
); -}; +}); +InlineLogLineDetails.displayName = 'InlineLogLineDetails'; export const LOG_LINE_DETAILS_HEIGHT = 35; diff --git a/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx b/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx index 424266d7b09..d38eff282f9 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; import { camelCase, groupBy } from 'lodash'; -import { startTransition, useCallback, useMemo, useRef, useState } from 'react'; +import { memo, startTransition, useCallback, useMemo, useRef, useState } from 'react'; import { DataFrameType, GrafanaTheme2, store } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; @@ -22,7 +22,7 @@ interface LogLineDetailsComponentProps { logs: LogListModel[]; } -export const LogLineDetailsComponent = ({ log, logs }: LogLineDetailsComponentProps) => { +export const LogLineDetailsComponent = memo(({ log, logs }: LogLineDetailsComponentProps) => { const { displayedFields, logOptionsStorageKey, setDisplayedFields } = useLogListContext(); const [search, setSearch] = useState(''); const inputRef = useRef(''); @@ -123,7 +123,7 @@ export const LogLineDetailsComponent = ({ log, logs }: LogLineDetailsComponentPr isOpen={linksOpen} onToggle={(isOpen: boolean) => handleToggle('linksOpen', isOpen)} > - + ); -}; +}); +LogLineDetailsComponent.displayName = 'LogLineDetailsComponent'; function groupOptionName(group: string) { return `${camelCase(group)}Open`; diff --git a/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx b/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx index f7bbac50ae3..50e997ad80e 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'; +import { DragDropContext, Draggable, DraggableProvided, Droppable, DropResult } from '@hello-pangea/dnd'; import { useCallback } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; @@ -8,41 +8,52 @@ import { Card, IconButton, useStyles2 } from '@grafana/ui'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; +import { LogLineDetailsMode } from './LogLineDetails'; import { useLogListContext } from './LogListContext'; export const LogLineDetailsDisplayedFields = () => { const { displayedFields, setDisplayedFields } = useLogListContext(); - const onDragEnd = useCallback( - (result: DropResult) => { - if (result.destination == null) { - return; - } - + const reorganizeDisplayedFields = useCallback( + (srcIndex: number, destIndex: number) => { const newDisplayedFields = [...displayedFields]; - const element = displayedFields[result.source.index]; - newDisplayedFields.splice(result.source.index, 1); - newDisplayedFields.splice(result.destination.index, 0, element); + const element = displayedFields[srcIndex]; + + newDisplayedFields.splice(srcIndex, 1); + newDisplayedFields.splice(destIndex, 0, element); setDisplayedFields?.(newDisplayedFields); }, [displayedFields, setDisplayedFields] ); + const onDragEnd = useCallback( + (result: DropResult) => { + if (result.destination == null) { + return; + } + reorganizeDisplayedFields(result.source.index, result.destination.index); + }, + [reorganizeDisplayedFields] + ); + return (
- + {(provided) => { return ( - <> -
- {displayedFields.map((field, index) => ( - - ))} -
+
+ {displayedFields.map((field, index) => ( + + ))} {provided.placeholder} - +
); }}
@@ -54,38 +65,80 @@ export const LogLineDetailsDisplayedFields = () => { interface DraggableDisplayedFieldProps { field: string; index: number; + moveField: (srcIndex: number, destIndex: number) => void; } -const DraggableDisplayedField = ({ field, index }: DraggableDisplayedFieldProps) => { - const { onClickHideField } = useLogListContext(); - const styles = useStyles2(getStyles); +const DraggableDisplayedField = ({ field, index, moveField }: DraggableDisplayedFieldProps) => { return ( {(provided) => ( -
- -
- {field === LOG_LINE_BODY_FIELD_NAME ? t('logs.log-line-details.log-line-field', 'Log line') : field} -
- {onClickHideField && ( - onClickHideField(field)} - tooltip={t('logs.log-line-details.remove-displayed-field', 'Remove field')} - /> - )} -
-
+ )}
); }; -const getStyles = (theme: GrafanaTheme2) => ({ +const DisplayedField = ({ + field, + index, + moveField, + provided, +}: DraggableDisplayedFieldProps & { provided: DraggableProvided }) => { + const { detailsMode, displayedFields, onClickHideField } = useLogListContext(); + const styles = useStyles2(getStyles, detailsMode); + const nextIndex = index === displayedFields.length - 1 ? 0 : index + 1; + const prevIndex = index === 0 ? displayedFields.length - 1 : index - 1; + return ( +
+ +
+
+ {field === LOG_LINE_BODY_FIELD_NAME ? t('logs.log-line-details.log-line-field', 'Log line') : field} +
+ {displayedFields.length > 1 && ( + <> + moveField(index, nextIndex)} + tooltip={t('logs.log-line-details.move-displayed-field-down', 'Move down')} + /> + moveField(index, prevIndex)} + tooltip={t('logs.log-line-details.move-displayed-field-up', 'Move up')} + /> + + )} + {onClickHideField && ( + onClickHideField(field)} + tooltip={t('logs.log-line-details.remove-displayed-field', 'Remove field')} + /> + )} +
+
+
+ ); +}; + +const getStyles = (theme: GrafanaTheme2, detailsMode: LogLineDetailsMode) => ({ fieldCard: css({ - cursor: 'move', + display: 'block', padding: theme.spacing(1), marginBottom: theme.spacing(1), + width: detailsMode === 'inline' ? '30vw' : undefined, + overflow: 'hidden', + textOverflow: 'ellipsis', wordBreak: 'break-word', }), + fieldWrapper: css({ + cursor: 'move', + display: 'flex', + gap: theme.spacing(0.5), + justifyContent: 'space-evenly', + }), + field: css({ + flex: 1, + }), }); diff --git a/public/app/features/logs/components/panel/LogLineDetailsFields.tsx b/public/app/features/logs/components/panel/LogLineDetailsFields.tsx index 895de79f1cc..3f68e7e829a 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsFields.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsFields.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import { isEqual } from 'lodash'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import * as React from 'react'; +import { parse, stringify } from 'lossless-json'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { CoreApp, Field, fuzzySearch, GrafanaTheme2, IconName, LinkModel, LogLabelStatsModel } from '@grafana/data'; import { t } from '@grafana/i18n'; @@ -24,7 +24,7 @@ interface LogLineDetailsFieldsProps { search?: string; } -export const LogLineDetailsFields = ({ disableActions, fields, log, logs, search }: LogLineDetailsFieldsProps) => { +export const LogLineDetailsFields = memo(({ disableActions, fields, log, logs, search }: LogLineDetailsFieldsProps) => { if (!fields.length) { return null; } @@ -52,7 +52,8 @@ export const LogLineDetailsFields = ({ disableActions, fields, log, logs, search ))}
); -}; +}); +LogLineDetailsFields.displayName = 'LogLineDetailsFields'; interface LinkModelWithIcon extends LinkModel { icon?: IconName; @@ -104,12 +105,12 @@ const getFieldsStyles = (theme: GrafanaTheme2) => ({ fieldsTable: css({ display: 'grid', gap: theme.spacing(1), - gridTemplateColumns: `${theme.spacing(11.5)} minmax(15%, 30%) 1fr`, + gridTemplateColumns: `${theme.spacing(11.5)} auto 1fr`, }), fieldsTableNoActions: css({ display: 'grid', gap: theme.spacing(1), - gridTemplateColumns: `minmax(15%, 30%) 1fr`, + gridTemplateColumns: `auto 1fr`, }), }); @@ -148,6 +149,7 @@ export const LogLineDetailsField = ({ onClickHideField, onPinLine, pinLineButtonTooltipTitle, + syntaxHighlighting, } = useLogListContext(); const styles = useStyles2(getFieldStyles); @@ -309,8 +311,11 @@ export const LogLineDetailsField = ({
{singleKey ? keys[0] : }
- {singleValue ? values[0] : } - {singleValue && } + {singleValue ? ( + + ) : ( + + )}
@@ -406,10 +411,11 @@ const getFieldStyles = (theme: GrafanaTheme2) => ({ }), valueContainer: css({ display: 'flex', - alignItems: 'center', lineHeight: theme.typography.body.lineHeight, whiteSpace: 'pre-wrap', wordBreak: 'break-all', + maxHeight: '50vh', + overflow: 'auto', }), }); @@ -433,6 +439,7 @@ const getClipboardButtonStyles = (theme: GrafanaTheme2) => ({ button: css({ '& > button': { color: theme.colors.text.secondary, + gap: 0, padding: 0, justifyContent: 'center', borderRadius: theme.shape.radius.circle, @@ -472,6 +479,28 @@ const MultipleValue = ({ showCopy, values = [] }: { showCopy?: boolean; values: ); }; +const SingleValue = ({ value: originalValue, syntaxHighlighting }: { value: string; syntaxHighlighting?: boolean }) => { + const value = useMemo(() => { + if (!syntaxHighlighting) { + return originalValue; + } + try { + const parsed = stringify(parse(originalValue), undefined, 2); + if (parsed) { + return parsed; + } + } catch (error) {} + return originalValue; + }, [originalValue, syntaxHighlighting]); + + return ( + <> + {value} + + + ); +}; + interface AsyncIconButtonProps extends Pick, 'onClick'> { name: IconName; isActive(): Promise; diff --git a/public/app/features/logs/components/panel/LogListContext.tsx b/public/app/features/logs/components/panel/LogListContext.tsx index d27fe27d56c..d4e8dfea793 100644 --- a/public/app/features/logs/components/panel/LogListContext.tsx +++ b/public/app/features/logs/components/panel/LogListContext.tsx @@ -442,8 +442,11 @@ export const LogListContextProvider = ({ ); const closeDetails = useCallback(() => { + if (showDetails.length) { + removeDetailsScrollPosition(showDetails[0]); + } setShowDetails([]); - }, []); + }, [showDetails]); const toggleDetails = useCallback( (log: LogListModel) => { @@ -585,3 +588,17 @@ function getDetailsWidth( } return detailsWidth; } + +const detailsScrollMap = new Map(); + +export function saveDetailsScrollPosition(log: LogListModel, position: number) { + detailsScrollMap.set(log.uid, position); +} + +export function getDetailsScrollPosition(log: LogListModel) { + return detailsScrollMap.get(log.uid) ?? 0; +} + +export function removeDetailsScrollPosition(log: LogListModel) { + detailsScrollMap.delete(log.uid); +} diff --git a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx index 1a991f79ecf..949f61fc861 100644 --- a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx +++ b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx @@ -191,3 +191,9 @@ export const LogListContextProvider = ({ ); }; + +export const saveDetailsScrollPosition = jest.fn(); + +export const getDetailsScrollPosition = jest.fn(); + +export const removeDetailsScrollPosition = jest.fn(); diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts index d46b09db490..0872e241a30 100644 --- a/public/app/features/logs/components/panel/processing.ts +++ b/public/app/features/logs/components/panel/processing.ts @@ -110,7 +110,7 @@ export class LogListModel implements LogRowModel { get body(): string { if (this._body === undefined) { try { - const parsed = stringify(parse(this.raw), undefined, 2); + const parsed = stringify(parse(this.raw), undefined, this._wrapLogMessage ? 2 : 1); if (parsed) { this.raw = parsed; } diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index ed375de1044..971bb97365f 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -159,7 +159,7 @@ export const LogsPanel = ({ onNewLogsReceived, fontSize, syntaxHighlighting, - detailsMode, + detailsMode: detailsModeProp, ...options }, id, @@ -518,6 +518,9 @@ export const LogsPanel = ({ const onClickShowField = isOnClickShowField(options.onClickShowField) ? options.onClickShowField : showField; const onClickHideField = isOnClickHideField(options.onClickHideField) ? options.onClickHideField : hideField; + // In Dashboards, default to inline. Otherwise, let apps control or have automatic behavior. + const detailsMode = detailsModeProp ? detailsModeProp : app === CoreApp.Dashboard ? 'inline' : undefined; + return ( <> {contextRow && ( diff --git a/public/app/plugins/panel/logs/module.tsx b/public/app/plugins/panel/logs/module.tsx index de1562c4898..cccc4cfab01 100644 --- a/public/app/plugins/panel/logs/module.tsx +++ b/public/app/plugins/panel/logs/module.tsx @@ -123,7 +123,6 @@ export const plugin = new PanelPlugin(LogsPanel) }, ], }, - defaultValue: 'inline', }); } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index bce4bc8109f..d7243ee9e13 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -8811,6 +8811,8 @@ "links-section": "Links", "log-line-field": "Log line", "log-line-section": "Log line", + "move-displayed-field-down": "Move down", + "move-displayed-field-up": "Move up", "no-details": "No fields to display.", "pin-line": "Pin log", "remove-displayed-field": "Remove field",