mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
parent
dc52d1b252
commit
52673ad390
@ -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);
|
||||
// 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);
|
||||
// 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,
|
||||
});
|
||||
};
|
||||
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;
|
||||
}
|
||||
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();
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
this.setState((state) => {
|
||||
return {
|
||||
showDetails: !state.showDetails,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
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) {
|
||||
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;
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
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 });
|
||||
}
|
||||
setPermalinked(false);
|
||||
return;
|
||||
}
|
||||
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),
|
||||
[]
|
||||
);
|
||||
|
||||
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;
|
||||
const onOpenContext = useCallback(
|
||||
(row: LogRowModel) => {
|
||||
setShowingContext(true);
|
||||
props.onOpenContext(row, debouncedContextClose);
|
||||
},
|
||||
[debouncedContextClose, props]
|
||||
);
|
||||
|
||||
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 onRowClick = useCallback(
|
||||
(e: MouseEvent<HTMLTableRowElement>) => {
|
||||
if (handleTextSelection?.(e, row)) {
|
||||
// Event handled by the parent.
|
||||
return;
|
||||
}
|
||||
|
||||
const processedRow = this.escapeRow(row, forceEscape);
|
||||
if (!enableLogDetails) {
|
||||
return;
|
||||
}
|
||||
|
||||
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}
|
||||
setShowDetails((showDetails: boolean) => !showDetails);
|
||||
},
|
||||
[enableLogDetails, handleTextSelection, row]
|
||||
);
|
||||
|
||||
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 onMouseLeave = useCallback(() => {
|
||||
setMouseIsOver(false);
|
||||
}, []);
|
||||
|
||||
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>
|
||||
)}
|
||||
{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}>{timestamp}</td>}
|
||||
{showLabels && processedRow.uniqueLabels && (
|
||||
<td className={styles.logsRowLabels}>
|
||||
<LogLabels labels={processedRow.uniqueLabels} addTooltip={false} />
|
||||
</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>
|
||||
)}
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
</tr>
|
||||
{this.state.showDetails && (
|
||||
<LogDetails
|
||||
onPinLine={this.props.onPinLine}
|
||||
className={logRowDetailsBackground}
|
||||
showDuplicates={showDuplicates}
|
||||
getFieldLinks={getFieldLinks}
|
||||
onClickFilterLabel={onClickFilterLabel}
|
||||
onClickFilterOutLabel={onClickFilterOutLabel}
|
||||
onClickShowField={onClickShowField}
|
||||
onClickHideField={onClickHideField}
|
||||
getRows={getRows}
|
||||
)}
|
||||
{displayedFields && displayedFields.length > 0 ? (
|
||||
<LogRowMessageDisplayedFields
|
||||
row={processedRow}
|
||||
showContextToggle={showContextToggle}
|
||||
detectedFields={displayedFields}
|
||||
getFieldLinks={getFieldLinks}
|
||||
wrapLogMessage={wrapLogMessage}
|
||||
hasError={hasError}
|
||||
displayedFields={displayedFields}
|
||||
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}
|
||||
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;
|
||||
}
|
||||
|
||||
if (!this.logRowsRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
this.setState({
|
||||
selection,
|
||||
popoverMenuCoordinates: { x, y },
|
||||
selectedRow: row,
|
||||
});
|
||||
document.addEventListener('click', this.handleDeselection);
|
||||
document.addEventListener('contextmenu', this.handleDeselection);
|
||||
return true;
|
||||
};
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
closePopoverMenu = () => {
|
||||
document.removeEventListener('click', this.handleDeselection);
|
||||
document.removeEventListener('contextmenu', this.handleDeselection);
|
||||
this.setState({
|
||||
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: '',
|
||||
popoverMenuCoordinates: { x: 0, y: 0 },
|
||||
selectedRow: null,
|
||||
popoverMenuCoordinates: { x: 0, y: 0 },
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('click', this.handleDeselection);
|
||||
document.removeEventListener('contextmenu', this.handleDeselection);
|
||||
document.removeEventListener('selectionchange', this.handleDeselection);
|
||||
if (this.renderAllTimer) {
|
||||
clearTimeout(this.renderAllTimer);
|
||||
}
|
||||
}
|
||||
|
||||
makeGetRows = memoizeOne((orderedRows: LogRowModel[]) => {
|
||||
return () => orderedRows;
|
||||
});
|
||||
|
||||
sortLogs = memoizeOne((logRows: LogRowModel[], logsSortOrder: LogsSortOrder): LogRowModel[] =>
|
||||
sortLogRows(logRows, logsSortOrder)
|
||||
);
|
||||
|
||||
render() {
|
||||
const { deduplicatedRows, logRows, dedupStrategy, theme, logsSortOrder, previewLimit, pinnedLogs, ...rest } =
|
||||
this.props;
|
||||
const { renderAll } = this.state;
|
||||
const logRowsRef = useRef<HTMLDivElement>(null);
|
||||
const theme = useTheme2();
|
||||
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 dedupCount = useMemo(
|
||||
() => dedupedRows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0),
|
||||
[dedupedRows]
|
||||
);
|
||||
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);
|
||||
|
||||
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 = this.makeGetRows(orderedRows);
|
||||
|
||||
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
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (handleDeselectionRef.current) {
|
||||
document.removeEventListener('click', handleDeselectionRef.current);
|
||||
document.removeEventListener('contextmenu', handleDeselectionRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
setPreviewSize(Infinity);
|
||||
scrollElement?.removeEventListener('scroll', renderAll);
|
||||
scrollElement?.removeEventListener('wheel', renderAll);
|
||||
}
|
||||
|
||||
scrollElement.addEventListener('scroll', renderAll);
|
||||
scrollElement.addEventListener('wheel', renderAll);
|
||||
}, [logRows.length, scrollElement]);
|
||||
|
||||
/**
|
||||
* 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]
|
||||
);
|
||||
|
||||
const popoverMenuSupported = useCallback(() => {
|
||||
if (!config.featureToggles.logRowsPopoverMenu) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(onClickFilterOutString || onClickFilterString);
|
||||
}, [onClickFilterOutString, onClickFilterString]);
|
||||
|
||||
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,
|
||||
});
|
||||
}, []);
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (!logRowsRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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';
|
||||
);
|
||||
|
29
public/app/features/logs/components/PreviewLogRow.tsx
Normal file
29
public/app/features/logs/components/PreviewLogRow.tsx
Normal file
@ -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…
Reference in New Issue
Block a user