[ Security Solution ] Fix Refetch logic with new timeline batching (#205893)

## Summary

PR : https://github.com/elastic/kibana/pull/204034 fixed some issues
with timeline batching. It was not able to fix one of the issue with
`Refetch` logic which exists in `main` ( resulting in a flaky test ) and
causing some tests to fail in `8.16`, `8.17` and `8.x`.

## Issue Description

There are 2 issues with below video:

1. When user updates a status of an alert, the `Refetch` only happens on
the first `batch`. This behaviour is flaky currently. Even if the user
is on nth batch, table will fetch 0th batch and reset the user's page
back to 1.



https://github.com/user-attachments/assets/eaf88a82-0e9b-4743-8b2d-60fd327a2443
     


3. When user clicks `Refresh` manually, then also only first (0th)
`batch` is fetched, which should have rather fetched all the present
batches.




https://github.com/user-attachments/assets/8d578ce3-4f24-4e70-bc3a-ed6ba99167a0



### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.


- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
This commit is contained in:
Jatin Kathuria 2025-02-05 22:12:38 +01:00 committed by GitHub
parent ab7aae4c49
commit 54b4fac705
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 160 additions and 38 deletions

View file

@ -434,6 +434,8 @@ describe('useTimelineEventsHandler', () => {
);
});
expect(mockSearch).toHaveBeenCalledTimes(1);
mockSearch.mockClear();
await loadNextBatch(result);
@ -487,39 +489,142 @@ describe('useTimelineEventsHandler', () => {
});
});
test('should reset batch to 0th when the data is `refetched`', async () => {
const { result } = renderHook((args) => useTimelineEvents(args), {
initialProps: { ...props },
describe('refetching', () => {
/*
* Below are some use cases where refetch is triggered :
*
* - When user triggers a manual refresh of the data
* - When user updates an event, which triggers a refresh of the data
* - For example, when alert status is updated.
* - When user adds a new column
*
*/
test('should fetch first batch again when refetch is triggered', async () => {
const { result } = renderHook((args) => useTimelineEvents(args), {
initialProps: { ...props, timerangeKind: 'absolute' } as UseTimelineEventsProps,
});
await waitFor(() => {
expect(mockSearch).toHaveBeenCalledWith(
expect.objectContaining({ pagination: { activePage: 0, querySize: 25 } })
);
});
mockSearch.mockClear();
act(() => {
result.current[1].refetch();
});
await waitFor(() => {
expect(mockSearch).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ pagination: { activePage: 0, querySize: 25 } })
);
});
});
await waitFor(() => {
expect(mockSearch).toHaveBeenCalledWith(
expect.objectContaining({ pagination: { activePage: 0, querySize: 25 } })
);
test('should fetch first batch again when refetch is triggered with relative timerange', async () => {
const { result } = renderHook((args) => useTimelineEvents(args), {
initialProps: { ...props, timerangeKind: 'relative' } as UseTimelineEventsProps,
});
await waitFor(() => {
expect(mockSearch).toHaveBeenCalledWith(
expect.objectContaining({ pagination: { activePage: 0, querySize: 25 } })
);
});
mockSearch.mockClear();
act(() => {
result.current[1].refetch();
});
await waitFor(() => {
expect(mockSearch).toHaveBeenCalledTimes(1);
expect(mockSearch).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ pagination: { activePage: 0, querySize: 25 } })
);
});
});
mockSearch.mockClear();
test('should fetch first batch again when refetch is triggered when user has already fetched multiple batches', async () => {
const { result } = renderHook((args) => useTimelineEvents(args), {
initialProps: { ...props },
});
await loadNextBatch(result);
await waitFor(() => {
expect(mockSearch).toHaveBeenCalledWith(
expect.objectContaining({ pagination: { activePage: 0, querySize: 25 } })
);
});
await waitFor(() => {
expect(mockSearch).toHaveBeenCalledWith(
expect.objectContaining({ pagination: { activePage: 1, querySize: 25 } })
);
mockSearch.mockClear();
await loadNextBatch(result);
await waitFor(() => {
expect(mockSearch).toHaveBeenCalledWith(
expect.objectContaining({ pagination: { activePage: 1, querySize: 25 } })
);
});
mockSearch.mockClear();
act(() => {
result.current[1].refetch();
});
await waitFor(() => {
expect(mockSearch).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ pagination: { activePage: 0, querySize: 25 } })
);
});
});
});
mockSearch.mockClear();
describe('sort', () => {
test('should fetch first batch again when sort is updated', async () => {
const { result, rerender } = renderHook((args) => useTimelineEvents(args), {
initialProps: { ...props } as UseTimelineEventsProps,
});
act(() => {
result.current[1].refetch();
});
await waitFor(() => {
expect(mockSearch).toHaveBeenCalledWith(
expect.objectContaining({ pagination: { activePage: 0, querySize: 25 } })
);
});
await waitFor(() => {
expect(mockSearch).toHaveBeenCalledTimes(1);
expect(mockSearch).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ pagination: { activePage: 0, querySize: 25 } })
);
act(() => {
result.current[1].loadNextBatch();
});
await waitFor(() => {
expect(result.current[0]).toBe(DataLoadingState.loaded);
expect(mockSearch).toHaveBeenCalledWith(
expect.objectContaining({ pagination: { activePage: 0, querySize: 25 } })
);
});
mockSearch.mockClear();
act(() => {
rerender({
...props,
sort: [...initSortDefault, { ...initSortDefault[0], field: 'event.kind' }],
});
});
await waitFor(() => {
expect(mockSearch).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ pagination: { activePage: 0, querySize: 25 } })
);
});
});
});
@ -636,7 +741,7 @@ describe('useTimelineEventsHandler', () => {
//////////////////////
});
test('should request 0th batch (refetch) when batchSize is changed', async () => {
test('should request 0th batch when batchSize is changed', async () => {
const { result, rerender } = renderHook((args) => useTimelineEvents(args), {
initialProps: { ...props, limit: 5 },
});

View file

@ -6,7 +6,7 @@
*/
import deepEqual from 'fast-deep-equal';
import { isEmpty, noop } from 'lodash/fp';
import { isEmpty } from 'lodash/fp';
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { Subscription } from 'rxjs';
@ -163,7 +163,6 @@ export const useTimelineEventsHandler = ({
const [{ pageName }] = useRouteSpy();
const dispatch = useDispatch();
const { data } = useKibana().services;
const refetch = useRef<inputsModel.Refetch>(noop);
const abortCtrl = useRef(new AbortController());
const searchSubscription$ = useRef(new Subscription());
const [loading, setLoading] = useState<DataLoadingState>(DataLoadingState.loaded);
@ -215,13 +214,6 @@ export const useTimelineEventsHandler = ({
};
}, []);
const refetchGrid = useCallback(() => {
if (refetch.current != null) {
refetch.current();
}
loadBatchHandler(0);
}, [loadBatchHandler]);
useEffect(() => {
// when batch size changes, refetch DataGrid
setActiveBatch(0);
@ -233,7 +225,7 @@ export const useTimelineEventsHandler = ({
dsl: [],
response: [],
},
refetch: refetchGrid,
refetch: () => {},
totalCount: -1,
pageInfo: {
activePage: 0,
@ -353,6 +345,31 @@ export const useTimelineEventsHandler = ({
[pageName, skip, id, activeBatch, startTracking, data.search, dataViewId]
);
const refetchGrid = useCallback(() => {
/*
*
* Trigger search with a new request object to fetch the latest data.
*
*/
const newTimelineRequest: typeof timelineRequest = {
...timelineRequest,
factoryQueryType: TimelineEventsQueries.all,
language,
sort,
fieldRequested: timelineRequest?.fieldRequested ?? fields,
fields: timelineRequest?.fieldRequested ?? fields,
pagination: {
activePage: 0,
querySize: limit,
},
};
setTimelineRequest(newTimelineRequest);
timelineSearch(newTimelineRequest);
setActiveBatch(0);
}, [timelineRequest, timelineSearch, limit, language, sort, fields]);
useEffect(() => {
if (indexNames.length === 0) {
return;
@ -411,7 +428,7 @@ export const useTimelineEventsHandler = ({
* For example, newly requested fields
*
* */
activePage: activeBatch,
activePage: newActiveBatch,
querySize: limit,
};
@ -475,7 +492,7 @@ export const useTimelineEventsHandler = ({
dsl: [],
response: [],
},
refetch: refetchGrid,
refetch: () => {},
totalCount: -1,
pageInfo: {
activePage: 0,
@ -486,7 +503,7 @@ export const useTimelineEventsHandler = ({
refreshedAt: 0,
});
}
}, [filterQuery, id, refetchGrid, loadNextBatch]);
}, [filterQuery, id, loadNextBatch]);
const timelineSearchHandler = useCallback(
async (onNextHandler?: OnNextResponseHandler) => {
@ -560,7 +577,7 @@ export const useTimelineEvents = ({
setEventsPerPage((prev) => {
let result = [...prev];
if (querySize === limit) {
if (querySize === limit && activePage > 0) {
result[activePage] = timelineResponse.events;
} else {
result = [timelineResponse.events];