Log rows: Refactor as functional components and remove render preview (#98025)

* LogRow: migrate to functional component

* LogRowContextModal: remove dependency between test cases

* LogRows: turn into functional component

* LogRows: try GhostRow, preview, and move event listeners to the parent

* LogRows: adjust preview size for few log rows

* Explore: optimize inline and derived props

* Remove log

* Logs: restore set displayed fields

* Refactor props that cause re-renders

* Unmemoize shouldShowMenu

* Refactor to PreviewLogRow and add preview to DisplayedFields

* Refactor moving listeners to parent

* Update unit tests

* LogRows: update preview size to twice screen height

* Revert change

* Update unit test

* Update utils test

* Update logsPanel unit test

* Improve permalinking

* LogRows: decrease preview size

* PreviewRow: render log entry

* Prettier

* LogRow: update unit test

* Update missing props

* Fix logs volume toggling

* Destructure prop
pull/97179/head^2
Matias Chomicki 6 months ago committed by GitHub
parent dc52d1b252
commit 52673ad390
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 12
      public/app/features/explore/Explore.tsx
  2. 334
      public/app/features/explore/Logs/Logs.tsx
  3. 16
      public/app/features/explore/Logs/LogsContainer.tsx
  4. 1
      public/app/features/explore/Logs/LogsSamplePanel.tsx
  5. 1
      public/app/features/logs/components/InfiniteScroll.test.tsx
  6. 2
      public/app/features/logs/components/InfiniteScroll.tsx
  7. 16
      public/app/features/logs/components/LogRow.test.tsx
  8. 489
      public/app/features/logs/components/LogRow.tsx
  9. 2
      public/app/features/logs/components/LogRowMessage.tsx
  10. 18
      public/app/features/logs/components/LogRowMessageDisplayedFields.tsx
  11. 86
      public/app/features/logs/components/LogRows.test.tsx
  12. 365
      public/app/features/logs/components/LogRows.tsx
  13. 29
      public/app/features/logs/components/PreviewLogRow.tsx
  14. 51
      public/app/features/logs/components/log-context/LogRowContextModal.test.tsx
  15. 3
      public/app/features/logs/components/log-context/LogRowContextModal.tsx
  16. 33
      public/app/features/logs/utils.test.ts
  17. 31
      public/app/features/logs/utils.ts
  18. 5
      public/app/plugins/panel/logs/LogsPanel.tsx

@ -323,6 +323,10 @@ export class Explore extends PureComponent<Props, ExploreState> {
};
};
onPinLineCallback = () => {
this.setState({ contentOutlineVisible: true });
};
renderEmptyState(exploreContainerStyles: string) {
return (
<div className={cx(exploreContainerStyles)}>
@ -414,6 +418,8 @@ export class Explore extends PureComponent<Props, ExploreState> {
);
}
splitOpenFnLogs = this.onSplitOpen('logs');
renderLogsPanel(width: number) {
const { exploreId, syncedTimes, theme, queryResponse } = this.props;
const spacing = parseInt(theme.spacing(2).slice(0, -2), 10);
@ -435,14 +441,12 @@ export class Explore extends PureComponent<Props, ExploreState> {
onStartScanning={this.onStartScanning}
onStopScanning={this.onStopScanning}
eventBus={this.logsEventBus}
splitOpenFn={this.onSplitOpen('logs')}
splitOpenFn={this.splitOpenFnLogs}
scrollElement={this.scrollElement}
isFilterLabelActive={this.isFilterLabelActive}
onClickFilterString={this.onClickFilterString}
onClickFilterOutString={this.onClickFilterOutString}
onPinLineCallback={() => {
this.setState({ contentOutlineVisible: true });
}}
onPinLineCallback={this.onPinLineCallback}
/>
</ContentOutlineItem>
);

@ -1,7 +1,6 @@
import { css, cx } from '@emotion/css';
import { capitalize, groupBy } from 'lodash';
import memoizeOne from 'memoize-one';
import { useCallback, useEffect, useState, useRef } from 'react';
import { useCallback, useEffect, useState, useRef, useMemo } from 'react';
import * as React from 'react';
import { usePrevious, useUnmount } from 'react-use';
@ -189,6 +188,8 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
loadMoreLogs,
panelState,
eventBus,
onPinLineCallback,
scrollElement,
} = props;
const [showLabels, setShowLabels] = useState<boolean>(store.getBool(SETTINGS_KEYS.showLabels, false));
const [showTime, setShowTime] = useState<boolean>(store.getBool(SETTINGS_KEYS.showTime, true));
@ -210,8 +211,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
const [visualisationType, setVisualisationType] = useState<LogsVisualisationType | undefined>(
panelState?.logs?.visualisationType ?? getDefaultVisualisationType()
);
const [scrollIntoView, setScrollIntoView] = useState<((element: HTMLElement) => void) | undefined>(undefined);
const logsContainerRef = useRef<HTMLDivElement | undefined>(undefined);
const logsContainerRef = useRef<HTMLDivElement | null>(null);
const dispatch = useDispatch();
const previousLoading = usePrevious(loading);
@ -230,9 +230,13 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
// Get pinned log lines
const logsParent = outlineItems?.find((item) => item.panelId === PINNED_LOGS_PANELID && item.level === 'root');
const pinnedLogs = logsParent?.children
?.filter((outlines) => outlines.title === PINNED_LOGS_TITLE)
.map((pinnedLogs) => pinnedLogs.id);
const pinnedLogs = useMemo(
() =>
logsParent?.children
?.filter((outlines) => outlines.title === PINNED_LOGS_TITLE)
.map((pinnedLogs) => pinnedLogs.id),
[logsParent?.children]
);
const getPinnedLogsCount = useCallback(() => {
const logsParent = outlineItems?.find((item) => item.panelId === PINNED_LOGS_PANELID && item.level === 'root');
@ -433,39 +437,28 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
[props.eventBus]
);
const onLogsContainerRef = useCallback(
(node: HTMLDivElement) => {
logsContainerRef.current = node;
// In theory this should be just a function passed down to LogRows but:
// - LogRow.componentDidMount which calls scrollIntoView is called BEFORE the logsContainerRef is set
// - the if check below if (logsContainerRef.current) was falsy and scrolling doesn't happen
// - and LogRow.scrollToLogRow marks the line as scrolled anyway (and won't perform scrolling when the ref is set)
// - see more details in https://github.com/facebook/react/issues/29897
// We can change it once LogRow is converted into a functional component
setScrollIntoView(() => (element: HTMLElement) => {
if (config.featureToggles.logsInfiniteScrolling) {
if (logsContainerRef.current) {
topLogsRef.current?.scrollIntoView();
logsContainerRef.current.scroll({
behavior: 'smooth',
top: logsContainerRef.current.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2,
});
}
return;
}
const scrollElement = props.scrollElement;
if (scrollElement) {
scrollElement.scroll({
const scrollIntoView = useCallback(
(element: HTMLElement) => {
if (config.featureToggles.logsInfiniteScrolling) {
if (logsContainerRef.current) {
topLogsRef.current?.scrollIntoView();
logsContainerRef.current.scroll({
behavior: 'smooth',
top: scrollElement.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2,
top: logsContainerRef.current.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2,
});
}
});
return;
}
if (scrollElement) {
scrollElement.scroll({
behavior: 'smooth',
top: scrollElement.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2,
});
}
},
[props.scrollElement]
[scrollElement]
);
const onChangeLogsSortOrder = () => {
@ -644,7 +637,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
onCloseCallbackRef?.current();
}, [contextRow?.datasourceType, contextRow?.uid, onCloseCallbackRef]);
const onOpenContext = (row: LogRowModel, onClose: () => void) => {
const onOpenContext = useCallback((row: LogRowModel, onClose: () => void) => {
// we are setting the `contextOpen` open state and passing it down to the `LogRow` in order to highlight the row when a LogContext is open
setContextOpen(true);
setContextRow(row);
@ -653,37 +646,40 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
logRowUid: row.uid,
});
onCloseCallbackRef.current = onClose;
};
}, []);
const onPermalinkClick = async (row: LogRowModel) => {
// this is an extra check, to be sure that we are not
// creating permalinks for logs without an id-field.
// normally it should never happen, because we do not
// display the permalink button in such cases.
if (row.rowId === undefined) {
return;
}
const onPermalinkClick = useCallback(
async (row: LogRowModel) => {
// this is an extra check, to be sure that we are not
// creating permalinks for logs without an id-field.
// normally it should never happen, because we do not
// display the permalink button in such cases.
if (row.rowId === undefined) {
return;
}
// get explore state, add log-row-id and make timerange absolute
const urlState = getUrlStateFromPaneState(getState().explore.panes[exploreId]!);
urlState.panelsState = {
...panelState,
logs: { id: row.uid, visualisationType: visualisationType ?? getDefaultVisualisationType(), displayedFields },
};
urlState.range = getLogsPermalinkRange(row, logRows, absoluteRange);
// append changed urlState to baseUrl
const serializedState = serializeStateToUrlParam(urlState);
const baseUrl = /.*(?=\/explore)/.exec(`${window.location.href}`)![0];
const url = urlUtil.renderUrl(`${baseUrl}/explore`, { left: serializedState });
await createAndCopyShortLink(url);
reportInteraction('grafana_explore_logs_permalink_clicked', {
datasourceType: row.datasourceType ?? 'unknown',
logRowUid: row.uid,
logRowLevel: row.logLevel,
});
};
// get explore state, add log-row-id and make timerange absolute
const urlState = getUrlStateFromPaneState(getState().explore.panes[exploreId]!);
urlState.panelsState = {
...panelState,
logs: { id: row.uid, visualisationType: visualisationType ?? getDefaultVisualisationType(), displayedFields },
};
urlState.range = getLogsPermalinkRange(row, logRows, absoluteRange);
// append changed urlState to baseUrl
const serializedState = serializeStateToUrlParam(urlState);
const baseUrl = /.*(?=\/explore)/.exec(`${window.location.href}`)![0];
const url = urlUtil.renderUrl(`${baseUrl}/explore`, { left: serializedState });
await createAndCopyShortLink(url);
reportInteraction('grafana_explore_logs_permalink_clicked', {
datasourceType: row.datasourceType ?? 'unknown',
logRowUid: row.uid,
logRowLevel: row.logLevel,
});
},
[absoluteRange, displayedFields, exploreId, logRows, panelState, visualisationType]
);
const scrollToTopLogs = useCallback(() => {
if (config.featureToggles.logsInfiniteScrolling) {
@ -697,55 +693,62 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
topLogsRef.current?.scrollIntoView();
}, [logsContainerRef, topLogsRef]);
const onPinToContentOutlineClick = (row: LogRowModel, allowUnPin = true) => {
if (getPinnedLogsCount() === PINNED_LOGS_LIMIT && !allowUnPin) {
contentOutlineTrackPinLimitReached();
return;
}
const onPinToContentOutlineClick = useCallback(
(row: LogRowModel, allowUnPin = true) => {
if (getPinnedLogsCount() === PINNED_LOGS_LIMIT && !allowUnPin) {
contentOutlineTrackPinLimitReached();
return;
}
// find the Logs parent item
const logsParent = outlineItems?.find((item) => item.panelId === PINNED_LOGS_PANELID && item.level === 'root');
// find the Logs parent item
const logsParent = outlineItems?.find((item) => item.panelId === PINNED_LOGS_PANELID && item.level === 'root');
//update the parent's expanded state
if (logsParent && updateItem) {
updateItem(logsParent.id, { expanded: true });
}
//update the parent's expanded state
if (logsParent && updateItem) {
updateItem(logsParent.id, { expanded: true });
}
const alreadyPinned = pinnedLogs?.find((pin) => pin === row.rowId);
if (alreadyPinned && row.rowId && allowUnPin) {
unregister?.(row.rowId);
contentOutlineTrackPinRemoved();
} else if (getPinnedLogsCount() !== PINNED_LOGS_LIMIT && !alreadyPinned) {
register?.({
id: row.rowId,
icon: 'gf-logs',
title: PINNED_LOGS_TITLE,
panelId: PINNED_LOGS_PANELID,
level: 'child',
ref: null,
color: LogLevelColor[row.logLevel],
childOnTop: true,
onClick: () => {
onOpenContext(row, () => {});
contentOutlineTrackPinClicked();
},
onRemove: (id: string) => {
unregister?.(id);
contentOutlineTrackUnpinClicked();
},
});
contentOutlineTrackPinAdded();
}
const alreadyPinned = pinnedLogs?.find((pin) => pin === row.rowId);
if (alreadyPinned && row.rowId && allowUnPin) {
unregister?.(row.rowId);
contentOutlineTrackPinRemoved();
} else if (getPinnedLogsCount() !== PINNED_LOGS_LIMIT && !alreadyPinned) {
register?.({
id: row.rowId,
icon: 'gf-logs',
title: PINNED_LOGS_TITLE,
panelId: PINNED_LOGS_PANELID,
level: 'child',
ref: null,
color: LogLevelColor[row.logLevel],
childOnTop: true,
onClick: () => {
onOpenContext(row, () => {});
contentOutlineTrackPinClicked();
},
onRemove: (id: string) => {
unregister?.(id);
contentOutlineTrackUnpinClicked();
},
});
contentOutlineTrackPinAdded();
}
props.onPinLineCallback?.();
};
onPinLineCallback?.();
},
[getPinnedLogsCount, onOpenContext, onPinLineCallback, outlineItems, pinnedLogs, register, unregister, updateItem]
);
const hasUnescapedContent = checkUnescapedContent(logRows);
const filteredLogs = filterRows(logRows, hiddenLogLevels);
const { dedupedRows, dedupCount } = dedupRows(filteredLogs, dedupStrategy);
const navigationRange = createNavigationRange(logRows);
const infiniteScrollAvailable = !logsQueries?.some(
(query) => 'direction' in query && query.direction === LokiQueryDirection.Scan
const hasUnescapedContent = useMemo(() => checkUnescapedContent(logRows), [logRows]);
const filteredLogs = useMemo(() => filterRows(logRows, hiddenLogLevels), [hiddenLogLevels, logRows]);
const { dedupedRows, dedupCount } = useMemo(
() => dedupRows(filteredLogs, dedupStrategy),
[dedupStrategy, filteredLogs]
);
const navigationRange = useMemo(() => createNavigationRange(logRows), [logRows]);
const infiniteScrollAvailable = useMemo(
() => !logsQueries?.some((query) => 'direction' in query && query.direction === LokiQueryDirection.Scan),
[logsQueries]
);
return (
@ -938,58 +941,61 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
/>
</div>
)}
{visualisationType === 'logs' && hasData && (
{visualisationType === 'logs' && (
<div
className={config.featureToggles.logsInfiniteScrolling ? styles.scrollableLogRows : styles.logRows}
data-testid="logRows"
ref={onLogsContainerRef}
ref={logsContainerRef}
>
<InfiniteScroll
loading={loading}
loadMoreLogs={infiniteScrollAvailable ? loadMoreLogs : undefined}
range={props.range}
timeZone={timeZone}
rows={logRows}
scrollElement={logsContainerRef.current}
sortOrder={logsSortOrder}
app={CoreApp.Explore}
>
<LogRows
pinnedLogs={pinnedLogs}
logRows={logRows}
deduplicatedRows={dedupedRows}
dedupStrategy={dedupStrategy}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
showContextToggle={showContextToggle}
getRowContextQuery={getRowContextQuery}
showLabels={showLabels}
showTime={showTime}
enableLogDetails={true}
forceEscape={forceEscape}
wrapLogMessage={wrapLogMessage}
prettifyLogMessage={prettifyLogMessage}
{hasData && (
<InfiniteScroll
loading={loading}
loadMoreLogs={infiniteScrollAvailable ? loadMoreLogs : undefined}
range={props.range}
timeZone={timeZone}
getFieldLinks={getFieldLinks}
logsSortOrder={logsSortOrder}
displayedFields={displayedFields}
onClickShowField={showField}
onClickHideField={hideField}
rows={logRows}
scrollElement={logsContainerRef.current}
sortOrder={logsSortOrder}
app={CoreApp.Explore}
onLogRowHover={onLogRowHover}
onOpenContext={onOpenContext}
onPermalinkClick={onPermalinkClick}
permalinkedRowId={panelState?.logs?.id}
scrollIntoView={scrollIntoView}
isFilterLabelActive={props.isFilterLabelActive}
containerRendered={!!logsContainerRef}
onClickFilterString={props.onClickFilterString}
onClickFilterOutString={props.onClickFilterOutString}
onUnpinLine={onPinToContentOutlineClick}
onPinLine={onPinToContentOutlineClick}
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
/>
</InfiniteScroll>
>
<LogRows
pinnedLogs={pinnedLogs}
logRows={logRows}
deduplicatedRows={dedupedRows}
dedupStrategy={dedupStrategy}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
showContextToggle={showContextToggle}
getRowContextQuery={getRowContextQuery}
showLabels={showLabels}
showTime={showTime}
enableLogDetails={true}
forceEscape={forceEscape}
wrapLogMessage={wrapLogMessage}
prettifyLogMessage={prettifyLogMessage}
timeZone={timeZone}
getFieldLinks={getFieldLinks}
logsSortOrder={logsSortOrder}
displayedFields={displayedFields}
onClickShowField={showField}
onClickHideField={hideField}
app={CoreApp.Explore}
onLogRowHover={onLogRowHover}
onOpenContext={onOpenContext}
onPermalinkClick={onPermalinkClick}
permalinkedRowId={panelState?.logs?.id}
scrollIntoView={scrollIntoView}
isFilterLabelActive={props.isFilterLabelActive}
scrollElement={logsContainerRef.current}
onClickFilterString={props.onClickFilterString}
onClickFilterOutString={props.onClickFilterOutString}
onUnpinLine={onPinToContentOutlineClick}
onPinLine={onPinToContentOutlineClick}
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
renderPreview
/>
</InfiniteScroll>
)}
</div>
)}
{!loading && !hasData && !scanning && (
@ -1098,21 +1104,21 @@ const getStyles = (theme: GrafanaTheme2, wrapLogMessage: boolean, tableHeight: n
};
};
const checkUnescapedContent = memoizeOne((logRows: LogRowModel[]) => {
const checkUnescapedContent = (logRows: LogRowModel[]) => {
return logRows.some((r) => r.hasUnescapedContent);
});
};
const dedupRows = memoizeOne((logRows: LogRowModel[], dedupStrategy: LogsDedupStrategy) => {
const dedupRows = (logRows: LogRowModel[], dedupStrategy: LogsDedupStrategy) => {
const dedupedRows = dedupLogRows(logRows, dedupStrategy);
const dedupCount = dedupedRows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0);
return { dedupedRows, dedupCount };
});
};
const filterRows = memoizeOne((logRows: LogRowModel[], hiddenLogLevels: LogLevel[]) => {
const filterRows = (logRows: LogRowModel[], hiddenLogLevels: LogLevel[]) => {
return filterLogLevels(logRows, new Set(hiddenLogLevels));
});
};
const createNavigationRange = memoizeOne((logRows: LogRowModel[]): { from: number; to: number } | undefined => {
const createNavigationRange = (logRows: LogRowModel[]): { from: number; to: number } | undefined => {
if (!logRows || logRows.length === 0) {
return undefined;
}
@ -1124,4 +1130,4 @@ const createNavigationRange = memoizeOne((logRows: LogRowModel[]): { from: numbe
}
return { from: firstTimeStamp, to: lastTimeStamp };
});
};

@ -259,6 +259,14 @@ class LogsContainer extends PureComponent<LogsContainerProps, LogsContainerState
this.props.clearCache(this.props.exploreId);
};
loadLogsVolumeData = () => {
this.props.loadSupplementaryQueryData(this.props.exploreId, SupplementaryQueryType.LogsVolume);
};
onSetLogsVolumeEnabled = (enabled: boolean) => {
this.props.setSupplementaryQueryEnabled(this.props.exploreId, enabled, SupplementaryQueryType.LogsVolume);
};
render() {
const {
loading,
@ -267,8 +275,6 @@ class LogsContainer extends PureComponent<LogsContainerProps, LogsContainerState
logsMeta,
logsSeries,
logsQueries,
loadSupplementaryQueryData,
setSupplementaryQueryEnabled,
onClickFilterLabel,
onClickFilterOutLabel,
onStartScanning,
@ -319,16 +325,14 @@ class LogsContainer extends PureComponent<LogsContainerProps, LogsContainerState
logsMeta={logsMeta}
logsSeries={logsSeries}
logsVolumeEnabled={logsVolume.enabled}
onSetLogsVolumeEnabled={(enabled) =>
setSupplementaryQueryEnabled(exploreId, enabled, SupplementaryQueryType.LogsVolume)
}
onSetLogsVolumeEnabled={this.onSetLogsVolumeEnabled}
logsVolumeData={logsVolume.data}
logsQueries={logsQueries}
width={width}
splitOpen={splitOpenFn}
loading={loading}
loadingState={loadingState}
loadLogsVolumeData={() => loadSupplementaryQueryData(exploreId, SupplementaryQueryType.LogsVolume)}
loadLogsVolumeData={this.loadLogsVolumeData}
onChangeTime={this.onChangeTime}
loadMoreLogs={this.loadMoreLogs}
onClickFilterLabel={this.logDetailsFilterAvailable() ? onClickFilterLabel : undefined}

@ -101,6 +101,7 @@ export function LogsSamplePanel(props: Props) {
prettifyLogMessage={store.getBool(SETTINGS_KEYS.prettifyLogMessage, false)}
timeZone={timeZone}
enableLogDetails={true}
scrollElement={null}
/>
</div>
</>

@ -28,6 +28,7 @@ const defaultProps: Omit<Props, 'children'> = {
rows: [],
sortOrder: LogsSortOrder.Descending,
timeZone: 'browser',
scrollElement: null,
};
function ScrollWithWrapper({ children, ...props }: Props) {

@ -17,7 +17,7 @@ export type Props = {
loadMoreLogs?: (range: AbsoluteTimeRange) => void;
range: TimeRange;
rows: LogRowModel[];
scrollElement?: HTMLDivElement;
scrollElement: HTMLDivElement | null;
sortOrder: LogsSortOrder;
timeZone: TimeZone;
topScrollEnabled?: boolean;

@ -62,7 +62,7 @@ describe('LogRow', () => {
describe('with permalinking', () => {
it('reports via feature tracking when log line matches', () => {
const scrollIntoView = jest.fn();
setup({ permalinkedRowId: 'log-row-id', scrollIntoView, containerRendered: true });
setup({ permalinkedRowId: 'log-row-id', scrollIntoView });
expect(reportInteraction).toHaveBeenCalledWith('grafana_explore_logs_permalink_opened', {
logRowUid: 'log-row-id',
datasourceType: 'unknown',
@ -73,7 +73,6 @@ describe('LogRow', () => {
it('highlights row with same permalink-id', () => {
const { container } = setup({
permalinkedRowId: 'log-row-id',
containerRendered: true,
scrollIntoView: jest.fn(),
});
const row = container.querySelector('tr');
@ -86,7 +85,6 @@ describe('LogRow', () => {
const { container } = setup({
permalinkedRowId: 'log-row-id',
enableLogDetails: true,
containerRendered: true,
scrollIntoView: jest.fn(),
});
const row = container.querySelector('tr');
@ -111,28 +109,22 @@ describe('LogRow', () => {
it('calls `scrollIntoView` if permalink matches', () => {
const scrollIntoView = jest.fn();
setup({ permalinkedRowId: 'log-row-id', scrollIntoView, containerRendered: true });
setup({ permalinkedRowId: 'log-row-id', scrollIntoView });
expect(scrollIntoView).toHaveBeenCalled();
});
it('does not call `scrollIntoView` if permalink does not match', () => {
const scrollIntoView = jest.fn();
setup({ permalinkedRowId: 'wrong-log-row-id', scrollIntoView, containerRendered: true });
setup({ permalinkedRowId: 'wrong-log-row-id', scrollIntoView });
expect(scrollIntoView).not.toHaveBeenCalled();
});
it('calls `scrollIntoView` once', async () => {
const scrollIntoView = jest.fn();
setup({ permalinkedRowId: 'log-row-id', scrollIntoView, containerRendered: true });
setup({ permalinkedRowId: 'log-row-id', scrollIntoView });
await userEvent.hover(screen.getByText('test123'));
expect(scrollIntoView).toHaveBeenCalledTimes(1);
});
it('does not call `scrollIntoView` if permalink matches but container is not rendered yet', () => {
const scrollIntoView = jest.fn();
setup({ permalinkedRowId: 'log-row-id', scrollIntoView, containerRendered: false });
expect(scrollIntoView).not.toHaveBeenCalled();
});
});
it('should render the menu cell on mouse over', async () => {

@ -1,8 +1,5 @@
import { cx } from '@emotion/css';
import { debounce } from 'lodash';
import memoizeOne from 'memoize-one';
import * as React from 'react';
import { MouseEvent, PureComponent, ReactNode } from 'react';
import { MouseEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
CoreApp,
@ -16,7 +13,7 @@ import {
} from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { DataQuery, TimeZone } from '@grafana/schema';
import { Icon, PopoverContent, Themeable2, Tooltip, withTheme2 } from '@grafana/ui';
import { Icon, PopoverContent, Tooltip, useTheme2 } from '@grafana/ui';
import { checkLogsError, checkLogsSampled, escapeUnescapedString } from '../utils';
@ -26,7 +23,7 @@ import { LogRowMessage } from './LogRowMessage';
import { LogRowMessageDisplayedFields } from './LogRowMessageDisplayedFields';
import { getLogLevelStyles, LogRowStyles } from './getLogRowStyles';
interface Props extends Themeable2 {
export interface Props {
row: LogRowModel;
showDuplicates: boolean;
showLabels: boolean;
@ -63,293 +60,261 @@ interface Props extends Themeable2 {
onUnpinLine?: (row: LogRowModel) => void;
pinLineButtonTooltipTitle?: PopoverContent;
pinned?: boolean;
containerRendered?: boolean;
handleTextSelection?: (e: MouseEvent<HTMLTableRowElement>, row: LogRowModel) => boolean;
logRowMenuIconsBefore?: ReactNode[];
logRowMenuIconsAfter?: ReactNode[];
}
interface State {
permalinked: boolean;
showingContext: boolean;
showDetails: boolean;
mouseIsOver: boolean;
}
/**
* Renders a log line.
*
* When user hovers over it for a certain time, it lazily parses the log line.
* Once a parser is found, it will determine fields, that will be highlighted.
* When the user requests stats for a field, they will be calculated and rendered below the row.
*/
class UnThemedLogRow extends PureComponent<Props, State> {
state: State = {
permalinked: false,
showingContext: false,
showDetails: false,
mouseIsOver: false,
};
logLineRef: React.RefObject<HTMLTableRowElement>;
constructor(props: Props) {
super(props);
this.logLineRef = React.createRef();
}
// we are debouncing the state change by 3 seconds to highlight the logline after the context closed.
debouncedContextClose = debounce(() => {
this.setState({ showingContext: false });
}, 3000);
onOpenContext = (row: LogRowModel) => {
this.setState({ showingContext: true });
this.props.onOpenContext(row, this.debouncedContextClose);
};
onRowClick = (e: MouseEvent<HTMLTableRowElement>) => {
if (this.props.handleTextSelection?.(e, this.props.row)) {
// Event handled by the parent.
return;
}
if (!this.props.enableLogDetails) {
return;
}
export const LogRow = ({
getRows,
onClickFilterLabel,
onClickFilterOutLabel,
onClickShowField,
onClickHideField,
enableLogDetails,
row,
showDuplicates,
showContextToggle,
showLabels,
showTime,
displayedFields,
wrapLogMessage,
prettifyLogMessage,
getFieldLinks,
forceEscape,
app,
styles,
getRowContextQuery,
pinned,
logRowMenuIconsBefore,
logRowMenuIconsAfter,
timeZone,
permalinkedRowId,
scrollIntoView,
handleTextSelection,
onLogRowHover,
...props
}: Props) => {
const [showingContext, setShowingContext] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [mouseIsOver, setMouseIsOver] = useState(false);
const [permalinked, setPermalinked] = useState(false);
const logLineRef = useRef<HTMLTableRowElement | null>(null);
const theme = useTheme2();
this.setState((state) => {
return {
showDetails: !state.showDetails,
};
});
};
const timestamp = useMemo(
() =>
dateTimeFormat(row.timeEpochMs, {
timeZone: timeZone,
defaultWithMS: true,
}),
[row.timeEpochMs, timeZone]
);
const levelStyles = useMemo(() => getLogLevelStyles(theme, row.logLevel), [row.logLevel, theme]);
const processedRow = useMemo(
() =>
row.hasUnescapedContent && forceEscape
? { ...row, entry: escapeUnescapedString(row.entry), raw: escapeUnescapedString(row.raw) }
: row,
[forceEscape, row]
);
const errorMessage = checkLogsError(row);
const hasError = errorMessage !== undefined;
const sampleMessage = checkLogsSampled(row);
const isSampled = sampleMessage !== undefined;
renderTimeStamp(epochMs: number) {
return dateTimeFormat(epochMs, {
timeZone: this.props.timeZone,
defaultWithMS: true,
});
}
onMouseEnter = () => {
this.setState({ mouseIsOver: true });
if (this.props.onLogRowHover) {
this.props.onLogRowHover(this.props.row);
}
};
onMouseMove = (e: MouseEvent) => {
// No need to worry about text selection.
if (!this.props.handleTextSelection) {
useEffect(() => {
if (permalinkedRowId !== row.uid) {
setPermalinked(false);
return;
}
// The user is selecting text, so hide the log row menu so it doesn't interfere.
if (document.getSelection()?.toString() && e.buttons > 0) {
this.setState({ mouseIsOver: false });
}
};
onMouseLeave = () => {
this.setState({ mouseIsOver: false });
};
componentDidMount() {
this.scrollToLogRow(this.state, true);
}
componentDidUpdate(_: Props, prevState: State) {
this.scrollToLogRow(prevState);
}
scrollToLogRow = (prevState: State, mounted = false) => {
const { row, permalinkedRowId, scrollIntoView, containerRendered } = this.props;
if (permalinkedRowId !== row.uid) {
// only set the new state if the row is not permalinked anymore or if the component was mounted.
if (prevState.permalinked || mounted) {
this.setState({ permalinked: false });
}
if (!permalinked) {
setPermalinked(true);
return;
}
if (!this.state.permalinked && containerRendered && this.logLineRef.current && scrollIntoView) {
if (logLineRef.current && scrollIntoView) {
// at this point this row is the permalinked row, so we need to scroll to it and highlight it if possible.
scrollIntoView(this.logLineRef.current);
scrollIntoView(logLineRef.current);
reportInteraction('grafana_explore_logs_permalink_opened', {
datasourceType: row.datasourceType ?? 'unknown',
logRowUid: row.uid,
});
this.setState({ permalinked: true });
setPermalinked(true);
}
};
}, [permalinked, permalinkedRowId, row.datasourceType, row.uid, scrollIntoView]);
escapeRow = memoizeOne((row: LogRowModel, forceEscape: boolean | undefined) => {
return row.hasUnescapedContent && forceEscape
? { ...row, entry: escapeUnescapedString(row.entry), raw: escapeUnescapedString(row.raw) }
: row;
});
// we are debouncing the state change by 3 seconds to highlight the logline after the context closed.
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedContextClose = useCallback(
debounce(() => {
setShowingContext(false);
}, 3000),
[]
);
const onOpenContext = useCallback(
(row: LogRowModel) => {
setShowingContext(true);
props.onOpenContext(row, debouncedContextClose);
},
[debouncedContextClose, props]
);
const onRowClick = useCallback(
(e: MouseEvent<HTMLTableRowElement>) => {
if (handleTextSelection?.(e, row)) {
// Event handled by the parent.
return;
}
if (!enableLogDetails) {
return;
}
render() {
const {
getRows,
onClickFilterLabel,
onClickFilterOutLabel,
onClickShowField,
onClickHideField,
enableLogDetails,
row,
showDuplicates,
showContextToggle,
showLabels,
showTime,
displayedFields,
wrapLogMessage,
prettifyLogMessage,
theme,
getFieldLinks,
forceEscape,
app,
styles,
getRowContextQuery,
pinned,
logRowMenuIconsBefore,
logRowMenuIconsAfter,
} = this.props;
setShowDetails((showDetails: boolean) => !showDetails);
},
[enableLogDetails, handleTextSelection, row]
);
const { showDetails, showingContext, permalinked } = this.state;
const levelStyles = getLogLevelStyles(theme, row.logLevel);
const { errorMessage, hasError } = checkLogsError(row);
const { sampleMessage, isSampled } = checkLogsSampled(row);
const logRowBackground = cx(styles.logsRow, {
[styles.errorLogRow]: hasError,
[styles.highlightBackground]: showingContext || permalinked || pinned,
});
const logRowDetailsBackground = cx(styles.logsRow, {
[styles.errorLogRow]: hasError,
[styles.highlightBackground]: permalinked && !this.state.showDetails,
});
const onMouseEnter = useCallback(() => {
setMouseIsOver(true);
if (onLogRowHover) {
onLogRowHover(row);
}
}, [onLogRowHover, row]);
const onMouseMove = useCallback(
(e: MouseEvent) => {
// No need to worry about text selection.
if (!handleTextSelection) {
return;
}
// The user is selecting text, so hide the log row menu so it doesn't interfere.
if (document.getSelection()?.toString() && e.buttons > 0) {
setMouseIsOver(false);
}
},
[handleTextSelection]
);
const processedRow = this.escapeRow(row, forceEscape);
const onMouseLeave = useCallback(() => {
setMouseIsOver(false);
}, []);
return (
<>
<tr
ref={this.logLineRef}
className={logRowBackground}
onClick={this.onRowClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
onMouseMove={this.onMouseMove}
/**
* For better accessibility support, we listen to the onFocus event here (to display the LogRowMenuCell), and
* to onBlur event in the LogRowMenuCell (to hide it). This way, the LogRowMenuCell is displayed when the user navigates
* using the keyboard.
*/
onFocus={this.onMouseEnter}
return (
<>
<tr
ref={logLineRef}
className={`${styles.logsRow} ${hasError ? styles.errorLogRow : ''} ${showingContext || permalinked || pinned ? styles.highlightBackground : ''}`}
onClick={onRowClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onMouseMove={onMouseMove}
/**
* For better accessibility support, we listen to the onFocus event here (to display the LogRowMenuCell), and
* to onBlur event in the LogRowMenuCell (to hide it). This way, the LogRowMenuCell is displayed when the user navigates
* using the keyboard.
*/
onFocus={onMouseEnter}
>
{showDuplicates && (
<td className={styles.logsRowDuplicates}>
{processedRow.duplicates && processedRow.duplicates > 0 ? `${processedRow.duplicates + 1}x` : null}
</td>
)}
<td
className={
hasError || isSampled ? styles.logsRowWithError : `${levelStyles.logsRowLevelColor} ${styles.logsRowLevel}`
}
>
{showDuplicates && (
<td className={styles.logsRowDuplicates}>
{processedRow.duplicates && processedRow.duplicates > 0 ? `${processedRow.duplicates + 1}x` : null}
</td>
{hasError && (
<Tooltip content={`Error: ${errorMessage}`} placement="right" theme="error">
<Icon className={styles.logIconError} name="exclamation-triangle" size="xs" />
</Tooltip>
)}
<td
className={
hasError || isSampled
? styles.logsRowWithError
: `${levelStyles.logsRowLevelColor} ${styles.logsRowLevel}`
}
>
{hasError && (
<Tooltip content={`Error: ${errorMessage}`} placement="right" theme="error">
<Icon className={styles.logIconError} name="exclamation-triangle" size="xs" />
</Tooltip>
)}
{isSampled && (
<Tooltip content={`${sampleMessage}`} placement="right" theme="info">
<Icon className={styles.logIconInfo} name="info-circle" size="xs" />
</Tooltip>
)}
</td>
<td
title={enableLogDetails ? (showDetails ? 'Hide log details' : 'See log details') : ''}
className={enableLogDetails ? styles.logsRowToggleDetails : ''}
>
{enableLogDetails && (
<Icon className={styles.topVerticalAlign} name={showDetails ? 'angle-down' : 'angle-right'} />
)}
</td>
{showTime && <td className={styles.logsRowLocalTime}>{this.renderTimeStamp(row.timeEpochMs)}</td>}
{showLabels && processedRow.uniqueLabels && (
<td className={styles.logsRowLabels}>
<LogLabels labels={processedRow.uniqueLabels} addTooltip={false} />
</td>
{isSampled && (
<Tooltip content={`${sampleMessage}`} placement="right" theme="info">
<Icon className={styles.logIconInfo} name="info-circle" size="xs" />
</Tooltip>
)}
{displayedFields && displayedFields.length > 0 ? (
<LogRowMessageDisplayedFields
row={processedRow}
showContextToggle={showContextToggle}
detectedFields={displayedFields}
getFieldLinks={getFieldLinks}
wrapLogMessage={wrapLogMessage}
onOpenContext={this.onOpenContext}
onPermalinkClick={this.props.onPermalinkClick}
styles={styles}
onPinLine={this.props.onPinLine}
onUnpinLine={this.props.onUnpinLine}
pinned={this.props.pinned}
mouseIsOver={this.state.mouseIsOver}
onBlur={this.onMouseLeave}
logRowMenuIconsBefore={logRowMenuIconsBefore}
logRowMenuIconsAfter={logRowMenuIconsAfter}
/>
) : (
<LogRowMessage
row={processedRow}
showContextToggle={showContextToggle}
getRowContextQuery={getRowContextQuery}
wrapLogMessage={wrapLogMessage}
prettifyLogMessage={prettifyLogMessage}
onOpenContext={this.onOpenContext}
onPermalinkClick={this.props.onPermalinkClick}
app={app}
styles={styles}
onPinLine={this.props.onPinLine}
onUnpinLine={this.props.onUnpinLine}
pinLineButtonTooltipTitle={this.props.pinLineButtonTooltipTitle}
pinned={this.props.pinned}
mouseIsOver={this.state.mouseIsOver}
onBlur={this.onMouseLeave}
expanded={this.state.showDetails}
logRowMenuIconsBefore={logRowMenuIconsBefore}
logRowMenuIconsAfter={logRowMenuIconsAfter}
/>
</td>
<td
title={enableLogDetails ? (showDetails ? 'Hide log details' : 'See log details') : ''}
className={enableLogDetails ? styles.logsRowToggleDetails : ''}
>
{enableLogDetails && (
<Icon className={styles.topVerticalAlign} name={showDetails ? 'angle-down' : 'angle-right'} />
)}
</tr>
{this.state.showDetails && (
<LogDetails
onPinLine={this.props.onPinLine}
className={logRowDetailsBackground}
showDuplicates={showDuplicates}
</td>
{showTime && <td className={styles.logsRowLocalTime}>{timestamp}</td>}
{showLabels && processedRow.uniqueLabels && (
<td className={styles.logsRowLabels}>
<LogLabels labels={processedRow.uniqueLabels} addTooltip={false} />
</td>
)}
{displayedFields && displayedFields.length > 0 ? (
<LogRowMessageDisplayedFields
row={processedRow}
showContextToggle={showContextToggle}
detectedFields={displayedFields}
getFieldLinks={getFieldLinks}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
onClickShowField={onClickShowField}
onClickHideField={onClickHideField}
getRows={getRows}
wrapLogMessage={wrapLogMessage}
onOpenContext={onOpenContext}
onPermalinkClick={props.onPermalinkClick}
styles={styles}
onPinLine={props.onPinLine}
onUnpinLine={props.onUnpinLine}
pinned={pinned}
mouseIsOver={mouseIsOver}
onBlur={onMouseLeave}
logRowMenuIconsBefore={logRowMenuIconsBefore}
logRowMenuIconsAfter={logRowMenuIconsAfter}
/>
) : (
<LogRowMessage
row={processedRow}
showContextToggle={showContextToggle}
getRowContextQuery={getRowContextQuery}
wrapLogMessage={wrapLogMessage}
hasError={hasError}
displayedFields={displayedFields}
prettifyLogMessage={prettifyLogMessage}
onOpenContext={onOpenContext}
onPermalinkClick={props.onPermalinkClick}
app={app}
styles={styles}
isFilterLabelActive={this.props.isFilterLabelActive}
pinLineButtonTooltipTitle={this.props.pinLineButtonTooltipTitle}
onPinLine={props.onPinLine}
onUnpinLine={props.onUnpinLine}
pinLineButtonTooltipTitle={props.pinLineButtonTooltipTitle}
pinned={pinned}
mouseIsOver={mouseIsOver}
onBlur={onMouseLeave}
expanded={showDetails}
logRowMenuIconsBefore={logRowMenuIconsBefore}
logRowMenuIconsAfter={logRowMenuIconsAfter}
/>
)}
</>
);
}
}
export const LogRow = withTheme2(UnThemedLogRow);
LogRow.displayName = 'LogRow';
</tr>
{showDetails && (
<LogDetails
onPinLine={props.onPinLine}
className={`${styles.logsRow} ${hasError ? styles.errorLogRow : ''} ${permalinked && !showDetails ? styles.highlightBackground : ''}`}
showDuplicates={showDuplicates}
getFieldLinks={getFieldLinks}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
onClickShowField={onClickShowField}
onClickHideField={onClickHideField}
getRows={getRows}
row={processedRow}
wrapLogMessage={wrapLogMessage}
hasError={hasError}
displayedFields={displayedFields}
app={app}
styles={styles}
isFilterLabelActive={props.isFilterLabelActive}
pinLineButtonTooltipTitle={props.pinLineButtonTooltipTitle}
/>
)}
</>
);
};

@ -156,7 +156,7 @@ export const LogRowMessage = memo((props: Props) => {
() => restructureLog(raw, prettifyLogMessage, wrapLogMessage, Boolean(expanded)),
[raw, prettifyLogMessage, wrapLogMessage, expanded]
);
const shouldShowMenu = useMemo(() => mouseIsOver || pinned, [mouseIsOver, pinned]);
const shouldShowMenu = mouseIsOver || pinned;
return (
<>
{

@ -24,6 +24,7 @@ export interface Props {
onBlur: () => void;
logRowMenuIconsBefore?: ReactNode[];
logRowMenuIconsAfter?: ReactNode[];
preview?: boolean;
}
export const LogRowMessageDisplayedFields = memo((props: Props) => {
@ -37,6 +38,7 @@ export const LogRowMessageDisplayedFields = memo((props: Props) => {
pinned,
logRowMenuIconsBefore,
logRowMenuIconsAfter,
preview,
...rest
} = props;
const wrapClassName = wrapLogMessage ? '' : displayedFieldsStyles.noWrap;
@ -52,8 +54,7 @@ export const LogRowMessageDisplayedFields = memo((props: Props) => {
}
const field = fields.find((field) => {
const { keys } = field;
return keys[0] === parsedKey;
return field.keys[0] === parsedKey;
});
if (field != null) {
@ -67,7 +68,18 @@ export const LogRowMessageDisplayedFields = memo((props: Props) => {
return line.trimStart();
}, [detectedFields, fields, row.entry, row.labels]);
const shouldShowMenu = useMemo(() => mouseIsOver || pinned, [mouseIsOver, pinned]);
const shouldShowMenu = mouseIsOver || pinned;
if (preview) {
return (
<>
<td>
<div>{line}</div>
</td>
<td></td>
</>
);
}
return (
<>

@ -1,10 +1,9 @@
import { act, render, screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { range } from 'lodash';
import { LogRowModel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
import { LogRows, PREVIEW_LIMIT, Props } from './LogRows';
import { LogRows, Props } from './LogRows';
import { createLogRow } from './__mocks__/logRow';
jest.mock('@grafana/runtime', () => ({
@ -34,6 +33,7 @@ describe('LogRows', () => {
onClickFilterOutLabel={() => {}}
onClickHideField={() => {}}
onClickShowField={() => {}}
scrollElement={null}
/>
);
@ -43,57 +43,6 @@ describe('LogRows', () => {
expect(screen.queryAllByRole('row').at(2)).toHaveTextContent('log message 3');
});
it('renders rows only limited number of rows first', () => {
const rows: LogRowModel[] = [createLogRow({ uid: '1' }), createLogRow({ uid: '2' }), createLogRow({ uid: '3' })];
jest.useFakeTimers();
const { rerender } = render(
<LogRows
logRows={rows}
dedupStrategy={LogsDedupStrategy.none}
showLabels={false}
showTime={false}
wrapLogMessage={true}
prettifyLogMessage={true}
timeZone={'utc'}
previewLimit={1}
enableLogDetails={true}
/>
);
// There is an extra row with the rows that are rendering
expect(screen.queryAllByRole('row')).toHaveLength(2);
expect(screen.queryAllByRole('row').at(0)).toHaveTextContent('log message 1');
act(() => {
jest.runAllTimers();
});
rerender(
<LogRows
logRows={rows}
dedupStrategy={LogsDedupStrategy.none}
showLabels={false}
showTime={false}
wrapLogMessage={true}
prettifyLogMessage={true}
timeZone={'utc'}
previewLimit={1}
enableLogDetails={true}
displayedFields={[]}
onClickFilterLabel={() => {}}
onClickFilterOutLabel={() => {}}
onClickHideField={() => {}}
onClickShowField={() => {}}
/>
);
expect(screen.queryAllByRole('row')).toHaveLength(3);
expect(screen.queryAllByRole('row').at(0)).toHaveTextContent('log message 1');
expect(screen.queryAllByRole('row').at(1)).toHaveTextContent('log message 2');
expect(screen.queryAllByRole('row').at(2)).toHaveTextContent('log message 3');
jest.useRealTimers();
});
it('renders deduped rows if supplied', () => {
const rows: LogRowModel[] = [createLogRow({ uid: '1' }), createLogRow({ uid: '2' }), createLogRow({ uid: '3' })];
const dedupedRows: LogRowModel[] = [createLogRow({ uid: '4' }), createLogRow({ uid: '5' })];
@ -113,6 +62,7 @@ describe('LogRows', () => {
onClickFilterOutLabel={() => {}}
onClickHideField={() => {}}
onClickShowField={() => {}}
scrollElement={null}
/>
);
expect(screen.queryAllByRole('row')).toHaveLength(2);
@ -120,31 +70,6 @@ describe('LogRows', () => {
expect(screen.queryAllByRole('row').at(1)).toHaveTextContent('log message 5');
});
it('renders with default preview limit', () => {
// PREVIEW_LIMIT * 2 is there because otherwise we just render all rows
const rows: LogRowModel[] = range(PREVIEW_LIMIT * 2 + 1).map((num) => createLogRow({ uid: num.toString() }));
render(
<LogRows
logRows={rows}
dedupStrategy={LogsDedupStrategy.none}
showLabels={false}
showTime={false}
wrapLogMessage={true}
prettifyLogMessage={true}
timeZone={'utc'}
enableLogDetails={true}
displayedFields={[]}
onClickFilterLabel={() => {}}
onClickFilterOutLabel={() => {}}
onClickHideField={() => {}}
onClickShowField={() => {}}
/>
);
// There is an extra row with the rows that are rendering
expect(screen.queryAllByRole('row')).toHaveLength(101);
});
it('renders asc ordered rows if order and function supplied', () => {
const rows: LogRowModel[] = [
createLogRow({ uid: '1', timeEpochMs: 1 }),
@ -167,6 +92,7 @@ describe('LogRows', () => {
onClickFilterOutLabel={() => {}}
onClickHideField={() => {}}
onClickShowField={() => {}}
scrollElement={null}
/>
);
@ -196,6 +122,7 @@ describe('LogRows', () => {
onClickFilterOutLabel={() => {}}
onClickHideField={() => {}}
onClickShowField={() => {}}
scrollElement={null}
/>
);
@ -222,6 +149,7 @@ describe('Popover menu', () => {
displayedFields={[]}
onClickFilterOutString={() => {}}
onClickFilterString={() => {}}
scrollElement={null}
{...overrides}
/>
);

@ -1,6 +1,5 @@
import { cx } from '@emotion/css';
import memoizeOne from 'memoize-one';
import { PureComponent, MouseEvent, createRef, ReactNode } from 'react';
import { MouseEvent, ReactNode, useState, useMemo, useCallback, useRef, useEffect, memo } from 'react';
import {
TimeZone,
@ -15,7 +14,7 @@ import {
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { withTheme2, Themeable2, PopoverContent } from '@grafana/ui';
import { PopoverContent, useTheme2 } from '@grafana/ui';
import { PopoverMenu } from '../../explore/Logs/PopoverMenu';
import { UniqueKeyMaker } from '../UniqueKeyMaker';
@ -23,11 +22,10 @@ import { sortLogRows, targetIsElement } from '../utils';
//Components
import { LogRow } from './LogRow';
import { PreviewLogRow } from './PreviewLogRow';
import { getLogRowStyles } from './getLogRowStyles';
export const PREVIEW_LIMIT = 100;
export interface Props extends Themeable2 {
export interface Props {
logRows?: LogRowModel[];
deduplicatedRows?: LogRowModel[];
dedupStrategy: LogsDedupStrategy;
@ -64,7 +62,6 @@ export interface Props extends Themeable2 {
isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise<boolean>;
pinnedRowId?: string;
pinnedLogs?: string[];
containerRendered?: boolean;
/**
* If false or undefined, the `contain:strict` css property will be added to the wrapping `<table>` for performance reasons.
* Any overflowing content will be clipped at the table boundary.
@ -74,219 +71,223 @@ export interface Props extends Themeable2 {
onClickFilterOutString?: (value: string, refId?: string) => void;
logRowMenuIconsBefore?: ReactNode[];
logRowMenuIconsAfter?: ReactNode[];
scrollElement: HTMLDivElement | null;
renderPreview?: boolean;
}
interface State {
renderAll: boolean;
type PopoverStateType = {
selection: string;
selectedRow: LogRowModel | null;
popoverMenuCoordinates: { x: number; y: number };
}
class UnThemedLogRows extends PureComponent<Props, State> {
renderAllTimer: number | null = null;
logRowsRef = createRef<HTMLDivElement>();
static defaultProps = {
previewLimit: PREVIEW_LIMIT,
};
};
state: State = {
renderAll: false,
selection: '',
selectedRow: null,
popoverMenuCoordinates: { x: 0, y: 0 },
};
/**
* Toggle the `contextIsOpen` state when a context of one LogRow is opened in order to not show the menu of the other log rows.
*/
openContext = (row: LogRowModel, onClose: () => void): void => {
if (this.props.onOpenContext) {
this.props.onOpenContext(row, onClose);
}
};
popoverMenuSupported() {
if (!config.featureToggles.logRowsPopoverMenu) {
return false;
}
return Boolean(this.props.onClickFilterOutString || this.props.onClickFilterString);
}
handleSelection = (e: MouseEvent<HTMLTableRowElement>, row: LogRowModel): boolean => {
const selection = document.getSelection()?.toString();
if (!selection) {
return false;
}
if (this.popoverMenuSupported() === false) {
// This signals onRowClick inside LogRow to skip the event because the user is selecting text
return selection ? true : false;
}
export const LogRows = memo(
({
deduplicatedRows,
logRows = [],
dedupStrategy,
logsSortOrder,
previewLimit,
pinnedLogs,
onOpenContext,
onClickFilterOutString,
onClickFilterString,
scrollElement,
renderPreview = false,
enableLogDetails,
permalinkedRowId,
...props
}: Props) => {
const [previewSize, setPreviewSize] = useState(
/**
* If renderPreview is enabled, either half of the log rows or twice the screen size of log rows will be rendered.
* The biggest of those values will be used. Else, all rows are rendered.
*/
renderPreview && !permalinkedRowId
? Math.max(2 * Math.ceil(window.innerHeight / 20), Math.ceil(logRows.length / 3))
: Infinity
);
const [popoverState, setPopoverState] = useState<PopoverStateType>({
selection: '',
selectedRow: null,
popoverMenuCoordinates: { x: 0, y: 0 },
});
const logRowsRef = useRef<HTMLDivElement>(null);
const theme = useTheme2();
const styles = getLogRowStyles(theme);
const dedupedRows = deduplicatedRows ? deduplicatedRows : logRows;
const dedupCount = useMemo(
() => dedupedRows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0),
[dedupedRows]
);
const showDuplicates = dedupStrategy !== LogsDedupStrategy.none && dedupCount > 0;
// Staged rendering
const orderedRows = useMemo(
() => (logsSortOrder ? sortLogRows(dedupedRows, logsSortOrder) : dedupedRows),
[dedupedRows, logsSortOrder]
);
// React profiler becomes unusable if we pass all rows to all rows and their labels, using getter instead
const getRows = useMemo(() => () => orderedRows, [orderedRows]);
const handleDeselectionRef = useRef<((e: Event) => void) | null>(null);
const keyMaker = new UniqueKeyMaker();
// eslint-disable-next-line react-hooks/exhaustive-deps
if (!this.logRowsRef.current) {
return false;
}
useEffect(() => {
return () => {
if (handleDeselectionRef.current) {
document.removeEventListener('click', handleDeselectionRef.current);
document.removeEventListener('contextmenu', handleDeselectionRef.current);
}
};
}, []);
const MENU_WIDTH = 270;
const MENU_HEIGHT = 105;
const x = e.clientX + MENU_WIDTH > window.innerWidth ? window.innerWidth - MENU_WIDTH : e.clientX;
const y = e.clientY + MENU_HEIGHT > window.innerHeight ? window.innerHeight - MENU_HEIGHT : e.clientY;
useEffect(() => {
if (!scrollElement) {
return;
}
this.setState({
selection,
popoverMenuCoordinates: { x, y },
selectedRow: row,
});
document.addEventListener('click', this.handleDeselection);
document.addEventListener('contextmenu', this.handleDeselection);
return true;
};
function renderAll() {
setPreviewSize(Infinity);
scrollElement?.removeEventListener('scroll', renderAll);
scrollElement?.removeEventListener('wheel', renderAll);
}
handleDeselection = (e: Event) => {
if (targetIsElement(e.target) && !this.logRowsRef.current?.contains(e.target)) {
// The mouseup event comes from outside the log rows, close the menu.
this.closePopoverMenu();
return;
}
if (document.getSelection()?.toString()) {
return;
}
this.closePopoverMenu();
};
scrollElement.addEventListener('scroll', renderAll);
scrollElement.addEventListener('wheel', renderAll);
}, [logRows.length, scrollElement]);
closePopoverMenu = () => {
document.removeEventListener('click', this.handleDeselection);
document.removeEventListener('contextmenu', this.handleDeselection);
this.setState({
selection: '',
popoverMenuCoordinates: { x: 0, y: 0 },
selectedRow: null,
});
};
/**
* Toggle the `contextIsOpen` state when a context of one LogRow is opened in order to not show the menu of the other log rows.
*/
const openContext = useCallback(
(row: LogRowModel, onClose: () => void): void => {
if (onOpenContext) {
onOpenContext(row, onClose);
}
},
[onOpenContext]
);
componentDidMount() {
// Staged rendering
const { logRows, previewLimit } = this.props;
const rowCount = logRows ? logRows.length : 0;
// Render all right away if not too far over the limit
const renderAll = rowCount <= previewLimit! * 2;
if (renderAll) {
this.setState({ renderAll });
} else {
this.renderAllTimer = window.setTimeout(() => this.setState({ renderAll: true }), 2000);
}
}
const popoverMenuSupported = useCallback(() => {
if (!config.featureToggles.logRowsPopoverMenu) {
return false;
}
return Boolean(onClickFilterOutString || onClickFilterString);
}, [onClickFilterOutString, onClickFilterString]);
componentWillUnmount() {
document.removeEventListener('click', this.handleDeselection);
document.removeEventListener('contextmenu', this.handleDeselection);
document.removeEventListener('selectionchange', this.handleDeselection);
if (this.renderAllTimer) {
clearTimeout(this.renderAllTimer);
}
}
const closePopoverMenu = useCallback(() => {
if (handleDeselectionRef.current) {
document.removeEventListener('click', handleDeselectionRef.current);
document.removeEventListener('contextmenu', handleDeselectionRef.current);
handleDeselectionRef.current = null;
}
setPopoverState({
selection: '',
popoverMenuCoordinates: { x: 0, y: 0 },
selectedRow: null,
});
}, []);
makeGetRows = memoizeOne((orderedRows: LogRowModel[]) => {
return () => orderedRows;
});
const handleDeselection = useCallback(
(e: Event) => {
if (targetIsElement(e.target) && !logRowsRef.current?.contains(e.target)) {
// The mouseup event comes from outside the log rows, close the menu.
closePopoverMenu();
return;
}
if (document.getSelection()?.toString()) {
return;
}
closePopoverMenu();
},
[closePopoverMenu]
);
sortLogs = memoizeOne((logRows: LogRowModel[], logsSortOrder: LogsSortOrder): LogRowModel[] =>
sortLogRows(logRows, logsSortOrder)
);
const handleSelection = useCallback(
(e: MouseEvent<HTMLElement>, row: LogRowModel): boolean => {
const selection = document.getSelection()?.toString();
if (!selection) {
return false;
}
if (popoverMenuSupported() === false) {
// This signals onRowClick inside LogRow to skip the event because the user is selecting text
return selection ? true : false;
}
render() {
const { deduplicatedRows, logRows, dedupStrategy, theme, logsSortOrder, previewLimit, pinnedLogs, ...rest } =
this.props;
const { renderAll } = this.state;
const styles = getLogRowStyles(theme);
const dedupedRows = deduplicatedRows ? deduplicatedRows : logRows;
const hasData = logRows && logRows.length > 0;
const dedupCount = dedupedRows
? dedupedRows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0)
: 0;
const showDuplicates = dedupStrategy !== LogsDedupStrategy.none && dedupCount > 0;
// Staged rendering
const processedRows = dedupedRows ? dedupedRows : [];
const orderedRows = logsSortOrder ? this.sortLogs(processedRows, logsSortOrder) : processedRows;
const firstRows = orderedRows.slice(0, previewLimit!);
const lastRows = orderedRows.slice(previewLimit!, orderedRows.length);
if (!logRowsRef.current) {
return false;
}
// React profiler becomes unusable if we pass all rows to all rows and their labels, using getter instead
const getRows = this.makeGetRows(orderedRows);
const MENU_WIDTH = 270;
const MENU_HEIGHT = 105;
const x = e.clientX + MENU_WIDTH > window.innerWidth ? window.innerWidth - MENU_WIDTH : e.clientX;
const y = e.clientY + MENU_HEIGHT > window.innerHeight ? window.innerHeight - MENU_HEIGHT : e.clientY;
const keyMaker = new UniqueKeyMaker();
setPopoverState({
selection,
popoverMenuCoordinates: { x, y },
selectedRow: row,
});
handleDeselectionRef.current = handleDeselection;
document.addEventListener('click', handleDeselection);
document.addEventListener('contextmenu', handleDeselection);
return true;
},
[handleDeselection, popoverMenuSupported]
);
return (
<div className={styles.logRows} ref={this.logRowsRef}>
{this.state.selection && this.state.selectedRow && (
<div className={styles.logRows} ref={logRowsRef}>
{popoverState.selection && popoverState.selectedRow && (
<PopoverMenu
close={this.closePopoverMenu}
row={this.state.selectedRow}
selection={this.state.selection}
{...this.state.popoverMenuCoordinates}
onClickFilterString={rest.onClickFilterString}
onClickFilterOutString={rest.onClickFilterOutString}
close={closePopoverMenu}
row={popoverState.selectedRow}
selection={popoverState.selection}
{...popoverState.popoverMenuCoordinates}
onClickFilterString={onClickFilterString}
onClickFilterOutString={onClickFilterOutString}
/>
)}
<table className={cx(styles.logsRowsTable, this.props.overflowingContent ? '' : styles.logsRowsTableContain)}>
<table className={cx(styles.logsRowsTable, props.overflowingContent ? '' : styles.logsRowsTableContain)}>
<tbody>
{hasData &&
firstRows.map((row) => (
{orderedRows.map((row, index) =>
index < previewSize ? (
<LogRow
key={keyMaker.getKey(row.uid)}
getRows={getRows}
row={row}
showDuplicates={showDuplicates}
logsSortOrder={logsSortOrder}
onOpenContext={this.openContext}
onOpenContext={openContext}
styles={styles}
onPermalinkClick={this.props.onPermalinkClick}
scrollIntoView={this.props.scrollIntoView}
permalinkedRowId={this.props.permalinkedRowId}
onPinLine={this.props.onPinLine}
onUnpinLine={this.props.onUnpinLine}
pinLineButtonTooltipTitle={this.props.pinLineButtonTooltipTitle}
pinned={this.props.pinnedRowId === row.uid || pinnedLogs?.some((logId) => logId === row.rowId)}
isFilterLabelActive={this.props.isFilterLabelActive}
handleTextSelection={this.handleSelection}
{...rest}
onPermalinkClick={props.onPermalinkClick}
scrollIntoView={props.scrollIntoView}
permalinkedRowId={permalinkedRowId}
onPinLine={props.onPinLine}
onUnpinLine={props.onUnpinLine}
pinLineButtonTooltipTitle={props.pinLineButtonTooltipTitle}
pinned={props.pinnedRowId === row.uid || pinnedLogs?.some((logId) => logId === row.rowId)}
isFilterLabelActive={props.isFilterLabelActive}
handleTextSelection={handleSelection}
enableLogDetails={enableLogDetails}
{...props}
/>
))}
{hasData &&
renderAll &&
lastRows.map((row) => (
<LogRow
key={keyMaker.getKey(row.uid)}
) : (
<PreviewLogRow
key={`preview_${keyMaker.getKey(row.uid)}`}
enableLogDetails={false}
getRows={getRows}
row={row}
showDuplicates={showDuplicates}
logsSortOrder={logsSortOrder}
onOpenContext={this.openContext}
onOpenContext={openContext}
styles={styles}
onPermalinkClick={this.props.onPermalinkClick}
scrollIntoView={this.props.scrollIntoView}
permalinkedRowId={this.props.permalinkedRowId}
onPinLine={this.props.onPinLine}
onUnpinLine={this.props.onUnpinLine}
pinLineButtonTooltipTitle={this.props.pinLineButtonTooltipTitle}
pinned={this.props.pinnedRowId === row.uid || pinnedLogs?.some((logId) => logId === row.rowId)}
isFilterLabelActive={this.props.isFilterLabelActive}
handleTextSelection={this.handleSelection}
{...rest}
showDuplicates={showDuplicates}
{...props}
row={row}
/>
))}
{hasData && !renderAll && (
<tr>
<td colSpan={5}>Rendering {orderedRows.length - previewLimit!} rows...</td>
</tr>
)
)}
</tbody>
</table>
</div>
);
}
}
export const LogRows = withTheme2(UnThemedLogRows);
LogRows.displayName = 'LogsRows';
);

@ -0,0 +1,29 @@
import { Props } from './LogRow';
import { LogRowMessageDisplayedFields } from './LogRowMessageDisplayedFields';
const emptyFn = () => {};
export const PreviewLogRow = ({ row, showDuplicates, showLabels, showTime, displayedFields, ...rest }: Props) => {
return (
<tr>
{showDuplicates && <td></td>}
<td></td>
<td></td>
{showTime && <td>{row.timeEpochMs}</td>}
{showLabels && row.uniqueLabels && <td></td>}
{displayedFields ? (
<LogRowMessageDisplayedFields
{...rest}
row={row}
detectedFields={displayedFields}
mouseIsOver={false}
onBlur={emptyFn}
onOpenContext={emptyFn}
preview
/>
) : (
<td>{row.entry}</td>
)}
<td></td>
</tr>
);
};

@ -55,31 +55,7 @@ const dfAfter = createDataFrame({
],
});
let uniqueRefIdCounter = 1;
const getRowContext = jest.fn().mockImplementation(async (_, options) => {
uniqueRefIdCounter += 1;
const refId = `refid_${uniqueRefIdCounter}`;
if (options.direction === LogRowContextQueryDirection.Forward) {
return {
data: [
{
refId,
...dfBefore,
},
],
};
} else {
return {
data: [
{
refId,
...dfAfter,
},
],
};
}
});
let getRowContext = jest.fn();
const dispatchMock = jest.fn();
jest.mock('app/types', () => ({
...jest.requireActual('app/types'),
@ -102,9 +78,34 @@ const timeZone = 'UTC';
describe('LogRowContextModal', () => {
const originalScrollIntoView = window.HTMLElement.prototype.scrollIntoView;
let uniqueRefIdCounter = 1;
beforeEach(() => {
window.HTMLElement.prototype.scrollIntoView = jest.fn();
uniqueRefIdCounter = 1;
getRowContext = jest.fn().mockImplementation(async (_, options) => {
uniqueRefIdCounter += 1;
const refId = `refid_${uniqueRefIdCounter}`;
if (options.direction === LogRowContextQueryDirection.Forward) {
return {
data: [
{
refId,
...dfBefore,
},
],
};
} else {
return {
data: [
{
refId,
...dfAfter,
},
],
};
}
});
});
afterEach(() => {
window.HTMLElement.prototype.scrollIntoView = originalScrollIntoView;

@ -540,6 +540,7 @@ export const LogRowContextModal: React.FunctionComponent<LogRowContextModalProps
displayedFields={displayedFields}
onClickShowField={showField}
onClickHideField={hideField}
scrollElement={null}
/>
</td>
</tr>
@ -562,6 +563,7 @@ export const LogRowContextModal: React.FunctionComponent<LogRowContextModalProps
onPinLine={() => setSticky(true)}
pinnedRowId={sticky ? row.uid : undefined}
overflowingContent={true}
scrollElement={null}
/>
</td>
</tr>
@ -580,6 +582,7 @@ export const LogRowContextModal: React.FunctionComponent<LogRowContextModalProps
displayedFields={displayedFields}
onClickShowField={showField}
onClickHideField={hideField}
scrollElement={null}
/>
</>
</td>

@ -24,6 +24,7 @@ import {
logRowsToReadableJson,
mergeLogsVolumeDataFrames,
sortLogsResult,
checkLogsSampled,
} from './utils';
describe('getLoglevel()', () => {
@ -240,8 +241,36 @@ describe('checkLogsError()', () => {
foo: 'boo',
} as Labels,
} as LogRowModel;
test('should return correct error if error is present', () => {
expect(checkLogsError(log)).toStrictEqual({ hasError: true, errorMessage: 'Error Message' });
test('should return the error if present', () => {
expect(checkLogsError(log)).toStrictEqual('Error Message');
});
test('should return undefined otherwise', () => {
expect(checkLogsError({ ...log, labels: {} })).toStrictEqual(undefined);
});
});
describe('checkLogsSampled()', () => {
const log = {
labels: {
__adaptive_logs_sampled__: 'true',
foo: 'boo',
} as Labels,
} as LogRowModel;
test('should return a message if is sampled', () => {
expect(checkLogsSampled(log)).toStrictEqual('Logs like this one have been dropped by Adaptive Logs');
});
test('should return an interpolated message if is sampled', () => {
expect(
checkLogsSampled({
...log,
labels: {
__adaptive_logs_sampled__: '10',
},
})
).toStrictEqual('10% of logs like this one have been dropped by Adaptive Logs');
});
test('should return undefined otherwise', () => {
expect(checkLogsSampled({ ...log, labels: {} })).toStrictEqual(undefined);
});
});

@ -142,32 +142,17 @@ export const sortLogRows = (logRows: LogRowModel[], sortOrder: LogsSortOrder) =>
sortOrder === LogsSortOrder.Ascending ? logRows.sort(sortInAscendingOrder) : logRows.sort(sortInDescendingOrder);
// Currently supports only error condition in Loki logs
export const checkLogsError = (logRow: LogRowModel): { hasError: boolean; errorMessage?: string } => {
if (logRow.labels.__error__) {
return {
hasError: true,
errorMessage: logRow.labels.__error__,
};
}
return {
hasError: false,
};
export const checkLogsError = (logRow: LogRowModel): string | undefined => {
return logRow.labels.__error__;
};
export const checkLogsSampled = (logRow: LogRowModel): { isSampled: boolean; sampleMessage?: string } => {
if (logRow.labels.__adaptive_logs_sampled__) {
let msg =
logRow.labels.__adaptive_logs_sampled__ === 'true'
? 'Logs like this one have been dropped by Adaptive Logs'
: `${logRow.labels.__adaptive_logs_sampled__}% of logs like this one have been dropped by Adaptive Logs`;
return {
isSampled: true,
sampleMessage: msg,
};
export const checkLogsSampled = (logRow: LogRowModel): string | undefined => {
if (!logRow.labels.__adaptive_logs_sampled__) {
return undefined;
}
return {
isSampled: false,
};
return logRow.labels.__adaptive_logs_sampled__ === 'true'
? 'Logs like this one have been dropped by Adaptive Logs'
: `${logRow.labels.__adaptive_logs_sampled__}% of logs like this one have been dropped by Adaptive Logs`;
};
export const escapeUnescapedString = (string: string) =>

@ -425,11 +425,11 @@ export const LogsPanel = ({
range={data.timeRange}
timeZone={timeZone}
rows={logRows}
scrollElement={scrollElement ?? undefined}
scrollElement={scrollElement}
sortOrder={sortOrder}
>
<LogRows
containerRendered={logsContainerRef.current !== null}
scrollElement={scrollElement}
scrollIntoView={scrollIntoView}
permalinkedRowId={getLogsPanelState()?.logs?.id ?? undefined}
onPermalinkClick={showPermaLink() ? onPermalinkClick : undefined}
@ -465,6 +465,7 @@ export const LogsPanel = ({
onClickHideField={displayedFields !== undefined ? onClickHideField : undefined}
logRowMenuIconsBefore={isReactNodeArray(logRowMenuIconsBefore) ? logRowMenuIconsBefore : undefined}
logRowMenuIconsAfter={isReactNodeArray(logRowMenuIconsAfter) ? logRowMenuIconsAfter : undefined}
renderPreview
/>
</InfiniteScroll>
{showCommonLabels && isAscending && renderCommonLabels()}

Loading…
Cancel
Save