mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Logs: Implement "infinite" scrolling in log context (#69459)
* logs: context: allow "infinite" scroll * Log Context: Add visual changes to infinite scrolling (#70461) * remove text showing how many log lines are loaded * better positioning of loading indicators * add border * fix import * better loading states * improve corner cases * increase page size 10 => 50 * updated unit test, simplified code * fixed tests * updated tests * removed unused code * fixed test * improved refid-handling in loki * removed unnecessary code * better variable name * refactor * refactor --------- Co-authored-by: Sven Grossmann <sven.grossmann@grafana.com>
This commit is contained in:
parent
5159317ee7
commit
9ca888527b
@ -84,7 +84,7 @@ interface Props extends Themeable2 {
|
||||
onClickFilterOutLabel: (key: string, value: string) => void;
|
||||
onStartScanning?: () => void;
|
||||
onStopScanning?: () => void;
|
||||
getRowContext?: (row: LogRowModel, options?: LogRowContextOptions) => Promise<any>;
|
||||
getRowContext?: (row: LogRowModel, origRow: LogRowModel, options: LogRowContextOptions) => Promise<any>;
|
||||
getRowContextQuery?: (row: LogRowModel, options?: LogRowContextOptions) => Promise<DataQuery | null>;
|
||||
getLogRowContextUi?: (row: LogRowModel, runContextQuery?: () => void) => React.ReactNode;
|
||||
getFieldLinks: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array<LinkModel<Field>>;
|
||||
@ -102,7 +102,7 @@ interface State {
|
||||
prettifyLogMessage: boolean;
|
||||
dedupStrategy: LogsDedupStrategy;
|
||||
hiddenLogLevels: LogLevel[];
|
||||
logsSortOrder: LogsSortOrder | null;
|
||||
logsSortOrder: LogsSortOrder;
|
||||
isFlipping: boolean;
|
||||
displayedFields: string[];
|
||||
forceEscape: boolean;
|
||||
@ -488,7 +488,7 @@ class UnthemedLogs extends PureComponent<Props, State> {
|
||||
open={contextOpen}
|
||||
row={contextRow}
|
||||
onClose={this.onCloseContext}
|
||||
getRowContext={getRowContext}
|
||||
getRowContext={(row, options) => getRowContext(row, contextRow, options)}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
getLogRowContextUi={getLogRowContextUi}
|
||||
logsSortOrder={logsSortOrder}
|
||||
|
@ -72,11 +72,15 @@ class LogsContainer extends PureComponent<LogsContainerProps> {
|
||||
);
|
||||
}
|
||||
|
||||
getLogRowContext = async (row: LogRowModel, options?: LogRowContextOptions): Promise<DataQueryResponse | []> => {
|
||||
getLogRowContext = async (
|
||||
row: LogRowModel,
|
||||
origRow: LogRowModel,
|
||||
options: LogRowContextOptions
|
||||
): Promise<DataQueryResponse | []> => {
|
||||
const { datasourceInstance, logsQueries } = this.props;
|
||||
|
||||
if (hasLogsContextSupport(datasourceInstance)) {
|
||||
const query = this.getQuery(logsQueries, row, datasourceInstance);
|
||||
const query = this.getQuery(logsQueries, origRow, datasourceInstance);
|
||||
return datasourceInstance.getLogRowContext(row, options, query);
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,29 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { Spinner } from '@grafana/ui';
|
||||
|
||||
import { Place } from './types';
|
||||
|
||||
// ideally we'd use `@grafana/ui/LoadingPlaceholder`, but that
|
||||
// one has a large margin-bottom.
|
||||
|
||||
type Props = {
|
||||
place: Place;
|
||||
};
|
||||
|
||||
export const LoadingIndicator = ({ place }: Props) => {
|
||||
const text = place === 'above' ? 'Loading newer logs...' : 'Loading older logs...';
|
||||
return (
|
||||
<div className={loadingIndicatorStyles}>
|
||||
<div>
|
||||
{text} <Spinner inline />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const loadingIndicatorStyles = css`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
`;
|
@ -2,46 +2,28 @@ 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';
|
||||
import { LogContextButtons } 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 call onChangeWrapLines when the checkbox is used, case 1', async () => {
|
||||
const onChangeWrapLines = jest.fn();
|
||||
render(<LogContextButtons onChangeWrapLines={onChangeWrapLines} />);
|
||||
const wrapLinesBox = screen.getByRole('checkbox', {
|
||||
name: 'Wrap lines',
|
||||
});
|
||||
await userEvent.click(wrapLinesBox);
|
||||
expect(onChangeWrapLines).toHaveBeenCalledTimes(1);
|
||||
expect(onChangeWrapLines).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
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,
|
||||
it('should call onChangeWrapLines when the checkbox is used, case 2', async () => {
|
||||
const onChangeWrapLines = jest.fn();
|
||||
render(<LogContextButtons onChangeWrapLines={onChangeWrapLines} wrapLines />);
|
||||
const wrapLinesBox = screen.getByRole('checkbox', {
|
||||
name: 'Wrap lines',
|
||||
});
|
||||
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);
|
||||
await userEvent.click(wrapLinesBox);
|
||||
expect(onChangeWrapLines).toHaveBeenCalledTimes(1);
|
||||
expect(onChangeWrapLines).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
@ -1,61 +1,25 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { ButtonGroup, ButtonSelect, InlineField, InlineFieldRow, InlineSwitch, useStyles2 } from '@grafana/ui';
|
||||
|
||||
const getStyles = () => {
|
||||
return {
|
||||
buttonGroup: 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 },
|
||||
];
|
||||
import { InlineSwitch } from '@grafana/ui';
|
||||
|
||||
export type Props = {
|
||||
option: SelectableValue<number>;
|
||||
onChangeOption: (item: SelectableValue<number>) => void;
|
||||
position?: 'top' | 'bottom';
|
||||
|
||||
wrapLines?: boolean;
|
||||
onChangeWrapLines?: (wrapLines: boolean) => void;
|
||||
onChangeWrapLines: (wrapLines: boolean) => void;
|
||||
};
|
||||
|
||||
export const LogContextButtons = (props: Props) => {
|
||||
const { option, onChangeOption, wrapLines, onChangeWrapLines, position } = props;
|
||||
const { wrapLines, onChangeWrapLines } = props;
|
||||
const internalOnChangeWrapLines = useCallback(
|
||||
(event: React.FormEvent<HTMLInputElement>) => {
|
||||
if (onChangeWrapLines) {
|
||||
const state = event.currentTarget.checked;
|
||||
reportInteraction('grafana_explore_logs_log_context_toggle_lines_clicked', {
|
||||
state,
|
||||
});
|
||||
onChangeWrapLines(state);
|
||||
}
|
||||
const state = event.currentTarget.checked;
|
||||
reportInteraction('grafana_explore_logs_log_context_toggle_lines_clicked', {
|
||||
state,
|
||||
});
|
||||
onChangeWrapLines(state);
|
||||
},
|
||||
[onChangeWrapLines]
|
||||
);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<ButtonGroup className={styles.buttonGroup}>
|
||||
{position === 'top' && onChangeWrapLines && (
|
||||
<InlineFieldRow>
|
||||
<InlineField label="Wrap lines">
|
||||
<InlineSwitch value={wrapLines} onChange={internalOnChangeWrapLines} />
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
)}
|
||||
<ButtonSelect variant="canvas" value={option} options={LoadMoreOptions} onChange={onChangeOption} />
|
||||
</ButtonGroup>
|
||||
);
|
||||
return <InlineSwitch showLabel value={wrapLines} onChange={internalOnChangeWrapLines} label="Wrap lines" />;
|
||||
};
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { 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 { render } from 'test/redux-rtl';
|
||||
|
||||
import {
|
||||
@ -57,15 +56,32 @@ const dfAfter = createDataFrame({
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let uniqueRefIdCounter = 1;
|
||||
|
||||
const getRowContext = jest.fn().mockImplementation(async (_, options) => {
|
||||
uniqueRefIdCounter += 1;
|
||||
const refId = `refid_${uniqueRefIdCounter}`;
|
||||
if (options.direction === LogRowContextQueryDirection.Forward) {
|
||||
return { data: [dfBefore] };
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
refId,
|
||||
...dfBefore,
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return { data: [dfAfter] };
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
refId,
|
||||
...dfAfter,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
});
|
||||
const getRowContextQuery = jest.fn().mockResolvedValue({ datasource: { uid: 'test-uid' } });
|
||||
|
||||
const dispatchMock = jest.fn();
|
||||
jest.mock('app/types', () => ({
|
||||
...jest.requireActual('app/types'),
|
||||
@ -99,7 +115,14 @@ describe('LogRowContextModal', () => {
|
||||
|
||||
it('should not render modal when it is closed', async () => {
|
||||
render(
|
||||
<LogRowContextModal row={row} open={false} onClose={() => {}} getRowContext={getRowContext} timeZone={timeZone} />
|
||||
<LogRowContextModal
|
||||
row={row}
|
||||
open={false}
|
||||
onClose={() => {}}
|
||||
getRowContext={getRowContext}
|
||||
timeZone={timeZone}
|
||||
logsSortOrder={LogsSortOrder.Descending}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Log context')).not.toBeInTheDocument());
|
||||
@ -107,7 +130,14 @@ describe('LogRowContextModal', () => {
|
||||
|
||||
it('should render modal when it is open', async () => {
|
||||
render(
|
||||
<LogRowContextModal row={row} open={true} onClose={() => {}} getRowContext={getRowContext} timeZone={timeZone} />
|
||||
<LogRowContextModal
|
||||
row={row}
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
getRowContext={getRowContext}
|
||||
timeZone={timeZone}
|
||||
logsSortOrder={LogsSortOrder.Descending}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Log context')).toBeInTheDocument());
|
||||
@ -115,7 +145,14 @@ describe('LogRowContextModal', () => {
|
||||
|
||||
it('should call getRowContext on open and change of row', async () => {
|
||||
render(
|
||||
<LogRowContextModal row={row} open={false} onClose={() => {}} getRowContext={getRowContext} timeZone={timeZone} />
|
||||
<LogRowContextModal
|
||||
row={row}
|
||||
open={false}
|
||||
onClose={() => {}}
|
||||
getRowContext={getRowContext}
|
||||
timeZone={timeZone}
|
||||
logsSortOrder={LogsSortOrder.Descending}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(getRowContext).not.toHaveBeenCalled());
|
||||
@ -123,7 +160,14 @@ describe('LogRowContextModal', () => {
|
||||
|
||||
it('should call getRowContext on open', async () => {
|
||||
render(
|
||||
<LogRowContextModal row={row} open={true} onClose={() => {}} getRowContext={getRowContext} timeZone={timeZone} />
|
||||
<LogRowContextModal
|
||||
row={row}
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
getRowContext={getRowContext}
|
||||
timeZone={timeZone}
|
||||
logsSortOrder={LogsSortOrder.Descending}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(getRowContext).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
@ -143,53 +187,6 @@ describe('LogRowContextModal', () => {
|
||||
await waitFor(() => expect(screen.getAllByText('foo123').length).toBe(3));
|
||||
});
|
||||
|
||||
it('should call getRowContext when limit changes', async () => {
|
||||
render(
|
||||
<LogRowContextModal row={row} open={true} onClose={() => {}} getRowContext={getRowContext} timeZone={timeZone} />
|
||||
);
|
||||
await waitFor(() => expect(getRowContext).toHaveBeenCalledTimes(2));
|
||||
|
||||
const fiftyLinesButton = screen.getByRole('button', {
|
||||
name: /50 lines/i,
|
||||
});
|
||||
await userEvent.click(fiftyLinesButton);
|
||||
const twentyLinesButton = screen.getByRole('menuitemradio', {
|
||||
name: /20 lines/i,
|
||||
});
|
||||
await userEvent.click(twentyLinesButton);
|
||||
|
||||
await waitFor(() => expect(getRowContext).toHaveBeenCalledTimes(4));
|
||||
});
|
||||
|
||||
it('should call getRowContextQuery when limit changes', async () => {
|
||||
render(
|
||||
<LogRowContextModal
|
||||
row={row}
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
getRowContext={getRowContext}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
);
|
||||
|
||||
// this will call it initially and in the first fetchResults
|
||||
await waitFor(() => expect(getRowContextQuery).toHaveBeenCalledTimes(2));
|
||||
|
||||
const tenLinesButton = screen.getByRole('button', {
|
||||
name: /50 lines/i,
|
||||
});
|
||||
await userEvent.click(tenLinesButton);
|
||||
const twentyLinesButton = screen.getByRole('menuitemradio', {
|
||||
name: /20 lines/i,
|
||||
});
|
||||
act(() => {
|
||||
userEvent.click(twentyLinesButton);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getRowContextQuery).toHaveBeenCalledTimes(3));
|
||||
});
|
||||
|
||||
it('should show a split view button', async () => {
|
||||
const getRowContextQuery = jest.fn().mockResolvedValue({ datasource: { uid: 'test-uid' } });
|
||||
|
||||
@ -201,6 +198,7 @@ describe('LogRowContextModal', () => {
|
||||
getRowContext={getRowContext}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
timeZone={timeZone}
|
||||
logsSortOrder={LogsSortOrder.Descending}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -215,7 +213,14 @@ describe('LogRowContextModal', () => {
|
||||
|
||||
it('should not show a split view button', async () => {
|
||||
render(
|
||||
<LogRowContextModal row={row} open={true} onClose={() => {}} getRowContext={getRowContext} timeZone={timeZone} />
|
||||
<LogRowContextModal
|
||||
row={row}
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
getRowContext={getRowContext}
|
||||
timeZone={timeZone}
|
||||
logsSortOrder={LogsSortOrder.Descending}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@ -237,6 +242,7 @@ describe('LogRowContextModal', () => {
|
||||
getRowContext={getRowContext}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
timeZone={timeZone}
|
||||
logsSortOrder={LogsSortOrder.Descending}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -254,6 +260,7 @@ describe('LogRowContextModal', () => {
|
||||
getRowContext={getRowContext}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
timeZone={timeZone}
|
||||
logsSortOrder={LogsSortOrder.Descending}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -279,6 +286,7 @@ describe('LogRowContextModal', () => {
|
||||
getRowContext={getRowContext}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
timeZone={timeZone}
|
||||
logsSortOrder={LogsSortOrder.Descending}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -314,6 +322,7 @@ describe('LogRowContextModal', () => {
|
||||
getRowContext={getRowContext}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
timeZone={timeZone}
|
||||
logsSortOrder={LogsSortOrder.Descending}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { useAsync, useAsyncFn } from 'react-use';
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import {
|
||||
DataQueryResponse,
|
||||
@ -11,13 +11,12 @@ import {
|
||||
LogRowModel,
|
||||
LogsDedupStrategy,
|
||||
LogsSortOrder,
|
||||
SelectableValue,
|
||||
dateTime,
|
||||
TimeRange,
|
||||
} from '@grafana/data';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { DataQuery, TimeZone } from '@grafana/schema';
|
||||
import { Icon, Button, LoadingBar, Modal, useTheme2 } from '@grafana/ui';
|
||||
import { DataQuery, LoadingState, TimeZone } from '@grafana/schema';
|
||||
import { Icon, Button, Modal, useTheme2 } from '@grafana/ui';
|
||||
import { dataFrameToLogsModel } from 'app/core/logsModel';
|
||||
import store from 'app/core/store';
|
||||
import { SETTINGS_KEYS } from 'app/features/explore/Logs/utils/logs';
|
||||
@ -27,7 +26,9 @@ import { useDispatch } from 'app/types';
|
||||
import { sortLogRows } from '../../utils';
|
||||
import { LogRows } from '../LogRows';
|
||||
|
||||
import { LoadMoreOptions, LogContextButtons } from './LogContextButtons';
|
||||
import { LoadingIndicator } from './LoadingIndicator';
|
||||
import { LogContextButtons } from './LogContextButtons';
|
||||
import { Place } from './types';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
@ -64,6 +65,8 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
max-height: 75%;
|
||||
align-self: stretch;
|
||||
display: inline-block;
|
||||
border: 1px solid ${theme.colors.border.weak};
|
||||
border-radius: ${theme.shape.radius.default};
|
||||
& > table {
|
||||
min-width: 100%;
|
||||
}
|
||||
@ -102,27 +105,52 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
color: ${theme.colors.text.link};
|
||||
}
|
||||
`,
|
||||
loadingCell: css`
|
||||
position: sticky;
|
||||
left: 50%;
|
||||
display: inline-block;
|
||||
transform: translateX(-50%);
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
||||
export enum LogGroupPosition {
|
||||
Bottom = 'bottom',
|
||||
Top = 'top',
|
||||
}
|
||||
|
||||
interface LogRowContextModalProps {
|
||||
row: LogRowModel;
|
||||
open: boolean;
|
||||
timeZone: TimeZone;
|
||||
onClose: () => void;
|
||||
getRowContext: (row: LogRowModel, options?: LogRowContextOptions) => Promise<DataQueryResponse>;
|
||||
getRowContext: (row: LogRowModel, options: LogRowContextOptions) => Promise<DataQueryResponse>;
|
||||
|
||||
getRowContextQuery?: (row: LogRowModel, options?: LogRowContextOptions) => Promise<DataQuery | null>;
|
||||
logsSortOrder?: LogsSortOrder | null;
|
||||
logsSortOrder: LogsSortOrder;
|
||||
runContextQuery?: () => void;
|
||||
getLogRowContextUi?: DataSourceWithLogsContextSupport['getLogRowContextUi'];
|
||||
}
|
||||
|
||||
type Section = {
|
||||
loadingState: LoadingState;
|
||||
rows: LogRowModel[];
|
||||
};
|
||||
type Context = Record<Place, Section>;
|
||||
|
||||
const makeEmptyContext = (): Context => ({
|
||||
above: { loadingState: LoadingState.NotStarted, rows: [] },
|
||||
below: { loadingState: LoadingState.NotStarted, rows: [] },
|
||||
});
|
||||
|
||||
const getLoadMoreDirection = (place: Place, sortOrder: LogsSortOrder): LogRowContextQueryDirection => {
|
||||
if (place === 'above' && sortOrder === LogsSortOrder.Descending) {
|
||||
return LogRowContextQueryDirection.Forward;
|
||||
}
|
||||
if (place === 'below' && sortOrder === LogsSortOrder.Ascending) {
|
||||
return LogRowContextQueryDirection.Forward;
|
||||
}
|
||||
|
||||
return LogRowContextQueryDirection.Backward;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export const LogRowContextModal: React.FunctionComponent<LogRowContextModalProps> = ({
|
||||
row,
|
||||
open,
|
||||
@ -133,30 +161,54 @@ export const LogRowContextModal: React.FunctionComponent<LogRowContextModalProps
|
||||
onClose,
|
||||
getRowContext,
|
||||
}) => {
|
||||
const scrollElement = React.createRef<HTMLDivElement>();
|
||||
const entryElement = React.createRef<HTMLTableRowElement>();
|
||||
const scrollElement = useRef<HTMLDivElement | null>(null);
|
||||
const entryElement = useRef<HTMLTableRowElement | null>(null);
|
||||
// We can not use `entryElement` to scroll to the right element because it's
|
||||
// sticky. That's why we add another row and use this ref to scroll to that
|
||||
// first.
|
||||
const preEntryElement = React.createRef<HTMLTableRowElement>();
|
||||
const preEntryElement = useRef<HTMLTableRowElement | null>(null);
|
||||
|
||||
const prevScrollHeightRef = useRef<number | null>(null);
|
||||
const prevClientHeightRef = useRef<number | null>(null);
|
||||
|
||||
const aboveLoadingElement = useRef<HTMLDivElement | null>(null);
|
||||
const belowLoadingElement = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const theme = useTheme2();
|
||||
const styles = getStyles(theme);
|
||||
const [context, setContext] = useState<{ after: LogRowModel[]; before: LogRowModel[] }>({ after: [], before: [] });
|
||||
// LoadMoreOptions[2] refers to 50 lines
|
||||
const defaultLimit = LoadMoreOptions[2];
|
||||
const [limit, setLimit] = useState<number>(defaultLimit.value!);
|
||||
const [loadingWidth, setLoadingWidth] = useState(0);
|
||||
const [loadMoreOption, setLoadMoreOption] = useState<SelectableValue<number>>(defaultLimit);
|
||||
|
||||
// we need to keep both the "above" and "below" rows
|
||||
// in the same react-state, to be able to atomically change both
|
||||
// at the same time.
|
||||
// we create the `setSection` convenience function to adjust any
|
||||
// part of it easily.
|
||||
const [context, setContext] = useState<Context>(makeEmptyContext());
|
||||
const setSection = (place: Place, fun: (s: Section) => Section) => {
|
||||
setContext((c) => {
|
||||
const newContext = { ...c };
|
||||
newContext[place] = fun(c[place]);
|
||||
return newContext;
|
||||
});
|
||||
};
|
||||
|
||||
// this is used to "cancel" the ongoing load-more requests.
|
||||
// whenever we want to cancel them, we increment this number.
|
||||
// and when those requests return, we check if the number
|
||||
// is still the same as when we started. and if it is not the same,
|
||||
// we ignore the results.
|
||||
//
|
||||
// best would be to literally cancel those requests,
|
||||
// but right now there's no way with the current logs-context API.
|
||||
const generationRef = useRef(1);
|
||||
|
||||
const [contextQuery, setContextQuery] = useState<DataQuery | null>(null);
|
||||
const [wrapLines, setWrapLines] = useState(
|
||||
store.getBool(SETTINGS_KEYS.logContextWrapLogMessage, store.getBool(SETTINGS_KEYS.wrapLogMessage, true))
|
||||
);
|
||||
|
||||
const getFullTimeRange = useCallback(() => {
|
||||
const { before, after } = context;
|
||||
const allRows = sortLogRows([...before, row, ...after], LogsSortOrder.Ascending);
|
||||
const { below, above } = context;
|
||||
const allRows = sortLogRows([...below.rows, row, ...above.rows], LogsSortOrder.Ascending);
|
||||
const fromMs = allRows[0].timeEpochMs;
|
||||
let toMs = allRows[allRows.length - 1].timeEpochMs;
|
||||
// In case we have a lot of logs and from and to have same millisecond
|
||||
@ -178,68 +230,48 @@ export const LogRowContextModal: React.FunctionComponent<LogRowContextModalProps
|
||||
return range;
|
||||
}, [context, row]);
|
||||
|
||||
const onChangeLimitOption = (option: SelectableValue<number>) => {
|
||||
setLoadMoreOption(option);
|
||||
if (option.value) {
|
||||
setLimit(option.value);
|
||||
reportInteraction('grafana_explore_logs_log_context_load_more_clicked', {
|
||||
datasourceType: row.datasourceType,
|
||||
logRowUid: row.uid,
|
||||
new_limit: option.value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const updateContextQuery = async () => {
|
||||
const updateContextQuery = useCallback(async () => {
|
||||
const contextQuery = getRowContextQuery ? await getRowContextQuery(row) : null;
|
||||
setContextQuery(contextQuery);
|
||||
}, [row, getRowContextQuery]);
|
||||
|
||||
const updateResults = async () => {
|
||||
await updateContextQuery();
|
||||
setContext(makeEmptyContext());
|
||||
generationRef.current += 1; // results from currently running loadMore calls will be ignored
|
||||
};
|
||||
|
||||
const [{ loading }, fetchResults] = useAsyncFn(async () => {
|
||||
if (open && row && limit) {
|
||||
await updateContextQuery();
|
||||
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: [] });
|
||||
const loadMore = async (place: Place): Promise<LogRowModel[]> => {
|
||||
const { below, above } = context;
|
||||
// we consider all the currently existing rows, even the original row,
|
||||
// this way this array of rows will never be empty
|
||||
const allRows = [...above.rows, row, ...below.rows];
|
||||
const refRow = allRows.at(place === 'above' ? 0 : -1);
|
||||
if (refRow == null) {
|
||||
throw new Error('should never happen. the array always contains at least 1 item (the middle row)');
|
||||
}
|
||||
}, [row, open, limit]);
|
||||
|
||||
const direction = getLoadMoreDirection(place, logsSortOrder);
|
||||
|
||||
const result = await getRowContext(refRow, { limit: PAGE_SIZE, direction });
|
||||
const newRows = dataFrameToLogsModel(result.data).rows;
|
||||
|
||||
if (logsSortOrder === LogsSortOrder.Ascending) {
|
||||
newRows.reverse();
|
||||
}
|
||||
|
||||
const out = newRows.filter((r) => {
|
||||
return r.timeEpochNs !== refRow.timeEpochNs || r.entry !== refRow.entry;
|
||||
});
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchResults();
|
||||
updateContextQuery();
|
||||
}
|
||||
}, [fetchResults, open]);
|
||||
}, [updateContextQuery, open]);
|
||||
|
||||
const [displayedFields, setDisplayedFields] = useState<string[]>([]);
|
||||
|
||||
@ -260,22 +292,106 @@ export const LogRowContextModal: React.FunctionComponent<LogRowContextModalProps
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!loading && entryElement.current && preEntryElement.current) {
|
||||
preEntryElement.current.scrollIntoView({ block: 'center' });
|
||||
entryElement.current.scrollIntoView({ block: 'center' });
|
||||
const maybeLoadMore = async (place: Place) => {
|
||||
const section = context[place];
|
||||
if (section.loadingState === LoadingState.Loading) {
|
||||
return;
|
||||
}
|
||||
}, [entryElement, preEntryElement, context, loading]);
|
||||
|
||||
setSection(place, (section) => ({
|
||||
...section,
|
||||
loadingState: LoadingState.Loading,
|
||||
}));
|
||||
|
||||
const currentGen = generationRef.current;
|
||||
try {
|
||||
const newRows = await loadMore(place);
|
||||
if (currentGen === generationRef.current) {
|
||||
setSection(place, (section) => ({
|
||||
rows: place === 'above' ? [...newRows, ...section.rows] : [...section.rows, ...newRows],
|
||||
loadingState: newRows.length === 0 ? LoadingState.Done : LoadingState.NotStarted,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
setSection(place, (section) => ({
|
||||
rows: section.rows,
|
||||
loadingState: LoadingState.Error,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const onScrollHit = async (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
|
||||
for (const entry of entries) {
|
||||
// If the element is not intersecting, skip to the next one
|
||||
if (!entry.isIntersecting) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetElement = entry.target;
|
||||
|
||||
if (targetElement === aboveLoadingElement.current) {
|
||||
maybeLoadMore('above');
|
||||
} else if (targetElement === belowLoadingElement.current) {
|
||||
maybeLoadMore('below');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const scroll = scrollElement.current;
|
||||
const aboveElem = aboveLoadingElement.current;
|
||||
const belowElem = belowLoadingElement.current;
|
||||
|
||||
if (scroll == null) {
|
||||
// should not happen, but need to make typescript happy
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(onScrollHit, { root: scroll });
|
||||
|
||||
if (aboveElem != null) {
|
||||
observer.observe(aboveElem);
|
||||
}
|
||||
|
||||
if (belowElem != null) {
|
||||
observer.observe(belowElem);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}); // on every render, why not
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const width = scrollElement?.current?.parentElement?.clientWidth;
|
||||
if (width && width > 0) {
|
||||
setLoadingWidth(width);
|
||||
const scrollE = scrollElement.current;
|
||||
if (scrollE == null) {
|
||||
return;
|
||||
}
|
||||
}, [scrollElement]);
|
||||
|
||||
const prevClientHeight = prevClientHeightRef.current;
|
||||
const currentClientHeight = scrollE.clientHeight;
|
||||
prevClientHeightRef.current = currentClientHeight;
|
||||
if (prevClientHeight !== currentClientHeight) {
|
||||
// height has changed, we scroll to the center
|
||||
preEntryElement.current?.scrollIntoView({ block: 'center' });
|
||||
entryElement.current?.scrollIntoView({ block: 'center' });
|
||||
return;
|
||||
}
|
||||
|
||||
const prevScrollHeight = prevScrollHeightRef.current;
|
||||
const currentHeight = scrollE.scrollHeight;
|
||||
prevScrollHeightRef.current = currentHeight;
|
||||
if (prevScrollHeight != null) {
|
||||
const newScrollTop = scrollE.scrollTop + (currentHeight - prevScrollHeight);
|
||||
scrollE.scrollTop = newScrollTop;
|
||||
}
|
||||
}, [context.above.rows]);
|
||||
|
||||
useAsync(updateContextQuery, [getRowContextQuery, row]);
|
||||
|
||||
const loadingStateAbove = context.above.loadingState;
|
||||
const loadingStateBelow = context.below.loadingState;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={open}
|
||||
@ -285,32 +401,31 @@ export const LogRowContextModal: React.FunctionComponent<LogRowContextModalProps
|
||||
onDismiss={onClose}
|
||||
>
|
||||
{config.featureToggles.logsContextDatasourceUi && getLogRowContextUi && (
|
||||
<div className={styles.datasourceUi}>{getLogRowContextUi(row, fetchResults)}</div>
|
||||
<div className={styles.datasourceUi}>{getLogRowContextUi(row, updateResults)}</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
|
||||
position="top"
|
||||
wrapLines={wrapLines}
|
||||
onChangeWrapLines={setWrapLines}
|
||||
onChangeOption={onChangeLimitOption}
|
||||
option={loadMoreOption}
|
||||
/>
|
||||
<LogContextButtons wrapLines={wrapLines} onChangeWrapLines={setWrapLines} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={loading ? '' : styles.hidden}>
|
||||
<LoadingBar width={loadingWidth} />
|
||||
</div>
|
||||
<div ref={scrollElement} className={styles.logRowGroups}>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className={styles.loadingCell}>
|
||||
{loadingStateAbove !== LoadingState.Done && loadingStateAbove !== LoadingState.Error && (
|
||||
<div ref={aboveLoadingElement}>
|
||||
<LoadingIndicator place="above" />
|
||||
</div>
|
||||
)}
|
||||
{loadingStateAbove === LoadingState.Error && <div>Error loading log more logs.</div>}
|
||||
{loadingStateAbove === LoadingState.Done && <div>No more logs available.</div>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className={styles.noMarginBottom}>
|
||||
<LogRows
|
||||
logRows={context.after}
|
||||
logRows={context.above.rows}
|
||||
dedupStrategy={LogsDedupStrategy.none}
|
||||
showLabels={store.getBool(SETTINGS_KEYS.showLabels, false)}
|
||||
showTime={store.getBool(SETTINGS_KEYS.showTime, true)}
|
||||
@ -344,29 +459,37 @@ export const LogRowContextModal: React.FunctionComponent<LogRowContextModalProps
|
||||
</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={wrapLines}
|
||||
prettifyLogMessage={store.getBool(SETTINGS_KEYS.prettifyLogMessage, false)}
|
||||
enableLogDetails={true}
|
||||
timeZone={timeZone}
|
||||
displayedFields={displayedFields}
|
||||
onClickShowField={showField}
|
||||
onClickHideField={hideField}
|
||||
/>
|
||||
<>
|
||||
<LogRows
|
||||
logRows={context.below.rows}
|
||||
dedupStrategy={LogsDedupStrategy.none}
|
||||
showLabels={store.getBool(SETTINGS_KEYS.showLabels, false)}
|
||||
showTime={store.getBool(SETTINGS_KEYS.showTime, true)}
|
||||
wrapLogMessage={wrapLines}
|
||||
prettifyLogMessage={store.getBool(SETTINGS_KEYS.prettifyLogMessage, false)}
|
||||
enableLogDetails={true}
|
||||
timeZone={timeZone}
|
||||
displayedFields={displayedFields}
|
||||
onClickShowField={showField}
|
||||
onClickHideField={hideField}
|
||||
/>
|
||||
</>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className={styles.loadingCell}>
|
||||
{loadingStateBelow !== LoadingState.Done && loadingStateBelow !== LoadingState.Error && (
|
||||
<div ref={belowLoadingElement}>
|
||||
<LoadingIndicator place="below" />
|
||||
</div>
|
||||
)}
|
||||
{loadingStateBelow === LoadingState.Error && <div>Error loading log more logs.</div>}
|
||||
{loadingStateBelow === LoadingState.Done && <div>No more logs available.</div>}
|
||||
</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.ButtonRow>
|
||||
<a
|
||||
|
1
public/app/features/logs/components/log-context/types.ts
Normal file
1
public/app/features/logs/components/log-context/types.ts
Normal file
@ -0,0 +1 @@
|
||||
export type Place = 'above' | 'below';
|
@ -127,7 +127,11 @@ export class LogContextProvider {
|
||||
const query: LokiQuery = {
|
||||
expr,
|
||||
queryType: LokiQueryType.Range,
|
||||
refId: `${REF_ID_STARTER_LOG_ROW_CONTEXT}${row.dataFrame.refId || ''}`,
|
||||
// refId has to be:
|
||||
// - always different (temporarily, will be fixed later)
|
||||
// - not increase in size
|
||||
// because it may be called many times from logs-context
|
||||
refId: `${REF_ID_STARTER_LOG_ROW_CONTEXT}_${Math.random().toString()}`,
|
||||
maxLines: limit,
|
||||
direction: queryDirection,
|
||||
datasource: { uid: this.datasource.uid, type: this.datasource.type },
|
||||
|
Loading…
Reference in New Issue
Block a user