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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user