[8.8] [Security Solution][Endpoint][Response Actions] Fix table navigation when trays are expanded (#157777) (#158306)

# Backport

This will backport the following commits from `main` to `8.8`:
- [[Security Solution][Endpoint][Response Actions] Fix table navigation
when trays are expanded
(#157777)](https://github.com/elastic/kibana/pull/157777)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT
[{"author":{"name":"Ashokaditya","email":"1849116+ashokaditya@users.noreply.github.com"},"sourceCommit":{"committedDate":"2023-05-23T18:49:02Z","message":"[Security
Solution][Endpoint][Response Actions] Fix table navigation when trays
are expanded (#157777)\n\n## Summary\r\n\r\nFixes an issue where when an
action detail is shown via an expanded tray\r\nitem and the total number
of items goes beyond the first page on the\r\nresponse actions history
page/flyout, switching between pages while the\r\ntray is open breaks
the page.\r\n\r\n- [x] fix paging with trays expanded on a flyout \r\n-
[x] fix paging with trays expanded on a page\r\n- [x] ensure when the
page is loaded with `?withOutputs=` with action\r\nids from different
sets of pages, table paging doesn't break when paged,\r\nand trays show
open for the action ids in `?withOutputs=` URL param\r\n- tests:\r\n -
[x] page navigation flyout/page view\r\n - [x] page reload with URL
params (cypress)\r\n
\r\n**flyout**\r\n\r\n![response-logs-flyout](b68f6e5d-9d0e-456f-8b07-dea07526571b)\r\n\r\n*page
with URL load*\r\n- three actions are open on two different pages\r\n-
we re-load page 2 with two open trays and then navigate to page 1
to\r\nsee the third one open\r\n- also re-load page 1; we see the tray
open, then navigate to page 2 to\r\nsee the other two trays
open.\r\n\r\n![response-logs-page-reload](58896b2e-4078-42c0-ac6c-c34d5b1cd42b)\r\n\r\n###
Checklist\r\n\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios","sha":"9e01dc815f87ccb70f961852f6d7c014e3e43c0b","branchLabelMapping":{"^v8.9.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:Defend
Workflows","OLM
Sprint","v8.9.0","v8.8.1"],"number":157777,"url":"https://github.com/elastic/kibana/pull/157777","mergeCommit":{"message":"[Security
Solution][Endpoint][Response Actions] Fix table navigation when trays
are expanded (#157777)\n\n## Summary\r\n\r\nFixes an issue where when an
action detail is shown via an expanded tray\r\nitem and the total number
of items goes beyond the first page on the\r\nresponse actions history
page/flyout, switching between pages while the\r\ntray is open breaks
the page.\r\n\r\n- [x] fix paging with trays expanded on a flyout \r\n-
[x] fix paging with trays expanded on a page\r\n- [x] ensure when the
page is loaded with `?withOutputs=` with action\r\nids from different
sets of pages, table paging doesn't break when paged,\r\nand trays show
open for the action ids in `?withOutputs=` URL param\r\n- tests:\r\n -
[x] page navigation flyout/page view\r\n - [x] page reload with URL
params (cypress)\r\n
\r\n**flyout**\r\n\r\n![response-logs-flyout](b68f6e5d-9d0e-456f-8b07-dea07526571b)\r\n\r\n*page
with URL load*\r\n- three actions are open on two different pages\r\n-
we re-load page 2 with two open trays and then navigate to page 1
to\r\nsee the third one open\r\n- also re-load page 1; we see the tray
open, then navigate to page 2 to\r\nsee the other two trays
open.\r\n\r\n![response-logs-page-reload](58896b2e-4078-42c0-ac6c-c34d5b1cd42b)\r\n\r\n###
Checklist\r\n\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios","sha":"9e01dc815f87ccb70f961852f6d7c014e3e43c0b"}},"sourceBranch":"main","suggestedTargetBranches":["8.8"],"targetPullRequestStates":[{"branch":"main","label":"v8.9.0","labelRegex":"^v8.9.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/157777","number":157777,"mergeCommit":{"message":"[Security
Solution][Endpoint][Response Actions] Fix table navigation when trays
are expanded (#157777)\n\n## Summary\r\n\r\nFixes an issue where when an
action detail is shown via an expanded tray\r\nitem and the total number
of items goes beyond the first page on the\r\nresponse actions history
page/flyout, switching between pages while the\r\ntray is open breaks
the page.\r\n\r\n- [x] fix paging with trays expanded on a flyout \r\n-
[x] fix paging with trays expanded on a page\r\n- [x] ensure when the
page is loaded with `?withOutputs=` with action\r\nids from different
sets of pages, table paging doesn't break when paged,\r\nand trays show
open for the action ids in `?withOutputs=` URL param\r\n- tests:\r\n -
[x] page navigation flyout/page view\r\n - [x] page reload with URL
params (cypress)\r\n
\r\n**flyout**\r\n\r\n![response-logs-flyout](b68f6e5d-9d0e-456f-8b07-dea07526571b)\r\n\r\n*page
with URL load*\r\n- three actions are open on two different pages\r\n-
we re-load page 2 with two open trays and then navigate to page 1
to\r\nsee the third one open\r\n- also re-load page 1; we see the tray
open, then navigate to page 2 to\r\nsee the other two trays
open.\r\n\r\n![response-logs-page-reload](58896b2e-4078-42c0-ac6c-c34d5b1cd42b)\r\n\r\n###
Checklist\r\n\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios","sha":"9e01dc815f87ccb70f961852f6d7c014e3e43c0b"}},{"branch":"8.8","label":"v8.8.1","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

Co-authored-by: Ashokaditya <1849116+ashokaditya@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2023-05-26 10:33:20 -04:00 committed by GitHub
parent 8c83d40f58
commit b7375b08d8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 248 additions and 42 deletions

View file

@ -32,6 +32,9 @@ export interface IndexedEndpointAndFleetActionsForHostResponse {
endpointActionResponsesIndex: string;
}
export interface IndexEndpointAndFleetActionsForHostOptions {
numResponseActions?: number;
}
/**
* Indexes a random number of Endpoint (via Fleet) Actions for a given host
* (NOTE: ensure that fleet is setup first before calling this loading function)
@ -43,11 +46,13 @@ export interface IndexedEndpointAndFleetActionsForHostResponse {
export const indexEndpointAndFleetActionsForHost = async (
esClient: Client,
endpointHost: HostMetadata,
fleetActionGenerator: FleetActionGenerator = defaultFleetActionGenerator
fleetActionGenerator: FleetActionGenerator = defaultFleetActionGenerator,
options: IndexEndpointAndFleetActionsForHostOptions = {}
): Promise<IndexedEndpointAndFleetActionsForHostResponse> => {
const ES_INDEX_OPTIONS = { headers: { 'X-elastic-product-origin': 'fleet' } };
const agentId = endpointHost.elastic.agent.id;
const total = fleetActionGenerator.randomN(5) + 1; // generate at least one
const actionsCount = options.numResponseActions ?? 1;
const total = fleetActionGenerator.randomN(5) + actionsCount;
const response: IndexedEndpointAndFleetActionsForHostResponse = {
actions: [],
actionResponses: [],

View file

@ -26,6 +26,7 @@ import type {
import {
deleteIndexedEndpointAndFleetActions,
indexEndpointAndFleetActionsForHost,
type IndexEndpointAndFleetActionsForHostOptions,
} from './index_endpoint_fleet_actions';
import type {
@ -88,6 +89,7 @@ export async function indexEndpointHostDocs({
enrollFleet,
generator,
withResponseActions = true,
numResponseActions,
}: {
numDocs: number;
client: Client;
@ -99,6 +101,7 @@ export async function indexEndpointHostDocs({
enrollFleet: boolean;
generator: EndpointDocGenerator;
withResponseActions?: boolean;
numResponseActions?: IndexEndpointAndFleetActionsForHostOptions['numResponseActions'];
}): Promise<IndexedHostsResponse> {
const timeBetweenDocs = 6 * 3600 * 1000; // 6 hours between metadata documents
const timestamp = new Date().getTime();
@ -198,7 +201,10 @@ export async function indexEndpointHostDocs({
const actionsResponse = await indexEndpointAndFleetActionsForHost(
client,
hostMetadata,
undefined
undefined,
{
numResponseActions,
}
);
mergeAndAppendArrays(response, actionsResponse);
}

View file

@ -64,7 +64,8 @@ export async function indexHostsAndAlerts(
fleet: boolean,
options: TreeOptions = {},
DocGenerator: typeof EndpointDocGenerator = EndpointDocGenerator,
withResponseActions = true
withResponseActions = true,
numResponseActions?: number
): Promise<IndexedHostsAndAlertsResponse> {
const random = seedrandom(seed);
const epmEndpointPackage = await getEndpointPackageInfo(kbnClient);
@ -117,6 +118,7 @@ export async function indexHostsAndAlerts(
enrollFleet: fleet,
generator,
withResponseActions,
numResponseActions,
});
mergeAndAppendArrays(response, indexedHosts);

View file

@ -19,6 +19,7 @@ import {
EuiToolTip,
type HorizontalAlignment,
type CriteriaWithPagination,
EuiSkeletonText,
} from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { FormattedMessage } from '@kbn/i18n-react';
@ -55,12 +56,12 @@ interface ExpandedRowMapType {
const getResponseActionListTableColumns = ({
getTestId,
itemIdToExpandedRowMap,
expandedRowMap,
showHostNames,
onClickCallback,
}: {
getTestId: (suffix?: string | undefined) => string | undefined;
itemIdToExpandedRowMap: ExpandedRowMapType;
expandedRowMap: ExpandedRowMapType;
showHostNames: boolean;
onClickCallback: (actionListDataItem: ActionListApiResponse['data'][number]) => () => void;
}) => {
@ -245,10 +246,8 @@ const getResponseActionListTableColumns = ({
<EuiButtonIcon
data-test-subj={getTestId('expand-button')}
onClick={onClickCallback(actionListDataItem)}
aria-label={
itemIdToExpandedRowMap[actionId] ? ARIA_LABELS.collapse : ARIA_LABELS.expand
}
iconType={itemIdToExpandedRowMap[actionId] ? 'arrowUp' : 'arrowDown'}
aria-label={expandedRowMap[actionId] ? ARIA_LABELS.collapse : ARIA_LABELS.expand}
iconType={expandedRowMap[actionId] ? 'arrowUp' : 'arrowDown'}
/>
);
},
@ -290,13 +289,13 @@ export const ActionsLogTable = memo<ActionsLogTableProps>(
showHostNames,
totalItemCount,
}) => {
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<ExpandedRowMapType>({});
const getTestId = useTestIdGenerator(dataTestSubj);
const { pagination: paginationFromUrlParams } = useUrlPagination();
const { withOutputs: withOutputsFromUrl } = useActionHistoryUrlParams();
const getActionIdsWithDetails = useCallback((): string[] => {
const [expandedRowMap, setExpandedRowMap] = useState<ExpandedRowMapType>({});
const actionIdsWithOpenTrays = useMemo((): string[] => {
// get the list of action ids from URL params on the history page
if (!isFlyout) {
return withOutputsFromUrl ?? [];
@ -309,40 +308,48 @@ export const ActionsLogTable = memo<ActionsLogTableProps>(
: [];
}, [isFlyout, queryParams.withOutputs, withOutputsFromUrl]);
const redoOpenTrays = useCallback(() => {
if (actionIdsWithOpenTrays.length && items.length) {
const openDetails = actionIdsWithOpenTrays.reduce<ExpandedRowMapType>(
(idToRowMap, actionId) => {
const actionItem = items.find((item) => item.id === actionId);
if (!actionItem) {
idToRowMap[actionId] = <EuiSkeletonText size="relative" lines={8} />;
} else {
idToRowMap[actionId] = (
<ActionsLogExpandedTray action={actionItem} data-test-subj={dataTestSubj} />
);
}
return idToRowMap;
},
{}
);
setExpandedRowMap(openDetails);
}
}, [actionIdsWithOpenTrays, dataTestSubj, items]);
// open trays that were open using URL params/ query params
useEffect(() => {
const actionIdsWithDetails = getActionIdsWithDetails();
const openDetails = actionIdsWithDetails.reduce<ExpandedRowMapType>(
(idToRowMap, actionId) => {
idToRowMap[actionId] = (
<ActionsLogExpandedTray
action={items.filter((item) => item.id === actionId)[0]}
data-test-subj={dataTestSubj}
/>
);
return idToRowMap;
},
{}
);
setItemIdToExpandedRowMap(openDetails);
}, [dataTestSubj, getActionIdsWithDetails, items, queryParams.withOutputs, withOutputsFromUrl]);
redoOpenTrays();
}, [redoOpenTrays]);
const toggleDetails = useCallback(
(action: ActionListApiResponse['data'][number]) => {
const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap };
if (itemIdToExpandedRowMapValues[action.id]) {
const expandedRowMapCopy = { ...expandedRowMap };
if (expandedRowMapCopy[action.id]) {
// close tray
delete itemIdToExpandedRowMapValues[action.id];
delete expandedRowMapCopy[action.id];
} else {
// assign the expanded tray content to the map
// with action details
itemIdToExpandedRowMapValues[action.id] = (
expandedRowMapCopy[action.id] = (
<ActionsLogExpandedTray action={action} data-test-subj={dataTestSubj} />
);
}
onShowActionDetails(Object.keys(itemIdToExpandedRowMapValues));
setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues);
onShowActionDetails(Object.keys(expandedRowMapCopy));
setExpandedRowMap(expandedRowMapCopy);
},
[itemIdToExpandedRowMap, onShowActionDetails, dataTestSubj]
[expandedRowMap, onShowActionDetails, dataTestSubj]
);
// memoized callback for toggleDetails
@ -409,11 +416,11 @@ export const ActionsLogTable = memo<ActionsLogTableProps>(
() =>
getResponseActionListTableColumns({
getTestId,
itemIdToExpandedRowMap,
expandedRowMap,
onClickCallback,
showHostNames,
}),
[itemIdToExpandedRowMap, getTestId, onClickCallback, showHostNames]
[expandedRowMap, getTestId, onClickCallback, showHostNames]
);
return (
@ -425,7 +432,7 @@ export const ActionsLogTable = memo<ActionsLogTableProps>(
items={items}
columns={columns}
itemId="id"
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
itemIdToExpandedRowMap={expandedRowMap}
isExpandable
pagination={tablePagination}
onChange={onChange}

View file

@ -462,6 +462,65 @@ describe('Response actions history', () => {
expect(noTrays).toEqual([]);
});
it('should show already expanded trays on page navigation', async () => {
// start with two pages worth of response actions
// 10 on page 1, 3 on page 2
useGetEndpointActionListMock.mockReturnValue({
...getBaseMockedActionList(),
data: await getActionListMock({ actionCount: 13 }),
});
render();
const { getByTestId, getAllByTestId } = renderResult;
// on page 1
expect(getByTestId(`${testPrefix}-endpointListTableTotal`)).toHaveTextContent(
'Showing 1-10 of 13 response actions'
);
const expandButtonsOnPage1 = getAllByTestId(`${testPrefix}-expand-button`);
// expand 2nd, 4th, 6th rows
expandButtonsOnPage1.forEach((button, i) => {
if ([1, 3, 5].includes(i)) {
userEvent.click(button);
}
});
// verify 3 rows are expanded
const traysOnPage1 = getAllByTestId(`${testPrefix}-details-tray`);
expect(traysOnPage1).toBeTruthy();
expect(traysOnPage1.length).toEqual(3);
// go to 2nd page
const page2 = getByTestId('pagination-button-1');
userEvent.click(page2);
// verify on page 2
expect(getByTestId(`${testPrefix}-endpointListTableTotal`)).toHaveTextContent(
'Showing 11-13 of 13 response actions'
);
// go back to 1st page
userEvent.click(getByTestId('pagination-button-0'));
// verify on page 1
expect(getByTestId(`${testPrefix}-endpointListTableTotal`)).toHaveTextContent(
'Showing 1-10 of 13 response actions'
);
const traysOnPage1back = getAllByTestId(`${testPrefix}-details-tray`);
const expandButtonsOnPage1back = getAllByTestId(`${testPrefix}-expand-button`);
const expandedButtons = expandButtonsOnPage1back.reduce<number[]>((acc, button, i) => {
// find expanded rows
if (button.getAttribute('aria-label') === 'Collapse') {
acc.push(i);
}
return acc;
}, []);
// verify 3 rows are expanded
expect(traysOnPage1back).toBeTruthy();
expect(traysOnPage1back.length).toEqual(3);
// verify 3 rows that are expanded are the ones from before
expect(expandedButtons).toEqual([1, 3, 5]);
});
it('should contain relevant details in each expanded row', async () => {
render();
const { getAllByTestId } = renderResult;

View file

@ -226,7 +226,7 @@ export const ResponseActionsLog = memo<
setUrlWithOutputs(actionIds.join());
}
},
[isFlyout, setUrlWithOutputs]
[isFlyout, setUrlWithOutputs, setQueryParams]
);
if (error?.body?.statusCode === 404 && error?.body?.message === 'index_not_found_exception') {

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ReturnTypeFromChainable } from '../../types';
import { indexEndpointHosts } from '../../tasks/index_endpoint_hosts';
import { login } from '../../tasks/login';
describe('Response actions history page', () => {
let endpointData: ReturnTypeFromChainable<typeof indexEndpointHosts>;
// let actionData: ReturnTypeFromChainable<typeof indexActionResponses>;
before(() => {
indexEndpointHosts({ numResponseActions: 11 }).then((indexEndpoints) => {
endpointData = indexEndpoints;
});
});
beforeEach(() => {
login();
});
after(() => {
if (endpointData) {
endpointData.cleanup();
// @ts-expect-error ignore setting to undefined
endpointData = undefined;
}
});
it('retains expanded action details on page reload', () => {
cy.visit(`/app/security/administration/response_actions_history`);
cy.getByTestSubj('response-actions-list-expand-button').eq(3).click(); // 4th row on 1st page
cy.getByTestSubj('response-actions-list-details-tray').should('exist');
cy.url().should('include', 'withOutputs');
// navigate to page 2
cy.getByTestSubj('pagination-button-1').click();
cy.getByTestSubj('response-actions-list-details-tray').should('not.exist');
// reload with URL params on page 2 with existing URL
cy.reload();
cy.getByTestSubj('response-actions-list-details-tray').should('not.exist');
// navigate to page 1
cy.getByTestSubj('pagination-button-0').click();
cy.getByTestSubj('response-actions-list-details-tray').should('exist');
});
});

View file

@ -110,7 +110,14 @@ export const dataLoaders = (
indexEndpointHosts: async (options: IndexEndpointHostsCyTaskOptions = {}) => {
const { kbnClient, esClient } = await stackServicesPromise;
const { count: numHosts, version, os, isolation, withResponseActions } = options;
const {
count: numHosts,
version,
os,
isolation,
withResponseActions,
numResponseActions,
} = options;
return cyLoadEndpointDataHandler(esClient, kbnClient, {
numHosts,
@ -118,6 +125,7 @@ export const dataLoaders = (
os,
isolation,
withResponseActions,
numResponseActions,
});
},

View file

@ -37,6 +37,7 @@ export interface CyLoadEndpointDataOptions
generatorSeed: string;
waitUntilTransformed: boolean;
withResponseActions: boolean;
numResponseActions?: number;
isolation: boolean;
bothIsolatedAndNormalEndpoints?: boolean;
}
@ -63,6 +64,7 @@ export const cyLoadEndpointDataHandler = async (
os,
withResponseActions,
isolation,
numResponseActions,
} = options;
const DocGenerator = EndpointDocGenerator.custom({
@ -91,7 +93,8 @@ export const cyLoadEndpointDataHandler = async (
enableFleetIntegration,
undefined,
DocGenerator,
withResponseActions
withResponseActions,
numResponseActions
);
if (waitUntilTransformed) {

View file

@ -42,7 +42,7 @@ export type ReturnTypeFromChainable<C extends PossibleChainable> = C extends Cyp
: never;
export type IndexEndpointHostsCyTaskOptions = Partial<
{ count: number; withResponseActions: boolean } & Pick<
{ count: number; withResponseActions: boolean; numResponseActions?: number } & Pick<
CyLoadEndpointDataOptions,
'version' | 'os' | 'isolation'
>

View file

@ -343,6 +343,42 @@ describe('Response actions history page', () => {
);
expect(history.location.search).toEqual(`?startDate=${startDate}&endDate=${endDate}`);
});
it('should read and expand actions using `withOutputs`', () => {
const allActionIds = mockUseGetEndpointActionList.data?.data.map((action) => action.id) ?? [];
// select 5 actions to show details
const actionIdsWithDetails = allActionIds.filter((_, i) => [0, 2, 3, 4, 5].includes(i));
reactTestingLibrary.act(() => {
// load page 1 but with expanded actions.
history.push(
`/administration/response_actions_history?withOutputs=${actionIdsWithDetails.join(
','
)}&page=1&pageSize=10`
);
});
const { getByTestId, getAllByTestId } = render();
// verify on page 1
expect(getByTestId(`${testPrefix}-endpointListTableTotal`)).toHaveTextContent(
'Showing 1-10 of 43 response actions'
);
const traysOnPage1 = getAllByTestId(`${testPrefix}-details-tray`);
const expandButtonsOnPage1 = getAllByTestId(`${testPrefix}-expand-button`);
const expandedButtons = expandButtonsOnPage1.reduce<number[]>((acc, button, i) => {
// find expanded rows
if (button.getAttribute('aria-label') === 'Collapse') {
acc.push(i);
}
return acc;
}, []);
// verify 5 rows are expanded
expect(traysOnPage1.length).toEqual(5);
// verify 5 rows that are expanded are the ones from before
expect(expandedButtons).toEqual([0, 2, 3, 4, 5]);
});
});
describe('Set selected/set values to URL params', () => {
@ -442,5 +478,33 @@ describe('Response actions history page', () => {
expect(history.location.search).toEqual('?endDate=now&startDate=now-15m');
});
it('should set actionIds using `withOutputs` to URL params ', async () => {
const allActionIds = mockUseGetEndpointActionList.data?.data.map((action) => action.id) ?? [];
const actionIdsWithDetails = allActionIds
.reduce<string[]>((acc, e, i) => {
if ([0, 1].includes(i)) {
acc.push(e);
}
return acc;
}, [])
.join()
.split(',')
.join('%2C');
render();
const { getAllByTestId } = renderResult;
const expandButtons = getAllByTestId(`${testPrefix}-expand-button`);
// expand some rows
expandButtons.forEach((button, i) => {
if ([0, 1].includes(i)) {
userEvent.click(button);
}
});
// verify 2 rows are expanded and are the ones from before
expect(history.location.search).toEqual(`?withOutputs=${actionIdsWithDetails}`);
});
});
});