From b70d1b072442c45385c210087b4ac826df01de7b Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Mon, 21 Jul 2025 13:02:20 +0200 Subject: [PATCH] New Log Details: Support multiple log details displayed (#108327) * LogLine: optionally highlight search based on syntax highlighting state * Log Details: support multiple details open * Translations * Add test * LogLine: allow to highlight frontend searches * LogLine: improve hover vs details displayed colors * LogLineDetails: add scroll to log line option and improve toggling behavior * Translations * LogLineDetails: prevent sidebar re-rendering * LogLineDetails: more tests --- .../logs/components/panel/LogLine.tsx | 10 +- .../components/panel/LogLineDetails.test.tsx | 84 +++++++++++++ .../logs/components/panel/LogLineDetails.tsx | 114 ++++++++++++------ .../panel/LogLineDetailsComponent.tsx | 5 +- .../components/panel/LogLineDetailsHeader.tsx | 47 +++++--- .../logs/components/panel/LogListContext.tsx | 11 +- public/locales/en-US/grafana.json | 2 + 7 files changed, 210 insertions(+), 63 deletions(-) diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index 7bc7edd9cd6..58abcb1b434 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -241,7 +241,7 @@ const LogLineComponent = memo( )} - {detailsMode === 'inline' && detailsShown && } + {detailsMode === 'inline' && detailsShown && } ); } @@ -323,7 +323,7 @@ const LogLineBody = ({ log, styles }: { log: LogListModel; styles: LogLineStyles const { matchingUids, search } = useLogListSearchContext(); const highlight = useMemo(() => { - const searchWords = log.searchWords && log.searchWords[0] ? log.searchWords.slice() : []; + const searchWords = syntaxHighlighting && log.searchWords && log.searchWords[0] ? log.searchWords.slice() : []; if (search && matchingUids?.includes(log.uid)) { searchWords.push(search); } @@ -331,7 +331,7 @@ const LogLineBody = ({ log, styles }: { log: LogListModel; styles: LogLineStyles return undefined; } return { searchWords, highlightClassName: styles.matchHighLight }; - }, [log.searchWords, log.uid, matchingUids, search, styles.matchHighLight]); + }, [log.searchWords, log.uid, matchingUids, search, styles.matchHighLight, syntaxHighlighting]); if (log.hasAnsi) { return ( @@ -376,7 +376,7 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali parsedField: theme.colors.text.primary, }; - const hoverColor = tinycolor(theme.colors.background.canvas).darken(4).toRgbString(); + const hoverColor = tinycolor(theme.colors.background.canvas).darken(5).toRgbString(); return { logLine: css({ @@ -450,7 +450,7 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali lineHeight: theme.typography.bodySmall.lineHeight, }), detailsDisplayed: css({ - background: hoverColor, + background: tinycolor(theme.colors.background.canvas).darken(2).toRgbString(), }), pinnedLogLine: css({ backgroundColor: tinycolor(theme.colors.info.transparent).setAlpha(0.25).toString(), diff --git a/public/app/features/logs/components/panel/LogLineDetails.test.tsx b/public/app/features/logs/components/panel/LogLineDetails.test.tsx index fd4e5be7af3..2ce960fdd86 100644 --- a/public/app/features/logs/components/panel/LogLineDetails.test.tsx +++ b/public/app/features/logs/components/panel/LogLineDetails.test.tsx @@ -515,4 +515,88 @@ describe('LogLineDetails', () => { expect(setDisplayedFields).toHaveBeenCalledWith(['key1', 'key3', 'key2']); }); }); + + describe('Multiple log details', () => { + test('Does not render tabs when displaying a single log', () => { + setup(undefined, { labels: { key1: 'label1', key2: 'label2' } }); + expect(screen.queryAllByRole('tab')).toHaveLength(0); + }); + + test('Renders multiple log details', async () => { + const logs = [ + createLogLine({ uid: '1', logLevel: LogLevel.error, timeEpochMs: 1546297200000, entry: 'First log' }), + createLogLine({ uid: '2', logLevel: LogLevel.error, timeEpochMs: 1546297200000, entry: 'Second log' }), + ]; + setup({ logs }, undefined, { showDetails: logs }); + + expect(screen.queryAllByRole('tab')).toHaveLength(2); + + await userEvent.click(screen.getByText('Log line')); + + expect(screen.getAllByText('First log')).toHaveLength(1); + expect(screen.getAllByText('Second log')).toHaveLength(2); + + await userEvent.click(screen.queryAllByRole('tab')[0]); + + expect(screen.getAllByText('First log')).toHaveLength(2); + expect(screen.getAllByText('Second log')).toHaveLength(1); + }); + + test('Changes details focus when logs are added and removed', async () => { + const logs = [ + createLogLine({ uid: '1', logLevel: LogLevel.error, timeEpochMs: 1546297200000, entry: 'First log' }), + createLogLine({ uid: '2', logLevel: LogLevel.error, timeEpochMs: 1546297200000, entry: 'Second log' }), + ]; + + const props: Props = { + containerElement: document.createElement('div'), + focusLogLine: jest.fn(), + logs: [logs[0]], + onResize: jest.fn(), + }; + + const contextData: LogListContextData = { + ...defaultValue, + showDetails: [logs[0]], + }; + + const { rerender } = render( + + + + ); + + expect(screen.queryAllByRole('tab')).toHaveLength(0); + + await userEvent.click(screen.getByText('Log line')); + // Tab not displayed, only line body + expect(screen.getAllByText('First log')).toHaveLength(1); + + contextData.showDetails = logs; + props.logs = logs; + + rerender( + + + + ); + + expect(screen.queryAllByRole('tab')).toHaveLength(2); + // Tab and log line body + expect(screen.getAllByText('Second log')).toHaveLength(2); + + contextData.showDetails = [logs[1]]; + props.logs = [logs[1]]; + + rerender( + + + + ); + + expect(screen.queryAllByRole('tab')).toHaveLength(0); + // Tab not displayed, only line body + expect(screen.getAllByText('Second log')).toHaveLength(1); + }); + }); }); diff --git a/public/app/features/logs/components/panel/LogLineDetails.tsx b/public/app/features/logs/components/panel/LogLineDetails.tsx index 1065ec2456e..7a4e5a2e425 100644 --- a/public/app/features/logs/components/panel/LogLineDetails.tsx +++ b/public/app/features/logs/components/panel/LogLineDetails.tsx @@ -1,10 +1,12 @@ import { css } from '@emotion/css'; import { Resizable } from 're-resizable'; -import { memo, useCallback, useEffect, useRef } from 'react'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { usePrevious } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; -import { getDragStyles, useStyles2 } from '@grafana/ui'; +import { getDragStyles, Icon, Tab, TabsBar, useStyles2 } from '@grafana/ui'; import { LogLineDetailsComponent } from './LogLineDetailsComponent'; import { getDetailsScrollPosition, saveDetailsScrollPosition, useLogListContext } from './LogListContext'; @@ -20,23 +22,12 @@ export interface Props { export type LogLineDetailsMode = 'inline' | 'sidebar'; -export const LogLineDetails = ({ containerElement, focusLogLine, logs, onResize }: Props) => { - const { detailsWidth, noInteractions, setDetailsWidth, showDetails } = useLogListContext(); +export const LogLineDetails = memo(({ containerElement, focusLogLine, logs, onResize }: Props) => { + const { detailsWidth, noInteractions, setDetailsWidth } = useLogListContext(); const styles = useStyles2(getStyles, 'sidebar'); const dragStyles = useStyles2(getDragStyles); const containerRef = useRef(null); - useEffect(() => { - focusLogLine(showDetails[0]); - if (!noInteractions) { - reportInteraction('logs_log_line_details_displayed', { - mode: 'sidebar', - }); - } - // Just once - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const handleResize = useCallback(() => { if (containerRef.current) { setDetailsWidth(containerRef.current.clientWidth); @@ -54,10 +45,6 @@ export const LogLineDetails = ({ containerElement, focusLogLine, logs, onResize const maxWidth = containerElement.clientWidth - LOG_LIST_MIN_WIDTH; - if (!showDetails.length) { - return null; - } - return (
-
- -
+
); -}; +}); +LogLineDetails.displayName = 'LogLineDetails'; + +const LogLineDetailsTabs = memo(({ focusLogLine, logs }: Pick) => { + const { closeDetails, noInteractions, showDetails, toggleDetails } = useLogListContext(); + const [currentLog, setCurrentLog] = useState(showDetails[0]); + const previousShowDetails = usePrevious(showDetails); + const styles = useStyles2(getStyles, 'sidebar'); + + useEffect(() => { + focusLogLine(currentLog); + if (!noInteractions) { + reportInteraction('logs_log_line_details_displayed', { + mode: 'sidebar', + }); + } + // Once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!showDetails.length) { + closeDetails(); + return; + } + // Focus on the recently open + if (!previousShowDetails || showDetails.length > previousShowDetails.length) { + setCurrentLog(showDetails[showDetails.length - 1]); + return; + } else if (!showDetails.find((log) => log.uid === currentLog.uid)) { + setCurrentLog(showDetails[showDetails.length - 1]); + } + }, [closeDetails, currentLog.uid, previousShowDetails, showDetails]); + + return ( + <> + {showDetails.length > 1 && ( + + {showDetails.map((log) => { + return ( + setCurrentLog(log)} + suffix={() => ( + toggleDetails(log)} + /> + )} + /> + ); + })} + + )} +
+ +
+ + ); +}); +LogLineDetailsTabs.displayName = 'LogLineDetailsTabs'; export interface InlineLogLineDetailsProps { + log: LogListModel; logs: LogListModel[]; } -export const InlineLogLineDetails = memo(({ logs }: InlineLogLineDetailsProps) => { - const { noInteractions, showDetails } = useLogListContext(); +export const InlineLogLineDetails = memo(({ logs, log }: InlineLogLineDetailsProps) => { + const { noInteractions } = useLogListContext(); const styles = useStyles2(getStyles, 'inline'); const scrollRef = useRef(null); @@ -96,25 +146,21 @@ export const InlineLogLineDetails = memo(({ logs }: InlineLogLineDetailsProps) = }, [noInteractions]); const saveScroll = useCallback(() => { - saveDetailsScrollPosition(showDetails[0], scrollRef.current?.scrollTop ?? 0); - }, [showDetails]); + saveDetailsScrollPosition(log, scrollRef.current?.scrollTop ?? 0); + }, [log]); useEffect(() => { if (!scrollRef.current) { return; } - scrollRef.current.scrollTop = getDetailsScrollPosition(showDetails[0]); - }, [showDetails]); - - if (!showDetails.length) { - return null; - } + scrollRef.current.scrollTop = getDetailsScrollPosition(log); + }, [log]); return (
- +
diff --git a/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx b/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx index ca964bdebc2..96429ce4327 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx @@ -19,11 +19,12 @@ import { useLogListContext } from './LogListContext'; import { LogListModel } from './processing'; interface LogLineDetailsComponentProps { + focusLogLine?: (log: LogListModel) => void; log: LogListModel; logs: LogListModel[]; } -export const LogLineDetailsComponent = memo(({ log, logs }: LogLineDetailsComponentProps) => { +export const LogLineDetailsComponent = memo(({ focusLogLine, log, logs }: LogLineDetailsComponentProps) => { const { displayedFields, noInteractions, logOptionsStorageKey, setDisplayedFields } = useLogListContext(); const [search, setSearch] = useState(''); const inputRef = useRef(''); @@ -101,7 +102,7 @@ export const LogLineDetailsComponent = memo(({ log, logs }: LogLineDetailsCompon return ( <> - +
void; log: LogListModel; search: string; onSearch(newSearch: string): void; } -export const LogLineDetailsHeader = ({ log, search, onSearch }: Props) => { +export const LogLineDetailsHeader = ({ focusLogLine, log, search, onSearch }: Props) => { const { closeDetails, detailsMode, @@ -53,6 +54,10 @@ export const LogLineDetailsHeader = ({ log, search, onSearch }: Props) => { [noInteractions] ); + const scrollToLogLine = useCallback(() => { + focusLogLine?.(log); + }, [focusLogLine, log]); + const copyLogLine = useCallback(() => { copyText(log.entry, containerRef); reportInteractionWrapper('logs_log_line_details_header_copy_clicked'); @@ -143,22 +148,32 @@ export const LogLineDetailsHeader = ({ log, search, onSearch }: Props) => { ref={inputRef} suffix={search !== '' ? clearSearch : undefined} /> - {showLogLineToggle && ( - - )}
+ {focusLogLine && ( + + )} + {showLogLineToggle && ( + + )} { - if (showDetails.length) { - removeDetailsScrollPosition(showDetails[0]); - } + showDetails.forEach((log) => removeDetailsScrollPosition(log)); setShowDetails([]); }, [showDetails]); @@ -477,12 +475,13 @@ export const LogListContextProvider = ({ if (!enableLogDetails) { return; } - const found = showDetails.findIndex((stateLog) => stateLog === log || stateLog.uid === log.uid); - if (found >= 0) { + const found = showDetails.find((stateLog) => stateLog === log || stateLog.uid === log.uid); + if (found) { + removeDetailsScrollPosition(found); setShowDetails(showDetails.filter((stateLog) => stateLog !== log && stateLog.uid !== log.uid)); } else { // Supporting one displayed details for now - setShowDetails([log]); + setShowDetails([...showDetails, log]); } }, [enableLogDetails, showDetails] diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 8d5e7f0702a..e0453c80fb6 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -8912,6 +8912,8 @@ "no-details": "No fields to display.", "pin-line": "Pin log", "remove-displayed-field": "Remove field", + "remove-log": "Remove log", + "scroll-to-logline": "Scroll to log line", "search": { "no-results": "No results to display." },