mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
9665b3afe7
commit
a6a7cebbe5
@ -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"],
|
||||
|
@ -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')}
|
||||
/>
|
||||
|
@ -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}>
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -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();
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -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));
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
@ -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,
|
||||
});
|
||||
};
|
@ -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
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user