Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Logs UI] Add pagination to the log stream shared component #81193

Merged
merged 15 commits into from
Nov 12, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions x-pack/plugins/infra/common/http_api/log_entries/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,17 @@ export type LogEntryContext = rt.TypeOf<typeof logEntryContextRT>;
export type LogEntry = rt.TypeOf<typeof logEntryRT>;

export const logEntriesResponseRT = rt.type({
data: rt.type({
entries: rt.array(logEntryRT),
topCursor: rt.union([logEntriesCursorRT, rt.null]),
bottomCursor: rt.union([logEntriesCursorRT, rt.null]),
}),
data: rt.intersection([
rt.type({
entries: rt.array(logEntryRT),
topCursor: rt.union([logEntriesCursorRT, rt.null]),
bottomCursor: rt.union([logEntriesCursorRT, rt.null]),
}),
rt.partial({
hasMoreBefore: rt.boolean,
hasMoreAfter: rt.boolean,
}),
]),
});

export type LogEntriesResponse = rt.TypeOf<typeof logEntriesResponseRT>;
47 changes: 40 additions & 7 deletions x-pack/plugins/infra/public/components/log_stream/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/

import React, { useMemo } from 'react';
import React, { useMemo, useCallback } from 'react';
import { noop } from 'lodash';
import useMount from 'react-use/lib/useMount';
import { euiStyled } from '../../../../observability/public';
Expand All @@ -17,6 +17,8 @@ import { useLogStream } from '../../containers/logs/log_stream';

import { ScrollableLogTextStreamView } from '../logging/log_text_stream';

const PAGE_THRESHOLD = 2;

export interface LogStreamProps {
sourceId?: string;
startTimestamp: number;
Expand Down Expand Up @@ -58,7 +60,16 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re
});

// Internal state
const { loadingState, entries, fetchEntries } = useLogStream({
const {
loadingState,
pageLoadingState,
entries,
hasMoreBefore,
hasMoreAfter,
fetchEntries,
fetchPreviousEntries,
fetchNextEntries,
} = useLogStream({
sourceId,
startTimestamp,
endTimestamp,
Expand All @@ -70,6 +81,8 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re
const isReloading =
isLoadingSourceConfiguration || loadingState === 'uninitialized' || loadingState === 'loading';

const isLoadingMore = pageLoadingState === 'loading';

const columnConfigurations = useMemo(() => {
return sourceConfiguration ? sourceConfiguration.configuration.logColumns : [];
}, [sourceConfiguration]);
Expand All @@ -84,13 +97,33 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re
[entries]
);

const parsedHeight = typeof height === 'number' ? `${height}px` : height;

// Component lifetime
useMount(() => {
loadSourceConfiguration();
fetchEntries();
});

const parsedHeight = typeof height === 'number' ? `${height}px` : height;
// Pagination handler
const handlePagination = useCallback(
({ fromScroll, pagesBeforeStart, pagesAfterEnd }) => {
if (!fromScroll) {
return;
}

if (isLoadingMore) {
return;
}

if (pagesBeforeStart < PAGE_THRESHOLD) {
fetchPreviousEntries();
} else if (pagesAfterEnd < PAGE_THRESHOLD) {
fetchNextEntries();
}
},
[isLoadingMore, fetchPreviousEntries, fetchNextEntries]
);

return (
<LogStreamContent height={parsedHeight}>
Expand All @@ -101,13 +134,13 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re
scale="medium"
wrap={false}
isReloading={isReloading}
isLoadingMore={false}
hasMoreBeforeStart={false}
hasMoreAfterEnd={false}
isLoadingMore={isLoadingMore}
hasMoreBeforeStart={hasMoreBefore}
hasMoreAfterEnd={hasMoreAfter}
isStreaming={false}
lastLoadedTime={null}
jumpToTarget={noop}
reportVisibleInterval={noop}
reportVisibleInterval={handlePagination}
loadNewerItems={noop}
reloadItems={fetchEntries}
highlightedItem={highlight ?? null}
Expand Down
18 changes: 10 additions & 8 deletions x-pack/plugins/infra/public/containers/logs/log_entries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,16 +367,16 @@ const logEntriesStateReducer = (prevState: LogEntriesStateParams, action: Action
case Action.ReceiveNewEntries:
return {
...prevState,
...action.payload,
entries: action.payload.entries,
topCursor: action.payload.topCursor,
bottomCursor: action.payload.bottomCursor,
centerCursor: getCenterCursor(action.payload.entries),
lastLoadedTime: new Date(),
isReloading: false,

// Be optimistic. If any of the before/after requests comes empty, set
// the corresponding flag to `false`
hasMoreBeforeStart: true,
hasMoreAfterEnd: true,
hasMoreBeforeStart: action.payload.hasMoreBefore ?? prevState.hasMoreBeforeStart,
hasMoreAfterEnd: action.payload.hasMoreAfter ?? prevState.hasMoreAfterEnd,
};

case Action.ReceiveEntriesBefore: {
const newEntries = action.payload.entries;
const prevEntries = cleanDuplicateItems(prevState.entries, newEntries);
Expand All @@ -385,7 +385,7 @@ const logEntriesStateReducer = (prevState: LogEntriesStateParams, action: Action
const update = {
entries,
isLoadingMore: false,
hasMoreBeforeStart: newEntries.length > 0,
hasMoreBeforeStart: action.payload.hasMoreBefore ?? prevState.hasMoreBeforeStart,
// Keep the previous cursor if request comes empty, to easily extend the range.
topCursor: newEntries.length > 0 ? action.payload.topCursor : prevState.topCursor,
centerCursor: getCenterCursor(entries),
Expand All @@ -402,7 +402,7 @@ const logEntriesStateReducer = (prevState: LogEntriesStateParams, action: Action
const update = {
entries,
isLoadingMore: false,
hasMoreAfterEnd: newEntries.length > 0,
hasMoreAfterEnd: action.payload.hasMoreAfter ?? prevState.hasMoreAfterEnd,
// Keep the previous cursor if request comes empty, to easily extend the range.
bottomCursor: newEntries.length > 0 ? action.payload.bottomCursor : prevState.bottomCursor,
centerCursor: getCenterCursor(entries),
Expand All @@ -419,6 +419,8 @@ const logEntriesStateReducer = (prevState: LogEntriesStateParams, action: Action
topCursor: null,
bottomCursor: null,
centerCursor: null,
// Assume there are more pages on both ends unless proven wrong by the
// API with an explicit `false` response.
hasMoreBeforeStart: true,
hasMoreAfterEnd: true,
};
Expand Down
171 changes: 160 additions & 11 deletions x-pack/plugins/infra/public/containers/logs/log_stream/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { useState, useMemo } from 'react';
import { useMemo, useEffect } from 'react';
import useSetState from 'react-use/lib/useSetState';
import usePrevious from 'react-use/lib/usePrevious';
import { esKuery } from '../../../../../../../src/plugins/data/public';
import { fetchLogEntries } from '../log_entries/api/fetch_log_entries';
import { useTrackedPromise } from '../../../utils/use_tracked_promise';
Expand All @@ -21,19 +23,62 @@ interface LogStreamProps {

interface LogStreamState {
entries: LogEntry[];
topCursor: LogEntriesCursor | null;
bottomCursor: LogEntriesCursor | null;
hasMoreBefore: boolean;
hasMoreAfter: boolean;
}

type LoadingState = 'uninitialized' | 'loading' | 'success' | 'error';

interface LogStreamReturn extends LogStreamState {
fetchEntries: () => void;
loadingState: 'uninitialized' | 'loading' | 'success' | 'error';
fetchPreviousEntries: () => void;
fetchNextEntries: () => void;
loadingState: LoadingState;
pageLoadingState: LoadingState;
}

const INITIAL_STATE: LogStreamState = {
entries: [],
topCursor: null,
bottomCursor: null,
// Assume there are pages available until the API proves us wrong
hasMoreBefore: true,
hasMoreAfter: true,
};

const EMPTY_DATA = {
entries: [],
topCursor: null,
bottomCursor: null,
};

export function useLogStream({
sourceId,
startTimestamp,
endTimestamp,
query,
center,
}: LogStreamProps): LogStreamState {
}: LogStreamProps): LogStreamReturn {
const { services } = useKibanaContextForPlugin();
const [entries, setEntries] = useState<LogStreamState['entries']>([]);
const [state, setState] = useSetState<LogStreamState>(INITIAL_STATE);

// Ensure the pagination keeps working when the timerange gets extended
const prevStartTimestamp = usePrevious(startTimestamp);
const prevEndTimestamp = usePrevious(endTimestamp);
Comment on lines +68 to +69
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice, I love the expressiveness of usePrevious!


useEffect(() => {
if (prevStartTimestamp && prevStartTimestamp > startTimestamp) {
setState({ hasMoreBefore: true });
}
}, [prevStartTimestamp, startTimestamp, setState]);

useEffect(() => {
if (prevEndTimestamp && prevEndTimestamp < endTimestamp) {
setState({ hasMoreAfter: true });
}
}, [prevEndTimestamp, endTimestamp, setState]);

const parsedQuery = useMemo(() => {
return query
Expand All @@ -46,7 +91,7 @@ export function useLogStream({
{
cancelPreviousOn: 'creation',
createPromise: () => {
setEntries([]);
setState(INITIAL_STATE);
const fetchPosition = center ? { center } : { before: 'last' };

return fetchLogEntries(
Expand All @@ -61,26 +106,130 @@ export function useLogStream({
);
},
onResolve: ({ data }) => {
setEntries(data.entries);
setState((prevState) => ({
...data,
hasMoreBefore: data.hasMoreBefore ?? prevState.hasMoreBefore,
hasMoreAfter: data.hasMoreAfter ?? prevState.hasMoreAfter,
}));
},
},
[sourceId, startTimestamp, endTimestamp, query]
);

const loadingState = useMemo(() => convertPromiseStateToLoadingState(entriesPromise.state), [
entriesPromise.state,
]);
const [previousEntriesPromise, fetchPreviousEntries] = useTrackedPromise(
{
cancelPreviousOn: 'creation',
createPromise: () => {
if (state.topCursor === null) {
throw new Error(
'useLogState: Cannot fetch previous entries. No cursor is set.\nEnsure you have called `fetchEntries` at least once.'
);
}

if (!state.hasMoreBefore) {
return Promise.resolve({ data: EMPTY_DATA });
}

return fetchLogEntries(
{
sourceId,
startTimestamp,
endTimestamp,
query: parsedQuery,
before: state.topCursor,
},
services.http.fetch
);
},
onResolve: ({ data }) => {
if (!data.entries.length) {
return;
}
setState((prevState) => ({
entries: [...data.entries, ...prevState.entries],
hasMoreBefore: data.hasMoreBefore ?? prevState.hasMoreBefore,
topCursor: data.topCursor ?? prevState.topCursor,
}));
},
},
[sourceId, startTimestamp, endTimestamp, query, state.topCursor]
);

const [nextEntriesPromise, fetchNextEntries] = useTrackedPromise(
{
cancelPreviousOn: 'creation',
createPromise: () => {
if (state.bottomCursor === null) {
throw new Error(
'useLogState: Cannot fetch next entries. No cursor is set.\nEnsure you have called `fetchEntries` at least once.'
);
}

if (!state.hasMoreAfter) {
return Promise.resolve({ data: EMPTY_DATA });
}

return fetchLogEntries(
{
sourceId,
startTimestamp,
endTimestamp,
query: parsedQuery,
after: state.bottomCursor,
},
services.http.fetch
);
},
onResolve: ({ data }) => {
if (!data.entries.length) {
return;
}
setState((prevState) => ({
entries: [...prevState.entries, ...data.entries],
hasMoreAfter: data.hasMoreAfter ?? prevState.hasMoreAfter,
bottomCursor: data.bottomCursor ?? prevState.bottomCursor,
}));
},
},
[sourceId, startTimestamp, endTimestamp, query, state.bottomCursor]
);

const loadingState = useMemo<LoadingState>(
() => convertPromiseStateToLoadingState(entriesPromise.state),
[entriesPromise.state]
);

const pageLoadingState = useMemo<LoadingState>(() => {
const states = [previousEntriesPromise.state, nextEntriesPromise.state];

if (states.includes('pending')) {
return 'loading';
}

if (states.includes('rejected')) {
return 'error';
}

if (states.includes('resolved')) {
return 'success';
}

return 'uninitialized';
}, [previousEntriesPromise.state, nextEntriesPromise.state]);

return {
entries,
...state,
fetchEntries,
fetchPreviousEntries,
fetchNextEntries,
loadingState,
pageLoadingState,
};
}

function convertPromiseStateToLoadingState(
state: 'uninitialized' | 'pending' | 'resolved' | 'rejected'
): LogStreamState['loadingState'] {
): LoadingState {
switch (state) {
case 'uninitialized':
return 'uninitialized';
Expand Down
Loading