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 <matyax@gmail.com>

* 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 <matyax@gmail.com>
This commit is contained in:
Sven Grossmann 2023-04-14 17:05:43 +02:00 committed by GitHub
parent 9665b3afe7
commit a6a7cebbe5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 570 additions and 1211 deletions

View File

@ -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"],

View File

@ -358,7 +358,6 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
onClickFilterOutLabel={this.onClickFilterOutLabel}
onStartScanning={this.onStartScanning}
onStopScanning={this.onStopScanning}
scrollElement={this.scrollElement}
eventBus={this.logsEventBus}
splitOpenFn={this.onSplitOpen('logs')}
/>

View File

@ -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<Props, State> {
isFlipping: false,
displayedFields: [],
forceEscape: false,
contextOpen: false,
contextRow: undefined,
};
constructor(props: Props) {
@ -296,6 +300,28 @@ class UnthemedLogs extends PureComponent<Props, State> {
});
};
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<Props, State> {
clearCache,
addResultsToCache,
exploreId,
scrollElement,
getRowContext,
getLogRowContextUi,
} = this.props;
@ -366,6 +391,8 @@ class UnthemedLogs extends PureComponent<Props, State> {
isFlipping,
displayedFields,
forceEscape,
contextOpen,
contextRow,
} = this.state;
const styles = getStyles(theme, wrapLogMessage);
@ -380,6 +407,17 @@ class UnthemedLogs extends PureComponent<Props, State> {
return (
<>
{getRowContext && contextRow && (
<LogRowContextModal
open={contextOpen}
row={contextRow}
onClose={this.onCloseContext}
getRowContext={getRowContext}
getLogRowContextUi={getLogRowContextUi}
logsSortOrder={logsSortOrder}
timeZone={timeZone}
/>
)}
<Collapse label="Logs volume" collapsible isOpen={logsVolumeEnabled} onToggle={this.onToggleLogsVolumeCollapse}>
{logsVolumeEnabled && (
<LogsVolumePanelList
@ -507,8 +545,8 @@ class UnthemedLogs extends PureComponent<Props, State> {
onClickShowField={this.showField}
onClickHideField={this.hideField}
app={CoreApp.Explore}
scrollElement={scrollElement}
onLogRowHover={this.onLogRowHover}
onOpenContext={this.onOpenContext}
/>
{!loading && !hasData && !scanning && (
<div className={styles.noData}>

View File

@ -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<LogsContainerProps> {
exploreId,
addResultsToCache,
clearCache,
scrollElement,
logsVolume,
} = this.props;
@ -175,7 +173,6 @@ class LogsContainer extends PureComponent<LogsContainerProps> {
getFieldLinks={this.getFieldLinks}
addResultsToCache={() => addResultsToCache(exploreId)}
clearCache={() => clearCache(exploreId)}
scrollElement={scrollElement}
eventBus={this.props.eventBus}
/>
</LogsCrossFadeTransition>

View File

@ -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<Props, State> {
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<Props, State> {
}
};
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<Props, State> {
row,
showDuplicates,
showContextToggle,
showRowMenu,
showLabels,
showTime,
displayedFields,
@ -162,7 +139,6 @@ class UnThemedLogRow extends PureComponent<Props, State> {
getFieldLinks,
forceEscape,
app,
scrollElement,
styles,
} = this.props;
const { showDetails, showContext } = this.state;
@ -219,22 +195,11 @@ class UnThemedLogRow extends PureComponent<Props, State> {
) : (
<LogRowMessage
row={processedRow}
getRows={getRows}
errors={errors}
hasMoreContextRows={hasMoreContextRows}
getLogRowContextUi={getLogRowContextUi}
runContextQuery={runContextQuery}
updateLimit={updateLimit}
context={context}
contextIsOpen={showContext}
showContextToggle={showContextToggle}
showRowMenu={showRowMenu}
wrapLogMessage={wrapLogMessage}
prettifyLogMessage={prettifyLogMessage}
onToggleContext={this.toggleContext}
onOpenContext={this.onOpenContext}
app={app}
scrollElement={scrollElement}
logsSortOrder={logsSortOrder}
styles={styles}
/>
)}
@ -260,37 +225,6 @@ class UnThemedLogRow extends PureComponent<Props, State> {
</>
);
}
render() {
const { showContext } = this.state;
const { logsSortOrder, row, getRowContext, getLogRowContextUi } = this.props;
if (showContext) {
return (
<>
<LogRowContextProvider row={row} getRowContext={getRowContext} logsSortOrder={logsSortOrder}>
{({ result, errors, hasMoreContextRows, updateLimit, runContextQuery, logsSortOrder }) => {
return (
<>
{this.renderLogRow(
result,
errors,
hasMoreContextRows,
updateLimit,
logsSortOrder,
getLogRowContextUi,
runContextQuery
)}
</>
);
}}
</LogRowContextProvider>
</>
);
}
return this.renderLogRow();
}
}
export const LogRow = withTheme2(UnThemedLogRow);

View File

@ -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<Props> {
logRowRef: React.RefObject<HTMLTableCellElement> = React.createRef();
onContextToggle = (e: React.SyntheticEvent<HTMLElement>) => {
e.stopPropagation();
this.props.onToggleContext('open');
};
onShowContextClick = (e: React.SyntheticEvent<HTMLElement, Event>) => {
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<Props> {
// 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`.
}
<td
ref={this.logRowRef}
style={contextIsOpen ? { position: 'unset' } : undefined}
className={styles.logsRowMessage}
>
<td className={styles.logsRowMessage}>
<div
className={cx(
{ [styles.positionRelative]: wrapLogMessage },
{ [styles.horizontalScroll]: !wrapLogMessage }
)}
>
{contextIsOpen && context && (
<LogRowContext
row={row}
getLogRowContextUi={getLogRowContextUi}
runContextQuery={runContextQuery}
context={context}
errors={errors}
wrapLogMessage={wrapLogMessage}
hasMoreContextRows={hasMoreContextRows}
onOutsideClick={onToggleContext}
logsSortOrder={logsSortOrder}
onLoadMoreContext={() => {
if (updateLimit) {
updateLimit();
}
}}
/>
)}
<button className={cx(styles.logLine, styles.positionRelative, { [styles.rowWithContext]: contextIsOpen })}>
<button className={cx(styles.logLine, styles.positionRelative)}>
{renderLogMessage(hasAnsi, restructuredEntry, row.searchWords, styles.logsRowMatchHighLight)}
</button>
</div>
</td>
{showRowMenu && (
<td className={cx('log-row-menu-cell', styles.logRowMenuCell)}>
<span
className={cx('log-row-menu', styles.rowMenu, {
[styles.rowMenuWithContextButton]: shouldShowContextToggle,
})}
onClick={(e) => e.stopPropagation()}
>
{shouldShowContextToggle && (
<Tooltip placement="top" content={'Show context'}>
<IconButton size="md" name="gf-show-context" onClick={this.onShowContextClick} />
</Tooltip>
)}
<Tooltip placement="top" content={'Copy'}>
<IconButton size="md" name="copy" onClick={() => navigator.clipboard.writeText(restructuredEntry)} />
<td className={cx('log-row-menu-cell', styles.logRowMenuCell)}>
<span
className={cx('log-row-menu', styles.rowMenu, {
[styles.rowMenuWithContextButton]: shouldShowContextToggle,
})}
onClick={(e) => e.stopPropagation()}
>
{shouldShowContextToggle && (
<Tooltip placement="top" content={'Show context'}>
<IconButton size="md" name="gf-show-context" onClick={this.onShowContextClick} />
</Tooltip>
</span>
</td>
)}
)}
<Tooltip placement="top" content={'Copy'}>
<IconButton size="md" name="copy" onClick={() => navigator.clipboard.writeText(restructuredEntry)} />
</Tooltip>
</span>
</td>
</>
);
}

View File

@ -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<Props, State> {
@ -65,18 +64,15 @@ class UnThemedLogRows extends PureComponent<Props, State> {
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<Props, State> {
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<Props, State> {
getLogRowContextUi={getLogRowContextUi}
row={row}
showContextToggle={showContextToggle}
showRowMenu={!contextIsOpen}
showDuplicates={showDuplicates}
showLabels={showLabels}
showTime={showTime}
@ -179,10 +173,9 @@ class UnThemedLogRows extends PureComponent<Props, State> {
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<Props, State> {
getLogRowContextUi={getLogRowContextUi}
row={row}
showContextToggle={showContextToggle}
showRowMenu={!contextIsOpen}
showDuplicates={showDuplicates}
showLabels={showLabels}
showTime={showTime}
@ -212,10 +204,9 @@ class UnThemedLogRows extends PureComponent<Props, State> {
getFieldLinks={getFieldLinks}
logsSortOrder={logsSortOrder}
forceEscape={forceEscape}
toggleContextIsOpen={this.toggleContextIsOpen}
onOpenContext={this.openContext}
onLogRowHover={onLogRowHover}
app={app}
scrollElement={scrollElement}
styles={styles}
/>
))}

View File

@ -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<number> = { label: '10 lines', value: 10 };
const position: 'top' | 'bottom' = 'bottom';
beforeEach(() => {
render(<LogContextButtons option={option} onChangeOption={onChangeOption} position={position} />);
});
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);
});
});

View File

@ -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<SelectableValue<number>> = [
{ 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<number>;
onChangeOption: (item: SelectableValue<number>) => void;
position?: 'top' | 'bottom';
};
export const LogContextButtons = (props: Props) => {
const { option, onChangeOption } = props;
const styles = useStyles2(getStyles);
return (
<ButtonGroup className={styles.logSamplesButton}>
<ButtonSelect variant="canvas" value={option} options={LoadMoreOptions} onChange={onChangeOption} />
</ButtonGroup>
);
};

View File

@ -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(<LogRowContextGroup {...defaultProps} />);
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(<LogRowContextGroup {...defaultProps} />);
expect(await screen.findByText(`Showing 3 lines ${expected} match.`)).toBeInTheDocument();
});
});

View File

@ -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<string | DataQueryError>;
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<string | DataQueryError>;
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<HTMLDivElement>();
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 && (
<div ref={datasourceUiRef} className={datasourceUi}>
{getLogRowContextUi(row, runContextQuery)}
</div>
)}
<div className={header}>
<span
className={css`
opacity: 0.6;
`}
>
Showing {rows.length} lines {logGroupPosition} match.
</span>
{(rows.length >= 10 || (rows.length > 10 && rows.length % 10 !== 0)) && canLoadMoreRows && (
<Button className={headerButton} variant="secondary" size="sm" onClick={onLoadMoreContext}>
Load 10 more lines
</Button>
)}
</div>
</>
);
};
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<HTMLDivElement>(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 (
<div
className={cx(commonStyles, className, groupPosition === LogGroupPosition.Bottom ? bottomContext : afterContext)}
>
{/* When displaying "after" context */}
{shouldScrollToBottom && !error && <LogRowContextGroupHeader onHeightChange={changeHeight} {...headerProps} />}
<div className={logs}>
<CustomScrollbar autoHide onScroll={updateScroll} scrollTop={scrollTop} autoHeightMin={'210px'}>
<div ref={listContainerRef}>
{!error && (
<List
items={rows}
renderItem={(item) => {
return (
<div
className={css`
padding: 5px 0;
`}
>
{typeof item === 'string' && textUtil.hasAnsiCodes(item) ? (
<LogMessageAnsi value={item} />
) : (
String(item)
)}
</div>
);
}}
/>
)}
{error && <Alert title={error} />}
</div>
</CustomScrollbar>
</div>
{/* When displaying "before" context */}
{!shouldScrollToBottom && !error && <LogRowContextGroupHeader {...headerProps} />}
</div>
);
};
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 (
<ClickOutsideWrapper onClick={handleOutsideClick}>
{/* 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 */}
<div onClick={(e) => e.stopPropagation()}>
{context.after && (
<LogRowContextGroup
rows={context.after}
error={errors && errors.after}
row={row}
className={cx(top, width)}
shouldScrollToBottom
canLoadMoreRows={hasMoreContextRows ? hasMoreContextRows.after : false}
onLoadMoreContext={onLoadMoreContext}
groupPosition={LogGroupPosition.Top}
logsSortOrder={logsSortOrder}
getLogRowContextUi={getLogRowContextUi}
runContextQuery={runContextQuery}
onHeightChange={setHeight}
/>
)}
{context.before && (
<LogRowContextGroup
onLoadMoreContext={onLoadMoreContext}
canLoadMoreRows={hasMoreContextRows ? hasMoreContextRows.before : false}
row={row}
rows={context.before}
error={errors && errors.before}
className={cx(beforeContext, width)}
groupPosition={LogGroupPosition.Bottom}
logsSortOrder={logsSortOrder}
/>
)}
<div className={cx(title, width)}>
<h5>Log context</h5>
<div className={actions}>
<IconButton size="lg" name="times" onClick={() => onOutsideClick('close_button')} />
</div>
</div>
</div>
</ClickOutsideWrapper>
);
};

View File

@ -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(
<LogRowContextModal row={row} open={false} onClose={() => {}} getRowContext={getRowContext} timeZone={timeZone} />
);
expect(screen.queryByText('Log context')).not.toBeInTheDocument();
});
it('should render modal when it is open', async () => {
act(() => {
render(
<LogRowContextModal
row={row}
open={true}
onClose={() => {}}
getRowContext={getRowContext}
timeZone={timeZone}
/>
);
});
await waitFor(() => expect(screen.queryByText('Log context')).toBeInTheDocument());
});
it('should call getRowContext on open and change of row', () => {
render(
<LogRowContextModal row={row} open={false} onClose={() => {}} getRowContext={getRowContext} timeZone={timeZone} />
);
expect(getRowContext).not.toHaveBeenCalled();
});
it('should call getRowContext on open', async () => {
act(() => {
render(
<LogRowContextModal
row={row}
open={true}
onClose={() => {}}
getRowContext={getRowContext}
timeZone={timeZone}
/>
);
});
await waitFor(() => expect(getRowContext).toHaveBeenCalledTimes(2));
});
it('should call getRowContext when limit changes', async () => {
act(() => {
render(
<LogRowContextModal
row={row}
open={true}
onClose={() => {}}
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));
});
});

View File

@ -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<DataQueryResponse>;
logsSortOrder?: LogsSortOrder | null;
runContextQuery?: () => void;
getLogRowContextUi?: DataSourceWithLogsContextSupport['getLogRowContextUi'];
}
export const LogRowContextModal: React.FunctionComponent<LogRowContextModalProps> = ({
row,
open,
logsSortOrder,
getLogRowContextUi,
onClose,
getRowContext,
timeZone,
}) => {
const scrollElement = React.createRef<HTMLDivElement>();
const entryElement = React.createRef<HTMLTableRowElement>();
const theme = useTheme2();
const styles = getStyles(theme);
const [context, setContext] = useState<{ after: LogRowModel[]; before: LogRowModel[] }>({ after: [], before: [] });
const [limit, setLimit] = useState<number>(LoadMoreOptions[0].value!);
const [loadingWidth, setLoadingWidth] = useState(0);
const [loadMoreOption, setLoadMoreOption] = useState<SelectableValue<number>>(LoadMoreOptions[0]);
const onChangeLimitOption = (option: SelectableValue<number>) => {
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<string[]>([]);
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 (
<Modal
isOpen={open}
title="Log context"
contentClassName={styles.flexColumn}
className={styles.modal}
onDismiss={onClose}
>
{config.featureToggles.logsContextDatasourceUi && getLogRowContextUi && (
<div className={styles.datasourceUi}>{getLogRowContextUi(row, fetchResults)}</div>
)}
<div className={cx(styles.flexRow, styles.paddingBottom)}>
<div className={loading ? styles.hidden : ''}>
Showing {context.after.length} lines {logsSortOrder === LogsSortOrder.Ascending ? 'after' : 'before'} match.
</div>
<div>
<LogContextButtons onChangeOption={onChangeLimitOption} option={loadMoreOption} />
</div>
</div>
<div className={loading ? '' : styles.hidden}>
<LoadingBar width={loadingWidth} />
</div>
<div ref={scrollElement} className={styles.logRowGroups}>
<table>
<tbody>
<tr>
<td className={styles.noMarginBottom}>
<LogRows
logRows={context.after}
dedupStrategy={LogsDedupStrategy.none}
showLabels={store.getBool(SETTINGS_KEYS.showLabels, false)}
showTime={store.getBool(SETTINGS_KEYS.showTime, true)}
wrapLogMessage={store.getBool(SETTINGS_KEYS.wrapLogMessage, true)}
prettifyLogMessage={store.getBool(SETTINGS_KEYS.prettifyLogMessage, false)}
enableLogDetails={true}
timeZone={timeZone}
displayedFields={displayedFields}
onClickShowField={showField}
onClickHideField={hideField}
/>
</td>
</tr>
<tr ref={entryElement} className={styles.entry}>
<td className={styles.noMarginBottom}>
<LogRows
logRows={[row]}
dedupStrategy={LogsDedupStrategy.none}
showLabels={store.getBool(SETTINGS_KEYS.showLabels, false)}
showTime={store.getBool(SETTINGS_KEYS.showTime, true)}
wrapLogMessage={store.getBool(SETTINGS_KEYS.wrapLogMessage, true)}
prettifyLogMessage={store.getBool(SETTINGS_KEYS.prettifyLogMessage, false)}
enableLogDetails={true}
timeZone={timeZone}
displayedFields={displayedFields}
onClickShowField={showField}
onClickHideField={hideField}
/>
</td>
</tr>
<tr>
<td>
<LogRows
logRows={context.before}
dedupStrategy={LogsDedupStrategy.none}
showLabels={store.getBool(SETTINGS_KEYS.showLabels, false)}
showTime={store.getBool(SETTINGS_KEYS.showTime, true)}
wrapLogMessage={store.getBool(SETTINGS_KEYS.wrapLogMessage, true)}
prettifyLogMessage={store.getBool(SETTINGS_KEYS.prettifyLogMessage, false)}
enableLogDetails={true}
timeZone={timeZone}
displayedFields={displayedFields}
onClickShowField={showField}
onClickHideField={hideField}
/>
</td>
</tr>
</tbody>
</table>
</div>
<div>
<div className={cx(styles.paddingTop, loading ? styles.hidden : '')}>
Showing {context.before.length} lines {logsSortOrder === LogsSortOrder.Descending ? 'after' : 'before'} match.
</div>
</div>
</Modal>
);
};

View File

@ -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<DataQueryResponse> => {
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<DataQueryResponse> => {
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<DataQueryResponse> => {
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<DataQueryResponse> => {
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 <div data-testid="mockChild" />;
}
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 <div data-testid="mockChild" />;
}
if (updateLimitCalled && result.before.length > 0 && limit > 10) {
expect(limit).toBe(20);
}
return <div data-testid="mockChild" />;
});
render(
<LogRowContextProvider row={row} getRowContext={getRowContextMock}>
{mockedChildren}
</LogRowContextProvider>
);
await screen.findByTestId('mockChild');
});
});
});

View File

@ -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<DataQueryResponse>;
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<DataQueryResponse>,
row: LogRowModel,
limit: number,
logsSortOrder?: LogsSortOrder | null
): Promise<ResultType> => {
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<DataQueryResponse | DataQueryError> = 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<string> = fieldCache.getFieldByName('ts')!;
const idField: Field<string> | 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<string> = 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<ResultType>(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<ResultType>();
// 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,
});
};

View File

@ -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

View File

@ -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,