Add anomalies tab to user page (#126079)

* Add anomalies tab to the users page
This commit is contained in:
Pablo Machado 2022-03-01 13:35:19 +01:00 committed by GitHub
parent 25b97bbac1
commit 1bc178fe76
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 987 additions and 462 deletions

View file

@ -6,7 +6,7 @@
*/
import { useState, useEffect, useMemo } from 'react';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { DEFAULT_ANOMALY_SCORE } from '../../../../../common/constants';
import { anomaliesTableData } from '../api/anomalies_table_data';
import { InfluencerInput, Anomalies, CriteriaFields } from '../types';
@ -23,6 +23,7 @@ interface Args {
threshold?: number;
skip?: boolean;
criteriaFields?: CriteriaFields[];
filterQuery?: estypes.QueryDslQueryContainer;
}
type Return = [boolean, Anomalies | null];
@ -55,6 +56,7 @@ export const useAnomaliesTableData = ({
endDate,
threshold = -1,
skip = false,
filterQuery,
}: Args): Return => {
const [tableData, setTableData] = useState<Anomalies | null>(null);
const { isMlUser, jobs } = useInstalledSecurityJobs();
@ -84,6 +86,7 @@ export const useAnomaliesTableData = ({
{
jobIds,
criteriaFields: criteriaFieldsInput,
influencersFilterQuery: filterQuery,
aggregationInterval: 'auto',
threshold: getThreshold(anomalyScore, threshold),
earliestMs,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { Anomalies, InfluencerInput, CriteriaFields } from '../types';
import { KibanaServices } from '../../../lib/kibana';
@ -19,6 +20,7 @@ export interface Body {
dateFormatTz: string;
maxRecords: number;
maxExamples: number;
influencersFilterQuery?: estypes.QueryDslQueryContainer;
}
export const anomaliesTableData = async (body: Body, signal: AbortSignal): Promise<Anomalies> => {

View file

@ -0,0 +1,26 @@
/*
* 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 { UsersType } from '../../../../users/store/model';
import { getCriteriaFromUsersType } from './get_criteria_from_users_type';
describe('get_criteria_from_user_type', () => {
test('returns user name from criteria if the user type is details', () => {
const criteria = getCriteriaFromUsersType(UsersType.details, 'admin');
expect(criteria).toEqual([{ fieldName: 'user.name', fieldValue: 'admin' }]);
});
test('returns empty array from criteria if the user type is page but rather an empty array', () => {
const criteria = getCriteriaFromUsersType(UsersType.page, 'admin');
expect(criteria).toEqual([]);
});
test('returns empty array from criteria if the user name is undefined and user type is details', () => {
const criteria = getCriteriaFromUsersType(UsersType.details, undefined);
expect(criteria).toEqual([]);
});
});

View file

@ -0,0 +1,20 @@
/*
* 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 { UsersType } from '../../../../users/store/model';
import { CriteriaFields } from '../types';
export const getCriteriaFromUsersType = (
type: UsersType,
userName: string | undefined
): CriteriaFields[] => {
if (type === UsersType.details && userName != null) {
return [{ fieldName: 'user.name', fieldValue: userName }];
} else {
return [];
}
};

View file

@ -5,42 +5,31 @@
* 2.0.
*/
import { cloneDeep } from 'lodash/fp';
import { getHostNameFromInfluencers } from './get_host_name_from_influencers';
import { mockAnomalies } from '../mock';
describe('get_host_name_from_influencers', () => {
let anomalies = cloneDeep(mockAnomalies);
beforeEach(() => {
anomalies = cloneDeep(mockAnomalies);
});
test('returns host names from influencers from the mock', () => {
const hostName = getHostNameFromInfluencers(anomalies.anomalies[0].influencers);
expect(hostName).toEqual('zeek-iowa');
expect(getHostNameFromInfluencers(mockAnomalies.anomalies[0].influencers)).toEqual('zeek-iowa');
});
test('returns null if there are no influencers from the mock', () => {
anomalies.anomalies[0].influencers = [];
const hostName = getHostNameFromInfluencers(anomalies.anomalies[0].influencers);
expect(hostName).toEqual(null);
expect(getHostNameFromInfluencers([])).toEqual(null);
});
test('returns null if it is given undefined influencers', () => {
const hostName = getHostNameFromInfluencers();
expect(hostName).toEqual(null);
expect(getHostNameFromInfluencers()).toEqual(null);
});
test('returns null if there influencers is an empty object', () => {
anomalies.anomalies[0].influencers = [{}];
const hostName = getHostNameFromInfluencers(anomalies.anomalies[0].influencers);
expect(hostName).toEqual(null);
expect(getHostNameFromInfluencers([{}])).toEqual(null);
});
test('returns host name mixed with other data', () => {
anomalies.anomalies[0].influencers = [{ 'host.name': 'name-1' }, { 'source.ip': '127.0.0.1' }];
const hostName = getHostNameFromInfluencers(anomalies.anomalies[0].influencers);
const hostName = getHostNameFromInfluencers([
{ 'host.name': 'name-1' },
{ 'source.ip': '127.0.0.1' },
]);
expect(hostName).toEqual('name-1');
});
});

View file

@ -5,38 +5,27 @@
* 2.0.
*/
import { cloneDeep } from 'lodash/fp';
import { getNetworkFromInfluencers } from './get_network_from_influencers';
import { mockAnomalies } from '../mock';
import { DestinationOrSource } from '../types';
describe('get_network_from_influencers', () => {
let anomalies = cloneDeep(mockAnomalies);
beforeEach(() => {
anomalies = cloneDeep(mockAnomalies);
});
test('returns null if there are no influencers from the mock', () => {
anomalies.anomalies[0].influencers = [];
const network = getNetworkFromInfluencers(anomalies.anomalies[0].influencers);
expect(network).toEqual(null);
test('returns null if there are no influencers', () => {
expect(getNetworkFromInfluencers([])).toEqual(null);
});
test('returns null if the influencers is an empty object', () => {
anomalies.anomalies[0].influencers = [{}];
const network = getNetworkFromInfluencers(anomalies.anomalies[0].influencers);
expect(network).toEqual(null);
expect(getNetworkFromInfluencers([{}])).toEqual(null);
});
test('returns null if the influencers are undefined', () => {
const network = getNetworkFromInfluencers();
expect(network).toEqual(null);
expect(getNetworkFromInfluencers()).toEqual(null);
});
test('returns network name of source mixed with other data', () => {
anomalies.anomalies[0].influencers = [{ 'host.name': 'name-1' }, { 'source.ip': '127.0.0.1' }];
const network = getNetworkFromInfluencers(anomalies.anomalies[0].influencers);
const network = getNetworkFromInfluencers([
{ 'host.name': 'name-1' },
{ 'source.ip': '127.0.0.1' },
]);
const expected: { ip: string; type: DestinationOrSource } = {
ip: '127.0.0.1',
type: 'source.ip',
@ -45,11 +34,10 @@ describe('get_network_from_influencers', () => {
});
test('returns network name mixed with other data', () => {
anomalies.anomalies[0].influencers = [
const network = getNetworkFromInfluencers([
{ 'host.name': 'name-1' },
{ 'destination.ip': '127.0.0.1' },
];
const network = getNetworkFromInfluencers(anomalies.anomalies[0].influencers);
]);
const expected: { ip: string; type: DestinationOrSource } = {
ip: '127.0.0.1',
type: 'destination.ip',

View file

@ -0,0 +1,35 @@
/*
* 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 { getUserNameFromInfluencers } from './get_user_name_from_influencers';
import { mockAnomalies } from '../mock';
describe('get_user_name_from_influencers', () => {
test('returns user names from influencers from the mock', () => {
expect(getUserNameFromInfluencers(mockAnomalies.anomalies[0].influencers)).toEqual('root');
});
test('returns null if there are no influencers from the mock', () => {
expect(getUserNameFromInfluencers([])).toEqual(null);
});
test('returns null if it is given undefined influencers', () => {
expect(getUserNameFromInfluencers()).toEqual(null);
});
test('returns null if there influencers is an empty object', () => {
expect(getUserNameFromInfluencers([{}])).toEqual(null);
});
test('returns user name mixed with other data', () => {
const userName = getUserNameFromInfluencers([
{ 'user.name': 'root' },
{ 'source.ip': '127.0.0.1' },
]);
expect(userName).toEqual('root');
});
});

View file

@ -0,0 +1,31 @@
/*
* 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 { getEntries } from '../get_entries';
export const getUserNameFromInfluencers = (
influencers: Array<Record<string, string>> = [],
userName?: string
): string | null => {
const recordFound = influencers.find((influencer) => {
const [influencerName, influencerValue] = getEntries(influencer);
if (influencerName === 'user.name') {
if (userName == null) {
return true;
} else {
return influencerValue === userName;
}
} else {
return false;
}
});
if (recordFound != null) {
return Object.values(recordFound)[0];
} else {
return null;
}
};

View file

@ -15,13 +15,12 @@ import * as i18n from './translations';
import { getAnomaliesHostTableColumnsCurated } from './get_anomalies_host_table_columns';
import { convertAnomaliesToHosts } from './convert_anomalies_to_hosts';
import { Loader } from '../../loader';
import { getIntervalFromAnomalies } from '../anomaly/get_interval_from_anomalies';
import { AnomaliesHostTableProps } from '../types';
import { useMlCapabilities } from '../hooks/use_ml_capabilities';
import { BasicTable } from './basic_table';
import { hostEquality } from './host_equality';
import { getCriteriaFromHostType } from '../criteria/get_criteria_from_host_type';
import { Panel } from '../../panel';
import { anomaliesTableDefaultEquality } from './default_equality';
const sorting = {
sort: {
@ -33,7 +32,6 @@ const sorting = {
const AnomaliesHostTableComponent: React.FC<AnomaliesHostTableProps> = ({
startDate,
endDate,
narrowDateRange,
hostName,
skip,
type,
@ -44,18 +42,14 @@ const AnomaliesHostTableComponent: React.FC<AnomaliesHostTableProps> = ({
endDate,
skip,
criteriaFields: getCriteriaFromHostType(type, hostName),
filterQuery: {
exists: { field: 'host.name' },
},
});
const hosts = convertAnomaliesToHosts(tableData, hostName);
const interval = getIntervalFromAnomalies(tableData);
const columns = getAnomaliesHostTableColumnsCurated(
type,
startDate,
endDate,
interval,
narrowDateRange
);
const columns = getAnomaliesHostTableColumnsCurated(type, startDate, endDate);
const pagination = {
initialPageIndex: 0,
initialPageSize: 10,
@ -94,4 +88,7 @@ const AnomaliesHostTableComponent: React.FC<AnomaliesHostTableProps> = ({
}
};
export const AnomaliesHostTable = React.memo(AnomaliesHostTableComponent, hostEquality);
export const AnomaliesHostTable = React.memo(
AnomaliesHostTableComponent,
anomaliesTableDefaultEquality
);

View file

@ -0,0 +1,97 @@
/*
* 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 { 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 { Loader } from '../../loader';
import { AnomaliesUserTableProps } from '../types';
import { useMlCapabilities } from '../hooks/use_ml_capabilities';
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';
const sorting = {
sort: {
field: 'anomaly.severity',
direction: 'desc',
},
} as const;
const AnomaliesUserTableComponent: React.FC<AnomaliesUserTableProps> = ({
startDate,
endDate,
userName,
skip,
type,
}) => {
const capabilities = useMlCapabilities();
const [loading, tableData] = useAnomaliesTableData({
startDate,
endDate,
skip,
criteriaFields: getCriteriaFromUsersType(type, userName),
filterQuery: {
exists: { field: 'user.name' },
},
});
const users = convertAnomaliesToUsers(tableData, userName);
const columns = getAnomaliesUserTableColumnsCurated(type, startDate, endDate);
const pagination = {
initialPageIndex: 0,
initialPageSize: 10,
totalItemCount: users.length,
pageSizeOptions: [5, 10, 20, 50],
hidePerPageOptions: false,
};
if (!hasMlUserPermissions(capabilities)) {
return null;
} else {
return (
<Panel loading={loading}>
<HeaderSection
subtitle={`${i18n.SHOWING}: ${pagination.totalItemCount.toLocaleString()} ${i18n.UNIT(
pagination.totalItemCount
)}`}
title={i18n.ANOMALIES}
tooltip={i18n.TOOLTIP}
isInspectDisabled={skip}
/>
<BasicTable
// @ts-expect-error the Columns<T, U> type is not as specific as EUI's...
columns={columns}
items={users}
pagination={pagination}
sorting={sorting}
/>
{loading && (
<Loader data-test-subj="anomalies-host-table-loading-panel" overlay size="xl" />
)}
</Panel>
);
}
};
export const AnomaliesUserTable = React.memo(
AnomaliesUserTableComponent,
anomaliesTableDefaultEquality
);

View file

@ -0,0 +1,167 @@
/*
* 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 { mockAnomalies } from '../mock';
import { convertAnomaliesToUsers, getUserNameFromEntity } from './convert_anomalies_to_users';
import { AnomaliesByUser } from '../types';
describe('convert_anomalies_to_users', () => {
test('it returns expected anomalies from a user', () => {
const entities = convertAnomaliesToUsers(mockAnomalies);
const expected: AnomaliesByUser[] = [
{
anomaly: mockAnomalies.anomalies[0],
userName: 'root',
},
{
anomaly: mockAnomalies.anomalies[1],
userName: 'root',
},
];
expect(entities).toEqual(expected);
});
test('it returns empty anomalies if sent in a null', () => {
const entities = convertAnomaliesToUsers(null);
const expected: AnomaliesByUser[] = [];
expect(entities).toEqual(expected);
});
test('it returns a specific anomaly if sent in the user name of an anomaly', () => {
const anomalies = {
...mockAnomalies,
anomalies: [
{
...mockAnomalies.anomalies[0],
entityName: 'something-else',
entityValue: 'something-else',
influencers: [
{ 'host.name': 'zeek-iowa' },
{ 'process.name': 'du' },
{ 'user.name': 'something-else' },
],
},
mockAnomalies.anomalies[1],
],
};
const entities = convertAnomaliesToUsers(anomalies, 'root');
const expected: AnomaliesByUser[] = [
{
anomaly: anomalies.anomalies[1],
userName: 'root',
},
];
expect(entities).toEqual(expected);
});
test('it returns a specific anomaly if an influencer has the user name', () => {
const anomalies = {
...mockAnomalies,
anomalies: [
{
...mockAnomalies.anomalies[0],
entityName: 'something-else',
entityValue: 'something-else',
influencers: [
{ 'host.name': 'zeek-iowa' },
{ 'process.name': 'du' },
{ 'user.name': 'something-else' },
],
},
{
...mockAnomalies.anomalies[1],
entityName: 'something-else',
entityValue: 'something-else',
},
],
};
const entities = convertAnomaliesToUsers(anomalies, 'root');
const expected: AnomaliesByUser[] = [
{
anomaly: anomalies.anomalies[1],
userName: 'root',
},
];
expect(entities).toEqual(expected);
});
test('it returns empty anomalies if sent in the name of one that does not exist', () => {
const entities = convertAnomaliesToUsers(mockAnomalies, 'some-made-up-name-here-for-you');
const expected: AnomaliesByUser[] = [];
expect(entities).toEqual(expected);
});
test('it returns true for a found entity name passed in', () => {
const anomalies = {
...mockAnomalies,
anomalies: [
{
...mockAnomalies.anomalies[0],
entityName: 'user.name',
entityValue: 'admin',
},
mockAnomalies.anomalies[1],
],
};
const found = getUserNameFromEntity(anomalies.anomalies[0], 'admin');
expect(found).toEqual(true);
});
test('it returns false for an entity name that does not exist', () => {
const anomalies = {
...mockAnomalies,
anomalies: [
{
...mockAnomalies.anomalies[0],
entityName: 'user.name',
entityValue: 'admin',
},
mockAnomalies.anomalies[1],
],
};
const found = getUserNameFromEntity(anomalies.anomalies[0], 'some-made-up-entity-name');
expect(found).toEqual(false);
});
test('it returns true for an entity that has user.name within it if no name is passed in', () => {
const anomalies = {
...mockAnomalies,
anomalies: [
{
...mockAnomalies.anomalies[0],
entityName: 'user.name',
entityValue: 'something-made-up',
},
mockAnomalies.anomalies[1],
],
};
const found = getUserNameFromEntity(anomalies.anomalies[0]);
expect(found).toEqual(true);
});
test('it returns false for an entity that is not user.name and no name is passed in', () => {
const anomalies = {
...mockAnomalies,
anomalies: [
{
...mockAnomalies.anomalies[0],
entityValue: 'made-up',
},
mockAnomalies.anomalies[1],
],
};
const found = getUserNameFromEntity(anomalies.anomalies[0]);
expect(found).toEqual(false);
});
});

View file

@ -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 { Anomalies, AnomaliesByUser, Anomaly } from '../types';
import { getUserNameFromInfluencers } from '../influencers/get_user_name_from_influencers';
export const convertAnomaliesToUsers = (
anomalies: Anomalies | null,
userName?: string
): AnomaliesByUser[] => {
if (anomalies == null) {
return [];
} else {
return anomalies.anomalies.reduce<AnomaliesByUser[]>((accum, item) => {
if (getUserNameFromEntity(item, userName)) {
return [...accum, { userName: item.entityValue, anomaly: item }];
} else {
const userNameFromInfluencers = getUserNameFromInfluencers(item.influencers, userName);
if (userNameFromInfluencers != null) {
return [...accum, { userName: userNameFromInfluencers, anomaly: item }];
} else {
return accum;
}
}
}, []);
}
};
export const getUserNameFromEntity = (anomaly: Anomaly, userName?: string): boolean => {
if (anomaly.entityName !== 'user.name') {
return false;
} else if (userName == null) {
return true;
} else {
return anomaly.entityValue === userName;
}
};

View file

@ -7,8 +7,7 @@
import { mockAnomalies } from '../mock';
import { cloneDeep } from 'lodash/fp';
import { createCompoundHostKey, createCompoundNetworkKey } from './create_compound_key';
import { AnomaliesByHost, AnomaliesByNetwork } from '../types';
import { createCompoundAnomalyKey } from './create_compound_key';
describe('create_explorer_link', () => {
let anomalies = cloneDeep(mockAnomalies);
@ -17,22 +16,8 @@ describe('create_explorer_link', () => {
anomalies = cloneDeep(mockAnomalies);
});
test('it creates a compound host key', () => {
const anomaliesByHost: AnomaliesByHost = {
hostName: 'some-host-name',
anomaly: anomalies.anomalies[0],
};
const key = createCompoundHostKey(anomaliesByHost);
expect(key).toEqual('some-host-name-process.name-du-16.193669439507826-job-1');
});
test('it creates a compound network key', () => {
const anomaliesByNetwork: AnomaliesByNetwork = {
type: 'destination.ip',
ip: '127.0.0.1',
anomaly: anomalies.anomalies[0],
};
const key = createCompoundNetworkKey(anomaliesByNetwork);
expect(key).toEqual('127.0.0.1-process.name-du-16.193669439507826-job-1');
test('it creates a compound anomaly key', () => {
const key = createCompoundAnomalyKey(anomalies.anomalies[0]);
expect(key).toEqual('process.name-du-16.193669439507826-job-1');
});
});

View file

@ -5,10 +5,7 @@
* 2.0.
*/
import { AnomaliesByHost, AnomaliesByNetwork } from '../types';
import { Anomaly } from '../types';
export const createCompoundHostKey = (anomaliesByHost: AnomaliesByHost): string =>
`${anomaliesByHost.hostName}-${anomaliesByHost.anomaly.entityName}-${anomaliesByHost.anomaly.entityValue}-${anomaliesByHost.anomaly.severity}-${anomaliesByHost.anomaly.jobId}`;
export const createCompoundNetworkKey = (anomaliesByNetwork: AnomaliesByNetwork): string =>
`${anomaliesByNetwork.ip}-${anomaliesByNetwork.anomaly.entityName}-${anomaliesByNetwork.anomaly.entityValue}-${anomaliesByNetwork.anomaly.severity}-${anomaliesByNetwork.anomaly.jobId}`;
export const createCompoundAnomalyKey = (anomaly: Anomaly): string =>
`${anomaly.entityName}-${anomaly.entityValue}-${anomaly.severity}-${anomaly.jobId}`;

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { hostEquality } from './host_equality';
import { anomaliesTableDefaultEquality } from './default_equality';
import { AnomaliesHostTableProps } from '../types';
import { HostsType } from '../../../../hosts/store/model';
@ -25,7 +25,7 @@ describe('host_equality', () => {
skip: false,
type: HostsType.details,
};
const equal = hostEquality(prev, next);
const equal = anomaliesTableDefaultEquality(prev, next);
expect(equal).toEqual(true);
});
@ -44,7 +44,7 @@ describe('host_equality', () => {
skip: false,
type: HostsType.details,
};
const equal = hostEquality(prev, next);
const equal = anomaliesTableDefaultEquality(prev, next);
expect(equal).toEqual(false);
});
@ -63,7 +63,7 @@ describe('host_equality', () => {
skip: false,
type: HostsType.details,
};
const equal = hostEquality(prev, next);
const equal = anomaliesTableDefaultEquality(prev, next);
expect(equal).toEqual(false);
});
@ -82,7 +82,7 @@ describe('host_equality', () => {
skip: false,
type: HostsType.details,
};
const equal = hostEquality(prev, next);
const equal = anomaliesTableDefaultEquality(prev, next);
expect(equal).toEqual(false);
});
@ -101,7 +101,7 @@ describe('host_equality', () => {
skip: false,
type: HostsType.details,
};
const equal = hostEquality(prev, next);
const equal = anomaliesTableDefaultEquality(prev, next);
expect(equal).toEqual(false);
});
@ -120,7 +120,7 @@ describe('host_equality', () => {
skip: false,
type: HostsType.details,
};
const equal = hostEquality(prev, next);
const equal = anomaliesTableDefaultEquality(prev, next);
expect(equal).toEqual(false);
});
});

View file

@ -5,11 +5,11 @@
* 2.0.
*/
import { AnomaliesHostTableProps } from '../types';
import { AnomaliesTableCommonProps } from '../types';
export const hostEquality = (
prevProps: AnomaliesHostTableProps,
nextProps: AnomaliesHostTableProps
export const anomaliesTableDefaultEquality = (
prevProps: AnomaliesTableCommonProps,
nextProps: AnomaliesTableCommonProps
): boolean =>
prevProps.startDate === nextProps.startDate &&
prevProps.endDate === nextProps.endDate &&

View file

@ -5,121 +5,35 @@
* 2.0.
*/
import React from 'react';
import '../../../mock/match_media';
import { getAnomaliesHostTableColumnsCurated } from './get_anomalies_host_table_columns';
import { HostsType } from '../../../../hosts/store/model';
import * as i18n from './translations';
import { AnomaliesByHost, Anomaly } from '../types';
import { Columns } from '../../paginated_table';
import { TestProviders } from '../../../mock';
import { useMountAppended } from '../../../utils/use_mount_appended';
jest.mock('../../../lib/kibana');
const startDate = new Date(2001).toISOString();
const endDate = new Date(3000).toISOString();
const interval = 'days';
const narrowDateRange = jest.fn();
describe('get_anomalies_host_table_columns', () => {
const mount = useMountAppended();
describe('get_anomalies_host_table_columns', () => {
test('on hosts page, we expect to get all columns', () => {
expect(
getAnomaliesHostTableColumnsCurated(
HostsType.page,
startDate,
endDate,
interval,
narrowDateRange
).length
).toEqual(6);
expect(getAnomaliesHostTableColumnsCurated(HostsType.page, startDate, endDate).length).toEqual(
6
);
});
test('on host details page, we expect to remove one columns', () => {
const columns = getAnomaliesHostTableColumnsCurated(
HostsType.details,
startDate,
endDate,
interval,
narrowDateRange
);
const columns = getAnomaliesHostTableColumnsCurated(HostsType.details, startDate, endDate);
expect(columns.length).toEqual(5);
});
test('on host page, we should have Host Name', () => {
const columns = getAnomaliesHostTableColumnsCurated(
HostsType.page,
startDate,
endDate,
interval,
narrowDateRange
);
const columns = getAnomaliesHostTableColumnsCurated(HostsType.page, startDate, endDate);
expect(columns.some((col) => col.name === i18n.HOST_NAME)).toEqual(true);
});
test('on host details page, we should not have Host Name', () => {
const columns = getAnomaliesHostTableColumnsCurated(
HostsType.details,
startDate,
endDate,
interval,
narrowDateRange
);
const columns = getAnomaliesHostTableColumnsCurated(HostsType.details, startDate, endDate);
expect(columns.some((col) => col.name === i18n.HOST_NAME)).toEqual(false);
});
test('on host page, undefined influencers should turn into an empty column string', () => {
const columns = getAnomaliesHostTableColumnsCurated(
HostsType.page,
startDate,
endDate,
interval,
narrowDateRange
);
const column = columns.find((col) => col.name === i18n.INFLUENCED_BY) as Columns<
Anomaly['influencers'],
AnomaliesByHost
>;
const anomaly: AnomaliesByHost = {
hostName: 'host.name',
anomaly: {
detectorIndex: 0,
entityName: 'entity-name-1',
entityValue: 'entity-value-1',
jobId: 'job-1',
rowId: 'row-1',
severity: 100,
time: new Date('01/01/2000').valueOf(),
source: {
job_id: 'job-1',
result_type: 'result-1',
probability: 50,
multi_bucket_impact: 0,
record_score: 0,
initial_record_score: 0,
bucket_span: 0,
detector_index: 0,
is_interim: true,
timestamp: new Date('01/01/2000').valueOf(),
by_field_name: 'some field name',
by_field_value: 'some field value',
partition_field_name: 'partition field name',
partition_field_value: 'partition field value',
function: 'function-1',
function_description: 'description-1',
typical: [5, 3],
actual: [7, 4],
influencers: [],
},
},
};
if (column != null && column.render != null) {
const wrapper = mount(<TestProviders>{column.render(undefined, anomaly)}</TestProviders>);
expect(wrapper.text()).toEqual('');
} else {
expect(column).not.toBe(null);
}
});
});

View file

@ -6,27 +6,18 @@
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { Columns } from '../../paginated_table';
import { AnomaliesByHost, Anomaly, NarrowDateRange } from '../types';
import { AnomaliesByHost, Anomaly } from '../types';
import { getRowItemDraggable } from '../../tables/helpers';
import { EntityDraggable } from '../entity_draggable';
import { createCompoundHostKey } from './create_compound_key';
import { createCompoundAnomalyKey } from './create_compound_key';
import { HostDetailsLink } from '../../links';
import * as i18n from './translations';
import { getEntries } from '../get_entries';
import { DraggableScore } from '../score/draggable_score';
import { ExplorerLink } from '../links/create_explorer_link';
import { HostsType } from '../../../../hosts/store/model';
import { escapeDataProviderId } from '../../drag_and_drop/helpers';
import { FormattedRelativePreferenceDate } from '../../formatted_date';
import { getAnomaliesDefaultTableColumns } from './get_anomalies_table_columns';
export const getAnomaliesHostTableColumns = (
startDate: string,
endDate: string,
interval: string,
narrowDateRange: NarrowDateRange
endDate: string
): [
Columns<AnomaliesByHost['hostName'], AnomaliesByHost>,
Columns<Anomaly['severity'], AnomaliesByHost>,
@ -43,100 +34,21 @@ export const getAnomaliesHostTableColumns = (
getRowItemDraggable({
rowItem: hostName,
attrName: 'host.name',
idPrefix: `anomalies-host-table-hostName-${createCompoundHostKey(
anomaliesByHost
idPrefix: `anomalies-host-table-hostName-${createCompoundAnomalyKey(
anomaliesByHost.anomaly
)}-hostName`,
render: (item) => <HostDetailsLink hostName={item} />,
}),
},
{
name: i18n.DETECTOR,
field: 'anomaly.jobId',
sortable: true,
render: (jobId, anomaliesByHost) => (
<ExplorerLink
score={anomaliesByHost.anomaly}
startDate={startDate}
endDate={endDate}
linkName={jobId}
/>
),
},
{
name: i18n.SCORE,
field: 'anomaly.severity',
sortable: true,
render: (_, anomaliesByHost) => (
<DraggableScore
id={escapeDataProviderId(
`anomalies-host-table-severity-${createCompoundHostKey(anomaliesByHost)}`
)}
score={anomaliesByHost.anomaly}
/>
),
},
{
name: i18n.ENTITY,
field: 'anomaly.entityValue',
sortable: true,
render: (entityValue, anomaliesByHost) => (
<EntityDraggable
idPrefix={`anomalies-host-table-entityValue${createCompoundHostKey(
anomaliesByHost
)}-entity`}
entityName={anomaliesByHost.anomaly.entityName}
entityValue={entityValue}
/>
),
},
{
name: i18n.INFLUENCED_BY,
field: 'anomaly.influencers',
render: (influencers, anomaliesByHost) => (
<EuiFlexGroup direction="column" gutterSize="none" responsive={false}>
{influencers &&
influencers.map((influencer) => {
const [key, value] = getEntries(influencer);
const entityName = key != null ? key : '';
const entityValue = value != null ? value : '';
return (
<EuiFlexItem
key={`${entityName}-${entityValue}-${createCompoundHostKey(anomaliesByHost)}`}
grow={false}
>
<EuiFlexGroup gutterSize="none" responsive={false}>
<EuiFlexItem grow={false}>
<EntityDraggable
idPrefix={`anomalies-host-table-influencers-${entityName}-${entityValue}-${createCompoundHostKey(
anomaliesByHost
)}`}
entityName={entityName}
entityValue={entityValue}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);
})}
</EuiFlexGroup>
),
},
{
name: i18n.TIME_STAMP,
field: 'anomaly.time',
sortable: true,
render: (time) => <FormattedRelativePreferenceDate value={time} />,
},
...getAnomaliesDefaultTableColumns(startDate, endDate),
];
export const getAnomaliesHostTableColumnsCurated = (
pageType: HostsType,
startDate: string,
endDate: string,
interval: string,
narrowDateRange: NarrowDateRange
endDate: string
) => {
const columns = getAnomaliesHostTableColumns(startDate, endDate, interval, narrowDateRange);
const columns = getAnomaliesHostTableColumns(startDate, endDate);
// Columns to exclude from host details pages
if (pageType === HostsType.details) {

View file

@ -9,19 +9,12 @@ import '../../../mock/match_media';
import { getAnomaliesNetworkTableColumnsCurated } from './get_anomalies_network_table_columns';
import { NetworkType } from '../../../../network/store/model';
import * as i18n from './translations';
import { AnomaliesByNetwork, Anomaly } from '../types';
import { Columns } from '../../paginated_table';
import React from 'react';
import { TestProviders } from '../../../mock';
import { useMountAppended } from '../../../utils/use_mount_appended';
jest.mock('../../../../common/lib/kibana');
const startDate = new Date(2001).toISOString();
const endDate = new Date(3000).toISOString();
describe('get_anomalies_network_table_columns', () => {
const mount = useMountAppended();
test('on network page, we expect to get all columns', () => {
expect(
getAnomaliesNetworkTableColumnsCurated(NetworkType.page, startDate, endDate).length
@ -42,52 +35,4 @@ describe('get_anomalies_network_table_columns', () => {
const columns = getAnomaliesNetworkTableColumnsCurated(NetworkType.details, startDate, endDate);
expect(columns.some((col) => col.name === i18n.NETWORK_NAME)).toEqual(false);
});
test('on network page, undefined influencers should turn into an empty column string', () => {
const columns = getAnomaliesNetworkTableColumnsCurated(NetworkType.page, startDate, endDate);
const column = columns.find((col) => col.name === i18n.INFLUENCED_BY) as Columns<
Anomaly['influencers'],
AnomaliesByNetwork
>;
const anomaly: AnomaliesByNetwork = {
type: 'source.ip',
ip: '127.0.0.1',
anomaly: {
detectorIndex: 0,
entityName: 'entity-name-1',
entityValue: 'entity-value-1',
jobId: 'job-1',
rowId: 'row-1',
severity: 100,
time: new Date('01/01/2000').valueOf(),
source: {
job_id: 'job-1',
result_type: 'result-1',
probability: 50,
multi_bucket_impact: 0,
record_score: 0,
initial_record_score: 0,
bucket_span: 0,
detector_index: 0,
is_interim: true,
timestamp: new Date('01/01/2000').valueOf(),
by_field_name: 'some field name',
by_field_value: 'some field value',
partition_field_name: 'partition field name',
partition_field_value: 'partition field value',
function: 'function-1',
function_description: 'description-1',
typical: [5, 3],
actual: [7, 4],
influencers: [],
},
},
};
if (column != null && column.render != null) {
const wrapper = mount(<TestProviders>{column.render(undefined, anomaly)}</TestProviders>);
expect(wrapper.text()).toEqual('');
} else {
expect(column).not.toBe(null);
}
});
});

View file

@ -6,23 +6,17 @@
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { Columns } from '../../paginated_table';
import { Anomaly, AnomaliesByNetwork } from '../types';
import { getRowItemDraggable } from '../../tables/helpers';
import { EntityDraggable } from '../entity_draggable';
import { createCompoundNetworkKey } from './create_compound_key';
import { createCompoundAnomalyKey } from './create_compound_key';
import { NetworkDetailsLink } from '../../links';
import * as i18n from './translations';
import { getEntries } from '../get_entries';
import { DraggableScore } from '../score/draggable_score';
import { ExplorerLink } from '../links/create_explorer_link';
import { FormattedRelativePreferenceDate } from '../../formatted_date';
import { NetworkType } from '../../../../network/store/model';
import { escapeDataProviderId } from '../../drag_and_drop/helpers';
import { FlowTarget } from '../../../../../common/search_strategy';
import { getAnomaliesDefaultTableColumns } from './get_anomalies_table_columns';
export const getAnomaliesNetworkTableColumns = (
startDate: string,
@ -44,84 +38,13 @@ export const getAnomaliesNetworkTableColumns = (
getRowItemDraggable({
rowItem: ip,
attrName: anomaliesByNetwork.type,
idPrefix: `anomalies-network-table-ip-${createCompoundNetworkKey(anomaliesByNetwork)}`,
idPrefix: `anomalies-network-table-ip-${createCompoundAnomalyKey(
anomaliesByNetwork.anomaly
)}`,
render: (item) => <NetworkDetailsLink ip={item} flowTarget={flowTarget} />,
}),
},
{
name: i18n.DETECTOR,
field: 'anomaly.jobId',
sortable: true,
render: (jobId, anomaliesByHost) => (
<ExplorerLink
score={anomaliesByHost.anomaly}
startDate={startDate}
endDate={endDate}
linkName={jobId}
/>
),
},
{
name: i18n.SCORE,
field: 'anomaly.severity',
sortable: true,
render: (_, anomaliesByNetwork) => (
<DraggableScore
id={escapeDataProviderId(
`anomalies-network-table-severity-${createCompoundNetworkKey(anomaliesByNetwork)}`
)}
score={anomaliesByNetwork.anomaly}
/>
),
},
{
name: i18n.ENTITY,
field: 'anomaly.entityValue',
sortable: true,
render: (entityValue, anomaliesByNetwork) => (
<EntityDraggable
idPrefix={`anomalies-network-table-entityValue-${createCompoundNetworkKey(
anomaliesByNetwork
)}`}
entityName={anomaliesByNetwork.anomaly.entityName}
entityValue={entityValue}
/>
),
},
{
name: i18n.INFLUENCED_BY,
field: 'anomaly.influencers',
render: (influencers, anomaliesByNetwork) => (
<EuiFlexGroup direction="column" gutterSize="none" responsive={false}>
{influencers &&
influencers.map((influencer) => {
const [key, value] = getEntries(influencer);
const entityName = key != null ? key : '';
const entityValue = value != null ? value : '';
return (
<EuiFlexItem
key={`${entityName}-${entityValue}-${createCompoundNetworkKey(anomaliesByNetwork)}`}
grow={false}
>
<EntityDraggable
idPrefix={`anomalies-network-table-influencers-${entityName}-${entityValue}-${createCompoundNetworkKey(
anomaliesByNetwork
)}`}
entityName={entityName}
entityValue={entityValue}
/>
</EuiFlexItem>
);
})}
</EuiFlexGroup>
),
},
{
name: i18n.TIME_STAMP,
field: 'anomaly.time',
sortable: true,
render: (time) => <FormattedRelativePreferenceDate value={time} />,
},
...getAnomaliesDefaultTableColumns(startDate, endDate),
];
export const getAnomaliesNetworkTableColumnsCurated = (

View file

@ -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 '../../../mock/match_media';
import * as i18n from './translations';
import { AnomaliesBy, Anomaly } from '../types';
import { Columns } from '../../paginated_table';
import React from 'react';
import { TestProviders } from '../../../mock';
import { useMountAppended } from '../../../utils/use_mount_appended';
import { getAnomaliesDefaultTableColumns } from './get_anomalies_table_columns';
jest.mock('../../../../common/lib/kibana');
const startDate = new Date(2001).toISOString();
const endDate = new Date(3000).toISOString();
describe('getAnomaliesDefaultTableColumns', () => {
const mount = useMountAppended();
test('it should return all columns', () => {
expect(getAnomaliesDefaultTableColumns(startDate, endDate).length).toEqual(5);
});
test('it should return an empty column string for undefined influencers', () => {
const columns = getAnomaliesDefaultTableColumns(startDate, endDate);
const column = columns.find((col) => col.name === i18n.INFLUENCED_BY) as Columns<
Anomaly['influencers'],
AnomaliesBy
>;
const anomaly: AnomaliesBy = {
anomaly: {
detectorIndex: 0,
entityName: 'entity-name-1',
entityValue: 'entity-value-1',
jobId: 'job-1',
rowId: 'row-1',
severity: 100,
time: new Date('01/01/2000').valueOf(),
source: {
job_id: 'job-1',
result_type: 'result-1',
probability: 50,
multi_bucket_impact: 0,
record_score: 0,
initial_record_score: 0,
bucket_span: 0,
detector_index: 0,
is_interim: true,
timestamp: new Date('01/01/2000').valueOf(),
by_field_name: 'some field name',
by_field_value: 'some field value',
partition_field_name: 'partition field name',
partition_field_value: 'partition field value',
function: 'function-1',
function_description: 'description-1',
typical: [5, 3],
actual: [7, 4],
influencers: [],
},
},
};
if (column != null && column.render != null) {
const wrapper = mount(<TestProviders>{column.render(undefined, anomaly)}</TestProviders>);
expect(wrapper.text()).toEqual('');
} else {
expect(column).not.toBe(null);
}
});
});

View file

@ -0,0 +1,111 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { Columns } from '../../paginated_table';
import { AnomaliesBy, Anomaly } from '../types';
import { EntityDraggable } from '../entity_draggable';
import { createCompoundAnomalyKey } from './create_compound_key';
import * as i18n from './translations';
import { getEntries } from '../get_entries';
import { DraggableScore } from '../score/draggable_score';
import { ExplorerLink } from '../links/create_explorer_link';
import { escapeDataProviderId } from '../../drag_and_drop/helpers';
import { FormattedRelativePreferenceDate } from '../../formatted_date';
export const getAnomaliesDefaultTableColumns = (
startDate: string,
endDate: string
): [
Columns<Anomaly['severity'], AnomaliesBy>,
Columns<Anomaly['jobId'], AnomaliesBy>,
Columns<Anomaly['entityValue'], AnomaliesBy>,
Columns<Anomaly['influencers'], AnomaliesBy>,
Columns<Anomaly['time'], AnomaliesBy>
] => [
{
name: i18n.DETECTOR,
field: 'anomaly.jobId',
sortable: true,
render: (jobId, anomalyBy) => (
<ExplorerLink
score={anomalyBy.anomaly}
startDate={startDate}
endDate={endDate}
linkName={jobId}
/>
),
},
{
name: i18n.SCORE,
field: 'anomaly.severity',
sortable: true,
render: (_, anomalyBy) => (
<DraggableScore
id={escapeDataProviderId(
`anomalies-table-severity-${createCompoundAnomalyKey(anomalyBy.anomaly)}`
)}
score={anomalyBy.anomaly}
/>
),
},
{
name: i18n.ENTITY,
field: 'anomaly.entityValue',
sortable: true,
render: (entityValue, anomalyBy) => (
<EntityDraggable
idPrefix={`anomalies-table-entityValue${createCompoundAnomalyKey(
anomalyBy.anomaly
)}-entity`}
entityName={anomalyBy.anomaly.entityName}
entityValue={entityValue}
/>
),
},
{
name: i18n.INFLUENCED_BY,
field: 'anomaly.influencers',
render: (influencers, anomalyBy) => (
<EuiFlexGroup direction="column" gutterSize="none" responsive={false}>
{influencers &&
influencers.map((influencer) => {
const [key, value] = getEntries(influencer);
const entityName = key != null ? key : '';
const entityValue = value != null ? value : '';
return (
<EuiFlexItem
key={`${entityName}-${entityValue}-${createCompoundAnomalyKey(anomalyBy.anomaly)}`}
grow={false}
>
<EuiFlexGroup gutterSize="none" responsive={false}>
<EuiFlexItem grow={false}>
<EntityDraggable
idPrefix={`anomalies-table-influencers-${entityName}-${entityValue}-${createCompoundAnomalyKey(
anomalyBy.anomaly
)}`}
entityName={entityName}
entityValue={entityValue}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);
})}
</EuiFlexGroup>
),
},
{
name: i18n.TIME_STAMP,
field: 'anomaly.time',
sortable: true,
render: (time) => <FormattedRelativePreferenceDate value={time} />,
},
];

View file

@ -0,0 +1,40 @@
/*
* 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 { UsersType } from '../../../../users/store/model';
import '../../../mock/match_media';
import { getAnomaliesUserTableColumnsCurated } from './get_anomalies_user_table_columns';
import * as i18n from './translations';
jest.mock('../../../lib/kibana');
const startDate = new Date(2001).toISOString();
const endDate = new Date(3000).toISOString();
describe('get_anomalies_user_table_columns', () => {
test('on users page, we expect to get all columns', () => {
expect(getAnomaliesUserTableColumnsCurated(UsersType.page, startDate, endDate).length).toEqual(
6
);
});
test('on user details page, we expect to remove one columns', () => {
const columns = getAnomaliesUserTableColumnsCurated(UsersType.details, startDate, endDate);
expect(columns.length).toEqual(5);
});
test('on users page, we should have User Name', () => {
const columns = getAnomaliesUserTableColumnsCurated(UsersType.page, startDate, endDate);
expect(columns.some((col) => col.name === i18n.USER_NAME)).toEqual(true);
});
test('on user details page, we should not have User Name', () => {
const columns = getAnomaliesUserTableColumnsCurated(UsersType.details, startDate, endDate);
expect(columns.some((col) => col.name === i18n.USER_NAME)).toEqual(false);
});
});

View file

@ -0,0 +1,60 @@
/*
* 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 { Columns } from '../../paginated_table';
import { AnomaliesByUser, Anomaly } from '../types';
import { getRowItemDraggable } from '../../tables/helpers';
import { createCompoundAnomalyKey } from './create_compound_key';
import { UserDetailsLink } from '../../links';
import * as i18n from './translations';
import { UsersType } from '../../../../users/store/model';
import { getAnomaliesDefaultTableColumns } from './get_anomalies_table_columns';
export const getAnomaliesUserTableColumns = (
startDate: string,
endDate: string
): [
Columns<AnomaliesByUser['userName'], AnomaliesByUser>,
Columns<Anomaly['severity'], AnomaliesByUser>,
Columns<Anomaly['jobId'], AnomaliesByUser>,
Columns<Anomaly['entityValue'], AnomaliesByUser>,
Columns<Anomaly['influencers'], AnomaliesByUser>,
Columns<Anomaly['time'], AnomaliesByUser>
] => [
{
name: i18n.USER_NAME,
field: 'userName',
sortable: true,
render: (userName, anomaliesByUser) =>
getRowItemDraggable({
rowItem: userName,
attrName: 'user.name',
idPrefix: `anomalies-user-table-userName-${createCompoundAnomalyKey(
anomaliesByUser.anomaly
)}-userName`,
render: (item) => <UserDetailsLink userName={item} />,
}),
},
...getAnomaliesDefaultTableColumns(startDate, endDate),
];
export const getAnomaliesUserTableColumnsCurated = (
pageType: UsersType,
startDate: string,
endDate: string
) => {
const columns = getAnomaliesUserTableColumns(startDate, endDate);
// Columns to exclude from user details pages
if (pageType === UsersType.details) {
return columns.filter((column) => column.name !== i18n.USER_NAME);
} else {
return columns;
}
};

View file

@ -6,12 +6,11 @@
*/
import { AnomaliesNetworkTableProps } from '../types';
import { anomaliesTableDefaultEquality } from './default_equality';
export const networkEquality = (
prevProps: AnomaliesNetworkTableProps,
nextProps: AnomaliesNetworkTableProps
): boolean =>
prevProps.startDate === nextProps.startDate &&
prevProps.endDate === nextProps.endDate &&
prevProps.skip === nextProps.skip &&
anomaliesTableDefaultEquality(prevProps, nextProps) &&
prevProps.flowTarget === nextProps.flowTarget;

View file

@ -42,6 +42,10 @@ export const HOST_NAME = i18n.translate('xpack.securitySolution.ml.table.hostNam
defaultMessage: 'Host name',
});
export const USER_NAME = i18n.translate('xpack.securitySolution.ml.table.userNameTitle', {
defaultMessage: 'User name',
});
export const INFLUENCED_BY = i18n.translate('xpack.securitySolution.ml.table.influencedByTitle', {
defaultMessage: 'Influenced by',
});

View file

@ -10,6 +10,7 @@ import { FlowTarget } from '../../../../common/search_strategy';
import { HostsType } from '../../../hosts/store/model';
import { NetworkType } from '../../../network/store/model';
import { UsersType } from '../../../users/store/model';
export interface Source {
job_id: string;
@ -62,37 +63,48 @@ export interface Anomalies {
export type NarrowDateRange = (score: Anomaly, interval: string) => void;
export interface AnomaliesByHost {
hostName: string;
export interface AnomaliesBy {
anomaly: Anomaly;
}
export interface AnomaliesByHost extends AnomaliesBy {
hostName: string;
}
export type DestinationOrSource = 'source.ip' | 'destination.ip';
export interface AnomaliesByNetwork {
export interface AnomaliesByNetwork extends AnomaliesBy {
type: DestinationOrSource;
ip: string;
anomaly: Anomaly;
}
export interface HostOrNetworkProps {
export interface AnomaliesByUser extends AnomaliesBy {
userName: string;
}
export interface AnomaliesTableCommonProps {
startDate: string;
endDate: string;
narrowDateRange: NarrowDateRange;
skip: boolean;
}
export type AnomaliesHostTableProps = HostOrNetworkProps & {
export type AnomaliesHostTableProps = AnomaliesTableCommonProps & {
hostName?: string;
type: HostsType;
};
export type AnomaliesNetworkTableProps = HostOrNetworkProps & {
export type AnomaliesNetworkTableProps = AnomaliesTableCommonProps & {
ip?: string;
type: NetworkType;
flowTarget?: FlowTarget;
};
export type AnomaliesUserTableProps = AnomaliesTableCommonProps & {
userName?: string;
type: UsersType;
};
const sourceOrDestination = ['source.ip', 'destination.ip'];
export const isDestinationOrSource = (value: string | null): value is DestinationOrSource =>

View file

@ -12,9 +12,10 @@ import { GlobalTimeArgs } from '../../use_global_time';
import { HostsType } from '../../../../hosts/store/model';
import { NetworkType } from '../../../../network/store//model';
import { FlowTarget } from '../../../../../common/search_strategy';
import { UsersType } from '../../../../users/store/model';
interface QueryTabBodyProps {
type: HostsType | NetworkType;
type: HostsType | NetworkType | UsersType;
filterQuery?: string | ESTermQuery;
}

View file

@ -205,6 +205,7 @@ export const mockGlobalState: State = {
limit: 10,
// TODO sort: { field: RiskScoreFields.riskScore, direction: Direction.desc },
},
[usersModel.UsersTableType.anomalies]: null,
},
},
details: {
@ -214,6 +215,7 @@ export const mockGlobalState: State = {
limit: 10,
// TODO sort: { field: HostRulesFields.riskScore, direction: Direction.desc },
},
[usersModel.UsersTableType.anomalies]: null,
},
},
},

View file

@ -0,0 +1,24 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/x-pack/plugins/security_solution/public/users'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/plugins/security_solution/public/users',
coverageReporters: ['text', 'html'],
collectCoverageFrom: ['<rootDir>/x-pack/plugins/security_solution/public/users/**/*.{ts,tsx}'],
// See: https://github.com/elastic/kibana/issues/117255, the moduleNameMapper creates mocks to avoid memory leaks from kibana core.
moduleNameMapper: {
'core/server$': '<rootDir>/x-pack/plugins/security_solution/server/__mocks__/core.mock.ts',
'task_manager/server$':
'<rootDir>/x-pack/plugins/security_solution/server/__mocks__/task_manager.mock.ts',
'alerting/server$': '<rootDir>/x-pack/plugins/security_solution/server/__mocks__/alert.mock.ts',
'actions/server$': '<rootDir>/x-pack/plugins/security_solution/server/__mocks__/action.mock.ts',
},
};

View file

@ -10,6 +10,6 @@ import { UsersTableType } from '../store/model';
export const usersDetailsPagePath = `${USERS_PATH}/:detailName`;
export const usersTabPath = `${USERS_PATH}/:tabName(${UsersTableType.allUsers})`;
export const usersTabPath = `${USERS_PATH}/:tabName(${UsersTableType.allUsers}|${UsersTableType.anomalies})`;
export const usersDetailsTabPath = `${usersDetailsPagePath}/:tabName(${UsersTableType.allUsers})`;

View file

@ -17,7 +17,7 @@ export const navTabsUsersDetails = (hostName: string): UsersDetailsNavTab => {
return {
[UsersTableType.allUsers]: {
id: UsersTableType.allUsers,
name: i18n.ALL_USERS_TITLE,
name: i18n.NAVIGATION_ALL_USERS_TITLE,
href: getTabsOnUsersDetailsUrl(hostName, UsersTableType.allUsers),
disabled: false,
},

View file

@ -21,7 +21,8 @@ import { SecurityPageName } from '../../../app/types';
export const type = usersModel.UsersType.details;
const TabNameMappedToI18nKey: Record<UsersTableType, string> = {
[UsersTableType.allUsers]: i18n.ALL_USERS_TITLE,
[UsersTableType.allUsers]: i18n.NAVIGATION_ALL_USERS_TITLE,
[UsersTableType.anomalies]: i18n.NAVIGATION_ANOMALIES_TITLE,
};
export const getBreadcrumbs = (

View file

@ -15,8 +15,14 @@ const getTabsOnUsersUrl = (tabName: UsersTableType) => `${USERS_PATH}/${tabName}
export const navTabsUsers: UsersNavTab = {
[UsersTableType.allUsers]: {
id: UsersTableType.allUsers,
name: i18n.ALL_USERS_TITLE,
name: i18n.NAVIGATION_ALL_USERS_TITLE,
href: getTabsOnUsersUrl(UsersTableType.allUsers),
disabled: false,
},
[UsersTableType.anomalies]: {
id: UsersTableType.anomalies,
name: i18n.NAVIGATION_ANOMALIES_TITLE,
href: getTabsOnUsersUrl(UsersTableType.anomalies),
disabled: false,
},
};

View file

@ -10,7 +10,7 @@ import { ESTermQuery } from '../../../../common/typed_json';
import { DocValueFields } from '../../../../../timelines/common';
import { NavTab } from '../../../common/components/navigation/types';
type KeyUsersNavTab = UsersTableType.allUsers;
type KeyUsersNavTab = UsersTableType.allUsers | UsersTableType.anomalies;
export type UsersNavTab = Record<KeyUsersNavTab, NavTab>;
export interface QueryTabBodyProps {

View file

@ -11,6 +11,16 @@ export const PAGE_TITLE = i18n.translate('xpack.securitySolution.users.pageTitle
defaultMessage: 'Users',
});
export const ALL_USERS_TITLE = i18n.translate('xpack.securitySolution.users.allUsers', {
defaultMessage: 'All users',
});
export const NAVIGATION_ALL_USERS_TITLE = i18n.translate(
'xpack.securitySolution.users.navigation.allUsersTitle',
{
defaultMessage: 'All users',
}
);
export const NAVIGATION_ANOMALIES_TITLE = i18n.translate(
'xpack.securitySolution.users.navigation.anomaliesTitle',
{
defaultMessage: 'Anomalies',
}
);

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 { mount } from 'enzyme';
import React from 'react';
import { Router } from 'react-router-dom';
import '../../common/mock/match_media';
import { TestProviders } from '../../common/mock';
import { SecuritySolutionTabNavigation } from '../../common/components/navigation';
import { Users } from './users';
import { useSourcererDataView } from '../../common/containers/sourcerer';
jest.mock('../../common/containers/sourcerer');
jest.mock('../../common/components/search_bar', () => ({
SiemSearchBar: () => null,
}));
jest.mock('../../common/components/query_bar', () => ({
QueryBar: () => null,
}));
type Action = 'PUSH' | 'POP' | 'REPLACE';
const pop: Action = 'POP';
const location = {
pathname: '/network',
search: '',
state: '',
hash: '',
};
const mockHistory = {
length: 2,
location,
action: pop,
push: jest.fn(),
replace: jest.fn(),
go: jest.fn(),
goBack: jest.fn(),
goForward: jest.fn(),
block: jest.fn(),
createHref: jest.fn(),
listen: jest.fn(),
};
const mockUseSourcererDataView = useSourcererDataView as jest.Mock;
describe('Users - rendering', () => {
test('it renders the Setup Instructions text when no index is available', async () => {
mockUseSourcererDataView.mockReturnValue({
indicesExist: false,
});
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<Users />
</Router>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(true);
});
test('it should render tab navigation', async () => {
mockUseSourcererDataView.mockReturnValue({
indicesExist: true,
indexPattern: {},
});
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<Users />
</Router>
</TestProviders>
);
expect(wrapper.find(SecuritySolutionTabNavigation).exists()).toBe(true);
});
});

View file

@ -5,18 +5,22 @@
* 2.0.
*/
import React, { memo } from 'react';
import React, { memo, useCallback } from 'react';
import { Route, Switch } from 'react-router-dom';
import { UsersTabsProps } from './types';
import { UsersTableType } from '../store/model';
import { USERS_PATH } from '../../../common/constants';
import { AllUsersQueryTabBody } from './navigation';
import { AnomaliesQueryTabBody } from '../../common/containers/anomalies/anomalies_query_tab_body';
import { AnomaliesUserTable } from '../../common/components/ml/tables/anomalies_user_table';
import { Anomaly } from '../../common/components/ml/types';
import { scoreIntervalToDateTime } from '../../common/components/ml/score/score_interval_to_datetime';
import { UpdateDateRange } from '../../common/components/charts/common';
export const UsersTabs = memo<UsersTabsProps>(
({
deleteQuery,
docValueFields,
filterQuery,
from,
indexNames,
@ -24,21 +28,55 @@ export const UsersTabs = memo<UsersTabsProps>(
setQuery,
to,
type,
setAbsoluteRangeDatePicker,
}) => {
const narrowDateRange = useCallback(
(score: Anomaly, interval: string) => {
const fromTo = scoreIntervalToDateTime(score, interval);
setAbsoluteRangeDatePicker({
id: 'global',
from: fromTo.from,
to: fromTo.to,
});
},
[setAbsoluteRangeDatePicker]
);
const updateDateRange = useCallback<UpdateDateRange>(
({ x }) => {
if (!x) {
return;
}
const [min, max] = x;
setAbsoluteRangeDatePicker({
id: 'global',
from: new Date(min).toISOString(),
to: new Date(max).toISOString(),
});
},
[setAbsoluteRangeDatePicker]
);
const tabProps = {
deleteQuery,
endDate: to,
filterQuery,
indexNames,
skip: isInitializing || filterQuery === undefined,
setQuery,
startDate: from,
type,
narrowDateRange,
updateDateRange,
};
return (
<Switch>
<Route path={`${USERS_PATH}/:tabName(${UsersTableType.allUsers})`}>
<AllUsersQueryTabBody
deleteQuery={deleteQuery}
endDate={to}
filterQuery={filterQuery}
indexNames={indexNames}
skip={isInitializing || filterQuery === undefined}
setQuery={setQuery}
startDate={from}
type={type}
docValueFields={docValueFields}
/>
<AllUsersQueryTabBody {...tabProps} />
</Route>
<Route path={`${USERS_PATH}/:tabName(${UsersTableType.anomalies})`}>
<AnomaliesQueryTabBody {...tabProps} AnomaliesTableComponent={AnomaliesUserTable} />
</Route>
</Switch>
);

View file

@ -12,6 +12,7 @@ export enum UsersType {
export enum UsersTableType {
allUsers = 'allUsers',
anomalies = 'anomalies',
}
export type AllUsersTables = UsersTableType;
@ -32,6 +33,7 @@ export interface TableUpdates {
export interface UsersQueries {
[UsersTableType.allUsers]: AllUsersQuery;
[UsersTableType.anomalies]: null | undefined;
}
export interface UsersPageModel {

View file

@ -17,6 +17,7 @@ import {
} from './actions';
import { setUsersPageQueriesActivePageToZero } from './helpers';
import { UsersTableType, UsersModel } from './model';
import { HostsTableType } from '../../hosts/store/model';
export const initialUsersState: UsersModel = {
page: {
@ -24,12 +25,8 @@ export const initialUsersState: UsersModel = {
[UsersTableType.allUsers]: {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
limit: DEFAULT_TABLE_LIMIT,
// TODO Fix me
// sort: {
// field: AllUsersFields.allUsers,
// direction: Direction.desc,
// },
},
[HostsTableType.anomalies]: null,
},
},
details: {
@ -37,12 +34,8 @@ export const initialUsersState: UsersModel = {
[UsersTableType.allUsers]: {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
limit: DEFAULT_TABLE_LIMIT,
// TODO Fix me
// sort: {
// field: HostRulesFields.riskScore,
// direction: Direction.desc,
// },
},
[HostsTableType.anomalies]: null,
},
},
};
@ -75,7 +68,6 @@ export const usersReducer = reducerWithInitialState(initialUsersState)
queries: {
...state[usersType].queries,
[tableType]: {
// TODO: Steph/users fix active page/limit on users tables. is broken because multiple UsersTableType.userRules tables
...state[usersType].queries[tableType],
activePage,
},
@ -89,7 +81,6 @@ export const usersReducer = reducerWithInitialState(initialUsersState)
queries: {
...state[usersType].queries,
[tableType]: {
// TODO: Steph/users fix active page/limit on users tables. is broken because multiple UsersTableType.userRules tables
...state[usersType].queries[tableType],
limit,
},