Skip to content

Commit

Permalink
Add pagination to Notable anomalies table
Browse files Browse the repository at this point in the history
  • Loading branch information
machadoum committed Apr 25, 2023
1 parent 4e4f408 commit e187fd7
Show file tree
Hide file tree
Showing 15 changed files with 521 additions and 105 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ describe('Entity Analytics Dashboard', () => {

it('renders table', () => {
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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -69,10 +69,17 @@ jest.mock('../../../lib/kibana', () => {

describe('useSecurityJobsHelpers', () => {
afterEach(() => {
mockSetupMlJob.mockReset();
mockStartDatafeeds.mockReset();
mockStopDatafeeds.mockReset();
mockSetupMlJob.mockReset();

mockStartDatafeeds.mockReturnValue(
Promise.resolve({ [`datafeed-${jobId}`]: { started: true } })
);
mockStopDatafeeds.mockReturnValue(
Promise.resolve([{ [`datafeed-${jobId}`]: { stopped: true } }])
);
mockSetupMlJob.mockReturnValue(Promise.resolve());
});

it('renders isLoading=true when installing job', async () => {
Expand Down Expand Up @@ -100,7 +107,6 @@ describe('useSecurityJobsHelpers', () => {
});

it('does not call setupMlJob if job is already installed', async () => {
mockSetupMlJob.mockReturnValue(Promise.resolve());
const { result } = renderHook(() => useEnableDataFeed(), {
wrapper,
});
Expand All @@ -113,7 +119,6 @@ describe('useSecurityJobsHelpers', () => {
});

it('calls setupMlJob if job is uninstalled', async () => {
mockSetupMlJob.mockReturnValue(Promise.resolve());
const { result } = renderHook(() => useEnableDataFeed(), {
wrapper,
});
Expand Down Expand Up @@ -160,9 +165,78 @@ describe('useSecurityJobsHelpers', () => {
});
});

it('return enabled:true when startDataFeed successfully installed the job', async () => {
const { result } = renderHook(() => useEnableDataFeed(), {
wrapper,
});
await act(async () => {
const response = await result.current.enableDatafeed(JOB, TIMESTAMP, true);
expect(response.enabled).toBeTruthy();
});
});

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,
});
await act(async () => {
const response = await result.current.enableDatafeed(JOB, TIMESTAMP, true);
expect(response.enabled).toBeFalsy();
});
});

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 () => {
const response = await result.current.enableDatafeed(JOB, TIMESTAMP, true);
expect(response.enabled).toBeFalsy();
});
});

it('return enabled:false when enableDatafeed successfully uninstalled the job', async () => {
const { result } = renderHook(() => useEnableDataFeed(), {
wrapper,
});
await act(async () => {
const response = await result.current.enableDatafeed(JOB, TIMESTAMP, false);
expect(response.enabled).toBeFalsy();
});
});

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 () => {
const response = await result.current.enableDatafeed(JOB, TIMESTAMP, false);
expect(response.enabled).toBeTruthy();
});
});

it('return enabled:true when enableDatafeed fails to uninstall the job', async () => {
mockStopDatafeeds.mockReturnValue(
Promise.resolve([{ [`datafeed-${jobId}`]: { stopped: false, error: 'test_error' } }])
);

const { result } = renderHook(() => useEnableDataFeed(), {
wrapper,
});
await act(async () => {
const response = await result.current.enableDatafeed(JOB, TIMESTAMP, false);
expect(response.enabled).toBeTruthy();
});
});

describe('telemetry', () => {
it('reports telemetry when installing and enabling a job', async () => {
mockSetupMlJob.mockReturnValue(new Promise((resolve) => resolve({})));
const { result } = renderHook(() => useEnableDataFeed(), {
wrapper,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -16,7 +17,7 @@ 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
Expand All @@ -29,9 +30,9 @@ export const useEnableDataFeed = () => {
const enableDatafeed = useCallback(
async (job: SecurityJob, latestTimestampMs: number, enable: boolean) => {
submitTelemetry(job, enable);
setIsLoading(true);

if (!job.isInstalled) {
setIsLoading(true);
try {
await setupMlJob({
configTemplate: job.moduleId,
Expand All @@ -56,25 +57,19 @@ export const useEnableDataFeed = () => {
status: ML_JOB_TELEMETRY_STATUS.installationError,
errorMessage: `${i18n.CREATE_JOB_FAILURE} - ${error.message}`,
});
return;

return { enabled: false };
}
}

// Max start time for job is no more than two weeks ago to ensure job performance
const date = new Date();
const maxStartTime = date.setDate(date.getDate() - 14);

setIsLoading(true);
const datafeedId = `datafeed-${job.id}`;
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) {
const reportEnableJobError = (error: Error) => {
track(METRIC_TYPE.COUNT, TELEMETRY_EVENT.JOB_ENABLE_FAILURE);
addError(error, { title: i18n.START_JOB_FAILURE });
telemetry.reportMLJobUpdate({
Expand All @@ -83,16 +78,32 @@ export const useEnableDataFeed = () => {
status: ML_JOB_TELEMETRY_STATUS.startError,
errorMessage: `${i18n.START_JOB_FAILURE} - ${error.message}`,
});
}
} else {
};

try {
await stopDatafeeds({ datafeedIds: [`datafeed-${job.id}`] });
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.stopped,
status: ML_JOB_TELEMETRY_STATUS.started,
});

return { enabled: response[datafeedId] ? response[datafeedId].started : false };
} catch (error) {
reportEnableJobError(error);
} finally {
setIsLoading(false);
}
} else {
const reportDisableError = (error: Error) => {
track(METRIC_TYPE.COUNT, TELEMETRY_EVENT.JOB_DISABLE_FAILURE);
addError(error, { title: i18n.STOP_JOB_FAILURE });
telemetry.reportMLJobUpdate({
Expand All @@ -101,16 +112,39 @@ export const useEnableDataFeed = () => {
status: ML_JOB_TELEMETRY_STATUS.stopError,
errorMessage: `${i18n.STOP_JOB_FAILURE} - ${error.message}`,
});
};

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) {
reportDisableError(error);
} finally {
setIsLoading(false);
}
}
setIsLoading(false);
return { enabled: !enable };
},
[addError, telemetry]
);

return { enableDatafeed, isLoading };
};

const isErrorResponse = (response: ErrorResponse): response is ErrorResponse =>
!isEmpty(response.error);

const submitTelemetry = (job: SecurityJob, enabled: boolean) => {
// Report type of job enabled/disabled
track(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,8 @@ export const MlPopover = React.memo(() => {
const { enableDatafeed, isLoading: isLoadingEnableDataFeed } = useEnableDataFeed();
const handleJobStateChange = useCallback(
async (job: SecurityJob, latestTimestampMs: number, enable: boolean) => {
const result = await enableDatafeed(job, latestTimestampMs, enable);
await enableDatafeed(job, latestTimestampMs, enable);
refreshJobs();
return result;
},
[refreshJobs, enableDatafeed]
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<EuiBasicTableColumn<AnomaliesCount>>;

Expand All @@ -27,7 +22,8 @@ const MediumShadeText = styled.span`

export const useAnomaliesColumns = (
loading: boolean,
onJobStateChange: (job: SecurityJob) => Promise<void>
onJobEnabled: (job: SecurityJob) => void,
recentlyEnabledJobIds: string[]
): AnomaliesColumns => {
const columns: AnomaliesColumns = useMemo(
() => [
Expand Down Expand Up @@ -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 <AnomaliesCountLink count={count} jobId={job.id} entity={entity} />;
} else if (isJobFailed(job.jobState, job.datafeedState)) {
return i18n.JOB_STATUS_FAILED;
} else if (job.isCompatible) {
return <EnableJob job={job} isLoading={loading} onJobStateChange={onJobStateChange} />;
} else {
return <EuiIcon aria-label="Warning" size="s" type="warning" color="warning" />;
}
return (
<TotalAnomalies
count={count}
job={job}
entity={entity}
loading={loading}
onJobEnabled={onJobEnabled}
recentlyEnabledJobIds={recentlyEnabledJobIds}
/>
);
},
},
],
[loading, onJobStateChange]
[loading, onJobEnabled, recentlyEnabledJobIds]
);
return columns;
};

const EnableJob = ({
job,
isLoading,
onJobStateChange,
}: {
job: SecurityJob;
isLoading: boolean;
onJobStateChange: (job: SecurityJob) => Promise<void>;
}) => {
const handleChange = useCallback(() => onJobStateChange(job), [job, onJobStateChange]);

return isLoading || isJobLoading(job.jobState, job.datafeedState) ? (
<EuiLoadingSpinner size="m" data-test-subj="job-switch-loader" />
) : (
<LinkAnchor onClick={handleChange} data-test-subj="enable-job">
{i18n.RUN_JOB}
</LinkAnchor>
);
};
Loading

0 comments on commit e187fd7

Please sign in to comment.