diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 81369efe2c1..05acc2bbe90 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -84,7 +84,7 @@ interface Props extends Themeable2 { onClickFilterOutLabel: (key: string, value: string) => void; onStartScanning?: () => void; onStopScanning?: () => void; - getRowContext?: (row: LogRowModel, options?: LogRowContextOptions) => Promise; + getRowContext?: (row: LogRowModel, origRow: LogRowModel, options: LogRowContextOptions) => Promise; getRowContextQuery?: (row: LogRowModel, options?: LogRowContextOptions) => Promise; getLogRowContextUi?: (row: LogRowModel, runContextQuery?: () => void) => React.ReactNode; getFieldLinks: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array>; @@ -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 { open={contextOpen} row={contextRow} onClose={this.onCloseContext} - getRowContext={getRowContext} + getRowContext={(row, options) => getRowContext(row, contextRow, options)} getRowContextQuery={getRowContextQuery} getLogRowContextUi={getLogRowContextUi} logsSortOrder={logsSortOrder} diff --git a/public/app/features/explore/Logs/LogsContainer.tsx b/public/app/features/explore/Logs/LogsContainer.tsx index e80573baefb..acef026f34d 100644 --- a/public/app/features/explore/Logs/LogsContainer.tsx +++ b/public/app/features/explore/Logs/LogsContainer.tsx @@ -72,11 +72,15 @@ class LogsContainer extends PureComponent { ); } - getLogRowContext = async (row: LogRowModel, options?: LogRowContextOptions): Promise => { + getLogRowContext = async ( + row: LogRowModel, + origRow: LogRowModel, + options: LogRowContextOptions + ): Promise => { 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); } diff --git a/public/app/features/logs/components/log-context/LoadingIndicator.tsx b/public/app/features/logs/components/log-context/LoadingIndicator.tsx new file mode 100644 index 00000000000..06a72775e9c --- /dev/null +++ b/public/app/features/logs/components/log-context/LoadingIndicator.tsx @@ -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 ( +
+
+ {text} +
+
+ ); +}; + +const loadingIndicatorStyles = css` + display: flex; + justify-content: center; +`; diff --git a/public/app/features/logs/components/log-context/LogContextButtons.test.tsx b/public/app/features/logs/components/log-context/LogContextButtons.test.tsx index 941bf2abf39..2d3ac411445 100644 --- a/public/app/features/logs/components/log-context/LogContextButtons.test.tsx +++ b/public/app/features/logs/components/log-context/LogContextButtons.test.tsx @@ -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 = { label: '10 lines', value: 10 }; - const position: 'top' | 'bottom' = 'bottom'; - - beforeEach(() => { - render(); + it('should call onChangeWrapLines when the checkbox is used, case 1', async () => { + const onChangeWrapLines = jest.fn(); + render(); + 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(); + 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); }); }); diff --git a/public/app/features/logs/components/log-context/LogContextButtons.tsx b/public/app/features/logs/components/log-context/LogContextButtons.tsx index a7db0994b75..a1d578353a6 100644 --- a/public/app/features/logs/components/log-context/LogContextButtons.tsx +++ b/public/app/features/logs/components/log-context/LogContextButtons.tsx @@ -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> = [ - { 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; - onChangeOption: (item: SelectableValue) => 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) => { - 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 ( - - {position === 'top' && onChangeWrapLines && ( - - - - - - )} - - - ); + return ; }; diff --git a/public/app/features/logs/components/log-context/LogRowContextModal.test.tsx b/public/app/features/logs/components/log-context/LogRowContextModal.test.tsx index 34b958d8111..aec1364f483 100644 --- a/public/app/features/logs/components/log-context/LogRowContextModal.test.tsx +++ b/public/app/features/logs/components/log-context/LogRowContextModal.test.tsx @@ -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( - {}} getRowContext={getRowContext} timeZone={timeZone} /> + {}} + 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( - {}} getRowContext={getRowContext} timeZone={timeZone} /> + {}} + 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( - {}} getRowContext={getRowContext} timeZone={timeZone} /> + {}} + 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( - {}} getRowContext={getRowContext} timeZone={timeZone} /> + {}} + 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( - {}} 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( - {}} - 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( - {}} getRowContext={getRowContext} timeZone={timeZone} /> + {}} + 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} /> ); diff --git a/public/app/features/logs/components/log-context/LogRowContextModal.tsx b/public/app/features/logs/components/log-context/LogRowContextModal.tsx index b2fa2a42cdc..191e81913be 100644 --- a/public/app/features/logs/components/log-context/LogRowContextModal.tsx +++ b/public/app/features/logs/components/log-context/LogRowContextModal.tsx @@ -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; + getRowContext: (row: LogRowModel, options: LogRowContextOptions) => Promise; getRowContextQuery?: (row: LogRowModel, options?: LogRowContextOptions) => Promise; - logsSortOrder?: LogsSortOrder | null; + logsSortOrder: LogsSortOrder; runContextQuery?: () => void; getLogRowContextUi?: DataSourceWithLogsContextSupport['getLogRowContextUi']; } +type Section = { + loadingState: LoadingState; + rows: LogRowModel[]; +}; +type Context = Record; + +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 = ({ row, open, @@ -133,30 +161,54 @@ export const LogRowContextModal: React.FunctionComponent { - const scrollElement = React.createRef(); - const entryElement = React.createRef(); + const scrollElement = useRef(null); + const entryElement = useRef(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(); + const preEntryElement = useRef(null); + + const prevScrollHeightRef = useRef(null); + const prevClientHeightRef = useRef(null); + + const aboveLoadingElement = useRef(null); + const belowLoadingElement = useRef(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(defaultLimit.value!); - const [loadingWidth, setLoadingWidth] = useState(0); - const [loadMoreOption, setLoadMoreOption] = useState>(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(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(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) => { - 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 => { + 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([]); @@ -260,22 +292,106 @@ export const LogRowContextModal: React.FunctionComponent { - 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 ( {config.featureToggles.logsContextDatasourceUi && getLogRowContextUi && ( -
{getLogRowContextUi(row, fetchResults)}
+
{getLogRowContextUi(row, updateResults)}
)}
-
- Showing {context.after.length} lines {logsSortOrder === LogsSortOrder.Ascending ? 'after' : 'before'} match. -
- +
-
- -
+ + + + + +
+ {loadingStateAbove !== LoadingState.Done && loadingStateAbove !== LoadingState.Error && ( +
+ +
+ )} + {loadingStateAbove === LoadingState.Error &&
Error loading log more logs.
} + {loadingStateAbove === LoadingState.Done &&
No more logs available.
} +
- + <> + + +
+ {loadingStateBelow !== LoadingState.Done && loadingStateBelow !== LoadingState.Error && ( +
+ +
+ )} + {loadingStateBelow === LoadingState.Error &&
Error loading log more logs.
} + {loadingStateBelow === LoadingState.Done &&
No more logs available.
}
-
-
- Showing {context.before.length} lines {logsSortOrder === LogsSortOrder.Descending ? 'after' : 'before'} match. -
-