From a6a7cebbe5e133aaf9f1b4b2cd44966fe31d95f6 Mon Sep 17 00:00:00 2001 From: Sven Grossmann Date: Fri, 14 Apr 2023 17:05:43 +0200 Subject: [PATCH] Logs: Redesign and improve LogContext (#65939) * Logs: Add new LogRowContext types to grafana/data * use right type for `RowContextOptions` * add missing renames * add show context modal * no need to call * removed unused css * sort properties * rename * use correct * use * add tests for * wip * remove add/minus buttons * add tests * disable processing of context results in Loki * moved into table to align properly * remove imports * add highlighting of opened logline * improve scrolling behavior * correct style for the table * use correct query direction * fix text * use LoadingBar * use overflow auto * rename `onToggleContext` to `onOpenContext` * add missing import * mock scrollIntoView * update unused props * remove unused import * no need to process context dataframes * only show `LogRowContextModal` if `getRowContext` is defined * remove unused param * use `userEvent` rather `fireEvent` * change to `TimeZone` * directly use style classes * revert change to public_dashboard_service_mock.go * improved styling * add missing await in test * fix lint * fix lint * remove LogRow scrolling when context is opened * remove references to `scrollElement` * Update public/app/features/logs/components/log-context/LogRowContextModal.tsx Co-authored-by: Matias Chomicki * fix lint * add comment explaining `onCloseContext` * add comment about debounced onClose * add comments and remove `showRowMenu` * scroll twice to correctly center the element * revert double scrolling * remove unnecessary `processDataFrame` * trigger drone --------- Co-authored-by: Matias Chomicki --- .betterer.results | 6 - public/app/features/explore/Explore.tsx | 1 - public/app/features/explore/Logs.tsx | 44 +- public/app/features/explore/LogsContainer.tsx | 3 - .../app/features/logs/components/LogRow.tsx | 88 +--- .../logs/components/LogRowMessage.tsx | 119 +---- .../app/features/logs/components/LogRows.tsx | 25 +- .../log-context/LogContextButtons.test.tsx | 47 ++ .../log-context/LogContextButtons.tsx | 38 ++ .../log-context/LogRowContext.test.tsx | 43 -- .../components/log-context/LogRowContext.tsx | 455 ------------------ .../log-context/LogRowContextModal.test.tsx | 100 ++++ .../log-context/LogRowContextModal.tsx | 299 ++++++++++++ .../LogRowContextProvider.test.tsx | 184 ------- .../log-context/LogRowContextProvider.tsx | 248 ---------- .../datasource/elasticsearch/datasource.ts | 46 +- .../datasource/loki/LogContextProvider.ts | 35 +- 17 files changed, 570 insertions(+), 1211 deletions(-) create mode 100644 public/app/features/logs/components/log-context/LogContextButtons.test.tsx create mode 100644 public/app/features/logs/components/log-context/LogContextButtons.tsx delete mode 100644 public/app/features/logs/components/log-context/LogRowContext.test.tsx delete mode 100644 public/app/features/logs/components/log-context/LogRowContext.tsx create mode 100644 public/app/features/logs/components/log-context/LogRowContextModal.test.tsx create mode 100644 public/app/features/logs/components/log-context/LogRowContextModal.tsx delete mode 100644 public/app/features/logs/components/log-context/LogRowContextProvider.test.tsx delete mode 100644 public/app/features/logs/components/log-context/LogRowContextProvider.tsx diff --git a/.betterer.results b/.betterer.results index ccfc022545e..d07511ae306 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2950,12 +2950,6 @@ exports[`better eslint`] = { "public/app/features/live/index.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/logs/components/log-context/LogRowContextProvider.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Do not use any type assertions.", "3"] - ], "public/app/features/logs/utils.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 3fab8c067ad..c85b9494459 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -358,7 +358,6 @@ export class Explore extends React.PureComponent { onClickFilterOutLabel={this.onClickFilterOutLabel} onStartScanning={this.onStartScanning} onStopScanning={this.onStopScanning} - scrollElement={this.scrollElement} eventBus={this.logsEventBus} splitOpenFn={this.onSplitOpen('logs')} /> diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index c1c2e0e8a96..5c9854adb73 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -45,6 +45,7 @@ import store from 'app/core/store'; import { ExploreId } from 'app/types/explore'; import { LogRows } from '../logs/components/LogRows'; +import { LogRowContextModal } from '../logs/components/log-context/LogRowContextModal'; import { LogsMetaRow } from './LogsMetaRow'; import LogsNavigation from './LogsNavigation'; @@ -70,7 +71,6 @@ interface Props extends Themeable2 { datasourceType?: string; logsVolumeEnabled: boolean; logsVolumeData: DataQueryResponse | undefined; - scrollElement?: HTMLDivElement; onSetLogsVolumeEnabled: (enabled: boolean) => void; loadLogsVolumeData: () => void; showContextToggle?: (row?: LogRowModel) => boolean; @@ -98,6 +98,8 @@ interface State { isFlipping: boolean; displayedFields: string[]; forceEscape: boolean; + contextOpen: boolean; + contextRow?: LogRowModel; } // We need to override css overflow of divs in Collapse element to enable sticky Logs navigation @@ -135,6 +137,8 @@ class UnthemedLogs extends PureComponent { isFlipping: false, displayedFields: [], forceEscape: false, + contextOpen: false, + contextRow: undefined, }; constructor(props: Props) { @@ -296,6 +300,28 @@ class UnthemedLogs extends PureComponent { }); }; + onCloseContext = () => { + this.setState({ + contextOpen: false, + contextRow: undefined, + }); + }; + + onOpenContext = (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 + this.setState({ + contextOpen: true, + contextRow: row, + }); + this.onCloseContext = () => { + this.setState({ + contextOpen: false, + contextRow: undefined, + }); + onClose(); + }; + }; + checkUnescapedContent = memoizeOne((logRows: LogRowModel[]) => { return !!logRows.some((r) => r.hasUnescapedContent); }); @@ -350,7 +376,6 @@ class UnthemedLogs extends PureComponent { clearCache, addResultsToCache, exploreId, - scrollElement, getRowContext, getLogRowContextUi, } = this.props; @@ -366,6 +391,8 @@ class UnthemedLogs extends PureComponent { isFlipping, displayedFields, forceEscape, + contextOpen, + contextRow, } = this.state; const styles = getStyles(theme, wrapLogMessage); @@ -380,6 +407,17 @@ class UnthemedLogs extends PureComponent { return ( <> + {getRowContext && contextRow && ( + + )} {logsVolumeEnabled && ( { onClickShowField={this.showField} onClickHideField={this.hideField} app={CoreApp.Explore} - scrollElement={scrollElement} onLogRowHover={this.onLogRowHover} + onOpenContext={this.onOpenContext} /> {!loading && !hasData && !scanning && (
diff --git a/public/app/features/explore/LogsContainer.tsx b/public/app/features/explore/LogsContainer.tsx index ec88df6d6e2..b222fb8053e 100644 --- a/public/app/features/explore/LogsContainer.tsx +++ b/public/app/features/explore/LogsContainer.tsx @@ -35,7 +35,6 @@ interface LogsContainerProps extends PropsFromRedux { scanRange?: RawTimeRange; syncedTimes: boolean; loadingState: LoadingState; - scrollElement?: HTMLDivElement; onClickFilterLabel: (key: string, value: string) => void; onClickFilterOutLabel: (key: string, value: string) => void; onStartScanning: () => void; @@ -115,7 +114,6 @@ class LogsContainer extends PureComponent { exploreId, addResultsToCache, clearCache, - scrollElement, logsVolume, } = this.props; @@ -175,7 +173,6 @@ class LogsContainer extends PureComponent { getFieldLinks={this.getFieldLinks} addResultsToCache={() => addResultsToCache(exploreId)} clearCache={() => clearCache(exploreId)} - scrollElement={scrollElement} eventBus={this.props.eventBus} /> diff --git a/public/app/features/logs/components/LogRow.tsx b/public/app/features/logs/components/LogRow.tsx index ef10135df72..a63a73b03a4 100644 --- a/public/app/features/logs/components/LogRow.tsx +++ b/public/app/features/logs/components/LogRow.tsx @@ -1,4 +1,5 @@ import { cx } from '@emotion/css'; +import { debounce } from 'lodash'; import React, { PureComponent } from 'react'; import { @@ -10,7 +11,6 @@ import { dateTimeFormat, CoreApp, DataFrame, - DataSourceWithLogsContextSupport, LogRowContextOptions, } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; @@ -24,12 +24,6 @@ import { LogLabels } from './LogLabels'; import { LogRowMessage } from './LogRowMessage'; import { LogRowMessageDisplayedFields } from './LogRowMessageDisplayedFields'; import { getLogLevelStyles, LogRowStyles } from './getLogRowStyles'; -import { - LogRowContextRows, - LogRowContextQueryErrors, - HasMoreContextRows, - LogRowContextProvider, -} from './log-context/LogRowContextProvider'; interface Props extends Themeable2 { row: LogRowModel; @@ -42,8 +36,6 @@ interface Props extends Themeable2 { enableLogDetails: boolean; logsSortOrder?: LogsSortOrder | null; forceEscape?: boolean; - scrollElement?: HTMLDivElement; - showRowMenu?: boolean; app?: CoreApp; displayedFields?: string[]; getRows: () => LogRowModel[]; @@ -57,7 +49,7 @@ interface Props extends Themeable2 { onClickShowField?: (key: string) => void; onClickHideField?: (key: string) => void; onLogRowHover?: (row?: LogRowModel) => void; - toggleContextIsOpen?: () => void; + onOpenContext: (row: LogRowModel, onClose: () => void) => void; styles: LogRowStyles; } @@ -79,20 +71,14 @@ class UnThemedLogRow extends PureComponent { showDetails: false, }; - toggleContext = (method: string) => { - const { datasourceType, uid: logRowUid } = this.props.row; - reportInteraction('grafana_explore_logs_log_context_clicked', { - datasourceType, - logRowUid, - type: method, - }); + // we are debouncing the state change by 3 seconds to highlight the logline after the context closed. + debouncedContextClose = debounce(() => { + this.setState({ showContext: false }); + }, 3000); - this.props.toggleContextIsOpen?.(); - this.setState((state) => { - return { - showContext: !state.showContext, - }; - }); + onOpenContext = (row: LogRowModel) => { + this.setState({ showContext: true }); + this.props.onOpenContext(row, this.debouncedContextClose); }; toggleDetails = () => { @@ -133,15 +119,7 @@ class UnThemedLogRow extends PureComponent { } }; - renderLogRow( - context?: LogRowContextRows, - errors?: LogRowContextQueryErrors, - hasMoreContextRows?: HasMoreContextRows, - updateLimit?: () => void, - logsSortOrder?: LogsSortOrder | null, - getLogRowContextUi?: DataSourceWithLogsContextSupport['getLogRowContextUi'], - runContextQuery?: () => void - ) { + render() { const { getRows, onClickFilterLabel, @@ -152,7 +130,6 @@ class UnThemedLogRow extends PureComponent { row, showDuplicates, showContextToggle, - showRowMenu, showLabels, showTime, displayedFields, @@ -162,7 +139,6 @@ class UnThemedLogRow extends PureComponent { getFieldLinks, forceEscape, app, - scrollElement, styles, } = this.props; const { showDetails, showContext } = this.state; @@ -219,22 +195,11 @@ class UnThemedLogRow extends PureComponent { ) : ( )} @@ -260,37 +225,6 @@ class UnThemedLogRow extends PureComponent { ); } - - render() { - const { showContext } = this.state; - const { logsSortOrder, row, getRowContext, getLogRowContextUi } = this.props; - - if (showContext) { - return ( - <> - - {({ result, errors, hasMoreContextRows, updateLimit, runContextQuery, logsSortOrder }) => { - return ( - <> - {this.renderLogRow( - result, - errors, - hasMoreContextRows, - updateLimit, - logsSortOrder, - getLogRowContextUi, - runContextQuery - )} - - ); - }} - - - ); - } - - return this.renderLogRow(); - } } export const LogRow = withTheme2(UnThemedLogRow); diff --git a/public/app/features/logs/components/LogRowMessage.tsx b/public/app/features/logs/components/LogRowMessage.tsx index 97a6a87b135..c32f651bcae 100644 --- a/public/app/features/logs/components/LogRowMessage.tsx +++ b/public/app/features/logs/components/LogRowMessage.tsx @@ -3,40 +3,21 @@ import memoizeOne from 'memoize-one'; import React, { PureComponent } from 'react'; import Highlighter from 'react-highlight-words'; -import { - LogRowModel, - findHighlightChunksInText, - LogsSortOrder, - CoreApp, - DataSourceWithLogsContextSupport, -} from '@grafana/data'; +import { LogRowModel, findHighlightChunksInText, CoreApp } from '@grafana/data'; import { IconButton, Tooltip } from '@grafana/ui'; import { LogMessageAnsi } from './LogMessageAnsi'; import { LogRowStyles } from './getLogRowStyles'; -import { LogRowContext } from './log-context/LogRowContext'; -import { LogRowContextQueryErrors, HasMoreContextRows, LogRowContextRows } from './log-context/LogRowContextProvider'; export const MAX_CHARACTERS = 100000; interface Props { row: LogRowModel; - hasMoreContextRows?: HasMoreContextRows; - contextIsOpen: boolean; wrapLogMessage: boolean; prettifyLogMessage: boolean; - errors?: LogRowContextQueryErrors; - context?: LogRowContextRows; - showRowMenu?: boolean; app?: CoreApp; - scrollElement?: HTMLDivElement; showContextToggle?: (row?: LogRowModel) => boolean; - getLogRowContextUi?: DataSourceWithLogsContextSupport['getLogRowContextUi']; - getRows: () => LogRowModel[]; - onToggleContext: (method: string) => void; - updateLimit?: () => void; - runContextQuery?: () => void; - logsSortOrder?: LogsSortOrder | null; + onOpenContext: (row: LogRowModel) => void; styles: LogRowStyles; } @@ -78,42 +59,14 @@ const restructureLog = memoizeOne((line: string, prettifyLogMessage: boolean): s }); export class LogRowMessage extends PureComponent { - logRowRef: React.RefObject = React.createRef(); - - onContextToggle = (e: React.SyntheticEvent) => { - e.stopPropagation(); - this.props.onToggleContext('open'); - }; - onShowContextClick = (e: React.SyntheticEvent) => { - const { scrollElement } = this.props; - this.onContextToggle(e); - if (scrollElement && this.logRowRef.current) { - scrollElement.scroll({ - behavior: 'smooth', - top: scrollElement.scrollTop + this.logRowRef.current.getBoundingClientRect().top - window.innerHeight / 2, - }); - } + const { onOpenContext } = this.props; + e.stopPropagation(); + onOpenContext(this.props.row); }; render() { - const { - row, - errors, - hasMoreContextRows, - updateLimit, - runContextQuery, - context, - contextIsOpen, - showRowMenu, - wrapLogMessage, - prettifyLogMessage, - onToggleContext, - logsSortOrder, - showContextToggle, - getLogRowContextUi, - styles, - } = this.props; + const { row, wrapLogMessage, prettifyLogMessage, showContextToggle, styles } = this.props; const { hasAnsi, raw } = row; const restructuredEntry = restructureLog(raw, prettifyLogMessage); const shouldShowContextToggle = showContextToggle ? showContextToggle(row) : false; @@ -124,59 +77,35 @@ export class LogRowMessage extends PureComponent { // When context is open, the position has to be NOT relative. // Setting the postion as inline-style to // overwrite the more sepecific style definition from `styles.logsRowMessage`. } - +
- {contextIsOpen && context && ( - { - if (updateLimit) { - updateLimit(); - } - }} - /> - )} -
- {showRowMenu && ( - - e.stopPropagation()} - > - {shouldShowContextToggle && ( - - - - )} - - navigator.clipboard.writeText(restructuredEntry)} /> + + e.stopPropagation()} + > + {shouldShowContextToggle && ( + + - - - )} + )} + + navigator.clipboard.writeText(restructuredEntry)} /> + + + ); } diff --git a/public/app/features/logs/components/LogRows.tsx b/public/app/features/logs/components/LogRows.tsx index 2109c479c0d..14a65bffe4a 100644 --- a/public/app/features/logs/components/LogRows.tsx +++ b/public/app/features/logs/components/LogRows.tsx @@ -39,7 +39,6 @@ export interface Props extends Themeable2 { forceEscape?: boolean; displayedFields?: string[]; app?: CoreApp; - scrollElement?: HTMLDivElement; showContextToggle?: (row?: LogRowModel) => boolean; onClickFilterLabel?: (key: string, value: string) => void; onClickFilterOutLabel?: (key: string, value: string) => void; @@ -49,11 +48,11 @@ export interface Props extends Themeable2 { onClickShowField?: (key: string) => void; onClickHideField?: (key: string) => void; onLogRowHover?: (row?: LogRowModel) => void; + onOpenContext?: (row: LogRowModel, onClose: () => void) => void; } interface State { renderAll: boolean; - contextIsOpen: boolean; } class UnThemedLogRows extends PureComponent { @@ -65,18 +64,15 @@ class UnThemedLogRows extends PureComponent { state: State = { renderAll: false, - contextIsOpen: false, }; /** * Toggle the `contextIsOpen` state when a context of one LogRow is opened in order to not show the menu of the other log rows. */ - toggleContextIsOpen = (): void => { - this.setState((state) => { - return { - contextIsOpen: !state.contextIsOpen, - }; - }); + openContext = (row: LogRowModel, onClose: () => void): void => { + if (this.props.onOpenContext) { + this.props.onOpenContext(row, onClose); + } }; componentDidMount() { @@ -130,10 +126,9 @@ class UnThemedLogRows extends PureComponent { forceEscape, onLogRowHover, app, - scrollElement, getLogRowContextUi, } = this.props; - const { renderAll, contextIsOpen } = this.state; + const { renderAll } = this.state; const styles = getLogRowStyles(theme); const dedupedRows = deduplicatedRows ? deduplicatedRows : logRows; const hasData = logRows && logRows.length > 0; @@ -163,7 +158,6 @@ class UnThemedLogRows extends PureComponent { getLogRowContextUi={getLogRowContextUi} row={row} showContextToggle={showContextToggle} - showRowMenu={!contextIsOpen} showDuplicates={showDuplicates} showLabels={showLabels} showTime={showTime} @@ -179,10 +173,9 @@ class UnThemedLogRows extends PureComponent { getFieldLinks={getFieldLinks} logsSortOrder={logsSortOrder} forceEscape={forceEscape} - toggleContextIsOpen={this.toggleContextIsOpen} + onOpenContext={this.openContext} onLogRowHover={onLogRowHover} app={app} - scrollElement={scrollElement} styles={styles} /> ))} @@ -196,7 +189,6 @@ class UnThemedLogRows extends PureComponent { getLogRowContextUi={getLogRowContextUi} row={row} showContextToggle={showContextToggle} - showRowMenu={!contextIsOpen} showDuplicates={showDuplicates} showLabels={showLabels} showTime={showTime} @@ -212,10 +204,9 @@ class UnThemedLogRows extends PureComponent { getFieldLinks={getFieldLinks} logsSortOrder={logsSortOrder} forceEscape={forceEscape} - toggleContextIsOpen={this.toggleContextIsOpen} + onOpenContext={this.openContext} onLogRowHover={onLogRowHover} app={app} - scrollElement={scrollElement} styles={styles} /> ))} diff --git a/public/app/features/logs/components/log-context/LogContextButtons.test.tsx b/public/app/features/logs/components/log-context/LogContextButtons.test.tsx new file mode 100644 index 00000000000..941bf2abf39 --- /dev/null +++ b/public/app/features/logs/components/log-context/LogContextButtons.test.tsx @@ -0,0 +1,47 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { SelectableValue } from '@grafana/data'; + +import { LogContextButtons, LoadMoreOptions } from './LogContextButtons'; + +describe('LogContextButtons', () => { + const onChangeOption = jest.fn(); + const option: SelectableValue = { label: '10 lines', value: 10 }; + const position: 'top' | 'bottom' = 'bottom'; + + beforeEach(() => { + render(); + }); + + it('should render a ButtonGroup with one button', () => { + const buttons = screen.getAllByRole('button'); + expect(buttons.length).toBe(1); + }); + + it('should render a ButtonSelect with LoadMoreOptions', async () => { + const tenLinesButton = screen.getByRole('button', { + name: /10 lines/i, + }); + await userEvent.click(tenLinesButton); + const options = screen.getAllByRole('menuitemradio'); + expect(options.length).toBe(LoadMoreOptions.length); + options.forEach((optionEl, index) => { + expect(optionEl).toHaveTextContent(LoadMoreOptions[index].label!); + }); + }); + + it('should call onChangeOption when an option is selected', async () => { + const tenLinesButton = screen.getByRole('button', { + name: /10 lines/i, + }); + await userEvent.click(tenLinesButton); + const twentyLinesButton = screen.getByRole('menuitemradio', { + name: /20 lines/i, + }); + await userEvent.click(twentyLinesButton); + const newOption = { label: '20 lines', value: 20 }; + expect(onChangeOption).toHaveBeenCalledWith(newOption); + }); +}); diff --git a/public/app/features/logs/components/log-context/LogContextButtons.tsx b/public/app/features/logs/components/log-context/LogContextButtons.tsx new file mode 100644 index 00000000000..d9c6b419480 --- /dev/null +++ b/public/app/features/logs/components/log-context/LogContextButtons.tsx @@ -0,0 +1,38 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { ButtonGroup, ButtonSelect, useStyles2 } from '@grafana/ui'; + +const getStyles = () => { + return { + logSamplesButton: css` + display: inline-flex; + `, + }; +}; + +export const LoadMoreOptions: Array> = [ + { label: '10 lines', value: 10 }, + { label: '20 lines', value: 20 }, + { label: '50 lines', value: 50 }, + { label: '100 lines', value: 100 }, + { label: '200 lines', value: 200 }, +]; + +export type Props = { + option: SelectableValue; + onChangeOption: (item: SelectableValue) => void; + position?: 'top' | 'bottom'; +}; + +export const LogContextButtons = (props: Props) => { + const { option, onChangeOption } = props; + const styles = useStyles2(getStyles); + + return ( + + + + ); +}; diff --git a/public/app/features/logs/components/log-context/LogRowContext.test.tsx b/public/app/features/logs/components/log-context/LogRowContext.test.tsx deleted file mode 100644 index 40e43b01b02..00000000000 --- a/public/app/features/logs/components/log-context/LogRowContext.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import React from 'react'; - -import { LogRowModel, LogsSortOrder } from '@grafana/data'; - -import { LogGroupPosition, LogRowContextGroup } from './LogRowContext'; - -describe('LogRowContextGroup component', () => { - it('should correctly render logs with ANSI', () => { - const defaultProps = { - rows: ['Log 1 with \u001B[31mANSI\u001B[0m code', 'Log 2', 'Log 3 with \u001B[31mANSI\u001B[0m code'], - onLoadMoreContext: () => {}, - canLoadMoreRows: false, - row: {} as LogRowModel, - className: '', - groupPosition: LogGroupPosition.Top, - }; - - render(); - expect(screen.getAllByTestId('ansiLogLine')).toHaveLength(2); - }); - - it.each([ - [LogGroupPosition.Top, LogsSortOrder.Ascending, 'before'], - [LogGroupPosition.Top, LogsSortOrder.Descending, 'after'], - [LogGroupPosition.Bottom, LogsSortOrder.Ascending, 'after'], - [LogGroupPosition.Bottom, LogsSortOrder.Descending, 'before'], - ])(`should when component is %s and sorting is %s display '%s'`, async (groupPosition, logsSortOrder, expected) => { - const defaultProps = { - rows: ['Log 1', 'Log 2', 'Log 3'], - onLoadMoreContext: () => {}, - canLoadMoreRows: false, - row: {} as LogRowModel, - className: '', - groupPosition, - logsSortOrder, - }; - - render(); - - expect(await screen.findByText(`Showing 3 lines ${expected} match.`)).toBeInTheDocument(); - }); -}); diff --git a/public/app/features/logs/components/log-context/LogRowContext.tsx b/public/app/features/logs/components/log-context/LogRowContext.tsx deleted file mode 100644 index 937be311f34..00000000000 --- a/public/app/features/logs/components/log-context/LogRowContext.tsx +++ /dev/null @@ -1,455 +0,0 @@ -import { css, cx } from '@emotion/css'; -import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import usePrevious from 'react-use/lib/usePrevious'; - -import { - DataQueryError, - GrafanaTheme2, - LogRowModel, - LogsSortOrder, - textUtil, - DataSourceWithLogsContextSupport, -} from '@grafana/data'; -import { config } from '@grafana/runtime'; -import { - Alert, - Button, - ClickOutsideWrapper, - CustomScrollbar, - IconButton, - List, - useStyles2, - useTheme2, -} from '@grafana/ui'; - -import { LogMessageAnsi } from '../LogMessageAnsi'; - -import { HasMoreContextRows, LogRowContextQueryErrors, LogRowContextRows } from './LogRowContextProvider'; - -export enum LogGroupPosition { - Bottom = 'bottom', - Top = 'top', -} - -interface LogRowContextProps { - row: LogRowModel; - context: LogRowContextRows; - wrapLogMessage: boolean; - errors?: LogRowContextQueryErrors; - hasMoreContextRows?: HasMoreContextRows; - logsSortOrder?: LogsSortOrder | null; - onOutsideClick: (method: string) => void; - onLoadMoreContext: () => void; - runContextQuery?: () => void; - getLogRowContextUi?: DataSourceWithLogsContextSupport['getLogRowContextUi']; -} - -const getLogRowContextStyles = (theme: GrafanaTheme2, wrapLogMessage?: boolean, datasourceUiHeight?: number) => { - if (!config.featureToggles.logsContextDatasourceUi || !datasourceUiHeight) { - datasourceUiHeight = 0; - } - /** - * This is workaround for displaying uncropped context when we have unwrapping log messages. - * We are using margins to correctly position context. Because non-wrapped logs have always 1 line of log - * and 1 line of Show/Hide context switch. Therefore correct position can be reliably achieved by margins. - * We also adjust width to 75%. - */ - - const headerHeight = 40; - const logsHeight = 220; - const contextHeight = datasourceUiHeight + headerHeight + logsHeight; - const bottomContextHeight = headerHeight + logsHeight; - const width = wrapLogMessage ? '100%' : '75%'; - const afterContext = wrapLogMessage - ? css` - top: -${contextHeight}px; - ` - : css` - margin-top: -${contextHeight}px; - `; - - const beforeContext = wrapLogMessage - ? css` - top: 100%; - ` - : css` - margin-top: ${theme.spacing(2.5)}; - `; - return { - width: css` - width: ${width}; - `, - bottomContext: css` - height: ${bottomContextHeight}px; - `, - commonStyles: css` - position: absolute; - height: ${contextHeight}px; - z-index: ${theme.zIndex.dropdown}; - overflow: hidden; - background: ${theme.colors.background.primary}; - box-shadow: 0 0 ${theme.spacing(1.25)} ${theme.v1.palette.black}; - border: 1px solid ${theme.colors.background.secondary}; - border-radius: ${theme.shape.radius.default}; - font-family: ${theme.typography.fontFamily}; - `, - header: css` - height: ${headerHeight}px; - padding: ${theme.spacing(0, 1.25)}; - display: flex; - align-items: center; - background: ${theme.colors.background.canvas}; - `, - datasourceUi: css` - height: ${datasourceUiHeight}px; - padding: ${theme.spacing(0, 1.25)}; - display: flex; - align-items: center; - background: ${theme.colors.background.canvas}; - `, - top: css` - border-radius: 0 0 ${theme.shape.radius.default} ${theme.shape.radius.default}; - box-shadow: 0 0 ${theme.spacing(1.25)} ${theme.v1.palette.black}; - clip-path: inset(0px -${theme.spacing(1.25)} -${theme.spacing(1.25)} -${theme.spacing(1.25)}); - `, - title: css` - position: absolute; - width: ${width}; - margin-top: -${contextHeight + headerHeight}px; - z-index: ${theme.zIndex.modal}; - height: ${headerHeight}px; - background: ${theme.colors.background.secondary}; - border: 1px solid ${theme.colors.background.secondary}; - border-radius: ${theme.shape.radius.default} ${theme.shape.radius.default} 0 0; - box-shadow: 0 0 ${theme.spacing(1.25)} ${theme.v1.palette.black}; - clip-path: inset(-${theme.spacing(1.25)} -${theme.spacing(1.25)} 0px -${theme.spacing(1.25)}); - font-family: ${theme.typography.fontFamily}; - - display: flex; - flex-direction: row; - align-items: center; - - padding: ${theme.spacing()}; - - > h5 { - margin: 0; - flex: 1; - } - `, - actions: css` - align-items: center; - display: flex; - `, - headerButton: css` - margin-left: ${theme.spacing(1)}; - `, - logs: css` - height: ${logsHeight}px; - padding: ${theme.spacing(1.25)}; - font-family: ${theme.typography.fontFamilyMonospace}; - - .scrollbar-view { - overscroll-behavior: contain; - } - `, - - afterContext, - beforeContext, - }; -}; - -interface LogRowContextGroupHeaderProps { - row: LogRowModel; - rows: Array; - onLoadMoreContext: () => void; - groupPosition: LogGroupPosition; - shouldScrollToBottom?: boolean; - canLoadMoreRows?: boolean; - logsSortOrder?: LogsSortOrder | null; - getLogRowContextUi?: DataSourceWithLogsContextSupport['getLogRowContextUi']; - runContextQuery?: () => void; - onHeightChange?: (height: number) => void; -} -interface LogRowContextGroupProps extends LogRowContextGroupHeaderProps { - rows: Array; - groupPosition: LogGroupPosition; - className?: string; - error?: string; -} - -const LogRowContextGroupHeader = ({ - row, - rows, - onLoadMoreContext, - canLoadMoreRows, - groupPosition, - logsSortOrder, - getLogRowContextUi, - runContextQuery, - onHeightChange, -}: LogRowContextGroupHeaderProps) => { - const [height, setHeight] = useState(0); - const datasourceUiRef = React.createRef(); - const theme = useTheme2(); - const { datasourceUi, header, headerButton } = getLogRowContextStyles(theme, undefined, height); - - // determine the position in time for this LogGroup by taking the ordering of - // logs and position of the component itself into account. - let logGroupPosition = 'after'; - if (groupPosition === LogGroupPosition.Bottom) { - if (logsSortOrder === LogsSortOrder.Descending) { - logGroupPosition = 'before'; - } - } else if (logsSortOrder === LogsSortOrder.Ascending) { - logGroupPosition = 'before'; - } - - if (config.featureToggles.logsContextDatasourceUi) { - // disabling eslint here, because this condition does not change in runtime - - // eslint-disable-next-line react-hooks/rules-of-hooks - const resizeObserver = useMemo( - () => - new ResizeObserver((entries) => { - for (let entry of entries) { - setHeight(entry.contentRect.height); - if (onHeightChange) { - onHeightChange(entry.contentRect.height); - } - } - }), - [onHeightChange] - ); - - // eslint-disable-next-line react-hooks/rules-of-hooks - useLayoutEffect(() => { - // observe the first child of the ref, which is the datasource controlled component and varies in height - // TODO: this is a bit of a hack and we can remove this as soon as we move back from the absolute positioned context - const child = datasourceUiRef.current?.children.item(0); - if (child) { - resizeObserver.observe(child); - } - return () => { - resizeObserver.disconnect(); - }; - }, [datasourceUiRef, resizeObserver]); - } - - return ( - <> - {config.featureToggles.logsContextDatasourceUi && getLogRowContextUi && ( -
- {getLogRowContextUi(row, runContextQuery)} -
- )} -
- - Showing {rows.length} lines {logGroupPosition} match. - - {(rows.length >= 10 || (rows.length > 10 && rows.length % 10 !== 0)) && canLoadMoreRows && ( - - )} -
- - ); -}; - -export const LogRowContextGroup = ({ - row, - rows, - error, - className, - shouldScrollToBottom, - canLoadMoreRows, - onLoadMoreContext, - groupPosition, - logsSortOrder, - getLogRowContextUi, - runContextQuery, - onHeightChange, -}: LogRowContextGroupProps) => { - const [height, setHeight] = useState(0); - const theme = useTheme2(); - const { commonStyles, logs, bottomContext, afterContext } = getLogRowContextStyles(theme, undefined, height); - const [scrollTop, setScrollTop] = useState(0); - const [scrollHeight, setScrollHeight] = useState(0); - - const listContainerRef = useRef(null); - const prevRows = usePrevious(rows); - const prevScrollTop = usePrevious(scrollTop); - const prevScrollHeight = usePrevious(scrollHeight); - - /** - * This hook is responsible of keeping the right scroll position of the top - * context when rows are added. Since rows are added at the top of the DOM, - * the scroll position changes and we need to adjust the scrollTop. - */ - useLayoutEffect(() => { - if (!shouldScrollToBottom || !listContainerRef.current) { - return; - } - - const previousRowsLength = prevRows?.length ?? 0; - const previousScrollHeight = prevScrollHeight ?? 0; - const previousScrollTop = prevScrollTop ?? 0; - const scrollElement = listContainerRef.current.parentElement; - let currentScrollHeight = 0; - - if (scrollElement) { - currentScrollHeight = scrollElement.scrollHeight - scrollElement.clientHeight; - setScrollHeight(currentScrollHeight); - } - - if (rows.length > previousRowsLength && currentScrollHeight > previousScrollHeight) { - setScrollTop(previousScrollTop + (currentScrollHeight - previousScrollHeight)); - } - }, [shouldScrollToBottom, rows, prevRows, prevScrollTop, prevScrollHeight]); - - /** - * Keeps track of the scroll position of the list container. - */ - const updateScroll = () => { - const scrollElement = listContainerRef.current?.parentElement; - if (scrollElement) { - setScrollTop(listContainerRef.current?.parentElement.scrollTop); - } - }; - - const changeHeight = (height: number) => { - setHeight(height); - if (onHeightChange) { - onHeightChange(height); - } - }; - - const headerProps = { - row, - rows, - onLoadMoreContext, - canLoadMoreRows, - groupPosition, - logsSortOrder, - getLogRowContextUi, - runContextQuery, - }; - - return ( -
- {/* When displaying "after" context */} - {shouldScrollToBottom && !error && } -
- -
- {!error && ( - { - return ( -
- {typeof item === 'string' && textUtil.hasAnsiCodes(item) ? ( - - ) : ( - String(item) - )} -
- ); - }} - /> - )} - {error && } -
-
-
- {/* When displaying "before" context */} - {!shouldScrollToBottom && !error && } -
- ); -}; - -export const LogRowContext = ({ - row, - context, - errors, - onOutsideClick, - onLoadMoreContext, - runContextQuery: runContextQuery, - hasMoreContextRows, - wrapLogMessage, - logsSortOrder, - getLogRowContextUi, -}: LogRowContextProps) => { - useEffect(() => { - const handleEscKeyDown = (e: KeyboardEvent): void => { - if (e.key === 'Escape' || e.key === 'Esc') { - onOutsideClick('close_esc'); - } - }; - document.addEventListener('keydown', handleEscKeyDown, false); - return () => { - document.removeEventListener('keydown', handleEscKeyDown, false); - }; - }, [onOutsideClick, row]); - const [height, setHeight] = useState(0); - const { beforeContext, title, top, actions, width } = useStyles2((theme) => - getLogRowContextStyles(theme, wrapLogMessage, height) - ); - const handleOutsideClick = useCallback(() => onOutsideClick('close_outside_click'), [onOutsideClick]); - - return ( - - {/* e.stopPropagation is necessary so the log details doesn't open when clicked on log line in context - * and/or when context log line is being highlighted - */} - {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} -
e.stopPropagation()}> - {context.after && ( - - )} - - {context.before && ( - - )} -
-
Log context
-
- onOutsideClick('close_button')} /> -
-
-
-
- ); -}; diff --git a/public/app/features/logs/components/log-context/LogRowContextModal.test.tsx b/public/app/features/logs/components/log-context/LogRowContextModal.test.tsx new file mode 100644 index 00000000000..69ab23f7c36 --- /dev/null +++ b/public/app/features/logs/components/log-context/LogRowContextModal.test.tsx @@ -0,0 +1,100 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { createLogRow } from '../__mocks__/logRow'; + +import { LogRowContextModal } from './LogRowContextModal'; + +const getRowContext = jest.fn().mockResolvedValue({ data: { fields: [], rows: [] } }); + +const row = createLogRow({ uid: '1' }); + +const timeZone = 'UTC'; + +describe('LogRowContextModal', () => { + const originalScrollIntoView = window.HTMLElement.prototype.scrollIntoView; + + beforeEach(() => { + window.HTMLElement.prototype.scrollIntoView = jest.fn(); + }); + afterEach(() => { + window.HTMLElement.prototype.scrollIntoView = originalScrollIntoView; + jest.clearAllMocks(); + }); + + it('should not render modal when it is closed', () => { + render( + {}} getRowContext={getRowContext} timeZone={timeZone} /> + ); + + expect(screen.queryByText('Log context')).not.toBeInTheDocument(); + }); + + it('should render modal when it is open', async () => { + act(() => { + render( + {}} + getRowContext={getRowContext} + timeZone={timeZone} + /> + ); + }); + + await waitFor(() => expect(screen.queryByText('Log context')).toBeInTheDocument()); + }); + + it('should call getRowContext on open and change of row', () => { + render( + {}} getRowContext={getRowContext} timeZone={timeZone} /> + ); + + expect(getRowContext).not.toHaveBeenCalled(); + }); + it('should call getRowContext on open', async () => { + act(() => { + render( + {}} + getRowContext={getRowContext} + timeZone={timeZone} + /> + ); + }); + await waitFor(() => expect(getRowContext).toHaveBeenCalledTimes(2)); + }); + + it('should call getRowContext when limit changes', async () => { + act(() => { + render( + {}} + getRowContext={getRowContext} + timeZone={timeZone} + /> + ); + }); + await waitFor(() => expect(getRowContext).toHaveBeenCalledTimes(2)); + + const tenLinesButton = screen.getByRole('button', { + name: /10 lines/i, + }); + await userEvent.click(tenLinesButton); + const twentyLinesButton = screen.getByRole('menuitemradio', { + name: /20 lines/i, + }); + act(() => { + userEvent.click(twentyLinesButton); + }); + + await waitFor(() => expect(getRowContext).toHaveBeenCalledTimes(4)); + }); +}); diff --git a/public/app/features/logs/components/log-context/LogRowContextModal.tsx b/public/app/features/logs/components/log-context/LogRowContextModal.tsx new file mode 100644 index 00000000000..c2f53ca314f --- /dev/null +++ b/public/app/features/logs/components/log-context/LogRowContextModal.tsx @@ -0,0 +1,299 @@ +import { css, cx } from '@emotion/css'; +import React, { useEffect, useLayoutEffect, useState } from 'react'; +import { useAsyncFn } from 'react-use'; + +import { + DataQueryResponse, + DataSourceWithLogsContextSupport, + GrafanaTheme2, + LogRowContextOptions, + LogRowContextQueryDirection, + LogRowModel, + LogsDedupStrategy, + LogsSortOrder, + SelectableValue, +} from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { TimeZone } from '@grafana/schema'; +import { LoadingBar, Modal, useTheme2 } from '@grafana/ui'; +import { dataFrameToLogsModel } from 'app/core/logsModel'; +import store from 'app/core/store'; +import { SETTINGS_KEYS } from 'app/features/explore/utils/logs'; + +import { LogRows } from '../LogRows'; + +import { LoadMoreOptions, LogContextButtons } from './LogContextButtons'; + +const getStyles = (theme: GrafanaTheme2) => { + return { + modal: css` + width: 85vw; + ${theme.breakpoints.down('md')} { + width: 100%; + } + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + `, + entry: css` + position: sticky; + z-index: 1; + top: -1px; + bottom: -1px; + & > td { + padding: ${theme.spacing(1)} 0 ${theme.spacing(1)} 0; + } + background: ${theme.colors.emphasize(theme.colors.background.secondary)}; + + & > table { + margin-bottom: 0; + } + `, + datasourceUi: css` + padding-bottom: ${theme.spacing(1.25)}; + display: flex; + align-items: center; + `, + logRowGroups: css` + overflow: auto; + max-height: 75%; + align-self: stretch; + display: inline-block; + & > table { + min-width: 100%; + } + `, + flexColumn: css` + display: flex; + flex-direction: column; + padding: 0 ${theme.spacing(3)} ${theme.spacing(3)} ${theme.spacing(3)}; + `, + flexRow: css` + display: flex; + flex-direction: row; + align-items: center; + & > div:last-child { + margin-left: auto; + } + `, + noMarginBottom: css` + & > table { + margin-bottom: 0; + } + `, + hidden: css` + display: none; + `, + paddingTop: css` + padding-top: ${theme.spacing(1)}; + `, + paddingBottom: css` + padding-bottom: ${theme.spacing(1)}; + `, + }; +}; + +export enum LogGroupPosition { + Bottom = 'bottom', + Top = 'top', +} + +interface LogRowContextModalProps { + row: LogRowModel; + open: boolean; + timeZone: TimeZone; + onClose: () => void; + getRowContext: (row: LogRowModel, options?: LogRowContextOptions) => Promise; + logsSortOrder?: LogsSortOrder | null; + runContextQuery?: () => void; + getLogRowContextUi?: DataSourceWithLogsContextSupport['getLogRowContextUi']; +} + +export const LogRowContextModal: React.FunctionComponent = ({ + row, + open, + logsSortOrder, + getLogRowContextUi, + onClose, + getRowContext, + timeZone, +}) => { + const scrollElement = React.createRef(); + const entryElement = React.createRef(); + + const theme = useTheme2(); + const styles = getStyles(theme); + const [context, setContext] = useState<{ after: LogRowModel[]; before: LogRowModel[] }>({ after: [], before: [] }); + const [limit, setLimit] = useState(LoadMoreOptions[0].value!); + const [loadingWidth, setLoadingWidth] = useState(0); + const [loadMoreOption, setLoadMoreOption] = useState>(LoadMoreOptions[0]); + + const onChangeLimitOption = (option: SelectableValue) => { + setLoadMoreOption(option); + setLimit(option.value!); + }; + + const [{ loading }, fetchResults] = useAsyncFn(async () => { + if (open && row && limit) { + const rawResults = await Promise.all([ + getRowContext(row, { + limit: logsSortOrder === LogsSortOrder.Descending ? limit + 1 : limit, + direction: + logsSortOrder === LogsSortOrder.Descending + ? LogRowContextQueryDirection.Forward + : LogRowContextQueryDirection.Backward, + }), + getRowContext(row, { + limit: logsSortOrder === LogsSortOrder.Ascending ? limit + 1 : limit, + direction: + logsSortOrder === LogsSortOrder.Ascending + ? LogRowContextQueryDirection.Forward + : LogRowContextQueryDirection.Backward, + }), + ]); + + const logsModels = rawResults.map((result) => { + return dataFrameToLogsModel(result.data); + }); + + const afterRows = logsSortOrder === LogsSortOrder.Ascending ? logsModels[0].rows.reverse() : logsModels[0].rows; + const beforeRows = logsSortOrder === LogsSortOrder.Ascending ? logsModels[1].rows.reverse() : logsModels[1].rows; + + setContext({ + after: afterRows.filter((r) => { + return r.timeEpochNs !== row.timeEpochNs && r.entry !== row.entry; + }), + before: beforeRows.filter((r) => { + return r.timeEpochNs !== row.timeEpochNs && r.entry !== row.entry; + }), + }); + } else { + setContext({ after: [], before: [] }); + } + }, [row, open, limit]); + + useEffect(() => { + if (open) { + fetchResults(); + } + }, [fetchResults, open]); + + const [displayedFields, setDisplayedFields] = useState([]); + + const showField = (key: string) => { + const index = displayedFields.indexOf(key); + + if (index === -1) { + setDisplayedFields([...displayedFields, key]); + } + }; + + const hideField = (key: string) => { + const index = displayedFields.indexOf(key); + + if (index > -1) { + displayedFields.splice(index, 1); + setDisplayedFields([...displayedFields]); + } + }; + + useLayoutEffect(() => { + if (entryElement.current) { + entryElement.current.scrollIntoView({ block: 'center' }); + } + }, [entryElement, context]); + + useLayoutEffect(() => { + const width = scrollElement?.current?.parentElement?.clientWidth; + if (width && width > 0) { + setLoadingWidth(width); + } + }, [scrollElement]); + + return ( + + {config.featureToggles.logsContextDatasourceUi && getLogRowContextUi && ( +
{getLogRowContextUi(row, fetchResults)}
+ )} +
+
+ Showing {context.after.length} lines {logsSortOrder === LogsSortOrder.Ascending ? 'after' : 'before'} match. +
+
+ +
+
+
+ +
+
+ + + + + + + + + + + + +
+ +
+ +
+ +
+
+
+
+ Showing {context.before.length} lines {logsSortOrder === LogsSortOrder.Descending ? 'after' : 'before'} match. +
+
+
+ ); +}; diff --git a/public/app/features/logs/components/log-context/LogRowContextProvider.test.tsx b/public/app/features/logs/components/log-context/LogRowContextProvider.test.tsx deleted file mode 100644 index 79a2aee3e27..00000000000 --- a/public/app/features/logs/components/log-context/LogRowContextProvider.test.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import React from 'react'; - -import { FieldType, LogRowModel, MutableDataFrame, DataQueryResponse, LogRowContextOptions } from '@grafana/data'; - -import { createLogRow } from '../__mocks__/logRow'; - -import { getRowContexts, LogRowContextProvider } from './LogRowContextProvider'; - -const row = createLogRow({ entry: '4', timeEpochMs: 4 }); - -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - reportInteraction: jest.fn(), -})); - -describe('getRowContexts', () => { - describe('when called with a DataFrame and results are returned', () => { - it('then the result should be in correct format and filtered', async () => { - const firstResult = new MutableDataFrame({ - refId: 'B', - fields: [ - { name: 'ts', type: FieldType.time, values: [3, 2, 1] }, - { name: 'line', type: FieldType.string, values: ['3', '2', '1'], labels: {} }, - { name: 'id', type: FieldType.string, values: ['3', '2', '1'], labels: {} }, - ], - }); - const secondResult = new MutableDataFrame({ - refId: 'B', - fields: [ - { name: 'ts', type: FieldType.time, values: [6, 5, 4] }, - { name: 'line', type: FieldType.string, values: ['6', '5', '4'], labels: {} }, - { name: 'id', type: FieldType.string, values: ['6', '5', '4'], labels: {} }, - ], - }); - let called = false; - const getRowContextMock = (row: LogRowModel, options?: LogRowContextOptions): Promise => { - if (!called) { - called = true; - return Promise.resolve({ data: [firstResult] }); - } - return Promise.resolve({ data: [secondResult] }); - }; - - const result = await getRowContexts(getRowContextMock, row, 10); - - expect(result).toEqual({ - data: [ - ['3', '2'], - ['6', '5', '4'], - ], - errors: ['', ''], - }); - }); - - it('then the result should be in correct format and filtered without uid', async () => { - const firstResult = new MutableDataFrame({ - refId: 'B', - fields: [ - { name: 'ts', type: FieldType.time, values: [3, 2, 1] }, - { name: 'line', type: FieldType.string, values: ['3', '2', '1'], labels: {} }, - ], - }); - const secondResult = new MutableDataFrame({ - refId: 'B', - fields: [ - { name: 'ts', type: FieldType.time, values: [6, 5, 4] }, - { name: 'line', type: FieldType.string, values: ['6', '5', '4'], labels: {} }, - ], - }); - let called = false; - const getRowContextMock = (row: LogRowModel, options?: LogRowContextOptions): Promise => { - if (!called) { - called = true; - return Promise.resolve({ data: [firstResult] }); - } - return Promise.resolve({ data: [secondResult] }); - }; - - const result = await getRowContexts(getRowContextMock, row, 10); - - expect(result).toEqual({ - data: [ - ['3', '2', '1'], - ['6', '5'], - ], - errors: ['', ''], - }); - }); - }); - - describe('when called with a DataFrame and errors occur', () => { - it('then the result should be in correct format', async () => { - const firstError = new Error('Error 1'); - const secondError = new Error('Error 2'); - let called = false; - const getRowContextMock = (row: LogRowModel, options?: LogRowContextOptions): Promise => { - if (!called) { - called = true; - return Promise.reject(firstError); - } - return Promise.reject(secondError); - }; - - const result = await getRowContexts(getRowContextMock, row, 10); - - expect(result).toEqual({ data: [[], []], errors: ['Error 1', 'Error 2'] }); - }); - }); -}); - -describe('LogRowContextProvider', () => { - describe('when requesting longer context', () => { - it('can request more log lines', async () => { - const firstResult = new MutableDataFrame({ - refId: 'B', - fields: [ - { name: 'ts', type: FieldType.time, values: [10, 9, 8, 7, 6, 5] }, - { - name: 'line', - type: FieldType.string, - values: ['10', '9', '8', '7', '6', '5'], - labels: {}, - }, - { - name: 'id', - type: FieldType.string, - values: ['10', '9', '8', '7', '6', '5'], - labels: {}, - }, - ], - }); - - const secondResult = new MutableDataFrame({ - refId: 'B', - fields: [ - { name: 'ts', type: FieldType.time, values: [14, 13, 12] }, - { name: 'line', type: FieldType.string, values: ['14', '13', '12'], labels: {} }, - { name: 'id', type: FieldType.string, values: ['14', '13', '12'], labels: {} }, - ], - }); - - let called = false; - const getRowContextMock = (row: LogRowModel, options?: LogRowContextOptions): Promise => { - if (!called) { - called = true; - return Promise.resolve({ data: [firstResult] }); - } - return Promise.resolve({ data: [secondResult] }); - }; - let updateLimitCalled = false; - - const mockedChildren = jest.fn((mockState) => { - const { result, errors, hasMoreContextRows, updateLimit, limit } = mockState; - if (!updateLimitCalled && result.before.length === 0) { - expect(result).toEqual({ before: [], after: [] }); - expect(errors).toEqual({ before: undefined, after: undefined }); - expect(hasMoreContextRows).toEqual({ before: true, after: true }); - expect(limit).toBe(10); - return
; - } - if (!updateLimitCalled && result.before.length > 0) { - expect(result).toEqual({ before: ['10', '9', '8', '7', '6', '5'], after: ['14', '13', '12'] }); - expect(errors).toEqual({ before: '', after: '' }); - expect(hasMoreContextRows).toEqual({ before: true, after: true }); - expect(limit).toBe(10); - updateLimit(); - updateLimitCalled = true; - return
; - } - if (updateLimitCalled && result.before.length > 0 && limit > 10) { - expect(limit).toBe(20); - } - return
; - }); - render( - - {mockedChildren} - - ); - await screen.findByTestId('mockChild'); - }); - }); -}); diff --git a/public/app/features/logs/components/log-context/LogRowContextProvider.tsx b/public/app/features/logs/components/log-context/LogRowContextProvider.tsx deleted file mode 100644 index a9fed25c431..00000000000 --- a/public/app/features/logs/components/log-context/LogRowContextProvider.tsx +++ /dev/null @@ -1,248 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import useAsync from 'react-use/lib/useAsync'; - -import { - DataQueryError, - DataQueryResponse, - Field, - FieldCache, - LogRowContextOptions, - LogRowContextQueryDirection, - LogRowModel, - LogsSortOrder, - toDataFrame, -} from '@grafana/data'; -import { reportInteraction } from '@grafana/runtime'; - -export interface LogRowContextRows { - before?: string[]; - after?: string[]; -} -export interface LogRowContextQueryErrors { - before?: string; - after?: string; -} - -export interface HasMoreContextRows { - before: boolean; - after: boolean; -} - -interface ResultType { - data: string[][]; - errors: string[]; - doNotCheckForMore?: boolean; -} - -interface LogRowContextProviderProps { - row: LogRowModel; - logsSortOrder?: LogsSortOrder | null; - getRowContext: (row: LogRowModel, options?: LogRowContextOptions) => Promise; - children: (props: { - result: LogRowContextRows; - errors: LogRowContextQueryErrors; - hasMoreContextRows: HasMoreContextRows; - updateLimit: () => void; - runContextQuery: () => void; - limit: number; - logsSortOrder?: LogsSortOrder | null; - }) => JSX.Element; -} - -export const getRowContexts = async ( - getRowContext: (row: LogRowModel, options?: LogRowContextOptions) => Promise, - row: LogRowModel, - limit: number, - logsSortOrder?: LogsSortOrder | null -): Promise => { - const promises = [ - getRowContext(row, { - limit, - }), - getRowContext(row, { - // The start time is inclusive so we will get the one row we are using as context entry - limit: limit + 1, - direction: LogRowContextQueryDirection.Forward, - }), - ]; - - const results: Array = await Promise.all(promises.map((p) => p.catch((e) => e))); - - const data = results.map((result) => { - const dataResult: DataQueryResponse = result as DataQueryResponse; - if (!dataResult.data) { - return []; - } - - const data = []; - for (let index = 0; index < dataResult.data.length; index++) { - const dataFrame = toDataFrame(dataResult.data[index]); - const fieldCache = new FieldCache(dataFrame); - const timestampField: Field = fieldCache.getFieldByName('ts')!; - const idField: Field | undefined = fieldCache.getFieldByName('id'); - - for (let fieldIndex = 0; fieldIndex < timestampField.values.length; fieldIndex++) { - // TODO: this filtering is datasource dependant so it will make sense to move it there so the API is - // to return correct list of lines handling inclusive ranges or how to filter the correct line on the - // datasource. - - // Filter out the row that is the one used as a focal point for the context as we will get it in one of the - // requests. - if (idField) { - // For Loki this means we filter only the one row. Issue is we could have other rows logged at the same - // ns which came before but they come in the response that search for logs after. This means right now - // we will show those as if they came after. This is not strictly correct but seems better than losing them - // and making this correct would mean quite a bit of complexity to shuffle things around and messing up - // counts. - if (idField.values.get(fieldIndex) === row.uid) { - continue; - } - } else { - // Fallback to timestamp. This should not happen right now as this feature is implemented only for loki - // and that has ID. Later this branch could be used in other DS but mind that this could also filter out - // logs which were logged in the same timestamp and that can be a problem depending on the precision. - if (parseInt(timestampField.values.get(fieldIndex), 10) === row.timeEpochMs) { - continue; - } - } - - const lineField: Field = dataFrame.fields.filter((field) => field.name === 'line')[0]; - const line = lineField.values.get(fieldIndex); // assuming that both fields have same length - - // since we request limit+1 logs, we occasionally get one extra log in the response - if (data.length < limit) { - data.push(line); - } - } - } - - return logsSortOrder === LogsSortOrder.Ascending ? data.reverse() : data; - }); - - const errors = results.map((result) => { - const errorResult: DataQueryError = result as DataQueryError; - if (!errorResult.message) { - return ''; - } - - return errorResult.message; - }); - - return { - data: logsSortOrder === LogsSortOrder.Ascending ? data.reverse() : data, - errors: logsSortOrder === LogsSortOrder.Ascending ? errors.reverse() : errors, - }; -}; - -export const LogRowContextProvider = ({ getRowContext, row, children, logsSortOrder }: LogRowContextProviderProps) => { - // React Hook that creates a number state value called limit to component state and a setter function called setLimit - // The initial value for limit is 10 - // Used for the number of rows to retrieve from backend from a specific point in time - const [limit, setLimit] = useState(10); - - // React Hook that creates an object state value called result to component state and a setter function called setResult - // The initial value for result is null - // Used for sorting the response from backend - const [result, setResult] = useState(null as unknown as ResultType); - - // React Hook that creates an object state value called hasMoreContextRows to component state and a setter function called setHasMoreContextRows - // The initial value for hasMoreContextRows is {before: true, after: true} - // Used for indicating in UI if there are more rows to load in a given direction - const [hasMoreContextRows, setHasMoreContextRows] = useState({ - before: true, - after: true, - }); - - const [results, setResults] = useState(); - - // React Hook that resolves two promises every time the limit prop changes - // First promise fetches limit number of rows backwards in time from a specific point in time - // Second promise fetches limit number of rows forwards in time from a specific point in time - const { value } = useAsync(async () => { - return await getRowContexts(getRowContext, row, limit, logsSortOrder); // Moved it to a separate function for debugging purposes - }, [limit]); - - useEffect(() => { - setResults(value); - }, [value]); - - // React Hook that performs a side effect every time the value (from useAsync hook) prop changes - // The side effect changes the result state with the response from the useAsync hook - // The side effect changes the hasMoreContextRows state if there are more context rows before or after the current result - useEffect(() => { - if (results) { - setResult((currentResult) => { - if (!results.doNotCheckForMore) { - let hasMoreLogsBefore = true, - hasMoreLogsAfter = true; - - const currentResultBefore = currentResult?.data[0]; - const currentResultAfter = currentResult?.data[1]; - const valueBefore = results.data[0]; - const valueAfter = results.data[1]; - - // checks if there are more log rows in a given direction - // if after fetching additional rows the length of result is the same, - // we can assume there are no logs in that direction within a given time range - if (currentResult && (!valueBefore || currentResultBefore.length === valueBefore.length)) { - hasMoreLogsBefore = false; - } - - if (currentResult && (!valueAfter || currentResultAfter.length === valueAfter.length)) { - hasMoreLogsAfter = false; - } - - setHasMoreContextRows({ - before: hasMoreLogsBefore, - after: hasMoreLogsAfter, - }); - } - - return results; - }); - } - }, [results]); - - const updateLimit = useCallback(() => { - setLimit(limit + 10); - - const { datasourceType, uid: logRowUid } = row; - reportInteraction('grafana_explore_logs_log_context_load_more_clicked', { - datasourceType, - logRowUid, - newLimit: limit + 10, - }); - }, [limit, row]); - - const runContextQuery = useCallback(async () => { - const results = await getRowContexts(getRowContext, row, limit, logsSortOrder); - results.doNotCheckForMore = true; - setResults(results); - }, [getRowContext, limit, logsSortOrder, row]); - - const resultData = useMemo( - () => ({ - before: result ? result.data[0] : [], - after: result ? result.data[1] : [], - }), - [result] - ); - - const errorsData = useMemo( - () => ({ - before: result ? result.errors[0] : undefined, - after: result ? result.errors[1] : undefined, - }), - [result] - ); - - return children({ - result: resultData, - errors: errorsData, - hasMoreContextRows, - updateLimit, - runContextQuery, - limit, - logsSortOrder, - }); -}; diff --git a/public/app/plugins/datasource/elasticsearch/datasource.ts b/public/app/plugins/datasource/elasticsearch/datasource.ts index 38842c63ca4..d296ae50a56 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.ts +++ b/public/app/plugins/datasource/elasticsearch/datasource.ts @@ -1,6 +1,6 @@ import { cloneDeep, find, first as _first, isNumber, isObject, isString, map as _map } from 'lodash'; import { generate, lastValueFrom, Observable, of, throwError } from 'rxjs'; -import { catchError, first, map, mergeMap, skipWhile, throwIfEmpty, tap, switchMap } from 'rxjs/operators'; +import { catchError, first, map, mergeMap, skipWhile, throwIfEmpty, tap } from 'rxjs/operators'; import { SemVer } from 'semver'; import { @@ -26,11 +26,8 @@ import { CoreApp, SupplementaryQueryType, DataQueryError, - FieldCache, - FieldType, rangeUtil, Field, - sortDataFrame, LogRowContextQueryDirection, LogRowContextOptions, } from '@grafana/data'; @@ -515,9 +512,6 @@ export class ElasticDatasource statusText: err.statusText, }; throw error; - }), - switchMap((res) => { - return of(processToLogContextDataFrames(res)); }) ) ); @@ -1224,44 +1218,6 @@ function transformHitsBasedOnDirection(response: any, direction: 'asc' | 'desc') }; } -function processToLogContextDataFrames(result: DataQueryResponse): DataQueryResponse { - const frames = result.data.map((frame) => sortDataFrame(frame, 0, true)); - const processedFrames = frames.map((frame) => { - // log-row-context requires specific field-names to work, so we set them here: "ts", "line", "id" - const cache = new FieldCache(frame); - const timestampField = cache.getFirstFieldOfType(FieldType.time); - const lineField = cache.getFirstFieldOfType(FieldType.string); - const idField = cache.getFieldByName('_id'); - - if (!timestampField || !lineField || !idField) { - return { ...frame, fields: [] }; - } - - return { - ...frame, - fields: [ - { - ...timestampField, - name: 'ts', - }, - { - ...lineField, - name: 'line', - }, - { - ...idField, - name: 'id', - }, - ], - }; - }); - - return { - ...result, - data: processedFrames, - }; -} - function createContextTimeRange(rowTimeEpochMs: number, direction: string, intervalPattern: Interval | undefined) { const offset = 7; // For log context, we want to request data from 7 subsequent/previous indices diff --git a/public/app/plugins/datasource/loki/LogContextProvider.ts b/public/app/plugins/datasource/loki/LogContextProvider.ts index 087563445c3..27fc206eddc 100644 --- a/public/app/plugins/datasource/loki/LogContextProvider.ts +++ b/public/app/plugins/datasource/loki/LogContextProvider.ts @@ -50,42 +50,9 @@ export class LogContextProvider { const { query, range } = await this.prepareLogRowContextQueryTarget(row, limit, direction, origQuery); - const processDataFrame = (frame: DataFrame): DataFrame => { - // log-row-context requires specific field-names to work, so we set them here: "ts", "line", "id" - const cache = new FieldCache(frame); - const timestampField = cache.getFirstFieldOfType(FieldType.time); - const lineField = cache.getFirstFieldOfType(FieldType.string); - const idField = cache.getFieldByName('id'); - - if (timestampField === undefined || lineField === undefined || idField === undefined) { - // this should never really happen, but i want to keep typescript happy - return { ...frame, fields: [] }; - } - - return { - ...frame, - fields: [ - { - ...timestampField, - name: 'ts', - }, - { - ...lineField, - name: 'line', - }, - { - ...idField, - name: 'id', - }, - ], - }; - }; - const processResults = (result: DataQueryResponse): DataQueryResponse => { const frames: DataFrame[] = result.data; - const processedFrames = frames - .map((frame) => sortDataFrameByTime(frame, SortDirection.Descending)) - .map((frame) => processDataFrame(frame)); // rename fields if needed + const processedFrames = frames.map((frame) => sortDataFrameByTime(frame, SortDirection.Descending)); return { ...result,