Skip to content

Commit ea1c672

Browse files
Alejandro Fernández Gómezkibanamachine
andcommitted
[Logs UI] Add pagination to the log stream shared component (#81193)
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
1 parent d283252 commit ea1c672

8 files changed

Lines changed: 299 additions & 73 deletions

File tree

x-pack/plugins/infra/common/http_api/log_entries/entries.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,17 @@ export type LogEntryContext = rt.TypeOf<typeof logEntryContextRT>;
9999
export type LogEntry = rt.TypeOf<typeof logEntryRT>;
100100

101101
export const logEntriesResponseRT = rt.type({
102-
data: rt.type({
103-
entries: rt.array(logEntryRT),
104-
topCursor: rt.union([logEntriesCursorRT, rt.null]),
105-
bottomCursor: rt.union([logEntriesCursorRT, rt.null]),
106-
}),
102+
data: rt.intersection([
103+
rt.type({
104+
entries: rt.array(logEntryRT),
105+
topCursor: rt.union([logEntriesCursorRT, rt.null]),
106+
bottomCursor: rt.union([logEntriesCursorRT, rt.null]),
107+
}),
108+
rt.partial({
109+
hasMoreBefore: rt.boolean,
110+
hasMoreAfter: rt.boolean,
111+
}),
112+
]),
107113
});
108114

109115
export type LogEntriesResponse = rt.TypeOf<typeof logEntriesResponseRT>;

x-pack/plugins/infra/public/components/log_stream/index.tsx

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* you may not use this file except in compliance with the Elastic License.
55
*/
66

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

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

20+
const PAGE_THRESHOLD = 2;
21+
2022
export interface LogStreamProps {
2123
sourceId?: string;
2224
startTimestamp: number;
@@ -58,7 +60,16 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re
5860
});
5961

6062
// Internal state
61-
const { loadingState, entries, fetchEntries } = useLogStream({
63+
const {
64+
loadingState,
65+
pageLoadingState,
66+
entries,
67+
hasMoreBefore,
68+
hasMoreAfter,
69+
fetchEntries,
70+
fetchPreviousEntries,
71+
fetchNextEntries,
72+
} = useLogStream({
6273
sourceId,
6374
startTimestamp,
6475
endTimestamp,
@@ -70,6 +81,8 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re
7081
const isReloading =
7182
isLoadingSourceConfiguration || loadingState === 'uninitialized' || loadingState === 'loading';
7283

84+
const isLoadingMore = pageLoadingState === 'loading';
85+
7386
const columnConfigurations = useMemo(() => {
7487
return sourceConfiguration ? sourceConfiguration.configuration.logColumns : [];
7588
}, [sourceConfiguration]);
@@ -84,13 +97,33 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re
8497
[entries]
8598
);
8699

100+
const parsedHeight = typeof height === 'number' ? `${height}px` : height;
101+
87102
// Component lifetime
88103
useMount(() => {
89104
loadSourceConfiguration();
90105
fetchEntries();
91106
});
92107

93-
const parsedHeight = typeof height === 'number' ? `${height}px` : height;
108+
// Pagination handler
109+
const handlePagination = useCallback(
110+
({ fromScroll, pagesBeforeStart, pagesAfterEnd }) => {
111+
if (!fromScroll) {
112+
return;
113+
}
114+
115+
if (isLoadingMore) {
116+
return;
117+
}
118+
119+
if (pagesBeforeStart < PAGE_THRESHOLD) {
120+
fetchPreviousEntries();
121+
} else if (pagesAfterEnd < PAGE_THRESHOLD) {
122+
fetchNextEntries();
123+
}
124+
},
125+
[isLoadingMore, fetchPreviousEntries, fetchNextEntries]
126+
);
94127

95128
return (
96129
<LogStreamContent height={parsedHeight}>
@@ -101,13 +134,13 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re
101134
scale="medium"
102135
wrap={false}
103136
isReloading={isReloading}
104-
isLoadingMore={false}
105-
hasMoreBeforeStart={false}
106-
hasMoreAfterEnd={false}
137+
isLoadingMore={isLoadingMore}
138+
hasMoreBeforeStart={hasMoreBefore}
139+
hasMoreAfterEnd={hasMoreAfter}
107140
isStreaming={false}
108141
lastLoadedTime={null}
109142
jumpToTarget={noop}
110-
reportVisibleInterval={noop}
143+
reportVisibleInterval={handlePagination}
111144
loadNewerItems={noop}
112145
reloadItems={fetchEntries}
113146
highlightedItem={highlight ?? null}

x-pack/plugins/infra/public/containers/logs/log_entries/index.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -367,16 +367,16 @@ const logEntriesStateReducer = (prevState: LogEntriesStateParams, action: Action
367367
case Action.ReceiveNewEntries:
368368
return {
369369
...prevState,
370-
...action.payload,
370+
entries: action.payload.entries,
371+
topCursor: action.payload.topCursor,
372+
bottomCursor: action.payload.bottomCursor,
371373
centerCursor: getCenterCursor(action.payload.entries),
372374
lastLoadedTime: new Date(),
373375
isReloading: false,
374-
375-
// Be optimistic. If any of the before/after requests comes empty, set
376-
// the corresponding flag to `false`
377-
hasMoreBeforeStart: true,
378-
hasMoreAfterEnd: true,
376+
hasMoreBeforeStart: action.payload.hasMoreBefore ?? prevState.hasMoreBeforeStart,
377+
hasMoreAfterEnd: action.payload.hasMoreAfter ?? prevState.hasMoreAfterEnd,
379378
};
379+
380380
case Action.ReceiveEntriesBefore: {
381381
const newEntries = action.payload.entries;
382382
const prevEntries = cleanDuplicateItems(prevState.entries, newEntries);
@@ -385,7 +385,7 @@ const logEntriesStateReducer = (prevState: LogEntriesStateParams, action: Action
385385
const update = {
386386
entries,
387387
isLoadingMore: false,
388-
hasMoreBeforeStart: newEntries.length > 0,
388+
hasMoreBeforeStart: action.payload.hasMoreBefore ?? prevState.hasMoreBeforeStart,
389389
// Keep the previous cursor if request comes empty, to easily extend the range.
390390
topCursor: newEntries.length > 0 ? action.payload.topCursor : prevState.topCursor,
391391
centerCursor: getCenterCursor(entries),
@@ -402,7 +402,7 @@ const logEntriesStateReducer = (prevState: LogEntriesStateParams, action: Action
402402
const update = {
403403
entries,
404404
isLoadingMore: false,
405-
hasMoreAfterEnd: newEntries.length > 0,
405+
hasMoreAfterEnd: action.payload.hasMoreAfter ?? prevState.hasMoreAfterEnd,
406406
// Keep the previous cursor if request comes empty, to easily extend the range.
407407
bottomCursor: newEntries.length > 0 ? action.payload.bottomCursor : prevState.bottomCursor,
408408
centerCursor: getCenterCursor(entries),
@@ -419,6 +419,8 @@ const logEntriesStateReducer = (prevState: LogEntriesStateParams, action: Action
419419
topCursor: null,
420420
bottomCursor: null,
421421
centerCursor: null,
422+
// Assume there are more pages on both ends unless proven wrong by the
423+
// API with an explicit `false` response.
422424
hasMoreBeforeStart: true,
423425
hasMoreAfterEnd: true,
424426
};

x-pack/plugins/infra/public/containers/logs/log_stream/index.ts

Lines changed: 160 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
* you may not use this file except in compliance with the Elastic License.
55
*/
66

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

2224
interface LogStreamState {
2325
entries: LogEntry[];
26+
topCursor: LogEntriesCursor | null;
27+
bottomCursor: LogEntriesCursor | null;
28+
hasMoreBefore: boolean;
29+
hasMoreAfter: boolean;
30+
}
31+
32+
type LoadingState = 'uninitialized' | 'loading' | 'success' | 'error';
33+
34+
interface LogStreamReturn extends LogStreamState {
2435
fetchEntries: () => void;
25-
loadingState: 'uninitialized' | 'loading' | 'success' | 'error';
36+
fetchPreviousEntries: () => void;
37+
fetchNextEntries: () => void;
38+
loadingState: LoadingState;
39+
pageLoadingState: LoadingState;
2640
}
2741

42+
const INITIAL_STATE: LogStreamState = {
43+
entries: [],
44+
topCursor: null,
45+
bottomCursor: null,
46+
// Assume there are pages available until the API proves us wrong
47+
hasMoreBefore: true,
48+
hasMoreAfter: true,
49+
};
50+
51+
const EMPTY_DATA = {
52+
entries: [],
53+
topCursor: null,
54+
bottomCursor: null,
55+
};
56+
2857
export function useLogStream({
2958
sourceId,
3059
startTimestamp,
3160
endTimestamp,
3261
query,
3362
center,
34-
}: LogStreamProps): LogStreamState {
63+
}: LogStreamProps): LogStreamReturn {
3564
const { services } = useKibanaContextForPlugin();
36-
const [entries, setEntries] = useState<LogStreamState['entries']>([]);
65+
const [state, setState] = useSetState<LogStreamState>(INITIAL_STATE);
66+
67+
// Ensure the pagination keeps working when the timerange gets extended
68+
const prevStartTimestamp = usePrevious(startTimestamp);
69+
const prevEndTimestamp = usePrevious(endTimestamp);
70+
71+
useEffect(() => {
72+
if (prevStartTimestamp && prevStartTimestamp > startTimestamp) {
73+
setState({ hasMoreBefore: true });
74+
}
75+
}, [prevStartTimestamp, startTimestamp, setState]);
76+
77+
useEffect(() => {
78+
if (prevEndTimestamp && prevEndTimestamp < endTimestamp) {
79+
setState({ hasMoreAfter: true });
80+
}
81+
}, [prevEndTimestamp, endTimestamp, setState]);
3782

3883
const parsedQuery = useMemo(() => {
3984
return query
@@ -46,7 +91,7 @@ export function useLogStream({
4691
{
4792
cancelPreviousOn: 'creation',
4893
createPromise: () => {
49-
setEntries([]);
94+
setState(INITIAL_STATE);
5095
const fetchPosition = center ? { center } : { before: 'last' };
5196

5297
return fetchLogEntries(
@@ -61,26 +106,130 @@ export function useLogStream({
61106
);
62107
},
63108
onResolve: ({ data }) => {
64-
setEntries(data.entries);
109+
setState((prevState) => ({
110+
...data,
111+
hasMoreBefore: data.hasMoreBefore ?? prevState.hasMoreBefore,
112+
hasMoreAfter: data.hasMoreAfter ?? prevState.hasMoreAfter,
113+
}));
65114
},
66115
},
67116
[sourceId, startTimestamp, endTimestamp, query]
68117
);
69118

70-
const loadingState = useMemo(() => convertPromiseStateToLoadingState(entriesPromise.state), [
71-
entriesPromise.state,
72-
]);
119+
const [previousEntriesPromise, fetchPreviousEntries] = useTrackedPromise(
120+
{
121+
cancelPreviousOn: 'creation',
122+
createPromise: () => {
123+
if (state.topCursor === null) {
124+
throw new Error(
125+
'useLogState: Cannot fetch previous entries. No cursor is set.\nEnsure you have called `fetchEntries` at least once.'
126+
);
127+
}
128+
129+
if (!state.hasMoreBefore) {
130+
return Promise.resolve({ data: EMPTY_DATA });
131+
}
132+
133+
return fetchLogEntries(
134+
{
135+
sourceId,
136+
startTimestamp,
137+
endTimestamp,
138+
query: parsedQuery,
139+
before: state.topCursor,
140+
},
141+
services.http.fetch
142+
);
143+
},
144+
onResolve: ({ data }) => {
145+
if (!data.entries.length) {
146+
return;
147+
}
148+
setState((prevState) => ({
149+
entries: [...data.entries, ...prevState.entries],
150+
hasMoreBefore: data.hasMoreBefore ?? prevState.hasMoreBefore,
151+
topCursor: data.topCursor ?? prevState.topCursor,
152+
}));
153+
},
154+
},
155+
[sourceId, startTimestamp, endTimestamp, query, state.topCursor]
156+
);
157+
158+
const [nextEntriesPromise, fetchNextEntries] = useTrackedPromise(
159+
{
160+
cancelPreviousOn: 'creation',
161+
createPromise: () => {
162+
if (state.bottomCursor === null) {
163+
throw new Error(
164+
'useLogState: Cannot fetch next entries. No cursor is set.\nEnsure you have called `fetchEntries` at least once.'
165+
);
166+
}
167+
168+
if (!state.hasMoreAfter) {
169+
return Promise.resolve({ data: EMPTY_DATA });
170+
}
171+
172+
return fetchLogEntries(
173+
{
174+
sourceId,
175+
startTimestamp,
176+
endTimestamp,
177+
query: parsedQuery,
178+
after: state.bottomCursor,
179+
},
180+
services.http.fetch
181+
);
182+
},
183+
onResolve: ({ data }) => {
184+
if (!data.entries.length) {
185+
return;
186+
}
187+
setState((prevState) => ({
188+
entries: [...prevState.entries, ...data.entries],
189+
hasMoreAfter: data.hasMoreAfter ?? prevState.hasMoreAfter,
190+
bottomCursor: data.bottomCursor ?? prevState.bottomCursor,
191+
}));
192+
},
193+
},
194+
[sourceId, startTimestamp, endTimestamp, query, state.bottomCursor]
195+
);
196+
197+
const loadingState = useMemo<LoadingState>(
198+
() => convertPromiseStateToLoadingState(entriesPromise.state),
199+
[entriesPromise.state]
200+
);
201+
202+
const pageLoadingState = useMemo<LoadingState>(() => {
203+
const states = [previousEntriesPromise.state, nextEntriesPromise.state];
204+
205+
if (states.includes('pending')) {
206+
return 'loading';
207+
}
208+
209+
if (states.includes('rejected')) {
210+
return 'error';
211+
}
212+
213+
if (states.includes('resolved')) {
214+
return 'success';
215+
}
216+
217+
return 'uninitialized';
218+
}, [previousEntriesPromise.state, nextEntriesPromise.state]);
73219

74220
return {
75-
entries,
221+
...state,
76222
fetchEntries,
223+
fetchPreviousEntries,
224+
fetchNextEntries,
77225
loadingState,
226+
pageLoadingState,
78227
};
79228
}
80229

81230
function convertPromiseStateToLoadingState(
82231
state: 'uninitialized' | 'pending' | 'resolved' | 'rejected'
83-
): LogStreamState['loadingState'] {
232+
): LoadingState {
84233
switch (state) {
85234
case 'uninitialized':
86235
return 'uninitialized';

0 commit comments

Comments
 (0)