From e953e760062a5b44feb2054b959407dcf178cfb0 Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Thu, 6 Nov 2025 20:21:51 +0100 Subject: [PATCH] Log Details: Dedicated context provider + improvements (#113409) * LogDetailsContext: create component * LogListContext: extract details out of context * Refactor components to use new context provider * More component updates * Update currentLog implementation * Use new context provider * LogLineDetails: prevent cascade of listeners * LogDetailsContext: sync currentLog with changes * LogLine: use icon status to show the current log * LogLineDetails: first tab is the last open log line * LogLineDetailsLog: respect font size * Update tests * Update tests * LogList: add integration test * LogLine: use level to mark the current log * Chore: only check uids, no need for references * Fix duplicated hook usage * chore: overflow auto * LogList: consider field selector width * Revert "LogLine: use level to mark the current log" This reverts commit 2d5d54d9a7a9b4589cf2ac0344cc904fe5ce246a. * LogLine: darken details displayed, font weight bold current * LogLineMenu: icon when current log * Differenciate contrast from light and dark themes * Use angle-right for the active icon --- .../logs/components/ControlledLogRows.tsx | 1 - .../logs/components/ControlledLogsTable.tsx | 5 +- .../components/fieldSelector/FieldList.tsx | 2 +- .../panel/LogDetailsContext.test.tsx | 41 +++ .../components/panel/LogDetailsContext.tsx | 241 ++++++++++++++ .../logs/components/panel/LogLine.test.tsx | 13 +- .../logs/components/panel/LogLine.tsx | 22 +- .../components/panel/LogLineDetails.test.tsx | 95 ++---- .../logs/components/panel/LogLineDetails.tsx | 49 ++- .../panel/LogLineDetailsDisplayedFields.tsx | 4 +- .../components/panel/LogLineDetailsFields.tsx | 3 +- .../components/panel/LogLineDetailsHeader.tsx | 5 +- .../components/panel/LogLineDetailsLinks.tsx | 4 +- .../components/panel/LogLineDetailsLog.tsx | 8 +- .../components/panel/LogLineMenu.test.tsx | 13 +- .../logs/components/panel/LogLineMenu.tsx | 11 +- .../logs/components/panel/LogList.test.tsx | 295 +++++++++++------- .../logs/components/panel/LogList.tsx | 52 +-- .../logs/components/panel/LogListContext.tsx | 183 +---------- .../logs/components/panel/LogListControls.tsx | 5 +- .../panel/__mocks__/LogListContext.tsx | 31 +- .../logs/components/panel/virtualization.ts | 2 +- 22 files changed, 612 insertions(+), 473 deletions(-) create mode 100644 public/app/features/logs/components/panel/LogDetailsContext.test.tsx create mode 100644 public/app/features/logs/components/panel/LogDetailsContext.tsx diff --git a/public/app/features/logs/components/ControlledLogRows.tsx b/public/app/features/logs/components/ControlledLogRows.tsx index ee663e3d0d9..4094d4f515c 100644 --- a/public/app/features/logs/components/ControlledLogRows.tsx +++ b/public/app/features/logs/components/ControlledLogRows.tsx @@ -79,7 +79,6 @@ export const ControlledLogRows = forwardRef diff --git a/public/app/features/logs/components/fieldSelector/FieldList.tsx b/public/app/features/logs/components/fieldSelector/FieldList.tsx index bac519beafd..4ae9a01f74a 100644 --- a/public/app/features/logs/components/fieldSelector/FieldList.tsx +++ b/public/app/features/logs/components/fieldSelector/FieldList.tsx @@ -46,7 +46,7 @@ export const FieldList = ({ activeFields, clear, fields, reorder, suggestedField function getStyles(theme: GrafanaTheme2) { return { sidebarWrap: css({ - overflowY: 'scroll', + overflowY: 'auto', flex: 1, scrollbarWidth: 'thin', }), diff --git a/public/app/features/logs/components/panel/LogDetailsContext.test.tsx b/public/app/features/logs/components/panel/LogDetailsContext.test.tsx new file mode 100644 index 00000000000..cfab2d71599 --- /dev/null +++ b/public/app/features/logs/components/panel/LogDetailsContext.test.tsx @@ -0,0 +1,41 @@ +import { renderHook } from '@testing-library/react'; +import { ReactNode } from 'react'; + +import { createLogLine } from '../mocks/logRow'; + +import { + useLogDetailsContextData, + useLogDetailsContext, + LogDetailsContext, + LogDetailsContextData, +} from './LogDetailsContext'; + +const log = createLogLine({ rowId: 'yep', uid: 'uid' }); +const contextValue: LogDetailsContextData = { + currentLog: log, + closeDetails: () => {}, + detailsDisplayed: () => false, + detailsMode: 'sidebar', + detailsWidth: 1337, + enableLogDetails: false, + setCurrentLog: () => {}, + setDetailsMode: () => {}, + setDetailsWidth: () => {}, + showDetails: [], + toggleDetails: () => {}, +}; +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +test('Provides the Log Details Context data', () => { + const { result } = renderHook(() => useLogDetailsContext(), { wrapper }); + + expect(result.current).toEqual(contextValue); +}); + +test('Allows to access context attributes', () => { + const { result } = renderHook(() => useLogDetailsContextData('detailsWidth'), { wrapper }); + + expect(result.current).toEqual(contextValue.detailsWidth); +}); diff --git a/public/app/features/logs/components/panel/LogDetailsContext.tsx b/public/app/features/logs/components/panel/LogDetailsContext.tsx new file mode 100644 index 00000000000..de0c044911e --- /dev/null +++ b/public/app/features/logs/components/panel/LogDetailsContext.tsx @@ -0,0 +1,241 @@ +import { debounce } from 'lodash'; +import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react'; + +import { LogRowModel, store } from '@grafana/data'; + +import { getSidebarWidth } from '../fieldSelector/FieldSelector'; + +import { LogLineDetailsMode } from './LogLineDetails'; +import { LogListModel } from './processing'; +import { getScrollbarWidth, LOG_LIST_CONTROLS_WIDTH, LOG_LIST_MIN_WIDTH } from './virtualization'; + +export interface LogDetailsContextData { + currentLog: LogListModel | undefined; + closeDetails: () => void; + detailsDisplayed: (log: LogListModel) => boolean; + detailsMode: LogLineDetailsMode; + detailsWidth: number; + enableLogDetails: boolean; + setCurrentLog(log: LogListModel): void; + setDetailsMode: (mode: LogLineDetailsMode) => void; + setDetailsWidth: (width: number) => void; + showDetails: LogListModel[]; + toggleDetails: (log: LogListModel) => void; +} + +export const emptyContextData: LogDetailsContextData = { + currentLog: undefined, + closeDetails: () => {}, + detailsDisplayed: () => false, + detailsMode: 'sidebar', + detailsWidth: 0, + enableLogDetails: false, + setCurrentLog: () => {}, + setDetailsMode: () => {}, + setDetailsWidth: () => {}, + showDetails: [], + toggleDetails: () => {}, +}; +export const LogDetailsContext = createContext(emptyContextData); + +export const useLogDetailsContextData = (key: keyof LogDetailsContextData) => { + const data: LogDetailsContextData = useContext(LogDetailsContext); + return data[key]; +}; + +export const useLogDetailsContext = (): LogDetailsContextData => { + return useContext(LogDetailsContext); +}; + +export interface Props { + children?: ReactNode; + // Only ControlledLogRows can send an undefined containerElement. See LogList.tsx + containerElement?: HTMLDivElement; + detailsMode?: LogLineDetailsMode; + enableLogDetails: boolean; + logs: LogRowModel[]; + logOptionsStorageKey?: string; + showControls: boolean; +} + +export const LogDetailsContextProvider = ({ + children, + containerElement, + enableLogDetails, + logOptionsStorageKey, + detailsMode: detailsModeProp = logOptionsStorageKey + ? (store.get(`${logOptionsStorageKey}.detailsMode`) ?? getDefaultDetailsMode(containerElement)) + : getDefaultDetailsMode(containerElement), + logs, + showControls, +}: Props) => { + const [showDetails, setShowDetails] = useState([]); + + const [currentLog, setCurrentLog] = useState(undefined); + const [detailsWidth, setDetailsWidthState] = useState( + getDetailsWidth(containerElement, logOptionsStorageKey, undefined, detailsModeProp, showControls) + ); + const [detailsMode, setDetailsMode] = useState( + detailsModeProp ?? getDefaultDetailsMode(containerElement) + ); + + // Sync details mode + useEffect(() => { + if (detailsModeProp) { + setDetailsMode(detailsModeProp); + } + }, [detailsModeProp]); + + // Sync show details + useEffect(() => { + if (!showDetails.length) { + return; + } + const newShowDetails = showDetails.filter( + (expandedLog) => logs.findIndex((log) => log.uid === expandedLog.uid) >= 0 + ); + if (newShowDetails.length !== showDetails.length) { + setShowDetails(newShowDetails); + } + }, [logs, showDetails]); + + // Sync log details inline and sidebar width + useEffect(() => { + setDetailsWidthState(getDetailsWidth(containerElement, logOptionsStorageKey, undefined, detailsMode, showControls)); + }, [containerElement, detailsMode, logOptionsStorageKey, showControls]); + + // Sync log details width + useEffect(() => { + if (!containerElement) { + return; + } + const handleResize = debounce(() => { + setDetailsWidthState((detailsWidth) => + getDetailsWidth(containerElement, logOptionsStorageKey, detailsWidth, detailsMode, showControls) + ); + }, 50); + const observer = new ResizeObserver(() => handleResize()); + observer.observe(containerElement); + return () => observer.disconnect(); + }, [containerElement, detailsMode, logOptionsStorageKey, showControls, showDetails]); + + const closeDetails = useCallback(() => { + showDetails.forEach((log) => removeDetailsScrollPosition(log)); + setShowDetails([]); + setCurrentLog(undefined); + }, [showDetails]); + + const detailsDisplayed = useCallback( + (log: LogListModel) => !!showDetails.find((shownLog) => shownLog.uid === log.uid), + [showDetails] + ); + + const toggleDetails = useCallback( + (log: LogListModel) => { + if (!enableLogDetails) { + return; + } + const found = showDetails.find((stateLog) => stateLog.uid === log.uid); + if (found) { + removeDetailsScrollPosition(found); + const newShowDetails = showDetails.filter((stateLog) => stateLog.uid !== log.uid); + setShowDetails(newShowDetails); + if (currentLog && currentLog.uid === log.uid) { + setCurrentLog(newShowDetails[newShowDetails.length - 1]); + } + } else { + // Supporting one displayed details for now + setShowDetails([...showDetails, log]); + setCurrentLog(log); + } + }, + [currentLog, enableLogDetails, showDetails] + ); + + const setDetailsWidth = useCallback( + (width: number) => { + if (!logOptionsStorageKey || !containerElement) { + return; + } + + const maxWidth = containerElement.clientWidth - getSidebarWidth(logOptionsStorageKey) - LOG_LIST_MIN_WIDTH; + if (width > maxWidth) { + return; + } + + store.set(`${logOptionsStorageKey}.detailsWidth`, width); + setDetailsWidthState(width); + }, + [containerElement, logOptionsStorageKey] + ); + + return ( + + {children} + + ); +}; + +// Only ControlledLogRows can send an undefined containerElement. See LogList.tsx +export function getDetailsWidth( + containerElement: HTMLDivElement | undefined, + logOptionsStorageKey?: string, + currentWidth?: number, + detailsMode: LogLineDetailsMode = 'sidebar', + showControls?: boolean +) { + if (!containerElement) { + return 0; + } + const availableWidth = containerElement.clientWidth - getSidebarWidth(logOptionsStorageKey); + if (detailsMode === 'inline') { + return availableWidth - getScrollbarWidth() - (showControls ? LOG_LIST_CONTROLS_WIDTH : 0); + } + const defaultWidth = availableWidth * 0.4; + const detailsWidth = + currentWidth || + (logOptionsStorageKey + ? parseInt(store.get(`${logOptionsStorageKey}.detailsWidth`) ?? defaultWidth, 10) + : defaultWidth); + + const maxWidth = availableWidth - LOG_LIST_MIN_WIDTH; + + // The user might have resized the screen. + if (detailsWidth >= availableWidth || detailsWidth > maxWidth) { + return currentWidth ?? defaultWidth; + } + 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); +} + +export function getDefaultDetailsMode(container: HTMLDivElement | undefined): LogLineDetailsMode { + const width = container?.clientWidth ?? window.innerWidth; + return width > 1440 ? 'sidebar' : 'inline'; +} diff --git a/public/app/features/logs/components/panel/LogLine.test.tsx b/public/app/features/logs/components/panel/LogLine.test.tsx index 5222fae50d9..8ac0b2281e8 100644 --- a/public/app/features/logs/components/panel/LogLine.test.tsx +++ b/public/app/features/logs/components/panel/LogLine.test.tsx @@ -8,6 +8,7 @@ import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { createLogLine } from '../mocks/logRow'; import { getDisplayedFieldsForLogs, OTEL_PROBE_FIELD } from '../otel/formats'; +import { emptyContextData, LogDetailsContext } from './LogDetailsContext'; import { getGridTemplateColumns, getStyles, LogLine, Props } from './LogLine'; import { LogListFontSize } from './LogList'; import { LogListContextProvider, LogListContext } from './LogListContext'; @@ -551,32 +552,32 @@ describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => { describe('Inline details', () => { test('Details are not rendered if details mode is not inline', () => { render( - - + ); expect(screen.queryByPlaceholderText('Search field names and values')).not.toBeInTheDocument(); }); test('Details are rendered if details mode is inline', () => { render( - - + ); expect(screen.getByPlaceholderText('Search field names and values')).toBeInTheDocument(); }); diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index d58a861439e..17f628b5914 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -23,6 +23,7 @@ import { LogMessageAnsi } from '../LogMessageAnsi'; import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from '../otel/formats'; import { HighlightedLogRenderer } from './HighlightedLogRenderer'; +import { useLogDetailsContext } from './LogDetailsContext'; import { InlineLogLineDetails } from './LogLineDetails'; import { LogLineMenu } from './LogLineMenu'; import { useLogIsPermalinked, useLogIsPinned, useLogListContext } from './LogListContext'; @@ -113,10 +114,7 @@ const LogLineComponent = memo( wrapLogMessage, }: LogLineComponentProps) => { const { - detailsDisplayed, - detailsMode, dedupStrategy, - enableLogDetails, fontSize, hasLogsWithErrors, hasSampledLogs, @@ -124,6 +122,7 @@ const LogLineComponent = memo( timestampResolution, onLogLineHover, } = useLogListContext(); + const { currentLog, detailsDisplayed, detailsMode, enableLogDetails } = useLogDetailsContext(); const [collapsed, setCollapsed] = useState( wrapLogMessage && log.collapsed !== undefined ? log.collapsed : undefined ); @@ -195,6 +194,7 @@ const LogLineComponent = memo( [log, onClick] ); + const isLogDetailsFocused = currentLog?.uid === log.uid; const detailsShown = detailsDisplayed(log); return ( @@ -202,13 +202,13 @@ const LogLineComponent = memo( {/* A button element could be used but in Safari it prevents text selection. Fallback available for a11y in LogLineMenu */} {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
- + {dedupStrategy !== LogsDedupStrategy.none && (
{log.duplicates && log.duplicates > 0 ? `${log.duplicates + 1}x` : null} @@ -249,7 +249,7 @@ const LogLineComponent = memo(
)}
{ const setup = ( propOverrides?: Partial, rowOverrides?: Partial, - contextOverrides?: Partial + logListcontextOverrides?: Partial, + logDetailsContextOverrides?: Partial ) => { const logs = [createLogLine({ logLevel: LogLevel.error, timeEpochMs: 1546297200000, ...rowOverrides })]; @@ -83,13 +84,22 @@ const setup = ( const contextData: LogListContextData = { ...defaultValue, + ...logListcontextOverrides, + }; + + const detailsData: LogDetailsContextData = { + ...emptyContextData, + enableLogDetails: true, showDetails: logs, - ...contextOverrides, + currentLog: logs[0], + ...logDetailsContextOverrides, }; return render( - + + + ); }; @@ -175,7 +185,10 @@ describe('LogLineDetails', () => { onClickFilterLabel: onClickFilterLabelMock, onClickFilterOutLabel: onClickFilterOutLabelMock, isLabelFilterActive: isLabelFilterActiveMock, + }, + { showDetails: [log], + currentLog: log, } ); @@ -278,7 +291,7 @@ describe('LogLineDetails', () => { } ); - setup({ logs: [log] }, undefined, { showDetails: [log] }); + setup({ logs: [log] }, undefined, undefined, { showDetails: [log], currentLog: log }); expect(screen.getByText('Fields')).toBeInTheDocument(); expect(screen.getByText('Links')).toBeInTheDocument(); @@ -347,7 +360,7 @@ describe('LogLineDetails', () => { } ); - setup({ logs: [log] }, undefined, { showDetails: [log] }); + setup({ logs: [log] }, undefined, undefined, { showDetails: [log], currentLog: log }); expect(screen.getByText('Log line')).toBeInTheDocument(); expect(screen.getByText('Fields')).toBeInTheDocument(); @@ -599,7 +612,7 @@ describe('LogLineDetails', () => { 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 }); + setup({ logs }, undefined, undefined, { showDetails: logs, currentLog: logs[1] }); expect(screen.queryAllByRole('tab')).toHaveLength(2); @@ -607,70 +620,6 @@ describe('LogLineDetails', () => { 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]], - timeRange: getDefaultTimeRange(), - timeZone: 'browser', - showControls: true, - }; - - 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); }); }); @@ -733,7 +682,7 @@ describe('LogLineDetails', () => { }) ); - setup({ logs: [log] }, undefined, { showDetails: [log] }); + setup({ logs: [log] }, undefined, undefined, { showDetails: [log], currentLog: log }); expect(screen.getByText('Links')).toBeInTheDocument(); expect(screen.getByText('Trace')).toBeInTheDocument(); @@ -795,7 +744,7 @@ describe('LogLineDetails', () => { }) ); - setup({ logs: [log] }, undefined, { showDetails: [log] }); + setup({ logs: [log] }, undefined, undefined, { showDetails: [log], currentLog: log }); expect(screen.getByText('Links')).toBeInTheDocument(); expect(screen.getByText('Trace')).toBeInTheDocument(); diff --git a/public/app/features/logs/components/panel/LogLineDetails.tsx b/public/app/features/logs/components/panel/LogLineDetails.tsx index 2a7b63005f9..19639e61f8c 100644 --- a/public/app/features/logs/components/panel/LogLineDetails.tsx +++ b/public/app/features/logs/components/panel/LogLineDetails.tsx @@ -1,15 +1,17 @@ import { css } from '@emotion/css'; import { Resizable } from 're-resizable'; -import { memo, useCallback, useEffect, useRef, useState } from 'react'; -import { usePrevious } from 'react-use'; +import { memo, useCallback, useEffect, useMemo, useRef } from 'react'; import { GrafanaTheme2, TimeRange } from '@grafana/data'; import { t } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; import { getDragStyles, Icon, Tab, TabsBar, useStyles2 } from '@grafana/ui'; +import { getSidebarWidth } from '../fieldSelector/FieldSelector'; + +import { getDetailsScrollPosition, saveDetailsScrollPosition, useLogDetailsContext } from './LogDetailsContext'; import { LogLineDetailsComponent } from './LogLineDetailsComponent'; -import { getDetailsScrollPosition, saveDetailsScrollPosition, useLogListContext } from './LogListContext'; +import { useLogListContext } from './LogListContext'; import { LogListModel } from './processing'; import { LOG_LIST_MIN_WIDTH } from './virtualization'; @@ -26,7 +28,8 @@ export type LogLineDetailsMode = 'inline' | 'sidebar'; export const LogLineDetails = memo( ({ containerElement, focusLogLine, logs, timeRange, timeZone, showControls }: Props) => { - const { detailsWidth, noInteractions, setDetailsWidth } = useLogListContext(); + const { noInteractions, logOptionsStorageKey } = useLogListContext(); + const { detailsWidth, setDetailsWidth } = useLogDetailsContext(); const styles = useStyles2(getStyles, 'sidebar', showControls); const dragStyles = useStyles2(getDragStyles); const containerRef = useRef(null); @@ -45,7 +48,7 @@ export const LogLineDetails = memo( } }, [noInteractions]); - const maxWidth = containerElement.clientWidth - LOG_LIST_MIN_WIDTH; + const maxWidth = containerElement.clientWidth - getSidebarWidth(logOptionsStorageKey) - LOG_LIST_MIN_WIDTH; return ( ) => { - const { app, closeDetails, noInteractions, showDetails, toggleDetails, wrapLogMessage } = useLogListContext(); - const [currentLog, setCurrentLog] = useState(showDetails[0]); - const previousShowDetails = usePrevious(showDetails); + const { app, noInteractions, wrapLogMessage } = useLogListContext(); + const { currentLog, setCurrentLog, showDetails, toggleDetails } = useLogDetailsContext(); + const styles = useStyles2(getStyles, 'sidebar'); useEffect(() => { // When wrapping is enabled and details is in sidebar mode, the logs panel width changes and the // user may lose focus of the log line, so we scroll to it. - if (wrapLogMessage) { + if (wrapLogMessage && currentLog) { focusLogLine(currentLog); } if (!noInteractions) { @@ -90,25 +93,17 @@ const LogLineDetailsTabs = memo( // 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]); + const tabs = useMemo(() => showDetails.slice().reverse(), [showDetails]); + + if (!currentLog) { + return null; + } return ( <> {showDetails.length > 1 && ( - {showDetails.map((log) => { + {tabs.map((log) => { return ( toggleDetails(log)} + onClick={(e) => { + e.stopPropagation(); + toggleDetails(log); + }} /> )} /> @@ -152,7 +150,8 @@ export interface InlineLogLineDetailsProps { } export const InlineLogLineDetails = memo(({ logs, log, onResize, timeRange, timeZone }: InlineLogLineDetailsProps) => { - const { app, detailsWidth, noInteractions } = useLogListContext(); + const { app, noInteractions } = useLogListContext(); + const { detailsWidth } = useLogDetailsContext(); const styles = useStyles2(getStyles, 'inline'); const scrollRef = useRef(null); diff --git a/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx b/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx index 24a0c67948b..72c6ce07054 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx @@ -6,6 +6,7 @@ import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; import { Card, IconButton, useStyles2 } from '@grafana/ui'; +import { useLogDetailsContext } from './LogDetailsContext'; import { LogLineDetailsMode } from './LogLineDetails'; import { useLogListContext } from './LogListContext'; import { reportInteractionOnce } from './analytics'; @@ -89,7 +90,8 @@ const DisplayedField = ({ moveField, provided, }: DraggableDisplayedFieldProps & { provided: DraggableProvided }) => { - const { detailsMode, displayedFields, onClickHideField } = useLogListContext(); + const { displayedFields, onClickHideField } = useLogListContext(); + const { detailsMode } = useLogDetailsContext(); const styles = useStyles2(getStyles, detailsMode); const nextIndex = index === displayedFields.length - 1 ? 0 : index + 1; const prevIndex = index === 0 ? displayedFields.length - 1 : index - 1; diff --git a/public/app/features/logs/components/panel/LogLineDetailsFields.tsx b/public/app/features/logs/components/panel/LogLineDetailsFields.tsx index c6dbda48529..e8482b4d7c0 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsFields.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsFields.tsx @@ -14,6 +14,7 @@ import { LogLabelStats } from '../LogLabelStats'; import { FieldDef } from '../logParser'; import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from '../otel/formats'; +import { useLogDetailsContext } from './LogDetailsContext'; import { useLogListContext } from './LogListContext'; import { LogListModel, getNormalizedFieldName } from './processing'; @@ -139,7 +140,6 @@ export const LogLineDetailsField = ({ const [fieldStats, setFieldStats] = useState(null); const { app, - closeDetails, displayedFields, isLabelFilterActive, noInteractions, @@ -151,6 +151,7 @@ export const LogLineDetailsField = ({ pinLineButtonTooltipTitle, prettifyJSON, } = useLogListContext(); + const { closeDetails } = useLogDetailsContext(); const styles = useStyles2(getFieldStyles); diff --git a/public/app/features/logs/components/panel/LogLineDetailsHeader.tsx b/public/app/features/logs/components/panel/LogLineDetailsHeader.tsx index 59a576f26a7..40d2b2fa14a 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsHeader.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsHeader.tsx @@ -9,6 +9,7 @@ import { IconButton, Input, useStyles2 } from '@grafana/ui'; import { copyText, handleOpenLogsContextClick } from '../../utils'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; +import { useLogDetailsContext } from './LogDetailsContext'; import { LogLineDetailsMode } from './LogLineDetails'; import { useLogIsPinned, useLogListContext } from './LogListContext'; import { LogListModel } from './processing'; @@ -22,14 +23,11 @@ interface Props { export const LogLineDetailsHeader = ({ focusLogLine, log, search, onSearch }: Props) => { const { - closeDetails, - detailsMode, displayedFields, getRowContextQuery, logOptionsStorageKey, logSupportsContext, noInteractions, - setDetailsMode, onClickHideField, onClickShowField, onOpenContext, @@ -40,6 +38,7 @@ export const LogLineDetailsHeader = ({ focusLogLine, log, search, onSearch }: Pr isAssistantAvailable, openAssistantByLog, } = useLogListContext(); + const { closeDetails, detailsMode, setDetailsMode } = useLogDetailsContext(); const pinned = useLogIsPinned(log); const styles = useStyles2(getStyles, detailsMode, wrapLogMessage); const containerRef = useRef(null); diff --git a/public/app/features/logs/components/panel/LogLineDetailsLinks.tsx b/public/app/features/logs/components/panel/LogLineDetailsLinks.tsx index c791facd8d4..6c2dc4f5b94 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsLinks.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsLinks.tsx @@ -7,6 +7,7 @@ import { DataLinkButton, Icon, Toggletip, useStyles2 } from '@grafana/ui'; import { FieldDef } from '../logParser'; +import { useLogDetailsContext } from './LogDetailsContext'; import { filterFields, MultipleValue, SingleValue } from './LogLineDetailsFields'; import { useLogListContext } from './LogListContext'; import { LogListModel } from './processing'; @@ -53,7 +54,8 @@ interface LogLineDetailsFieldProps { } export const LogLineDetailsField = ({ field, log }: LogLineDetailsFieldProps) => { - const { closeDetails, onPinLine, pinLineButtonTooltipTitle, prettifyJSON } = useLogListContext(); + const { onPinLine, pinLineButtonTooltipTitle, prettifyJSON } = useLogListContext(); + const { closeDetails } = useLogDetailsContext(); const styles = useStyles2(getFieldStyles); diff --git a/public/app/features/logs/components/panel/LogLineDetailsLog.tsx b/public/app/features/logs/components/panel/LogLineDetailsLog.tsx index 28ecad3d8a3..71adc3a1c02 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsLog.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsLog.tsx @@ -7,6 +7,7 @@ import { LogMessageAnsi } from '../LogMessageAnsi'; import { HighlightedLogRenderer } from './HighlightedLogRenderer'; import { getStyles } from './LogLine'; +import { useLogListContext } from './LogListContext'; import { LogListModel } from './processing'; interface Props { @@ -15,6 +16,7 @@ interface Props { } export const LogLineDetailsLog = memo(({ log: originalLog, syntaxHighlighting }: Props) => { + const { fontSize } = useLogListContext(); const logStyles = useStyles2(getStyles); const log = useMemo(() => { const log = originalLog.clone(); @@ -23,7 +25,7 @@ export const LogLineDetailsLog = memo(({ log: originalLog, syntaxHighlighting }: return (
-
+
{log.hasAnsi ? ( @@ -52,4 +54,8 @@ const styles = { maxHeight: '50vh', overflow: 'auto', }), + noHover: css({ + // Disable hover style + pointerEvents: 'none', + }), }; diff --git a/public/app/features/logs/components/panel/LogLineMenu.test.tsx b/public/app/features/logs/components/panel/LogLineMenu.test.tsx index 78e11921e8a..cadd99b3c41 100644 --- a/public/app/features/logs/components/panel/LogLineMenu.test.tsx +++ b/public/app/features/logs/components/panel/LogLineMenu.test.tsx @@ -5,6 +5,7 @@ import { CoreApp, createTheme, LogsDedupStrategy, LogsSortOrder } from '@grafana import { createLogLine } from '../mocks/logRow'; +import { LogDetailsContextProvider } from './LogDetailsContext'; import { getStyles } from './LogLine'; import { LogLineMenu, LogLineMenuCustomItem } from './LogLineMenu'; import { LogListContextProvider } from './LogListContext'; @@ -158,8 +159,10 @@ describe('LogLineMenu', () => { test('Allows to open log details', async () => { render( - - + + + + ); await userEvent.click(screen.getByLabelText('Log menu')); @@ -168,8 +171,10 @@ describe('LogLineMenu', () => { test('Does not show log details option when disabled', async () => { render( - - + + + + ); await userEvent.click(screen.getByLabelText('Log menu')); diff --git a/public/app/features/logs/components/panel/LogLineMenu.tsx b/public/app/features/logs/components/panel/LogLineMenu.tsx index a7831da2c64..f28fbe267d1 100644 --- a/public/app/features/logs/components/panel/LogLineMenu.tsx +++ b/public/app/features/logs/components/panel/LogLineMenu.tsx @@ -7,6 +7,7 @@ import { Dropdown, IconButton, Menu } from '@grafana/ui'; import { copyText, handleOpenLogsContextClick } from '../../utils'; +import { useLogDetailsContext } from './LogDetailsContext'; import { LogLineStyles } from './LogLine'; import { useLogIsPinned, useLogListContext } from './LogListContext'; import { LogListModel } from './processing'; @@ -29,14 +30,13 @@ type MenuItemDivider = { export type LogLineMenuCustomItem = MenuItem | MenuItemDivider; interface Props { + active?: boolean; log: LogListModel; styles: LogLineStyles; } -export const LogLineMenu = ({ log, styles }: Props) => { +export const LogLineMenu = ({ active, log, styles }: Props) => { const { - enableLogDetails, - detailsDisplayed, getRowContextQuery, onOpenContext, onPermalinkClick, @@ -44,10 +44,10 @@ export const LogLineMenu = ({ log, styles }: Props) => { onUnpinLine, logLineMenuCustomItems = [], logSupportsContext, - toggleDetails, isAssistantAvailable, openAssistantByLog, } = useLogListContext(); + const { enableLogDetails, detailsDisplayed, toggleDetails } = useLogDetailsContext(); const pinned = useLogIsPinned(log); const menuRef = useRef(null); @@ -158,9 +158,10 @@ export const LogLineMenu = ({ log, styles }: Props) => { ); diff --git a/public/app/features/logs/components/panel/LogList.test.tsx b/public/app/features/logs/components/panel/LogList.test.tsx index 73c3a04fb02..38080a00657 100644 --- a/public/app/features/logs/components/panel/LogList.test.tsx +++ b/public/app/features/logs/components/panel/LogList.test.tsx @@ -104,112 +104,6 @@ describe('LogList', () => { expect(onLogRowHover).toHaveBeenCalledWith(expect.objectContaining(logs[0])); }); - test('Supports showing log details', async () => { - jest.spyOn(store, 'get').mockImplementation((option: string) => { - if (option === 'storage-key.detailsMode') { - return 'sidebar'; - } - return undefined; - }); - const onClickFilterLabel = jest.fn(); - const onClickFilterOutLabel = jest.fn(); - const onClickShowField = jest.fn(); - - render( - - ); - - await userEvent.click(screen.getByText('log message 1')); - await screen.findByText('Fields'); - - expect(screen.getByText('name_of_the_label')).toBeInTheDocument(); - expect(screen.getByText('value of the label')).toBeInTheDocument(); - - await userEvent.click(screen.getByLabelText('Filter for value in query A')); - expect(onClickFilterLabel).toHaveBeenCalledTimes(1); - - await userEvent.click(screen.getByLabelText('Filter out value in query A')); - expect(onClickFilterOutLabel).toHaveBeenCalledTimes(1); - - await userEvent.click(screen.getByLabelText('Show this field instead of the message')); - expect(onClickShowField).toHaveBeenCalledTimes(1); - - await userEvent.click(screen.getByLabelText('Close log details')); - - expect(screen.queryByText('Fields')).not.toBeInTheDocument(); - expect(screen.queryByText('Close log details')).not.toBeInTheDocument(); - }); - - test('Supports showing inline log details', async () => { - jest.spyOn(store, 'get').mockImplementation((option: string) => { - if (option === 'storage-key.detailsMode') { - return 'inline'; - } - return undefined; - }); - const onClickFilterLabel = jest.fn(); - const onClickFilterOutLabel = jest.fn(); - const onClickShowField = jest.fn(); - - render( - - ); - - await userEvent.click(screen.getByText('log message 1')); - await screen.findByText('Fields'); - - expect(screen.getByText('name_of_the_label')).toBeInTheDocument(); - expect(screen.getByText('value of the label')).toBeInTheDocument(); - - await userEvent.click(screen.getByLabelText('Filter for value in query A')); - expect(onClickFilterLabel).toHaveBeenCalledTimes(1); - - await userEvent.click(screen.getByLabelText('Filter out value in query A')); - expect(onClickFilterOutLabel).toHaveBeenCalledTimes(1); - - await userEvent.click(screen.getByLabelText('Show this field instead of the message')); - expect(onClickShowField).toHaveBeenCalledTimes(1); - - await userEvent.click(screen.getByLabelText('Close log details')); - - expect(screen.queryByText('Fields')).not.toBeInTheDocument(); - expect(screen.queryByText('Close log details')).not.toBeInTheDocument(); - }); - - test('Allows people to select text without opening log details', async () => { - const spy = jest.spyOn(document, 'getSelection'); - spy.mockReturnValue({ - toString: () => 'selected log line', - removeAllRanges: () => {}, - addRange: (range: Range) => {}, - } as Selection); - - render(); - - await userEvent.click(screen.getByText('log message 1')); - - expect(screen.queryByText('name_of_the_label')).not.toBeInTheDocument(); - expect(screen.queryByText('value of the label')).not.toBeInTheDocument(); - expect(screen.queryByText('Fields')).not.toBeInTheDocument(); - expect(screen.queryByText('Close log details')).not.toBeInTheDocument(); - - spy.mockRestore(); - }); - test('Shows controls with level filters based on the displayed logs', async () => { logs = [createLogRow({ uid: '1', logLevel: LogLevel.info }), createLogRow({ uid: '2', logLevel: LogLevel.debug })]; @@ -578,4 +472,193 @@ describe('LogList', () => { config.featureToggles.otelLogsFormatting = originalState; }); }); + + describe('Log details', () => { + test('Supports showing log details', async () => { + jest.spyOn(store, 'get').mockImplementation((option: string) => { + if (option === 'storage-key.detailsMode') { + return 'sidebar'; + } + return undefined; + }); + const onClickFilterLabel = jest.fn(); + const onClickFilterOutLabel = jest.fn(); + const onClickShowField = jest.fn(); + + render( + + ); + + await userEvent.click(screen.getByText('log message 1')); + await screen.findByText('Fields'); + + expect(screen.getByText('name_of_the_label')).toBeInTheDocument(); + expect(screen.getByText('value of the label')).toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText('Filter for value in query A')); + expect(onClickFilterLabel).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByLabelText('Filter out value in query A')); + expect(onClickFilterOutLabel).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByLabelText('Show this field instead of the message')); + expect(onClickShowField).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByLabelText('Close log details')); + + expect(screen.queryByText('Fields')).not.toBeInTheDocument(); + expect(screen.queryByText('Close log details')).not.toBeInTheDocument(); + }); + + test('Supports showing inline log details', async () => { + jest.spyOn(store, 'get').mockImplementation((option: string) => { + if (option === 'storage-key.detailsMode') { + return 'inline'; + } + return undefined; + }); + const onClickFilterLabel = jest.fn(); + const onClickFilterOutLabel = jest.fn(); + const onClickShowField = jest.fn(); + + render( + + ); + + await userEvent.click(screen.getByText('log message 1')); + await screen.findByText('Fields'); + + expect(screen.getByText('name_of_the_label')).toBeInTheDocument(); + expect(screen.getByText('value of the label')).toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText('Filter for value in query A')); + expect(onClickFilterLabel).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByLabelText('Filter out value in query A')); + expect(onClickFilterOutLabel).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByLabelText('Show this field instead of the message')); + expect(onClickShowField).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByLabelText('Close log details')); + + expect(screen.queryByText('Fields')).not.toBeInTheDocument(); + expect(screen.queryByText('Close log details')).not.toBeInTheDocument(); + }); + + test('Allows people to select text without opening log details', async () => { + const spy = jest.spyOn(document, 'getSelection'); + spy.mockReturnValue({ + toString: () => 'selected log line', + removeAllRanges: () => {}, + addRange: (range: Range) => {}, + } as Selection); + + render(); + + await userEvent.click(screen.getByText('log message 1')); + + expect(screen.queryByText('name_of_the_label')).not.toBeInTheDocument(); + expect(screen.queryByText('value of the label')).not.toBeInTheDocument(); + expect(screen.queryByText('Fields')).not.toBeInTheDocument(); + expect(screen.queryByText('Close log details')).not.toBeInTheDocument(); + + spy.mockRestore(); + }); + + 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' }), + ]; + + render(); + + // Open details of 2 logs + await userEvent.click(screen.getByText('First log')); + await userEvent.click(screen.getByText('Second log')); + + // 2 tabs + expect(screen.queryAllByRole('tab')).toHaveLength(2); + + // Expand Log line section inside Details + await userEvent.click(screen.getByText('Log line')); + + // Tab + log line in the list + expect(screen.getAllByText('First log')).toHaveLength(2); + // Tab + log line in the list + Log details (active tab) + expect(screen.getAllByText('Second log')).toHaveLength(3); + + // Make first log active + await userEvent.click(screen.queryAllByRole('tab')[1]); + + // Tab + log line in the list + Log details (active tab) + expect(screen.getAllByText('First log')).toHaveLength(3); + // Tab + log line in the list + expect(screen.getAllByText('Second log')).toHaveLength(2); + }); + + 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' }), + createLogLine({ uid: '3', logLevel: LogLevel.error, timeEpochMs: 1546297200000, entry: 'Third log' }), + ]; + + render(); + + // No details shown + expect(screen.queryByPlaceholderText('Search field names and values')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText('First log')); + + // Details shown + expect(screen.getByPlaceholderText('Search field names and values')).toBeInTheDocument(); + + // No tabs, only one details displayed + expect(screen.queryAllByRole('tab')).toHaveLength(0); + + await userEvent.click(screen.getByText('Second log')); + + // 2 details displayed, Second log is the first tab + expect(screen.queryAllByRole('tab')).toHaveLength(2); + expect(screen.queryAllByRole('tab')[0]).toHaveTextContent('Second log'); + + await userEvent.click(screen.getByText('Third log')); + + // 3 details displayed, Second log is the first tab + expect(screen.queryAllByRole('tab')).toHaveLength(3); + expect(screen.queryAllByRole('tab')[0]).toHaveTextContent('Third log'); + + await userEvent.click(screen.getAllByText('Third log')[1]); + + // 2 details displayed, Second log is the first tab + expect(screen.queryAllByRole('tab')).toHaveLength(2); + expect(screen.queryAllByRole('tab')[0]).toHaveTextContent('Second log'); + + await userEvent.click(screen.getAllByText('Second log')[1]); + + // No tabs, only one details displayed + expect(screen.queryAllByRole('tab')).toHaveLength(0); + + await userEvent.click(screen.getByText('First log')); + + // No details shown + expect(screen.queryByPlaceholderText('Search field names and values')).not.toBeInTheDocument(); + }); + }); }); diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 27a9379c8b8..8a8a9b085dc 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -26,6 +26,7 @@ import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; import { LogListFieldSelector } from '../fieldSelector/FieldSelector'; import { InfiniteScrollMode, InfiniteScroll, LoadMoreLogsType } from './InfiniteScroll'; +import { LogDetailsContextProvider, useLogDetailsContext } from './LogDetailsContext'; import { getGridTemplateColumns, LogLineTimestampResolution } from './LogLine'; import { LogLineDetails, LogLineDetailsMode } from './LogLineDetails'; import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu'; @@ -174,9 +175,7 @@ export const LogList = ({ app={app} containerElement={containerElement} dedupStrategy={dedupStrategy} - detailsMode={detailsMode} displayedFields={displayedFields} - enableLogDetails={enableLogDetails} filterLevels={filterLevels} fontSize={fontSize} getRowContextQuery={getRowContextQuery} @@ -213,24 +212,33 @@ export const LogList = ({ timestampResolution={timestampResolution} wrapLogMessage={wrapLogMessage} > - - - + + + + + ); }; @@ -255,7 +263,6 @@ const LogListComponent = ({ app, displayedFields, dedupStrategy, - detailsMode, filterLevels, fontSize, forceEscape, @@ -265,14 +272,13 @@ const LogListComponent = ({ onClickFilterOutString, permalinkedLogId, prettifyJSON, - showDetails, showTime, showUniqueLabels, sortOrder, timestampResolution, - toggleDetails, wrapLogMessage, } = useLogListContext(); + const { detailsMode, showDetails, toggleDetails } = useLogDetailsContext(); const [processedLogs, setProcessedLogs] = useState([]); const [listHeight, setListHeight] = useState(getListHeight(containerElement, app)); const theme = useTheme2(); diff --git a/public/app/features/logs/components/panel/LogListContext.tsx b/public/app/features/logs/components/panel/LogListContext.tsx index 0539743fb02..a917b0eac56 100644 --- a/public/app/features/logs/components/panel/LogListContext.tsx +++ b/public/app/features/logs/components/panel/LogListContext.tsx @@ -1,4 +1,3 @@ -import { debounce } from 'lodash'; import { createContext, Dispatch, @@ -31,22 +30,16 @@ import { checkLogsError, checkLogsSampled, downloadLogs as download, DownloadFor import { getSidebarState } from '../fieldSelector/FieldSelector'; import { getDisplayedFieldsForLogs } from '../otel/formats'; +import { getDefaultDetailsMode, getDetailsWidth } from './LogDetailsContext'; import { LogLineTimestampResolution } from './LogLine'; -import { LogLineDetailsMode } from './LogLineDetails'; import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu'; import { LogListOptions, LogListFontSize } from './LogList'; import { reportInteractionOnce } from './analytics'; import { LogListModel } from './processing'; -import { getScrollbarWidth, LOG_LIST_CONTROLS_WIDTH, LOG_LIST_MIN_WIDTH } from './virtualization'; export interface LogListContextData extends Omit { - closeDetails: () => void; controlsExpanded: boolean; - detailsDisplayed: (log: LogListModel) => boolean; - detailsMode: LogLineDetailsMode; - detailsWidth: number; downloadLogs: (format: DownloadFormat) => void; - enableLogDetails: boolean; filterLevels: LogLevel[]; forceEscape: boolean; hasLogsWithErrors?: boolean; @@ -55,8 +48,6 @@ export interface LogListContextData extends Omit void; setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => void; - setDetailsMode: (mode: LogLineDetailsMode) => void; - setDetailsWidth: (width: number) => void; setFilterLevels: (filterLevels: LogLevel[]) => void; setFontSize: (size: LogListFontSize) => void; setForceEscape: (forceEscape: boolean) => void; @@ -69,24 +60,17 @@ export interface LogListContextData extends Omit void; setTimestampResolution: (format: LogLineTimestampResolution) => void; setWrapLogMessage: (showTime: boolean) => void; - showDetails: LogListModel[]; timestampResolution: LogLineTimestampResolution; - toggleDetails: (log: LogListModel) => void; isAssistantAvailable: boolean; openAssistantByLog: ((log: LogListModel) => void) | undefined; } export const LogListContext = createContext({ app: CoreApp.Unknown, - closeDetails: () => {}, controlsExpanded: false, dedupStrategy: LogsDedupStrategy.none, - detailsDisplayed: () => false, - detailsMode: 'sidebar', - detailsWidth: 0, displayedFields: [], downloadLogs: () => {}, - enableLogDetails: false, filterLevels: [], forceEscape: false, fontSize: 'default', @@ -94,8 +78,6 @@ export const LogListContext = createContext({ noInteractions: false, setControlsExpanded: () => {}, setDedupStrategy: () => {}, - setDetailsMode: () => {}, - setDetailsWidth: () => {}, setFilterLevels: () => {}, setFontSize: () => {}, setForceEscape: () => {}, @@ -108,12 +90,10 @@ export const LogListContext = createContext({ setSyntaxHighlighting: () => {}, setTimestampResolution: () => {}, setWrapLogMessage: () => {}, - showDetails: [], showTime: true, sortOrder: LogsSortOrder.Ascending, syntaxHighlighting: true, timestampResolution: 'ns', - toggleDetails: () => {}, wrapLogMessage: false, isAssistantAvailable: false, openAssistantByLog: () => {}, @@ -157,10 +137,8 @@ export interface Props { children?: ReactNode; // Only ControlledLogRows can send an undefined containerElement. See LogList.tsx containerElement?: HTMLDivElement; - detailsMode?: LogLineDetailsMode; dedupStrategy: LogsDedupStrategy; displayedFields: string[]; - enableLogDetails: boolean; filterLevels?: LogLevel[]; fontSize: LogListFontSize; getRowContextQuery?: GetRowContextQueryFn; @@ -202,11 +180,7 @@ export const LogListContextProvider = ({ app, children, containerElement, - enableLogDetails, logOptionsStorageKey, - detailsMode: detailsModeProp = logOptionsStorageKey - ? (store.get(`${logOptionsStorageKey}.detailsMode`) ?? getDefaultDetailsMode(containerElement)) - : getDefaultDetailsMode(containerElement), dedupStrategy, displayedFields, filterLevels, @@ -259,13 +233,6 @@ export const LogListContextProvider = ({ syntaxHighlighting, timestampResolution, }); - const [showDetails, setShowDetails] = useState([]); - const [detailsWidth, setDetailsWidthState] = useState( - getDetailsWidth(containerElement, logOptionsStorageKey, undefined, detailsModeProp, showControls) - ); - const [detailsMode, setDetailsMode] = useState( - detailsModeProp ?? getDefaultDetailsMode(containerElement) - ); const { isAvailable: isAssistantAvailable, openAssistant } = useAssistant(); const [prettifyJSON, setPrettifyJSONState] = useState(prettifyJSONProp); const [wrapLogMessage, setWrapLogMessageState] = useState(wrapLogMessageProp); @@ -284,8 +251,10 @@ export const LogListContextProvider = ({ syntaxHighlighting, wrapLogMessage, prettifyJSON, - detailsWidth, - detailsMode, + detailsWidth: getDetailsWidth(containerElement, logOptionsStorageKey), + detailsMode: logOptionsStorageKey + ? (store.get(`${logOptionsStorageKey}.detailsMode`) ?? getDefaultDetailsMode(containerElement)) + : getDefaultDetailsMode(containerElement), withDisplayedFields: displayedFields.length > 0, timestampResolution: logListState.timestampResolution, }); @@ -348,13 +317,6 @@ export const LogListContextProvider = ({ }); }, [filterLevels]); - // Sync details mode - useEffect(() => { - if (detailsModeProp) { - setDetailsMode(detailsModeProp); - } - }, [detailsModeProp]); - // Sync font size useEffect(() => { setLogListState((logListState) => ({ ...logListState, fontSize })); @@ -367,39 +329,6 @@ export const LogListContextProvider = ({ } }, [logListState, pinnedLogs]); - // Sync show details - useEffect(() => { - if (!showDetails.length) { - return; - } - const newShowDetails = showDetails.filter( - (expandedLog) => logs.findIndex((log) => log.uid === expandedLog.uid) >= 0 - ); - if (newShowDetails.length !== showDetails.length) { - setShowDetails(newShowDetails); - } - }, [logs, showDetails]); - - // Sync log details inline and sidebar width - useEffect(() => { - setDetailsWidthState(getDetailsWidth(containerElement, logOptionsStorageKey, undefined, detailsMode, showControls)); - }, [containerElement, detailsMode, logOptionsStorageKey, showControls]); - - // Sync log details width - useEffect(() => { - if (!containerElement) { - return; - } - const handleResize = debounce(() => { - setDetailsWidthState((detailsWidth) => - getDetailsWidth(containerElement, logOptionsStorageKey, detailsWidth, detailsMode, showControls) - ); - }, 50); - const observer = new ResizeObserver(() => handleResize()); - observer.observe(containerElement); - return () => observer.disconnect(); - }, [containerElement, detailsMode, logOptionsStorageKey, showControls]); - // Sync prettifyJSON useEffect(() => { if (prettifyJSONProp !== undefined) { @@ -434,11 +363,6 @@ export const LogListContextProvider = ({ // If the user has a large viewport, show the expanded state by default const [controlsExpanded, setControlsExpanded] = useState(controlsExpandedFromStore); - const detailsDisplayed = useCallback( - (log: LogListModel) => !!showDetails.find((shownLog) => shownLog.uid === log.uid), - [showDetails] - ); - const setDedupStrategy = useCallback( (dedupStrategy: LogsDedupStrategy) => { setLogListState({ ...logListState, dedupStrategy }); @@ -564,45 +488,6 @@ export const LogListContextProvider = ({ [displayedFields, logListState.filterLevels, logs, logsMeta] ); - const closeDetails = useCallback(() => { - showDetails.forEach((log) => removeDetailsScrollPosition(log)); - setShowDetails([]); - }, [showDetails]); - - const toggleDetails = useCallback( - (log: LogListModel) => { - if (!enableLogDetails) { - return; - } - 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([...showDetails, log]); - } - }, - [enableLogDetails, showDetails] - ); - - const setDetailsWidth = useCallback( - (width: number) => { - if (!logOptionsStorageKey || !containerElement) { - return; - } - - const maxWidth = containerElement.clientWidth - LOG_LIST_MIN_WIDTH; - if (width > maxWidth) { - return; - } - - store.set(`${logOptionsStorageKey}.detailsWidth`, width); - setDetailsWidthState(width); - }, - [containerElement, logOptionsStorageKey] - ); - const setTimestampResolution = useCallback( (timestampResolution: LogLineTimestampResolution) => { if (logOptionsStorageKey) { @@ -634,15 +519,10 @@ export const LogListContextProvider = ({ = containerElement.clientWidth || detailsWidth > maxWidth) { - return currentWidth ?? defaultWidth; - } - 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); -} - async function handleOpenAssistant(openAssistant: (props: OpenAssistantProps) => void, log: LogListModel) { const datasource = await getDataSourceSrv().get(log.datasourceUid); const context = []; @@ -793,11 +625,6 @@ ${log.entry.replaceAll('`', '\\`')} }); } -export function getDefaultDetailsMode(container: HTMLDivElement | undefined): LogLineDetailsMode { - const width = container?.clientWidth ?? window.innerWidth; - return width > 1440 ? 'sidebar' : 'inline'; -} - export function getDefaultControlsExpandedMode(container: HTMLDivElement | null): boolean { const width = container?.clientWidth ?? window.innerWidth; return width > 1200; diff --git a/public/app/features/logs/components/panel/LogListControls.tsx b/public/app/features/logs/components/panel/LogListControls.tsx index 0f7074c7972..5b482af2aad 100644 --- a/public/app/features/logs/components/panel/LogListControls.tsx +++ b/public/app/features/logs/components/panel/LogListControls.tsx @@ -22,7 +22,7 @@ import { DownloadFormat } from '../../utils'; import { useLogListContext } from './LogListContext'; import { LogListControlsOption, LogListControlsSelectOption } from './LogListControlsOption'; import { useLogListSearchContext } from './LogListSearchContext'; -import { ScrollToLogsEvent } from './virtualization'; +import { LOG_LIST_CONTROLS_WIDTH, ScrollToLogsEvent } from './virtualization'; type Props = { eventBus: EventBus; @@ -757,7 +757,6 @@ const getWrapButtonStyles = (theme: GrafanaTheme2, expanded: boolean) => { }; }; -export const CONTROLS_WIDTH = 35; export const CONTROLS_WIDTH_EXPANDED = 176; const getStyles = (theme: GrafanaTheme2, controlsExpanded: boolean) => { @@ -769,7 +768,7 @@ const getStyles = (theme: GrafanaTheme2, controlsExpanded: boolean) => { gap: theme.spacing(3), flexDirection: 'column', justifyContent: 'flex-start', - width: controlsExpanded ? CONTROLS_WIDTH_EXPANDED : CONTROLS_WIDTH, + width: controlsExpanded ? CONTROLS_WIDTH_EXPANDED : LOG_LIST_CONTROLS_WIDTH, paddingTop: theme.spacing(0.75), paddingLeft: theme.spacing(1), borderLeft: `solid 1px ${theme.colors.border.medium}`, diff --git a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx index af289c71f2c..5960b82d5f5 100644 --- a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx +++ b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx @@ -17,19 +17,14 @@ jest.mock('@grafana/assistant', () => { export const LogListContext = createContext({ app: CoreApp.Unknown, - closeDetails: () => {}, dedupStrategy: LogsDedupStrategy.none, - detailsDisplayed: () => false, - detailsWidth: 0, displayedFields: [], downloadLogs: () => {}, - enableLogDetails: false, filterLevels: [], fontSize: 'default', forceEscape: false, hasUnescapedContent: false, setDedupStrategy: () => {}, - setDetailsWidth: () => {}, setFilterLevels: () => {}, setFontSize: () => {}, setForceEscape: () => {}, @@ -42,15 +37,11 @@ export const LogListContext = createContext({ setSyntaxHighlighting: () => {}, setTimestampResolution: () => {}, setWrapLogMessage: () => {}, - showDetails: [], showTime: true, sortOrder: LogsSortOrder.Ascending, syntaxHighlighting: true, timestampResolution: 'ns', - toggleDetails: () => {}, wrapLogMessage: false, - detailsMode: 'sidebar', - setDetailsMode: () => {}, isAssistantAvailable: false, openAssistantByLog: () => {}, controlsExpanded: false, @@ -77,8 +68,6 @@ export const useLogIsPermalinked = (log: LogListModel) => { }; export const defaultValue: LogListContextData = { - detailsMode: 'sidebar', - setDetailsMode: jest.fn(), setDedupStrategy: jest.fn(), setFilterLevels: jest.fn(), setFontSize: jest.fn(), @@ -92,18 +81,11 @@ export const defaultValue: LogListContextData = { setSyntaxHighlighting: jest.fn(), setTimestampResolution: jest.fn(), setWrapLogMessage: jest.fn(), - closeDetails: jest.fn(), - detailsDisplayed: jest.fn(), - detailsWidth: 300, downloadLogs: jest.fn(), - enableLogDetails: false, filterLevels: [], fontSize: 'default', forceEscape: false, hasUnescapedContent: false, - setDetailsWidth: jest.fn(), - showDetails: [], - toggleDetails: jest.fn(), app: CoreApp.Explore, dedupStrategy: LogsDedupStrategy.exact, displayedFields: [], @@ -122,7 +104,6 @@ export const defaultProps: Props = { containerElement: document.createElement('div'), dedupStrategy: LogsDedupStrategy.none, displayedFields: [], - enableLogDetails: false, filterLevels: [], fontSize: 'default', getRowContextQuery: jest.fn(), @@ -146,7 +127,6 @@ export const LogListContextProvider = ({ children, dedupStrategy = LogsDedupStrategy.none, displayedFields = [], - enableLogDetails = false, filterLevels = [], getRowContextQuery = jest.fn(), logLineMenuCustomItems = undefined, @@ -159,13 +139,12 @@ export const LogListContextProvider = ({ onUnpinLine = jest.fn(), permalinkedLogId, pinnedLogs = [], - showDetails = [], showTime = true, sortOrder = LogsSortOrder.Descending, syntaxHighlighting = true, timestampResolution = 'ms', wrapLogMessage = true, -}: Partial & { showDetails?: LogListModel[] }) => { +}: Partial) => { const hasLogsWithErrors = logs.some((log) => !!checkLogsError(log)); const hasSampledLogs = logs.some((log) => !!checkLogsSampled(log)); @@ -177,7 +156,6 @@ export const LogListContextProvider = ({ dedupStrategy, displayedFields, downloadLogs: jest.fn(), - enableLogDetails, hasLogsWithErrors, hasSampledLogs, filterLevels, @@ -203,7 +181,6 @@ export const LogListContextProvider = ({ setSortOrder: jest.fn(), setSyntaxHighlighting: jest.fn(), setWrapLogMessage: jest.fn(), - showDetails, showTime, sortOrder, syntaxHighlighting, @@ -215,9 +192,3 @@ 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/virtualization.ts b/public/app/features/logs/components/panel/virtualization.ts index fde63fc3422..4c2c77b7c64 100644 --- a/public/app/features/logs/components/panel/virtualization.ts +++ b/public/app/features/logs/components/panel/virtualization.ts @@ -13,7 +13,7 @@ export const FIELD_GAP_MULTIPLIER = 1.5; export const DEFAULT_LINE_HEIGHT = 22; -export const LOG_LIST_CONTROLS_WIDTH = 32; +export const LOG_LIST_CONTROLS_WIDTH = 35; export class LogLineVirtualization { private ctx: CanvasRenderingContext2D | null = null;