From 2de7ddc70e841f4106270070c24392b1cd97bb95 Mon Sep 17 00:00:00 2001 From: Pablo Machado Date: Thu, 20 Oct 2022 18:00:51 +0200 Subject: [PATCH] [Security Solution] Add quick job installation to anomalies table (#142861) * Add run job button --- .../ml/anomaly/use_anomalies_search.test.ts | 111 ++++++------- .../ml/anomaly/use_anomalies_search.ts | 73 +++------ .../components/ml/api/anomalies_search.ts | 2 +- .../public/common/components/ml/api/errors.ts | 4 + .../components/ml/api/throw_if_not_ok.ts | 31 ++-- .../ml_popover/hooks/translations.ts | 28 ++++ .../hooks/use_enable_data_feed.test.tsx | 146 ++++++++++++++++++ .../ml_popover/hooks/use_enable_data_feed.ts | 82 ++++++++++ .../hooks/use_security_jobs.test.ts | 8 +- .../ml_popover/hooks/use_security_jobs.ts | 18 ++- .../components/ml_popover/ml_popover.tsx | 138 ++--------------- .../components/ml_popover/translations.ts | 21 --- .../description_step/ml_job_description.tsx | 2 +- .../components/rules/ml_job_select/index.tsx | 2 +- .../entity_analytics/anomalies/columns.tsx | 113 ++++++-------- .../entity_analytics/anomalies/index.test.tsx | 46 ++++-- .../entity_analytics/anomalies/index.tsx | 42 ++++- .../entity_analytics/header/index.tsx | 12 +- .../translations/translations/fr-FR.json | 3 - .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - 21 files changed, 512 insertions(+), 376 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/translations.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_enable_data_feed.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_enable_data_feed.ts diff --git a/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_search.test.ts b/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_search.test.ts index 49be1077da393..430fddcd36fc6 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_search.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_search.test.ts @@ -4,27 +4,26 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -// import React from 'react'; -import type { RenderResult } from '@testing-library/react-hooks'; import { act, renderHook } from '@testing-library/react-hooks'; import { TestProviders } from '../../../mock'; -import type { Refetch } from '../../../store/inputs/model'; -import type { AnomaliesCount } from './use_anomalies_search'; -import { useNotableAnomaliesSearch, AnomalyJobStatus, AnomalyEntity } from './use_anomalies_search'; +import { useNotableAnomaliesSearch, AnomalyEntity } from './use_anomalies_search'; const jobId = 'auth_rare_source_ip_for_a_user'; const from = 'now-24h'; const to = 'now'; -const JOBS = [{ id: jobId, jobState: 'started', datafeedState: 'started' }]; +const job = { id: jobId, jobState: 'started', datafeedState: 'started' }; +const JOBS = [job]; +const useSecurityJobsRefetch = jest.fn(); -const mockuseInstalledSecurityJobs = jest.fn().mockReturnValue({ +const mockUseSecurityJobs = jest.fn().mockReturnValue({ loading: false, - isMlUser: true, + isMlAdmin: true, jobs: JOBS, + refetch: useSecurityJobsRefetch, }); -jest.mock('../hooks/use_installed_security_jobs', () => ({ - useInstalledSecurityJobs: () => mockuseInstalledSecurityJobs(), +jest.mock('../../ml_popover/hooks/use_security_jobs', () => ({ + useSecurityJobs: () => mockUseSecurityJobs(), })); const mockAddToastError = jest.fn(); @@ -72,14 +71,7 @@ describe('useNotableAnomaliesSearch', () => { expect(mockNotableAnomaliesSearch).not.toHaveBeenCalled(); }); - it('refetch calls notableAnomaliesSearch', async () => { - let renderResult: RenderResult<{ - isLoading: boolean; - data: AnomaliesCount[]; - refetch: Refetch; - }>; - - // first notableAnomaliesSearch call + it('refetch calls useSecurityJobs().refetch', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook( () => useNotableAnomaliesSearch({ skip: false, from, to }), @@ -87,17 +79,13 @@ describe('useNotableAnomaliesSearch', () => { wrapper: TestProviders, } ); - renderResult = result; await waitForNextUpdate(); - }); - await act(async () => { - mockNotableAnomaliesSearch.mockClear(); // clear the first notableAnomaliesSearch call - await renderResult.current.refetch(); + result.current.refetch(); }); - expect(mockNotableAnomaliesSearch).toHaveBeenCalled(); + expect(useSecurityJobsRefetch).toHaveBeenCalled(); }); it('returns formated data', async () => { @@ -119,9 +107,8 @@ describe('useNotableAnomaliesSearch', () => { expect.arrayContaining([ { count: 99, - jobId, name: jobId, - status: AnomalyJobStatus.enabled, + job, entity: AnomalyEntity.Host, }, ]) @@ -129,11 +116,28 @@ describe('useNotableAnomaliesSearch', () => { }); }); + it('does not throw error when aggregations is undefined', async () => { + await act(async () => { + mockNotableAnomaliesSearch.mockResolvedValue({}); + const { waitForNextUpdate } = renderHook( + () => useNotableAnomaliesSearch({ skip: false, from, to }), + { + wrapper: TestProviders, + } + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(mockAddToastError).not.toBeCalled(); + }); + }); + it('returns uninstalled jobs', async () => { - mockuseInstalledSecurityJobs.mockReturnValue({ + mockUseSecurityJobs.mockReturnValue({ loading: false, - isMlUser: true, + isMlAdmin: true, jobs: [], + refetch: useSecurityJobsRefetch, }); await act(async () => { @@ -153,9 +157,8 @@ describe('useNotableAnomaliesSearch', () => { expect.arrayContaining([ { count: 0, - jobId: undefined, - name: jobId, - status: AnomalyJobStatus.uninstalled, + name: job.id, + job: undefined, entity: AnomalyEntity.Host, }, ]) @@ -169,16 +172,18 @@ describe('useNotableAnomaliesSearch', () => { mockNotableAnomaliesSearch.mockResolvedValue({ aggregations: { number_of_anomalies: { buckets: [jobCount] } }, }); - mockuseInstalledSecurityJobs.mockReturnValue({ + + const customJob = { + id: customJobId, + jobState: 'started', + datafeedState: 'started', + }; + + mockUseSecurityJobs.mockReturnValue({ loading: false, - isMlUser: true, - jobs: [ - { - id: customJobId, - jobState: 'started', - datafeedState: 'started', - }, - ], + isMlAdmin: true, + jobs: [customJob], + refetch: useSecurityJobsRefetch, }); await act(async () => { @@ -196,9 +201,8 @@ describe('useNotableAnomaliesSearch', () => { expect.arrayContaining([ { count: 99, - jobId: customJobId, - name: jobId, - status: AnomalyJobStatus.enabled, + name: job.id, + job: customJob, entity: AnomalyEntity.Host, }, ]) @@ -209,6 +213,12 @@ describe('useNotableAnomaliesSearch', () => { it('returns the most recent job when there are multiple jobs matching one notable job id`', async () => { const mostRecentJobId = `mostRecent_${jobId}`; const leastRecentJobId = `leastRecent_${jobId}`; + const mostRecentJob = { + id: mostRecentJobId, + jobState: 'started', + datafeedState: 'started', + latestTimestampSortValue: 1661731200000, // 2022-08-29 + }; mockNotableAnomaliesSearch.mockResolvedValue({ aggregations: { @@ -221,9 +231,9 @@ describe('useNotableAnomaliesSearch', () => { }, }); - mockuseInstalledSecurityJobs.mockReturnValue({ + mockUseSecurityJobs.mockReturnValue({ loading: false, - isMlUser: true, + isMlAdmin: true, jobs: [ { id: leastRecentJobId, @@ -231,13 +241,9 @@ describe('useNotableAnomaliesSearch', () => { datafeedState: 'started', latestTimestampSortValue: 1661644800000, // 2022-08-28 }, - { - id: mostRecentJobId, - jobState: 'started', - datafeedState: 'started', - latestTimestampSortValue: 1661731200000, // 2022-08-29 - }, + mostRecentJob, ], + refetch: useSecurityJobsRefetch, }); await act(async () => { @@ -254,9 +260,8 @@ describe('useNotableAnomaliesSearch', () => { expect.arrayContaining([ { count: 99, - jobId: mostRecentJobId, name: jobId, - status: AnomalyJobStatus.enabled, + job: mostRecentJob, entity: AnomalyEntity.Host, }, ]) diff --git a/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_search.ts b/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_search.ts index 1a95f56465ba8..90025eb8d65fe 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_search.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_search.ts @@ -5,28 +5,20 @@ * 2.0. */ -import { useState, useEffect, useMemo, useRef } from 'react'; -import { filter, head, noop, orderBy, pipe, has } from 'lodash/fp'; -import type { MlSummaryJob } from '@kbn/ml-plugin/common'; +import { useState, useEffect, useMemo } from 'react'; +import { filter, head, orderBy, pipe, has } from 'lodash/fp'; import { DEFAULT_ANOMALY_SCORE } from '../../../../../common/constants'; import * as i18n from './translations'; import { useUiSetting$ } from '../../../lib/kibana'; import { useAppToasts } from '../../../hooks/use_app_toasts'; -import { useInstalledSecurityJobs } from '../hooks/use_installed_security_jobs'; import { notableAnomaliesSearch } from '../api/anomalies_search'; import type { NotableAnomaliesJobId } from '../../../../overview/components/entity_analytics/anomalies/config'; import { NOTABLE_ANOMALIES_IDS } from '../../../../overview/components/entity_analytics/anomalies/config'; import { getAggregatedAnomaliesQuery } from '../../../../overview/components/entity_analytics/anomalies/query'; import type { inputsModel } from '../../../store'; -import { isJobFailed, isJobStarted } from '../../../../../common/machine_learning/helpers'; - -export enum AnomalyJobStatus { - 'enabled', - 'disabled', - 'uninstalled', - 'failed', -} +import { useSecurityJobs } from '../../ml_popover/hooks/use_security_jobs'; +import type { SecurityJob } from '../../ml_popover/types'; export const enum AnomalyEntity { User, @@ -35,10 +27,9 @@ export const enum AnomalyEntity { export interface AnomaliesCount { name: NotableAnomaliesJobId; - jobId?: string; count: number; - status: AnomalyJobStatus; entity: AnomalyEntity; + job?: SecurityJob; } interface UseNotableAnomaliesSearchProps { @@ -57,19 +48,20 @@ export const useNotableAnomaliesSearch = ({ refetch: inputsModel.Refetch; } => { const [data, setData] = useState(formatResultData([], [])); - const refetch = useRef(noop); const { - loading: installedJobsLoading, - isMlUser, - jobs: installedSecurityJobs, - } = useInstalledSecurityJobs(); + loading: jobsLoading, + isMlAdmin: isMlUser, + jobs: securityJobs, + refetch: refetchJobs, + } = useSecurityJobs(); + const [loading, setLoading] = useState(true); const { addError } = useAppToasts(); const [anomalyScoreThreshold] = useUiSetting$(DEFAULT_ANOMALY_SCORE); const { notableAnomaliesJobs, query } = useMemo(() => { - const newNotableAnomaliesJobs = installedSecurityJobs.filter(({ id }) => + const newNotableAnomaliesJobs = securityJobs.filter(({ id }) => NOTABLE_ANOMALIES_IDS.some((notableJobId) => matchJobId(id, notableJobId)) ); @@ -84,7 +76,7 @@ export const useNotableAnomaliesSearch = ({ query: newQuery, notableAnomaliesJobs: newNotableAnomaliesJobs, }; - }, [installedSecurityJobs, anomalyScoreThreshold, from, to]); + }, [securityJobs, anomalyScoreThreshold, from, to]); useEffect(() => { let isSubscribed = true; @@ -102,7 +94,7 @@ export const useNotableAnomaliesSearch = ({ try { const response = await notableAnomaliesSearch( { - jobIds: notableAnomaliesJobs.map(({ id }) => id), + jobIds: notableAnomaliesJobs.filter((job) => job.isInstalled).map(({ id }) => id), query, }, abortCtrl.signal @@ -110,7 +102,7 @@ export const useNotableAnomaliesSearch = ({ if (isSubscribed) { setLoading(false); - const buckets = response.aggregations.number_of_anomalies.buckets; + const buckets = response.aggregations?.number_of_anomalies.buckets ?? []; setData(formatResultData(buckets, notableAnomaliesJobs)); } } catch (error) { @@ -122,32 +114,14 @@ export const useNotableAnomaliesSearch = ({ } fetchAnomaliesSearch(); - refetch.current = fetchAnomaliesSearch; + return () => { isSubscribed = false; abortCtrl.abort(); }; - }, [skip, isMlUser, addError, query, notableAnomaliesJobs]); + }, [skip, isMlUser, addError, query, notableAnomaliesJobs, refetchJobs]); - return { isLoading: loading || installedJobsLoading, data, refetch: refetch.current }; -}; - -const getMLJobStatus = ( - notableJobId: NotableAnomaliesJobId, - job: MlSummaryJob | undefined, - notableAnomaliesJobs: MlSummaryJob[] -) => { - if (job) { - if (isJobStarted(job.jobState, job.datafeedState)) { - return AnomalyJobStatus.enabled; - } - if (isJobFailed(job.jobState, job.datafeedState)) { - return AnomalyJobStatus.failed; - } - } - return notableAnomaliesJobs.some(({ id }) => matchJobId(id, notableJobId)) - ? AnomalyJobStatus.disabled - : AnomalyJobStatus.uninstalled; + return { isLoading: loading || jobsLoading, data, refetch: refetchJobs }; }; function formatResultData( @@ -155,7 +129,7 @@ function formatResultData( key: string; doc_count: number; }>, - notableAnomaliesJobs: MlSummaryJob[] + notableAnomaliesJobs: SecurityJob[] ): AnomaliesCount[] { return NOTABLE_ANOMALIES_IDS.map((notableJobId) => { const job = findJobWithId(notableJobId)(notableAnomaliesJobs); @@ -164,10 +138,9 @@ function formatResultData( return { name: notableJobId, - jobId: job?.id, count: bucket?.doc_count ?? 0, - status: getMLJobStatus(notableJobId, job, notableAnomaliesJobs), entity: hasUserName ? AnomalyEntity.User : AnomalyEntity.Host, + job, }; }); } @@ -183,8 +156,8 @@ const matchJobId = (jobId: string, notableJobId: NotableAnomaliesJobId) => * When multiple jobs match a notable job id, it returns the most recent one. */ const findJobWithId = (notableJobId: NotableAnomaliesJobId) => - pipe( - filter(({ id }) => matchJobId(id, notableJobId)), - orderBy('latestTimestampSortValue', 'desc'), + pipe( + filter(({ id }) => matchJobId(id, notableJobId)), + orderBy('latestTimestampSortValue', 'desc'), head ); diff --git a/x-pack/plugins/security_solution/public/common/components/ml/api/anomalies_search.ts b/x-pack/plugins/security_solution/public/common/components/ml/api/anomalies_search.ts index 2431b3da3220d..966137c616f99 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/api/anomalies_search.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml/api/anomalies_search.ts @@ -14,7 +14,7 @@ export interface Body { } export interface AnomaliesSearchResponse { - aggregations: { + aggregations?: { number_of_anomalies: { buckets: Array<{ key: NotableAnomaliesJobId; diff --git a/x-pack/plugins/security_solution/public/common/components/ml/api/errors.ts b/x-pack/plugins/security_solution/public/common/components/ml/api/errors.ts index b289890bb4335..5fad08ed0979a 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/api/errors.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml/api/errors.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { ErrorResponseBase } from '@elastic/elasticsearch/lib/api/types'; import { has } from 'lodash/fp'; import type { MlError } from '../types'; @@ -19,3 +20,6 @@ export interface MlStartJobError { // Otherwise for now, has will work ok even though it casts 'unknown' to 'any' export const isMlStartJobError = (value: unknown): value is MlStartJobError => has('error.msg', value) && has('error.response', value) && has('error.statusCode', value); + +export const isUnknownError = (value: unknown): value is ErrorResponseBase => + has('error.error.reason', value); diff --git a/x-pack/plugins/security_solution/public/common/components/ml/api/throw_if_not_ok.ts b/x-pack/plugins/security_solution/public/common/components/ml/api/throw_if_not_ok.ts index c4bd1888627f3..f88de77348322 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/api/throw_if_not_ok.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml/api/throw_if_not_ok.ts @@ -8,7 +8,7 @@ import * as i18n from './translations'; import { ToasterError } from '../../toasters'; import type { SetupMlResponse } from '../../ml_popover/types'; -import { isMlStartJobError } from './errors'; +import { isMlStartJobError, isUnknownError } from './errors'; export const tryParseResponse = (response: string): string => { try { @@ -22,18 +22,21 @@ export const throwIfErrorAttachedToSetup = ( setupResponse: SetupMlResponse, jobIdErrorFilter: string[] = [] ): void => { - const jobErrors = setupResponse.jobs.reduce( - (accum, job) => - job.error != null && jobIdErrorFilter.includes(job.id) - ? [ - ...accum, - job.error.msg, - tryParseResponse(job.error.response), - `${i18n.STATUS_CODE} ${job.error.statusCode}`, - ] - : accum, - [] - ); + const jobErrors = setupResponse.jobs.reduce((accum, job) => { + if (job.error != null && jobIdErrorFilter.includes(job.id)) { + if (isMlStartJobError(job)) { + return [ + ...accum, + job.error.msg, + tryParseResponse(job.error.response), + `${i18n.STATUS_CODE} ${job.error.statusCode}`, + ]; + } else if (isUnknownError(job)) { + return [job.error.error.reason]; + } + } + return accum; + }, []); const dataFeedErrors = setupResponse.datafeeds.reduce( (accum, dataFeed) => @@ -67,6 +70,8 @@ export const throwIfErrorAttached = ( tryParseResponse(dataFeed.error.response), `${i18n.STATUS_CODE} ${dataFeed.error.statusCode}`, ]; + } else if (isUnknownError(dataFeed)) { + return [dataFeed.error.error.reason]; } else { return accum; } diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/translations.ts b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/translations.ts new file mode 100644 index 0000000000000..0553ab20985b1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/translations.ts @@ -0,0 +1,28 @@ +/* + * 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 START_JOB_FAILURE = i18n.translate( + 'xpack.securitySolution.components.mlPopup.hooks.errors.startJobFailureTitle', + { + defaultMessage: 'Start job failure', + } +); + +export const STOP_JOB_FAILURE = i18n.translate( + 'xpack.securitySolution.components.mlPopup.hooks.errors.stopJobFailureTitle', + { + defaultMessage: 'Stop job failure', + } +); + +export const CREATE_JOB_FAILURE = i18n.translate( + 'xpack.securitySolution.components.mlPopup.hooks.errors.createJobFailureTitle', + { + defaultMessage: 'Create job failure', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_enable_data_feed.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_enable_data_feed.test.tsx new file mode 100644 index 0000000000000..0f5289ae587d7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_enable_data_feed.test.tsx @@ -0,0 +1,146 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { useEnableDataFeed } from './use_enable_data_feed'; +import { + createSecuritySolutionStorageMock, + kibanaObservable, + mockGlobalState, + SUB_PLUGINS_REDUCER, + TestProviders, +} from '../../../mock'; +import { createStore } from '../../../store'; +import type { State } from '../../../store'; + +import type { SecurityJob } from '../types'; +import { tGridReducer } from '@kbn/timelines-plugin/public'; + +const state: State = mockGlobalState; +const { storage } = createSecuritySolutionStorageMock(); +const store = createStore( + state, + SUB_PLUGINS_REDUCER, + { dataTable: tGridReducer }, + kibanaObservable, + storage +); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +const TIMESTAMP = 99999999; +const JOB = { + isInstalled: false, + datafeedState: 'failed', + jobState: 'failed', + isCompatible: true, +} as SecurityJob; + +const mockSetupMlJob = jest.fn().mockReturnValue(Promise.resolve()); +const mockStartDatafeeds = jest.fn().mockReturnValue(Promise.resolve()); +const mockStopDatafeeds = jest.fn().mockReturnValue(Promise.resolve()); + +jest.mock('../api', () => ({ + setupMlJob: () => mockSetupMlJob(), + startDatafeeds: (...params: unknown[]) => mockStartDatafeeds(...params), + stopDatafeeds: () => mockStopDatafeeds(), +})); + +describe('useSecurityJobsHelpers', () => { + afterEach(() => { + mockSetupMlJob.mockReset(); + mockStartDatafeeds.mockReset(); + mockStopDatafeeds.mockReset(); + }); + + it('renders isLoading=true when installing job', async () => { + let resolvePromiseCb: (value: unknown) => void; + mockSetupMlJob.mockReturnValue( + new Promise((resolve) => { + resolvePromiseCb = resolve; + }) + ); + const { result, waitForNextUpdate } = renderHook(() => useEnableDataFeed(), { + wrapper, + }); + expect(result.current.isLoading).toBe(false); + + await act(async () => { + const enableDataFeedPromise = result.current.enableDatafeed(JOB, TIMESTAMP, false); + + await waitForNextUpdate(); + expect(result.current.isLoading).toBe(true); + + resolvePromiseCb({}); + await enableDataFeedPromise; + expect(result.current.isLoading).toBe(false); + }); + }); + + it('does not call setupMlJob if job is already installed', async () => { + mockSetupMlJob.mockReturnValue(Promise.resolve()); + const { result } = renderHook(() => useEnableDataFeed(), { + wrapper, + }); + + await act(async () => { + await result.current.enableDatafeed({ ...JOB, isInstalled: true }, TIMESTAMP, false); + }); + + expect(mockSetupMlJob).not.toBeCalled(); + }); + + it('calls setupMlJob if job is uninstalled', async () => { + mockSetupMlJob.mockReturnValue(Promise.resolve()); + const { result } = renderHook(() => useEnableDataFeed(), { + wrapper, + }); + await act(async () => { + await result.current.enableDatafeed({ ...JOB, isInstalled: false }, TIMESTAMP, false); + }); + expect(mockSetupMlJob).toBeCalled(); + }); + + it('calls startDatafeeds if enable param is true', async () => { + const { result } = renderHook(() => useEnableDataFeed(), { + wrapper, + }); + await act(async () => { + await result.current.enableDatafeed(JOB, TIMESTAMP, true); + }); + expect(mockStartDatafeeds).toBeCalled(); + expect(mockStopDatafeeds).not.toBeCalled(); + }); + + it('calls stopDatafeeds if enable param is false', async () => { + const { result } = renderHook(() => useEnableDataFeed(), { + wrapper, + }); + await act(async () => { + await result.current.enableDatafeed(JOB, TIMESTAMP, false); + }); + expect(mockStartDatafeeds).not.toBeCalled(); + expect(mockStopDatafeeds).toBeCalled(); + }); + + it('calls startDatafeeds with 2 weeks old start date', async () => { + jest.useFakeTimers('modern').setSystemTime(new Date('1989-03-07')); + + const { result } = renderHook(() => useEnableDataFeed(), { + wrapper, + }); + await act(async () => { + await result.current.enableDatafeed(JOB, TIMESTAMP, true); + }); + expect(mockStartDatafeeds).toBeCalledWith({ + datafeedIds: [`datafeed-undefined`], + start: new Date('1989-02-21').getTime(), + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_enable_data_feed.ts b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_enable_data_feed.ts new file mode 100644 index 0000000000000..2d73d07c1787c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_enable_data_feed.ts @@ -0,0 +1,82 @@ +/* + * 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 { useCallback, useState } from 'react'; +import { useAppToasts } from '../../../hooks/use_app_toasts'; +import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../../lib/telemetry'; +import { setupMlJob, startDatafeeds, stopDatafeeds } from '../api'; +import type { SecurityJob } from '../types'; +import * as i18n from './translations'; + +// Enable/Disable Job & Datafeed -- passed to JobsTable for use as callback on JobSwitch +export const useEnableDataFeed = () => { + const { addError } = useAppToasts(); + const [isLoading, setIsLoading] = useState(false); + + const enableDatafeed = useCallback( + async (job: SecurityJob, latestTimestampMs: number, enable: boolean) => { + submitTelemetry(job, enable); + + if (!job.isInstalled) { + setIsLoading(true); + try { + await setupMlJob({ + configTemplate: job.moduleId, + indexPatternName: job.defaultIndexPattern, + jobIdErrorFilter: [job.id], + groups: job.groups, + }); + setIsLoading(false); + } catch (error) { + addError(error, { title: i18n.CREATE_JOB_FAILURE }); + setIsLoading(false); + return; + } + } + + // Max start time for job is no more than two weeks ago to ensure job performance + const date = new Date(); + const maxStartTime = date.setDate(date.getDate() - 14); + + setIsLoading(true); + if (enable) { + const startTime = Math.max(latestTimestampMs, maxStartTime); + try { + await startDatafeeds({ datafeedIds: [`datafeed-${job.id}`], start: startTime }); + } catch (error) { + track(METRIC_TYPE.COUNT, TELEMETRY_EVENT.JOB_ENABLE_FAILURE); + addError(error, { title: i18n.START_JOB_FAILURE }); + } + } else { + try { + await stopDatafeeds({ datafeedIds: [`datafeed-${job.id}`] }); + } catch (error) { + track(METRIC_TYPE.COUNT, TELEMETRY_EVENT.JOB_DISABLE_FAILURE); + addError(error, { title: i18n.STOP_JOB_FAILURE }); + } + } + setIsLoading(false); + }, + [addError] + ); + + return { enableDatafeed, isLoading }; +}; + +const submitTelemetry = (job: SecurityJob, enabled: boolean) => { + // Report type of job enabled/disabled + track( + METRIC_TYPE.COUNT, + job.isElasticJob + ? enabled + ? TELEMETRY_EVENT.SIEM_JOB_ENABLED + : TELEMETRY_EVENT.SIEM_JOB_DISABLED + : enabled + ? TELEMETRY_EVENT.CUSTOM_JOB_ENABLED + : TELEMETRY_EVENT.CUSTOM_JOB_DISABLED + ); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.test.ts b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.test.ts index db564d13456a0..23b284264387b 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.test.ts @@ -71,7 +71,7 @@ describe('useSecurityJobs', () => { bucketSpanSeconds: 900, }; - const { result, waitForNextUpdate } = renderHook(() => useSecurityJobs(false)); + const { result, waitForNextUpdate } = renderHook(() => useSecurityJobs()); await waitForNextUpdate(); expect(result.current.jobs).toHaveLength(6); @@ -79,7 +79,7 @@ describe('useSecurityJobs', () => { }); it('returns those permissions', async () => { - const { result, waitForNextUpdate } = renderHook(() => useSecurityJobs(false)); + const { result, waitForNextUpdate } = renderHook(() => useSecurityJobs()); await waitForNextUpdate(); expect(result.current.isMlAdmin).toEqual(true); @@ -88,7 +88,7 @@ describe('useSecurityJobs', () => { it('renders a toast error if an ML call fails', async () => { (getModules as jest.Mock).mockRejectedValue('whoops'); - const { waitForNextUpdate } = renderHook(() => useSecurityJobs(false)); + const { waitForNextUpdate } = renderHook(() => useSecurityJobs()); await waitForNextUpdate(); expect(appToastsMock.addError).toHaveBeenCalledWith('whoops', { @@ -104,7 +104,7 @@ describe('useSecurityJobs', () => { }); it('returns empty jobs and false predicates', () => { - const { result } = renderHook(() => useSecurityJobs(false)); + const { result } = renderHook(() => useSecurityJobs()); expect(result.current.jobs).toEqual([]); expect(result.current.isMlAdmin).toEqual(false); diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.ts b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.ts index c7d2c07eec2dd..4f39fd1a746c8 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.ts @@ -5,8 +5,9 @@ * 2.0. */ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { noop } from 'lodash/fp'; import { DEFAULT_INDEX_KEY } from '../../../../../common/constants'; import { hasMlAdminPermissions } from '../../../../../common/machine_learning/has_ml_admin_permissions'; import { hasMlLicense } from '../../../../../common/machine_learning/has_ml_license'; @@ -18,12 +19,14 @@ import { createSecurityJobs } from './use_security_jobs_helpers'; import { useMlCapabilities } from '../../ml/hooks/use_ml_capabilities'; import * as i18n from '../../ml/translations'; import { getJobsSummary } from '../../ml/api/get_jobs_summary'; +import type { inputsModel } from '../../../store'; export interface UseSecurityJobsReturn { loading: boolean; jobs: SecurityJob[]; isMlAdmin: boolean; isLicensed: boolean; + refetch: inputsModel.Refetch; } /** @@ -35,25 +38,24 @@ export interface UseSecurityJobsReturn { * NOTE: If the user is not an ml admin, jobs will be empty and isMlAdmin will be false. * If you only need installed jobs, try the {@link useInstalledSecurityJobs} hook. * - * @param refetchData */ -export const useSecurityJobs = (refetchData: boolean): UseSecurityJobsReturn => { +export const useSecurityJobs = (): UseSecurityJobsReturn => { const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(true); const mlCapabilities = useMlCapabilities(); const [securitySolutionDefaultIndex] = useUiSetting$(DEFAULT_INDEX_KEY); const http = useHttp(); const { addError } = useAppToasts(); - + const refetch = useRef(noop); const isMlAdmin = hasMlAdminPermissions(mlCapabilities); const isLicensed = hasMlLicense(mlCapabilities); useEffect(() => { let isSubscribed = true; const abortCtrl = new AbortController(); - setLoading(true); async function fetchSecurityJobIdsFromGroupsData() { + setLoading(true); if (isMlAdmin && isLicensed) { try { // Batch fetch all installed jobs, ML modules, and check which modules are compatible with securitySolutionDefaultIndex @@ -87,11 +89,13 @@ export const useSecurityJobs = (refetchData: boolean): UseSecurityJobsReturn => } fetchSecurityJobIdsFromGroupsData(); + + refetch.current = fetchSecurityJobIdsFromGroupsData; return () => { isSubscribed = false; abortCtrl.abort(); }; - }, [refetchData, isMlAdmin, isLicensed, securitySolutionDefaultIndex, addError, http]); + }, [isMlAdmin, isLicensed, securitySolutionDefaultIndex, addError, http]); - return { isLicensed, isMlAdmin, jobs, loading }; + return { isLicensed, isMlAdmin, jobs, loading, refetch: refetch.current }; }; diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_popover.tsx b/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_popover.tsx index accb9eb6d7387..9483c549485de 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_popover.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_popover.tsx @@ -13,17 +13,10 @@ import { EuiSpacer, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import moment from 'moment'; -import type { Dispatch } from 'react'; -import React, { useCallback, useReducer, useState, useMemo } from 'react'; +import React, { useCallback, useState, useMemo } from 'react'; import styled from 'styled-components'; - import { MLJobsAwaitingNodeWarning } from '@kbn/ml-plugin/public'; import { useKibana } from '../../lib/kibana'; -import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../lib/telemetry'; -import type { ActionToaster } from '../toasters'; -import { errorToToaster, useStateToaster } from '../toasters'; -import { setupMlJob, startDatafeeds, stopDatafeeds } from './api'; import { filterJobs } from './helpers'; import { JobsTableFilters } from './jobs_table/filters/jobs_table_filters'; import { JobsTable } from './jobs_table/jobs_table'; @@ -33,6 +26,7 @@ import * as i18n from './translations'; import type { JobsFilters, SecurityJob } from './types'; import { UpgradeContents } from './upgrade_contents'; import { useSecurityJobs } from './hooks/use_security_jobs'; +import { useEnableDataFeed } from './hooks/use_enable_data_feed'; const PopoverContentsDiv = styled.div` max-width: 684px; @@ -44,49 +38,6 @@ const PopoverContentsDiv = styled.div` PopoverContentsDiv.displayName = 'PopoverContentsDiv'; -interface State { - isLoading: boolean; - refreshToggle: boolean; -} - -type Action = { type: 'refresh' } | { type: 'loading' } | { type: 'success' } | { type: 'failure' }; - -function mlPopoverReducer(state: State, action: Action): State { - switch (action.type) { - case 'refresh': { - return { - ...state, - refreshToggle: !state.refreshToggle, - }; - } - case 'loading': { - return { - ...state, - isLoading: true, - }; - } - case 'success': { - return { - ...state, - isLoading: false, - }; - } - case 'failure': { - return { - ...state, - isLoading: false, - }; - } - default: - return state; - } -} - -const initialState: State = { - isLoading: false, - refreshToggle: true, -}; - const defaultFilterProps: JobsFilters = { filterQuery: '', showCustomJobs: false, @@ -95,8 +46,6 @@ const defaultFilterProps: JobsFilters = { }; export const MlPopover = React.memo(() => { - const [{ isLoading, refreshToggle }, dispatch] = useReducer(mlPopoverReducer, initialState); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [filterProperties, setFilterProperties] = useState(defaultFilterProps); const { @@ -104,13 +53,18 @@ export const MlPopover = React.memo(() => { isLicensed, loading: isLoadingSecurityJobs, jobs, - } = useSecurityJobs(refreshToggle); - const [, dispatchToaster] = useStateToaster(); + refetch: refreshJobs, + } = useSecurityJobs(); + const docLinks = useKibana().services.docLinks; + const { enableDatafeed, isLoading: isLoadingEnableDataFeed } = useEnableDataFeed(); const handleJobStateChange = useCallback( - (job: SecurityJob, latestTimestampMs: number, enable: boolean) => - enableDatafeed(job, latestTimestampMs, enable, dispatch, dispatchToaster), - [dispatch, dispatchToaster] + async (job: SecurityJob, latestTimestampMs: number, enable: boolean) => { + const result = await enableDatafeed(job, latestTimestampMs, enable); + refreshJobs(); + return result; + }, + [refreshJobs, enableDatafeed] ); const filteredJobs = filterJobs({ @@ -169,7 +123,7 @@ export const MlPopover = React.memo(() => { iconSide="right" onClick={() => { setIsPopoverOpen(!isPopoverOpen); - dispatch({ type: 'refresh' }); + refreshJobs(); }} textProps={{ style: { fontSize: '1rem' } }} > @@ -225,7 +179,7 @@ export const MlPopover = React.memo(() => { @@ -238,68 +192,4 @@ export const MlPopover = React.memo(() => { } }); -// Enable/Disable Job & Datafeed -- passed to JobsTable for use as callback on JobSwitch -const enableDatafeed = async ( - job: SecurityJob, - latestTimestampMs: number, - enable: boolean, - dispatch: Dispatch, - dispatchToaster: Dispatch -) => { - submitTelemetry(job, enable); - - if (!job.isInstalled) { - dispatch({ type: 'loading' }); - try { - await setupMlJob({ - configTemplate: job.moduleId, - indexPatternName: job.defaultIndexPattern, - jobIdErrorFilter: [job.id], - groups: job.groups, - }); - dispatch({ type: 'success' }); - } catch (error) { - errorToToaster({ title: i18n.CREATE_JOB_FAILURE, error, dispatchToaster }); - dispatch({ type: 'failure' }); - dispatch({ type: 'refresh' }); - return; - } - } - - // Max start time for job is no more than two weeks ago to ensure job performance - const maxStartTime = moment.utc().subtract(14, 'days').valueOf(); - - if (enable) { - const startTime = Math.max(latestTimestampMs, maxStartTime); - try { - await startDatafeeds({ datafeedIds: [`datafeed-${job.id}`], start: startTime }); - } catch (error) { - track(METRIC_TYPE.COUNT, TELEMETRY_EVENT.JOB_ENABLE_FAILURE); - errorToToaster({ title: i18n.START_JOB_FAILURE, error, dispatchToaster }); - } - } else { - try { - await stopDatafeeds({ datafeedIds: [`datafeed-${job.id}`] }); - } catch (error) { - track(METRIC_TYPE.COUNT, TELEMETRY_EVENT.JOB_DISABLE_FAILURE); - errorToToaster({ title: i18n.STOP_JOB_FAILURE, error, dispatchToaster }); - } - } - dispatch({ type: 'refresh' }); -}; - -const submitTelemetry = (job: SecurityJob, enabled: boolean) => { - // Report type of job enabled/disabled - track( - METRIC_TYPE.COUNT, - job.isElasticJob - ? enabled - ? TELEMETRY_EVENT.SIEM_JOB_ENABLED - : TELEMETRY_EVENT.SIEM_JOB_DISABLED - : enabled - ? TELEMETRY_EVENT.CUSTOM_JOB_ENABLED - : TELEMETRY_EVENT.CUSTOM_JOB_DISABLED - ); -}; - MlPopover.displayName = 'MlPopover'; diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/translations.ts b/x-pack/plugins/security_solution/public/common/components/ml_popover/translations.ts index 08df99d5cebeb..2fa1178060005 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/translations.ts @@ -41,24 +41,3 @@ export const MODULE_NOT_COMPATIBLE_TITLE = (incompatibleJobCount: number) => defaultMessage: '{incompatibleJobCount} {incompatibleJobCount, plural, =1 {job} other {jobs}} are currently unavailable', }); - -export const START_JOB_FAILURE = i18n.translate( - 'xpack.securitySolution.components.mlPopup.errors.startJobFailureTitle', - { - defaultMessage: 'Start job failure', - } -); - -export const STOP_JOB_FAILURE = i18n.translate( - 'xpack.securitySolution.containers.errors.stopJobFailureTitle', - { - defaultMessage: 'Stop job failure', - } -); - -export const CREATE_JOB_FAILURE = i18n.translate( - 'xpack.securitySolution.components.mlPopup.errors.createJobFailureTitle', - { - defaultMessage: 'Create job failure', - } -); 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 index c9dcb19c64e81..b5342934302b3 100644 --- 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 @@ -73,7 +73,7 @@ const Wrapper = styled.div` `; const MlJobDescriptionComponent: React.FC<{ jobId: string }> = ({ jobId }) => { - const { jobs } = useSecurityJobs(false); + const { jobs } = useSecurityJobs(); const { services: { http, ml }, } = useKibana(); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/ml_job_select/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/ml_job_select/index.tsx index d77b52a227cdf..a067930ca78ed 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/ml_job_select/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/ml_job_select/index.tsx @@ -67,7 +67,7 @@ const renderJobOption = (option: MlJobOption) => ( export const MlJobSelect: React.FC = ({ describedByIds = [], field }) => { const jobIds = field.value as string[]; const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); - const { loading, jobs } = useSecurityJobs(false); + const { loading, jobs } = useSecurityJobs(); const mlUrl = useKibana().services.application.getUrlForApp('ml'); const handleJobSelect = useCallback( (selectedJobOptions: MlJobOption[]): void => { diff --git a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/columns.tsx b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/columns.tsx index cc727efa5188e..465072743d1d5 100644 --- a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/columns.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/columns.tsx @@ -7,22 +7,25 @@ import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import type { EuiBasicTableColumn } from '@elastic/eui'; -import { EuiLink } from '@elastic/eui'; -import { ML_PAGES, useMlHref } from '@kbn/ml-plugin/public'; +import { EuiIcon, EuiLoadingSpinner } from '@elastic/eui'; import { useDispatch } from 'react-redux'; + import * as i18n from './translations'; import type { AnomaliesCount } from '../../../../common/components/ml/anomaly/use_anomalies_search'; -import { - AnomalyJobStatus, - AnomalyEntity, -} from '../../../../common/components/ml/anomaly/use_anomalies_search'; -import { useKibana } from '../../../../common/lib/kibana'; +import { AnomalyEntity } from '../../../../common/components/ml/anomaly/use_anomalies_search'; + import { LinkAnchor, SecuritySolutionLinkAnchor } from '../../../../common/components/links'; import { SecurityPageName } from '../../../../app/types'; import { usersActions } from '../../../../users/store'; import { hostsActions } from '../../../../hosts/store'; import { HostsType } from '../../../../hosts/store/model'; import { UsersType } from '../../../../users/store/model'; +import type { SecurityJob } from '../../../../common/components/ml_popover/types'; +import { + isJobFailed, + isJobStarted, + isJobLoading, +} from '../../../../../common/machine_learning/helpers'; type AnomaliesColumns = Array>; @@ -30,10 +33,10 @@ const MediumShadeText = styled.span` color: ${({ theme }) => theme.eui.euiColorMediumShade}; `; -const INSTALL_JOBS_DOC = - 'https://www.elastic.co/guide/en/machine-learning/current/ml-ad-run-jobs.html'; - -export const useAnomaliesColumns = (loading: boolean): AnomaliesColumns => { +export const useAnomaliesColumns = ( + loading: boolean, + onJobStateChange: (job: SecurityJob) => Promise +): AnomaliesColumns => { const columns: AnomaliesColumns = useMemo( () => [ { @@ -42,8 +45,8 @@ export const useAnomaliesColumns = (loading: boolean): AnomaliesColumns => { truncateText: true, mobileOptions: { show: true }, 'data-test-subj': 'anomalies-table-column-name', - render: (name, { status, count }) => { - if (count > 0 || status === AnomalyJobStatus.enabled) { + render: (name, { count, job }) => { + if (count > 0 || (job && isJobStarted(job.jobState, job.datafeedState))) { return name; } else { return {name}; @@ -52,14 +55,16 @@ export const useAnomaliesColumns = (loading: boolean): AnomaliesColumns => { }, { field: 'count', - sortable: ({ count, status }) => { + sortable: ({ count, job }) => { if (count > 0) { return count; } - if (status === AnomalyJobStatus.disabled) { - return -1; + + if (job && isJobStarted(job.jobState, job.datafeedState)) { + return 0; } - return -2; + + return -1; }, truncateText: true, align: 'right', @@ -67,65 +72,41 @@ export const useAnomaliesColumns = (loading: boolean): AnomaliesColumns => { mobileOptions: { show: true }, width: '15%', 'data-test-subj': 'anomalies-table-column-count', - render: (count, { status, jobId, entity }) => { - if (loading) return ''; - - if (count > 0 || status === AnomalyJobStatus.enabled) { - return ; + render: (count, { entity, job }) => { + if (!job) return ''; + + if (count > 0 || isJobStarted(job.jobState, job.datafeedState)) { + return ; + } else if (isJobFailed(job.jobState, job.datafeedState)) { + return i18n.JOB_STATUS_FAILED; + } else if (job.isCompatible) { + return ; } else { - if (status === AnomalyJobStatus.disabled && jobId) { - return ; - } - - if (status === AnomalyJobStatus.uninstalled) { - return ( - - {i18n.JOB_STATUS_UNINSTALLED} - - ); - } - - return {I18N_JOB_STATUS[status]}; + return ; } }, }, ], - [loading] + [loading, onJobStateChange] ); return columns; }; -const I18N_JOB_STATUS = { - [AnomalyJobStatus.disabled]: i18n.JOB_STATUS_DISABLED, - [AnomalyJobStatus.failed]: i18n.JOB_STATUS_FAILED, -}; - -const EnableJobLink = ({ jobId }: { jobId: string }) => { - const { - services: { - ml, - http, - application: { navigateToUrl }, - }, - } = useKibana(); - - const jobUrl = useMlHref(ml, http.basePath.get(), { - page: ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE, - pageState: { - jobId, - }, - }); - - const onClick = useCallback( - (ev) => { - ev.preventDefault(); - navigateToUrl(jobUrl); - }, - [jobUrl, navigateToUrl] - ); +const EnableJob = ({ + job, + isLoading, + onJobStateChange, +}: { + job: SecurityJob; + isLoading: boolean; + onJobStateChange: (job: SecurityJob) => Promise; +}) => { + const handleChange = useCallback(() => onJobStateChange(job), [job, onJobStateChange]); - return ( - + return isLoading || isJobLoading(job.jobState, job.datafeedState) ? ( + + ) : ( + {i18n.RUN_JOB} ); diff --git a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/index.test.tsx index 642e4ae207362..37d5cabca6ce4 100644 --- a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/index.test.tsx @@ -9,12 +9,10 @@ import { render } from '@testing-library/react'; import React from 'react'; import { EntityAnalyticsAnomalies } from '.'; import type { AnomaliesCount } from '../../../../common/components/ml/anomaly/use_anomalies_search'; -import { - AnomalyJobStatus, - AnomalyEntity, -} from '../../../../common/components/ml/anomaly/use_anomalies_search'; +import { AnomalyEntity } from '../../../../common/components/ml/anomaly/use_anomalies_search'; import { TestProviders } from '../../../../common/mock'; +import type { SecurityJob } from '../../../../common/components/ml_popover/types'; const mockUseNotableAnomaliesSearch = jest.fn().mockReturnValue({ isLoading: false, @@ -22,6 +20,15 @@ const mockUseNotableAnomaliesSearch = jest.fn().mockReturnValue({ refetch: jest.fn(), }); +jest.mock( + '@kbn/ml-plugin/public/application/components/jobs_awaiting_node_warning/new_job_awaiting_node_shared/lazy_loader', + () => { + return { + MLJobsAwaitingNodeWarning: () => <>, + }; + } +); + jest.mock('../../../../common/components/ml/anomaly/use_anomalies_search', () => { const original = jest.requireActual( '../../../../common/components/ml/anomaly/use_anomalies_search' @@ -66,10 +73,9 @@ describe('EntityAnalyticsAnomalies', () => { it('renders enabled jobs', () => { const jobCount: AnomaliesCount = { - jobId: 'v3_windows_anomalous_script', + job: { isInstalled: true, datafeedState: 'started', jobState: 'opened' } as SecurityJob, name: 'v3_windows_anomalous_script', count: 9999, - status: AnomalyJobStatus.enabled, entity: AnomalyEntity.User, }; @@ -93,10 +99,14 @@ describe('EntityAnalyticsAnomalies', () => { it('renders disabled jobs', () => { const jobCount: AnomaliesCount = { - jobId: 'v3_windows_anomalous_script', + job: { + isInstalled: true, + datafeedState: 'stopped', + jobState: 'closed', + isCompatible: true, + } as SecurityJob, name: 'v3_windows_anomalous_script', count: 0, - status: AnomalyJobStatus.disabled, entity: AnomalyEntity.User, }; @@ -114,15 +124,15 @@ describe('EntityAnalyticsAnomalies', () => { expect(getByTestId('anomalies-table-column-name')).toHaveTextContent(jobCount.name); expect(getByTestId('anomalies-table-column-count')).toHaveTextContent('Run job'); - expect(getByTestId('jobs-table-link')).toBeInTheDocument(); + expect(getByTestId('enable-job')).toBeInTheDocument(); }); it('renders uninstalled jobs', () => { const jobCount: AnomaliesCount = { - jobId: 'v3_windows_anomalous_script', + job: { isInstalled: false, isCompatible: true } as SecurityJob, name: 'v3_windows_anomalous_script', count: 0, - status: AnomalyJobStatus.uninstalled, + entity: AnomalyEntity.User, }; @@ -139,15 +149,20 @@ describe('EntityAnalyticsAnomalies', () => { ); expect(getByTestId('anomalies-table-column-name')).toHaveTextContent(jobCount.name); - expect(getByTestId('anomalies-table-column-count')).toHaveTextContent('uninstalled'); + expect(getByTestId('anomalies-table-column-count')).toHaveTextContent('Run job'); + expect(getByTestId('enable-job')).toBeInTheDocument(); }); it('renders failed jobs', () => { const jobCount: AnomaliesCount = { - jobId: 'v3_windows_anomalous_script', + job: { + isInstalled: true, + datafeedState: 'failed', + jobState: 'failed', + isCompatible: true, + } as SecurityJob, name: 'v3_windows_anomalous_script', count: 0, - status: AnomalyJobStatus.failed, entity: AnomalyEntity.User, }; @@ -169,10 +184,9 @@ describe('EntityAnalyticsAnomalies', () => { it('renders empty count column while loading', () => { const jobCount: AnomaliesCount = { - jobId: 'v3_windows_anomalous_script', + job: undefined, name: 'v3_windows_anomalous_script', count: 0, - status: AnomalyJobStatus.failed, entity: AnomalyEntity.User, }; diff --git a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/index.tsx b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/index.tsx index d88ad343eab3c..beeae25f64b2a 100644 --- a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/index.tsx @@ -4,10 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiInMemoryTable, EuiPanel } from '@elastic/eui'; -import { ML_PAGES, useMlHref } from '@kbn/ml-plugin/public'; +import { MLJobsAwaitingNodeWarning, ML_PAGES, useMlHref } from '@kbn/ml-plugin/public'; import { HeaderSection } from '../../../../common/components/header_section'; import { useQueryToggle } from '../../../../common/containers/query_toggle'; import { LastUpdatedAt } from '../../../../common/components/last_updated_at'; @@ -27,6 +27,8 @@ import { SecurityPageName } from '../../../../app/types'; import { getTabsOnUsersUrl } from '../../../../common/components/link_to/redirect_to_users'; import { UsersTableType } from '../../../../users/store/model'; import { useKibana } from '../../../../common/lib/kibana'; +import { useEnableDataFeed } from '../../../../common/components/ml_popover/hooks/use_enable_data_feed'; +import type { SecurityJob } from '../../../../common/components/ml_popover/types'; const TABLE_QUERY_ID = 'entityAnalyticsDashboardAnomaliesTable'; @@ -51,22 +53,40 @@ export const EntityAnalyticsAnomalies = () => { const [updatedAt, setUpdatedAt] = useState(Date.now()); const { toggleStatus, setToggleStatus } = useQueryToggle(TABLE_QUERY_ID); const { deleteQuery, setQuery, from, to } = useGlobalTime(false); - const { isLoading, data, refetch } = useNotableAnomaliesSearch({ + const { + isLoading: isSearchLoading, + data, + refetch, + } = useNotableAnomaliesSearch({ skip: !toggleStatus, from, to, }); - const columns = useAnomaliesColumns(isLoading); + const { isLoading: isEnableDataFeedLoading, enableDatafeed } = useEnableDataFeed(); + + const handleJobStateChange = useCallback( + async (job: SecurityJob) => { + const result = await enableDatafeed(job, job.latestTimestampMs || 0, true); + refetch(); + return result; + }, + [refetch, enableDatafeed] + ); + + const columns = useAnomaliesColumns( + isSearchLoading || isEnableDataFeedLoading, + handleJobStateChange + ); const getSecuritySolutionLinkProps = useGetSecuritySolutionLinkProps(); useEffect(() => { setUpdatedAt(Date.now()); - }, [isLoading]); // Update the time when data loads + }, [isSearchLoading]); // Update the time when data loads useQueryInspector({ refetch, queryId: TABLE_QUERY_ID, - loading: isLoading, + loading: isSearchLoading, setQuery, deleteQuery, }); @@ -87,12 +107,17 @@ export const EntityAnalyticsAnomalies = () => { return [onClick, href]; }, [getSecuritySolutionLinkProps]); + const installedJobsIds = useMemo( + () => data.filter(({ job }) => !!job && job.isInstalled).map(({ job }) => job?.id ?? ''), + [data] + ); + return ( } + subtitle={} toggleStatus={toggleStatus} toggleQuery={setToggleStatus} > @@ -124,12 +149,13 @@ export const EntityAnalyticsAnomalies = () => { + {toggleStatus && ( diff --git a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/header/index.tsx b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/header/index.tsx index 1daf91cd2285c..78ded5e6fc941 100644 --- a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/header/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/header/index.tsx @@ -30,6 +30,7 @@ import { useGlobalTime } from '../../../../common/containers/use_global_time'; import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities'; import { useQueryInspector } from '../../../../common/components/page/manage_query'; import { ENTITY_ANALYTICS_ANOMALIES_PANEL } from '../anomalies'; +import { isJobStarted } from '../../../../../common/machine_learning/helpers'; const StyledEuiTitle = styled(EuiTitle)` color: ${({ theme: { eui } }) => eui.euiColorDanger}; @@ -69,6 +70,7 @@ export const EntityAnalyticsHeader = () => { }); const { data } = useNotableAnomaliesSearch({ skip: false, from, to }); + const dispatch = useDispatch(); const getSecuritySolutionLinkProps = useGetSecuritySolutionLinkProps(); const isPlatinumOrTrialLicense = useMlCapabilities().isPlatinumOrTrialLicense; @@ -138,8 +140,14 @@ export const EntityAnalyticsHeader = () => { inspect: inspectHostRiskScore, }); - // Anomalies are enabled if at least one job is installed - const areJobsEnabled = useMemo(() => data.some(({ jobId }) => !!jobId), [data]); + // Anomaly jobs are enabled if at least one job is started or has data + const areJobsEnabled = useMemo( + () => + data.some( + ({ job, count }) => count > 0 || (job && isJobStarted(job.jobState, job.datafeedState)) + ), + [data] + ); const totalAnomalies = useMemo( () => (areJobsEnabled ? sumBy('count', data) : '-'), diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index d99e56245c44b..1f04ed95b4419 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -26150,8 +26150,6 @@ "xpack.securitySolution.components.mlPopover.jobsTable.filters.showAllJobsLabel": "Tâches Elastic", "xpack.securitySolution.components.mlPopover.jobsTable.filters.showSiemJobsLabel": "Tâches personnalisées", "xpack.securitySolution.components.mlPopup.cloudLink": "déploiement sur le cloud", - "xpack.securitySolution.components.mlPopup.errors.createJobFailureTitle": "Échec de création de la tâche", - "xpack.securitySolution.components.mlPopup.errors.startJobFailureTitle": "Échec de démarrage de la tâche", "xpack.securitySolution.components.mlPopup.hooks.errors.indexPatternFetchFailureTitle": "Échec de récupération du modèle d'indexation", "xpack.securitySolution.components.mlPopup.hooks.errors.siemJobFetchFailureTitle": "Échec de récupération de la tâche Security", "xpack.securitySolution.components.mlPopup.jobsTable.createCustomJobButtonLabel": "Création d'une tâche personnalisée", @@ -26212,7 +26210,6 @@ "xpack.securitySolution.containers.detectionEngine.createPrePackagedTimelineSuccesDescription": "Installation effectuée des modèles de chronologies prépackagées à partir d'Elastic", "xpack.securitySolution.containers.detectionEngine.rulesAndTimelines": "Impossible de récupérer les règles et les chronologies", "xpack.securitySolution.containers.detectionEngine.tagFetchFailDescription": "Impossible de récupérer les balises", - "xpack.securitySolution.containers.errors.stopJobFailureTitle": "Échec d'arrêt de la tâche", "xpack.securitySolution.contextMenuItemByRouter.viewDetails": "Afficher les détails", "xpack.securitySolution.customizeEventRenderers.customizeEventRenderersDescription": "Les outils de rendu d'événement transmettent automatiquement les détails les plus pertinents d'un événement pour révéler son histoire", "xpack.securitySolution.customizeEventRenderers.customizeEventRenderersTitle": "Personnaliser les outils de rendu d'événement", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 8475224e49c07..b529dafaecfe2 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -26125,8 +26125,6 @@ "xpack.securitySolution.components.mlPopover.jobsTable.filters.showAllJobsLabel": "Elastic ジョブ", "xpack.securitySolution.components.mlPopover.jobsTable.filters.showSiemJobsLabel": "カスタムジョブ", "xpack.securitySolution.components.mlPopup.cloudLink": "クラウド展開", - "xpack.securitySolution.components.mlPopup.errors.createJobFailureTitle": "ジョブ作成エラー", - "xpack.securitySolution.components.mlPopup.errors.startJobFailureTitle": "ジョブ開始エラー", "xpack.securitySolution.components.mlPopup.hooks.errors.indexPatternFetchFailureTitle": "インデックスパターン取得エラー", "xpack.securitySolution.components.mlPopup.hooks.errors.siemJobFetchFailureTitle": "セキュリティジョブ取得エラー", "xpack.securitySolution.components.mlPopup.jobsTable.createCustomJobButtonLabel": "カスタムジョブを作成", @@ -26187,7 +26185,6 @@ "xpack.securitySolution.containers.detectionEngine.createPrePackagedTimelineSuccesDescription": "Elasticから事前にパッケージ化されているタイムラインテンプレートをインストールしました", "xpack.securitySolution.containers.detectionEngine.rulesAndTimelines": "ルールとタイムラインを取得できませんでした", "xpack.securitySolution.containers.detectionEngine.tagFetchFailDescription": "タグを取得できませんでした", - "xpack.securitySolution.containers.errors.stopJobFailureTitle": "ジョブ停止エラー", "xpack.securitySolution.contextMenuItemByRouter.viewDetails": "詳細を表示", "xpack.securitySolution.customizeEventRenderers.customizeEventRenderersDescription": "イベントレンダラーは、イベントで最も関連性が高い詳細情報を自動的に表示し、ストーリーを明らかにします", "xpack.securitySolution.customizeEventRenderers.customizeEventRenderersTitle": "イベントレンダラーのカスタマイズ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index f212c72f2ff7b..d6f202da17c15 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -26159,8 +26159,6 @@ "xpack.securitySolution.components.mlPopover.jobsTable.filters.showAllJobsLabel": "Elastic 作业", "xpack.securitySolution.components.mlPopover.jobsTable.filters.showSiemJobsLabel": "定制作业", "xpack.securitySolution.components.mlPopup.cloudLink": "云部署", - "xpack.securitySolution.components.mlPopup.errors.createJobFailureTitle": "创建作业失败", - "xpack.securitySolution.components.mlPopup.errors.startJobFailureTitle": "启动作业失败", "xpack.securitySolution.components.mlPopup.hooks.errors.indexPatternFetchFailureTitle": "索引模式提取失败", "xpack.securitySolution.components.mlPopup.hooks.errors.siemJobFetchFailureTitle": "Security 作业提取失败", "xpack.securitySolution.components.mlPopup.jobsTable.createCustomJobButtonLabel": "创建定制作业", @@ -26221,7 +26219,6 @@ "xpack.securitySolution.containers.detectionEngine.createPrePackagedTimelineSuccesDescription": "安装 Elastic 预先打包的时间线模板", "xpack.securitySolution.containers.detectionEngine.rulesAndTimelines": "无法提取规则和时间线", "xpack.securitySolution.containers.detectionEngine.tagFetchFailDescription": "无法提取标签", - "xpack.securitySolution.containers.errors.stopJobFailureTitle": "停止作业失败", "xpack.securitySolution.contextMenuItemByRouter.viewDetails": "查看详情", "xpack.securitySolution.customizeEventRenderers.customizeEventRenderersDescription": "事件呈现器自动在事件中传送最相关的详情,以揭示其故事", "xpack.securitySolution.customizeEventRenderers.customizeEventRenderersTitle": "定制事件呈现器",