Update anomalies tab to display the same quantity of anomalies when navigating from entity analytics page (#139910)

* Create Job id filter component

* Add job filter to anomalies tab

* Add interval selector to anomalies tab

* Preselect anomalies table interval from entity analytics page link

* Infer anomaly entity from top hits aggregation
This commit is contained in:
Pablo Machado 2022-09-07 22:06:05 +02:00 committed by GitHub
parent 6ed79f42db
commit 801600746d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 901 additions and 395 deletions

View file

@ -6,6 +6,7 @@
*/
import React from 'react';
import { useInstalledSecurityJobsIds } from '../hooks/use_installed_security_jobs';
import type { InfluencerInput, Anomalies, CriteriaFields } from '../types';
import { useAnomaliesTableData } from './use_anomalies_table_data';
@ -25,12 +26,15 @@ interface Props {
export const AnomalyTableProvider = React.memo<Props>(
({ influencers, startDate, endDate, children, criteriaFields, skip }) => {
const { jobIds } = useInstalledSecurityJobsIds();
const [isLoadingAnomaliesData, anomaliesData] = useAnomaliesTableData({
criteriaFields,
influencers,
startDate,
endDate,
skip,
jobIds,
aggregationInterval: 'auto',
});
return <>{children({ isLoadingAnomaliesData, anomaliesData })}</>;
}

View file

@ -10,7 +10,7 @@ import { act, renderHook } from '@testing-library/react-hooks';
import { TestProviders } from '../../../mock';
import type { Refetch } from '../../../store/inputs/model';
import type { AnomaliesCount } from './use_anomalies_search';
import { useNotableAnomaliesSearch, AnomalyJobStatus } from './use_anomalies_search';
import { useNotableAnomaliesSearch, AnomalyJobStatus, AnomalyEntity } from './use_anomalies_search';
const jobId = 'auth_rare_source_ip_for_a_user';
const from = 'now-24h';
@ -122,6 +122,7 @@ describe('useNotableAnomaliesSearch', () => {
jobId,
name: jobId,
status: AnomalyJobStatus.enabled,
entity: AnomalyEntity.Host,
},
])
);
@ -155,6 +156,7 @@ describe('useNotableAnomaliesSearch', () => {
jobId: undefined,
name: jobId,
status: AnomalyJobStatus.uninstalled,
entity: AnomalyEntity.Host,
},
])
);
@ -197,6 +199,7 @@ describe('useNotableAnomaliesSearch', () => {
jobId: customJobId,
name: jobId,
status: AnomalyJobStatus.enabled,
entity: AnomalyEntity.Host,
},
])
);
@ -254,6 +257,7 @@ describe('useNotableAnomaliesSearch', () => {
jobId: mostRecentJobId,
name: jobId,
status: AnomalyJobStatus.enabled,
entity: AnomalyEntity.Host,
},
])
);

View file

@ -6,8 +6,9 @@
*/
import { useState, useEffect, useMemo, useRef } from 'react';
import { filter, head, noop, orderBy, pipe } from 'lodash/fp';
import { filter, head, noop, orderBy, pipe, has } from 'lodash/fp';
import type { MlSummaryJob } from '@kbn/ml-plugin/common';
import { DEFAULT_ANOMALY_SCORE } from '../../../../../common/constants';
import * as i18n from './translations';
import { useUiSetting$ } from '../../../lib/kibana';
@ -27,11 +28,17 @@ export enum AnomalyJobStatus {
'failed',
}
export const enum AnomalyEntity {
User,
Host,
}
export interface AnomaliesCount {
name: NotableAnomaliesJobId;
jobId?: string;
count: number;
status: AnomalyJobStatus;
entity: AnomalyEntity;
}
interface UseNotableAnomaliesSearchProps {
@ -142,6 +149,7 @@ const getMLJobStatus = (
? AnomalyJobStatus.disabled
: AnomalyJobStatus.uninstalled;
};
function formatResultData(
buckets: Array<{
key: string;
@ -152,12 +160,14 @@ function formatResultData(
return NOTABLE_ANOMALIES_IDS.map((notableJobId) => {
const job = findJobWithId(notableJobId)(notableAnomaliesJobs);
const bucket = buckets.find(({ key }) => key === job?.id);
const hasUserName = has("entity.hits.hits[0]._source['user.name']", bucket);
return {
name: notableJobId,
jobId: job?.id,
count: bucket?.doc_count ?? 0,
status: getMLJobStatus(notableJobId, job, notableAnomaliesJobs),
entity: hasUserName ? AnomalyEntity.User : AnomalyEntity.Host,
};
});
}

View file

@ -14,7 +14,8 @@ import type { InfluencerInput, Anomalies, CriteriaFields } from '../types';
import * as i18n from './translations';
import { useTimeZone, useUiSetting$ } from '../../../lib/kibana';
import { useAppToasts } from '../../../hooks/use_app_toasts';
import { useInstalledSecurityJobs } from '../hooks/use_installed_security_jobs';
import { useMlCapabilities } from '../hooks/use_ml_capabilities';
import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions';
interface Args {
influencers?: InfluencerInput[];
@ -24,6 +25,8 @@ interface Args {
skip?: boolean;
criteriaFields?: CriteriaFields[];
filterQuery?: estypes.QueryDslQueryContainer;
jobIds: string[];
aggregationInterval: string;
}
type Return = [boolean, Anomalies | null];
@ -57,15 +60,18 @@ export const useAnomaliesTableData = ({
threshold = -1,
skip = false,
filterQuery,
jobIds,
aggregationInterval,
}: Args): Return => {
const [tableData, setTableData] = useState<Anomalies | null>(null);
const { isMlUser, jobs } = useInstalledSecurityJobs();
const mlCapabilities = useMlCapabilities();
const isMlUser = hasMlUserPermissions(mlCapabilities);
const [loading, setLoading] = useState(true);
const { addError } = useAppToasts();
const timeZone = useTimeZone();
const [anomalyScore] = useUiSetting$<number>(DEFAULT_ANOMALY_SCORE);
const jobIds = jobs.map((job) => job.id);
const startDateMs = useMemo(() => new Date(startDate).getTime(), [startDate]);
const endDateMs = useMemo(() => new Date(endDate).getTime(), [endDate]);
@ -89,7 +95,7 @@ export const useAnomaliesTableData = ({
jobIds,
criteriaFields: criteriaFieldsInput,
influencersFilterQuery: filterQuery,
aggregationInterval: 'auto',
aggregationInterval,
threshold: getThreshold(anomalyScore, threshold),
earliestMs,
latestMs,
@ -135,6 +141,7 @@ export const useAnomaliesTableData = ({
endDateMs,
skip,
isMlUser,
aggregationInterval,
// eslint-disable-next-line react-hooks/exhaustive-deps
jobIds.sort().join(),
]);

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { MlSummaryJob } from '@kbn/ml-plugin/public';
import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions';
@ -65,3 +65,10 @@ export const useInstalledSecurityJobs = (): UseInstalledSecurityJobsReturn => {
return { isLicensed, isMlUser, jobs, loading };
};
export const useInstalledSecurityJobsIds = () => {
const { jobs, loading } = useInstalledSecurityJobs();
const jobIds = useMemo(() => jobs.map((job) => job.id), [jobs]);
return { jobIds, loading };
};

View file

@ -5,11 +5,12 @@
* 2.0.
*/
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data';
import { HeaderSection } from '../../header_section';
import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions';
import * as i18n from './translations';
import { getAnomaliesHostTableColumnsCurated } from './get_anomalies_host_table_columns';
@ -20,8 +21,13 @@ import { useMlCapabilities } from '../hooks/use_ml_capabilities';
import { BasicTable } from './basic_table';
import { getCriteriaFromHostType } from '../criteria/get_criteria_from_host_type';
import { Panel } from '../../panel';
import { anomaliesTableDefaultEquality } from './default_equality';
import { useQueryToggle } from '../../../containers/query_toggle';
import { useInstalledSecurityJobsIds } from '../hooks/use_installed_security_jobs';
import { useDeepEqualSelector } from '../../../hooks/use_selector';
import type { State } from '../../../store';
import { JobIdFilter } from './job_id_filter';
import { SelectInterval } from './select_interval';
import { hostsActions, hostsSelectors } from '../../../../hosts/store';
const sorting = {
sort: {
@ -37,6 +43,7 @@ const AnomaliesHostTableComponent: React.FC<AnomaliesHostTableProps> = ({
skip,
type,
}) => {
const dispatch = useDispatch();
const capabilities = useMlCapabilities();
const { toggleStatus, setToggleStatus } = useQueryToggle(`AnomaliesHostTable`);
const [querySkip, setQuerySkip] = useState(skip || !toggleStatus);
@ -52,7 +59,51 @@ const AnomaliesHostTableComponent: React.FC<AnomaliesHostTableProps> = ({
[setQuerySkip, setToggleStatus]
);
const [loading, tableData] = useAnomaliesTableData({
const { jobIds, loading: loadingJobs } = useInstalledSecurityJobsIds();
const getAnomaliesHostsTableFilterQuerySelector = useMemo(
() => hostsSelectors.hostsAnomaliesJobIdFilterSelector(),
[]
);
const selectedJobIds = useDeepEqualSelector((state: State) =>
getAnomaliesHostsTableFilterQuerySelector(state, type)
);
const onSelectJobId = useCallback(
(newSelection: string[]) => {
dispatch(
hostsActions.updateHostsAnomaliesJobIdFilter({
jobIds: newSelection,
hostsType: type,
})
);
},
[dispatch, type]
);
const getAnomaliesHostTableIntervalQuerySelector = useMemo(
() => hostsSelectors.hostsAnomaliesIntervalSelector(),
[]
);
const selectedInterval = useDeepEqualSelector((state: State) =>
getAnomaliesHostTableIntervalQuerySelector(state, type)
);
const onSelectInterval = useCallback(
(newInterval: string) => {
dispatch(
hostsActions.updateHostsAnomaliesInterval({
interval: newInterval,
hostsType: type,
})
);
},
[dispatch, type]
);
const [loadingTable, tableData] = useAnomaliesTableData({
startDate,
endDate,
skip: querySkip,
@ -60,6 +111,8 @@ const AnomaliesHostTableComponent: React.FC<AnomaliesHostTableProps> = ({
filterQuery: {
exists: { field: 'host.name' },
},
jobIds: selectedJobIds.length > 0 ? selectedJobIds : jobIds,
aggregationInterval: selectedInterval,
});
const hosts = convertAnomaliesToHosts(tableData, hostName);
@ -77,7 +130,7 @@ const AnomaliesHostTableComponent: React.FC<AnomaliesHostTableProps> = ({
return null;
} else {
return (
<Panel loading={loading}>
<Panel loading={loadingTable || loadingJobs}>
<HeaderSection
subtitle={`${i18n.SHOWING}: ${pagination.totalItemCount.toLocaleString()} ${i18n.UNIT(
pagination.totalItemCount
@ -87,6 +140,21 @@ const AnomaliesHostTableComponent: React.FC<AnomaliesHostTableProps> = ({
toggleStatus={toggleStatus}
tooltip={i18n.TOOLTIP}
isInspectDisabled={skip}
headerFilters={
<EuiFlexGroup>
<EuiFlexItem>
<SelectInterval interval={selectedInterval} onChange={onSelectInterval} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<JobIdFilter
title={i18n.JOB_ID}
onSelect={onSelectJobId}
selectedJobIds={selectedJobIds}
jobIds={jobIds}
/>
</EuiFlexItem>
</EuiFlexGroup>
}
/>
{toggleStatus && (
<BasicTable
@ -99,7 +167,7 @@ const AnomaliesHostTableComponent: React.FC<AnomaliesHostTableProps> = ({
/>
)}
{loading && (
{(loadingTable || loadingJobs) && (
<Loader data-test-subj="anomalies-host-table-loading-panel" overlay size="xl" />
)}
</Panel>
@ -107,7 +175,4 @@ const AnomaliesHostTableComponent: React.FC<AnomaliesHostTableProps> = ({
}
};
export const AnomaliesHostTable = React.memo(
AnomaliesHostTableComponent,
anomaliesTableDefaultEquality
);
export const AnomaliesHostTable = React.memo(AnomaliesHostTableComponent);

View file

@ -5,7 +5,9 @@
* 2.0.
*/
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data';
import { HeaderSection } from '../../header_section';
@ -17,10 +19,15 @@ import type { AnomaliesNetworkTableProps } from '../types';
import { getAnomaliesNetworkTableColumnsCurated } from './get_anomalies_network_table_columns';
import { useMlCapabilities } from '../hooks/use_ml_capabilities';
import { BasicTable } from './basic_table';
import { networkEquality } from './network_equality';
import { getCriteriaFromNetworkType } from '../criteria/get_criteria_from_network_type';
import { Panel } from '../../panel';
import { useQueryToggle } from '../../../containers/query_toggle';
import { useInstalledSecurityJobsIds } from '../hooks/use_installed_security_jobs';
import { useDeepEqualSelector } from '../../../hooks/use_selector';
import type { State } from '../../../store';
import { JobIdFilter } from './job_id_filter';
import { networkActions, networkSelectors } from '../../../../network/store';
import { SelectInterval } from './select_interval';
const sorting = {
sort: {
@ -38,7 +45,7 @@ const AnomaliesNetworkTableComponent: React.FC<AnomaliesNetworkTableProps> = ({
flowTarget,
}) => {
const capabilities = useMlCapabilities();
const dispatch = useDispatch();
const { toggleStatus, setToggleStatus } = useQueryToggle(`AnomaliesNetwork-${flowTarget}`);
const [querySkip, setQuerySkip] = useState(skip || !toggleStatus);
useEffect(() => {
@ -53,11 +60,57 @@ const AnomaliesNetworkTableComponent: React.FC<AnomaliesNetworkTableProps> = ({
[setQuerySkip, setToggleStatus]
);
const [loading, tableData] = useAnomaliesTableData({
const { jobIds, loading: loadingJobs } = useInstalledSecurityJobsIds();
const getAnomaliesUserTableFilterQuerySelector = useMemo(
() => networkSelectors.networkAnomaliesJobIdFilterSelector(),
[]
);
const selectedJobIds = useDeepEqualSelector((state: State) =>
getAnomaliesUserTableFilterQuerySelector(state, type)
);
const onSelectJobId = useCallback(
(newSelection: string[]) => {
dispatch(
networkActions.updateNetworkAnomaliesJobIdFilter({
jobIds: newSelection,
networkType: type,
})
);
},
[dispatch, type]
);
const getAnomaliesNetworkTableIntervalQuerySelector = useMemo(
() => networkSelectors.networkAnomaliesIntervalSelector(),
[]
);
const selectedInterval = useDeepEqualSelector((state: State) =>
getAnomaliesNetworkTableIntervalQuerySelector(state, type)
);
const onSelectInterval = useCallback(
(newInterval: string) => {
dispatch(
networkActions.updateNetworkAnomaliesInterval({
interval: newInterval,
networkType: type,
})
);
},
[dispatch, type]
);
const [loadingTable, tableData] = useAnomaliesTableData({
startDate,
endDate,
skip: querySkip,
criteriaFields: getCriteriaFromNetworkType(type, ip, flowTarget),
jobIds: selectedJobIds.length > 0 ? selectedJobIds : jobIds,
aggregationInterval: selectedInterval,
});
const networks = convertAnomaliesToNetwork(tableData, ip);
@ -74,7 +127,7 @@ const AnomaliesNetworkTableComponent: React.FC<AnomaliesNetworkTableProps> = ({
return null;
} else {
return (
<Panel loading={loading}>
<Panel loading={loadingTable || loadingJobs}>
<HeaderSection
subtitle={`${i18n.SHOWING}: ${pagination.totalItemCount.toLocaleString()} ${i18n.UNIT(
pagination.totalItemCount
@ -84,6 +137,21 @@ const AnomaliesNetworkTableComponent: React.FC<AnomaliesNetworkTableProps> = ({
toggleQuery={toggleQuery}
toggleStatus={toggleStatus}
isInspectDisabled={skip}
headerFilters={
<EuiFlexGroup>
<EuiFlexItem>
<SelectInterval interval={selectedInterval} onChange={onSelectInterval} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<JobIdFilter
title={i18n.JOB_ID}
onSelect={onSelectJobId}
selectedJobIds={selectedJobIds}
jobIds={jobIds}
/>
</EuiFlexItem>
</EuiFlexGroup>
}
/>
{toggleStatus && (
<BasicTable
@ -96,7 +164,7 @@ const AnomaliesNetworkTableComponent: React.FC<AnomaliesNetworkTableProps> = ({
/>
)}
{loading && (
{(loadingTable || loadingJobs) && (
<Loader data-test-subj="anomalies-network-table-loading-panel" overlay size="xl" />
)}
</Panel>
@ -104,4 +172,4 @@ const AnomaliesNetworkTableComponent: React.FC<AnomaliesNetworkTableProps> = ({
}
};
export const AnomaliesNetworkTable = React.memo(AnomaliesNetworkTableComponent, networkEquality);
export const AnomaliesNetworkTable = React.memo(AnomaliesNetworkTableComponent);

View file

@ -5,8 +5,10 @@
* 2.0.
*/
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data';
import { HeaderSection } from '../../header_section';
@ -20,10 +22,15 @@ import { BasicTable } from './basic_table';
import { getCriteriaFromUsersType } from '../criteria/get_criteria_from_users_type';
import { Panel } from '../../panel';
import { anomaliesTableDefaultEquality } from './default_equality';
import { convertAnomaliesToUsers } from './convert_anomalies_to_users';
import { getAnomaliesUserTableColumnsCurated } from './get_anomalies_user_table_columns';
import { useQueryToggle } from '../../../containers/query_toggle';
import { JobIdFilter } from './job_id_filter';
import { SelectInterval } from './select_interval';
import { useDeepEqualSelector } from '../../../hooks/use_selector';
import { usersActions, usersSelectors } from '../../../../users/store';
import type { State } from '../../../store/types';
import { useInstalledSecurityJobsIds } from '../hooks/use_installed_security_jobs';
const sorting = {
sort: {
@ -39,6 +46,7 @@ const AnomaliesUserTableComponent: React.FC<AnomaliesUserTableProps> = ({
skip,
type,
}) => {
const dispatch = useDispatch();
const capabilities = useMlCapabilities();
const { toggleStatus, setToggleStatus } = useQueryToggle(`AnomaliesUserTable`);
@ -55,7 +63,51 @@ const AnomaliesUserTableComponent: React.FC<AnomaliesUserTableProps> = ({
[setQuerySkip, setToggleStatus]
);
const [loading, tableData] = useAnomaliesTableData({
const { jobIds, loading: loadingJobs } = useInstalledSecurityJobsIds();
const getAnomaliesUserTableFilterQuerySelector = useMemo(
() => usersSelectors.usersAnomaliesJobIdFilterSelector(),
[]
);
const selectedJobIds = useDeepEqualSelector((state: State) =>
getAnomaliesUserTableFilterQuerySelector(state, type)
);
const onSelectJobId = useCallback(
(newSelection: string[]) => {
dispatch(
usersActions.updateUsersAnomaliesJobIdFilter({
jobIds: newSelection,
usersType: type,
})
);
},
[dispatch, type]
);
const getAnomaliesUserTableIntervalQuerySelector = useMemo(
() => usersSelectors.usersAnomaliesIntervalSelector(),
[]
);
const selectedInterval = useDeepEqualSelector((state: State) =>
getAnomaliesUserTableIntervalQuerySelector(state, type)
);
const onSelectInterval = useCallback(
(newInterval: string) => {
dispatch(
usersActions.updateUsersAnomaliesInterval({
interval: newInterval,
usersType: type,
})
);
},
[dispatch, type]
);
const [loadingTable, tableData] = useAnomaliesTableData({
startDate,
endDate,
skip: querySkip,
@ -63,6 +115,8 @@ const AnomaliesUserTableComponent: React.FC<AnomaliesUserTableProps> = ({
filterQuery: {
exists: { field: 'user.name' },
},
jobIds: selectedJobIds.length > 0 ? selectedJobIds : jobIds,
aggregationInterval: selectedInterval,
});
const users = convertAnomaliesToUsers(tableData, userName);
@ -80,7 +134,7 @@ const AnomaliesUserTableComponent: React.FC<AnomaliesUserTableProps> = ({
return null;
} else {
return (
<Panel loading={loading} data-test-subj="user-anomalies-tab">
<Panel loading={loadingTable || loadingJobs} data-test-subj="user-anomalies-tab">
<HeaderSection
subtitle={`${i18n.SHOWING}: ${pagination.totalItemCount.toLocaleString()} ${i18n.UNIT(
pagination.totalItemCount
@ -90,6 +144,22 @@ const AnomaliesUserTableComponent: React.FC<AnomaliesUserTableProps> = ({
toggleStatus={toggleStatus}
tooltip={i18n.TOOLTIP}
isInspectDisabled={skip}
headerFilters={
<EuiFlexGroup>
<EuiFlexItem>
<SelectInterval interval={selectedInterval} onChange={onSelectInterval} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<JobIdFilter
title={i18n.JOB_ID}
onSelect={onSelectJobId}
selectedJobIds={selectedJobIds}
jobIds={jobIds}
/>
</EuiFlexItem>
</EuiFlexGroup>
}
/>
{toggleStatus && (
@ -103,15 +173,12 @@ const AnomaliesUserTableComponent: React.FC<AnomaliesUserTableProps> = ({
/>
)}
{loading && (
<Loader data-test-subj="anomalies-host-table-loading-panel" overlay size="xl" />
{(loadingTable || loadingJobs) && (
<Loader data-test-subj="anomalies-user-table-loading-panel" overlay size="xl" />
)}
</Panel>
);
}
};
export const AnomaliesUserTable = React.memo(
AnomaliesUserTableComponent,
anomaliesTableDefaultEquality
);
export const AnomaliesUserTable = React.memo(AnomaliesUserTableComponent);

View file

@ -1,126 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { anomaliesTableDefaultEquality } from './default_equality';
import type { AnomaliesHostTableProps } from '../types';
import { HostsType } from '../../../../hosts/store/model';
describe('host_equality', () => {
test('it returns true if start and end date are equal', () => {
const prev: AnomaliesHostTableProps = {
startDate: new Date('2000').toISOString(),
endDate: new Date('2000').toISOString(),
narrowDateRange: jest.fn(),
skip: false,
type: HostsType.details,
};
const next: AnomaliesHostTableProps = {
startDate: new Date('2000').toISOString(),
endDate: new Date('2000').toISOString(),
narrowDateRange: jest.fn(),
skip: false,
type: HostsType.details,
};
const equal = anomaliesTableDefaultEquality(prev, next);
expect(equal).toEqual(true);
});
test('it returns false if starts are not equal', () => {
const prev: AnomaliesHostTableProps = {
startDate: new Date('2001').toISOString(),
endDate: new Date('2000').toISOString(),
narrowDateRange: jest.fn(),
skip: false,
type: HostsType.details,
};
const next: AnomaliesHostTableProps = {
startDate: new Date('2000').toISOString(),
endDate: new Date('2000').toISOString(),
narrowDateRange: jest.fn(),
skip: false,
type: HostsType.details,
};
const equal = anomaliesTableDefaultEquality(prev, next);
expect(equal).toEqual(false);
});
test('it returns false if starts are not equal for next', () => {
const prev: AnomaliesHostTableProps = {
startDate: new Date('2000').toISOString(),
endDate: new Date('2000').toISOString(),
narrowDateRange: jest.fn(),
skip: false,
type: HostsType.details,
};
const next: AnomaliesHostTableProps = {
startDate: new Date('2001').toISOString(),
endDate: new Date('2000').toISOString(),
narrowDateRange: jest.fn(),
skip: false,
type: HostsType.details,
};
const equal = anomaliesTableDefaultEquality(prev, next);
expect(equal).toEqual(false);
});
test('it returns false if ends are not equal', () => {
const prev: AnomaliesHostTableProps = {
startDate: new Date('2000').toISOString(),
endDate: new Date('2001').toISOString(),
narrowDateRange: jest.fn(),
skip: false,
type: HostsType.details,
};
const next: AnomaliesHostTableProps = {
startDate: new Date('2000').toISOString(),
endDate: new Date('2000').toISOString(),
narrowDateRange: jest.fn(),
skip: false,
type: HostsType.details,
};
const equal = anomaliesTableDefaultEquality(prev, next);
expect(equal).toEqual(false);
});
test('it returns false if ends are not equal for next', () => {
const prev: AnomaliesHostTableProps = {
startDate: new Date('2000').toISOString(),
endDate: new Date('2000').toISOString(),
narrowDateRange: jest.fn(),
skip: false,
type: HostsType.details,
};
const next: AnomaliesHostTableProps = {
startDate: new Date('2000').toISOString(),
endDate: new Date('2001').toISOString(),
narrowDateRange: jest.fn(),
skip: false,
type: HostsType.details,
};
const equal = anomaliesTableDefaultEquality(prev, next);
expect(equal).toEqual(false);
});
test('it returns false if skip is not equal', () => {
const prev: AnomaliesHostTableProps = {
startDate: new Date('2000').toISOString(),
endDate: new Date('2000').toISOString(),
narrowDateRange: jest.fn(),
skip: true,
type: HostsType.details,
};
const next: AnomaliesHostTableProps = {
startDate: new Date('2000').toISOString(),
endDate: new Date('2000').toISOString(),
narrowDateRange: jest.fn(),
skip: false,
type: HostsType.details,
};
const equal = anomaliesTableDefaultEquality(prev, next);
expect(equal).toEqual(false);
});
});

View file

@ -1,16 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { AnomaliesTableCommonProps } from '../types';
export const anomaliesTableDefaultEquality = (
prevProps: AnomaliesTableCommonProps,
nextProps: AnomaliesTableCommonProps
): boolean =>
prevProps.startDate === nextProps.startDate &&
prevProps.endDate === nextProps.endDate &&
prevProps.skip === nextProps.skip;

View file

@ -0,0 +1,48 @@
/*
* 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 { storiesOf } from '@storybook/react';
import type { ReactNode } from 'react';
import React from 'react';
import { ThemeProvider } from 'styled-components';
import { euiLightVars } from '@kbn/ui-theme';
import { action } from '@storybook/addon-actions';
import { JobIdFilter } from './job_id_filter';
const withTheme = (storyFn: () => ReactNode) => (
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: true })}>{storyFn()}</ThemeProvider>
);
storiesOf('JobIdFilter', module)
.addDecorator(withTheme)
.add('empty', () => (
<JobIdFilter title="Job id" selectedJobIds={[]} jobIds={[]} onSelect={action('onSelect')} />
))
.add('one selected item', () => (
<JobIdFilter
title="Job id"
selectedJobIds={['test_job_1']}
jobIds={['test_job_1', 'test_job_2', 'test_job_3', 'test_job_4']}
onSelect={action('onSelect')}
/>
))
.add('multiple selected item', () => (
<JobIdFilter
title="Job id"
selectedJobIds={['test_job_2', 'test_job_3']}
jobIds={['test_job_1', 'test_job_2', 'test_job_3', 'test_job_4']}
onSelect={action('onSelect')}
/>
))
.add('no selected item', () => (
<JobIdFilter
title="Job id"
selectedJobIds={[]}
jobIds={['test_job_1', 'test_job_2', 'test_job_3', 'test_job_4']}
onSelect={action('onSelect')}
/>
));

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { fireEvent, render } from '@testing-library/react';
import React from 'react';
import { JobIdFilter } from './job_id_filter';
describe('JobIdFilter', () => {
it('is disabled when job id is empty', () => {
const { getByTestId } = render(
<JobIdFilter title="Job id" selectedJobIds={[]} jobIds={[]} onSelect={jest.fn()} />
);
expect(getByTestId('job-id-filter-button')).toBeDisabled();
});
it('calls onSelect when clicked', () => {
const onSelectCb = jest.fn();
const { getByText, getByTestId } = render(
<JobIdFilter
title="Job id"
selectedJobIds={[]}
jobIds={['test_job_1', 'test_job_2', 'test_job_3', 'test_job_4']}
onSelect={onSelectCb}
/>
);
fireEvent.click(getByTestId('job-id-filter-button'));
fireEvent.click(getByText('test_job_2'));
expect(onSelectCb).toBeCalledWith(['test_job_2']);
});
it('displays job id as selected when it is present in selectedJobIds', () => {
const { getByTestId } = render(
<JobIdFilter
title="Job id"
selectedJobIds={['test_job_2']}
jobIds={['test_job_1', 'test_job_2', 'test_job_3', 'test_job_4']}
onSelect={jest.fn()}
/>
);
fireEvent.click(getByTestId('job-id-filter-button'));
expect(
getByTestId('job-id-filter-item-test_job_2').querySelector('span[data-euiicon-type=check]')
).toBeInTheDocument();
});
});

View file

@ -0,0 +1,79 @@
/*
* 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, useMemo, useState } from 'react';
import { EuiFilterButton, EuiFilterGroup, EuiFilterSelectItem, EuiPopover } from '@elastic/eui';
export const JobIdFilter: React.FC<{
selectedJobIds: string[];
jobIds: string[];
onSelect: (jobIds: string[]) => void;
title: string;
}> = ({ selectedJobIds, onSelect, title, jobIds }) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const onButtonClick = useCallback(() => {
setIsPopoverOpen(!isPopoverOpen);
}, [isPopoverOpen]);
const closePopover = useCallback(() => {
setIsPopoverOpen(false);
}, []);
const updateSelection = useCallback(
(selectedJobId: string) => {
const currentSelection = selectedJobIds ?? [];
const newSelection = currentSelection.includes(selectedJobId)
? currentSelection.filter((s) => s !== selectedJobId)
: [...currentSelection, selectedJobId];
onSelect(newSelection);
},
[selectedJobIds, onSelect]
);
const button = useMemo(
() => (
<EuiFilterButton
disabled={jobIds.length === 0}
data-test-subj="job-id-filter-button"
hasActiveFilters={selectedJobIds.length > 0}
iconType="arrowDown"
isSelected={isPopoverOpen}
numActiveFilters={selectedJobIds.length}
onClick={onButtonClick}
contentProps={{ style: { minWidth: 112 } }} // avoid resizing when selecting job id
>
{title}
</EuiFilterButton>
),
[isPopoverOpen, onButtonClick, title, selectedJobIds.length, jobIds]
);
return (
<EuiFilterGroup>
<EuiPopover
button={button}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="none"
>
<div className="euiFilterSelect__items">
{jobIds.map((id) => (
<EuiFilterSelectItem
data-test-subj={`job-id-filter-item-${id}`}
checked={selectedJobIds.includes(id) ? 'on' : undefined}
key={id}
onClick={() => updateSelection(id)}
>
{id}
</EuiFilterSelectItem>
))}
</div>
</EuiPopover>
</EuiFilterGroup>
);
};

View file

@ -1,148 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { networkEquality } from './network_equality';
import type { AnomaliesNetworkTableProps } from '../types';
import { NetworkType } from '../../../../network/store/model';
import { FlowTarget } from '../../../../../common/search_strategy';
describe('network_equality', () => {
test('it returns true if start and end date are equal', () => {
const prev: AnomaliesNetworkTableProps = {
startDate: new Date('2000').toISOString(),
endDate: new Date('2000').toISOString(),
narrowDateRange: jest.fn(),
skip: false,
type: NetworkType.details,
};
const next: AnomaliesNetworkTableProps = {
startDate: new Date('2000').toISOString(),
endDate: new Date('2000').toISOString(),
narrowDateRange: jest.fn(),
skip: false,
type: NetworkType.details,
};
const equal = networkEquality(prev, next);
expect(equal).toEqual(true);
});
test('it returns false if starts are not equal', () => {
const prev: AnomaliesNetworkTableProps = {
startDate: new Date('2001').toISOString(),
endDate: new Date('2000').toISOString(),
narrowDateRange: jest.fn(),
skip: false,
type: NetworkType.details,
};
const next: AnomaliesNetworkTableProps = {
startDate: new Date('2000').toISOString(),
endDate: new Date('2000').toISOString(),
narrowDateRange: jest.fn(),
skip: false,
type: NetworkType.details,
};
const equal = networkEquality(prev, next);
expect(equal).toEqual(false);
});
test('it returns false if starts are not equal for next', () => {
const prev: AnomaliesNetworkTableProps = {
startDate: new Date('2000').toISOString(),
endDate: new Date('2000').toISOString(),
narrowDateRange: jest.fn(),
skip: false,
type: NetworkType.details,
};
const next: AnomaliesNetworkTableProps = {
startDate: new Date('2001').toISOString(),
endDate: new Date('2000').toISOString(),
narrowDateRange: jest.fn(),
skip: false,
type: NetworkType.details,
};
const equal = networkEquality(prev, next);
expect(equal).toEqual(false);
});
test('it returns false if ends are not equal', () => {
const prev: AnomaliesNetworkTableProps = {
startDate: new Date('2000').toISOString(),
endDate: new Date('2001').toISOString(),
narrowDateRange: jest.fn(),
skip: false,
type: NetworkType.details,
};
const next: AnomaliesNetworkTableProps = {
startDate: new Date('2000').toISOString(),
endDate: new Date('2000').toISOString(),
narrowDateRange: jest.fn(),
skip: false,
type: NetworkType.details,
};
const equal = networkEquality(prev, next);
expect(equal).toEqual(false);
});
test('it returns false if ends are not equal for next', () => {
const prev: AnomaliesNetworkTableProps = {
startDate: new Date('2000').toISOString(),
endDate: new Date('2000').toISOString(),
narrowDateRange: jest.fn(),
skip: false,
type: NetworkType.details,
};
const next: AnomaliesNetworkTableProps = {
startDate: new Date('2000').toISOString(),
endDate: new Date('2001').toISOString(),
narrowDateRange: jest.fn(),
skip: false,
type: NetworkType.details,
};
const equal = networkEquality(prev, next);
expect(equal).toEqual(false);
});
test('it returns false if skip is not equal', () => {
const prev: AnomaliesNetworkTableProps = {
startDate: new Date('2000').toISOString(),
endDate: new Date('2000').toISOString(),
narrowDateRange: jest.fn(),
skip: true,
type: NetworkType.details,
};
const next: AnomaliesNetworkTableProps = {
startDate: new Date('2000').toISOString(),
endDate: new Date('2000').toISOString(),
narrowDateRange: jest.fn(),
skip: false,
type: NetworkType.details,
};
const equal = networkEquality(prev, next);
expect(equal).toEqual(false);
});
test('it returns false if flowType is not equal', () => {
const prev: AnomaliesNetworkTableProps = {
startDate: new Date('2000').toISOString(),
endDate: new Date('2000').toISOString(),
narrowDateRange: jest.fn(),
skip: true,
type: NetworkType.details,
flowTarget: FlowTarget.source,
};
const next: AnomaliesNetworkTableProps = {
startDate: new Date('2000').toISOString(),
endDate: new Date('2000').toISOString(),
narrowDateRange: jest.fn(),
skip: false,
type: NetworkType.details,
flowTarget: FlowTarget.destination,
};
const equal = networkEquality(prev, next);
expect(equal).toEqual(false);
});
});

View file

@ -1,16 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { AnomaliesNetworkTableProps } from '../types';
import { anomaliesTableDefaultEquality } from './default_equality';
export const networkEquality = (
prevProps: AnomaliesNetworkTableProps,
nextProps: AnomaliesNetworkTableProps
): boolean =>
anomaliesTableDefaultEquality(prevProps, nextProps) &&
prevProps.flowTarget === nextProps.flowTarget;

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { SelectInterval } from './select_interval';
describe('SelectInterval', () => {
it('selects the given interval', () => {
const { getByText } = render(<SelectInterval interval={'day'} onChange={jest.fn()} />);
expect((getByText('1 day') as HTMLOptionElement).selected).toBeTruthy();
});
it('calls onChange when clicked', () => {
const onChangeCb = jest.fn();
const { getByText, getByTestId } = render(
<SelectInterval interval={'day'} onChange={onChangeCb} />
);
userEvent.selectOptions(getByTestId('selectInterval'), getByText('1 hour'));
expect(onChangeCb).toBeCalledWith('hour');
});
});

View file

@ -0,0 +1,55 @@
/*
* 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 { EuiSelect, EuiIcon, EuiToolTip } from '@elastic/eui';
import * as i18n from './translations';
const OPTIONS = [
{
value: 'auto',
text: i18n.INTERVAL_AUTO,
},
{
value: 'hour',
text: i18n.INTERVAL_HOUR,
},
{
value: 'day',
text: i18n.INTERVAL_DAY,
},
{
value: 'second',
text: i18n.INTERVAL_SHOW_ALL,
},
];
export const SelectInterval: React.FC<{
interval: string;
onChange: (interval: string) => void;
}> = ({ interval, onChange }) => {
const onChangeCb = useCallback(
(e) => {
onChange(e.target.value);
},
[onChange]
);
return (
<EuiSelect
data-test-subj="selectInterval"
prepend={i18n.INTERVAL}
append={
<EuiToolTip content={i18n.INTERVAL_TOOLTIP}>
<EuiIcon type="questionInCircle" color="subdued" />
</EuiToolTip>
}
options={OPTIONS}
value={interval}
onChange={onChangeCb}
/>
);
};

View file

@ -65,3 +65,35 @@ export const NETWORK_NAME = i18n.translate('xpack.securitySolution.ml.table.netw
export const TIME_STAMP = i18n.translate('xpack.securitySolution.ml.table.timestampTitle', {
defaultMessage: 'Timestamp',
});
export const JOB_ID = i18n.translate('xpack.securitySolution.ml.table.jobIdFilter', {
defaultMessage: 'Job',
});
export const INTERVAL_TOOLTIP = i18n.translate('xpack.securitySolution.ml.table.intervalTooltip', {
defaultMessage:
'Show only the highest severity anomaly for each interval (such as hour or day) or show all anomalies in the selected time period.',
});
export const INTERVAL = i18n.translate('xpack.securitySolution.ml.table.intervalLabel', {
defaultMessage: 'Interval',
});
export const INTERVAL_AUTO = i18n.translate('xpack.securitySolution.ml.table.intervalAutoOption', {
defaultMessage: 'Auto',
});
export const INTERVAL_HOUR = i18n.translate('xpack.securitySolution.ml.table.intervalHourOption', {
defaultMessage: '1 hour',
});
export const INTERVAL_DAY = i18n.translate('xpack.securitySolution.ml.table.intervalDayOption', {
defaultMessage: '1 day',
});
export const INTERVAL_SHOW_ALL = i18n.translate(
'xpack.securitySolution.ml.table.intervalshowAllOption',
{
defaultMessage: 'Show all',
}
);

View file

@ -75,7 +75,10 @@ export const mockGlobalState: State = {
},
events: { activePage: 0, limit: 10 },
uncommonProcesses: { activePage: 0, limit: 10 },
anomalies: null,
anomalies: {
jobIdSelection: [],
intervalSelection: 'auto',
},
hostRisk: {
activePage: 0,
limit: 10,
@ -96,7 +99,10 @@ export const mockGlobalState: State = {
},
events: { activePage: 0, limit: 10 },
uncommonProcesses: { activePage: 0, limit: 10 },
anomalies: null,
anomalies: {
jobIdSelection: [],
intervalSelection: 'auto',
},
hostRisk: {
activePage: 0,
limit: 10,
@ -150,6 +156,10 @@ export const mockGlobalState: State = {
activePage: 0,
limit: 10,
},
[networkModel.NetworkTableType.anomalies]: {
jobIdSelection: [],
intervalSelection: 'auto',
},
},
},
details: {
@ -190,6 +200,10 @@ export const mockGlobalState: State = {
limit: 10,
sort: { direction: Direction.desc },
},
[networkModel.NetworkTableType.anomalies]: {
jobIdSelection: [],
intervalSelection: 'auto',
},
},
},
},
@ -205,7 +219,10 @@ export const mockGlobalState: State = {
activePage: 0,
limit: 10,
},
[usersModel.UsersTableType.anomalies]: null,
[usersModel.UsersTableType.anomalies]: {
jobIdSelection: [],
intervalSelection: 'auto',
},
[usersModel.UsersTableType.risk]: {
activePage: 0,
limit: 10,
@ -220,7 +237,10 @@ export const mockGlobalState: State = {
},
details: {
queries: {
[usersModel.UsersTableType.anomalies]: null,
[usersModel.UsersTableType.anomalies]: {
jobIdSelection: [],
intervalSelection: 'auto',
},
[usersModel.UsersTableType.events]: { activePage: 0, limit: 10 },
},
},

View file

@ -44,3 +44,13 @@ export const updateHostRiskScoreSeverityFilter = actionCreator<{
severitySelection: RiskSeverity[];
hostsType: HostsType;
}>('UPDATE_HOST_RISK_SCORE_SEVERITY');
export const updateHostsAnomaliesJobIdFilter = actionCreator<{
jobIds: string[];
hostsType: HostsType;
}>('UPDATE_HOSTS_ANOMALIES_JOB_ID_FILTER');
export const updateHostsAnomaliesInterval = actionCreator<{
interval: string;
hostsType: HostsType;
}>('UPDATE_HOSTS_ANOMALIES_INTERVAL');

View file

@ -32,7 +32,10 @@ export const mockHostsState: HostsModel = {
activePage: 8,
limit: DEFAULT_TABLE_LIMIT,
},
[HostsTableType.anomalies]: null,
[HostsTableType.anomalies]: {
jobIdSelection: [],
intervalSelection: 'auto',
},
[HostsTableType.risk]: {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
limit: DEFAULT_TABLE_LIMIT,
@ -68,7 +71,10 @@ export const mockHostsState: HostsModel = {
activePage: 8,
limit: DEFAULT_TABLE_LIMIT,
},
[HostsTableType.anomalies]: null,
[HostsTableType.anomalies]: {
jobIdSelection: [],
intervalSelection: 'auto',
},
[HostsTableType.risk]: {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
limit: DEFAULT_TABLE_LIMIT,
@ -96,7 +102,10 @@ describe('Hosts redux store', () => {
limit: 10,
sortField: 'lastSeen',
},
[HostsTableType.anomalies]: null,
[HostsTableType.anomalies]: {
jobIdSelection: [],
intervalSelection: 'auto',
},
[HostsTableType.authentications]: {
activePage: 0,
limit: 10,
@ -133,7 +142,10 @@ describe('Hosts redux store', () => {
limit: 10,
sortField: 'lastSeen',
},
[HostsTableType.anomalies]: null,
[HostsTableType.anomalies]: {
jobIdSelection: [],
intervalSelection: 'auto',
},
[HostsTableType.authentications]: {
activePage: 0,
limit: 10,

View file

@ -42,12 +42,17 @@ export interface HostRiskScoreQuery extends BasicQueryPaginated {
severitySelection: RiskSeverity[];
}
export interface HostsAnomaliesQuery {
jobIdSelection: string[];
intervalSelection: string;
}
export interface Queries {
[HostsTableType.authentications]: BasicQueryPaginated;
[HostsTableType.hosts]: HostsQuery;
[HostsTableType.events]: BasicQueryPaginated;
[HostsTableType.uncommonProcesses]: BasicQueryPaginated;
[HostsTableType.anomalies]: null | undefined;
[HostsTableType.anomalies]: HostsAnomaliesQuery;
[HostsTableType.risk]: HostRiskScoreQuery;
[HostsTableType.sessions]: BasicQueryPaginated;
}

View file

@ -18,6 +18,8 @@ import {
updateHostRiskScoreSort,
updateTableActivePage,
updateTableLimit,
updateHostsAnomaliesJobIdFilter,
updateHostsAnomaliesInterval,
} from './actions';
import {
setHostPageQueriesActivePageToZero,
@ -49,7 +51,10 @@ export const initialHostsState: HostsState = {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
limit: DEFAULT_TABLE_LIMIT,
},
[HostsTableType.anomalies]: null,
[HostsTableType.anomalies]: {
jobIdSelection: [],
intervalSelection: 'auto',
},
[HostsTableType.risk]: {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
limit: DEFAULT_TABLE_LIMIT,
@ -85,7 +90,10 @@ export const initialHostsState: HostsState = {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
limit: DEFAULT_TABLE_LIMIT,
},
[HostsTableType.anomalies]: null,
[HostsTableType.anomalies]: {
jobIdSelection: [],
intervalSelection: 'auto',
},
[HostsTableType.risk]: {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
limit: DEFAULT_TABLE_LIMIT,
@ -190,4 +198,30 @@ export const hostsReducer = reducerWithInitialState(initialHostsState)
},
},
}))
.case(updateHostsAnomaliesJobIdFilter, (state, { jobIds, hostsType }) => ({
...state,
[hostsType]: {
...state[hostsType],
queries: {
...state[hostsType].queries,
[HostsTableType.anomalies]: {
...state[hostsType].queries[HostsTableType.anomalies],
jobIdSelection: jobIds,
},
},
},
}))
.case(updateHostsAnomaliesInterval, (state, { interval, hostsType }) => ({
...state,
[hostsType]: {
...state[hostsType],
queries: {
...state[hostsType].queries,
[HostsTableType.anomalies]: {
...state[hostsType].queries[HostsTableType.anomalies],
intervalSelection: interval,
},
},
},
}))
.build();

View file

@ -30,3 +30,9 @@ export const hostRiskScoreSeverityFilterSelector = () =>
export const uncommonProcessesSelector = () =>
createSelector(selectHosts, (hosts) => hosts.queries.uncommonProcesses);
export const hostsAnomaliesJobIdFilterSelector = () =>
createSelector(selectHosts, (hosts) => hosts.queries[HostsTableType.anomalies].jobIdSelection);
export const hostsAnomaliesIntervalSelector = () =>
createSelector(selectHosts, (hosts) => hosts.queries[HostsTableType.anomalies].intervalSelection);

View file

@ -47,6 +47,7 @@ import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml
import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities';
import { navTabsNetworkDetails } from './nav_tabs';
import { NetworkDetailsTabs } from './details_tabs';
import { useInstalledSecurityJobsIds } from '../../../common/components/ml/hooks/use_installed_security_jobs';
export { getTrailingBreadcrumbs } from './utils';
@ -115,11 +116,14 @@ const NetworkDetailsComponent: React.FC = () => {
ip,
});
const { jobIds } = useInstalledSecurityJobsIds();
const [isLoadingAnomaliesData, anomaliesData] = useAnomaliesTableData({
criteriaFields: networkToCriteria(detailName, flowTarget),
startDate: from,
endDate: to,
skip: isInitializing,
jobIds,
aggregationInterval: 'auto',
});
const headerDraggableArguments = useMemo(

View file

@ -7,6 +7,7 @@
import actionCreatorFactory from 'typescript-fsa';
import type { networkModel } from '.';
import type { NetworkType } from './model';
const actionCreator = actionCreatorFactory('x-pack/security_solution/local/network');
@ -23,3 +24,13 @@ export const setNetworkDetailsTablesActivePageToZero = actionCreator(
export const setNetworkTablesActivePageToZero = actionCreator(
'SET_NETWORK_TABLES_ACTIVE_PAGE_TO_ZERO'
);
export const updateNetworkAnomaliesJobIdFilter = actionCreator<{
jobIds: string[];
networkType: NetworkType;
}>('UPDATE_NETWORK_ANOMALIES_JOB_ID_FILTER');
export const updateNetworkAnomaliesInterval = actionCreator<{
interval: string;
networkType: NetworkType;
}>('UPDATE_NETWORK_ANOMALIES_INTERVAL');

View file

@ -79,6 +79,10 @@ export const mockNetworkState: NetworkModel = {
activePage: 0,
limit: DEFAULT_TABLE_LIMIT,
},
[NetworkDetailsTableType.anomalies]: {
jobIdSelection: [],
intervalSelection: 'auto',
},
},
},
details: {
@ -136,6 +140,10 @@ export const mockNetworkState: NetworkModel = {
limit: DEFAULT_TABLE_LIMIT,
sort: { direction: Direction.desc },
},
[NetworkDetailsTableType.anomalies]: {
jobIdSelection: [],
intervalSelection: 'auto',
},
},
flowTarget: FlowTarget.source,
},
@ -155,6 +163,10 @@ describe('Network redux store', () => {
limit: 10,
sort: { field: 'bytes_out', direction: 'desc' },
},
[NetworkDetailsTableType.anomalies]: {
jobIdSelection: [],
intervalSelection: 'auto',
},
[NetworkTableType.dns]: {
activePage: 0,
limit: 10,
@ -227,6 +239,10 @@ describe('Network redux store', () => {
field: 'bytes_out',
},
},
[NetworkDetailsTableType.anomalies]: {
jobIdSelection: [],
intervalSelection: 'auto',
},
[NetworkDetailsTableType.http]: {
activePage: 0,
limit: 10,

View file

@ -29,6 +29,7 @@ export enum NetworkTableType {
topNFlowDestination = 'topNFlowDestination',
topNFlowSource = 'topNFlowSource',
tls = 'tls',
anomalies = 'anomalies',
}
export type TopNTableType =
@ -53,6 +54,7 @@ export enum NetworkDetailsTableType {
topNFlowDestination = 'topNFlowDestination',
topNFlowSource = 'topNFlowSource',
users = 'users',
anomalies = 'anomalies',
}
export interface BasicQueryPaginated {
@ -105,6 +107,7 @@ export interface NetworkQueries {
[NetworkTableType.topNFlowSource]: TopNFlowQuery;
[NetworkTableType.tls]: TlsQuery;
[NetworkTableType.alerts]: BasicQueryPaginated;
[NetworkTableType.anomalies]: NetworkAnomaliesQuery;
}
export interface NetworkPageModel {
@ -115,6 +118,11 @@ export interface NetworkUsersQuery extends BasicQueryPaginated {
sort: SortField<NetworkUsersFields>;
}
export interface NetworkAnomaliesQuery {
jobIdSelection: string[];
intervalSelection: string;
}
export interface NetworkDetailsQueries {
[NetworkDetailsTableType.http]: HttpQuery;
[NetworkDetailsTableType.tls]: TlsQuery;
@ -123,6 +131,7 @@ export interface NetworkDetailsQueries {
[NetworkDetailsTableType.topNFlowDestination]: TopNFlowQuery;
[NetworkDetailsTableType.topNFlowSource]: TopNFlowQuery;
[NetworkDetailsTableType.users]: NetworkUsersQuery;
[NetworkDetailsTableType.anomalies]: NetworkAnomaliesQuery;
}
export interface NetworkDetailsModel {

View file

@ -6,7 +6,7 @@
*/
import { reducerWithInitialState } from 'typescript-fsa-reducers';
import { get } from 'lodash/fp';
import { get, set } from 'lodash/fp';
import {
Direction,
FlowTarget,
@ -21,13 +21,15 @@ import {
setNetworkDetailsTablesActivePageToZero,
setNetworkTablesActivePageToZero,
updateNetworkTable,
updateNetworkAnomaliesJobIdFilter,
updateNetworkAnomaliesInterval,
} from './actions';
import {
setNetworkDetailsQueriesActivePageToZero,
setNetworkPageQueriesActivePageToZero,
} from './helpers';
import type { NetworkModel } from './model';
import { NetworkDetailsTableType, NetworkTableType } from './model';
import { NetworkType, NetworkDetailsTableType, NetworkTableType } from './model';
export type NetworkState = NetworkModel;
@ -94,6 +96,10 @@ export const initialNetworkState: NetworkState = {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
limit: DEFAULT_TABLE_LIMIT,
},
[NetworkTableType.anomalies]: {
jobIdSelection: [],
intervalSelection: 'auto',
},
},
},
details: {
@ -153,6 +159,10 @@ export const initialNetworkState: NetworkState = {
direction: Direction.asc,
},
},
[NetworkDetailsTableType.anomalies]: {
jobIdSelection: [],
intervalSelection: 'auto',
},
},
flowTarget: FlowTarget.source,
},
@ -190,4 +200,18 @@ export const networkReducer = reducerWithInitialState(initialNetworkState)
queries: setNetworkDetailsQueriesActivePageToZero(state),
},
}))
.case(updateNetworkAnomaliesJobIdFilter, (state, { jobIds, networkType }) => {
if (networkType === NetworkType.page) {
return set('page.queries.anomalies.jobIdSelection', jobIds, state);
} else {
return set('details.queries.anomalies.jobIdSelection', jobIds, state);
}
})
.case(updateNetworkAnomaliesInterval, (state, { interval, networkType }) => {
if (networkType === NetworkType.page) {
return set('page.queries.anomalies.intervalSelection', interval, state);
} else {
return set('details.queries.anomalies.intervalSelection', interval, state);
}
})
.build();

View file

@ -20,6 +20,11 @@ import type {
} from './model';
import { NetworkDetailsTableType, NetworkTableType, NetworkType } from './model';
const selectNetwork = (
state: State,
networkType: NetworkType
): NetworkPageModel | NetworkDetailsModel => get(networkType, state.network);
const selectNetworkPage = (state: State): NetworkPageModel => state.network.page;
const selectNetworkDetails = (state: State): NetworkDetailsModel => state.network.details;
@ -87,3 +92,15 @@ export const httpSelector = () => createSelector(selectHttpByType, (httpQueries)
export const usersSelector = () =>
createSelector(selectNetworkDetails, (network) => network.queries.users);
export const networkAnomaliesJobIdFilterSelector = () =>
createSelector(
selectNetwork,
(network) => network.queries[NetworkTableType.anomalies].jobIdSelection
);
export const networkAnomaliesIntervalSelector = () =>
createSelector(
selectNetwork,
(network) => network.queries[NetworkTableType.anomalies].intervalSelection
);

View file

@ -8,11 +8,20 @@ import React, { useCallback, useMemo } from 'react';
import styled from 'styled-components';
import type { EuiBasicTableColumn } from '@elastic/eui';
import { ML_PAGES, useMlHref } from '@kbn/ml-plugin/public';
import { useDispatch } from 'react-redux';
import * as i18n from './translations';
import type { AnomaliesCount } from '../../../../common/components/ml/anomaly/use_anomalies_search';
import { AnomalyJobStatus } from '../../../../common/components/ml/anomaly/use_anomalies_search';
import {
AnomalyJobStatus,
AnomalyEntity,
} from '../../../../common/components/ml/anomaly/use_anomalies_search';
import { useKibana } from '../../../../common/lib/kibana';
import { LinkAnchor } from '../../../../common/components/links';
import { LinkAnchor, SecuritySolutionLinkAnchor } from '../../../../common/components/links';
import { SecurityPageName } from '../../../../app/types';
import { usersActions } from '../../../../users/store';
import { hostsActions } from '../../../../hosts/store';
import { HostsType } from '../../../../hosts/store/model';
import { UsersType } from '../../../../users/store/model';
type AnomaliesColumns = Array<EuiBasicTableColumn<AnomaliesCount>>;
@ -54,11 +63,11 @@ export const useAnomaliesColumns = (loading: boolean): AnomaliesColumns => {
mobileOptions: { show: true },
width: '15%',
'data-test-subj': 'anomalies-table-column-count',
render: (count, { status, jobId }) => {
render: (count, { status, jobId, entity }) => {
if (loading) return '';
if (count > 0 || status === AnomalyJobStatus.enabled) {
return count;
return <AnomaliesTabLink count={count} jobId={jobId} entity={entity} />;
} else {
if (status === AnomalyJobStatus.disabled && jobId) {
return <EnableJobLink jobId={jobId} />;
@ -110,3 +119,60 @@ const EnableJobLink = ({ jobId }: { jobId: string }) => {
</LinkAnchor>
);
};
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 (
<SecuritySolutionLinkAnchor onClick={onClick} deepLinkId={deepLinkId}>
{count}
</SecuritySolutionLinkAnchor>
);
};

View file

@ -5,34 +5,18 @@
* 2.0.
*/
interface AnomalyConfig {
name: string;
entity: 'User' | 'Host';
}
export const NOTABLE_ANOMALIES_CONFIG = {
auth_rare_source_ip_for_a_user: {
entity: 'User',
},
packetbeat_dns_tunneling: {
entity: 'Host',
},
packetbeat_rare_server_domain: {
entity: 'Host',
},
packetbeat_rare_dns_question: {
entity: 'Host',
},
suspicious_login_activity: {
entity: 'User',
},
v3_windows_anomalous_script: {
entity: 'User',
},
};
export const NOTABLE_ANOMALIES_IDS = Object.keys(
NOTABLE_ANOMALIES_CONFIG
) as NotableAnomaliesJobId[];
export type NotableAnomaliesJobId = keyof typeof NOTABLE_ANOMALIES_CONFIG;
export type NotableAnomaliesConfig = Record<NotableAnomaliesJobId, AnomalyConfig>;
export const NOTABLE_ANOMALIES_IDS: 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',
];
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';

View file

@ -9,7 +9,10 @@ import { render } from '@testing-library/react';
import React from 'react';
import { EntityAnalyticsAnomalies } from '.';
import type { AnomaliesCount } from '../../../../common/components/ml/anomaly/use_anomalies_search';
import { AnomalyJobStatus } from '../../../../common/components/ml/anomaly/use_anomalies_search';
import {
AnomalyJobStatus,
AnomalyEntity,
} from '../../../../common/components/ml/anomaly/use_anomalies_search';
import { TestProviders } from '../../../../common/mock';
@ -67,6 +70,7 @@ describe('EntityAnalyticsAnomalies', () => {
name: 'v3_windows_anomalous_script',
count: 9999,
status: AnomalyJobStatus.enabled,
entity: AnomalyEntity.User,
};
mockUseNotableAnomaliesSearch.mockReturnValue({
@ -93,6 +97,7 @@ describe('EntityAnalyticsAnomalies', () => {
name: 'v3_windows_anomalous_script',
count: 0,
status: AnomalyJobStatus.disabled,
entity: AnomalyEntity.User,
};
mockUseNotableAnomaliesSearch.mockReturnValue({
@ -118,6 +123,7 @@ describe('EntityAnalyticsAnomalies', () => {
name: 'v3_windows_anomalous_script',
count: 0,
status: AnomalyJobStatus.uninstalled,
entity: AnomalyEntity.User,
};
mockUseNotableAnomaliesSearch.mockReturnValue({
@ -142,6 +148,7 @@ describe('EntityAnalyticsAnomalies', () => {
name: 'v3_windows_anomalous_script',
count: 0,
status: AnomalyJobStatus.failed,
entity: AnomalyEntity.User,
};
mockUseNotableAnomaliesSearch.mockReturnValue({
@ -166,6 +173,7 @@ describe('EntityAnalyticsAnomalies', () => {
name: 'v3_windows_anomalous_script',
count: 0,
status: AnomalyJobStatus.failed,
entity: AnomalyEntity.User,
};
mockUseNotableAnomaliesSearch.mockReturnValue({

View file

@ -53,6 +53,16 @@ export const getAggregatedAnomaliesQuery = ({
terms: {
field: 'job_id',
},
aggs: {
entity: {
top_hits: {
_source: {
includes: ['host.name', 'user.name'],
},
size: 1,
},
},
},
},
},
});

View file

@ -29,6 +29,7 @@ import { useNetworkDetails } from '../../../../network/containers/details';
import { networkModel } from '../../../../network/store';
import { useAnomaliesTableData } from '../../../../common/components/ml/anomaly/use_anomalies_table_data';
import { LandingCards } from '../../../../common/components/landing_cards';
import { useInstalledSecurityJobsIds } from '../../../../common/components/ml/hooks/use_installed_security_jobs';
interface ExpandableNetworkProps {
expandedNetwork: { ip: string; flowTarget: FlowTargetSourceDest };
@ -115,12 +116,14 @@ export const ExpandableNetworkDetails = ({
});
useInvalidFilterQuery({ id, filterQuery, kqlError, query, startDate: from, endDate: to });
const { jobIds } = useInstalledSecurityJobsIds();
const [isLoadingAnomaliesData, anomaliesData] = useAnomaliesTableData({
criteriaFields: networkToCriteria(ip, flowTarget),
startDate: from,
endDate: to,
skip: isInitializing,
jobIds,
aggregationInterval: 'auto',
});
return indicesExist ? (

View file

@ -38,3 +38,13 @@ export const updateTableSorting = actionCreator<{
export const updateUserRiskScoreSeverityFilter = actionCreator<{
severitySelection: RiskSeverity[];
}>('UPDATE_USERS_RISK_SEVERITY_FILTER');
export const updateUsersAnomaliesJobIdFilter = actionCreator<{
jobIds: string[];
usersType: usersModel.UsersType;
}>('UPDATE_USERS_ANOMALIES_JOB_ID_FILTER');
export const updateUsersAnomaliesInterval = actionCreator<{
interval: string;
usersType: usersModel.UsersType;
}>('UPDATE_USERS_ANOMALIES_INTERVAL');

View file

@ -42,16 +42,21 @@ export interface UsersRiskScoreQuery extends BasicQueryPaginated {
severitySelection: RiskSeverity[];
}
export interface UsersAnomaliesQuery {
jobIdSelection: string[];
intervalSelection: string;
}
export interface UsersQueries {
[UsersTableType.allUsers]: AllUsersQuery;
[UsersTableType.authentications]: BasicQueryPaginated;
[UsersTableType.anomalies]: null | undefined;
[UsersTableType.anomalies]: UsersAnomaliesQuery;
[UsersTableType.risk]: UsersRiskScoreQuery;
[UsersTableType.events]: BasicQueryPaginated;
}
export interface UserDetailsQueries {
[UsersTableType.anomalies]: null | undefined;
[UsersTableType.anomalies]: UsersAnomaliesQuery;
[UsersTableType.events]: BasicQueryPaginated;
}

View file

@ -6,6 +6,7 @@
*/
import { reducerWithInitialState } from 'typescript-fsa-reducers';
import { set } from 'lodash/fp';
import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from '../../common/store/constants';
import {
@ -14,6 +15,8 @@ import {
updateTableLimit,
updateTableSorting,
updateUserRiskScoreSeverityFilter,
updateUsersAnomaliesInterval,
updateUsersAnomaliesJobIdFilter,
} from './actions';
import { setUsersPageQueriesActivePageToZero } from './helpers';
import type { UsersModel } from './model';
@ -46,7 +49,10 @@ export const initialUsersState: UsersModel = {
},
severitySelection: [],
},
[UsersTableType.anomalies]: null,
[UsersTableType.anomalies]: {
jobIdSelection: [],
intervalSelection: 'auto',
},
[UsersTableType.events]: {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
limit: DEFAULT_TABLE_LIMIT,
@ -55,7 +61,10 @@ export const initialUsersState: UsersModel = {
},
details: {
queries: {
[UsersTableType.anomalies]: null,
[UsersTableType.anomalies]: {
jobIdSelection: [],
intervalSelection: 'auto',
},
[UsersTableType.events]: {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
limit: DEFAULT_TABLE_LIMIT,
@ -126,4 +135,18 @@ export const usersReducer = reducerWithInitialState(initialUsersState)
},
},
}))
.case(updateUsersAnomaliesJobIdFilter, (state, { jobIds, usersType }) => {
if (usersType === 'page') {
return set('page.queries.anomalies.jobIdSelection', jobIds, state);
} else {
return set('details.queries.anomalies.jobIdSelection', jobIds, state);
}
})
.case(updateUsersAnomaliesInterval, (state, { interval, usersType }) => {
if (usersType === 'page') {
return set('page.queries.anomalies.intervalSelection', interval, state);
} else {
return set('details.queries.anomalies.intervalSelection', interval, state);
}
})
.build();

View file

@ -9,11 +9,14 @@ import { createSelector } from 'reselect';
import type { State } from '../../common/store/types';
import type { UsersPageModel } from './model';
import type { UserDetailsPageModel, UsersPageModel, UsersType } from './model';
import { UsersTableType } from './model';
const selectUserPage = (state: State): UsersPageModel => state.users.page;
const selectUsers = (state: State, usersType: UsersType): UsersPageModel | UserDetailsPageModel =>
state.users[usersType];
export const allUsersSelector = () =>
createSelector(selectUserPage, (users) => users.queries[UsersTableType.allUsers]);
@ -25,3 +28,9 @@ export const usersRiskScoreSeverityFilterSelector = () =>
export const authenticationsSelector = () =>
createSelector(selectUserPage, (users) => users.queries[UsersTableType.authentications]);
export const usersAnomaliesJobIdFilterSelector = () =>
createSelector(selectUsers, (users) => users.queries[UsersTableType.anomalies].jobIdSelection);
export const usersAnomaliesIntervalSelector = () =>
createSelector(selectUsers, (users) => users.queries[UsersTableType.anomalies].intervalSelection);