From 0ecb2cb4492775b5651793aa7f7ab5917de44c88 Mon Sep 17 00:00:00 2001 From: Pablo Machado Date: Tue, 25 Apr 2023 11:13:46 +0200 Subject: [PATCH] [Security Solutions] Display additional anomaly jobs in Entity Analytics Dashboard (#155520) issue: https://github.com/elastic/security-team/issues/6161 ## Summary * Adds more hardcoded jobs to the list of jobs displayed on the Notable anomalies table * Add pagination to the table * Remove the logic that refreshes the table when a job is installed * Move enableDataFeed logic to `` and use the response from the API to determine if the job was successfully installed. * Recently installed jobs are no longer sorted so users can find the jobs they have just installed. * When the page refreshes all jobs are sorted ![Apr-21-2023 17-47-28](https://user-images.githubusercontent.com/1490444/233953871-e2583aa8-4d7b-402a-aef3-e001dfc7ae18.gif) * I also replaced the loading spinner with a "Waiting" status when jobs are waiting for machine learning nodes to start because the loading spinner gave the false impression that the table would update at any moment. Screenshot 2023-04-24 at 11 22 57 TODO - [x] Cypress tests ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../e2e/dashboards/entity_analytics.cy.ts | 35 +- .../cypress/screens/entity_analytics.ts | 9 + .../ml/anomaly/use_anomalies_search.test.ts | 11 +- .../hooks/use_enable_data_feed.test.tsx | 320 +++++++++++------- .../ml_popover/hooks/use_enable_data_feed.ts | 148 ++++---- .../components/ml_popover/ml_popover.tsx | 16 +- .../logic/use_start_ml_jobs.tsx | 2 +- ...admin_job_description.integration.test.tsx | 3 +- .../admin/ml_admin_job_description.tsx | 15 +- .../entity_analytics/anomalies/columns.tsx | 60 +--- .../components/anomalies_tab_link.tsx | 73 ++++ .../anomalies/components/enable_job.test.tsx | 66 ++++ .../anomalies/components/enable_job.tsx | 41 +++ .../components/total_anomalies.test.tsx | 38 +++ .../anomalies/components/total_anomalies.tsx | 51 +++ .../entity_analytics/anomalies/config.ts | 49 ++- .../entity_analytics/anomalies/index.test.tsx | 35 +- .../entity_analytics/anomalies/index.tsx | 34 +- .../anomalies/translations.ts | 7 + 19 files changed, 744 insertions(+), 269 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/components/anomalies_tab_link.tsx create mode 100644 x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/components/enable_job.test.tsx create mode 100644 x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/components/enable_job.tsx create mode 100644 x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/components/total_anomalies.test.tsx create mode 100644 x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/components/total_anomalies.tsx diff --git a/x-pack/plugins/security_solution/cypress/e2e/dashboards/entity_analytics.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/dashboards/entity_analytics.cy.ts index 66820050516be..b8f8243ff9f87 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/dashboards/entity_analytics.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/dashboards/entity_analytics.cy.ts @@ -28,6 +28,10 @@ import { USERS_TABLE_ALERT_CELL, HOSTS_TABLE_ALERT_CELL, HOSTS_TABLE, + ANOMALIES_TABLE_NEXT_PAGE_BUTTON, + ANOMALIES_TABLE_ENABLE_JOB_BUTTON, + ANOMALIES_TABLE_ENABLE_JOB_LOADER, + ANOMALIES_TABLE_COUNT_COLUMN, } from '../../screens/entity_analytics'; import { openRiskTableFilterAndSelectTheLowOption } from '../../tasks/host_risk'; import { createRule } from '../../tasks/api_calls/rules'; @@ -35,6 +39,7 @@ import { waitForAlertsToPopulate } from '../../tasks/create_new_rule'; import { getNewRule } from '../../objects/rule'; import { clickOnFirstHostsAlerts, clickOnFirstUsersAlerts } from '../../tasks/risk_scores'; import { OPTION_LIST_LABELS, OPTION_LIST_VALUES } from '../../screens/common/filter_group'; +import { setRowsPerPageTo } from '../../tasks/table_pagination'; const TEST_USER_ALERTS = 2; const TEST_USER_NAME = 'test'; @@ -239,13 +244,39 @@ describe('Entity Analytics Dashboard', () => { }); describe('With anomalies data', () => { + before(() => { + esArchiverLoad('network'); + }); + + after(() => { + esArchiverUnload('network'); + }); + beforeEach(() => { visit(ENTITY_ANALYTICS_URL); }); - it('renders table', () => { + it('renders table with pagination', () => { cy.get(ANOMALIES_TABLE).should('be.visible'); - cy.get(ANOMALIES_TABLE_ROWS).should('have.length', 6); + cy.get(ANOMALIES_TABLE_ROWS).should('have.length', 10); + + // navigates to next page + cy.get(ANOMALIES_TABLE_NEXT_PAGE_BUTTON).click(); + cy.get(ANOMALIES_TABLE_ROWS).should('have.length', 10); + + // updates rows per page to 25 items + setRowsPerPageTo(25); + cy.get(ANOMALIES_TABLE_ROWS).should('have.length', 25); + }); + + it('enables a job', () => { + cy.get(ANOMALIES_TABLE_ROWS) + .eq(5) + .within(() => { + cy.get(ANOMALIES_TABLE_ENABLE_JOB_BUTTON).click(); + cy.get(ANOMALIES_TABLE_ENABLE_JOB_LOADER).should('be.visible'); + cy.get(ANOMALIES_TABLE_COUNT_COLUMN).should('include.text', '0'); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/entity_analytics.ts b/x-pack/plugins/security_solution/cypress/screens/entity_analytics.ts index 6095151fee57b..f41ae2a175f05 100644 --- a/x-pack/plugins/security_solution/cypress/screens/entity_analytics.ts +++ b/x-pack/plugins/security_solution/cypress/screens/entity_analytics.ts @@ -47,6 +47,15 @@ export const ANOMALIES_TABLE = export const ANOMALIES_TABLE_ROWS = '[data-test-subj="entity_analytics_anomalies"] .euiTableRow'; +export const ANOMALIES_TABLE_ENABLE_JOB_BUTTON = '[data-test-subj="enable-job"]'; + +export const ANOMALIES_TABLE_ENABLE_JOB_LOADER = '[data-test-subj="job-switch-loader"]'; + +export const ANOMALIES_TABLE_COUNT_COLUMN = '[data-test-subj="anomalies-table-column-count"]'; + +export const ANOMALIES_TABLE_NEXT_PAGE_BUTTON = + '[data-test-subj="entity_analytics_anomalies"] [data-test-subj="pagination-button-next"]'; + export const UPGRADE_CONFIRMATION_MODAL = (riskScoreEntity: RiskScoreEntity) => `[data-test-subj="${riskScoreEntity}-risk-score-upgrade-confirmation-modal"]`; 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 fa193a50afc8a..f4959bb223155 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 @@ -162,14 +162,9 @@ describe('useNotableAnomaliesSearch', () => { await waitForNextUpdate(); const names = result.current.data.map(({ name }) => name); - expect(names).toEqual([ - firstJobSecurityName, - secondJobSecurityName, - 'packetbeat_dns_tunneling', - 'packetbeat_rare_dns_question', - 'packetbeat_rare_server_domain', - 'suspicious_login_activity', - ]); + + expect(names[0]).toEqual(firstJobSecurityName); + expect(names[1]).toEqual(secondJobSecurityName); }); }); 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 index b4c8265d23242..52d955414a099 100644 --- 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 @@ -43,9 +43,9 @@ const JOB = { 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()); +const mockSetupMlJob = jest.fn(); +const mockStartDatafeeds = jest.fn(); +const mockStopDatafeeds = jest.fn(); jest.mock('../api', () => ({ setupMlJob: () => mockSetupMlJob(), @@ -69,186 +69,266 @@ jest.mock('../../../lib/kibana', () => { describe('useSecurityJobsHelpers', () => { afterEach(() => { - mockSetupMlJob.mockReset(); mockStartDatafeeds.mockReset(); mockStopDatafeeds.mockReset(); mockSetupMlJob.mockReset(); - }); - it('renders isLoading=true when installing job', async () => { - let resolvePromiseCb: (value: unknown) => void; - mockSetupMlJob.mockReturnValue( - new Promise((resolve) => { - resolvePromiseCb = resolve; - }) + mockStartDatafeeds.mockReturnValue( + Promise.resolve({ [`datafeed-${jobId}`]: { started: true } }) ); - const { result, waitForNextUpdate } = renderHook(() => useEnableDataFeed(), { - wrapper, - }); - expect(result.current.isLoading).toBe(false); + mockStopDatafeeds.mockReturnValue( + Promise.resolve([{ [`datafeed-${jobId}`]: { stopped: true } }]) + ); + mockSetupMlJob.mockReturnValue(Promise.resolve()); + }); - await act(async () => { - const enableDataFeedPromise = result.current.enableDatafeed(JOB, TIMESTAMP, false); + describe('enableDatafeed', () => { + 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 waitForNextUpdate(); - expect(result.current.isLoading).toBe(true); + await act(async () => { + const enableDataFeedPromise = result.current.enableDatafeed(JOB, TIMESTAMP); - resolvePromiseCb({}); - await enableDataFeedPromise; - expect(result.current.isLoading).toBe(false); - }); - }); + await waitForNextUpdate(); + expect(result.current.isLoading).toBe(true); - it('does not call setupMlJob if job is already installed', async () => { - mockSetupMlJob.mockReturnValue(Promise.resolve()); - const { result } = renderHook(() => useEnableDataFeed(), { - wrapper, + resolvePromiseCb({}); + await enableDataFeedPromise; + expect(result.current.isLoading).toBe(false); + }); }); - await act(async () => { - await result.current.enableDatafeed({ ...JOB, isInstalled: true }, TIMESTAMP, false); - }); + it('does not call setupMlJob if job is already installed', async () => { + const { result } = renderHook(() => useEnableDataFeed(), { + wrapper, + }); - expect(mockSetupMlJob).not.toBeCalled(); - }); + await act(async () => { + await result.current.enableDatafeed({ ...JOB, isInstalled: true }, TIMESTAMP); + }); - 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).not.toBeCalled(); }); - 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); + it('calls setupMlJob if job is uninstalled', async () => { + const { result } = renderHook(() => useEnableDataFeed(), { + wrapper, + }); + await act(async () => { + await result.current.enableDatafeed({ ...JOB, isInstalled: false }, TIMESTAMP); + }); + expect(mockSetupMlJob).toBeCalled(); }); - 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); + it('calls startDatafeeds when enableDatafeed is called', async () => { + const { result } = renderHook(() => useEnableDataFeed(), { + wrapper, + }); + await act(async () => { + await result.current.enableDatafeed(JOB, TIMESTAMP); + }); + expect(mockStartDatafeeds).toBeCalled(); + expect(mockStopDatafeeds).not.toBeCalled(); }); - expect(mockStartDatafeeds).not.toBeCalled(); - expect(mockStopDatafeeds).toBeCalled(); - }); - it('calls startDatafeeds with 2 weeks old start date', async () => { - jest.useFakeTimers().setSystemTime(new Date('1989-03-07')); + it('calls startDatafeeds with 2 weeks old start date', async () => { + jest.useFakeTimers().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-test_job_id`], - start: new Date('1989-02-21').getTime(), + const { result } = renderHook(() => useEnableDataFeed(), { + wrapper, + }); + await act(async () => { + await result.current.enableDatafeed(JOB, TIMESTAMP); + }); + expect(mockStartDatafeeds).toBeCalledWith({ + datafeedIds: [`datafeed-test_job_id`], + start: new Date('1989-02-21').getTime(), + }); }); - }); - describe('telemetry', () => { - it('reports telemetry when installing and enabling a job', async () => { - mockSetupMlJob.mockReturnValue(new Promise((resolve) => resolve({}))); + it('return enabled:true when startDataFeed successfully installed the job', async () => { const { result } = renderHook(() => useEnableDataFeed(), { wrapper, }); - await act(async () => { - await result.current.enableDatafeed(JOB, TIMESTAMP, true); + const response = await result.current.enableDatafeed(JOB, TIMESTAMP); + expect(response.enabled).toBeTruthy(); }); + }); - expect(mockedTelemetry.reportMLJobUpdate).toHaveBeenCalledWith({ - status: ML_JOB_TELEMETRY_STATUS.moduleInstalled, - isElasticJob: true, - jobId, - moduleId, + it('return enabled:false when startDataFeed promise is rejected while installing a job', async () => { + mockStartDatafeeds.mockReturnValue(Promise.reject(new Error('test_error'))); + const { result } = renderHook(() => useEnableDataFeed(), { + wrapper, }); - - expect(mockedTelemetry.reportMLJobUpdate).toHaveBeenCalledWith({ - status: ML_JOB_TELEMETRY_STATUS.started, - isElasticJob: true, - jobId, + await act(async () => { + const response = await result.current.enableDatafeed(JOB, TIMESTAMP); + expect(response.enabled).toBeFalsy(); }); }); - it('reports telemetry when stopping a job', async () => { + it('return enabled:false when startDataFeed failed to install the job', async () => { + mockStartDatafeeds.mockReturnValue( + Promise.resolve({ [`datafeed-${jobId}`]: { started: false, error: 'test_error' } }) + ); + const { result } = renderHook(() => useEnableDataFeed(), { wrapper, }); await act(async () => { - await result.current.enableDatafeed({ ...JOB, isInstalled: true }, TIMESTAMP, false); + const response = await result.current.enableDatafeed(JOB, TIMESTAMP); + expect(response.enabled).toBeFalsy(); + }); + }); + + describe('telemetry', () => { + it('reports telemetry when installing and enabling a job', async () => { + const { result } = renderHook(() => useEnableDataFeed(), { + wrapper, + }); + + await act(async () => { + await result.current.enableDatafeed(JOB, TIMESTAMP); + }); + + expect(mockedTelemetry.reportMLJobUpdate).toHaveBeenCalledWith({ + status: ML_JOB_TELEMETRY_STATUS.moduleInstalled, + isElasticJob: true, + jobId, + moduleId, + }); + + expect(mockedTelemetry.reportMLJobUpdate).toHaveBeenCalledWith({ + status: ML_JOB_TELEMETRY_STATUS.started, + isElasticJob: true, + jobId, + }); }); - expect(mockedTelemetry.reportMLJobUpdate).toHaveBeenCalledWith({ - status: ML_JOB_TELEMETRY_STATUS.stopped, - isElasticJob: true, - jobId, + it('reports telemetry when starting a job fails', async () => { + mockStartDatafeeds.mockReturnValue(Promise.reject(new Error('test_error'))); + const { result } = renderHook(() => useEnableDataFeed(), { + wrapper, + }); + await act(async () => { + await result.current.enableDatafeed({ ...JOB, isInstalled: true }, TIMESTAMP); + }); + + expect(mockedTelemetry.reportMLJobUpdate).toHaveBeenCalledWith({ + status: ML_JOB_TELEMETRY_STATUS.startError, + errorMessage: 'Start job failure - test_error', + isElasticJob: true, + jobId, + }); + }); + + it('reports telemetry when installing a module fails', async () => { + mockSetupMlJob.mockReturnValue(Promise.reject(new Error('test_error'))); + const { result } = renderHook(() => useEnableDataFeed(), { + wrapper, + }); + await act(async () => { + await result.current.enableDatafeed(JOB, TIMESTAMP); + }); + + expect(mockedTelemetry.reportMLJobUpdate).toHaveBeenCalledWith({ + status: ML_JOB_TELEMETRY_STATUS.installationError, + errorMessage: 'Create job failure - test_error', + isElasticJob: true, + jobId, + moduleId, + }); }); }); + }); - it('reports telemetry when stopping a job fails', async () => { - mockStopDatafeeds.mockReturnValue(Promise.reject(new Error('test_error'))); + describe('disableDatafeed', () => { + it('return enabled:false when disableDatafeed successfully uninstalled the job', async () => { const { result } = renderHook(() => useEnableDataFeed(), { wrapper, }); await act(async () => { - await result.current.enableDatafeed({ ...JOB, isInstalled: true }, TIMESTAMP, false); - }); - - expect(mockedTelemetry.reportMLJobUpdate).toHaveBeenCalledWith({ - status: ML_JOB_TELEMETRY_STATUS.stopError, - errorMessage: 'Stop job failure - test_error', - isElasticJob: true, - jobId, + const response = await result.current.disableDatafeed(JOB); + expect(response.enabled).toBeFalsy(); }); }); - it('reports telemetry when starting a job fails', async () => { - mockStartDatafeeds.mockReturnValue(Promise.reject(new Error('test_error'))); + it('return enabled:true when promise is rejected while uninstalling the job', async () => { + mockStopDatafeeds.mockReturnValue(Promise.reject(new Error('test_error'))); const { result } = renderHook(() => useEnableDataFeed(), { wrapper, }); await act(async () => { - await result.current.enableDatafeed({ ...JOB, isInstalled: true }, TIMESTAMP, true); + const response = await result.current.disableDatafeed(JOB); + expect(response.enabled).toBeTruthy(); }); + }); + + it('return enabled:true when disableDatafeed fails to uninstall the job', async () => { + mockStopDatafeeds.mockReturnValue( + Promise.resolve([{ [`datafeed-${jobId}`]: { stopped: false, error: 'test_error' } }]) + ); - expect(mockedTelemetry.reportMLJobUpdate).toHaveBeenCalledWith({ - status: ML_JOB_TELEMETRY_STATUS.startError, - errorMessage: 'Start job failure - test_error', - isElasticJob: true, - jobId, + const { result } = renderHook(() => useEnableDataFeed(), { + wrapper, + }); + await act(async () => { + const response = await result.current.disableDatafeed(JOB); + expect(response.enabled).toBeTruthy(); }); }); - it('reports telemetry when installing a module fails', async () => { - mockSetupMlJob.mockReturnValue(Promise.reject(new Error('test_error'))); + it('calls stopDatafeeds when disableDatafeed is called', async () => { const { result } = renderHook(() => useEnableDataFeed(), { wrapper, }); await act(async () => { - await result.current.enableDatafeed(JOB, TIMESTAMP, true); + await result.current.disableDatafeed(JOB); + }); + expect(mockStartDatafeeds).not.toBeCalled(); + expect(mockStopDatafeeds).toBeCalled(); + }); + + describe('telemetry', () => { + it('reports telemetry when stopping a job', async () => { + const { result } = renderHook(() => useEnableDataFeed(), { + wrapper, + }); + await act(async () => { + await result.current.disableDatafeed({ ...JOB, isInstalled: true }); + }); + + expect(mockedTelemetry.reportMLJobUpdate).toHaveBeenCalledWith({ + status: ML_JOB_TELEMETRY_STATUS.stopped, + isElasticJob: true, + jobId, + }); }); - expect(mockedTelemetry.reportMLJobUpdate).toHaveBeenCalledWith({ - status: ML_JOB_TELEMETRY_STATUS.installationError, - errorMessage: 'Create job failure - test_error', - isElasticJob: true, - jobId, - moduleId, + it('reports telemetry when stopping a job fails', async () => { + mockStopDatafeeds.mockReturnValue(Promise.reject(new Error('test_error'))); + const { result } = renderHook(() => useEnableDataFeed(), { + wrapper, + }); + await act(async () => { + await result.current.disableDatafeed({ ...JOB, isInstalled: true }); + }); + + expect(mockedTelemetry.reportMLJobUpdate).toHaveBeenCalledWith({ + status: ML_JOB_TELEMETRY_STATUS.stopError, + errorMessage: 'Stop job failure - test_error', + isElasticJob: true, + jobId, + }); }); }); }); 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 index 48b7a918af26c..393e132436c38 100644 --- 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 @@ -5,6 +5,7 @@ * 2.0. */ +import { isEmpty } from 'lodash/fp'; import { useCallback, useState } from 'react'; import { useAppToasts } from '../../../hooks/use_app_toasts'; import { useKibana } from '../../../lib/kibana'; @@ -16,10 +17,10 @@ import { } from '../../../lib/telemetry'; import { setupMlJob, startDatafeeds, stopDatafeeds } from '../api'; -import type { SecurityJob } from '../types'; +import type { ErrorResponse, SecurityJob } from '../types'; import * as i18n from './translations'; -// Enable/Disable Job & Datafeed -- passed to JobsTable for use as callback on JobSwitch +// Enable/Disable Job & Datafeed export const useEnableDataFeed = () => { const { telemetry } = useKibana().services; @@ -27,11 +28,14 @@ export const useEnableDataFeed = () => { const [isLoading, setIsLoading] = useState(false); const enableDatafeed = useCallback( - async (job: SecurityJob, latestTimestampMs: number, enable: boolean) => { - submitTelemetry(job, enable); + async (job: SecurityJob, latestTimestampMs: number) => { + setIsLoading(true); + track( + METRIC_TYPE.COUNT, + job.isElasticJob ? TELEMETRY_EVENT.SIEM_JOB_ENABLED : TELEMETRY_EVENT.CUSTOM_JOB_ENABLED + ); if (!job.isInstalled) { - setIsLoading(true); try { await setupMlJob({ configTemplate: job.moduleId, @@ -39,7 +43,6 @@ export const useEnableDataFeed = () => { jobIdErrorFilter: [job.id], groups: job.groups, }); - setIsLoading(false); telemetry.reportMLJobUpdate({ jobId: job.id, isElasticJob: job.isElasticJob, @@ -47,8 +50,8 @@ export const useEnableDataFeed = () => { status: ML_JOB_TELEMETRY_STATUS.moduleInstalled, }); } catch (error) { - addError(error, { title: i18n.CREATE_JOB_FAILURE }); setIsLoading(false); + addError(error, { title: i18n.CREATE_JOB_FAILURE }); telemetry.reportMLJobUpdate({ jobId: job.id, isElasticJob: job.isElasticJob, @@ -56,7 +59,8 @@ export const useEnableDataFeed = () => { status: ML_JOB_TELEMETRY_STATUS.installationError, errorMessage: `${i18n.CREATE_JOB_FAILURE} - ${error.message}`, }); - return; + + return { enabled: false }; } } @@ -64,63 +68,89 @@ export const useEnableDataFeed = () => { 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 }); - telemetry.reportMLJobUpdate({ - jobId: job.id, - isElasticJob: job.isElasticJob, - status: ML_JOB_TELEMETRY_STATUS.started, - }); - } catch (error) { - track(METRIC_TYPE.COUNT, TELEMETRY_EVENT.JOB_ENABLE_FAILURE); - addError(error, { title: i18n.START_JOB_FAILURE }); - telemetry.reportMLJobUpdate({ - jobId: job.id, - isElasticJob: job.isElasticJob, - status: ML_JOB_TELEMETRY_STATUS.startError, - errorMessage: `${i18n.START_JOB_FAILURE} - ${error.message}`, - }); - } - } else { - try { - await stopDatafeeds({ datafeedIds: [`datafeed-${job.id}`] }); - telemetry.reportMLJobUpdate({ - jobId: job.id, - isElasticJob: job.isElasticJob, - status: ML_JOB_TELEMETRY_STATUS.stopped, - }); - } catch (error) { - track(METRIC_TYPE.COUNT, TELEMETRY_EVENT.JOB_DISABLE_FAILURE); - addError(error, { title: i18n.STOP_JOB_FAILURE }); - telemetry.reportMLJobUpdate({ - jobId: job.id, - isElasticJob: job.isElasticJob, - status: ML_JOB_TELEMETRY_STATUS.stopError, - errorMessage: `${i18n.STOP_JOB_FAILURE} - ${error.message}`, - }); + const datafeedId = `datafeed-${job.id}`; + + const startTime = Math.max(latestTimestampMs, maxStartTime); + + try { + const response = await startDatafeeds({ + datafeedIds: [datafeedId], + start: startTime, + }); + + if (response[datafeedId]?.error) { + throw new Error(response[datafeedId].error); } + + telemetry.reportMLJobUpdate({ + jobId: job.id, + isElasticJob: job.isElasticJob, + status: ML_JOB_TELEMETRY_STATUS.started, + }); + + return { enabled: response[datafeedId] ? response[datafeedId].started : false }; + } catch (error) { + track(METRIC_TYPE.COUNT, TELEMETRY_EVENT.JOB_ENABLE_FAILURE); + addError(error, { title: i18n.START_JOB_FAILURE }); + telemetry.reportMLJobUpdate({ + jobId: job.id, + isElasticJob: job.isElasticJob, + status: ML_JOB_TELEMETRY_STATUS.startError, + errorMessage: `${i18n.START_JOB_FAILURE} - ${error.message}`, + }); + } finally { + setIsLoading(false); } - setIsLoading(false); + + return { enabled: false }; }, [addError, telemetry] ); - return { enableDatafeed, isLoading }; -}; + const disableDatafeed = useCallback( + async (job: SecurityJob) => { + track( + METRIC_TYPE.COUNT, + job.isElasticJob ? TELEMETRY_EVENT.SIEM_JOB_DISABLED : TELEMETRY_EVENT.CUSTOM_JOB_DISABLED + ); + setIsLoading(true); -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 + const datafeedId = `datafeed-${job.id}`; + + try { + const [response] = await stopDatafeeds({ datafeedIds: [datafeedId] }); + + if (isErrorResponse(response)) { + throw new Error(response.error); + } + + telemetry.reportMLJobUpdate({ + jobId: job.id, + isElasticJob: job.isElasticJob, + status: ML_JOB_TELEMETRY_STATUS.stopped, + }); + + return { enabled: response[datafeedId] ? !response[datafeedId].stopped : true }; + } catch (error) { + track(METRIC_TYPE.COUNT, TELEMETRY_EVENT.JOB_DISABLE_FAILURE); + addError(error, { title: i18n.STOP_JOB_FAILURE }); + telemetry.reportMLJobUpdate({ + jobId: job.id, + isElasticJob: job.isElasticJob, + status: ML_JOB_TELEMETRY_STATUS.stopError, + errorMessage: `${i18n.STOP_JOB_FAILURE} - ${error.message}`, + }); + } finally { + setIsLoading(false); + } + + return { enabled: true }; + }, + [addError, telemetry] ); + + return { enableDatafeed, disableDatafeed, isLoading }; }; + +const isErrorResponse = (response: ErrorResponse): response is ErrorResponse => + !isEmpty(response.error); 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 d518a42f2907d..5f3b834d777b8 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 @@ -59,14 +59,22 @@ export const MlPopover = React.memo(() => { } = useSecurityJobs(); const docLinks = useKibana().services.docLinks; - const { enableDatafeed, isLoading: isLoadingEnableDataFeed } = useEnableDataFeed(); + const { + enableDatafeed, + disableDatafeed, + isLoading: isLoadingEnableDataFeed, + } = useEnableDataFeed(); const handleJobStateChange = useCallback( async (job: SecurityJob, latestTimestampMs: number, enable: boolean) => { - const result = await enableDatafeed(job, latestTimestampMs, enable); + if (enable) { + await enableDatafeed(job, latestTimestampMs); + } else { + await disableDatafeed(job); + } + refreshJobs(); - return result; }, - [refreshJobs, enableDatafeed] + [refreshJobs, enableDatafeed, disableDatafeed] ); const filteredJobs = filterJobs({ diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/use_start_ml_jobs.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/use_start_ml_jobs.tsx index a0afcad5901e4..cf12b8b0f1bf6 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/use_start_ml_jobs.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/use_start_ml_jobs.tsx @@ -43,7 +43,7 @@ export const useStartMlJobs = (): ReturnUseStartMlJobs => { } const latestTimestampMs = job.latestTimestampMs ?? 0; - await enableDatafeed(job, latestTimestampMs, true); + await enableDatafeed(job, latestTimestampMs); }) ); refetchJobs(); 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 index ad9b3c751f59f..104ea748f106c 100644 --- 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 @@ -56,8 +56,7 @@ describe('MlAdminJobDescription', () => { userEvent.click(screen.getByTestId('job-switch')); expect(enableDatafeedSpy).toHaveBeenCalledWith( securityJobNotStarted, - securityJobNotStarted.latestTimestampMs, - true + securityJobNotStarted.latestTimestampMs ); await waitFor(() => { 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 index 7d4616a004364..7f1236aef08cd 100644 --- 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 @@ -25,14 +25,23 @@ const MlAdminJobDescriptionComponent: FC = ({ loading, refreshJob, }) => { - const { enableDatafeed, isLoading: isLoadingEnableDataFeed } = useEnableDataFeed(); + const { + enableDatafeed, + disableDatafeed, + isLoading: isLoadingEnableDataFeed, + } = useEnableDataFeed(); const handleJobStateChange = useCallback( async (_, latestTimestampMs: number, enable: boolean) => { - await enableDatafeed(job, latestTimestampMs, enable); + if (enable) { + await enableDatafeed(job, latestTimestampMs); + } else { + await disableDatafeed(job); + } + refreshJob(job); }, - [enableDatafeed, job, refreshJob] + [enableDatafeed, disableDatafeed, job, refreshJob] ); const switchComponent = useMemo( 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 1f8556dd27a98..4ef5d2811b5c2 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 @@ -4,20 +4,15 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useMemo } from 'react'; import styled from 'styled-components'; import type { EuiBasicTableColumn } from '@elastic/eui'; -import { EuiIcon, EuiLoadingSpinner } from '@elastic/eui'; import * as i18n from './translations'; -import type { AnomaliesCount } from '../../../../common/components/ml/anomaly/use_anomalies_search'; -import { LinkAnchor } from '../../../../common/components/links'; import type { SecurityJob } from '../../../../common/components/ml_popover/types'; -import { - isJobFailed, - isJobStarted, - isJobLoading, -} from '../../../../../common/machine_learning/helpers'; -import { AnomaliesCountLink } from './anomalies_count_link'; +import { isJobStarted } from '../../../../../common/machine_learning/helpers'; + +import { TotalAnomalies } from './components/total_anomalies'; +import type { AnomaliesCount } from '../../../../common/components/ml/anomaly/use_anomalies_search'; type AnomaliesColumns = Array>; @@ -27,7 +22,8 @@ const MediumShadeText = styled.span` export const useAnomaliesColumns = ( loading: boolean, - onJobStateChange: (job: SecurityJob) => Promise + onJobEnabled: (job: SecurityJob) => void, + recentlyEnabledJobIds: string[] ): AnomaliesColumns => { const columns: AnomaliesColumns = useMemo( () => [ @@ -66,40 +62,20 @@ export const useAnomaliesColumns = ( 'data-test-subj': 'anomalies-table-column-count', 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 { - return ; - } + return ( + + ); }, }, ], - [loading, onJobStateChange] + [loading, onJobEnabled, recentlyEnabledJobIds] ); return columns; }; - -const EnableJob = ({ - job, - isLoading, - onJobStateChange, -}: { - job: SecurityJob; - isLoading: boolean; - onJobStateChange: (job: SecurityJob) => Promise; -}) => { - const handleChange = useCallback(() => onJobStateChange(job), [job, onJobStateChange]); - - return isLoading || isJobLoading(job.jobState, job.datafeedState) ? ( - - ) : ( - - {i18n.RUN_JOB} - - ); -}; diff --git a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/components/anomalies_tab_link.tsx b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/components/anomalies_tab_link.tsx new file mode 100644 index 0000000000000..80f78ee50331e --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/components/anomalies_tab_link.tsx @@ -0,0 +1,73 @@ +/* + * 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 { useDispatch } from 'react-redux'; +import { SecuritySolutionLinkAnchor } from '../../../../../common/components/links'; +import { SecurityPageName } from '../../../../../app/types'; +import { usersActions } from '../../../../../explore/users/store'; +import { hostsActions } from '../../../../../explore/hosts/store'; +import { HostsType } from '../../../../../explore/hosts/store/model'; +import { UsersType } from '../../../../../explore/users/store/model'; +import { AnomalyEntity } from '../../../../../common/components/ml/anomaly/use_anomalies_search'; + +export const AnomaliesTabLink = ({ + count, + jobId, + entity, +}: { + count: number; + jobId?: string; + entity: AnomalyEntity; +}) => { + const dispatch = useDispatch(); + + const deepLinkId = + entity === AnomalyEntity.User + ? SecurityPageName.usersAnomalies + : SecurityPageName.hostsAnomalies; + + const onClick = useCallback(() => { + if (!jobId) return; + + if (entity === AnomalyEntity.User) { + dispatch( + usersActions.updateUsersAnomaliesJobIdFilter({ + jobIds: [jobId], + usersType: UsersType.page, + }) + ); + + dispatch( + usersActions.updateUsersAnomaliesInterval({ + interval: 'second', + usersType: UsersType.page, + }) + ); + } else { + dispatch( + hostsActions.updateHostsAnomaliesJobIdFilter({ + jobIds: [jobId], + hostsType: HostsType.page, + }) + ); + + dispatch( + hostsActions.updateHostsAnomaliesInterval({ + interval: 'second', + hostsType: HostsType.page, + }) + ); + } + }, [jobId, dispatch, entity]); + + return ( + + {count} + + ); +}; diff --git a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/components/enable_job.test.tsx b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/components/enable_job.test.tsx new file mode 100644 index 0000000000000..c5c199b79df09 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/components/enable_job.test.tsx @@ -0,0 +1,66 @@ +/* + * 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, fireEvent, waitFor } from '@testing-library/react'; +import { useEnableDataFeed } from '../../../../../common/components/ml_popover/hooks/use_enable_data_feed'; +import type { SecurityJob } from '../../../../../common/components/ml_popover/types'; +import { EnableJob } from './enable_job'; + +jest.mock('../../../../../common/components/ml_popover/hooks/use_enable_data_feed', () => ({ + useEnableDataFeed: jest.fn(() => ({ enableDatafeed: jest.fn(), isLoading: false })), +})); + +describe('EnableJob', () => { + const job = { id: 'job-1', latestTimestampMs: 123456789 } as SecurityJob; + + it('renders loading spinner when isLoading is true', () => { + const { queryByTestId } = render( + + ); + expect(queryByTestId('job-switch-loader')).toBeInTheDocument(); + }); + + it('renders enable job when isLoading is false', () => { + const { queryByTestId } = render( + + ); + expect(queryByTestId('job-switch-loader')).not.toBeInTheDocument(); + }); + + it('calls enableDatafeed and onJobEnabled when enable job is clicked', async () => { + const enableDatafeedMock = jest.fn(() => ({ enabled: true })); + const onJobEnabledMock = jest.fn(); + (useEnableDataFeed as jest.Mock).mockReturnValueOnce({ + enableDatafeed: enableDatafeedMock, + isLoading: false, + }); + const { getByText } = render( + + ); + fireEvent.click(getByText('Run job')); + + await waitFor(() => { + expect(enableDatafeedMock).toHaveBeenCalledWith(job, job.latestTimestampMs); + expect(onJobEnabledMock).toHaveBeenCalledWith(job); + }); + }); + + it('renders loading spinner when enabling data feed', async () => { + const enableDatafeedMock = jest.fn(() => ({ enabled: true })); + const onJobEnabledMock = jest.fn(); + (useEnableDataFeed as jest.Mock).mockReturnValueOnce({ + enableDatafeed: enableDatafeedMock, + isLoading: true, + }); + const { queryByTestId } = render( + + ); + + expect(queryByTestId('job-switch-loader')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/components/enable_job.tsx b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/components/enable_job.tsx new file mode 100644 index 0000000000000..533a0eddcbc1a --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/components/enable_job.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, { useCallback } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import type { SecurityJob } from '../../../../../common/components/ml_popover/types'; +import { LinkAnchor } from '../../../../../common/components/links'; +import * as i18n from '../translations'; +import { useEnableDataFeed } from '../../../../../common/components/ml_popover/hooks/use_enable_data_feed'; + +export const EnableJob = ({ + job, + isLoading, + onJobEnabled, +}: { + job: SecurityJob; + isLoading: boolean; + onJobEnabled: (job: SecurityJob) => void; +}) => { + const { enableDatafeed, isLoading: isEnabling } = useEnableDataFeed(); + + const handleChange = useCallback(async () => { + const result = await enableDatafeed(job, job.latestTimestampMs || 0); + + if (result.enabled) { + onJobEnabled(job); + } + }, [enableDatafeed, job, onJobEnabled]); + + return isLoading || isEnabling ? ( + + ) : ( + + {i18n.RUN_JOB} + + ); +}; diff --git a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/components/total_anomalies.test.tsx b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/components/total_anomalies.test.tsx new file mode 100644 index 0000000000000..3cd8e25869763 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/components/total_anomalies.test.tsx @@ -0,0 +1,38 @@ +/* + * 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 { AnomalyEntity } from '../../../../../common/components/ml/anomaly/use_anomalies_search'; +import type { SecurityJob } from '../../../../../common/components/ml_popover/types'; +import { render } from '@testing-library/react'; +import { TotalAnomalies } from './total_anomalies'; +import { TestProviders } from '../../../../../common/mock'; + +const defaultProps = { + count: 0, + job: { isInstalled: true, datafeedState: 'started', jobState: 'opened' } as SecurityJob, + entity: AnomalyEntity.User, + recentlyEnabledJobIds: [], + loading: false, + onJobEnabled: () => {}, +}; + +describe('TotalAnomalies', () => { + it('shows a waiting status when the job is loading', () => { + const loadingJob = { + isInstalled: false, + datafeedState: 'starting', + jobState: 'opening', + } as SecurityJob; + + const { container } = render(, { + wrapper: TestProviders, + }); + + expect(container).toHaveTextContent('Waiting'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/components/total_anomalies.tsx b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/components/total_anomalies.tsx new file mode 100644 index 0000000000000..8311b28177a08 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/components/total_anomalies.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 { EuiIcon } from '@elastic/eui'; +import React from 'react'; +import { + isJobFailed, + isJobLoading, + isJobStarted, +} from '../../../../../../common/machine_learning/helpers'; +import type { AnomalyEntity } from '../../../../../common/components/ml/anomaly/use_anomalies_search'; +import type { SecurityJob } from '../../../../../common/components/ml_popover/types'; +import * as i18n from '../translations'; +import { AnomaliesTabLink } from './anomalies_tab_link'; +import { EnableJob } from './enable_job'; + +export const TotalAnomalies = ({ + count, + job, + entity, + recentlyEnabledJobIds, + loading, + onJobEnabled, +}: { + count: number; + job: SecurityJob; + entity: AnomalyEntity; + recentlyEnabledJobIds: string[]; + loading: boolean; + onJobEnabled: (job: SecurityJob) => void; +}) => { + if (isJobLoading(job.jobState, job.datafeedState)) { + return <>{i18n.JOB_STATUS_WAITING}; + } else if (isJobFailed(job.jobState, job.datafeedState)) { + return <>{i18n.JOB_STATUS_FAILED}; + } else if ( + count > 0 || + isJobStarted(job.jobState, job.datafeedState) || + recentlyEnabledJobIds.includes(job.id) + ) { + return ; + } else if (job.isCompatible) { + return ; + } else { + return ; + } +}; diff --git a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/config.ts b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/config.ts index 29f227e800f7a..1396568384b17 100644 --- a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/config.ts +++ b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/config.ts @@ -5,18 +5,49 @@ * 2.0. */ -export const NOTABLE_ANOMALIES_IDS: NotableAnomaliesJobId[] = [ +export const NOTABLE_ANOMALIES_IDS = [ 'auth_rare_source_ip_for_a_user', 'packetbeat_dns_tunneling', 'packetbeat_rare_server_domain', 'packetbeat_rare_dns_question', 'suspicious_login_activity', 'v3_windows_anomalous_script', -]; -export type NotableAnomaliesJobId = - | 'auth_rare_source_ip_for_a_user' - | 'packetbeat_dns_tunneling' - | 'packetbeat_rare_server_domain' - | 'packetbeat_rare_dns_question' - | 'suspicious_login_activity' - | 'v3_windows_anomalous_script'; + 'high_count_network_denies', + 'v3_windows_anomalous_process_all_hosts', + 'v3_linux_rare_metadata_process', + 'packetbeat_rare_user_agent', + 'v3_linux_anomalous_process_all_hosts', + 'packetbeat_rare_urls', + 'v3_windows_anomalous_path_activity', + 'v3_windows_anomalous_process_creation', + 'v3_linux_system_process_discovery', + 'v3_linux_system_user_discovery', + 'high_count_by_destination_country', + 'auth_high_count_logon_events', + 'v3_linux_anomalous_user_name', + 'v3_rare_process_by_host_windows', + 'v3_linux_anomalous_network_activity', + 'auth_high_count_logon_fails', + 'auth_high_count_logon_events_for_a_source_ip', + 'v3_linux_rare_metadata_user', + 'rare_destination_country', + 'v3_linux_system_information_discovery', + 'v3_linux_rare_user_compiler', + 'v3_windows_anomalous_user_name', + 'v3_rare_process_by_host_linux', + 'v3_windows_anomalous_network_activity', + 'auth_rare_hour_for_a_user', + 'v3_windows_rare_metadata_user', + 'v3_windows_rare_user_type10_remote_login', + 'v3_linux_anomalous_network_port_activity', + 'v3_linux_rare_sudo_user', + 'v3_windows_anomalous_service', + 'v3_windows_rare_metadata_process', + 'v3_windows_rare_user_runas_event', + 'v3_linux_network_connection_discovery', + 'v3_linux_network_configuration_discovery', + 'auth_rare_user', + 'high_count_network_events', +] as const; + +export type NotableAnomaliesJobId = typeof NOTABLE_ANOMALIES_IDS[number]; 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 12ef348fcb903..58f075ff76b21 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 @@ -5,7 +5,7 @@ * 2.0. */ -import { fireEvent, render } from '@testing-library/react'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; import React from 'react'; import { EntityAnalyticsAnomalies } from '.'; import type { AnomaliesCount } from '../../../../common/components/ml/anomaly/use_anomalies_search'; @@ -14,6 +14,13 @@ import { AnomalyEntity } from '../../../../common/components/ml/anomaly/use_anom import { TestProviders } from '../../../../common/mock'; import type { SecurityJob } from '../../../../common/components/ml_popover/types'; +jest.mock('../../../../common/components/ml_popover/hooks/use_enable_data_feed', () => ({ + useEnableDataFeed: () => ({ + loading: false, + enableDatafeed: jest.fn().mockResolvedValue({ enabled: true }), + }), +})); + // Query toggle only works if pageName.lenght > 0 jest.mock('../../../../common/utils/route/use_route_spy', () => ({ useRouteSpy: jest.fn().mockReturnValue([ @@ -162,6 +169,32 @@ describe('EntityAnalyticsAnomalies', () => { expect(getByTestId('enable-job')).toBeInTheDocument(); }); + it('renders recently installed jobs', async () => { + const jobCount: AnomaliesCount = { + job: { isInstalled: false, isCompatible: true } as SecurityJob, + name: 'v3_windows_anomalous_script', + count: 0, + + entity: AnomalyEntity.User, + }; + + mockUseNotableAnomaliesSearch.mockReturnValue({ + isLoading: false, + data: [jobCount], + refetch: jest.fn(), + }); + + const { getByTestId } = render(, { wrapper: TestProviders }); + + act(() => { + fireEvent.click(getByTestId('enable-job')); + }); + + await waitFor(() => { + expect(getByTestId('anomalies-table-column-count')).toHaveTextContent('0'); + }); + }); + it('renders failed jobs', () => { const jobCount: AnomaliesCount = { job: { 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 25c97c1ababe8..44213690721ea 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 @@ -35,7 +35,6 @@ import { SecurityPageName } from '../../../../app/types'; import { getTabsOnUsersUrl } from '../../../../common/components/link_to/redirect_to_users'; import { UsersTableType } from '../../../../explore/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'; @@ -50,6 +49,8 @@ const TABLE_SORTING = { export const ENTITY_ANALYTICS_ANOMALIES_PANEL = 'entity_analytics_anomalies'; export const EntityAnalyticsAnomalies = () => { + const [recentlyEnabledJobIds, setRecentlyEnabledJobIds] = useState([]); + const { services: { ml, http, docLinks }, } = useKibana(); @@ -70,21 +71,12 @@ export const EntityAnalyticsAnomalies = () => { from, to, }); - 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 onJobEnabled = useCallback(async (job: SecurityJob) => { + setRecentlyEnabledJobIds((current) => [...current, job.id]); + }, []); + + const columns = useAnomaliesColumns(isSearchLoading, onJobEnabled, recentlyEnabledJobIds); const getSecuritySolutionLinkProps = useGetSecuritySolutionLinkProps(); useEffect(() => { @@ -116,8 +108,12 @@ export const EntityAnalyticsAnomalies = () => { }, [getSecuritySolutionLinkProps]); const installedJobsIds = useMemo( - () => data.filter(({ job }) => !!job && job.isInstalled).map(({ job }) => job?.id ?? ''), - [data] + () => + data + .filter(({ job }) => !!job && job.isInstalled) + .map(({ job }) => job?.id ?? '') + .concat(recentlyEnabledJobIds), + [data, recentlyEnabledJobIds] ); const incompatibleJobCount = useMemo( @@ -192,7 +188,6 @@ export const EntityAnalyticsAnomalies = () => { />

- )} @@ -201,6 +196,9 @@ export const EntityAnalyticsAnomalies = () => { responsive={false} items={data} columns={columns} + pagination={{ + showPerPageOptions: true, + }} loading={isSearchLoading} id={TABLE_QUERY_ID} sorting={TABLE_SORTING} diff --git a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/translations.ts b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/translations.ts index fdef8b65baddf..d8dbcd8664c95 100644 --- a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/translations.ts +++ b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/anomalies/translations.ts @@ -77,6 +77,13 @@ export const JOB_STATUS_FAILED = i18n.translate( } ); +export const JOB_STATUS_WAITING = i18n.translate( + 'xpack.securitySolution.entityAnalytics.anomalies.jobStatusLoading', + { + defaultMessage: 'Waiting', + } +); + export const MODULE_NOT_COMPATIBLE_TITLE = (incompatibleJobCount: number) => i18n.translate('xpack.securitySolution.entityAnalytics.anomalies.moduleNotCompatibleTitle', { values: { incompatibleJobCount },