From 89e8a038599dfeaae73e5df94528e4aba7aa4537 Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Tue, 8 Jul 2025 11:57:28 +0200 Subject: [PATCH] New Log Details: Add support to sort displayed fields (#107635) * Create LogLineDetailsDisplayedFields * Log labels: properly implement plurals * Pluralize * LogListContext: pass setDisplayedFields * LogLineDetailsDisplayedFields: update displayed fields * LogLineDetails: scroll to item after opening details * LogListContext: serve logOptionsStorageKey * Update test * Missing translations * LogLineDetailsDisplayedFields: update styles * LogLineDetails: update text selectors * Update tests * Reorganize log details: improve discoverability * Update test --- public/app/features/explore/Logs/Logs.tsx | 1 + .../components/panel/LogLineDetails.test.tsx | 46 ++++++++-- .../logs/components/panel/LogLineDetails.tsx | 14 ++- .../panel/LogLineDetailsComponent.tsx | 17 +++- .../panel/LogLineDetailsDisplayedFields.tsx | 91 +++++++++++++++++++ .../logs/components/panel/LogList.tsx | 26 +++++- .../logs/components/panel/LogListContext.tsx | 4 + public/app/features/logs/utils.ts | 12 +-- public/app/plugins/panel/logs/LogsPanel.tsx | 1 + public/locales/en-US/grafana.json | 15 +-- 10 files changed, 197 insertions(+), 30 deletions(-) create mode 100644 public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index c51cbfb7d4d..897bca4f24d 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -1144,6 +1144,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { permalinkedLogId={panelState?.logs?.id} pinLineButtonTooltipTitle={pinLineButtonTooltipTitle} pinnedLogs={pinnedLogs} + setDisplayedFields={setDisplayedFields} showControls showTime={showTime} sortOrder={logsSortOrder} diff --git a/public/app/features/logs/components/panel/LogLineDetails.test.tsx b/public/app/features/logs/components/panel/LogLineDetails.test.tsx index 626863e4aec..05c9287e7c3 100644 --- a/public/app/features/logs/components/panel/LogLineDetails.test.tsx +++ b/public/app/features/logs/components/panel/LogLineDetails.test.tsx @@ -40,6 +40,7 @@ const setup = ( const props: Props = { containerElement: document.createElement('div'), + focusLogLine: jest.fn(), logs, onResize: jest.fn(), ...(propOverrides || {}), @@ -198,8 +199,8 @@ describe('LogLineDetails', () => { setup(undefined, { entry: '' }); expect(screen.queryByText('Fields')).not.toBeInTheDocument(); expect(screen.queryByText('Links')).not.toBeInTheDocument(); - expect(screen.queryByText('Indexed labels')).not.toBeInTheDocument(); - expect(screen.queryByText('Parsed fields')).not.toBeInTheDocument(); + expect(screen.queryByText(/Indexed label/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Parsed field/)).not.toBeInTheDocument(); expect(screen.queryByText('Structured metadata')).not.toBeInTheDocument(); }); }); @@ -400,8 +401,8 @@ describe('LogLineDetails', () => { expect(screen.getByText('value2')).toBeInTheDocument(); expect(screen.getByText('label3')).toBeInTheDocument(); expect(screen.getByText('value3')).toBeInTheDocument(); - expect(screen.getByText('Indexed labels')).toBeInTheDocument(); - expect(screen.getByText('Parsed fields')).toBeInTheDocument(); + expect(screen.getByText(/Indexed label/)).toBeInTheDocument(); + expect(screen.getByText(/Parsed field/)).toBeInTheDocument(); expect(screen.getByText('Structured metadata')).toBeInTheDocument(); }); test('should not show label types if they are unavailable or not supported', () => { @@ -427,8 +428,8 @@ describe('LogLineDetails', () => { expect(screen.getByText('value3')).toBeInTheDocument(); expect(screen.getByText('Fields')).toBeInTheDocument(); - expect(screen.queryByText('Indexed labels')).not.toBeInTheDocument(); - expect(screen.queryByText('Parsed fields')).not.toBeInTheDocument(); + expect(screen.queryByText(/Indexed label/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Parsed field/)).not.toBeInTheDocument(); expect(screen.queryByText('Structured metadata')).not.toBeInTheDocument(); }); @@ -457,4 +458,37 @@ describe('LogLineDetails', () => { expect(screen.getAllByText('No results to display.')).toHaveLength(3); }); }); + + describe('Label types', () => { + test('Does not show displayed fields controls if not present', () => { + setup(undefined, { labels: { key1: 'label1', key2: 'label2' } }); + expect(screen.queryByText('Displayed fields')).not.toBeInTheDocument(); + }); + + test('Does not show displayed fields controls if required props are not present', () => { + setup(undefined, { labels: { key1: 'label1', key2: 'label2' } }, { displayedFields: ['key1', 'key2'] }); + expect(screen.queryByText('Displayed fields')).not.toBeInTheDocument(); + }); + + test('Shows displayed fields controls if required props are present', async () => { + const setDisplayedFields = jest.fn(); + const onClickHideField = jest.fn(); + setup( + undefined, + { labels: { key1: 'label1', key2: 'label2' } }, + { displayedFields: ['key1', 'key2'], setDisplayedFields, onClickHideField } + ); + + expect(screen.getByText('Organize displayed fields')).toBeInTheDocument(); + expect(screen.queryAllByLabelText('Remove field')).toHaveLength(0); + + await userEvent.click(screen.getByText('Organize displayed fields')); + + expect(screen.getAllByLabelText('Remove field')).toHaveLength(2); + + await userEvent.click(screen.getAllByLabelText('Remove field')[0]); + + expect(onClickHideField).toHaveBeenCalledWith('key1'); + }); + }); }); diff --git a/public/app/features/logs/components/panel/LogLineDetails.tsx b/public/app/features/logs/components/panel/LogLineDetails.tsx index 3f04338bf92..212da50add7 100644 --- a/public/app/features/logs/components/panel/LogLineDetails.tsx +++ b/public/app/features/logs/components/panel/LogLineDetails.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; import { Resizable } from 're-resizable'; -import { useCallback, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { getDragStyles, useStyles2 } from '@grafana/ui'; @@ -12,17 +12,23 @@ import { LOG_LIST_MIN_WIDTH } from './virtualization'; export interface Props { containerElement: HTMLDivElement; - logOptionsStorageKey?: string; + focusLogLine: (log: LogListModel) => void; logs: LogListModel[]; onResize(): void; } -export const LogLineDetails = ({ containerElement, logOptionsStorageKey, logs, onResize }: Props) => { - const { detailsWidth, setDetailsWidth, showDetails } = useLogListContext(); +export const LogLineDetails = ({ containerElement, focusLogLine, logs, onResize }: Props) => { + const { detailsWidth, logOptionsStorageKey, setDetailsWidth, showDetails } = useLogListContext(); const styles = useStyles2(getStyles); const dragStyles = useStyles2(getDragStyles); const containerRef = useRef(null); + useEffect(() => { + focusLogLine(showDetails[0]); + // Just once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const handleResize = useCallback(() => { if (containerRef.current) { setDetailsWidth(containerRef.current.clientWidth); diff --git a/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx b/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx index 7ae570d6500..fefd7004879 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx @@ -10,8 +10,10 @@ import { getLabelTypeFromRow } from '../../utils'; import { useAttributesExtensionLinks } from '../LogDetails'; import { createLogLineLinks } from '../logParser'; +import { LogLineDetailsDisplayedFields } from './LogLineDetailsDisplayedFields'; import { LabelWithLinks, LogLineDetailsFields, LogLineDetailsLabelFields } from './LogLineDetailsFields'; import { LogLineDetailsHeader } from './LogLineDetailsHeader'; +import { useLogListContext } from './LogListContext'; import { LogListModel } from './processing'; interface LogLineDetailsComponentProps { @@ -21,6 +23,7 @@ interface LogLineDetailsComponentProps { } export const LogLineDetailsComponent = ({ log, logOptionsStorageKey, logs }: LogLineDetailsComponentProps) => { + const { displayedFields, setDisplayedFields } = useLogListContext(); const [search, setSearch] = useState(''); const inputRef = useRef(''); const styles = useStyles2(getStyles); @@ -65,10 +68,12 @@ export const LogLineDetailsComponent = ({ log, logOptionsStorageKey, logs }: Log const fieldsOpen = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.log-details.fieldsOpen`, true) : true; + const displayedFieldsOpen = logOptionsStorageKey + ? store.getBool(`${logOptionsStorageKey}.log-details.displayedFieldsOpen`, false) + : false; const handleToggle = useCallback( (option: string, isOpen: boolean) => { - console.log(option, isOpen); store.set(`${logOptionsStorageKey}.log-details.${option}`, isOpen); }, [logOptionsStorageKey] @@ -100,6 +105,16 @@ export const LogLineDetailsComponent = ({ log, logOptionsStorageKey, logs }: Log >
{log.raw}
+ {displayedFields.length > 0 && setDisplayedFields && ( + handleToggle('displayedFieldsOpen', isOpen)} + > + + + )} {fieldsWithLinks.links.length > 0 && ( { + const { displayedFields, setDisplayedFields } = useLogListContext(); + + const onDragEnd = useCallback( + (result: DropResult) => { + if (result.destination == null) { + return; + } + + const newDisplayedFields = [...displayedFields]; + const element = displayedFields[result.source.index]; + newDisplayedFields.splice(result.source.index, 1); + newDisplayedFields.splice(result.destination.index, 0, element); + + setDisplayedFields?.(newDisplayedFields); + }, + [displayedFields, setDisplayedFields] + ); + + return ( +
+ + + {(provided) => { + return ( + <> +
+ {displayedFields.map((field, index) => ( + + ))} +
+ {provided.placeholder} + + ); + }} +
+
+
+ ); +}; + +interface DraggableDisplayedFieldProps { + field: string; + index: number; +} + +const DraggableDisplayedField = ({ field, index }: DraggableDisplayedFieldProps) => { + const { onClickHideField } = useLogListContext(); + const styles = useStyles2(getStyles); + 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) => ({ + fieldCard: css({ + cursor: 'move', + padding: theme.spacing(1), + marginBottom: theme.spacing(1), + wordBreak: 'break-word', + }), +}); diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 933ca2dde59..489741fd59b 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { debounce } from 'lodash'; import { Grammar } from 'prismjs'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, MouseEvent } from 'react'; -import { VariableSizeList } from 'react-window'; +import { Align, VariableSizeList } from 'react-window'; import { AbsoluteTimeRange, @@ -73,6 +73,7 @@ export interface Props { permalinkedLogId?: string; pinLineButtonTooltipTitle?: PopoverContent; pinnedLogs?: string[]; + setDisplayedFields?: (displayedFields: string[]) => void; showControls: boolean; showTime: boolean; sortOrder: LogsSortOrder; @@ -92,6 +93,7 @@ type LogListComponentProps = Omit< | 'dedupStrategy' | 'displayedFields' | 'enableLogDetails' + | 'logOptionsStorageKey' | 'permalinkedLogId' | 'showTime' | 'sortOrder' @@ -135,6 +137,7 @@ export const LogList = ({ permalinkedLogId, pinLineButtonTooltipTitle, pinnedLogs, + setDisplayedFields, showControls, showTime, sortOrder, @@ -176,6 +179,7 @@ export const LogList = ({ permalinkedLogId={permalinkedLogId} pinLineButtonTooltipTitle={pinLineButtonTooltipTitle} pinnedLogs={pinnedLogs} + setDisplayedFields={setDisplayedFields} showControls={showControls} showTime={showTime} sortOrder={sortOrder} @@ -191,7 +195,6 @@ export const LogList = ({ initialScrollPosition={initialScrollPosition} loading={loading} loadMore={loadMore} - logOptionsStorageKey={logOptionsStorageKey} logs={logs} showControls={showControls} timeRange={timeRange} @@ -210,7 +213,6 @@ const LogListComponent = ({ initialScrollPosition = 'top', loading, loadMore, - logOptionsStorageKey, logs, showControls, timeRange, @@ -271,6 +273,12 @@ const LogListComponent = ({ }, 25); }, []); + const debouncedScrollToItem = useMemo(() => { + return debounce((index: number, align?: Align) => { + listRef.current?.scrollToItem(index, align); + }, 250); + }, []); + useEffect(() => { const subscription = eventBus.subscribe(ScrollToLogsEvent, (e: ScrollToLogsEvent) => handleScrollToEvent(e, logs.length, listRef.current) @@ -380,6 +388,16 @@ const LogListComponent = ({ [filterLogs, levelFilteredLogs, matchingUids] ); + const focusLogLine = useCallback( + (log: LogListModel) => { + const index = filteredLogs.indexOf(log); + if (index >= 0) { + debouncedScrollToItem(index, 'start'); + } + }, + [debouncedScrollToItem, filteredLogs] + ); + return (
@@ -461,7 +479,7 @@ const LogListComponent = ({ {showDetails.length > 0 && ( diff --git a/public/app/features/logs/components/panel/LogListContext.tsx b/public/app/features/logs/components/panel/LogListContext.tsx index c638437aa2a..1ca6100a1d1 100644 --- a/public/app/features/logs/components/panel/LogListContext.tsx +++ b/public/app/features/logs/components/panel/LogListContext.tsx @@ -162,6 +162,7 @@ export interface Props { pinLineButtonTooltipTitle?: PopoverContent; pinnedLogs?: string[]; prettifyJSON?: boolean; + setDisplayedFields?: (displayedFields: string[]) => void; showControls: boolean; showUniqueLabels?: boolean; showTime: boolean; @@ -204,6 +205,7 @@ export const LogListContextProvider = ({ pinLineButtonTooltipTitle, pinnedLogs, prettifyJSON, + setDisplayedFields, showControls, showTime, showUniqueLabels, @@ -492,6 +494,7 @@ export const LogListContextProvider = ({ getRowContextQuery, logSupportsContext, logLineMenuCustomItems, + logOptionsStorageKey, onClickFilterLabel, onClickFilterOutLabel, onClickFilterString, @@ -509,6 +512,7 @@ export const LogListContextProvider = ({ prettifyJSON: logListState.prettifyJSON, setDedupStrategy, setDetailsWidth, + setDisplayedFields, setFilterLevels, setFontSize, setForceEscape, diff --git a/public/app/features/logs/utils.ts b/public/app/features/logs/utils.ts index 8524f6f1956..0d16732ba7d 100644 --- a/public/app/features/logs/utils.ts +++ b/public/app/features/logs/utils.ts @@ -408,17 +408,11 @@ function getDataSourceLabelType(labelType: string, datasourceType: string, plura case 'loki': switch (labelType) { case 'I': - return plural - ? t('logs.fields.type.loki.indexed-label-plural', 'Indexed labels') - : t('logs.fields.type.loki.indexed-label', 'Indexed label'); + return t('logs.fields.type.loki.indexed-label', 'Indexed label', { count: plural ? 2 : 1 }); case 'S': - return plural - ? t('logs.fields.type.loki.structured-metadata-plural', 'Structured metadata') - : t('logs.fields.type.loki.structured-metadata', 'Structured metadata'); + return t('logs.fields.type.loki.structured-metadata', 'Structured metadata', { count: plural ? 2 : 1 }); case 'P': - return plural - ? t('logs.fields.type.loki.parsed-label-plural', 'Parsed fields') - : t('logs.fields.type.loki.parsedl-label', 'Parsed field'); + return t('logs.fields.type.loki.parsedl-label', 'Parsed field', { count: plural ? 2 : 1 }); default: return null; } diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index a55bdd30a46..709055ce389 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -565,6 +565,7 @@ export const LogsPanel = ({ onOpenContext={onOpenContext} onPermalinkClick={showPermaLink() ? onPermalinkClick : undefined} permalinkedLogId={getLogsPanelState()?.logs?.id ?? undefined} + setDisplayedFields={setDisplayedFields} showControls={Boolean(showControls)} showTime={showTime} sortOrder={sortOrder} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 2a3a370bb07..197750619cf 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -8644,12 +8644,12 @@ "fields": { "type": { "loki": { - "indexed-label": "Indexed label", - "indexed-label-plural": "Indexed labels", - "parsed-label-plural": "Parsed fields", - "parsedl-label": "Parsed field", - "structured-metadata": "Structured metadata", - "structured-metadata-plural": "Structured metadata" + "indexed-label_one": "Indexed label", + "indexed-label_other": "Indexed labels", + "parsedl-label_one": "Parsed field", + "parsedl-label_other": "Parsed fields", + "structured-metadata_one": "Structured metadata", + "structured-metadata_other": "Structured metadata" } } }, @@ -8706,6 +8706,7 @@ "close": "Close log details", "copy-shortlink": "Copy shortlink", "copy-to-clipboard": "Copy to clipboard", + "displayed-fields-section": "Organize displayed fields", "fields": { "adhoc-statistics": "Ad-hoc statistics", "copy-value-to-clipboard": "Copy value to clipboard", @@ -8719,9 +8720,11 @@ "fields-section": "Fields", "hide-log-line": "Hide log line", "links-section": "Links", + "log-line-field": "Log line", "log-line-section": "Log line", "no-details": "No fields to display.", "pin-line": "Pin log", + "remove-displayed-field": "Remove field", "search": { "no-results": "No results to display." },