diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 48aed8a18ec..de38f8019fa 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -323,6 +323,10 @@ export class Explore extends PureComponent { }; }; + onPinLineCallback = () => { + this.setState({ contentOutlineVisible: true }); + }; + renderEmptyState(exploreContainerStyles: string) { return (
@@ -414,6 +418,8 @@ export class Explore extends PureComponent { ); } + 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 { 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} /> ); diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 86b4dd43a1f..3a026431656 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -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) => { loadMoreLogs, panelState, eventBus, + onPinLineCallback, + scrollElement, } = props; const [showLabels, setShowLabels] = useState(store.getBool(SETTINGS_KEYS.showLabels, false)); const [showTime, setShowTime] = useState(store.getBool(SETTINGS_KEYS.showTime, true)); @@ -210,8 +211,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { const [visualisationType, setVisualisationType] = useState( panelState?.logs?.visualisationType ?? getDefaultVisualisationType() ); - const [scrollIntoView, setScrollIntoView] = useState<((element: HTMLElement) => void) | undefined>(undefined); - const logsContainerRef = useRef(undefined); + const logsContainerRef = useRef(null); const dispatch = useDispatch(); const previousLoading = usePrevious(loading); @@ -230,9 +230,13 @@ const UnthemedLogs: React.FunctionComponent = (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.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) => { 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) => { 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) => { 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) => { />
)} - {visualisationType === 'logs' && hasData && ( + {visualisationType === 'logs' && (
- - - + > + + + )}
)} {!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 }; -}); +}; diff --git a/public/app/features/explore/Logs/LogsContainer.tsx b/public/app/features/explore/Logs/LogsContainer.tsx index 0b56ffdf521..0b727f33c9d 100644 --- a/public/app/features/explore/Logs/LogsContainer.tsx +++ b/public/app/features/explore/Logs/LogsContainer.tsx @@ -259,6 +259,14 @@ class LogsContainer extends PureComponent { + 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 - 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} diff --git a/public/app/features/explore/Logs/LogsSamplePanel.tsx b/public/app/features/explore/Logs/LogsSamplePanel.tsx index 44bc1430d57..63ec5219b06 100644 --- a/public/app/features/explore/Logs/LogsSamplePanel.tsx +++ b/public/app/features/explore/Logs/LogsSamplePanel.tsx @@ -101,6 +101,7 @@ export function LogsSamplePanel(props: Props) { prettifyLogMessage={store.getBool(SETTINGS_KEYS.prettifyLogMessage, false)} timeZone={timeZone} enableLogDetails={true} + scrollElement={null} /> diff --git a/public/app/features/logs/components/InfiniteScroll.test.tsx b/public/app/features/logs/components/InfiniteScroll.test.tsx index 666932be9d9..c6b92afaef5 100644 --- a/public/app/features/logs/components/InfiniteScroll.test.tsx +++ b/public/app/features/logs/components/InfiniteScroll.test.tsx @@ -28,6 +28,7 @@ const defaultProps: Omit = { rows: [], sortOrder: LogsSortOrder.Descending, timeZone: 'browser', + scrollElement: null, }; function ScrollWithWrapper({ children, ...props }: Props) { diff --git a/public/app/features/logs/components/InfiniteScroll.tsx b/public/app/features/logs/components/InfiniteScroll.tsx index d8cbeb918f3..4fffcf4c62e 100644 --- a/public/app/features/logs/components/InfiniteScroll.tsx +++ b/public/app/features/logs/components/InfiniteScroll.tsx @@ -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; diff --git a/public/app/features/logs/components/LogRow.test.tsx b/public/app/features/logs/components/LogRow.test.tsx index bf8a34cd98f..04e8f5ecb6a 100644 --- a/public/app/features/logs/components/LogRow.test.tsx +++ b/public/app/features/logs/components/LogRow.test.tsx @@ -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 () => { diff --git a/public/app/features/logs/components/LogRow.tsx b/public/app/features/logs/components/LogRow.tsx index 4cb008a314b..49a8f86c32a 100644 --- a/public/app/features/logs/components/LogRow.tsx +++ b/public/app/features/logs/components/LogRow.tsx @@ -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, 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(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 { - state: State = { - permalinked: false, - showingContext: false, - showDetails: false, - mouseIsOver: false, - }; - logLineRef: React.RefObject; - - 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) => { - 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) => { + if (handleTextSelection?.(e, row)) { + // Event handled by the parent. + return; + } - const processedRow = this.escapeRow(row, forceEscape); + if (!enableLogDetails) { + return; + } - return ( - <> - !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 ( + <> + + {showDuplicates && ( + + {processedRow.duplicates && processedRow.duplicates > 0 ? `${processedRow.duplicates + 1}x` : null} + + )} + - {showDuplicates && ( - - {processedRow.duplicates && processedRow.duplicates > 0 ? `${processedRow.duplicates + 1}x` : null} - + {hasError && ( + + + )} - - {hasError && ( - - - - )} - {isSampled && ( - - - - )} + {isSampled && ( + + + + )} + + + {enableLogDetails && ( + + )} + + {showTime && {timestamp}} + {showLabels && processedRow.uniqueLabels && ( + + - - {enableLogDetails && ( - - )} - - {showTime && {this.renderTimeStamp(row.timeEpochMs)}} - {showLabels && processedRow.uniqueLabels && ( - - - - )} - {displayedFields && displayedFields.length > 0 ? ( - - ) : ( - - )} - - {this.state.showDetails && ( - 0 ? ( + + ) : ( + )} - - ); - } -} - -export const LogRow = withTheme2(UnThemedLogRow); -LogRow.displayName = 'LogRow'; + + {showDetails && ( + + )} + + ); +}; diff --git a/public/app/features/logs/components/LogRowMessage.tsx b/public/app/features/logs/components/LogRowMessage.tsx index 77f240caaa1..f771d9a2bb6 100644 --- a/public/app/features/logs/components/LogRowMessage.tsx +++ b/public/app/features/logs/components/LogRowMessage.tsx @@ -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 ( <> { diff --git a/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx b/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx index 2cc495148a1..3378f8891d7 100644 --- a/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx +++ b/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx @@ -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 ( + <> + +
{line}
+ + + + ); + } return ( <> diff --git a/public/app/features/logs/components/LogRows.test.tsx b/public/app/features/logs/components/LogRows.test.tsx index cd861e2e37d..d892327b836 100644 --- a/public/app/features/logs/components/LogRows.test.tsx +++ b/public/app/features/logs/components/LogRows.test.tsx @@ -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( - - ); - - // 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( - {}} - 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( - {}} - 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} /> ); diff --git a/public/app/features/logs/components/LogRows.tsx b/public/app/features/logs/components/LogRows.tsx index 94b35c7d81d..13863170c75 100644 --- a/public/app/features/logs/components/LogRows.tsx +++ b/public/app/features/logs/components/LogRows.tsx @@ -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; pinnedRowId?: string; pinnedLogs?: string[]; - containerRendered?: boolean; /** * If false or undefined, the `contain:strict` css property will be added to the wrapping `` 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 { - renderAllTimer: number | null = null; - logRowsRef = createRef(); - - 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, 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({ 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(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, 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 ( -
- {this.state.selection && this.state.selectedRow && ( +
+ {popoverState.selection && popoverState.selectedRow && ( )} -
+
- {hasData && - firstRows.map((row) => ( + {orderedRows.map((row, index) => + index < previewSize ? ( 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) => ( - logId === row.rowId)} - isFilterLabelActive={this.props.isFilterLabelActive} - handleTextSelection={this.handleSelection} - {...rest} + showDuplicates={showDuplicates} + {...props} + row={row} /> - ))} - {hasData && !renderAll && ( - - - + ) )}
Rendering {orderedRows.length - previewLimit!} rows...
); } -} - -export const LogRows = withTheme2(UnThemedLogRows); -LogRows.displayName = 'LogsRows'; +); diff --git a/public/app/features/logs/components/PreviewLogRow.tsx b/public/app/features/logs/components/PreviewLogRow.tsx new file mode 100644 index 00000000000..6e2370bee39 --- /dev/null +++ b/public/app/features/logs/components/PreviewLogRow.tsx @@ -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 ( + + {showDuplicates && } + + + {showTime && {row.timeEpochMs}} + {showLabels && row.uniqueLabels && } + {displayedFields ? ( + + ) : ( + {row.entry} + )} + + + ); +}; diff --git a/public/app/features/logs/components/log-context/LogRowContextModal.test.tsx b/public/app/features/logs/components/log-context/LogRowContextModal.test.tsx index d5f89e6d977..29cf6c6c5e3 100644 --- a/public/app/features/logs/components/log-context/LogRowContextModal.test.tsx +++ b/public/app/features/logs/components/log-context/LogRowContextModal.test.tsx @@ -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; diff --git a/public/app/features/logs/components/log-context/LogRowContextModal.tsx b/public/app/features/logs/components/log-context/LogRowContextModal.tsx index b1b3dcd36d9..798e03ce065 100644 --- a/public/app/features/logs/components/log-context/LogRowContextModal.tsx +++ b/public/app/features/logs/components/log-context/LogRowContextModal.tsx @@ -540,6 +540,7 @@ export const LogRowContextModal: React.FunctionComponent @@ -562,6 +563,7 @@ export const LogRowContextModal: React.FunctionComponent setSticky(true)} pinnedRowId={sticky ? row.uid : undefined} overflowingContent={true} + scrollElement={null} /> @@ -580,6 +582,7 @@ export const LogRowContextModal: React.FunctionComponent diff --git a/public/app/features/logs/utils.test.ts b/public/app/features/logs/utils.test.ts index 484d16efe7a..289dfd023f8 100644 --- a/public/app/features/logs/utils.test.ts +++ b/public/app/features/logs/utils.test.ts @@ -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); }); }); diff --git a/public/app/features/logs/utils.ts b/public/app/features/logs/utils.ts index e6db5c67038..4d0ae113c70 100644 --- a/public/app/features/logs/utils.ts +++ b/public/app/features/logs/utils.ts @@ -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) => diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index 135f3eb7a79..9125f82a355 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -425,11 +425,11 @@ export const LogsPanel = ({ range={data.timeRange} timeZone={timeZone} rows={logRows} - scrollElement={scrollElement ?? undefined} + scrollElement={scrollElement} sortOrder={sortOrder} > {showCommonLabels && isAscending && renderCommonLabels()}