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 2d5d54d9a7.

* 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
This commit is contained in:
Matias Chomicki
2025-11-06 20:21:51 +01:00
committed by GitHub
parent c9e4c26c11
commit e953e76006
22 changed files with 612 additions and 473 deletions
@@ -79,7 +79,6 @@ export const ControlledLogRows = forwardRef<HTMLDivElement | null, ControlledLog
app={rest.app || CoreApp.Unknown}
displayedFields={[]}
dedupStrategy={dedupStrategy}
enableLogDetails={false}
filterLevels={filterLevels}
fontSize="default"
logOptionsStorageKey={logOptionsStorageKey}
@@ -8,7 +8,8 @@ import { LogsTableWrap } from '../../explore/Logs/LogsTableWrap';
import { LogRowsComponentProps } from './ControlledLogRows';
import { useLogListContext } from './panel/LogListContext';
import { CONTROLS_WIDTH, CONTROLS_WIDTH_EXPANDED, LogListControls } from './panel/LogListControls';
import { CONTROLS_WIDTH_EXPANDED, LogListControls } from './panel/LogListControls';
import { LOG_LIST_CONTROLS_WIDTH } from './panel/virtualization';
export const ControlledLogsTable = ({
loading,
@@ -38,7 +39,7 @@ export const ControlledLogsTable = ({
}
const tableWidthExpandedControls = width - (CONTROLS_WIDTH_EXPANDED + 12);
const tableWidth = width - (CONTROLS_WIDTH + 12);
const tableWidth = width - (LOG_LIST_CONTROLS_WIDTH + 12);
return (
<div ref={ref} className={styles.logRowsContainer}>
@@ -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',
}),
@@ -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 }) => (
<LogDetailsContext.Provider value={contextValue}>{children}</LogDetailsContext.Provider>
);
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);
});
@@ -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<LogDetailsContextData>(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<LogListModel[]>([]);
const [currentLog, setCurrentLog] = useState<LogListModel | undefined>(undefined);
const [detailsWidth, setDetailsWidthState] = useState(
getDetailsWidth(containerElement, logOptionsStorageKey, undefined, detailsModeProp, showControls)
);
const [detailsMode, setDetailsMode] = useState<LogLineDetailsMode>(
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 (
<LogDetailsContext.Provider
value={{
closeDetails,
currentLog: detailsMode === 'sidebar' ? currentLog : undefined,
detailsDisplayed,
detailsMode,
detailsWidth,
enableLogDetails,
setCurrentLog,
setDetailsMode,
setDetailsWidth,
showDetails,
toggleDetails,
}}
>
{children}
</LogDetailsContext.Provider>
);
};
// 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<string, number>();
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';
}
@@ -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(
<LogListContext.Provider
<LogDetailsContext.Provider
value={{
...defaultValue,
...emptyContextData,
showDetails: [log],
detailsMode: 'sidebar',
detailsDisplayed: jest.fn().mockReturnValue(true),
}}
>
<LogLine {...defaultProps} />
</LogListContext.Provider>
</LogDetailsContext.Provider>
);
expect(screen.queryByPlaceholderText('Search field names and values')).not.toBeInTheDocument();
});
test('Details are rendered if details mode is inline', () => {
render(
<LogListContext.Provider
<LogDetailsContext.Provider
value={{
...defaultValue,
...emptyContextData,
showDetails: [log],
detailsMode: 'inline',
detailsDisplayed: jest.fn().mockReturnValue(true),
}}
>
<LogLine {...defaultProps} />
</LogListContext.Provider>
</LogDetailsContext.Provider>
);
expect(screen.getByPlaceholderText('Search field names and values')).toBeInTheDocument();
});
@@ -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<boolean | undefined>(
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 */}
<div
className={`${styles.logLine} ${variant ?? ''} ${pinned ? styles.pinnedLogLine : ''} ${permalinked ? styles.permalinkedLogLine : ''} ${detailsShown ? styles.detailsDisplayed : ''} ${fontSize === 'small' ? styles.fontSizeSmall : ''} ${enableLogDetails ? styles.clickable : ''}`}
className={`${styles.logLine} ${variant ?? ''} ${pinned ? styles.pinnedLogLine : ''} ${permalinked ? styles.permalinkedLogLine : ''} ${detailsShown ? styles.detailsDisplayed : ''} ${isLogDetailsFocused ? styles.currentLog : ''} ${fontSize === 'small' ? styles.fontSizeSmall : ''} ${enableLogDetails ? styles.clickable : ''}`}
ref={onOverflow ? logLineRef : undefined}
onMouseEnter={handleMouseOver}
onFocus={handleMouseOver}
onClick={handleClick}
>
<LogLineMenu styles={styles} log={log} />
<LogLineMenu styles={styles} log={log} active={isLogDetailsFocused} />
{dedupStrategy !== LogsDedupStrategy.none && (
<div className={`${styles.duplicates}`}>
{log.duplicates && log.duplicates > 0 ? `${log.duplicates + 1}x` : null}
@@ -249,7 +249,7 @@ const LogLineComponent = memo(
</div>
)}
<div
className={`${styles.fieldsWrapper} ${detailsShown ? styles.detailsDisplayed : ''} ${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`} ${collapsed === true ? styles.collapsedLogLine : ''}`}
className={`${styles.fieldsWrapper} ${detailsShown ? styles.detailsDisplayed : ''} ${isLogDetailsFocused ? styles.currentLog : ''} ${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`} ${collapsed === true ? styles.collapsedLogLine : ''}`}
style={
collapsed && virtualization
? { maxHeight: `${virtualization.getTruncationLineCount() * virtualization.getLineHeight()}px` }
@@ -481,7 +481,7 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali
parsedField: theme.colors.text.secondary,
};
const hoverColor = tinycolor(theme.colors.background.canvas).darken(5).toRgbString();
const hoverColor = tinycolor(theme.colors.background.canvas).darken(11).toRgbString();
return {
logLine: css({
@@ -554,7 +554,13 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali
lineHeight: theme.typography.bodySmall.lineHeight,
}),
detailsDisplayed: css({
background: tinycolor(theme.colors.background.canvas).darken(2).toRgbString(),
background: tinycolor(theme.colors.background.canvas)
.darken(theme.isDark ? 2 : 5)
.toRgbString(),
}),
currentLog: css({
background: hoverColor,
fontWeight: theme.typography.fontWeightBold,
}),
pinnedLogLine: css({
backgroundColor: tinycolor(theme.colors.info.transparent).setAlpha(0.25).toString(),
@@ -15,7 +15,6 @@ import {
DataFrame,
ScopedVars,
dateTime,
getDefaultTimeRange,
} from '@grafana/data';
import { setPluginLinksHook } from '@grafana/runtime';
import { createTempoDatasource } from 'app/plugins/datasource/tempo/test/mocks';
@@ -23,6 +22,7 @@ import { createTempoDatasource } from 'app/plugins/datasource/tempo/test/mocks';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { createLogLine } from '../mocks/logRow';
import { emptyContextData, LogDetailsContext, LogDetailsContextData } from './LogDetailsContext';
import { LogLineDetails, Props } from './LogLineDetails';
import { LogListContext, LogListContextData } from './LogListContext';
import { defaultValue } from './__mocks__/LogListContext';
@@ -60,7 +60,8 @@ afterAll(() => {
const setup = (
propOverrides?: Partial<Props>,
rowOverrides?: Partial<LogRowModel>,
contextOverrides?: Partial<LogListContextData>
logListcontextOverrides?: Partial<LogListContextData>,
logDetailsContextOverrides?: Partial<LogDetailsContextData>
) => {
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(
<LogListContext.Provider value={contextData}>
<LogLineDetails {...props} />
<LogDetailsContext.Provider value={detailsData}>
<LogLineDetails {...props} />
</LogDetailsContext.Provider>
</LogListContext.Provider>
);
};
@@ -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(
<LogListContext.Provider value={contextData}>
<LogLineDetails {...props} />
</LogListContext.Provider>
);
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(
<LogListContext.Provider value={contextData}>
<LogLineDetails {...props} />
</LogListContext.Provider>
);
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(
<LogListContext.Provider value={contextData}>
<LogLineDetails {...props} />
</LogListContext.Provider>
);
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();
@@ -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<HTMLDivElement | null>(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 (
<Resizable
@@ -69,15 +72,15 @@ LogLineDetails.displayName = 'LogLineDetails';
const LogLineDetailsTabs = memo(
({ focusLogLine, logs, timeRange, timeZone }: Pick<Props, 'focusLogLine' | 'logs' | 'timeRange' | 'timeZone'>) => {
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 && (
<TabsBar>
{showDetails.map((log) => {
{tabs.map((log) => {
return (
<Tab
key={log.uid}
@@ -120,7 +115,10 @@ const LogLineDetailsTabs = memo(
<Icon
name="times"
aria-label={t('logs.log-line-details.remove-log', 'Remove log')}
onClick={() => 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<HTMLDivElement | null>(null);
@@ -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;
@@ -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<LogLabelStatsModel[] | null>(null);
const {
app,
closeDetails,
displayedFields,
isLabelFilterActive,
noInteractions,
@@ -151,6 +151,7 @@ export const LogLineDetailsField = ({
pinLineButtonTooltipTitle,
prettifyJSON,
} = useLogListContext();
const { closeDetails } = useLogDetailsContext();
const styles = useStyles2(getFieldStyles);
@@ -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<HTMLDivElement | null>(null);
@@ -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);
@@ -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 (
<div className={styles.logLineWrapper}>
<div className={logStyles.logLine}>
<div className={`${logStyles.logLine} ${fontSize === 'small' ? logStyles.fontSizeSmall : ''} ${styles.noHover}`}>
<div className={logStyles.wrappedLogLine}>
{log.hasAnsi ? (
<span className="field no-highlighting">
@@ -52,4 +54,8 @@ const styles = {
maxHeight: '50vh',
overflow: 'auto',
}),
noHover: css({
// Disable hover style
pointerEvents: 'none',
}),
};
@@ -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(
<LogListContextProvider {...contextProps} enableLogDetails={true}>
<LogLineMenu log={log} styles={styles} />
<LogListContextProvider {...contextProps}>
<LogDetailsContextProvider enableLogDetails logs={contextProps.logs} showControls>
<LogLineMenu log={log} styles={styles} />
</LogDetailsContextProvider>
</LogListContextProvider>
);
await userEvent.click(screen.getByLabelText('Log menu'));
@@ -168,8 +171,10 @@ describe('LogLineMenu', () => {
test('Does not show log details option when disabled', async () => {
render(
<LogListContextProvider {...contextProps} enableLogDetails={false}>
<LogLineMenu log={log} styles={styles} />
<LogListContextProvider {...contextProps}>
<LogDetailsContextProvider logs={contextProps.logs} showControls enableLogDetails={false}>
<LogLineMenu log={log} styles={styles} />
</LogDetailsContextProvider>
</LogListContextProvider>
);
await userEvent.click(screen.getByLabelText('Log menu'));
@@ -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) => {
<Dropdown overlay={menu} placement="bottom-start">
<IconButton
className={styles.menuIcon}
name="ellipsis-v"
name={active ? 'angle-right' : 'ellipsis-v'}
aria-label={t('logs.log-line-menu.icon-label', 'Log menu')}
role="button"
variant={active ? 'primary' : undefined}
/>
</Dropdown>
);
@@ -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(
<LogList
{...defaultProps}
enableLogDetails={true}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
onClickShowField={onClickShowField}
logOptionsStorageKey="storage-key"
/>
);
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(
<LogList
{...defaultProps}
enableLogDetails={true}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
onClickShowField={onClickShowField}
logOptionsStorageKey="storage-key"
/>
);
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(<LogList {...defaultProps} enableLogDetails={true} />);
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(
<LogList
{...defaultProps}
enableLogDetails={true}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
onClickShowField={onClickShowField}
logOptionsStorageKey="storage-key"
/>
);
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(
<LogList
{...defaultProps}
enableLogDetails={true}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
onClickShowField={onClickShowField}
logOptionsStorageKey="storage-key"
/>
);
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(<LogList {...defaultProps} enableLogDetails={true} />);
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(<LogList {...defaultProps} enableLogDetails={true} logs={logs} detailsMode="sidebar" />);
// 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(<LogList {...defaultProps} enableLogDetails={true} logs={logs} detailsMode="sidebar" />);
// 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();
});
});
});
@@ -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}
>
<LogListSearchContextProvider>
<LogListComponent
containerElement={containerElement}
dataFrames={dataFrames}
eventBus={eventBus}
getFieldLinks={getFieldLinks}
grammar={grammar}
initialScrollPosition={initialScrollPosition}
infiniteScrollMode={infiniteScrollMode}
loading={loading}
loadMore={loadMore}
logs={logs}
showControls={showControls}
showFieldSelector={showFieldSelector}
timeRange={timeRange}
timeZone={timeZone}
/>
</LogListSearchContextProvider>
<LogDetailsContextProvider
containerElement={containerElement}
detailsMode={detailsMode}
enableLogDetails={enableLogDetails}
logs={logs}
logOptionsStorageKey={logOptionsStorageKey}
showControls={showControls}
>
<LogListSearchContextProvider>
<LogListComponent
containerElement={containerElement}
dataFrames={dataFrames}
eventBus={eventBus}
getFieldLinks={getFieldLinks}
grammar={grammar}
initialScrollPosition={initialScrollPosition}
infiniteScrollMode={infiniteScrollMode}
loading={loading}
loadMore={loadMore}
logs={logs}
showControls={showControls}
showFieldSelector={showFieldSelector}
timeRange={timeRange}
timeZone={timeZone}
/>
</LogListSearchContextProvider>
</LogDetailsContextProvider>
</LogListContextProvider>
);
};
@@ -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<LogListModel[]>([]);
const [listHeight, setListHeight] = useState(getListHeight(containerElement, app));
const theme = useTheme2();
@@ -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<Props, 'containerElement' | 'logs' | 'logsMeta' | 'showControls'> {
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<Props, 'containerElement' | 'lo
logLineMenuCustomItems?: LogLineMenuCustomItem[];
setControlsExpanded: (expanded: boolean) => 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<Props, 'containerElement' | 'lo
setSortOrder: (sortOrder: LogsSortOrder) => 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<LogListContextData>({
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<LogListContextData>({
noInteractions: false,
setControlsExpanded: () => {},
setDedupStrategy: () => {},
setDetailsMode: () => {},
setDetailsWidth: () => {},
setFilterLevels: () => {},
setFontSize: () => {},
setForceEscape: () => {},
@@ -108,12 +90,10 @@ export const LogListContext = createContext<LogListContextData>({
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<LogListModel[]>([]);
const [detailsWidth, setDetailsWidthState] = useState(
getDetailsWidth(containerElement, logOptionsStorageKey, undefined, detailsModeProp, showControls)
);
const [detailsMode, setDetailsMode] = useState<LogLineDetailsMode>(
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<boolean>(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 = ({
<LogListContext.Provider
value={{
app,
closeDetails,
controlsExpanded,
detailsDisplayed,
dedupStrategy: logListState.dedupStrategy,
detailsMode,
detailsWidth,
displayedFields,
downloadLogs,
enableLogDetails,
filterLevels: logListState.filterLevels,
fontSize: logListState.fontSize,
forceEscape: logListState.forceEscape,
@@ -672,8 +552,6 @@ export const LogListContextProvider = ({
prettifyJSON,
setControlsExpanded,
setDedupStrategy,
setDetailsMode,
setDetailsWidth,
setDisplayedFields,
setFilterLevels,
setFontSize,
@@ -687,13 +565,11 @@ export const LogListContextProvider = ({
setSyntaxHighlighting,
setTimestampResolution,
setWrapLogMessage,
showDetails,
showTime: logListState.showTime,
showUniqueLabels: logListState.showUniqueLabels,
sortOrder: logListState.sortOrder,
syntaxHighlighting: logListState.syntaxHighlighting,
timestampResolution: logListState.timestampResolution,
toggleDetails,
wrapLogMessage,
isAssistantAvailable,
openAssistantByLog,
@@ -717,50 +593,6 @@ export function isDedupStrategy(value: unknown): value is LogsDedupStrategy {
);
}
// Only ControlledLogRows can send an undefined containerElement. See LogList.tsx
function getDetailsWidth(
containerElement: HTMLDivElement | undefined,
logOptionsStorageKey?: string,
currentWidth?: number,
detailsMode: LogLineDetailsMode = 'sidebar',
showControls?: boolean
) {
if (!containerElement) {
return 0;
}
if (detailsMode === 'inline') {
return containerElement.clientWidth - getScrollbarWidth() - (showControls ? LOG_LIST_CONTROLS_WIDTH : 0);
}
const defaultWidth = containerElement.clientWidth * 0.4;
const detailsWidth =
currentWidth ||
(logOptionsStorageKey
? parseInt(store.get(`${logOptionsStorageKey}.detailsWidth`) ?? defaultWidth, 10)
: defaultWidth);
const maxWidth = containerElement.clientWidth - LOG_LIST_MIN_WIDTH;
// The user might have resized the screen.
if (detailsWidth >= containerElement.clientWidth || detailsWidth > maxWidth) {
return currentWidth ?? defaultWidth;
}
return detailsWidth;
}
const detailsScrollMap = new Map<string, number>();
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;
@@ -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}`,
@@ -17,19 +17,14 @@ jest.mock('@grafana/assistant', () => {
export const LogListContext = createContext<LogListContextData>({
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<LogListContextData>({
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<Props> & { showDetails?: LogListModel[] }) => {
}: Partial<Props>) => {
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 = ({
</LogListContext.Provider>
);
};
export const saveDetailsScrollPosition = jest.fn();
export const getDetailsScrollPosition = jest.fn();
export const removeDetailsScrollPosition = jest.fn();
@@ -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;