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.
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 },