diff --git a/fleet_packages.json b/fleet_packages.json index c4a7f87127f10..4651e86287588 100644 --- a/fleet_packages.json +++ b/fleet_packages.json @@ -20,7 +20,7 @@ [ { "name": "apm", - "version": "8.4.0", + "version": "8.6.0-preview-1663775281", "forceAlignStackVersion": true }, { @@ -29,7 +29,7 @@ }, { "name": "endpoint", - "version": "8.4.1" + "version": "8.5.0" }, { "name": "fleet_server", @@ -39,4 +39,4 @@ "name": "synthetics", "version": "0.10.2" } -] +] \ No newline at end of file diff --git a/x-pack/plugins/cases/public/components/user_profiles/no_matches.test.tsx b/x-pack/plugins/cases/public/components/user_profiles/no_matches.test.tsx index 3471aad3fec3c..e2d23dfd988e1 100644 --- a/x-pack/plugins/cases/public/components/user_profiles/no_matches.test.tsx +++ b/x-pack/plugins/cases/public/components/user_profiles/no_matches.test.tsx @@ -13,6 +13,6 @@ describe('NoMatches', () => { it('renders the no matches messages', () => { render(); - expect(screen.getByText('No matching users with required access.')); + expect(screen.getByText("User doesn't exist or is unavailable")); }); }); diff --git a/x-pack/plugins/cases/public/components/user_profiles/no_matches.tsx b/x-pack/plugins/cases/public/components/user_profiles/no_matches.tsx index 638d705fade86..ccd27b123f235 100644 --- a/x-pack/plugins/cases/public/components/user_profiles/no_matches.tsx +++ b/x-pack/plugins/cases/public/components/user_profiles/no_matches.tsx @@ -5,7 +5,15 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer, EuiText, EuiTextAlign } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLink, + EuiSpacer, + EuiText, + EuiTextAlign, +} from '@elastic/eui'; import React from 'react'; import * as i18n from './translations'; @@ -25,11 +33,18 @@ const NoMatchesComponent: React.FC = () => { - {i18n.NO_MATCHING_USERS} + {i18n.USER_DOES_NOT_EXIST}
- {i18n.TRY_MODIFYING_SEARCH} + {i18n.MODIFY_SEARCH} +
+ + {i18n.LEARN_PRIVILEGES_GRANT_ACCESS} +
diff --git a/x-pack/plugins/cases/public/components/user_profiles/translations.ts b/x-pack/plugins/cases/public/components/user_profiles/translations.ts index db76408759bec..2624ec834cd2e 100644 --- a/x-pack/plugins/cases/public/components/user_profiles/translations.ts +++ b/x-pack/plugins/cases/public/components/user_profiles/translations.ts @@ -44,12 +44,19 @@ export const ASSIGNEES = i18n.translate('xpack.cases.userProfile.assigneesTitle' defaultMessage: 'Assignees', }); -export const NO_MATCHING_USERS = i18n.translate('xpack.cases.userProfiles.noMatchingUsers', { - defaultMessage: 'No matching users with required access.', +export const USER_DOES_NOT_EXIST = i18n.translate('xpack.cases.userProfiles.userDoesNotExist', { + defaultMessage: "User doesn't exist or is unavailable", }); -export const TRY_MODIFYING_SEARCH = i18n.translate('xpack.cases.userProfiles.tryModifyingSearch', { - defaultMessage: 'Try modifying your search.', +export const LEARN_PRIVILEGES_GRANT_ACCESS = i18n.translate( + 'xpack.cases.userProfiles.learnPrivileges', + { + defaultMessage: 'Learn what privileges grant access to cases.', + } +); + +export const MODIFY_SEARCH = i18n.translate('xpack.cases.userProfiles.modifySearch', { + defaultMessage: "Modify your search or check the user's privileges.", }); export const INVALID_ASSIGNEES = i18n.translate('xpack.cases.create.invalidAssignees', { diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines_card.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines_card.tsx index ca6140b7b9625..7bf1ef06e1e75 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines_card.tsx @@ -26,6 +26,7 @@ import { KibanaLogic } from '../../../../shared/kibana'; import { LicensingLogic } from '../../../../shared/licensing'; import { CreateCustomPipelineApiLogic } from '../../../api/index/create_custom_pipeline_api_logic'; import { FetchCustomPipelineApiLogic } from '../../../api/index/fetch_custom_pipeline_api_logic'; +import { isApiIndex } from '../../../utils/indices'; import { CurlRequest } from '../components/curl_request/curl_request'; import { IndexViewLogic } from '../index_view_logic'; @@ -36,7 +37,7 @@ import { PipelinesLogic } from './pipelines_logic'; export const IngestPipelinesCard: React.FC = () => { const { indexName } = useValues(IndexViewLogic); - const { canSetPipeline, pipelineState, showModal } = useValues(PipelinesLogic); + const { canSetPipeline, index, pipelineState, showModal } = useValues(PipelinesLogic); const { closeModal, openModal, setPipelineState, savePipeline } = useActions(PipelinesLogic); const { makeRequest: fetchCustomPipeline } = useActions(FetchCustomPipelineApiLogic); const { makeRequest: createCustomPipeline } = useActions(CreateCustomPipelineApiLogic); @@ -97,22 +98,24 @@ export const IngestPipelinesCard: React.FC = () => { - - - - - - + + {isApiIndex(index) && ( + + + + + + )} {i18n.translate( diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts index c0dcc2dbdb0a9..0aaf30ef126d4 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts @@ -114,7 +114,7 @@ export function registerConnectorRoutes({ router, log }: RouteDependencies) { connectorId: schema.string(), }), body: schema.object({ - nextSyncConfig: schema.string(), + nextSyncConfig: schema.maybe(schema.string()), }), }, }, diff --git a/x-pack/plugins/ml/public/application/trained_models/nodes_overview/allocated_models.tsx b/x-pack/plugins/ml/public/application/trained_models/nodes_overview/allocated_models.tsx index 0da7778c6e2bc..d1808c2c82135 100644 --- a/x-pack/plugins/ml/public/application/trained_models/nodes_overview/allocated_models.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/nodes_overview/allocated_models.tsx @@ -190,6 +190,7 @@ export const AllocatedModels: FC = ({ })} onTableChange={() => {}} data-test-subj={'mlNodesAllocatedModels'} + css={{ overflow: 'auto' }} /> ); }; diff --git a/x-pack/plugins/ml/public/application/trained_models/nodes_overview/expanded_row.tsx b/x-pack/plugins/ml/public/application/trained_models/nodes_overview/expanded_row.tsx index 4c96904862786..a6b6adc4fbc20 100644 --- a/x-pack/plugins/ml/public/application/trained_models/nodes_overview/expanded_row.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/nodes_overview/expanded_row.tsx @@ -17,6 +17,7 @@ import { import { FormattedMessage } from '@kbn/i18n-react'; import { cloneDeep } from 'lodash'; import { FIELD_FORMAT_IDS } from '@kbn/field-formats-plugin/common'; +import { css } from '@emotion/react'; import { NodeItem } from './nodes_list'; import { useListItemsFormatter } from '../models_management/expanded_row'; import { AllocatedModels } from './allocated_models'; @@ -44,10 +45,12 @@ export const ExpandedRow: FC = ({ item }) => { attributes['ml.max_jvm_size'] = bytesFormatter(attributes['ml.max_jvm_size']); return ( - <> - - - +
+ @@ -85,25 +88,26 @@ export const ExpandedRow: FC = ({ item }) => { /> + - {allocatedModels.length > 0 ? ( - - - -
- -
-
- + {allocatedModels.length > 0 ? ( + <> + + + +
+ +
+
+ - -
-
- ) : null} - - + + + + ) : null} +
); }; diff --git a/x-pack/plugins/observability/server/ui_settings.ts b/x-pack/plugins/observability/server/ui_settings.ts index 69e7f0ba51af6..a5b111569e311 100644 --- a/x-pack/plugins/observability/server/ui_settings.ts +++ b/x-pack/plugins/observability/server/ui_settings.ts @@ -29,11 +29,16 @@ import { const technicalPreviewLabel = i18n.translate( 'xpack.observability.uiSettings.technicalPreviewLabel', - { - defaultMessage: 'technical preview', - } + { defaultMessage: 'technical preview' } ); +function feedbackLink({ href }: { href: string }) { + return `${i18n.translate( + 'xpack.observability.uiSettings.giveFeedBackLabel', + { defaultMessage: 'Give feedback' } + )}`; +} + type UiSettings = UiSettingsParams & { showInLabs?: boolean }; /** @@ -167,12 +172,7 @@ export const uiSettings: Record = { '{technicalPreviewLabel} Enable the Service groups feature on APM UI. {feedbackLink}.', values: { technicalPreviewLabel: `[${technicalPreviewLabel}]`, - feedbackLink: - '' + - i18n.translate('xpack.observability.enableServiceGroups.feedbackLinkText', { - defaultMessage: 'Give feedback', - }) + - '', + feedbackLink: feedbackLink({ href: 'https://ela.st/feedback-service-groups' }), }, }), schema: schema.boolean(), @@ -206,15 +206,7 @@ export const uiSettings: Record = { '{technicalPreviewLabel} Default APM Service Inventory page sort (for Services without Machine Learning applied) to sort by Service Name. {feedbackLink}.', values: { technicalPreviewLabel: `[${technicalPreviewLabel}]`, - feedbackLink: - '' + - i18n.translate( - 'xpack.observability.apmServiceInventoryOptimizedSorting.feedbackLinkText', - { - defaultMessage: 'Give feedback', - } - ) + - '', + feedbackLink: feedbackLink({ href: 'https://ela.st/feedback-apm-page-performance' }), }, } ), @@ -245,12 +237,7 @@ export const uiSettings: Record = { '{technicalPreviewLabel} Enable the APM Trace Explorer feature, that allows you to search and inspect traces with KQL or EQL. {feedbackLink}.', values: { technicalPreviewLabel: `[${technicalPreviewLabel}]`, - feedbackLink: - '' + - i18n.translate('xpack.observability.apmTraceExplorerTabDescription.feedbackLinkText', { - defaultMessage: 'Give feedback', - }) + - '', + feedbackLink: feedbackLink({ href: 'https://ela.st/feedback-trace-explorer' }), }, }), schema: schema.boolean(), @@ -269,12 +256,7 @@ export const uiSettings: Record = { '{technicalPreviewLabel} Enable the APM Operations Breakdown feature, that displays aggregates for backend operations. {feedbackLink}.', values: { technicalPreviewLabel: `[${technicalPreviewLabel}]`, - feedbackLink: - '' + - i18n.translate('xpack.observability.apmOperationsBreakdownDescription.feedbackLinkText', { - defaultMessage: 'Give feedback', - }) + - '', + feedbackLink: feedbackLink({ href: 'https://ela.st/feedback-operations-breakdown' }), }, }), schema: schema.boolean(), @@ -318,12 +300,7 @@ export const uiSettings: Record = { '{technicalPreviewLabel} Display Amazon Lambda metrics in the service metrics tab. {feedbackLink}', values: { technicalPreviewLabel: `[${technicalPreviewLabel}]`, - feedbackLink: - '' + - i18n.translate('xpack.observability.awsLambdaDescription', { - defaultMessage: 'Send feedback', - }) + - '', + feedbackLink: feedbackLink({ href: 'https://ela.st/feedback-aws-lambda' }), }, }), schema: schema.boolean(), diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts index 08adac7c9ede0..8b982e6e6f463 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts @@ -28,6 +28,8 @@ export const NoParametersRequestSchema = { body: schema.object({ ...BaseActionRequestSchema }), }; +export type BaseActionRequestBody = TypeOf; + export const KillOrSuspendProcessRequestSchema = { body: schema.object({ ...BaseActionRequestSchema, diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index bcbdcfb3b66b8..91eb10c5f45a2 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -253,6 +253,7 @@ export interface PendingActionsResponse { } export type PendingActionsRequestQuery = TypeOf; + export interface ActionDetails { /** The action id */ id: string; diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.test.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.test.tsx index c12643a30f943..b6a3995534d1f 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.test.tsx @@ -5,16 +5,24 @@ * 2.0. */ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { shallow } from 'enzyme'; import React from 'react'; import type { DraggableStateSnapshot, DraggingStyle } from 'react-beautiful-dnd'; -import { waitFor } from '@testing-library/react'; + import '../../mock/match_media'; +import { TimelineId } from '../../../../common/types'; import { mockBrowserFields } from '../../containers/source/mock'; import { TestProviders } from '../../mock'; import { mockDataProviders } from '../../../timelines/components/timeline/data_providers/mock/mock_data_providers'; +import { ROW_RENDERER_BROWSER_EXAMPLE_TIMELINE_ID } from '../../../timelines/components/row_renderers_browser/constants'; import { DragDropContextWrapper } from './drag_drop_context_wrapper'; -import { ConditionalPortal, DraggableWrapper, getStyle } from './draggable_wrapper'; +import { + ConditionalPortal, + disableHoverActions, + DraggableWrapper, + getStyle, +} from './draggable_wrapper'; import { useMountAppended } from '../../utils/use_mount_appended'; jest.mock('../../lib/kibana'); @@ -27,6 +35,26 @@ jest.mock('@elastic/eui', () => { }; }); +const timelineIdsWithHoverActions = [ + undefined, + TimelineId.active, + TimelineId.alternateTest, + TimelineId.casePage, + TimelineId.detectionsPage, + TimelineId.detectionsRulesDetailsPage, + TimelineId.hostsPageEvents, + TimelineId.hostsPageSessions, + TimelineId.kubernetesPageSessions, + TimelineId.networkPageEvents, + TimelineId.test, + TimelineId.usersPageEvents, +]; + +const timelineIdsNoHoverActions = [ + TimelineId.rulePreview, + ROW_RENDERER_BROWSER_EXAMPLE_TIMELINE_ID, +]; + describe('DraggableWrapper', () => { const dataProvider = mockDataProviders[0]; const message = 'draggable wrapper content'; @@ -36,6 +64,15 @@ describe('DraggableWrapper', () => { jest.useFakeTimers(); }); + afterEach(() => { + const portal = document.querySelector('[data-euiportal="true"]'); + if (portal != null) { + portal.innerHTML = ''; + } + + jest.useRealTimers(); + }); + describe('rendering', () => { test('it renders against the snapshot', () => { const wrapper = shallow( @@ -103,6 +140,56 @@ describe('DraggableWrapper', () => { expect(wrapper.find('[data-test-subj="hover-actions-copy-button"]').exists()).toBe(true); }); }); + + timelineIdsWithHoverActions.forEach((timelineId) => { + test(`it renders hover actions (by default) when 'isDraggable' is false and timelineId is '${timelineId}'`, async () => { + const isDraggable = false; + + const { container } = render( + + + message} + timelineId={timelineId} + /> + + + ); + + fireEvent.mouseEnter(container.querySelector('[data-test-subj="withHoverActionsButton"]')!); + + await waitFor(() => { + expect(screen.getByTestId('hover-actions-copy-button')).toBeInTheDocument(); + }); + }); + }); + + timelineIdsNoHoverActions.forEach((timelineId) => { + test(`it does NOT render hover actions when 'isDraggable' is false and timelineId is '${timelineId}'`, async () => { + const isDraggable = false; + + const { container } = render( + + + message} + timelineId={timelineId} + /> + + + ); + + fireEvent.mouseEnter(container.querySelector('[data-test-subj="withHoverActionsButton"]')!); + + await waitFor(() => { + expect(screen.queryByTestId('hover-actions-copy-button')).not.toBeInTheDocument(); + }); + }); + }); }); describe('text truncation styling', () => { @@ -192,4 +279,18 @@ describe('ConditionalPortal', () => { expect(getStyle(style, snapshot)).toHaveProperty('transitionDuration', '0.00000001s'); }); }); + + describe('disableHoverActions', () => { + timelineIdsNoHoverActions.forEach((timelineId) => + test(`it returns true when timelineId is ${timelineId}`, () => { + expect(disableHoverActions(timelineId)).toBe(true); + }) + ); + + timelineIdsWithHoverActions.forEach((timelineId) => + test(`it returns false when timelineId is ${timelineId}`, () => { + expect(disableHoverActions(timelineId)).toBe(false); + }) + ); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx index f972bcf463b5b..887f1635c08c6 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx @@ -18,6 +18,7 @@ import { Draggable, Droppable } from 'react-beautiful-dnd'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; +import { TimelineId } from '../../../../common/types'; import { dragAndDropActions } from '../../store/drag_and_drop'; import type { DataProvider } from '../../../timelines/components/timeline/data_providers/data_provider'; import { ROW_RENDERER_BROWSER_EXAMPLE_TIMELINE_ID } from '../../../timelines/components/row_renderers_browser/constants'; @@ -108,6 +109,9 @@ interface Props { onFilterAdded?: () => void; } +export const disableHoverActions = (timelineId: string | undefined): boolean => + [TimelineId.rulePreview, ROW_RENDERER_BROWSER_EXAMPLE_TIMELINE_ID].includes(timelineId ?? ''); + /** * Wraps a draggable component to handle registration / unregistration of the * data provider associated with the item being dropped @@ -370,7 +374,7 @@ const DraggableWrapperComponent: React.FC = ({ diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.test.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.test.tsx index 9160732e32b3e..6780335312251 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.test.tsx @@ -12,7 +12,7 @@ import { act, fireEvent, waitFor, waitForElementToBeRemoved } from '@testing-lib import userEvent from '@testing-library/user-event'; import type { ArtifactListPageRenderingSetup } from './mocks'; import { getArtifactListPageRenderingSetup } from './mocks'; -import { getDeferred } from '../mocks'; +import { getDeferred } from '../../mocks/utils'; jest.mock('../../../common/components/user_privileges'); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx index fc687282cccac..0586034d15550 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx @@ -11,6 +11,7 @@ import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-t import { EuiButton, EuiSpacer, EuiText } from '@elastic/eui'; import type { EuiFlyoutSize } from '@elastic/eui/src/components/flyout/flyout'; import { useLocation } from 'react-router-dom'; +import { useIsMounted } from '@kbn/securitysolution-hook-utils'; import type { ServerApiError } from '../../../common/types'; import { AdministrationListPage } from '../administration_list_page'; @@ -45,7 +46,6 @@ import { useToasts } from '../../../common/lib/kibana'; import { useMemoizedRouteState } from '../../common/hooks'; import { BackToExternalAppSecondaryButton } from '../back_to_external_app_secondary_button'; import { BackToExternalAppButton } from '../back_to_external_app_button'; -import { useIsMounted } from '../../hooks/use_is_mounted'; type ArtifactEntryCardType = typeof ArtifactEntryCard; @@ -221,7 +221,7 @@ export const ArtifactListPage = memo( ); const handleArtifactDeleteModalOnSuccess = useCallback(() => { - if (isMounted) { + if (isMounted()) { setSelectedItemForDelete(undefined); refetchListData(); } diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_delete_modal.test.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_delete_modal.test.ts index d0fb3e3c59dfa..57ea165f0b85f 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_delete_modal.test.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_delete_modal.test.ts @@ -11,7 +11,7 @@ import type { ArtifactListPageRenderingSetup } from '../mocks'; import { getArtifactListPageRenderingSetup } from '../mocks'; import { act, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { getDeferred } from '../../mocks'; +import { getDeferred } from '../../../mocks/utils'; describe('When displaying the Delete artifact modal in the Artifact List Page', () => { let renderResult: ReturnType; diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.test.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.test.tsx index ee3ac4a907ee4..5179bb7b76be6 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.test.tsx @@ -19,7 +19,7 @@ import type { trustedAppsAllHttpMocks } from '../../../mocks'; import { useUserPrivileges as _useUserPrivileges } from '../../../../common/components/user_privileges'; import { entriesToConditionEntries } from '../../../../common/utils/exception_list_items/mappers'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { getDeferred } from '../../mocks'; +import { getDeferred } from '../../../mocks/utils'; jest.mock('../../../../common/components/user_privileges'); const useUserPrivileges = _useUserPrivileges as jest.Mock; diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.tsx index 4d9f53c73341e..1261d01c7af44 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.tsx @@ -24,6 +24,7 @@ import { import type { EuiFlyoutSize } from '@elastic/eui/src/components/flyout/flyout'; import type { IHttpFetchError } from '@kbn/core-http-browser'; +import { useIsMounted } from '@kbn/securitysolution-hook-utils'; import { useUrlParams } from '../../../hooks/use_url_params'; import { useIsFlyoutOpened } from '../hooks/use_is_flyout_opened'; import { useTestIdGenerator } from '../../../hooks/use_test_id_generator'; @@ -39,7 +40,6 @@ import { useKibana, useToasts } from '../../../../common/lib/kibana'; import { createExceptionListItemForCreate } from '../../../../../common/endpoint/service/artifacts/utils'; import { useWithArtifactSubmitData } from '../hooks/use_with_artifact_submit_data'; import { useIsArtifactAllowedPerPolicyUsage } from '../hooks/use_is_artifact_allowed_per_policy_usage'; -import { useIsMounted } from '../../../hooks/use_is_mounted'; import { useGetArtifact } from '../../../hooks/artifacts'; import type { PolicyData } from '../../../../../common/endpoint/types'; @@ -271,7 +271,7 @@ export const ArtifactFlyout = memo( const handleFormComponentOnChange: ArtifactFormComponentProps['onChange'] = useCallback( ({ item: updatedItem, isValid }) => { - if (isMounted) { + if (isMounted()) { setFormState({ item: updatedItem, isValid, @@ -289,7 +289,7 @@ export const ArtifactFlyout = memo( : labels.flyoutCreateSubmitSuccess(result) ); - if (isMounted) { + if (isMounted()) { // Close the flyout // `undefined` will cause params to be dropped from url setUrlParams({ ...urlParams, itemId: undefined, show: undefined }, true); @@ -307,12 +307,12 @@ export const ArtifactFlyout = memo( submitHandler(formState.item, formMode) .then(handleSuccess) .catch((submitHandlerError) => { - if (isMounted) { + if (isMounted()) { setExternalSubmitHandlerError(submitHandlerError); } }) .finally(() => { - if (isMounted) { + if (isMounted()) { setExternalIsSubmittingData(false); } }); @@ -326,7 +326,7 @@ export const ArtifactFlyout = memo( useEffect(() => { if (isEditFlow && !hasItemDataForEdit && !error && isInitializing && !isLoadingItemForEdit) { fetchItemForEdit().then(({ data: editItemData }) => { - if (editItemData && isMounted) { + if (editItemData && isMounted()) { setFormState(createFormInitialState(apiClient.listId, editItemData)); } }); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_list_data.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_list_data.ts index 813e205b64c9a..ddae258fef895 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_list_data.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_list_data.ts @@ -8,8 +8,8 @@ import { useEffect, useMemo, useState } from 'react'; import type { Pagination } from '@elastic/eui'; import { useQuery } from '@tanstack/react-query'; +import { useIsMounted } from '@kbn/securitysolution-hook-utils'; import type { ServerApiError } from '../../../../common/types'; -import { useIsMounted } from '../../../hooks/use_is_mounted'; import { MANAGEMENT_PAGE_SIZE_OPTIONS } from '../../../common/constants'; import { useUrlParams } from '../../../hooks/use_url_params'; import type { ExceptionsListApiClient } from '../../../services/exceptions_list/exceptions_list_api_client'; @@ -98,7 +98,7 @@ export const useWithArtifactListData = ( // Once we know if data exists, update the page initializing state. // This should only ever happen at most once; useEffect(() => { - if (isMounted) { + if (isMounted()) { if (isPageInitializing && !isLoadingDataExists) { setIsPageInitializing(false); } @@ -107,7 +107,7 @@ export const useWithArtifactListData = ( // Update the uiPagination once the query succeeds useEffect(() => { - if (isMounted && listData && !isLoadingListData && isSuccessListData) { + if (isMounted() && listData && !isLoadingListData && isSuccessListData) { setUiPagination((prevState) => { return { ...prevState, @@ -134,7 +134,7 @@ export const useWithArtifactListData = ( // >> Check if data exists again (which should return true useEffect(() => { if ( - isMounted && + isMounted() && !isLoadingListData && !isLoadingDataExists && !listDataError && diff --git a/x-pack/plugins/security_solution/public/management/components/console/types.ts b/x-pack/plugins/security_solution/public/management/components/console/types.ts index 2d863e7878be2..5b90a18f27ce1 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/types.ts +++ b/x-pack/plugins/security_solution/public/management/components/console/types.ts @@ -168,7 +168,7 @@ export type CommandExecutionComponent< /** The arguments that could have been entered by the user */ TArgs extends SupportedArguments = any, /** Internal store for the Command execution */ - TStore extends object = Record, + TStore extends object = any, /** The metadata defined on the Command Definition */ TMeta = any > = ComponentType>; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/get_processes_action.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/get_processes_action.test.tsx index 29b6fd0446577..97689a790afa1 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/get_processes_action.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/get_processes_action.test.tsx @@ -129,7 +129,7 @@ describe('When using processes action from response actions console', () => { enterConsoleCommand(renderResult, 'processes'); await waitFor(() => { - expect(renderResult.getByTestId('getProcessesErrorCallout').textContent).toMatch( + expect(renderResult.getByTestId('getProcesses-actionFailure').textContent).toMatch( /error one \| error two/ ); }); @@ -145,7 +145,7 @@ describe('When using processes action from response actions console', () => { enterConsoleCommand(renderResult, 'processes'); await waitFor(() => { - expect(renderResult.getByTestId('performGetProcessesErrorCallout').textContent).toMatch( + expect(renderResult.getByTestId('getProcesses-apiFailure').textContent).toMatch( /this is an error/ ); }); diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/get_processes_action.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/get_processes_action.tsx index c778f03fcb5f5..d7b05ae721abd 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/get_processes_action.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/get_processes_action.tsx @@ -5,21 +5,17 @@ * 2.0. */ -import React, { memo, useEffect, useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import styled from 'styled-components'; import { EuiBasicTable } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import type { IHttpFetchError } from '@kbn/core-http-browser'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { useConsoleActionSubmitter } from './hooks/use_console_action_submitter'; import type { - ActionDetails, GetProcessesActionOutputContent, + ProcessesRequestBody, } from '../../../../common/endpoint/types'; -import { useGetActionDetails } from '../../hooks/endpoint/use_get_action_details'; -import type { EndpointCommandDefinitionMeta } from './types'; -import type { CommandExecutionComponentProps } from '../console/types'; import { useSendGetEndpointProcessesRequest } from '../../hooks/endpoint/use_send_get_endpoint_processes_request'; -import { ActionError } from './action_error'; +import type { ActionRequestComponentProps } from './types'; // @ts-expect-error TS2769 const StyledEuiBasicTable = styled(EuiBasicTable)` @@ -43,181 +39,90 @@ const StyledEuiBasicTable = styled(EuiBasicTable)` } `; -export const GetProcessesActionResult = memo< - CommandExecutionComponentProps< - { comment?: string }, - { - actionId?: string; - actionRequestSent?: boolean; - completedActionDetails?: ActionDetails; - apiError?: IHttpFetchError; - }, - EndpointCommandDefinitionMeta - > ->(({ command, setStore, store, status, setStatus, ResultComponent }) => { - const endpointId = command.commandDefinition?.meta?.endpointId; - const { actionId, completedActionDetails, apiError } = store; - - const isPending = status === 'pending'; - const isError = status === 'error'; - const actionRequestSent = Boolean(store.actionRequestSent); - - const { - mutate: getProcesses, - data: getProcessesData, - isSuccess: isGetProcessesSuccess, - error: processesActionRequestError, - } = useSendGetEndpointProcessesRequest(); - - const { data: actionDetails } = useGetActionDetails( - actionId ?? '-', - { - enabled: Boolean(actionId) && isPending, - refetchInterval: isPending ? 3000 : false, - } - ); - - // Send get processes request if not yet done - useEffect(() => { - if (!actionRequestSent && endpointId) { - getProcesses({ - endpoint_ids: [endpointId], - comment: command.args.args?.comment?.[0], - }); - - setStore((prevState) => { - return { ...prevState, actionRequestSent: true }; - }); - } - }, [actionRequestSent, command.args.args?.comment, endpointId, getProcesses, setStore]); +export const GetProcessesActionResult = memo( + ({ command, setStore, store, status, setStatus, ResultComponent }) => { + const endpointId = command.commandDefinition?.meta?.endpointId; + const actionCreator = useSendGetEndpointProcessesRequest(); + + const actionRequestBody = useMemo(() => { + return endpointId + ? { + endpoint_ids: [endpointId], + comment: command.args.args?.comment?.[0], + } + : undefined; + }, [command.args.args?.comment, endpointId]); + + const { result, actionDetails: completedActionDetails } = useConsoleActionSubmitter< + ProcessesRequestBody, + GetProcessesActionOutputContent + >({ + ResultComponent, + setStore, + store, + status, + setStatus, + actionCreator, + actionRequestBody, + dataTestSubj: 'getProcesses', + }); + + const columns = useMemo( + () => [ + { + field: 'user', + name: i18n.translate( + 'xpack.securitySolution.endpointResponseActions.getProcesses.table.header.user', + { defaultMessage: 'USER' } + ), + width: '10%', + }, + { + field: 'pid', + name: i18n.translate( + 'xpack.securitySolution.endpointResponseActions.getProcesses.table.header.pid', + { defaultMessage: 'PID' } + ), + width: '5%', + }, + { + field: 'entity_id', + name: i18n.translate( + 'xpack.securitySolution.endpointResponseActions.getProcesses.table.header.enityId', + { defaultMessage: 'ENTITY ID' } + ), + width: '30%', + }, + + { + field: 'command', + name: i18n.translate( + 'xpack.securitySolution.endpointResponseActions.getProcesses.table.header.command', + { defaultMessage: 'COMMAND' } + ), + width: '55%', + }, + ], + [] + ); - // If get processes request was created, store the action id if necessary - useEffect(() => { - if (isPending) { - if (isGetProcessesSuccess && actionId !== getProcessesData?.data.id) { - setStore((prevState) => { - return { ...prevState, actionId: getProcessesData?.data.id }; - }); - } else if (processesActionRequestError) { - setStatus('error'); - setStore((prevState) => { - return { ...prevState, apiError: processesActionRequestError }; - }); + const tableEntries = useMemo(() => { + if (endpointId) { + return completedActionDetails?.outputs?.[endpointId]?.content.entries ?? []; } - } - }, [ - actionId, - getProcessesData?.data.id, - processesActionRequestError, - isGetProcessesSuccess, - setStatus, - setStore, - isPending, - ]); - - useEffect(() => { - if (actionDetails?.data.isCompleted && isPending) { - setStatus('success'); - setStore((prevState) => { - return { - ...prevState, - completedActionDetails: actionDetails?.data, - }; - }); - } - }, [actionDetails?.data, setStatus, setStore, isPending]); - - const columns = useMemo( - () => [ - { - field: 'user', - name: i18n.translate( - 'xpack.securitySolution.endpointResponseActions.getProcesses.table.header.user', - { defaultMessage: 'USER' } - ), - width: '10%', - }, - { - field: 'pid', - name: i18n.translate( - 'xpack.securitySolution.endpointResponseActions.getProcesses.table.header.pid', - { defaultMessage: 'PID' } - ), - width: '5%', - }, - { - field: 'entity_id', - name: i18n.translate( - 'xpack.securitySolution.endpointResponseActions.getProcesses.table.header.enityId', - { defaultMessage: 'ENTITY ID' } - ), - width: '30%', - }, - - { - field: 'command', - name: i18n.translate( - 'xpack.securitySolution.endpointResponseActions.getProcesses.table.header.command', - { defaultMessage: 'COMMAND' } - ), - width: '55%', - }, - ], - [] - ); + return []; + }, [completedActionDetails?.outputs, endpointId]); - const tableEntries = useMemo(() => { - if (endpointId) { - return completedActionDetails?.outputs?.[endpointId]?.content.entries ?? []; + if (!completedActionDetails || !completedActionDetails.wasSuccessful) { + return result; } - return []; - }, [completedActionDetails?.outputs, endpointId]); - - // Show nothing if still pending - if (isPending) { - return ; - } - // Show errors if perform action fails - if (isError && apiError) { + // Show results return ( - - + + ); } - - // Show errors - if (completedActionDetails?.errors) { - return ( - - ); - } - - // Show results - return ( - - - - ); -}); +); GetProcessesActionResult.displayName = 'GetProcessesActionResult'; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/hooks.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/hooks.tsx deleted file mode 100644 index bbc390bb2ba53..0000000000000 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/hooks.tsx +++ /dev/null @@ -1,100 +0,0 @@ -/* - * 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 { useEffect, useRef } from 'react'; -import { useIsMounted } from '../../hooks/use_is_mounted'; -import { useGetActionDetails } from '../../hooks/endpoint/use_get_action_details'; -import { ACTION_DETAILS_REFRESH_INTERVAL } from './constants'; -import type { ActionRequestState, ActionRequestComponentProps } from './types'; -import type { useSendIsolateEndpointRequest } from '../../hooks/endpoint/use_send_isolate_endpoint_request'; -import type { useSendReleaseEndpointRequest } from '../../hooks/endpoint/use_send_release_endpoint_request'; - -export const useUpdateActionState = ({ - actionRequestApi, - actionRequest, - command, - endpointId, - setStatus, - setStore, - isPending, -}: Pick & { - actionRequestApi: ReturnType< - typeof useSendIsolateEndpointRequest | typeof useSendReleaseEndpointRequest - >; - actionRequest?: ActionRequestState; - endpointId?: string; - isPending: boolean; -}) => { - const isMounted = useIsMounted(); - const actionRequestSent = Boolean(actionRequest?.requestSent); - const { data: actionDetails } = useGetActionDetails(actionRequest?.actionId ?? '-', { - enabled: Boolean(actionRequest?.actionId) && isPending, - refetchInterval: isPending ? ACTION_DETAILS_REFRESH_INTERVAL : false, - }); - - // keep a reference to track the console's mounted state - // in order to update the store and cause a re-render on action request API response - const latestIsMounted = useRef(false); - latestIsMounted.current = isMounted; - - // Create action request - useEffect(() => { - if (!actionRequestSent && endpointId && isMounted) { - const request: ActionRequestState = { - requestSent: true, - actionId: undefined, - }; - - actionRequestApi - .mutateAsync({ - endpoint_ids: [endpointId], - comment: command.args.args?.comment?.[0], - }) - .then((response) => { - request.actionId = response.data.id; - - if (latestIsMounted.current) { - setStore((prevState) => { - return { ...prevState, actionRequest: { ...request } }; - }); - } - }); - - setStore((prevState) => { - return { ...prevState, actionRequest: request }; - }); - } - }, [ - actionRequestApi, - actionRequestSent, - command.args.args?.comment, - endpointId, - isMounted, - setStore, - ]); - - useEffect(() => { - // update the console's mounted state ref - latestIsMounted.current = isMounted; - // set to false when unmounted/console is hidden - return () => { - latestIsMounted.current = false; - }; - }, [isMounted]); - - useEffect(() => { - if (actionDetails?.data.isCompleted && isPending) { - setStatus('success'); - setStore((prevState) => { - return { - ...prevState, - completedActionDetails: actionDetails.data, - }; - }); - } - }, [actionDetails?.data, actionDetails?.data.isCompleted, setStatus, setStore, isPending]); -}; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/hooks/use_console_action_submitter.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/hooks/use_console_action_submitter.test.tsx new file mode 100644 index 0000000000000..7ef506ea30f32 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/hooks/use_console_action_submitter.test.tsx @@ -0,0 +1,275 @@ +/* + * 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 { + UseConsoleActionSubmitterOptions, + ConsoleActionSubmitter, + CommandResponseActionApiState, +} from './use_console_action_submitter'; +import { useConsoleActionSubmitter } from './use_console_action_submitter'; +import type { AppContextTestRender } from '../../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../../common/mock/endpoint'; +import { EndpointActionGenerator } from '../../../../../common/endpoint/data_generators/endpoint_action_generator'; +import React, { useState } from 'react'; +import type { CommandExecutionResultProps } from '../../console'; +import type { DeferredInterface } from '../../../mocks/utils'; +import { getDeferred } from '../../../mocks/utils'; +import type { ActionDetails } from '../../../../../common/endpoint/types'; +import { act, waitFor } from '@testing-library/react'; +import { responseActionsHttpMocks } from '../../../mocks/response_actions_http_mocks'; + +describe('When using `useConsoleActionSubmitter()` hook', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let renderArgs: UseConsoleActionSubmitterOptions; + let updateHookRenderArgs: () => void; + let hookRenderResultStorage: jest.Mock<(args: ConsoleActionSubmitter) => void>; + let releaseSuccessActionRequestApiResponse: DeferredInterface['resolve']; + let releaseFailedActionRequestApiResponse: DeferredInterface['reject']; + let apiMocks: ReturnType; + + const ActionSubmitterTestComponent = () => { + const [hookOptions, setHookOptions] = useState(renderArgs); + + updateHookRenderArgs = () => { + new Promise((r) => { + setTimeout(r, 1); + }).then(() => { + setHookOptions({ + ...renderArgs, + }); + }); + }; + + const { result, actionDetails } = useConsoleActionSubmitter(hookOptions); + + hookRenderResultStorage({ result, actionDetails }); + + return
{result}
; + }; + + const getOutputTextContent = (): string => { + return renderResult.getByTestId('testContainer').textContent ?? ''; + }; + + beforeEach(() => { + const { render: renderComponent, coreStart } = createAppRootMockRenderer(); + const actionGenerator = new EndpointActionGenerator(); + const deferred = getDeferred(); + + apiMocks = responseActionsHttpMocks(coreStart.http); + + hookRenderResultStorage = jest.fn(); + releaseSuccessActionRequestApiResponse = () => + deferred.resolve(actionGenerator.generateActionDetails({ id: '123' })); + releaseFailedActionRequestApiResponse = deferred.reject; + + let status: UseConsoleActionSubmitterOptions['status'] = 'pending'; + let commandStore: CommandResponseActionApiState = {}; + + renderArgs = { + dataTestSubj: 'test', + actionRequestBody: { + endpoint_ids: ['123'], + }, + actionCreator: { + mutateAsync: jest.fn(async () => { + return { + data: await deferred.promise, + }; + }), + } as unknown as UseConsoleActionSubmitterOptions['actionCreator'], + get status() { + return status; + }, + setStatus: jest.fn((newStatus) => { + status = newStatus; + updateHookRenderArgs(); + }), + get store() { + return commandStore; + }, + setStore: jest.fn((newStoreOrCallback: object | ((prevStore: object) => object)) => { + if (typeof newStoreOrCallback === 'function') { + commandStore = newStoreOrCallback(commandStore); + } else { + commandStore = newStoreOrCallback; + } + + updateHookRenderArgs(); + }), + ResultComponent: jest.fn( + ({ children, showAs, 'data-test-subj': dataTestSubj }: CommandExecutionResultProps) => { + return ( +
+ {children} +
+ ); + } + ), + }; + + render = () => { + renderResult = renderComponent(); + return renderResult; + }; + }); + + afterEach(() => { + renderResult.unmount(); + }); + + it('should return expected interface while its still pending', () => { + render(); + + expect(hookRenderResultStorage).toHaveBeenLastCalledWith({ + result: expect.anything(), + action: undefined, + }); + + expect(renderResult.getByTestId('test-pending')).not.toBeNull(); + }); + + it('should update command state when request is sent', () => { + render(); + + expect(renderArgs.store?.actionApiState?.request.sent).toBe(true); + expect(renderArgs.store?.actionApiState?.request.actionId).toBe(undefined); + }); + + it('should store the action id when action request api is successful', async () => { + render(); + + releaseSuccessActionRequestApiResponse(); + + await waitFor(() => { + expect(renderArgs.store?.actionApiState?.request.actionId).toBe('123'); + }); + }); + + it('should store action request api error', async () => { + render(); + const error = new Error('oh oh. request failed'); + + act(() => { + releaseFailedActionRequestApiResponse(error); + }); + + await waitFor(() => { + expect(renderArgs.store?.actionApiState?.request.actionId).toBe(undefined); + expect(renderArgs.store?.actionApiState?.request.error).toBe(error); + }); + + await waitFor(() => { + expect(getOutputTextContent()).toEqual( + 'The following error was encountered:oh oh. request failed' + ); + }); + }); + + it('should still store the action id if component is unmounted while action request API is in flight', async () => { + render(); + renderResult.unmount(); + + expect(renderArgs.store.actionApiState?.request.sent).toBe(true); + + const requestState = renderArgs.store.actionApiState?.request; + releaseSuccessActionRequestApiResponse(); + + await waitFor(() => { + // this check just ensure that we mutated the state when the api returned success instead of + // dispatching a `setStore()`. + expect(renderArgs.store.actionApiState?.request === requestState).toBe(true); + + expect(renderArgs.store.actionApiState?.request.actionId).toEqual('123'); + }); + }); + + it('should call action details api once we have an action id', async () => { + render(); + + expect(apiMocks.responseProvider.actionDetails).not.toHaveBeenCalled(); + + releaseSuccessActionRequestApiResponse(); + + await waitFor(() => { + expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledWith({ + path: '/api/endpoint/action/123', + }); + }); + }); + + it('should continue to show pending message until action completes', async () => { + apiMocks.responseProvider.actionDetails.mockImplementation(() => { + return { + data: new EndpointActionGenerator().generateActionDetails({ + id: '123', + isCompleted: false, + }), + }; + }); + render(); + releaseSuccessActionRequestApiResponse(); + + await waitFor(() => { + expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledWith({ + path: '/api/endpoint/action/123', + }); + }); + + expect(renderResult.getByTestId('test-pending')).not.toBeNull(); + + expect(hookRenderResultStorage).toHaveBeenLastCalledWith({ + result: expect.anything(), + actionDetails: undefined, + }); + }); + + it('should store action details api error', async () => { + const error = new Error('on oh. getting action details failed'); + apiMocks.responseProvider.actionDetails.mockImplementation(() => { + throw error; + }); + + render(); + releaseSuccessActionRequestApiResponse(); + + await waitFor(() => { + expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledWith({ + path: '/api/endpoint/action/123', + }); + }); + + expect(renderArgs.store.actionApiState?.actionDetailsError).toBe(error); + + expect(renderResult.getByTestId('test-apiFailure').textContent).toEqual( + 'The following error was encountered:on oh. getting action details failed' + ); + }); + + it('should store action details once action completes', async () => { + const actionDetails = new EndpointActionGenerator().generateActionDetails({ id: '123' }); + apiMocks.responseProvider.actionDetails.mockReturnValue({ data: actionDetails }); + + render(); + releaseSuccessActionRequestApiResponse(); + + await waitFor(() => { + expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledWith({ + path: '/api/endpoint/action/123', + }); + }); + + expect(renderArgs.store.actionApiState?.actionDetails).toBe(actionDetails); + expect(hookRenderResultStorage).toHaveBeenLastCalledWith({ + result: expect.anything(), + actionDetails, + }); + + expect(renderResult.getByTestId('test-success').textContent).toEqual(''); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/hooks/use_console_action_submitter.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/hooks/use_console_action_submitter.tsx new file mode 100644 index 0000000000000..7183b5cc61ef7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/hooks/use_console_action_submitter.tsx @@ -0,0 +1,292 @@ +/* + * 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 React, { useEffect, useMemo } from 'react'; +import type { UseMutationResult } from '@tanstack/react-query'; +import type { IHttpFetchError } from '@kbn/core-http-browser'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useIsMounted } from '@kbn/securitysolution-hook-utils'; +import { useTestIdGenerator } from '../../../hooks/use_test_id_generator'; +import type { BaseActionRequestBody } from '../../../../../common/endpoint/schema/actions'; +import { ActionSuccess } from '../action_success'; +import { ActionError } from '../action_error'; +import { FormattedError } from '../../formatted_error'; +import { useGetActionDetails } from '../../../hooks/endpoint/use_get_action_details'; +import { ACTION_DETAILS_REFRESH_INTERVAL } from '../constants'; +import type { + ActionDetails, + Immutable, + ResponseActionApiResponse, +} from '../../../../../common/endpoint/types'; +import type { CommandExecutionComponentProps } from '../../console'; + +export interface ConsoleActionSubmitter { + /** + * The ui to be returned to the console. This UI will display different states of the action, + * including pending, error conditions and generic success messages. + */ + result: JSX.Element; + actionDetails: Immutable> | undefined; +} + +/** + * Command store state for response action api state. + */ +export interface CommandResponseActionApiState { + actionApiState?: { + request: { + sent: boolean; + actionId: string | undefined; + error: IHttpFetchError | undefined; + }; + actionDetails: ActionDetails | undefined; + actionDetailsError: IHttpFetchError | undefined; + }; +} + +export interface UseConsoleActionSubmitterOptions< + TReqBody extends BaseActionRequestBody = BaseActionRequestBody, + TActionOutputContent extends object = object +> extends Pick< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + CommandExecutionComponentProps>, + 'ResultComponent' | 'setStore' | 'store' | 'status' | 'setStatus' + > { + actionCreator: UseMutationResult; + /** + * The API request body. If `undefined`, then API will not be called. + */ + actionRequestBody: TReqBody | undefined; + + dataTestSubj?: string; +} + +/** + * generic hook for use with Response Action commands. It will create the action, store its ID and + * continuously pull the Action's Details until it completes. It handles all aspects of UI display + * for the different states of the command (pending -> success/failure) + * + * @param actionCreator + * @param actionRequestBody + * @param setStatus + * @param status + * @param setStore + * @param store + * @param ResultComponent + * @param dataTestSubj + */ +export const useConsoleActionSubmitter = < + TReqBody extends BaseActionRequestBody = BaseActionRequestBody, + TActionOutputContent extends object = object +>({ + actionCreator, + actionRequestBody, + setStatus, + status, + setStore, + store, + ResultComponent, + dataTestSubj, +}: UseConsoleActionSubmitterOptions< + TReqBody, + TActionOutputContent +>): ConsoleActionSubmitter => { + const isMounted = useIsMounted(); + const getTestId = useTestIdGenerator(dataTestSubj); + const isPending = status === 'pending'; + + const currentActionState = useMemo< + Immutable>['actionApiState']> + >( + () => + store.actionApiState ?? { + request: { + sent: false, + error: undefined, + actionId: undefined, + }, + actionDetails: undefined, + actionDetailsError: undefined, + }, + [store.actionApiState] + ); + + const { actionDetails, actionDetailsError } = currentActionState; + const { + actionId, + sent: actionRequestSent, + error: actionRequestError, + } = currentActionState.request; + + const { data: apiActionDetailsResponse, error: apiActionDetailsError } = + useGetActionDetails(actionId ?? '-', { + enabled: Boolean(actionId) && isPending, + refetchInterval: isPending ? ACTION_DETAILS_REFRESH_INTERVAL : false, + }); + + // Create the action request if not yet done + useEffect(() => { + if (!actionRequestSent && actionRequestBody && isMounted()) { + const updatedRequestState: Required< + CommandResponseActionApiState + >['actionApiState']['request'] = { + ...( + currentActionState as Required< + CommandResponseActionApiState + >['actionApiState'] + ).request, + sent: true, + }; + + // The object defined above (`updatedRequestState`) is saved to the command state right away. + // the creation of the Action request (below) will mutate this object to store the Action ID + // once the API response is received. We do this to ensure that the action is not created more + // than once if the user happens to close the console prior to the response being returned. + // Once a response is received, we check if the component is mounted, and if so, then we send + // another update to the command store which will cause it to re-render and start checking for + // action completion. + actionCreator + .mutateAsync(actionRequestBody) + .then((response) => { + updatedRequestState.actionId = response.data.id; + }) + .catch((err) => { + updatedRequestState.error = err; + }) + .finally(() => { + // If the component is mounted, then set the store with the updated data (causes a rerender) + if (isMounted()) { + setStore((prevState) => { + return { + ...prevState, + actionApiState: { + ...(prevState.actionApiState ?? currentActionState), + request: { ...updatedRequestState }, + }, + }; + }); + } + }); + + setStore((prevState) => { + return { + ...prevState, + actionApiState: { + ...(prevState.actionApiState ?? currentActionState), + request: updatedRequestState, + }, + }; + }); + } + }, [ + actionCreator, + actionRequestBody, + actionRequestSent, + currentActionState, + isMounted, + setStore, + ]); + + // If an error was returned while attempting to create the action request, + // then set command status to error + useEffect(() => { + if (actionRequestError && isPending) { + setStatus('error'); + } + }, [actionRequestError, isPending, setStatus]); + + // If an error was return by the Action Details API, then store it and set the status to error + useEffect(() => { + if (apiActionDetailsError && isPending) { + setStatus('error'); + setStore((prevState) => { + return { + ...prevState, + actionApiState: { + ...(prevState.actionApiState ?? currentActionState), + actionDetails: undefined, + actionDetailsError: apiActionDetailsError, + }, + }; + }); + } + }, [apiActionDetailsError, currentActionState, isPending, setStatus, setStore]); + + // If the action details indicates complete, then update the action's console state and set the status to success + useEffect(() => { + if (apiActionDetailsResponse?.data.isCompleted && isPending) { + setStatus(apiActionDetailsResponse?.data.wasSuccessful ? 'success' : 'error'); + setStore((prevState) => { + return { + ...prevState, + actionApiState: { + ...(prevState.actionApiState ?? currentActionState), + actionDetails: apiActionDetailsResponse.data, + }, + // Unclear why I needed to cast this here. For some reason the `ActionDetails['outputs']` is + // reporting a type error for the `content` property, although the types seem to line up. + } as typeof prevState; + }); + } + }, [apiActionDetailsResponse, currentActionState, isPending, setStatus, setStore]); + + // Calculate the action's UI result based on the different API responses + const result = useMemo(() => { + if (isPending) { + return ; + } + + const apiError = actionRequestError || actionDetailsError; + + if (apiError) { + return ( + + + + + ); + } + + if (actionDetails) { + // Response action failures + if (actionDetails.errors) { + return ( + + ); + } + + return ( + + ); + } + + return <>; + }, [ + isPending, + actionRequestError, + actionDetailsError, + actionDetails, + ResultComponent, + getTestId, + ]); + + return { + result, + actionDetails: currentActionState.actionDetails, + }; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/isolate_action.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/isolate_action.test.tsx index 63e05d420deaf..110ddbb53b1b0 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/isolate_action.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/isolate_action.test.tsx @@ -16,9 +16,9 @@ import { getEndpointResponseActionsConsoleCommands } from './endpoint_response_a import { responseActionsHttpMocks } from '../../mocks/response_actions_http_mocks'; import { enterConsoleCommand } from '../console/mocks'; import { waitFor } from '@testing-library/react'; -import { getDeferred } from '../mocks'; import type { ResponderCapabilities } from '../../../../common/endpoint/constants'; import { RESPONDER_CAPABILITIES } from '../../../../common/endpoint/constants'; +import { getDeferred } from '../../mocks/utils'; describe('When using isolate action from response actions console', () => { let render: ( @@ -115,7 +115,7 @@ describe('When using isolate action from response actions console', () => { enterConsoleCommand(renderResult, 'isolate'); await waitFor(() => { - expect(renderResult.getByTestId('isolateSuccessCallout')).toBeTruthy(); + expect(renderResult.getByTestId('isolate-success')).toBeTruthy(); }); }); @@ -130,7 +130,7 @@ describe('When using isolate action from response actions console', () => { enterConsoleCommand(renderResult, 'isolate'); await waitFor(() => { - expect(renderResult.getByTestId('isolateErrorCallout').textContent).toMatch( + expect(renderResult.getByTestId('isolate-actionFailure').textContent).toMatch( /error one \| error two/ ); }); diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/isolate_action.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/isolate_action.tsx index ec11d022650ca..8df7692cf3ac2 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/isolate_action.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/isolate_action.tsx @@ -5,47 +5,37 @@ * 2.0. */ -import React, { memo } from 'react'; +import { memo, useMemo } from 'react'; +import { useConsoleActionSubmitter } from './hooks/use_console_action_submitter'; import type { ActionRequestComponentProps } from './types'; import { useSendIsolateEndpointRequest } from '../../hooks/endpoint/use_send_isolate_endpoint_request'; -import { ActionError } from './action_error'; -import { useUpdateActionState } from './hooks'; export const IsolateActionResult = memo( ({ command, setStore, store, status, setStatus, ResultComponent }) => { - const endpointId = command.commandDefinition?.meta?.endpointId; - const { completedActionDetails, actionRequest } = store; - const isPending = status === 'pending'; const isolateHostApi = useSendIsolateEndpointRequest(); - useUpdateActionState({ - actionRequestApi: isolateHostApi, - actionRequest, - command, - endpointId, - setStatus, - setStore, - isPending, - }); - - // Show nothing if still pending - if (isPending) { - return ; - } + const actionRequestBody = useMemo(() => { + const endpointId = command.commandDefinition?.meta?.endpointId; + const comment = command.args.args?.comment?.[0]; - // Show errors - if (completedActionDetails?.errors) { - return ( - - ); - } + return endpointId + ? { + endpoint_ids: [endpointId], + comment, + } + : undefined; + }, [command.args.args?.comment, command.commandDefinition?.meta?.endpointId]); - // Show Success - return ; + return useConsoleActionSubmitter({ + ResultComponent, + setStore, + store, + status, + setStatus, + actionCreator: isolateHostApi, + actionRequestBody, + dataTestSubj: 'isolate', + }).result; } ); IsolateActionResult.displayName = 'IsolateActionResult'; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/kill_process_action.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/kill_process_action.test.tsx index 827a4d6191754..f888df2099b13 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/kill_process_action.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/kill_process_action.test.tsx @@ -195,7 +195,7 @@ describe('When using the kill-process action from response actions console', () enterConsoleCommand(renderResult, 'kill-process --pid 123'); await waitFor(() => { - expect(renderResult.getByTestId('killProcessSuccessCallout')).toBeTruthy(); + expect(renderResult.getByTestId('killProcess-success')).toBeTruthy(); }); }); @@ -204,7 +204,7 @@ describe('When using the kill-process action from response actions console', () enterConsoleCommand(renderResult, 'kill-process --entityId 123wer'); await waitFor(() => { - expect(renderResult.getByTestId('killProcessSuccessCallout')).toBeTruthy(); + expect(renderResult.getByTestId('killProcess-success')).toBeTruthy(); }); }); @@ -219,7 +219,7 @@ describe('When using the kill-process action from response actions console', () enterConsoleCommand(renderResult, 'kill-process --pid 123'); await waitFor(() => { - expect(renderResult.getByTestId('killProcessErrorCallout').textContent).toMatch( + expect(renderResult.getByTestId('killProcess-actionFailure').textContent).toMatch( /error one \| error two/ ); }); @@ -234,7 +234,7 @@ describe('When using the kill-process action from response actions console', () enterConsoleCommand(renderResult, 'kill-process --pid 123'); await waitFor(() => { - expect(renderResult.getByTestId('killProcessAPIErrorCallout').textContent).toMatch( + expect(renderResult.getByTestId('killProcess-apiFailure').textContent).toMatch( /this is an error/ ); }); diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/kill_process_action.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/kill_process_action.tsx index 3e4dcf61d1690..bf501c31b9e85 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/kill_process_action.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/kill_process_action.tsx @@ -5,130 +5,40 @@ * 2.0. */ -import React, { memo, useEffect } from 'react'; -import { FormattedMessage } from '@kbn/i18n-react'; -import type { IHttpFetchError } from '@kbn/core-http-browser'; +import { memo, useMemo } from 'react'; +import type { KillOrSuspendProcessRequestBody } from '../../../../common/endpoint/types'; import { parsedPidOrEntityIdParameter } from './utils'; -import { ActionSuccess } from './action_success'; -import type { - ActionDetails, - KillProcessActionOutputContent, -} from '../../../../common/endpoint/types'; -import { useGetActionDetails } from '../../hooks/endpoint/use_get_action_details'; -import type { EndpointCommandDefinitionMeta } from './types'; import { useSendKillProcessRequest } from '../../hooks/endpoint/use_send_kill_process_endpoint_request'; -import type { CommandExecutionComponentProps } from '../console/types'; -import { ActionError } from './action_error'; -import { ACTION_DETAILS_REFRESH_INTERVAL } from './constants'; +import type { ActionRequestComponentProps } from './types'; +import { useConsoleActionSubmitter } from './hooks/use_console_action_submitter'; export const KillProcessActionResult = memo< - CommandExecutionComponentProps< - { comment?: string; pid?: string; entityId?: string }, - { - actionId?: string; - actionRequestSent?: boolean; - completedActionDetails?: ActionDetails; - apiError?: IHttpFetchError; - }, - EndpointCommandDefinitionMeta - > + ActionRequestComponentProps<{ pid?: string[]; entityId?: string[] }> >(({ command, setStore, store, status, setStatus, ResultComponent }) => { - const endpointId = command.commandDefinition?.meta?.endpointId; - const { actionId, completedActionDetails, apiError } = store; - const isPending = status === 'pending'; - const isError = status === 'error'; - const actionRequestSent = Boolean(store.actionRequestSent); + const actionCreator = useSendKillProcessRequest(); - const { mutate, data, isSuccess, error } = useSendKillProcessRequest(); - - const { data: actionDetails } = useGetActionDetails( - actionId ?? '-', - { - enabled: Boolean(actionId) && isPending, - refetchInterval: isPending ? ACTION_DETAILS_REFRESH_INTERVAL : false, - } - ); - - // Send Kill request if not yet done - useEffect(() => { + const actionRequestBody = useMemo(() => { + const endpointId = command.commandDefinition?.meta?.endpointId; const parameters = parsedPidOrEntityIdParameter(command.args.args); - if (!actionRequestSent && endpointId && parameters) { - mutate({ - endpoint_ids: [endpointId], - comment: command.args.args?.comment?.[0], - parameters, - }); - setStore((prevState) => { - return { ...prevState, actionRequestSent: true }; - }); - } - }, [actionRequestSent, command.args.args, endpointId, mutate, setStore]); - - // If kill-process request was created, store the action id if necessary - useEffect(() => { - if (isPending) { - if (isSuccess && actionId !== data.data.id) { - setStore((prevState) => { - return { ...prevState, actionId: data.data.id }; - }); - } else if (error) { - setStatus('error'); - setStore((prevState) => { - return { ...prevState, apiError: error }; - }); - } - } - }, [actionId, data?.data.id, isSuccess, error, setStore, setStatus, isPending]); - - useEffect(() => { - if (actionDetails?.data.isCompleted && isPending) { - setStatus('success'); - setStore((prevState) => { - return { - ...prevState, - completedActionDetails: actionDetails.data, - }; - }); - } - }, [actionDetails?.data, setStatus, setStore, isPending]); - - // Show API errors if perform action fails - if (isError && apiError) { - return ( - - - - ); - } - - // Show nothing if still pending - if (isPending || !completedActionDetails) { - return ; - } - - // Show errors - if (completedActionDetails?.errors) { - return ( - - ); - } - - // Show Success - return ( - - ); + return endpointId + ? { + endpoint_ids: [endpointId], + comment: command.args.args?.comment?.[0], + parameters, + } + : undefined; + }, [command.args.args, command.commandDefinition?.meta?.endpointId]); + + return useConsoleActionSubmitter({ + ResultComponent, + setStore, + store, + status, + setStatus, + actionCreator, + actionRequestBody, + dataTestSubj: 'killProcess', + }).result; }); KillProcessActionResult.displayName = 'KillProcessActionResult'; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/release_action.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/release_action.test.tsx index 19e3be94469eb..d1c1ea264f863 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/release_action.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/release_action.test.tsx @@ -16,9 +16,9 @@ import { getEndpointResponseActionsConsoleCommands } from './endpoint_response_a import { enterConsoleCommand } from '../console/mocks'; import { waitFor } from '@testing-library/react'; import { responseActionsHttpMocks } from '../../mocks/response_actions_http_mocks'; -import { getDeferred } from '../mocks'; import type { ResponderCapabilities } from '../../../../common/endpoint/constants'; import { RESPONDER_CAPABILITIES } from '../../../../common/endpoint/constants'; +import { getDeferred } from '../../mocks/utils'; describe('When using the release action from response actions console', () => { let render: ( @@ -116,7 +116,7 @@ describe('When using the release action from response actions console', () => { enterConsoleCommand(renderResult, 'release'); await waitFor(() => { - expect(renderResult.getByTestId('releaseSuccessCallout')).toBeTruthy(); + expect(renderResult.getByTestId('release-success')).toBeTruthy(); }); }); @@ -131,7 +131,7 @@ describe('When using the release action from response actions console', () => { enterConsoleCommand(renderResult, 'release'); await waitFor(() => { - expect(renderResult.getByTestId('releaseErrorCallout').textContent).toMatch( + expect(renderResult.getByTestId('release-actionFailure').textContent).toMatch( /error one \| error two/ ); }); diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/release_action.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/release_action.tsx index f789b48671324..9b0f371ca003f 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/release_action.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/release_action.tsx @@ -5,48 +5,37 @@ * 2.0. */ -import React, { memo } from 'react'; +import { memo, useMemo } from 'react'; import type { ActionRequestComponentProps } from './types'; import { useSendReleaseEndpointRequest } from '../../hooks/endpoint/use_send_release_endpoint_request'; -import { ActionError } from './action_error'; -import { useUpdateActionState } from './hooks'; +import { useConsoleActionSubmitter } from './hooks/use_console_action_submitter'; export const ReleaseActionResult = memo( ({ command, setStore, store, status, setStatus, ResultComponent }) => { - const endpointId = command.commandDefinition?.meta?.endpointId; - const { completedActionDetails, actionRequest } = store; - const isPending = status === 'pending'; - const releaseHostApi = useSendReleaseEndpointRequest(); - useUpdateActionState({ - actionRequestApi: releaseHostApi, - actionRequest, - command, - endpointId, - setStatus, - setStore, - isPending, - }); - - // Show nothing if still pending - if (isPending) { - return ; - } + const actionRequestBody = useMemo(() => { + const endpointId = command.commandDefinition?.meta?.endpointId; + const comment = command.args.args?.comment?.[0]; - // Show errors - if (completedActionDetails?.errors) { - return ( - - ); - } + return endpointId + ? { + endpoint_ids: [endpointId], + comment, + } + : undefined; + }, [command.args.args?.comment, command.commandDefinition?.meta?.endpointId]); - // Show Success - return ; + return useConsoleActionSubmitter({ + ResultComponent, + setStore, + store, + status, + setStatus, + actionCreator: releaseHostApi, + actionRequestBody, + dataTestSubj: 'release', + }).result; } ); ReleaseActionResult.displayName = 'ReleaseActionResult'; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/suspend_process_action.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/suspend_process_action.test.tsx index 9446fb5dcba6a..7479e52edfb07 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/suspend_process_action.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/suspend_process_action.test.tsx @@ -186,7 +186,7 @@ describe('When using the suspend-process action from response actions console', enterConsoleCommand(renderResult, 'suspend-process --pid 123'); await waitFor(() => { - expect(renderResult.getByTestId('suspendProcessSuccessCallout')).toBeTruthy(); + expect(renderResult.getByTestId('suspendProcess-success')).toBeTruthy(); }); }); @@ -195,7 +195,7 @@ describe('When using the suspend-process action from response actions console', enterConsoleCommand(renderResult, 'suspend-process --entityId 123wer'); await waitFor(() => { - expect(renderResult.getByTestId('suspendProcessSuccessCallout')).toBeTruthy(); + expect(renderResult.getByTestId('suspendProcess-success')).toBeTruthy(); }); }); @@ -210,7 +210,7 @@ describe('When using the suspend-process action from response actions console', enterConsoleCommand(renderResult, 'suspend-process --pid 123'); await waitFor(() => { - expect(renderResult.getByTestId('suspendProcessErrorCallout').textContent).toMatch( + expect(renderResult.getByTestId('suspendProcess-actionFailure').textContent).toMatch( /error one \| error two/ ); }); @@ -225,7 +225,7 @@ describe('When using the suspend-process action from response actions console', enterConsoleCommand(renderResult, 'suspend-process --pid 123'); await waitFor(() => { - expect(renderResult.getByTestId('suspendProcessAPIErrorCallout').textContent).toMatch( + expect(renderResult.getByTestId('suspendProcess-apiFailure').textContent).toMatch( /this is an error/ ); }); diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/suspend_process_action.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/suspend_process_action.tsx index a60e7eb6bd65b..f8401a81fa114 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/suspend_process_action.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/suspend_process_action.tsx @@ -5,130 +5,46 @@ * 2.0. */ -import React, { memo, useEffect } from 'react'; -import { FormattedMessage } from '@kbn/i18n-react'; -import type { IHttpFetchError } from '@kbn/core-http-browser'; +import { memo, useMemo } from 'react'; import { parsedPidOrEntityIdParameter } from './utils'; -import { ActionSuccess } from './action_success'; import type { - ActionDetails, SuspendProcessActionOutputContent, + KillOrSuspendProcessRequestBody, } from '../../../../common/endpoint/types'; -import { useGetActionDetails } from '../../hooks/endpoint/use_get_action_details'; -import type { EndpointCommandDefinitionMeta } from './types'; import { useSendSuspendProcessRequest } from '../../hooks/endpoint/use_send_suspend_process_endpoint_request'; -import type { CommandExecutionComponentProps } from '../console/types'; -import { ActionError } from './action_error'; -import { ACTION_DETAILS_REFRESH_INTERVAL } from './constants'; +import type { ActionRequestComponentProps } from './types'; +import { useConsoleActionSubmitter } from './hooks/use_console_action_submitter'; export const SuspendProcessActionResult = memo< - CommandExecutionComponentProps< - { comment?: string; pid?: string; entityId?: string }, - { - actionId?: string; - actionRequestSent?: boolean; - completedActionDetails?: ActionDetails; - apiError?: IHttpFetchError; - }, - EndpointCommandDefinitionMeta - > + ActionRequestComponentProps<{ pid?: string[]; entityId?: string[] }> >(({ command, setStore, store, status, setStatus, ResultComponent }) => { - const endpointId = command.commandDefinition?.meta?.endpointId; - const { actionId, completedActionDetails, apiError } = store; - const isPending = status === 'pending'; - const isError = status === 'error'; - const actionRequestSent = Boolean(store.actionRequestSent); + const actionCreator = useSendSuspendProcessRequest(); - const { mutate, data, isSuccess, error } = useSendSuspendProcessRequest(); - - const { data: actionDetails } = useGetActionDetails( - actionId ?? '-', - { - enabled: Boolean(actionId) && isPending, - refetchInterval: isPending ? ACTION_DETAILS_REFRESH_INTERVAL : false, - } - ); - - // Send Suspend request if not yet done - useEffect(() => { + const actionRequestBody = useMemo(() => { + const endpointId = command.commandDefinition?.meta?.endpointId; const parameters = parsedPidOrEntityIdParameter(command.args.args); - if (!actionRequestSent && endpointId && parameters) { - mutate({ - endpoint_ids: [endpointId], - comment: command.args.args?.comment?.[0], - parameters, - }); - setStore((prevState) => { - return { ...prevState, actionRequestSent: true }; - }); - } - }, [actionRequestSent, command.args.args, endpointId, mutate, setStore]); - - // If suspend-process request was created, store the action id if necessary - useEffect(() => { - if (isPending) { - if (isSuccess && actionId !== data.data.id) { - setStore((prevState) => { - return { ...prevState, actionId: data.data.id }; - }); - } else if (error) { - setStatus('error'); - setStore((prevState) => { - return { ...prevState, apiError: error }; - }); - } - } - }, [actionId, data?.data.id, isSuccess, error, setStore, setStatus, isPending]); - - useEffect(() => { - if (actionDetails?.data.isCompleted && isPending) { - setStatus('success'); - setStore((prevState) => { - return { - ...prevState, - completedActionDetails: actionDetails.data, - }; - }); - } - }, [actionDetails?.data, setStatus, setStore, isPending]); - - // Show API errors if perform action fails - if (isError && apiError) { - return ( - - - - ); - } - - // Show nothing if still pending - if (isPending || !completedActionDetails) { - return ; - } - - // Show errors - if (completedActionDetails?.errors) { - return ( - - ); - } - - // Show Success - return ( - - ); + return endpointId + ? { + endpoint_ids: [endpointId], + comment: command.args.args?.comment?.[0], + parameters, + } + : undefined; + }, [command.args.args, command.commandDefinition?.meta?.endpointId]); + + return useConsoleActionSubmitter< + KillOrSuspendProcessRequestBody, + SuspendProcessActionOutputContent + >({ + ResultComponent, + setStore, + store, + status, + setStatus, + actionCreator, + actionRequestBody, + dataTestSubj: 'suspendProcess', + }).result; }); SuspendProcessActionResult.displayName = 'SuspendProcessActionResult'; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/types.ts b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/types.ts index 34c306ed0a116..92cc3e8c9017a 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/types.ts +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/types.ts @@ -5,8 +5,9 @@ * 2.0. */ +import type { CommandResponseActionApiState } from './hooks/use_console_action_submitter'; import type { ManagedConsoleExtensionComponentProps } from '../console'; -import type { ActionDetails, HostMetadata } from '../../../../common/endpoint/types'; +import type { HostMetadata } from '../../../../common/endpoint/types'; import type { CommandExecutionComponentProps } from '../console/types'; export interface EndpointCommandDefinitionMeta { @@ -17,16 +18,9 @@ export type EndpointResponderExtensionComponentProps = ManagedConsoleExtensionCo endpoint: HostMetadata; }>; -export interface ActionRequestState { - requestSent: boolean; - actionId?: string; -} - -export type ActionRequestComponentProps = CommandExecutionComponentProps< - { comment?: string }, - { - actionRequest?: ActionRequestState; - completedActionDetails?: ActionDetails; - }, - EndpointCommandDefinitionMeta ->; +export type ActionRequestComponentProps = + CommandExecutionComponentProps< + { comment?: string } & TArgs, + CommandResponseActionApiState, + EndpointCommandDefinitionMeta + >; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/utils.test.ts b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/utils.test.ts index ab84e9de959f0..48b6753645c92 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/utils.test.ts +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/utils.test.ts @@ -19,9 +19,9 @@ describe('Endpoint Responder - Utilities', () => { expect(parameters).toEqual({ entity_id: '123qwe' }); }); - it('should return undefined if no params are defined', () => { + it('should return entity id with emtpy string if no params are defined', () => { const parameters = parsedPidOrEntityIdParameter({}); - expect(parameters).toEqual(undefined); + expect(parameters).toEqual({ entity_id: '' }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/utils.ts b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/utils.ts index 9ebcd090bd2a0..0b8e59d0353f7 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/utils.ts +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/utils.ts @@ -4,17 +4,17 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { EndpointActionDataParameterTypes } from '../../../../common/endpoint/types'; +import type { ResponseActionParametersWithPidOrEntityId } from '../../../../common/endpoint/types'; export const parsedPidOrEntityIdParameter = (parameters: { pid?: string[]; entityId?: string[]; -}): EndpointActionDataParameterTypes => { +}): ResponseActionParametersWithPidOrEntityId => { if (parameters.pid) { return { pid: Number(parameters.pid[0]) }; - } else if (parameters.entityId) { - return { entity_id: parameters.entityId[0] }; } - return undefined; + return { + entity_id: parameters?.entityId?.[0] ?? '', + }; }; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/hooks.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/hooks.tsx index bb2c1b7761d5a..34cc5a3ca0f99 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/hooks.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/hooks.tsx @@ -159,7 +159,6 @@ export const getCommandKey = ( } }; -// TODO: add more filter names here export type FilterName = keyof typeof FILTER_NAMES; export const useActionsLogFilter = ({ filterName, diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.test.tsx index 30148c9643baa..556c765296337 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.test.tsx @@ -393,6 +393,26 @@ describe('Response actions history', () => { expect(noTrays).toEqual([]); }); + it('should contain relevant details in each expanded row', async () => { + render(); + const { getAllByTestId } = renderResult; + + const expandButtons = getAllByTestId(`${testPrefix}-expand-button`); + expandButtons.map((button) => userEvent.click(button)); + const trays = getAllByTestId(`${testPrefix}-details-tray`); + expect(trays).toBeTruthy(); + expect(Array.from(trays[0].querySelectorAll('dt')).map((title) => title.textContent)).toEqual( + [ + 'Command placed', + 'Execution started on', + 'Execution completed', + 'Input', + 'Parameters', + 'Output:', + ] + ); + }); + it('should refresh data when autoRefresh is toggled on', async () => { render(); const { getByTestId } = renderResult; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.tsx index 2a9362830c76d..ac9a0829bb7b8 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.tsx @@ -5,112 +5,29 @@ * 2.0. */ -import { - EuiAvatar, - EuiBasicTable, - EuiButtonIcon, - EuiDescriptionList, - EuiEmptyPrompt, - EuiFacetButton, - EuiFlexGroup, - EuiFlexItem, - EuiHorizontalRule, - EuiScreenReaderOnly, - EuiI18nNumber, - EuiText, - EuiCodeBlock, - EuiToolTip, - RIGHT_ALIGNMENT, -} from '@elastic/eui'; -import { euiStyled, css } from '@kbn/kibana-react-plugin/common'; +import { EuiBasicTable, EuiEmptyPrompt, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; -import type { HorizontalAlignment, CriteriaWithPagination } from '@elastic/eui'; +import type { CriteriaWithPagination } from '@elastic/eui'; import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import type { ResponseActions, ResponseActionStatus, } from '../../../../common/endpoint/service/response_actions/constants'; -import { getEmptyValue } from '../../../common/components/empty_value'; -import { FormattedDate } from '../../../common/components/formatted_date'; + import type { ActionListApiResponse } from '../../../../common/endpoint/types'; import type { EndpointActionListRequestQuery } from '../../../../common/endpoint/schema/actions'; import { ManagementEmptyStateWrapper } from '../management_empty_state_wrapper'; import { useGetEndpointActionList } from '../../hooks'; -import { OUTPUT_MESSAGES, TABLE_COLUMN_NAMES, UX_MESSAGES } from './translations'; -import { MANAGEMENT_PAGE_SIZE_OPTIONS } from '../../common/constants'; +import { UX_MESSAGES } from './translations'; import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; import { ActionsLogFilters } from './components/actions_log_filters'; -import { - getActionStatus, - getUiCommand, - getCommandKey, - useDateRangePicker, -} from './components/hooks'; -import { StatusBadge } from './components/status_badge'; +import { getCommandKey, useDateRangePicker } from './components/hooks'; import { useActionHistoryUrlParams } from './components/use_action_history_url_params'; import { useUrlPagination } from '../../hooks/use_url_pagination'; import { ManagementPageLoader } from '../management_page_loader'; import { ActionsLogEmptyState } from './components/actions_log_empty_state'; - -const emptyValue = getEmptyValue(); - -// Truncated usernames -const StyledFacetButton = euiStyled(EuiFacetButton)` - .euiText { - margin-top: 0.38rem; - overflow-y: visible !important; - } -`; - -const customDescriptionListCss = css` - &.euiDescriptionList { - > .euiDescriptionList__title { - color: ${(props) => props.theme.eui.euiColorDarkShade}; - font-size: ${(props) => props.theme.eui.euiFontSizeXS}; - margin-top: ${(props) => props.theme.eui.euiSizeS}; - } - - > .euiDescriptionList__description { - font-weight: ${(props) => props.theme.eui.euiFontWeightSemiBold}; - margin-top: ${(props) => props.theme.eui.euiSizeS}; - } - } -`; - -const StyledDescriptionList = euiStyled(EuiDescriptionList).attrs({ - compressed: true, - type: 'column', -})` - ${customDescriptionListCss} -`; - -// output section styles -const topSpacingCss = css` - ${(props) => `${props.theme.eui.euiCodeBlockPaddingModifiers.paddingMedium} 0`} -`; -const dashedBorderCss = css` - ${(props) => `1px dashed ${props.theme.eui.euiColorDisabled}`}; -`; -const StyledDescriptionListOutput = euiStyled(EuiDescriptionList).attrs({ compressed: true })` - ${customDescriptionListCss} - dd { - margin: ${topSpacingCss}; - padding: ${topSpacingCss}; - border-top: ${dashedBorderCss}; - border-bottom: ${dashedBorderCss}; - } -`; - -// code block styles -const StyledEuiCodeBlock = euiStyled(EuiCodeBlock).attrs({ - transparentBackground: true, - paddingSize: 'none', -})` - code { - color: ${(props) => props.theme.eui.euiColorDarkShade} !important; - } -`; +import { useResponseActionsLogTable } from './use_response_actions_log_table'; export const ResponseActionsLog = memo< Pick & { @@ -131,9 +48,6 @@ export const ResponseActionsLog = memo< } = useActionHistoryUrlParams(); const getTestId = useTestIdGenerator('response-actions-list'); - const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<{ - [k: ActionListApiResponse['data'][number]['id']]: React.ReactNode; - }>({}); // Used to decide if display global loader or not (only the fist time tha page loads) const [isFirstAttempt, setIsFirstAttempt] = useState(true); @@ -183,6 +97,19 @@ export const ResponseActionsLog = memo< { retry: false } ); + // total actions + const totalItemCount = useMemo(() => actionList?.total ?? 0, [actionList]); + + // table columns and expanded row state + const { itemIdToExpandedRowMap, recordRangeLabel, responseActionListColumns, tablePagination } = + useResponseActionsLogTable({ + showHostNames, + pageIndex: isFlyout ? (queryParams.page || 1) - 1 : paginationFromUrlParams.page - 1, + pageSize: isFlyout ? queryParams.pageSize || 10 : paginationFromUrlParams.pageSize, + queryParams, + totalItemCount, + }); + // Hide page header when there is no actions index calling the setIsDataInResponse with false value. // Otherwise, it shows the page header calling the setIsDataInResponse with true value and it also keeps track // if the API request was done for the first time. @@ -248,310 +175,6 @@ export const ResponseActionsLog = memo< [setQueryParams] ); - // total actions - const totalItemCount = useMemo(() => actionList?.total ?? 0, [actionList]); - - // expanded tray contents - const toggleDetails = useCallback( - (item: ActionListApiResponse['data'][number]) => { - const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; - if (itemIdToExpandedRowMapValues[item.id]) { - delete itemIdToExpandedRowMapValues[item.id]; - } else { - const { - startedAt, - completedAt, - isCompleted, - wasSuccessful, - isExpired, - command: _command, - parameters, - } = item; - - const parametersList = parameters - ? Object.entries(parameters).map(([key, value]) => { - return `${key}:${value}`; - }) - : undefined; - - const command = getUiCommand(_command); - const dataList = [ - { - title: OUTPUT_MESSAGES.expandSection.placedAt, - description: `${startedAt}`, - }, - { - title: OUTPUT_MESSAGES.expandSection.startedAt, - description: `${startedAt}`, - }, - { - title: OUTPUT_MESSAGES.expandSection.completedAt, - description: `${completedAt ?? emptyValue}`, - }, - { - title: OUTPUT_MESSAGES.expandSection.input, - description: `${command}`, - }, - { - title: OUTPUT_MESSAGES.expandSection.parameters, - description: parametersList ? parametersList : emptyValue, - }, - ].map(({ title, description }) => { - return { - title: {title}, - description: {description}, - }; - }); - - const outputList = [ - { - title: ( - {`${OUTPUT_MESSAGES.expandSection.output}:`} - ), - description: ( - // codeblock for output - - {isExpired - ? OUTPUT_MESSAGES.hasExpired(command) - : isCompleted - ? wasSuccessful - ? OUTPUT_MESSAGES.wasSuccessful(command) - : OUTPUT_MESSAGES.hasFailed(command) - : OUTPUT_MESSAGES.isPending(command)} - - ), - }, - ]; - - itemIdToExpandedRowMapValues[item.id] = ( - <> - - - - - - - - - - ); - } - setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues); - }, - [getTestId, itemIdToExpandedRowMap] - ); - // memoized callback for toggleDetails - const onClickCallback = useCallback( - (actionListDataItem: ActionListApiResponse['data'][number]) => () => - toggleDetails(actionListDataItem), - [toggleDetails] - ); - - // table column - const responseActionListColumns = useMemo(() => { - const columns = [ - { - field: 'startedAt', - name: TABLE_COLUMN_NAMES.time, - width: !showHostNames ? '21%' : '15%', - truncateText: true, - render: (startedAt: ActionListApiResponse['data'][number]['startedAt']) => { - return ( - - ); - }, - }, - { - field: 'command', - name: TABLE_COLUMN_NAMES.command, - width: !showHostNames ? '21%' : '10%', - truncateText: true, - render: (_command: ActionListApiResponse['data'][number]['command']) => { - const command = getUiCommand(_command); - return ( - - - {command} - - - ); - }, - }, - { - field: 'createdBy', - name: TABLE_COLUMN_NAMES.user, - width: !showHostNames ? '21%' : '14%', - truncateText: true, - render: (userId: ActionListApiResponse['data'][number]['createdBy']) => { - return ( - - } - > - - - {userId} - - - - ); - }, - }, - // conditional hostnames column - { - field: 'hosts', - name: TABLE_COLUMN_NAMES.hosts, - width: '20%', - truncateText: true, - render: (_hosts: ActionListApiResponse['data'][number]['hosts']) => { - const hosts = _hosts && Object.values(_hosts); - // join hostnames if the action is for multiple agents - // and skip empty strings for names if any - const _hostnames = hosts - .reduce((acc, host) => { - if (host.name.trim()) { - acc.push(host.name); - } - return acc; - }, []) - .join(', '); - - let hostnames = _hostnames; - if (!_hostnames) { - if (hosts.length > 1) { - // when action was for a single agent and no host name - hostnames = UX_MESSAGES.unenrolled.hosts; - } else if (hosts.length === 1) { - // when action was for a multiple agents - // and none of them have a host name - hostnames = UX_MESSAGES.unenrolled.host; - } - } - return ( - - - {hostnames} - - - ); - }, - }, - { - field: 'comment', - name: TABLE_COLUMN_NAMES.comments, - width: !showHostNames ? '21%' : '30%', - truncateText: true, - render: (comment: ActionListApiResponse['data'][number]['comment']) => { - return ( - - - {comment ?? emptyValue} - - - ); - }, - }, - { - field: 'status', - name: TABLE_COLUMN_NAMES.status, - width: !showHostNames ? '15%' : '10%', - render: (_status: ActionListApiResponse['data'][number]['status']) => { - const status = getActionStatus(_status); - - return ( - - - - ); - }, - }, - { - field: '', - align: RIGHT_ALIGNMENT as HorizontalAlignment, - width: '40px', - isExpander: true, - name: ( - - {UX_MESSAGES.screenReaderExpand} - - ), - render: (actionListDataItem: ActionListApiResponse['data'][number]) => { - return ( - - ); - }, - }, - ]; - // filter out the `hosts` column - // if showHostNames is FALSE - if (!showHostNames) { - return columns.filter((column) => column.field !== 'hosts'); - } - return columns; - }, [showHostNames, getTestId, itemIdToExpandedRowMap, onClickCallback]); - - // table pagination - const tablePagination = useMemo(() => { - const pageIndex = isFlyout ? (queryParams.page || 1) - 1 : paginationFromUrlParams.page - 1; - const pageSize = isFlyout ? queryParams.pageSize || 10 : paginationFromUrlParams.pageSize; - return { - // this controls the table UI page - // to match 0-based table paging - pageIndex, - pageSize, - totalItemCount, - pageSizeOptions: MANAGEMENT_PAGE_SIZE_OPTIONS as number[], - }; - }, [ - isFlyout, - paginationFromUrlParams.page, - paginationFromUrlParams.pageSize, - queryParams.page, - queryParams.pageSize, - totalItemCount, - ]); - // handle onChange const handleTableOnChange = useCallback( ({ page: _page }: CriteriaWithPagination) => { @@ -563,16 +186,12 @@ export const ResponseActionsLog = memo< page: index + 1, pageSize: size, }; - if (isFlyout) { - setQueryParams((prevState) => ({ - ...prevState, - ...pagingArgs, - })); - } else { - setQueryParams((prevState) => ({ - ...prevState, - ...pagingArgs, - })); + + setQueryParams((prevState) => ({ + ...prevState, + ...pagingArgs, + })); + if (!isFlyout) { setPaginationOnUrlParams({ ...pagingArgs, }); @@ -582,42 +201,6 @@ export const ResponseActionsLog = memo< [isFlyout, reFetchEndpointActionList, setQueryParams, setPaginationOnUrlParams] ); - // compute record ranges - const pagedResultsCount = useMemo(() => { - const page = queryParams.page ?? 1; - const perPage = queryParams?.pageSize ?? 10; - - const totalPages = Math.ceil(totalItemCount / perPage); - const fromCount = perPage * page - perPage + 1; - const toCount = - page === totalPages || totalPages === 1 ? totalItemCount : fromCount + perPage - 1; - return { fromCount, toCount }; - }, [queryParams.page, queryParams.pageSize, totalItemCount]); - - // create range label to display - const recordRangeLabel = useMemo( - () => ( - - - - {'-'} - - - ), - total: , - recordsLabel: {UX_MESSAGES.recordsLabel(totalItemCount)}, - }} - /> - - ), - [getTestId, pagedResultsCount.fromCount, pagedResultsCount.toCount, totalItemCount] - ); - if (error?.body?.statusCode === 404 && error?.body?.message === 'index_not_found_exception') { return ; } else if (isFetching && isFirstAttempt) { @@ -675,7 +258,7 @@ export const ResponseActionsLog = memo< columns={responseActionListColumns} itemId="id" itemIdToExpandedRowMap={itemIdToExpandedRowMap} - isExpandable={true} + isExpandable pagination={tablePagination} onChange={handleTableOnChange} loading={isFetching} diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/use_response_actions_log_table.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/use_response_actions_log_table.tsx new file mode 100644 index 0000000000000..e4dd30b468127 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/use_response_actions_log_table.tsx @@ -0,0 +1,441 @@ +/* + * 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 React, { useCallback, useMemo, useState } from 'react'; +import type { HorizontalAlignment } from '@elastic/eui'; + +import { + EuiI18nNumber, + EuiAvatar, + EuiButtonIcon, + EuiCodeBlock, + EuiDescriptionList, + EuiFacetButton, + EuiFlexGroup, + EuiFlexItem, + RIGHT_ALIGNMENT, + EuiScreenReaderOnly, + EuiText, + EuiToolTip, +} from '@elastic/eui'; +import { css, euiStyled } from '@kbn/kibana-react-plugin/common'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import type { ActionListApiResponse } from '../../../../common/endpoint/types'; +import type { EndpointActionListRequestQuery } from '../../../../common/endpoint/schema/actions'; +import { FormattedDate } from '../../../common/components/formatted_date'; +import { OUTPUT_MESSAGES, TABLE_COLUMN_NAMES, UX_MESSAGES } from './translations'; +import { getActionStatus, getUiCommand } from './components/hooks'; +import { getEmptyValue } from '../../../common/components/empty_value'; +import { StatusBadge } from './components/status_badge'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +import { MANAGEMENT_PAGE_SIZE_OPTIONS } from '../../common/constants'; + +const emptyValue = getEmptyValue(); + +// Truncated usernames +const StyledFacetButton = euiStyled(EuiFacetButton)` + .euiText { + margin-top: 0.38rem; + overflow-y: visible !important; + } +`; + +const customDescriptionListCss = css` + &.euiDescriptionList { + > .euiDescriptionList__title { + color: ${(props) => props.theme.eui.euiColorDarkShade}; + font-size: ${(props) => props.theme.eui.euiFontSizeXS}; + margin-top: ${(props) => props.theme.eui.euiSizeS}; + } + + > .euiDescriptionList__description { + font-weight: ${(props) => props.theme.eui.euiFontWeightSemiBold}; + margin-top: ${(props) => props.theme.eui.euiSizeS}; + } + } +`; + +const StyledDescriptionList = euiStyled(EuiDescriptionList).attrs({ + compressed: true, + type: 'column', +})` + ${customDescriptionListCss} +`; + +// output section styles +const topSpacingCss = css` + ${(props) => `${props.theme.eui.euiCodeBlockPaddingModifiers.paddingMedium} 0`} +`; +const dashedBorderCss = css` + ${(props) => `1px dashed ${props.theme.eui.euiColorDisabled}`}; +`; +const StyledDescriptionListOutput = euiStyled(EuiDescriptionList).attrs({ compressed: true })` + ${customDescriptionListCss} + dd { + margin: ${topSpacingCss}; + padding: ${topSpacingCss}; + border-top: ${dashedBorderCss}; + border-bottom: ${dashedBorderCss}; + } +`; + +// code block styles +const StyledEuiCodeBlock = euiStyled(EuiCodeBlock).attrs({ + transparentBackground: true, + paddingSize: 'none', +})` + code { + color: ${(props) => props.theme.eui.euiColorDarkShade} !important; + } +`; + +export const useResponseActionsLogTable = ({ + pageIndex, + pageSize, + queryParams, + showHostNames, + totalItemCount, +}: { + pageIndex: number; + pageSize: number; + queryParams: EndpointActionListRequestQuery; + showHostNames: boolean; + totalItemCount: number; +}) => { + const getTestId = useTestIdGenerator('response-actions-list'); + + const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<{ + [k: ActionListApiResponse['data'][number]['id']]: React.ReactNode; + }>({}); + + // expanded tray contents + const toggleDetails = useCallback( + (item: ActionListApiResponse['data'][number]) => { + const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; + if (itemIdToExpandedRowMapValues[item.id]) { + delete itemIdToExpandedRowMapValues[item.id]; + } else { + const { + startedAt, + completedAt, + isCompleted, + wasSuccessful, + isExpired, + command: _command, + parameters, + } = item; + + const parametersList = parameters + ? Object.entries(parameters).map(([key, value]) => { + return `${key}:${value}`; + }) + : undefined; + + const command = getUiCommand(_command); + const dataList = [ + { + title: OUTPUT_MESSAGES.expandSection.placedAt, + description: `${startedAt}`, + }, + { + title: OUTPUT_MESSAGES.expandSection.startedAt, + description: `${startedAt}`, + }, + { + title: OUTPUT_MESSAGES.expandSection.completedAt, + description: `${completedAt ?? emptyValue}`, + }, + { + title: OUTPUT_MESSAGES.expandSection.input, + description: `${command}`, + }, + { + title: OUTPUT_MESSAGES.expandSection.parameters, + description: parametersList ? parametersList : emptyValue, + }, + ].map(({ title, description }) => { + return { + title: {title}, + description: {description}, + }; + }); + + const outputList = [ + { + title: ( + {`${OUTPUT_MESSAGES.expandSection.output}:`} + ), + description: ( + // codeblock for output + + {isExpired + ? OUTPUT_MESSAGES.hasExpired(command) + : isCompleted + ? wasSuccessful + ? OUTPUT_MESSAGES.wasSuccessful(command) + : OUTPUT_MESSAGES.hasFailed(command) + : OUTPUT_MESSAGES.isPending(command)} + + ), + }, + ]; + + itemIdToExpandedRowMapValues[item.id] = ( + <> + + + + + + + + + + ); + } + setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues); + }, + [getTestId, itemIdToExpandedRowMap] + ); + // memoized callback for toggleDetails + const onClickCallback = useCallback( + (actionListDataItem: ActionListApiResponse['data'][number]) => () => + toggleDetails(actionListDataItem), + [toggleDetails] + ); + + const responseActionListColumns = useMemo(() => { + const columns = [ + { + field: 'startedAt', + name: TABLE_COLUMN_NAMES.time, + width: !showHostNames ? '21%' : '15%', + truncateText: true, + render: (startedAt: ActionListApiResponse['data'][number]['startedAt']) => { + return ( + + ); + }, + }, + { + field: 'command', + name: TABLE_COLUMN_NAMES.command, + width: !showHostNames ? '21%' : '10%', + truncateText: true, + render: (_command: ActionListApiResponse['data'][number]['command']) => { + const command = getUiCommand(_command); + return ( + + + {command} + + + ); + }, + }, + { + field: 'createdBy', + name: TABLE_COLUMN_NAMES.user, + width: !showHostNames ? '21%' : '14%', + truncateText: true, + render: (userId: ActionListApiResponse['data'][number]['createdBy']) => { + return ( + + } + > + + + {userId} + + + + ); + }, + }, + // conditional hostnames column + { + field: 'hosts', + name: TABLE_COLUMN_NAMES.hosts, + width: '20%', + truncateText: true, + render: (_hosts: ActionListApiResponse['data'][number]['hosts']) => { + const hosts = _hosts && Object.values(_hosts); + // join hostnames if the action is for multiple agents + // and skip empty strings for names if any + const _hostnames = hosts + .reduce((acc, host) => { + if (host.name.trim()) { + acc.push(host.name); + } + return acc; + }, []) + .join(', '); + + let hostnames = _hostnames; + if (!_hostnames) { + if (hosts.length > 1) { + // when action was for a single agent and no host name + hostnames = UX_MESSAGES.unenrolled.hosts; + } else if (hosts.length === 1) { + // when action was for a multiple agents + // and none of them have a host name + hostnames = UX_MESSAGES.unenrolled.host; + } + } + return ( + + + {hostnames} + + + ); + }, + }, + { + field: 'comment', + name: TABLE_COLUMN_NAMES.comments, + width: !showHostNames ? '21%' : '30%', + truncateText: true, + render: (comment: ActionListApiResponse['data'][number]['comment']) => { + return ( + + + {comment ?? emptyValue} + + + ); + }, + }, + { + field: 'status', + name: TABLE_COLUMN_NAMES.status, + width: !showHostNames ? '15%' : '10%', + render: (_status: ActionListApiResponse['data'][number]['status']) => { + const status = getActionStatus(_status); + + return ( + + + + ); + }, + }, + { + field: '', + align: RIGHT_ALIGNMENT as HorizontalAlignment, + width: '40px', + isExpander: true, + name: ( + + {UX_MESSAGES.screenReaderExpand} + + ), + render: (actionListDataItem: ActionListApiResponse['data'][number]) => { + return ( + + ); + }, + }, + ]; + // filter out the `hosts` column + // if showHostNames is FALSE + if (!showHostNames) { + return columns.filter((column) => column.field !== 'hosts'); + } + return columns; + }, [showHostNames, getTestId, itemIdToExpandedRowMap, onClickCallback]); + + // table pagination + const tablePagination = useMemo(() => { + return { + pageIndex, + pageSize, + totalItemCount, + pageSizeOptions: MANAGEMENT_PAGE_SIZE_OPTIONS as number[], + }; + }, [pageIndex, pageSize, totalItemCount]); + + // compute record ranges + const pagedResultsCount = useMemo(() => { + const page = queryParams.page ?? 1; + const perPage = queryParams?.pageSize ?? 10; + + const totalPages = Math.ceil(totalItemCount / perPage); + const fromCount = perPage * page - perPage + 1; + const toCount = + page === totalPages || totalPages === 1 ? totalItemCount : fromCount + perPage - 1; + return { fromCount, toCount }; + }, [queryParams.page, queryParams.pageSize, totalItemCount]); + + // create range label to display + const recordRangeLabel = useMemo( + () => ( + + + + {'-'} + + + ), + total: , + recordsLabel: {UX_MESSAGES.recordsLabel(totalItemCount)}, + }} + /> + + ), + [getTestId, pagedResultsCount.fromCount, pagedResultsCount.toCount, totalItemCount] + ); + + return { itemIdToExpandedRowMap, responseActionListColumns, recordRangeLabel, tablePagination }; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/page_overlay/page_overlay.tsx b/x-pack/plugins/security_solution/public/management/components/page_overlay/page_overlay.tsx index b5c7629b76aa6..2d3a9c9cb7f07 100644 --- a/x-pack/plugins/security_solution/public/management/components/page_overlay/page_overlay.tsx +++ b/x-pack/plugins/security_solution/public/management/components/page_overlay/page_overlay.tsx @@ -13,6 +13,7 @@ import classnames from 'classnames'; import { useLocation } from 'react-router-dom'; import type { EuiPortalProps } from '@elastic/eui/src/components/portal/portal'; import type { EuiTheme } from '@kbn/kibana-react-plugin/common'; +import { useIsMounted } from '@kbn/securitysolution-hook-utils'; import { useHasFullScreenContent } from '../../../common/containers/use_full_screen'; import { FULL_SCREEN_CONTENT_OVERRIDES_CSS_STYLESHEET, @@ -22,7 +23,6 @@ import { SELECTOR_TIMELINE_IS_VISIBLE_CSS_CLASS_NAME, TIMELINE_EUI_THEME_ZINDEX_LEVEL, } from '../../../timelines/components/timeline/styles'; -import { useIsMounted } from '../../hooks/use_is_mounted'; const OverlayRootContainer = styled.div` border: none; @@ -246,7 +246,7 @@ export const PageOverlay = memo( // Capture the URL `pathname` that the overlay was opened for useEffect(() => { - if (isMounted) { + if (isMounted()) { setOpenedOnPathName((prevState) => { if (isHidden) { return null; @@ -270,7 +270,7 @@ export const PageOverlay = memo( // If `hideOnUrlPathNameChange` is true, then determine if the pathname changed and if so, call `onHide()` useEffect(() => { if ( - isMounted && + isMounted() && onHide && hideOnUrlPathnameChange && !isHidden && @@ -283,7 +283,7 @@ export const PageOverlay = memo( // Handle adding class names to the `document.body` DOM element useEffect(() => { - if (isMounted) { + if (isMounted()) { if (isHidden) { unSetDocumentBodyOverlayIsVisible(); unSetDocumentBodyLock(); diff --git a/x-pack/plugins/security_solution/public/management/hooks/endpoint/use_get_endpoints_list.test.ts b/x-pack/plugins/security_solution/public/management/hooks/endpoint/use_get_endpoints_list.test.ts index 0804bef55b3e7..cdb1041cda7ed 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/endpoint/use_get_endpoints_list.test.ts +++ b/x-pack/plugins/security_solution/public/management/hooks/endpoint/use_get_endpoints_list.test.ts @@ -11,6 +11,8 @@ import { useGetEndpointsList } from './use_get_endpoints_list'; import { HOST_METADATA_LIST_ROUTE } from '../../../../common/endpoint/constants'; import { useQuery as _useQuery } from '@tanstack/react-query'; import { endpointMetadataHttpMocks } from '../../pages/endpoint_hosts/mocks'; +import { EndpointStatus, HostStatus } from '../../../../common/endpoint/types'; +import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; const useQueryMock = _useQuery as jest.Mock; @@ -107,4 +109,127 @@ describe('useGetEndpointsList hook', () => { }) ); }); + + it('should also list inactive agents', async () => { + const getApiResponse = apiMocks.responseProvider.metadataList.getMockImplementation(); + + // set a few of the agents as inactive/unenrolled + apiMocks.responseProvider.metadataList.mockImplementation(() => { + if (getApiResponse) { + return { + ...getApiResponse(), + data: getApiResponse().data.map((item, i) => { + const isInactiveIndex = [0, 1, 3].includes(i); + return { + ...item, + host_status: isInactiveIndex ? HostStatus.INACTIVE : item.host_status, + metadata: { + ...item.metadata, + host: { + ...item.metadata.host, + hostname: isInactiveIndex + ? `${item.metadata.host.hostname}-inactive` + : item.metadata.host.hostname, + }, + Endpoint: { + ...item.metadata.Endpoint, + status: isInactiveIndex + ? EndpointStatus.unenrolled + : item.metadata.Endpoint.status, + }, + }, + }; + }), + }; + } + throw new Error('some error'); + }); + + // verify useGetEndpointsList hook returns the same inactive agents + const res = await renderReactQueryHook(() => useGetEndpointsList({ searchString: 'inactive' })); + expect( + res.data?.map((host) => host.name.split('-')[2]).filter((name) => name === 'inactive').length + ).toEqual(3); + }); + + it('should only list 50 agents when more than 50 in the metadata list API', async () => { + const getApiResponse = apiMocks.responseProvider.metadataList.getMockImplementation(); + + apiMocks.responseProvider.metadataList.mockImplementation(() => { + if (getApiResponse) { + const generator = new EndpointDocGenerator('seed'); + const total = 60; + const data = Array.from({ length: total }, () => { + const endpoint = { + metadata: generator.generateHostMetadata(), + host_status: HostStatus.UNHEALTHY, + }; + + generator.updateCommonInfo(); + + return endpoint; + }); + + return { + ...getApiResponse(), + data, + page: 0, + // this page size is not used by the hook (it uses the default of 50) + // this is only for the test + pageSize: 80, + total, + }; + } + throw new Error('some error'); + }); + + // verify useGetEndpointsList hook returns all 50 agents in the list + const res = await renderReactQueryHook(() => useGetEndpointsList({ searchString: '' })); + expect(res.data?.length).toEqual(50); + }); + + it('should only list 10 more agents when 50 or more agents are already selected', async () => { + const getApiResponse = apiMocks.responseProvider.metadataList.getMockImplementation(); + + apiMocks.responseProvider.metadataList.mockImplementation(() => { + if (getApiResponse) { + const generator = new EndpointDocGenerator('seed'); + const total = 61; + const data = Array.from({ length: total }, () => { + const endpoint = { + metadata: generator.generateHostMetadata(), + host_status: HostStatus.UNHEALTHY, + }; + + generator.updateCommonInfo(); + + return endpoint; + }); + + return { + ...getApiResponse(), + data, + page: 0, + // since we're mocking that all 50 agents are selected + // page size is set to max allowed + pageSize: 10000, + total, + }; + } + throw new Error('some error'); + }); + + // get the first 50 agents to select + const agentIdsToSelect = apiMocks.responseProvider + .metadataList() + .data.map((d) => d.metadata.agent.id) + .slice(0, 50); + + // call useGetEndpointsList with all 50 agents selected + const res = await renderReactQueryHook(() => + useGetEndpointsList({ searchString: '', selectedAgentIds: agentIdsToSelect }) + ); + // verify useGetEndpointsList hook returns 60 agents + expect(res.data?.length).toEqual(60); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/hooks/use_is_mounted.ts b/x-pack/plugins/security_solution/public/management/hooks/use_is_mounted.ts deleted file mode 100644 index 0c5a79b2ca2fc..0000000000000 --- a/x-pack/plugins/security_solution/public/management/hooks/use_is_mounted.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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 { useEffect, useState } from 'react'; - -/** - * Track when a component is mounted/unmounted. Good for use in async processing that may update - * a component's internal state. - */ -export const useIsMounted = (): boolean => { - const [isMounted, setIsMounted] = useState(false); - - useEffect(() => { - setIsMounted(true); - - return () => { - setIsMounted(false); - }; - }, []); - - return isMounted; -}; diff --git a/x-pack/plugins/security_solution/public/management/components/mocks.tsx b/x-pack/plugins/security_solution/public/management/mocks/utils.ts similarity index 93% rename from x-pack/plugins/security_solution/public/management/components/mocks.tsx rename to x-pack/plugins/security_solution/public/management/mocks/utils.ts index 45c12df818fd8..946d2d50b05d2 100644 --- a/x-pack/plugins/security_solution/public/management/components/mocks.tsx +++ b/x-pack/plugins/security_solution/public/management/mocks/utils.ts @@ -4,7 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -interface DeferredInterface { + +export interface DeferredInterface { promise: Promise; resolve: (data: T) => void; reject: (e: Error) => void; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.test.tsx index 37f04ff804c1d..b8f7a8c19fbcd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.test.tsx @@ -20,7 +20,7 @@ import { PolicyArtifactsDeleteModal } from './policy_artifacts_delete_modal'; import { exceptionsListAllHttpMocks } from '../../../../../mocks/exceptions_list_http_mocks'; import { ExceptionsListApiClient } from '../../../../../services/exceptions_list/exceptions_list_api_client'; import { POLICY_ARTIFACT_DELETE_MODAL_LABELS } from './translations'; -import { getDeferred } from '../../../../../components/mocks'; +import { getDeferred } from '../../../../../mocks/utils'; const listType: Array = [ 'endpoint_events', diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/list_handler.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/list_handler.test.ts new file mode 100644 index 0000000000000..51326c8adbd12 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/list_handler.test.ts @@ -0,0 +1,169 @@ +/* + * 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. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { KibanaResponseFactory, RequestHandler, RouteConfig } from '@kbn/core/server'; +import { + coreMock, + elasticsearchServiceMock, + httpServerMock, + httpServiceMock, + loggingSystemMock, + savedObjectsClientMock, +} from '@kbn/core/server/mocks'; +import type { EndpointActionListRequestQuery } from '../../../../common/endpoint/schema/actions'; +import { ENDPOINTS_ACTION_LIST_ROUTE } from '../../../../common/endpoint/constants'; +import { parseExperimentalConfigValue } from '../../../../common/experimental_features'; +import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; +import { EndpointAppContextService } from '../../endpoint_app_context_services'; +import { + createMockEndpointAppContextServiceSetupContract, + createMockEndpointAppContextServiceStartContract, + createRouteHandlerContext, +} from '../../mocks'; +import { registerActionListRoutes } from './list'; +import type { SecuritySolutionRequestHandlerContext } from '../../../types'; +import { doesLogsEndpointActionsIndexExist } from '../../utils'; +import { getActionList, getActionListByStatus } from '../../services'; + +jest.mock('../../utils'); +const mockDoesLogsEndpointActionsIndexExist = doesLogsEndpointActionsIndexExist as jest.Mock; + +jest.mock('../../services'); +const mockGetActionList = getActionList as jest.Mock; +const mockGetActionListByStatus = getActionListByStatus as jest.Mock; + +describe(' Action List Handler', () => { + let endpointAppContextService: EndpointAppContextService; + let mockResponse: jest.Mocked; + + let actionListHandler: ( + query?: EndpointActionListRequestQuery + ) => Promise>; + + beforeEach(() => { + const esClientMock = elasticsearchServiceMock.createScopedClusterClient(); + const routerMock = httpServiceMock.createRouter(); + endpointAppContextService = new EndpointAppContextService(); + endpointAppContextService.setup(createMockEndpointAppContextServiceSetupContract()); + endpointAppContextService.start(createMockEndpointAppContextServiceStartContract()); + mockDoesLogsEndpointActionsIndexExist.mockResolvedValue(true); + + registerActionListRoutes(routerMock, { + logFactory: loggingSystemMock.create(), + service: endpointAppContextService, + config: () => Promise.resolve(createMockConfig()), + experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental), + }); + + actionListHandler = async ( + query?: EndpointActionListRequestQuery + ): Promise> => { + const req = httpServerMock.createKibanaRequest({ + query, + }); + mockResponse = httpServerMock.createResponseFactory(); + const [, routeHandler]: [ + RouteConfig, + RequestHandler< + unknown, + EndpointActionListRequestQuery, + unknown, + SecuritySolutionRequestHandlerContext + > + ] = routerMock.get.mock.calls.find(([{ path }]) => + path.startsWith(ENDPOINTS_ACTION_LIST_ROUTE) + )!; + await routeHandler( + coreMock.createCustomRequestHandlerContext( + createRouteHandlerContext(esClientMock, savedObjectsClientMock.create()) + ) as SecuritySolutionRequestHandlerContext, + req, + mockResponse + ); + + return mockResponse; + }; + }); + + afterEach(() => { + endpointAppContextService.stop(); + }); + + describe('Internals', () => { + it('should return `notFound` when actions index does not exist', async () => { + mockDoesLogsEndpointActionsIndexExist.mockResolvedValue(false); + await actionListHandler({ pageSize: 10, page: 1 }); + expect(mockResponse.notFound).toHaveBeenCalledWith({ + body: 'index_not_found_exception', + }); + }); + + it('should return `ok` when actions index exists', async () => { + await actionListHandler({ pageSize: 10, page: 1 }); + expect(mockResponse.ok).toHaveBeenCalled(); + }); + + it('should call `getActionListByStatus` when statuses filter values are provided', async () => { + await actionListHandler({ pageSize: 10, page: 1, statuses: ['failed', 'pending'] }); + expect(mockGetActionListByStatus).toBeCalledWith( + expect.objectContaining({ statuses: ['failed', 'pending'] }) + ); + }); + + it('should correctly format the request when calling `getActionListByStatus`', async () => { + await actionListHandler({ + agentIds: 'agentX', + commands: 'running-processes', + statuses: 'failed', + userIds: 'userX', + }); + expect(mockGetActionListByStatus).toBeCalledWith( + expect.objectContaining({ + elasticAgentIds: ['agentX'], + commands: ['running-processes'], + statuses: ['failed'], + userIds: ['userX'], + }) + ); + }); + + it('should call `getActionList` when statuses filter values are not provided', async () => { + await actionListHandler({ + pageSize: 10, + page: 1, + commands: ['isolate', 'kill-process'], + userIds: ['userX', 'userY'], + }); + expect(mockGetActionList).toBeCalledWith( + expect.objectContaining({ + commands: ['isolate', 'kill-process'], + userIds: ['userX', 'userY'], + }) + ); + }); + + it('should correctly format the request when calling `getActionList`', async () => { + await actionListHandler({ + page: 1, + pageSize: 10, + agentIds: 'agentX', + commands: 'isolate', + userIds: 'userX', + }); + + expect(mockGetActionList).toHaveBeenCalledWith( + expect.objectContaining({ + commands: ['isolate'], + elasticAgentIds: ['agentX'], + userIds: ['userX'], + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 3c13f4bfe9c53..e0ff0ef2793f7 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -25697,9 +25697,6 @@ "xpack.securitySolution.endpoint.resolver.relatedEventLimitTitle": "Cette liste inclut {numberOfEntries} événements de processus.", "xpack.securitySolution.endpointPolicyStatus.revisionNumber": "rév. {revNumber}", "xpack.securitySolution.endpointResponseActions.actionError.errorMessage": "{ errorCount, plural, =1 {Erreur rencontrée} other {Erreurs rencontrées}} :", - "xpack.securitySolution.endpointResponseActions.getProcesses.performApiErrorMessage": "L'erreur suivante a été rencontrée : {error}", - "xpack.securitySolution.endpointResponseActions.killProcess.performApiErrorMessage": "L'erreur suivante a été rencontrée : {error}", - "xpack.securitySolution.endpointResponseActions.suspendProcess.performApiErrorMessage": "L'erreur suivante a été rencontrée : {error}", "xpack.securitySolution.event.reason.reasonRendererTitle": "Outil de rendu d'événement : {eventRendererName} ", "xpack.securitySolution.eventDetails.nestedColumnCheckboxAriaLabel": "Le champ {field} est un objet, et il est composé de champs imbriqués qui peuvent être ajoutés en tant que colonne", "xpack.securitySolution.eventDetails.viewColumnCheckboxAriaLabel": "Afficher la colonne {field}", @@ -28119,8 +28116,6 @@ "xpack.securitySolution.endpointManagement.noPermissionsSubText": "Vous devez disposer du rôle de superutilisateur pour utiliser cette fonctionnalité. Si vous ne disposez pas de ce rôle, ni d'autorisations pour modifier les rôles d'utilisateur, contactez votre administrateur Kibana.", "xpack.securitySolution.endpointManagemnet.noPermissionsText": "Vous ne disposez pas des autorisations Kibana requises pour utiliser Elastic Security Administration", "xpack.securitySolution.endpointPolicyStatus.tooltipTitleLabel": "Politique appliquée", - "xpack.securitySolution.endpointResponseActions.getProcesses.errorMessageTitle": "Échec de l’obtention des processus", - "xpack.securitySolution.endpointResponseActions.getProcesses.performApiErrorMessageTitle": "Échec de l’exécution de l’action d’obtention des processus", "xpack.securitySolution.endpointResponseActions.getProcesses.table.header.command": "COMMANDE", "xpack.securitySolution.endpointResponseActions.getProcesses.table.header.enityId": "ID D’ENTITÉ", "xpack.securitySolution.endpointResponseActions.getProcesses.table.header.pid": "PID", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 25c1c92f19b99..8715883e4251c 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -25672,9 +25672,6 @@ "xpack.securitySolution.endpoint.resolver.relatedEventLimitTitle": "このリストには、{numberOfEntries} 件のプロセスイベントが含まれています。", "xpack.securitySolution.endpointPolicyStatus.revisionNumber": "rev. {revNumber}", "xpack.securitySolution.endpointResponseActions.actionError.errorMessage": "次の{ errorCount, plural, other {件のエラー}}が発生しました:", - "xpack.securitySolution.endpointResponseActions.getProcesses.performApiErrorMessage": "次のエラーが発生しました:{error}", - "xpack.securitySolution.endpointResponseActions.killProcess.performApiErrorMessage": "次のエラーが発生しました:{error}", - "xpack.securitySolution.endpointResponseActions.suspendProcess.performApiErrorMessage": "次のエラーが発生しました:{error}", "xpack.securitySolution.event.reason.reasonRendererTitle": "イベントレンダラー:{eventRendererName} ", "xpack.securitySolution.eventDetails.nestedColumnCheckboxAriaLabel": "{field}フィールドはオブジェクトであり、列として追加できるネストされたフィールドに分解されます", "xpack.securitySolution.eventDetails.viewColumnCheckboxAriaLabel": "{field} 列を表示", @@ -28094,8 +28091,6 @@ "xpack.securitySolution.endpointManagement.noPermissionsSubText": "この機能を使用するには、スーパーユーザーロールが必要です。スーパーユーザーロールがなく、ユーザーロールを編集する権限もない場合は、Kibana管理者に問い合わせてください。", "xpack.securitySolution.endpointManagemnet.noPermissionsText": "Elastic Security Administrationを使用するために必要なKibana権限がありません。", "xpack.securitySolution.endpointPolicyStatus.tooltipTitleLabel": "ポリシーが適用されました", - "xpack.securitySolution.endpointResponseActions.getProcesses.errorMessageTitle": "プロセスの取得アクションが失敗しました", - "xpack.securitySolution.endpointResponseActions.getProcesses.performApiErrorMessageTitle": "プロセスの取得アクションの実行が失敗しました", "xpack.securitySolution.endpointResponseActions.getProcesses.table.header.command": "コマンド", "xpack.securitySolution.endpointResponseActions.getProcesses.table.header.enityId": "エンティティID", "xpack.securitySolution.endpointResponseActions.getProcesses.table.header.pid": "PID", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index cdec4730c09d3..ee88a0ed92217 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -25706,9 +25706,6 @@ "xpack.securitySolution.endpoint.resolver.relatedEventLimitTitle": "此列表包括 {numberOfEntries} 个进程事件。", "xpack.securitySolution.endpointPolicyStatus.revisionNumber": "修订版 {revNumber}", "xpack.securitySolution.endpointResponseActions.actionError.errorMessage": "遇到以下{ errorCount, plural, other {错误}}:", - "xpack.securitySolution.endpointResponseActions.getProcesses.performApiErrorMessage": "遇到以下错误:{error}", - "xpack.securitySolution.endpointResponseActions.killProcess.performApiErrorMessage": "遇到以下错误:{error}", - "xpack.securitySolution.endpointResponseActions.suspendProcess.performApiErrorMessage": "遇到以下错误:{error}", "xpack.securitySolution.event.reason.reasonRendererTitle": "事件渲染器:{eventRendererName} ", "xpack.securitySolution.eventDetails.nestedColumnCheckboxAriaLabel": "{field} 字段是对象,并分解为可以添加为列的嵌套字段", "xpack.securitySolution.eventDetails.viewColumnCheckboxAriaLabel": "查看 {field} 列", @@ -28128,8 +28125,6 @@ "xpack.securitySolution.endpointManagement.noPermissionsSubText": "您必须具有超级用户角色才能使用此功能。如果您不具有超级用户角色,且无权编辑用户角色,请与 Kibana 管理员联系。", "xpack.securitySolution.endpointManagemnet.noPermissionsText": "您没有所需的 Kibana 权限,无法使用 Elastic Security 管理", "xpack.securitySolution.endpointPolicyStatus.tooltipTitleLabel": "已应用策略", - "xpack.securitySolution.endpointResponseActions.getProcesses.errorMessageTitle": "获取进程操作失败", - "xpack.securitySolution.endpointResponseActions.getProcesses.performApiErrorMessageTitle": "执行获取进程操作失败", "xpack.securitySolution.endpointResponseActions.getProcesses.table.header.command": "命令", "xpack.securitySolution.endpointResponseActions.getProcesses.table.header.enityId": "实体 ID", "xpack.securitySolution.endpointResponseActions.getProcesses.table.header.pid": "PID", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/rule_quick_edit_buttons.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/rule_quick_edit_buttons.test.tsx index 4e6ae5c7cdb91..1605f09ebc0f5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/rule_quick_edit_buttons.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/rule_quick_edit_buttons.test.tsx @@ -20,9 +20,13 @@ jest.mock('../../../../common/lib/kibana', () => ({ const setRulesToUpdateAPIKey = jest.fn(); const setRulesToSnooze = jest.fn(); +const setRulesToUnsnooze = jest.fn(); const setRulesToSchedule = jest.fn(); +const setRulesToUnschedule = jest.fn(); const setRulesToSnoozeFilter = jest.fn(); +const setRulesToUnsnoozeFilter = jest.fn(); const setRulesToScheduleFilter = jest.fn(); +const setRulesToUnscheduleFilter = jest.fn(); const setRulesToUpdateAPIKeyFilter = jest.fn(); describe('rule_quick_edit_buttons', () => { @@ -46,9 +50,13 @@ describe('rule_quick_edit_buttons', () => { setRulesToDelete={() => {}} setRulesToUpdateAPIKey={() => {}} setRulesToSnooze={() => {}} + setRulesToUnsnooze={() => {}} setRulesToSchedule={() => {}} + setRulesToUnschedule={() => {}} setRulesToSnoozeFilter={() => {}} + setRulesToUnsnoozeFilter={() => {}} setRulesToScheduleFilter={() => {}} + setRulesToUnscheduleFilter={() => {}} setRulesToUpdateAPIKeyFilter={() => {}} /> ); @@ -58,7 +66,9 @@ describe('rule_quick_edit_buttons', () => { expect(wrapper.find('[data-test-subj="updateAPIKeys"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="deleteAll"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="bulkSnooze"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="bulkUnsnooze"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="bulkSnoozeSchedule"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="bulkRemoveSnoozeSchedule"]').exists()).toBeTruthy(); }); it('renders enableAll if rules are all disabled', async () => { @@ -77,9 +87,13 @@ describe('rule_quick_edit_buttons', () => { setRulesToDelete={() => {}} setRulesToUpdateAPIKey={() => {}} setRulesToSnooze={() => {}} + setRulesToUnsnooze={() => {}} setRulesToSchedule={() => {}} + setRulesToUnschedule={() => {}} setRulesToSnoozeFilter={() => {}} + setRulesToUnsnoozeFilter={() => {}} setRulesToScheduleFilter={() => {}} + setRulesToUnscheduleFilter={() => {}} setRulesToUpdateAPIKeyFilter={() => {}} /> ); @@ -104,20 +118,27 @@ describe('rule_quick_edit_buttons', () => { setRulesToDelete={() => {}} setRulesToUpdateAPIKey={() => {}} setRulesToSnooze={() => {}} + setRulesToUnsnooze={() => {}} setRulesToSchedule={() => {}} + setRulesToUnschedule={() => {}} setRulesToSnoozeFilter={() => {}} + setRulesToUnsnoozeFilter={() => {}} setRulesToScheduleFilter={() => {}} + setRulesToUnscheduleFilter={() => {}} setRulesToUpdateAPIKeyFilter={() => {}} /> ); expect(wrapper.find('[data-test-subj="disableAll"]').first().prop('isDisabled')).toBeTruthy(); expect(wrapper.find('[data-test-subj="deleteAll"]').first().prop('isDisabled')).toBeTruthy(); - - expect(wrapper.find('[data-test-subj="updateAPIKeys"]').first().prop('isDiabled')).toBeFalsy(); - expect(wrapper.find('[data-test-subj="bulkSnooze"]').first().prop('isDiabled')).toBeFalsy(); + expect(wrapper.find('[data-test-subj="updateAPIKeys"]').first().prop('isDisabled')).toBeFalsy(); + expect(wrapper.find('[data-test-subj="bulkSnooze"]').first().prop('isDisabled')).toBeFalsy(); + expect(wrapper.find('[data-test-subj="bulkUnsnooze"]').first().prop('isDisabled')).toBeFalsy(); + expect( + wrapper.find('[data-test-subj="bulkSnoozeSchedule"]').first().prop('isDisabled') + ).toBeFalsy(); expect( - wrapper.find('[data-test-subj="bulkSnoozeSchedule"]').first().prop('isDiabled') + wrapper.find('[data-test-subj="bulkRemoveSnoozeSchedule"]').first().prop('isDisabled') ).toBeFalsy(); }); @@ -137,10 +158,14 @@ describe('rule_quick_edit_buttons', () => { onActionPerformed={() => {}} setRulesToDelete={() => {}} setRulesToSnooze={setRulesToSnooze} + setRulesToUnsnooze={setRulesToUnsnooze} setRulesToSchedule={setRulesToSchedule} + setRulesToUnschedule={setRulesToUnschedule} setRulesToUpdateAPIKey={setRulesToUpdateAPIKey} setRulesToSnoozeFilter={setRulesToSnoozeFilter} + setRulesToUnsnoozeFilter={setRulesToUnsnoozeFilter} setRulesToScheduleFilter={setRulesToScheduleFilter} + setRulesToUnscheduleFilter={setRulesToUnscheduleFilter} setRulesToUpdateAPIKeyFilter={setRulesToUpdateAPIKeyFilter} /> ); @@ -148,14 +173,22 @@ describe('rule_quick_edit_buttons', () => { wrapper.find('[data-test-subj="bulkSnooze"]').first().simulate('click'); expect(setRulesToSnooze).toHaveBeenCalledTimes(1); + wrapper.find('[data-test-subj="bulkUnsnooze"]').first().simulate('click'); + expect(setRulesToUnsnooze).toHaveBeenCalledTimes(1); + wrapper.find('[data-test-subj="bulkSnoozeSchedule"]').first().simulate('click'); expect(setRulesToSchedule).toHaveBeenCalledTimes(1); + wrapper.find('[data-test-subj="bulkRemoveSnoozeSchedule"]').first().simulate('click'); + expect(setRulesToUnschedule).toHaveBeenCalledTimes(1); + wrapper.find('[data-test-subj="updateAPIKeys"]').first().simulate('click'); expect(setRulesToUpdateAPIKey).toHaveBeenCalledTimes(1); expect(setRulesToSnoozeFilter).not.toHaveBeenCalled(); + expect(setRulesToUnsnoozeFilter).not.toHaveBeenCalled(); expect(setRulesToScheduleFilter).not.toHaveBeenCalled(); + expect(setRulesToUnscheduleFilter).not.toHaveBeenCalled(); expect(setRulesToUpdateAPIKeyFilter).not.toHaveBeenCalled(); }); @@ -175,10 +208,14 @@ describe('rule_quick_edit_buttons', () => { onActionPerformed={() => {}} setRulesToDelete={() => {}} setRulesToSnooze={setRulesToSnooze} + setRulesToUnsnooze={setRulesToUnsnooze} setRulesToSchedule={setRulesToSchedule} + setRulesToUnschedule={setRulesToUnschedule} setRulesToUpdateAPIKey={setRulesToUpdateAPIKey} setRulesToSnoozeFilter={setRulesToSnoozeFilter} + setRulesToUnsnoozeFilter={setRulesToUnsnoozeFilter} setRulesToScheduleFilter={setRulesToScheduleFilter} + setRulesToUnscheduleFilter={setRulesToUnscheduleFilter} setRulesToUpdateAPIKeyFilter={setRulesToUpdateAPIKeyFilter} /> ); @@ -186,14 +223,22 @@ describe('rule_quick_edit_buttons', () => { wrapper.find('[data-test-subj="bulkSnooze"]').first().simulate('click'); expect(setRulesToSnoozeFilter).toHaveBeenCalledTimes(1); + wrapper.find('[data-test-subj="bulkUnsnooze"]').first().simulate('click'); + expect(setRulesToUnsnoozeFilter).toHaveBeenCalledTimes(1); + wrapper.find('[data-test-subj="bulkSnoozeSchedule"]').first().simulate('click'); expect(setRulesToScheduleFilter).toHaveBeenCalledTimes(1); + wrapper.find('[data-test-subj="bulkRemoveSnoozeSchedule"]').first().simulate('click'); + expect(setRulesToUnscheduleFilter).toHaveBeenCalledTimes(1); + wrapper.find('[data-test-subj="updateAPIKeys"]').first().simulate('click'); expect(setRulesToUpdateAPIKeyFilter).toHaveBeenCalledTimes(1); - expect(setRulesToSchedule).not.toHaveBeenCalled(); expect(setRulesToSnooze).not.toHaveBeenCalled(); + expect(setRulesToUnsnooze).not.toHaveBeenCalled(); + expect(setRulesToSchedule).not.toHaveBeenCalled(); + expect(setRulesToUnschedule).not.toHaveBeenCalled(); expect(setRulesToUpdateAPIKey).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/rule_quick_edit_buttons.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/rule_quick_edit_buttons.tsx index 0b7db1ebeceba..c91c461993a54 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/rule_quick_edit_buttons.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/rule_quick_edit_buttons.tsx @@ -25,14 +25,20 @@ export type ComponentOpts = { onPerformingAction?: () => void; onActionPerformed?: () => void; isSnoozingRules?: boolean; + isUnsnoozingRules?: boolean; isSchedulingRules?: boolean; + isUnschedulingRules?: boolean; isUpdatingRuleAPIKeys?: boolean; setRulesToDelete: React.Dispatch>; setRulesToUpdateAPIKey: React.Dispatch>; setRulesToSnooze: React.Dispatch>; + setRulesToUnsnooze: React.Dispatch>; setRulesToSchedule: React.Dispatch>; + setRulesToUnschedule: React.Dispatch>; setRulesToSnoozeFilter: React.Dispatch>; + setRulesToUnsnoozeFilter: React.Dispatch>; setRulesToScheduleFilter: React.Dispatch>; + setRulesToUnscheduleFilter: React.Dispatch>; setRulesToUpdateAPIKeyFilter: React.Dispatch>; } & BulkOperationsComponentOpts; @@ -65,16 +71,22 @@ export const RuleQuickEditButtons: React.FunctionComponent = ({ onPerformingAction = noop, onActionPerformed = noop, isSnoozingRules = false, + isUnsnoozingRules = false, isSchedulingRules = false, + isUnschedulingRules = false, isUpdatingRuleAPIKeys = false, enableRules, disableRules, setRulesToDelete, setRulesToUpdateAPIKey, setRulesToSnooze, + setRulesToUnsnooze, setRulesToSchedule, + setRulesToUnschedule, setRulesToSnoozeFilter, + setRulesToUnsnoozeFilter, setRulesToScheduleFilter, + setRulesToUnscheduleFilter, setRulesToUpdateAPIKeyFilter, }: ComponentOpts) => { const { @@ -90,7 +102,9 @@ export const RuleQuickEditButtons: React.FunctionComponent = ({ isDisablingRules || isDeletingRules || isSnoozingRules || + isUnsnoozingRules || isSchedulingRules || + isUnschedulingRules || isUpdatingRuleAPIKeys; const allRulesDisabled = useMemo(() => { @@ -220,6 +234,28 @@ export const RuleQuickEditButtons: React.FunctionComponent = ({ } } + async function onUnsnoozeAllClick() { + onPerformingAction(); + try { + if (isAllSelected) { + setRulesToUnsnoozeFilter(getFilter()); + } else { + setRulesToUnsnooze(selectedItems); + } + } catch (e) { + toasts.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.bulkActionPopover.failedToSnoozeRules', + { + defaultMessage: 'Failed to snooze or unsnooze rules', + } + ), + }); + } finally { + onActionPerformed(); + } + } + async function onScheduleAllClick() { onPerformingAction(); try { @@ -242,6 +278,28 @@ export const RuleQuickEditButtons: React.FunctionComponent = ({ } } + async function onUnscheduleAllClick() { + onPerformingAction(); + try { + if (isAllSelected) { + setRulesToUnscheduleFilter(getFilter()); + } else { + setRulesToUnschedule(selectedItems); + } + } catch (e) { + toasts.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.bulkActionPopover.failedToSnoozeRules', + { + defaultMessage: 'Failed to snooze or unsnooze rules', + } + ), + }); + } finally { + onActionPerformed(); + } + } + return ( = ({ />
+ + + + + = ({ /> + + + + + void; onSave: () => void; - setIsLoading: (isLoading: boolean) => void; + setIsSnoozingRule: (isLoading: boolean) => void; + setIsUnsnoozingRule: (isLoading: boolean) => void; onSearchPopulate?: (filter: string) => void; } & BulkOperationsComponentOpts; @@ -43,13 +47,29 @@ const failureMessage = i18n.translate( } ); +const deleteConfirmPlural = (total: number) => + i18n.translate('xpack.triggersActionsUI.sections.rulesList.bulkUnsnoozeConfirmationPlural', { + defaultMessage: 'Unsnooze {total, plural, one {# rule} other {# rules}}? ', + values: { total }, + }); + +const deleteConfirmSingle = (ruleName: string) => + i18n.translate('xpack.triggersActionsUI.sections.rulesList.bulkUnsnoozeConfirmationSingle', { + defaultMessage: 'Unsnooze {ruleName}?', + values: { ruleName }, + }); + export const BulkSnoozeModal = (props: BulkSnoozeModalProps) => { const { rulesToSnooze, + rulesToUnsnooze, rulesToSnoozeFilter, + rulesToUnsnoozeFilter, + numberOfSelectedRules = 0, onClose, onSave, - setIsLoading, + setIsSnoozingRule, + setIsUnsnoozingRule, onSearchPopulate, bulkSnoozeRules, bulkUnsnoozeRules, @@ -68,12 +88,12 @@ export const BulkSnoozeModal = (props: BulkSnoozeModalProps) => { return rulesToSnooze.length > 0; }, [rulesToSnooze, rulesToSnoozeFilter]); - const isSnoozed = useMemo(() => { - if (rulesToSnoozeFilter) { + const isUnsnoozeModalOpen = useMemo(() => { + if (rulesToUnsnoozeFilter) { return true; } - return rulesToSnooze.some((item) => isRuleSnoozed(item)); - }, [rulesToSnooze, rulesToSnoozeFilter]); + return rulesToUnsnooze.length > 0; + }, [rulesToUnsnooze, rulesToUnsnoozeFilter]); const interval = useMemo(() => { if (rulesToSnoozeFilter) { @@ -87,7 +107,7 @@ export const BulkSnoozeModal = (props: BulkSnoozeModalProps) => { const onSnoozeRule = async (schedule: SnoozeSchedule) => { onClose(); - setIsLoading(true); + setIsSnoozingRule(true); try { const response = await bulkSnoozeRules({ ids: rulesToSnooze.map((item) => item.id), @@ -100,18 +120,17 @@ export const BulkSnoozeModal = (props: BulkSnoozeModalProps) => { title: failureMessage, }); } - setIsLoading(false); + setIsSnoozingRule(false); onSave(); }; - const onUnsnoozeRule = async (scheduleIds?: string[]) => { + const onUnsnoozeRule = async () => { onClose(); - setIsLoading(true); + setIsUnsnoozingRule(true); try { const response = await bulkUnsnoozeRules({ - ids: rulesToSnooze.map((item) => item.id), - filter: rulesToSnoozeFilter, - scheduleIds, + ids: rulesToUnsnooze.map((item) => item.id), + filter: rulesToUnsnoozeFilter, }); showToast(response, 'snooze'); } catch (error) { @@ -119,10 +138,42 @@ export const BulkSnoozeModal = (props: BulkSnoozeModalProps) => { title: failureMessage, }); } - setIsLoading(false); + setIsUnsnoozingRule(false); onSave(); }; + const confirmationTitle = useMemo(() => { + if (!rulesToUnsnoozeFilter && numberOfSelectedRules === 1 && rulesToUnsnooze[0]) { + return deleteConfirmSingle(rulesToUnsnooze[0].name); + } + return deleteConfirmPlural(numberOfSelectedRules); + }, [rulesToUnsnooze, rulesToUnsnoozeFilter, numberOfSelectedRules]); + + if (isUnsnoozeModalOpen) { + return ( + + ); + } + if (isSnoozeModalOpen) { return ( @@ -138,11 +189,11 @@ export const BulkSnoozeModal = (props: BulkSnoozeModalProps) => { @@ -153,6 +204,7 @@ export const BulkSnoozeModal = (props: BulkSnoozeModalProps) => { ); } + return null; }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/bulk_snooze_schedule_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/bulk_snooze_schedule_modal.tsx index 56293c01614de..cbf009acaa9ae 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/bulk_snooze_schedule_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/bulk_snooze_schedule_modal.tsx @@ -5,18 +5,18 @@ * 2.0. */ -import React, { useMemo, useState } from 'react'; +import React, { useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { + EuiConfirmModal, EuiModal, EuiModalHeader, + EuiModalHeaderTitle, EuiModalBody, EuiModalFooter, EuiSpacer, EuiButtonEmpty, - EuiModalHeaderTitle, - EuiConfirmModal, } from '@elastic/eui'; import { withBulkRuleOperations, @@ -49,24 +49,30 @@ const deleteConfirmSingle = (ruleName: string) => export type BulkSnoozeScheduleModalProps = { rulesToSchedule: RuleTableItem[]; + rulesToUnschedule: RuleTableItem[]; rulesToScheduleFilter?: string; + rulesToUnscheduleFilter?: string; numberOfSelectedRules?: number; onClose: () => void; onSave: () => void; - setIsLoading: (isLoading: boolean) => void; + setIsSchedulingRule: (isLoading: boolean) => void; + setIsUnschedulingRule: (isLoading: boolean) => void; onSearchPopulate?: (filter: string) => void; } & BulkOperationsComponentOpts; export const BulkSnoozeScheduleModal = (props: BulkSnoozeScheduleModalProps) => { const { rulesToSchedule, + rulesToUnschedule, rulesToScheduleFilter, + rulesToUnscheduleFilter, numberOfSelectedRules = 0, onClose, onSave, bulkSnoozeRules, bulkUnsnoozeRules, - setIsLoading, + setIsSchedulingRule, + setIsUnschedulingRule, onSearchPopulate, } = props; @@ -76,8 +82,6 @@ export const BulkSnoozeScheduleModal = (props: BulkSnoozeScheduleModalProps) => const { showToast } = useBulkEditResponse({ onSearchPopulate }); - const [showConfirmation, setShowConfirmation] = useState(false); - const isScheduleModalOpen = useMemo(() => { if (rulesToScheduleFilter) { return true; @@ -85,9 +89,16 @@ export const BulkSnoozeScheduleModal = (props: BulkSnoozeScheduleModalProps) => return rulesToSchedule.length > 0; }, [rulesToSchedule, rulesToScheduleFilter]); + const isUnscheduleModalOpen = useMemo(() => { + if (rulesToUnscheduleFilter) { + return true; + } + return rulesToUnschedule.length > 0; + }, [rulesToUnschedule, rulesToUnscheduleFilter]); + const onAddSnoozeSchedule = async (schedule: SnoozeSchedule) => { onClose(); - setIsLoading(true); + setIsSchedulingRule(true); try { const response = await bulkSnoozeRules({ ids: rulesToSchedule.map((item) => item.id), @@ -100,18 +111,17 @@ export const BulkSnoozeScheduleModal = (props: BulkSnoozeScheduleModalProps) => title: failureMessage, }); } - setIsLoading(false); + setIsSchedulingRule(false); onSave(); }; const onRemoveSnoozeSchedule = async () => { - setShowConfirmation(false); onClose(); - setIsLoading(true); + setIsUnschedulingRule(true); try { const response = await bulkUnsnoozeRules({ - ids: rulesToSchedule.map((item) => item.id), - filter: rulesToScheduleFilter, + ids: rulesToUnschedule.map((item) => item.id), + filter: rulesToUnscheduleFilter, scheduleIds: [], }); showToast(response, 'snoozeSchedule'); @@ -120,7 +130,7 @@ export const BulkSnoozeScheduleModal = (props: BulkSnoozeScheduleModalProps) => title: failureMessage, }); } - setIsLoading(false); + setIsUnschedulingRule(false); onSave(); }; @@ -131,14 +141,11 @@ export const BulkSnoozeScheduleModal = (props: BulkSnoozeScheduleModalProps) => return deleteConfirmPlural(numberOfSelectedRules); }, [rulesToSchedule, rulesToScheduleFilter, numberOfSelectedRules]); - if (showConfirmation) { + if (isUnscheduleModalOpen) { return ( { - setShowConfirmation(false); - onClose(); - }} + onCancel={onClose} onConfirm={onRemoveSnoozeSchedule} confirmButtonText={i18n.translate( 'xpack.triggersActionsUI.sections.rulesList.bulkDeleteConfirmButton', @@ -154,6 +161,7 @@ export const BulkSnoozeScheduleModal = (props: BulkSnoozeScheduleModalProps) => )} buttonColor="danger" defaultFocusedButton="confirm" + data-test-subj="bulkRemoveScheduleConfirmationModal" /> ); } @@ -172,13 +180,12 @@ export const BulkSnoozeScheduleModal = (props: BulkSnoozeScheduleModalProps) => setShowConfirmation(true)} + onCancelSchedules={onRemoveSnoozeSchedule} onClose={() => {}} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx index 3bde062935a86..6bfe953e28696 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx @@ -80,8 +80,14 @@ jest.mock('../../../../common/get_experimental_features', () => ({ const ruleTags = ['a', 'b', 'c', 'd']; -const { loadRuleTypes, updateAPIKey, loadRuleTags, bulkSnoozeRules, bulkUpdateAPIKey } = - jest.requireMock('../../../lib/rule_api'); +const { + loadRuleTypes, + updateAPIKey, + loadRuleTags, + bulkSnoozeRules, + bulkUnsnoozeRules, + bulkUpdateAPIKey, +} = jest.requireMock('../../../lib/rule_api'); const { loadRuleAggregationsWithKueryFilter } = jest.requireMock( '../../../lib/rule_api/aggregate_kuery_filter' ); @@ -1994,6 +2000,33 @@ describe.skip('Rules list bulk actions', () => { ); }); + it('can bulk unsnooze', async () => { + await setup(); + wrapper.find('[data-test-subj="checkboxSelectRow-1"]').at(1).simulate('change'); + wrapper.find('[data-test-subj="selectAllRulesButton"]').at(1).simulate('click'); + wrapper.find('[data-test-subj="showBulkActionButton"]').first().simulate('click'); + + // Unselect something to test filtering + wrapper.find('[data-test-subj="checkboxSelectRow-2"]').at(1).simulate('change'); + + wrapper.find('[data-test-subj="bulkUnsnooze"]').first().simulate('click'); + + expect(wrapper.find('[data-test-subj="bulkUnsnoozeConfirmationModal"]').exists()).toBeTruthy(); + wrapper.find('[data-test-subj="confirmModalConfirmButton"]').first().simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(bulkUnsnoozeRules).toHaveBeenCalledWith( + expect.objectContaining({ + ids: [], + filter: 'NOT (alert.id: "alert:2")', + }) + ); + }); + it('can bulk add snooze schedule', async () => { await setup(); wrapper.find('[data-test-subj="checkboxSelectRow-1"]').at(1).simulate('change'); @@ -2020,6 +2053,36 @@ describe.skip('Rules list bulk actions', () => { ); }); + it('can bulk remove snooze schedule', async () => { + await setup(); + wrapper.find('[data-test-subj="checkboxSelectRow-1"]').at(1).simulate('change'); + wrapper.find('[data-test-subj="selectAllRulesButton"]').at(1).simulate('click'); + wrapper.find('[data-test-subj="showBulkActionButton"]').first().simulate('click'); + + // Unselect something to test filtering + wrapper.find('[data-test-subj="checkboxSelectRow-2"]').at(1).simulate('change'); + + wrapper.find('[data-test-subj="bulkRemoveSnoozeSchedule"]').first().simulate('click'); + + expect( + wrapper.find('[data-test-subj="bulkRemoveScheduleConfirmationModal"]').exists() + ).toBeTruthy(); + wrapper.find('[data-test-subj="confirmModalConfirmButton"]').first().simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(bulkUnsnoozeRules).toHaveBeenCalledWith( + expect.objectContaining({ + ids: [], + filter: 'NOT (alert.id: "alert:2")', + scheduleIds: [], + }) + ); + }); + it('can bulk update API key', async () => { await setup(); wrapper.find('[data-test-subj="checkboxSelectRow-1"]').at(1).simulate('change'); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx index a725a0e916c19..0061e56d48498 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx @@ -209,14 +209,22 @@ export const RulesList = ({ const [rulesToSnooze, setRulesToSnooze] = useState([]); const [rulesToSnoozeFilter, setRulesToSnoozeFilter] = useState(''); + const [rulesToUnsnooze, setRulesToUnsnooze] = useState([]); + const [rulesToUnsnoozeFilter, setRulesToUnsnoozeFilter] = useState(''); + const [rulesToSchedule, setRulesToSchedule] = useState([]); const [rulesToScheduleFilter, setRulesToScheduleFilter] = useState(''); + const [rulesToUnschedule, setRulesToUnschedule] = useState([]); + const [rulesToUnscheduleFilter, setRulesToUnscheduleFilter] = useState(''); + const [rulesToUpdateAPIKey, setRulesToUpdateAPIKey] = useState([]); const [rulesToUpdateAPIKeyFilter, setRulesToUpdateAPIKeyFilter] = useState(''); const [isSnoozingRules, setIsSnoozingRules] = useState(false); const [isSchedulingRules, setIsSchedulingRules] = useState(false); + const [isUnsnoozingRules, setIsUnsnoozingRules] = useState(false); + const [isUnschedulingRules, setIsUnschedulingRules] = useState(false); const [isUpdatingRuleAPIKeys, setIsUpdatingRuleAPIKeys] = useState(false); const hasAnyAuthorizedRuleType = useMemo(() => { @@ -597,11 +605,21 @@ export const RulesList = ({ setRulesToSnoozeFilter(''); }; + const clearRulesToUnsnooze = () => { + setRulesToUnsnooze([]); + setRulesToUnsnoozeFilter(''); + }; + const clearRulesToSchedule = () => { setRulesToSchedule([]); setRulesToScheduleFilter(''); }; + const clearRulesToUnschedule = () => { + setRulesToUnschedule([]); + setRulesToUnscheduleFilter(''); + }; + const clearRulesToUpdateAPIKey = () => { setRulesToUpdateAPIKey([]); setRulesToUpdateAPIKeyFilter(''); @@ -613,7 +631,9 @@ export const RulesList = ({ ruleTypesState.isLoading || isPerformingAction || isSnoozingRules || + isUnsnoozingRules || isSchedulingRules || + isUnschedulingRules || isUpdatingRuleAPIKeys ); }, [ @@ -621,7 +641,9 @@ export const RulesList = ({ ruleTypesState, isPerformingAction, isSnoozingRules, + isUnsnoozingRules, isSchedulingRules, + isUnschedulingRules, isUpdatingRuleAPIKeys, ]); @@ -903,14 +925,20 @@ export const RulesList = ({ setIsPerformingAction(false); }} isSnoozingRules={isSnoozingRules} + isUnsnoozingRules={isUnsnoozingRules} isSchedulingRules={isSchedulingRules} + isUnschedulingRules={isUnschedulingRules} isUpdatingRuleAPIKeys={isUpdatingRuleAPIKeys} setRulesToDelete={setRulesToDelete} setRulesToUpdateAPIKey={setRulesToUpdateAPIKey} setRulesToSnooze={setRulesToSnooze} + setRulesToUnsnooze={setRulesToUnsnooze} setRulesToSchedule={setRulesToSchedule} + setRulesToUnschedule={setRulesToUnschedule} setRulesToSnoozeFilter={setRulesToSnoozeFilter} + setRulesToUnsnoozeFilter={setRulesToUnsnoozeFilter} setRulesToScheduleFilter={setRulesToScheduleFilter} + setRulesToUnscheduleFilter={setRulesToUnscheduleFilter} setRulesToUpdateAPIKeyFilter={setRulesToUpdateAPIKeyFilter} /> @@ -983,13 +1011,19 @@ export const RulesList = ({ /> { clearRulesToSnooze(); + clearRulesToUnsnooze(); }} onSave={async () => { clearRulesToSnooze(); + clearRulesToUnsnooze(); onClearSelection(); await loadData(); }} @@ -997,14 +1031,19 @@ export const RulesList = ({ /> { clearRulesToSchedule(); + clearRulesToUnschedule(); }} onSave={async () => { clearRulesToSchedule(); + clearRulesToUnschedule(); onClearSelection(); await loadData(); }} diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.fixtures.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.fixtures.ts index 281ba95d8e0cb..3a81ad1c71dcb 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.fixtures.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.fixtures.ts @@ -247,8 +247,8 @@ export function generateMetadataDocs(timestamp: number) { dataset: 'endpoint.metadata', }, host: { - hostname: 'rezzani-7.example.com', - name: 'rezzani-7.example.com', + hostname: 'Example-host-name-XYZ', + name: 'Example-host-name-XYZ', id: 'fc0ff548-feba-41b6-8367-65e8790d0eaf', ip: ['10.101.149.26', '2606:a000:ffc0:39:11ef:37b9:3371:578c'], mac: ['e2-6d-f9-0-46-2e'], @@ -399,8 +399,8 @@ export function generateMetadataDocs(timestamp: number) { }, host: { architecture: 'x86', - hostname: 'rezzani-7.example.com', - name: 'rezzani-7.example.com', + hostname: 'Example-host-name-XYZ', + name: 'Example-host-name-XYZ', id: 'fc0ff548-feba-41b6-8367-65e8790d0eaf', ip: ['10.101.149.26', '2606:a000:ffc0:39:11ef:37b9:3371:578c'], mac: ['e2-6d-f9-0-46-2e'], @@ -550,8 +550,8 @@ export function generateMetadataDocs(timestamp: number) { }, host: { architecture: 'x86', - hostname: 'rezzani-7.example.com', - name: 'rezzani-7.example.com', + hostname: 'Example-host-name-XYZ', + name: 'Example-host-name-XYZ', id: 'fc0ff548-feba-41b6-8367-65e8790d0eaf', ip: ['10.101.149.26', '2606:a000:ffc0:39:11ef:37b9:3371:578c'], mac: ['e2-6d-f9-0-46-2e'], diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts index 508aa8884fcfe..0ee6b44952577 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts @@ -259,6 +259,26 @@ export default function ({ getService }: FtrProviderContext) { expect(body.pageSize).to.eql(10); }); + it('metadata api should return the endpoint based on the agent hostname', async () => { + const targetEndpointId = 'fc0ff548-feba-41b6-8367-65e8790d0eaf'; + const targetAgentHostname = 'Example-host-name-XYZ'; + const { body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .query({ + kuery: `united.endpoint.host.hostname:${targetAgentHostname}`, + }) + .expect(200); + expect(body.total).to.eql(1); + const resultHostId: string = body.data[0].metadata.host.id; + const resultElasticAgentName: string = body.data[0].metadata.host.hostname; + expect(resultHostId).to.eql(targetEndpointId); + expect(resultElasticAgentName).to.eql(targetAgentHostname); + expect(body.data.length).to.eql(1); + expect(body.page).to.eql(0); + expect(body.pageSize).to.eql(10); + }); + it('metadata api should return all hosts when filter is empty string', async () => { const { body } = await supertest .get(HOST_METADATA_LIST_ROUTE)