diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index 971e69729269e..e66791d7012dc 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -375,8 +375,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { linux_deadlock: `${SECURITY_SOLUTION_DOCS}ts-management.html#linux-deadlock`, }, packageActionTroubleshooting: { - // TODO: Pending to be updated when docs are ready - es_connection: '', + es_connection: `${SECURITY_SOLUTION_DOCS}ts-management.html`, }, responseActions: `${SECURITY_SOLUTION_DOCS}response-actions.html`, configureEndpointIntegrationPolicy: `${SECURITY_SOLUTION_DOCS}configure-endpoint-integration-policy.html`, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/build_ml_jobs_description.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/build_ml_jobs_description.tsx new file mode 100644 index 0000000000000..7012c4276ee45 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/build_ml_jobs_description.tsx @@ -0,0 +1,14 @@ +/* + * 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 from 'react'; +import type { ListItems } from './types'; +import { MlJobsDescription } from '../ml_jobs_description'; + +export const buildMlJobsDescription = (jobIds: string[], label: string): ListItems => ({ + title: label, + description: , +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx index d22bff896eb0b..b9b2abffdc96d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx @@ -45,7 +45,7 @@ import { buildRequiredFieldsDescription, buildAlertSuppressionDescription, } from './helpers'; -import { buildMlJobsDescription } from './ml_job_description'; +import { buildMlJobsDescription } from './build_ml_jobs_description'; import { buildActionsDescription } from './actions_description'; import { buildThrottleDescription } from './throttle_description'; import { THREAT_QUERY_LABEL } from './translations'; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.test.tsx deleted file mode 100644 index 0dc7b14ea8e1e..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.test.tsx +++ /dev/null @@ -1,40 +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 React from 'react'; -import { shallow } from 'enzyme'; - -import { mockOpenedJob, mockSecurityJobs } from '../../../../common/components/ml_popover/api.mock'; -import { MlJobDescription, AuditIcon, JobStatusBadge } from './ml_job_description'; - -jest.mock('../../../../common/lib/kibana'); - -describe('MlJobDescription', () => { - it('renders correctly', () => { - const wrapper = shallow( - {}} /> - ); - - expect(wrapper.find('[data-test-subj="machineLearningJobId"]')).toHaveLength(1); - }); -}); - -describe('AuditIcon', () => { - it('renders correctly', () => { - const wrapper = shallow(); - - expect(wrapper.find('EuiToolTip')).toHaveLength(0); - }); -}); - -describe('JobStatusBadge', () => { - it('renders correctly', () => { - const wrapper = shallow(); - - expect(wrapper.find('EuiBadge')).toHaveLength(1); - }); -}); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.tsx deleted file mode 100644 index abe5318b92c13..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.tsx +++ /dev/null @@ -1,150 +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 React, { useCallback } from 'react'; -import styled from 'styled-components'; -import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; - -import type { MlSummaryJob } from '@kbn/ml-plugin/public'; -import { ML_PAGES, useMlHref } from '@kbn/ml-plugin/public'; -import { isJobStarted } from '../../../../../common/machine_learning/helpers'; -import { useEnableDataFeed } from '../../../../common/components/ml_popover/hooks/use_enable_data_feed'; -import type { SecurityJob } from '../../../../common/components/ml_popover/types'; -import { JobSwitch } from '../../../../common/components/ml_popover/jobs_table/job_switch'; -import { useSecurityJobs } from '../../../../common/components/ml_popover/hooks/use_security_jobs'; -import { useKibana } from '../../../../common/lib/kibana'; -import type { ListItems } from './types'; -import * as i18n from './translations'; - -enum MessageLevels { - info = 'info', - warning = 'warning', - error = 'error', -} - -const AuditIconComponent: React.FC<{ - message: MlSummaryJob['auditMessage']; -}> = ({ message }) => { - if (!message) { - return null; - } - - let color = 'primary'; - let icon = 'alert'; - - if (message.level === MessageLevels.info) { - icon = 'iInCircle'; - } else if (message.level === MessageLevels.warning) { - color = 'warning'; - } else if (message.level === MessageLevels.error) { - color = 'danger'; - } - - return ( - - - - ); -}; - -export const AuditIcon = React.memo(AuditIconComponent); - -const JobStatusBadgeComponent: React.FC<{ job: MlSummaryJob }> = ({ job }) => { - const isStarted = isJobStarted(job.jobState, job.datafeedState); - const color = isStarted ? 'success' : 'danger'; - const text = isStarted ? i18n.ML_JOB_STARTED : i18n.ML_JOB_STOPPED; - - return ( - - {text} - - ); -}; - -export const JobStatusBadge = React.memo(JobStatusBadgeComponent); - -const JobLink = styled(EuiLink)` - margin-right: ${({ theme }) => theme.eui.euiSizeS}; -`; - -const Wrapper = styled.div` - overflow: hidden; -`; - -const MlJobDescriptionComponent: React.FC<{ - job: SecurityJob; - loading: boolean; - refreshJob: (job: SecurityJob) => void; -}> = ({ job, loading, refreshJob }) => { - const { - services: { http, ml }, - } = useKibana(); - const { enableDatafeed, isLoading: isLoadingEnableDataFeed } = useEnableDataFeed(); - const jobId = job.id; - const jobUrl = useMlHref(ml, http.basePath.get(), { - page: ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE, - pageState: { - jobId: [jobId], - }, - }); - - const jobIdSpan = {jobId}; - const isStarted = isJobStarted(job.jobState, job.datafeedState); - - const handleJobStateChange = useCallback( - async (_, latestTimestampMs: number, enable: boolean) => { - await enableDatafeed(job, latestTimestampMs, enable); - refreshJob(job); - }, - [enableDatafeed, job, refreshJob] - ); - - return ( - -
- - {jobIdSpan} - - -
- - - - - - - - - {isStarted ? i18n.ML_STOP_JOB_LABEL : i18n.ML_RUN_JOB_LABEL} - - -
- ); -}; - -export const MlJobDescription = React.memo(MlJobDescriptionComponent); - -const MlJobsDescription: React.FC<{ jobIds: string[] }> = ({ jobIds }) => { - const { loading, jobs, refetch: refreshJobs } = useSecurityJobs(); - const relevantJobs = jobs.filter((job) => jobIds.includes(job.id)); - return ( - <> - {relevantJobs.map((job) => ( - - ))} - - ); -}; - -export const buildMlJobsDescription = (jobIds: string[], label: string): ListItems => ({ - title: label, - description: , -}); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.ts similarity index 85% rename from x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.tsx rename to x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.ts index db72259d131f1..b4b2df5515342 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.ts @@ -91,34 +91,6 @@ export const NEW_TERMS_TYPE_DESCRIPTION = i18n.translate( } ); -export const ML_RUN_JOB_LABEL = i18n.translate( - 'xpack.securitySolution.detectionEngine.ruleDescription.mlRunJobLabel', - { - defaultMessage: 'Run job', - } -); - -export const ML_STOP_JOB_LABEL = i18n.translate( - 'xpack.securitySolution.detectionEngine.ruleDescription.mlStopJobLabel', - { - defaultMessage: 'Stop job', - } -); - -export const ML_JOB_STARTED = i18n.translate( - 'xpack.securitySolution.detectionEngine.ruleDescription.mlJobStartedDescription', - { - defaultMessage: 'Started', - } -); - -export const ML_JOB_STOPPED = i18n.translate( - 'xpack.securitySolution.detectionEngine.ruleDescription.mlJobStoppedDescription', - { - defaultMessage: 'Stopped', - } -); - export const THRESHOLD_RESULTS_ALL = i18n.translate( 'xpack.securitySolution.detectionEngine.ruleDescription.thresholdResultsAllDescription', { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/ml_audit_icon/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/ml_audit_icon/index.tsx new file mode 100644 index 0000000000000..d953cded9dd6d --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/ml_audit_icon/index.tsx @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export { MlAuditIcon } from './ml_audit_icon'; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/ml_audit_icon/ml_audit_icon.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/ml_audit_icon/ml_audit_icon.test.tsx new file mode 100644 index 0000000000000..7e6ca9a79bda0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/ml_audit_icon/ml_audit_icon.test.tsx @@ -0,0 +1,27 @@ +/* + * 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 from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { MlAuditIcon } from './ml_audit_icon'; + +describe('MlAuditIcon', () => { + it('should render null if message is undefined', () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it('should render tooltip with message text when hover over the icon', async () => { + render(); + + userEvent.hover(screen.getByTestId('mlJobAuditIcon')); + + expect(await screen.findByText('mock audit text')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/ml_audit_icon/ml_audit_icon.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/ml_audit_icon/ml_audit_icon.tsx new file mode 100644 index 0000000000000..19255b7fb95af --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/ml_audit_icon/ml_audit_icon.tsx @@ -0,0 +1,47 @@ +/* + * 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 { FC } from 'react'; +import React, { memo } from 'react'; +import { EuiIcon, EuiToolTip } from '@elastic/eui'; + +import type { MlSummaryJob } from '@kbn/ml-plugin/public'; + +enum MessageLevels { + info = 'info', + warning = 'warning', + error = 'error', +} + +interface MlAuditIconProps { + message: MlSummaryJob['auditMessage']; +} + +const MlAuditIconComponent: FC = ({ message }) => { + if (!message) { + return null; + } + + let color = 'primary'; + let icon = 'alert'; + + if (message.level === MessageLevels.info) { + icon = 'iInCircle'; + } else if (message.level === MessageLevels.warning) { + color = 'warning'; + } else if (message.level === MessageLevels.error) { + color = 'danger'; + } + + return ( + + + + ); +}; + +export const MlAuditIcon = memo(MlAuditIconComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/ml_job_link/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/ml_job_link/index.tsx new file mode 100644 index 0000000000000..909f6f03092c5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/ml_job_link/index.tsx @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export { MlJobLink } from './ml_job_link'; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/ml_job_link/ml_job_link.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/ml_job_link/ml_job_link.tsx new file mode 100644 index 0000000000000..a3eefb1240486 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/ml_job_link/ml_job_link.tsx @@ -0,0 +1,41 @@ +/* + * 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, { memo } from 'react'; +import styled from 'styled-components'; +import { EuiLink } from '@elastic/eui'; + +import { ML_PAGES, useMlHref } from '@kbn/ml-plugin/public'; +import { useKibana } from '../../../../common/lib/kibana'; + +const StyledJobEuiLInk = styled(EuiLink)` + margin-right: ${({ theme }) => theme.eui.euiSizeS}; +`; + +interface MlJobLinkProps { + jobId: string; +} + +const MlJobLinkComponent: React.FC = ({ jobId }) => { + const { + services: { http, ml }, + } = useKibana(); + const jobUrl = useMlHref(ml, http.basePath.get(), { + page: ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE, + pageState: { + jobId: [jobId], + }, + }); + + return ( + + {jobId} + + ); +}; + +export const MlJobLink = memo(MlJobLinkComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/ml_job_status_badge/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/ml_job_status_badge/index.tsx new file mode 100644 index 0000000000000..170a02b30651d --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/ml_job_status_badge/index.tsx @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export { MlJobStatusBadge } from './ml_job_status_badge'; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/ml_job_status_badge/ml_job_status_badge.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/ml_job_status_badge/ml_job_status_badge.test.tsx new file mode 100644 index 0000000000000..010aa82fc81ce --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/ml_job_status_badge/ml_job_status_badge.test.tsx @@ -0,0 +1,39 @@ +/* + * 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 from 'react'; +import { render, screen } from '@testing-library/react'; + +import { isJobStarted } from '../../../../../common/machine_learning/helpers'; +import { mockOpenedJob } from '../../../../common/components/ml_popover/api.mock'; + +import { MlJobStatusBadge } from './ml_job_status_badge'; + +jest.mock('../../../../../common/machine_learning/helpers'); + +const isJobStartedMock = isJobStarted as jest.Mock; + +describe('MlJobStatusBadge', () => { + it('should call isJobStarted helper', () => { + render(); + + expect(isJobStarted).toHaveBeenCalledWith(mockOpenedJob.jobState, mockOpenedJob.datafeedState); + }); + + it('should render started if isJobStarted return true', () => { + isJobStartedMock.mockReturnValueOnce(true); + render(); + + expect(screen.getByTestId('machineLearningJobStatus')).toHaveTextContent('Started'); + }); + + it('should render stopped if isJobStarted return false', () => { + isJobStartedMock.mockReturnValueOnce(false); + render(); + + expect(screen.getByTestId('machineLearningJobStatus')).toHaveTextContent('Stopped'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/ml_job_status_badge/ml_job_status_badge.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/ml_job_status_badge/ml_job_status_badge.tsx new file mode 100644 index 0000000000000..c6200b485529c --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/ml_job_status_badge/ml_job_status_badge.tsx @@ -0,0 +1,33 @@ +/* + * 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 { FC } from 'react'; +import React, { memo } from 'react'; +import { EuiBadge } from '@elastic/eui'; + +import type { MlSummaryJob } from '@kbn/ml-plugin/public'; +import { isJobStarted } from '../../../../../common/machine_learning/helpers'; + +import * as i18n from './translations'; + +interface JobStatusBadgeProps { + job: MlSummaryJob; +} + +const MlJobStatusBadgeComponent: FC = ({ job }) => { + const isStarted = isJobStarted(job.jobState, job.datafeedState); + const color = isStarted ? 'success' : 'danger'; + const text = isStarted ? i18n.ML_JOB_STARTED : i18n.ML_JOB_STOPPED; + + return ( + + {text} + + ); +}; + +export const MlJobStatusBadge = memo(MlJobStatusBadgeComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/ml_job_status_badge/translations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/ml_job_status_badge/translations.ts new file mode 100644 index 0000000000000..001bd3991fc39 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/ml_job_status_badge/translations.ts @@ -0,0 +1,22 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const ML_JOB_STARTED = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.mlJobStartedDescription', + { + defaultMessage: 'Started', + } +); + +export const ML_JOB_STOPPED = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.mlJobStoppedDescription', + { + defaultMessage: 'Stopped', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/admin/ml_admin_job_description.integration.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/admin/ml_admin_job_description.integration.test.tsx new file mode 100644 index 0000000000000..04f70ba18ad03 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/admin/ml_admin_job_description.integration.test.tsx @@ -0,0 +1,119 @@ +/* + * 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 from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import noop from 'lodash/noop'; +import { TestProviders } from '../../../../../common/mock'; +import { useEnableDataFeed } from '../../../../../common/components/ml_popover/hooks/use_enable_data_feed'; +import type { SecurityJob } from '../../../../../common/components/ml_popover/types'; + +import { MlAdminJobDescription } from './ml_admin_job_description'; + +jest.mock('../../../../../common/lib/kibana'); +jest.mock('../../../../../common/components/ml_popover/hooks/use_enable_data_feed', () => ({ + useEnableDataFeed: jest.fn(), +})); + +import { mockSecurityJobs } from '../../../../../common/components/ml_popover/api.mock'; + +const securityJobNotStarted: SecurityJob = { + ...mockSecurityJobs[0], + jobState: 'closed', + datafeedState: 'stopped', + auditMessage: { + text: 'Test warning', + }, +}; + +const useEnableDataFeedMock = (useEnableDataFeed as jest.Mock).mockReturnValue({ + isLoading: false, +}); + +describe('MlAdminJobDescription', () => { + it('should enable datafeed and call refreshJob when enabling job', async () => { + const refreshJobSpy = jest.fn(); + const enableDatafeedSpy = jest.fn(); + useEnableDataFeedMock.mockReturnValueOnce({ + enableDatafeed: enableDatafeedSpy, + }); + + render( + , + { + wrapper: TestProviders, + } + ); + + userEvent.click(screen.getByTestId('job-switch')); + expect(enableDatafeedSpy).toHaveBeenCalledWith( + securityJobNotStarted, + securityJobNotStarted.latestTimestampMs, + true + ); + + await waitFor(() => { + expect(refreshJobSpy).toHaveBeenCalledWith(securityJobNotStarted); + }); + }); + + it('should render loading spinner when job start is in progress', async () => { + useEnableDataFeedMock.mockReturnValueOnce({ + isLoading: true, + }); + + render( + , + { + wrapper: TestProviders, + } + ); + + expect(screen.getByTestId('job-switch-loader')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByTestId('job-switch')).toBeNull(); + }); + }); + + it('should render loading spinner when loading property passed', async () => { + render(, { + wrapper: TestProviders, + }); + + expect(screen.getByTestId('job-switch-loader')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByTestId('job-switch')).toBeNull(); + }); + }); + + it('should render job details correctly', async () => { + render(, { + wrapper: TestProviders, + }); + + // link to job + const linkElement = screen.getByTestId('machineLearningJobLink'); + + expect(linkElement).toHaveAttribute('href', expect.any(String)); + expect(linkElement).toHaveTextContent(securityJobNotStarted.id); + + // audit icon + expect(screen.getByTestId('mlJobAuditIcon')).toBeInTheDocument(); + + // job status + expect(screen.getByTestId('machineLearningJobStatus')).toHaveTextContent('Stopped'); + + // job action label + await waitFor(() => { + expect(screen.getByTestId('mlJobActionLabel')).toHaveTextContent('Run job'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/admin/ml_admin_job_description.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/admin/ml_admin_job_description.tsx new file mode 100644 index 0000000000000..7d4616a004364 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/admin/ml_admin_job_description.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FC } from 'react'; +import React, { useCallback, useMemo, memo } from 'react'; + +import { useEnableDataFeed } from '../../../../../common/components/ml_popover/hooks/use_enable_data_feed'; +import type { SecurityJob } from '../../../../../common/components/ml_popover/types'; +import { JobSwitch } from '../../../../../common/components/ml_popover/jobs_table/job_switch'; + +import { MlJobItem } from '../ml_job_item'; + +interface MlAdminJobDescriptionProps { + job: SecurityJob; + loading: boolean; + refreshJob: (job: SecurityJob) => void; +} + +const MlAdminJobDescriptionComponent: FC = ({ + job, + loading, + refreshJob, +}) => { + const { enableDatafeed, isLoading: isLoadingEnableDataFeed } = useEnableDataFeed(); + + const handleJobStateChange = useCallback( + async (_, latestTimestampMs: number, enable: boolean) => { + await enableDatafeed(job, latestTimestampMs, enable); + refreshJob(job); + }, + [enableDatafeed, job, refreshJob] + ); + + const switchComponent = useMemo( + () => ( + + ), + [handleJobStateChange, isLoadingEnableDataFeed, job, loading] + ); + + return ; +}; + +export const MlAdminJobDescription = memo(MlAdminJobDescriptionComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/admin/ml_admin_jobs_description.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/admin/ml_admin_jobs_description.test.tsx new file mode 100644 index 0000000000000..7c974ae907659 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/admin/ml_admin_jobs_description.test.tsx @@ -0,0 +1,49 @@ +/* + * 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 from 'react'; +import { render, screen } from '@testing-library/react'; +import type { MlSummaryJob } from '@kbn/ml-plugin/public'; + +import { MlAdminJobsDescription } from './ml_admin_jobs_description'; + +import { useSecurityJobs } from '../../../../../common/components/ml_popover/hooks/use_security_jobs'; + +jest.mock( + './ml_admin_job_description', + () => + ({ + MlAdminJobDescription: (props) => { + return
{props.job.id}
; + }, + } as Record>) +); + +jest.mock('../../../../../common/components/ml_popover/hooks/use_security_jobs'); + +const useSecurityJobsMock = useSecurityJobs as jest.Mock; + +describe('MlAdminJobsDescription', () => { + it('should render null if admin permissions absent', () => { + useSecurityJobsMock.mockReturnValueOnce({ jobs: [], isMlAdmin: false }); + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it('should render only jobs with job ids passed as props', () => { + useSecurityJobsMock.mockReturnValueOnce({ + jobs: [{ id: 'mock-1' }, { id: 'mock-2' }, { id: 'mock-3' }], + isMlAdmin: true, + }); + render(); + const expectedJobs = screen.getAllByTestId('adminMock'); + + expect(expectedJobs).toHaveLength(2); + expect(expectedJobs[0]).toHaveTextContent('mock-1'); + expect(expectedJobs[1]).toHaveTextContent('mock-2'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/admin/ml_admin_jobs_description.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/admin/ml_admin_jobs_description.tsx new file mode 100644 index 0000000000000..1fe676df75364 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/admin/ml_admin_jobs_description.tsx @@ -0,0 +1,37 @@ +/* + * 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 { FC } from 'react'; +import React, { memo } from 'react'; + +import { useSecurityJobs } from '../../../../../common/components/ml_popover/hooks/use_security_jobs'; + +import { MlAdminJobDescription } from './ml_admin_job_description'; + +interface MlAdminJobsDescriptionProps { + jobIds: string[]; +} + +const MlAdminJobsDescriptionComponent: FC = ({ jobIds }) => { + const { loading, jobs, refetch: refreshJobs, isMlAdmin } = useSecurityJobs(); + + if (!isMlAdmin) { + return null; + } + + const relevantJobs = jobs.filter((job) => jobIds.includes(job.id)); + + return ( + <> + {relevantJobs.map((job) => ( + + ))} + + ); +}; + +export const MlAdminJobsDescription = memo(MlAdminJobsDescriptionComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/index.tsx new file mode 100644 index 0000000000000..4a467e5dc4587 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/index.tsx @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export { MlJobsDescription } from './ml_jobs_description'; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/ml_job_item.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/ml_job_item.tsx new file mode 100644 index 0000000000000..377600652c7d0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/ml_job_item.tsx @@ -0,0 +1,51 @@ +/* + * 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 { FC, ReactNode } from 'react'; +import React, { memo } from 'react'; +import styled from 'styled-components'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import type { MlSummaryJob } from '@kbn/ml-plugin/public'; +import * as i18n from './translations'; + +import { isJobStarted } from '../../../../../common/machine_learning/helpers'; + +import { MlJobLink } from '../ml_job_link/ml_job_link'; +import { MlAuditIcon } from '../ml_audit_icon'; +import { MlJobStatusBadge } from '../ml_job_status_badge'; + +const Wrapper = styled.div` + overflow: hidden; +`; + +const MlJobItemComponent: FC<{ + job: MlSummaryJob; + switchComponent: ReactNode; +}> = ({ job, switchComponent, ...props }) => { + const isStarted = isJobStarted(job.jobState, job.datafeedState); + + return ( + +
+ + +
+ + + + + {switchComponent} + + {isStarted ? i18n.ML_STOP_JOB_LABEL : i18n.ML_RUN_JOB_LABEL} + + +
+ ); +}; + +export const MlJobItem = memo(MlJobItemComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/ml_jobs_description.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/ml_jobs_description.test.tsx new file mode 100644 index 0000000000000..a6ce1f1210d40 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/ml_jobs_description.test.tsx @@ -0,0 +1,49 @@ +/* + * 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 from 'react'; +import { render, screen } from '@testing-library/react'; + +import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions'; +import { hasMlAdminPermissions } from '../../../../../common/machine_learning/has_ml_admin_permissions'; + +import { MlJobsDescription } from './ml_jobs_description'; + +jest.mock('./admin/ml_admin_jobs_description', () => ({ + MlAdminJobsDescription: () =>
, +})); +jest.mock('./user/ml_user_jobs_description', () => ({ + MlUserJobsDescription: () =>
, +})); +jest.mock('../../../../common/components/ml/hooks/use_ml_capabilities'); +jest.mock('../../../../../common/machine_learning/has_ml_admin_permissions'); +jest.mock('../../../../../common/machine_learning/has_ml_user_permissions'); + +const hasMlUserPermissionsMock = hasMlUserPermissions as jest.Mock; + +describe('MlUserJobDescription', () => { + it('should render null if no ML permissions available', () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it('should render user jobs component if ML permissions is for user only', () => { + hasMlUserPermissionsMock.mockReturnValueOnce(true); + render(); + + expect(screen.getByTestId('userJobs')).toBeInTheDocument(); + expect(screen.queryByTestId('adminJobs')).not.toBeInTheDocument(); + }); + + it('should render admin jobs component if ML permissions is for admin', () => { + (hasMlAdminPermissions as jest.Mock).mockReturnValueOnce(true); + render(); + + expect(screen.getByTestId('adminJobs')).toBeInTheDocument(); + expect(screen.queryByTestId('userJobs')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/ml_jobs_description.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/ml_jobs_description.tsx new file mode 100644 index 0000000000000..68ddaa0f7584b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/ml_jobs_description.tsx @@ -0,0 +1,39 @@ +/* + * 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 { FC } from 'react'; +import React, { memo } from 'react'; + +import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities'; +import { hasMlAdminPermissions } from '../../../../../common/machine_learning/has_ml_admin_permissions'; +import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions'; + +import { MlAdminJobsDescription } from './admin/ml_admin_jobs_description'; +import { MlUserJobsDescription } from './user/ml_user_jobs_description'; + +interface MlJobsDescriptionProps { + jobIds: string[]; +} + +const MlJobsDescriptionComponent: FC = ({ jobIds }) => { + const mlCapabilities = useMlCapabilities(); + + const isMlUser = hasMlUserPermissions(mlCapabilities); + const isMlAdmin = hasMlAdminPermissions(mlCapabilities); + + if (isMlAdmin) { + return ; + } + + if (isMlUser) { + return ; + } + + return null; +}; + +export const MlJobsDescription = memo(MlJobsDescriptionComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/translations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/translations.ts new file mode 100644 index 0000000000000..7650a4cb4ec59 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/translations.ts @@ -0,0 +1,35 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const ML_RUN_JOB_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.mlRunJobLabel', + { + defaultMessage: 'Run job', + } +); + +export const ML_STOP_JOB_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.mlStopJobLabel', + { + defaultMessage: 'Stop job', + } +); + +export const ML_JOB_STOPPED = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.mlJobStoppedDescription', + { + defaultMessage: 'Stopped', + } +); + +export const ML_ADMIN_REQUIRED = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.mlAdminPermissionsRequiredDescription', + { + defaultMessage: 'ML Admin Permissions required to perform this action', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/user/ml_user_job_description.integration.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/user/ml_user_job_description.integration.test.tsx new file mode 100644 index 0000000000000..1d6a946c2634c --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/user/ml_user_job_description.integration.test.tsx @@ -0,0 +1,61 @@ +/* + * 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 from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { TestProviders } from '../../../../../common/mock'; + +import { MlUserJobDescription } from './ml_user_job_description'; + +jest.mock('../../../../../common/lib/kibana'); + +import { mockOpenedJob } from '../../../../../common/components/ml_popover/api.mock'; + +describe('MlUserJobDescription', () => { + it('should render switch component disabled', async () => { + render(, { wrapper: TestProviders }); + await waitFor(() => { + expect(screen.getByTestId('mlUserJobSwitch')).toBeDisabled(); + }); + }); + + it('should render toast that shows admin permissions required', async () => { + render(, { wrapper: TestProviders }); + + userEvent.hover(screen.getByTestId('mlUserJobSwitch').parentNode as Element); + + await waitFor(() => { + expect( + screen.getByText('ML Admin Permissions required to perform this action') + ).toBeInTheDocument(); + }); + }); + + it('should render job details correctly', async () => { + render(, { + wrapper: TestProviders, + }); + + // link to job + const linkElement = screen.getByTestId('machineLearningJobLink'); + + expect(linkElement).toHaveAttribute('href', expect.any(String)); + expect(linkElement).toHaveTextContent(mockOpenedJob.id); + + // audit icon is not present as auditMessage empty + expect(screen.queryByTestId('mlJobAuditIcon')).toBeNull(); + + // job status + expect(screen.getByTestId('machineLearningJobStatus')).toHaveTextContent('Started'); + + // job action label + await waitFor(() => { + expect(screen.getByTestId('mlJobActionLabel')).toHaveTextContent('Stop job'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/user/ml_user_job_description.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/user/ml_user_job_description.tsx new file mode 100644 index 0000000000000..3b4e4fc2ffc70 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/user/ml_user_job_description.tsx @@ -0,0 +1,42 @@ +/* + * 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 { FC } from 'react'; +import React, { useMemo, memo } from 'react'; +import { EuiSwitch, EuiToolTip } from '@elastic/eui'; +import noop from 'lodash/noop'; + +import type { MlSummaryJob } from '@kbn/ml-plugin/public'; + +import * as i18n from '../translations'; + +import { isJobStarted } from '../../../../../../common/machine_learning/helpers'; + +import { MlJobItem } from '../ml_job_item'; + +const MlUserJobDescriptionComponent: FC<{ + job: MlSummaryJob; +}> = ({ job }) => { + const switchComponent = useMemo( + () => ( + + + + ), + [job] + ); + + return ; +}; + +export const MlUserJobDescription = memo(MlUserJobDescriptionComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/user/ml_user_jobs_description.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/user/ml_user_jobs_description.test.tsx new file mode 100644 index 0000000000000..69eb3dbb41b81 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/user/ml_user_jobs_description.test.tsx @@ -0,0 +1,49 @@ +/* + * 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 from 'react'; +import { render, screen } from '@testing-library/react'; +import type { MlSummaryJob } from '@kbn/ml-plugin/public'; + +import { MlUserJobsDescription } from './ml_user_jobs_description'; + +import { useInstalledSecurityJobs } from '../../../../../common/components/ml/hooks/use_installed_security_jobs'; + +jest.mock( + './ml_user_job_description', + () => + ({ + MlUserJobDescription: (props) => { + return
{props.job.id}
; + }, + } as Record>) +); + +jest.mock('../../../../../common/components/ml/hooks/use_installed_security_jobs'); + +const useInstalledSecurityJobsMock = useInstalledSecurityJobs as jest.Mock; + +describe('MlUsersJobDescription', () => { + it('should render null if user permissions absent', () => { + useInstalledSecurityJobsMock.mockReturnValueOnce({ jobs: [], isMlUser: false }); + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it('should render only jobs with job ids passed as props', () => { + useInstalledSecurityJobsMock.mockReturnValueOnce({ + jobs: [{ id: 'mock-1' }, { id: 'mock-2' }, { id: 'mock-3' }], + isMlUser: true, + }); + render(); + + const expectedJobs = screen.getAllByTestId('userMock'); + expect(expectedJobs).toHaveLength(2); + expect(expectedJobs[0]).toHaveTextContent('mock-1'); + expect(expectedJobs[1]).toHaveTextContent('mock-2'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/user/ml_user_jobs_description.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/user/ml_user_jobs_description.tsx new file mode 100644 index 0000000000000..5f743d371d531 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/ml_jobs_description/user/ml_user_jobs_description.tsx @@ -0,0 +1,37 @@ +/* + * 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 { FC } from 'react'; +import React, { memo } from 'react'; + +import { useInstalledSecurityJobs } from '../../../../../common/components/ml/hooks/use_installed_security_jobs'; + +import { MlUserJobDescription } from './ml_user_job_description'; + +interface MlUserJobsDescriptionProps { + jobIds: string[]; +} + +const MlUserJobsDescriptionComponent: FC = ({ jobIds }) => { + const { isMlUser, jobs } = useInstalledSecurityJobs(); + + if (!isMlUser) { + return null; + } + + const relevantJobs = jobs.filter((job) => jobIds.includes(job.id)); + + return ( + <> + {relevantJobs.map((job) => ( + + ))} + + ); +}; + +export const MlUserJobsDescription = memo(MlUserJobsDescriptionComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx index 96422a717cb50..1f47db53a1087 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx @@ -563,7 +563,7 @@ export const schema: FormSchema = { label: i18n.translate( 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupByFieldsLabel', { - defaultMessage: 'Suppress Alerts By', + defaultMessage: 'Suppress alerts by', } ), labelAppend: ( diff --git a/x-pack/test/functional/config.base.js b/x-pack/test/functional/config.base.js index 1f4e987829be3..ea6a865ecbc58 100644 --- a/x-pack/test/functional/config.base.js +++ b/x-pack/test/functional/config.base.js @@ -175,6 +175,9 @@ export default async function ({ readConfigFile }) { connectors: { pathname: '/app/management/insightsAndAlerting/triggersActionsConnectors/', }, + triggersActions: { + pathname: '/app/management/insightsAndAlerting/triggersActions', + }, }, // choose where screenshots should be saved diff --git a/x-pack/test/functional/services/actions/api.ts b/x-pack/test/functional/services/actions/api.ts index a6ea0a2666119..89eef12cb3370 100644 --- a/x-pack/test/functional/services/actions/api.ts +++ b/x-pack/test/functional/services/actions/api.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export function ActionsAPIServiceProvider({ getService }: FtrProviderContext) { const kbnSupertest = getService('supertest'); + const log = getService('log'); return { async createConnector({ @@ -37,10 +38,24 @@ export function ActionsAPIServiceProvider({ getService }: FtrProviderContext) { }, async deleteConnector(id: string) { - return kbnSupertest + log.debug(`Deleting connector with id '${id}'...`); + const rsp = kbnSupertest .delete(`/api/actions/connector/${id}`) .set('kbn-xsrf', 'foo') .expect(204, ''); + log.debug('> Connector deleted.'); + return rsp; + }, + + async deleteAllConnectors() { + const { body } = await kbnSupertest + .get(`/api/actions/connectors`) + .set('kbn-xsrf', 'foo') + .expect(200); + + for (const connector of body) { + await this.deleteConnector(connector.id); + } }, }; } diff --git a/x-pack/test/functional/services/ml/alerting.ts b/x-pack/test/functional/services/ml/alerting.ts index 7007f0d00f284..8906ee94d0abe 100644 --- a/x-pack/test/functional/services/ml/alerting.ts +++ b/x-pack/test/functional/services/ml/alerting.ts @@ -26,9 +26,9 @@ export function MachineLearningAlertingProvider( return { async selectAnomalyDetectionAlertType() { - await testSubjects.click('xpack.ml.anomaly_detection_alert-SelectOption'); await retry.tryForTime(5000, async () => { - await testSubjects.existOrFail(`mlAnomalyAlertForm`); + await testSubjects.click('xpack.ml.anomaly_detection_alert-SelectOption'); + await testSubjects.existOrFail(`mlAnomalyAlertForm`, { timeout: 1000 }); }); }, @@ -50,8 +50,14 @@ export function MachineLearningAlertingProvider( }, async selectResultType(resultType: string) { - await testSubjects.click(`mlAnomalyAlertResult_${resultType}`); - await this.assertResultTypeSelection(resultType); + if ( + (await testSubjects.exists(`mlAnomalyAlertResult_${resultType}_selected`, { + timeout: 1000, + })) === false + ) { + await testSubjects.click(`mlAnomalyAlertResult_${resultType}`); + await this.assertResultTypeSelection(resultType); + } }, async assertResultTypeSelection(resultType: string) { @@ -168,5 +174,56 @@ export function MachineLearningAlertingProvider( mlApi.assertResponseStatusCode(204, status, body); } }, + + async openNotifySelection() { + await retry.tryForTime(5000, async () => { + await testSubjects.click('notifyWhenSelect'); + await testSubjects.existOrFail('onActionGroupChange', { timeout: 1000 }); + }); + }, + + async setRuleName(rulename: string) { + await testSubjects.setValue('ruleNameInput', rulename); + }, + + async scrollRuleNameIntoView() { + await testSubjects.scrollIntoView('ruleNameInput'); + }, + + async selectSlackConnectorType() { + await retry.tryForTime(5000, async () => { + await testSubjects.click('.slack-alerting-ActionTypeSelectOption'); + await testSubjects.existOrFail('createActionConnectorButton-0', { timeout: 1000 }); + }); + }, + + async clickCreateConnectorButton() { + await retry.tryForTime(5000, async () => { + await testSubjects.click('createActionConnectorButton-0'); + await testSubjects.existOrFail('connectorAddModal', { timeout: 1000 }); + }); + }, + + async setConnectorName(connectorname: string) { + await testSubjects.setValue('nameInput', connectorname); + }, + + async setWebhookUrl(webhookurl: string) { + await testSubjects.setValue('slackWebhookUrlInput', webhookurl); + }, + + async clickSaveActionButton() { + await retry.tryForTime(5000, async () => { + await testSubjects.click('saveActionButtonModal'); + await testSubjects.existOrFail('addNewActionConnectorActionGroup-0', { timeout: 1000 }); + }); + }, + + async openAddRuleVariable() { + await retry.tryForTime(5000, async () => { + await testSubjects.click('messageAddVariableButton'); + await testSubjects.existOrFail('variableMenuButton-alert.actionGroup', { timeout: 1000 }); + }); + }, }; } diff --git a/x-pack/test/screenshot_creation/apps/ml_docs/anomaly_detection/generate_anomaly_alerts.ts b/x-pack/test/screenshot_creation/apps/ml_docs/anomaly_detection/generate_anomaly_alerts.ts new file mode 100644 index 0000000000000..b561a61095ab1 --- /dev/null +++ b/x-pack/test/screenshot_creation/apps/ml_docs/anomaly_detection/generate_anomaly_alerts.ts @@ -0,0 +1,155 @@ +/* + * 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 * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { DATAFEED_STATE } from '@kbn/ml-plugin/common/constants/states'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +import { ECOMMERCE_INDEX_PATTERN } from '..'; + +function createTestJobAndDatafeed() { + const timestamp = Date.now(); + const jobId = `high_sum_total_sales_${timestamp}`; + + return { + job: { + job_id: jobId, + description: 'test_job', + groups: ['ecommerce'], + analysis_config: { + bucket_span: '1h', + detectors: [ + { + detector_description: 'High total sales', + function: 'high_sum', + field_name: 'taxful_total_price', + over_field_name: 'customer_full_name.keyword', + detector_index: 0, + }, + ], + influencers: ['customer_full_name.keyword', 'category.keyword'], + }, + data_description: { + time_field: 'order_date', + time_format: 'epoch_ms', + }, + analysis_limits: { + model_memory_limit: '13mb', + categorization_examples_limit: 4, + }, + }, + datafeed: { + datafeed_id: `datafeed-${jobId}`, + job_id: jobId, + query: { + bool: { + must: [ + { + match_all: {}, + }, + ], + filter: [], + must_not: [], + }, + }, + query_delay: '120s', + indices: [ECOMMERCE_INDEX_PATTERN], + } as unknown as estypes.MlDatafeed, + }; +} + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const ml = getService('ml'); + const pageObjects = getPageObjects(['triggersActionsUI']); + const commonScreenshots = getService('commonScreenshots'); + const browser = getService('browser'); + const actions = getService('actions'); + + const screenshotDirectories = ['ml_docs', 'anomaly_detection']; + + let testJobId = ''; + + describe('anomaly detection alert', function () { + before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/ecommerce'); + await ml.testResources.createIndexPatternIfNeeded('ft_ecommerce', 'order_date'); + + const { job, datafeed } = createTestJobAndDatafeed(); + + testJobId = job.job_id; + + // Set up jobs + // @ts-expect-error not full interface + await ml.api.createAnomalyDetectionJob(job); + await ml.api.openAnomalyDetectionJob(job.job_id); + await ml.api.createDatafeed(datafeed); + await ml.api.startDatafeed(datafeed.datafeed_id); + await ml.api.waitForDatafeedState(datafeed.datafeed_id, DATAFEED_STATE.STARTED); + await ml.api.assertJobResultsExist(job.job_id); + }); + + after(async () => { + await ml.api.deleteAnomalyDetectionJobES(testJobId); + await ml.api.cleanMlIndices(); + await ml.alerting.cleanAnomalyDetectionRules(); + await actions.api.deleteAllConnectors(); + }); + + describe('overview page alert flyout controls', () => { + it('alert flyout screenshot', async () => { + await ml.navigation.navigateToAlertsAndAction(); + await pageObjects.triggersActionsUI.clickCreateAlertButton(); + await ml.alerting.setRuleName('test-ecommerce'); + + await ml.alerting.openNotifySelection(); + await commonScreenshots.takeScreenshot('ml-rule', screenshotDirectories, 1920, 1400); + + // close popover + await browser.pressKeys(browser.keys.ESCAPE); + + await ml.alerting.selectAnomalyDetectionAlertType(); + await ml.testExecution.logTestStep('should have correct default values'); + await ml.alerting.assertSeverity(75); + await ml.alerting.assertPreviewButtonState(false); + await ml.testExecution.logTestStep('should complete the alert params'); + await ml.alerting.selectJobs([testJobId]); + await ml.alerting.selectResultType('bucket'); + await ml.alerting.setSeverity(75); + await ml.testExecution.logTestStep('should populate advanced settings with default values'); + await ml.alerting.assertTopNBuckets(1); + await ml.alerting.assertLookbackInterval('123m'); + await ml.testExecution.logTestStep('should preview the alert condition'); + await ml.alerting.assertPreviewButtonState(false); + await ml.alerting.setTestInterval('1y'); + await ml.alerting.assertPreviewButtonState(true); + await ml.alerting.scrollRuleNameIntoView(); + await ml.testExecution.logTestStep('take screenshot'); + await commonScreenshots.takeScreenshot( + 'ml-anomaly-alert-severity', + screenshotDirectories, + 1920, + 1400 + ); + await ml.alerting.selectSlackConnectorType(); + await ml.testExecution.logTestStep('should open connectors'); + await ml.alerting.clickCreateConnectorButton(); + await ml.alerting.setConnectorName('test-connector'); + await ml.alerting.setWebhookUrl('https://www.elastic.co'); + await ml.alerting.clickSaveActionButton(); + await ml.alerting.openAddRuleVariable(); + await ml.testExecution.logTestStep('take screenshot'); + await commonScreenshots.takeScreenshot( + 'ml-anomaly-alert-messages', + screenshotDirectories, + 1920, + 1400 + ); + }); + }); + }); +}; diff --git a/x-pack/test/screenshot_creation/apps/ml_docs/anomaly_detection/index.ts b/x-pack/test/screenshot_creation/apps/ml_docs/anomaly_detection/index.ts index 389a240eaa464..4d092c36d6545 100644 --- a/x-pack/test/screenshot_creation/apps/ml_docs/anomaly_detection/index.ts +++ b/x-pack/test/screenshot_creation/apps/ml_docs/anomaly_detection/index.ts @@ -10,6 +10,7 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('anomaly detection', function () { loadTestFile(require.resolve('./geographic_data')); + loadTestFile(require.resolve('./generate_anomaly_alerts')); loadTestFile(require.resolve('./population_analysis')); loadTestFile(require.resolve('./custom_urls')); loadTestFile(require.resolve('./mapping_anomalies')); diff --git a/x-pack/test/screenshot_creation/config.ts b/x-pack/test/screenshot_creation/config.ts index c83e90658ff72..5464806c20726 100644 --- a/x-pack/test/screenshot_creation/config.ts +++ b/x-pack/test/screenshot_creation/config.ts @@ -8,6 +8,7 @@ import Fs from 'fs'; import { CA_CERT_PATH } from '@kbn/dev-utils'; import { FtrConfigProviderContext } from '@kbn/test'; +import { pageObjects } from './page_objects'; import { services } from './services'; export default async function ({ readConfigFile }: FtrConfigProviderContext) { @@ -28,6 +29,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { // default to the xpack functional config ...xpackFunctionalConfig.getAll(), servers, + pageObjects, services, testFiles: [require.resolve('./apps')], junit: {