[SIEM] ML Rules Details (#61182) (#61200)

* Add basic help text to ML Job dropdown on Rule form

* Use EUI's preferred layout for form fields

* Add a link to ML in the Job select help text

* Restrict timeline picker to EUI guidelines

Don't display the row as fullwidth, lest the help text wrap across the
entire page. It only looks okay now because it was a short sentence;
adding the ML Job select with its wrapped text caused some visual
weirdness, so this at least makes it consistent.

* Add placeholder option to ML Job dropdown

* Humanize rule type on Rule Description component

This is displayed both on the readonly form view, and the Rule Details
page.

* Add useMlCapabilities hook

This is a base hook that we can combine with our permissions helpers.

* Restrict ML Rule creation to ML Admins

If we're auto-activating jobs on their behalf, they'll need to be an
admin.

* Extract ML Job status helpers to separate file

* WIP: Enrich Rule Description with ML Job Data

This adds the auditMessage as well as a link to ML; actual status is
next

* Display job status as a badge on Rule Details

Also simplifies the layout of these job details.

* Port helper tests to new location

* Fix DescriptionStep tests now that they use useSiemJobs

UseSiemJobs uses uiSettings, so we need to use our kibana mocks here.

* Fix responsiveness of ML Rule Details

The long job names were causing the panel to overflow.
This commit is contained in:
Ryland Herrick 2020-03-24 23:29:16 -05:00 committed by GitHub
parent 76295f3772
commit 99678091af
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 386 additions and 137 deletions

View file

@ -4,12 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { useState, useEffect, useContext } from 'react';
import { useState, useEffect } from 'react';
import { anomaliesTableData } from '../api/anomalies_table_data';
import { InfluencerInput, Anomalies, CriteriaFields } from '../types';
import { hasMlUserPermissions } from '../permissions/has_ml_user_permissions';
import { MlCapabilitiesContext } from '../permissions/ml_capabilities_provider';
import { useSiemJobs } from '../../ml_popover/hooks/use_siem_jobs';
import { useMlCapabilities } from '../../ml_popover/hooks/use_ml_capabilities';
import { useStateToaster, errorToToaster } from '../../toasters';
import * as i18n from './translations';
@ -59,7 +59,7 @@ export const useAnomaliesTableData = ({
const [tableData, setTableData] = useState<Anomalies | null>(null);
const [, siemJobs] = useSiemJobs(true);
const [loading, setLoading] = useState(true);
const capabilities = useContext(MlCapabilitiesContext);
const capabilities = useMlCapabilities();
const userPermissions = hasMlUserPermissions(capabilities);
const [, dispatchToaster] = useStateToaster();
const timeZone = useTimeZone();

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { isJobStarted, isJobLoading, isJobFailed } from './';
describe('isJobStarted', () => {
test('returns false if only jobState is enabled', () => {
expect(isJobStarted('started', 'closing')).toBe(false);
});
test('returns false if only datafeedState is enabled', () => {
expect(isJobStarted('stopping', 'opened')).toBe(false);
});
test('returns true if both enabled states are provided', () => {
expect(isJobStarted('started', 'opened')).toBe(true);
});
});
describe('isJobLoading', () => {
test('returns true if both loading states are not provided', () => {
expect(isJobLoading('started', 'closing')).toBe(true);
});
test('returns true if only jobState is loading', () => {
expect(isJobLoading('starting', 'opened')).toBe(true);
});
test('returns true if only datafeedState is loading', () => {
expect(isJobLoading('started', 'opening')).toBe(true);
});
test('returns false if both disabling states are provided', () => {
expect(isJobLoading('stopping', 'closing')).toBe(true);
});
});
describe('isJobFailed', () => {
test('returns true if only jobState is failure/deleted', () => {
expect(isJobFailed('failed', 'stopping')).toBe(true);
});
test('returns true if only dataFeed is failure/deleted', () => {
expect(isJobFailed('started', 'deleted')).toBe(true);
});
test('returns true if both enabled states are failure/deleted', () => {
expect(isJobFailed('failed', 'deleted')).toBe(true);
});
test('returns false only if both states are not failure/deleted', () => {
expect(isJobFailed('opened', 'stopping')).toBe(false);
});
});

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
// Based on ML Job/Datafeed States from x-pack/legacy/plugins/ml/common/constants/states.js
const enabledStates = ['started', 'opened'];
const loadingStates = ['starting', 'stopping', 'opening', 'closing'];
const failureStates = ['deleted', 'failed'];
export const isJobStarted = (jobState: string, datafeedState: string): boolean => {
return enabledStates.includes(jobState) && enabledStates.includes(datafeedState);
};
export const isJobLoading = (jobState: string, datafeedState: string): boolean => {
return loadingStates.includes(jobState) || loadingStates.includes(datafeedState);
};
export const isJobFailed = (jobState: string, datafeedState: string): boolean => {
return failureStates.includes(jobState) || failureStates.includes(datafeedState);
};

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useContext } from 'react';
import React from 'react';
import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data';
import { HeaderSection } from '../../header_section';
@ -16,7 +16,7 @@ import { Loader } from '../../loader';
import { getIntervalFromAnomalies } from '../anomaly/get_interval_from_anomalies';
import { AnomaliesHostTableProps } from '../types';
import { hasMlUserPermissions } from '../permissions/has_ml_user_permissions';
import { MlCapabilitiesContext } from '../permissions/ml_capabilities_provider';
import { useMlCapabilities } from '../../ml_popover/hooks/use_ml_capabilities';
import { BasicTable } from './basic_table';
import { hostEquality } from './host_equality';
import { getCriteriaFromHostType } from '../criteria/get_criteria_from_host_type';
@ -37,7 +37,7 @@ const AnomaliesHostTableComponent: React.FC<AnomaliesHostTableProps> = ({
skip,
type,
}) => {
const capabilities = useContext(MlCapabilitiesContext);
const capabilities = useMlCapabilities();
const [loading, tableData] = useAnomaliesTableData({
startDate,
endDate,

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useContext } from 'react';
import React from 'react';
import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data';
import { HeaderSection } from '../../header_section';
@ -13,8 +13,8 @@ import { convertAnomaliesToNetwork } from './convert_anomalies_to_network';
import { Loader } from '../../loader';
import { AnomaliesNetworkTableProps } from '../types';
import { getAnomaliesNetworkTableColumnsCurated } from './get_anomalies_network_table_columns';
import { useMlCapabilities } from '../../ml_popover/hooks/use_ml_capabilities';
import { hasMlUserPermissions } from '../permissions/has_ml_user_permissions';
import { MlCapabilitiesContext } from '../permissions/ml_capabilities_provider';
import { BasicTable } from './basic_table';
import { networkEquality } from './network_equality';
import { getCriteriaFromNetworkType } from '../criteria/get_criteria_from_network_type';
@ -35,7 +35,7 @@ const AnomaliesNetworkTableComponent: React.FC<AnomaliesNetworkTableProps> = ({
type,
flowTarget,
}) => {
const capabilities = useContext(MlCapabilitiesContext);
const capabilities = useMlCapabilities();
const [loading, tableData] = useAnomaliesTableData({
startDate,
endDate,

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useContext } from 'react';
import { MlCapabilitiesContext } from '../../ml/permissions/ml_capabilities_provider';
export const useMlCapabilities = () => useContext(MlCapabilitiesContext);

View file

@ -4,18 +4,18 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { useContext, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { checkRecognizer, getJobsSummary, getModules } from '../api';
import { SiemJob } from '../types';
import { hasMlUserPermissions } from '../../ml/permissions/has_ml_user_permissions';
import { MlCapabilitiesContext } from '../../ml/permissions/ml_capabilities_provider';
import { errorToToaster, useStateToaster } from '../../toasters';
import { useUiSetting$ } from '../../../lib/kibana';
import { DEFAULT_INDEX_KEY } from '../../../../common/constants';
import * as i18n from './translations';
import { createSiemJobs } from './use_siem_jobs_helpers';
import { useMlCapabilities } from './use_ml_capabilities';
type Return = [boolean, SiemJob[]];
@ -30,8 +30,8 @@ type Return = [boolean, SiemJob[]];
export const useSiemJobs = (refetchData: boolean): Return => {
const [siemJobs, setSiemJobs] = useState<SiemJob[]>([]);
const [loading, setLoading] = useState(true);
const capabilities = useContext(MlCapabilitiesContext);
const userPermissions = hasMlUserPermissions(capabilities);
const mlCapabilities = useMlCapabilities();
const userPermissions = hasMlUserPermissions(mlCapabilities);
const [siemDefaultIndex] = useUiSetting$<string[]>(DEFAULT_INDEX_KEY);
const [, dispatchToaster] = useStateToaster();

View file

@ -7,7 +7,7 @@
import { shallow, mount } from 'enzyme';
import React from 'react';
import { isChecked, isFailure, isJobLoading, JobSwitchComponent } from './job_switch';
import { JobSwitchComponent } from './job_switch';
import { cloneDeep } from 'lodash/fp';
import { mockSiemJobs } from '../__mocks__/api';
import { SiemJob } from '../types';
@ -75,54 +75,4 @@ describe('JobSwitch', () => {
);
expect(wrapper.find('[data-test-subj="job-switch"]').exists()).toBe(false);
});
describe('isChecked', () => {
test('returns false if only jobState is enabled', () => {
expect(isChecked('started', 'closing')).toBe(false);
});
test('returns false if only datafeedState is enabled', () => {
expect(isChecked('stopping', 'opened')).toBe(false);
});
test('returns true if both enabled states are provided', () => {
expect(isChecked('started', 'opened')).toBe(true);
});
});
describe('isJobLoading', () => {
test('returns true if both loading states are not provided', () => {
expect(isJobLoading('started', 'closing')).toBe(true);
});
test('returns true if only jobState is loading', () => {
expect(isJobLoading('starting', 'opened')).toBe(true);
});
test('returns true if only datafeedState is loading', () => {
expect(isJobLoading('started', 'opening')).toBe(true);
});
test('returns false if both disabling states are provided', () => {
expect(isJobLoading('stopping', 'closing')).toBe(true);
});
});
describe('isFailure', () => {
test('returns true if only jobState is failure/deleted', () => {
expect(isFailure('failed', 'stopping')).toBe(true);
});
test('returns true if only dataFeed is failure/deleted', () => {
expect(isFailure('started', 'deleted')).toBe(true);
});
test('returns true if both enabled states are failure/deleted', () => {
expect(isFailure('failed', 'deleted')).toBe(true);
});
test('returns false only if both states are not failure/deleted', () => {
expect(isFailure('opened', 'stopping')).toBe(false);
});
});
});

View file

@ -8,6 +8,7 @@ import styled from 'styled-components';
import React, { useState, useCallback } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSwitch } from '@elastic/eui';
import { SiemJob } from '../types';
import { isJobLoading, isJobStarted, isJobFailed } from '../../ml/helpers';
const StaticSwitch = styled(EuiSwitch)`
.euiSwitch__thumb,
@ -24,23 +25,6 @@ export interface JobSwitchProps {
onJobStateChange: (job: SiemJob, latestTimestampMs: number, enable: boolean) => Promise<void>;
}
// Based on ML Job/Datafeed States from x-pack/legacy/plugins/ml/common/constants/states.js
const enabledStates = ['started', 'opened'];
const loadingStates = ['starting', 'stopping', 'opening', 'closing'];
const failureStates = ['deleted', 'failed'];
export const isChecked = (jobState: string, datafeedState: string): boolean => {
return enabledStates.includes(jobState) && enabledStates.includes(datafeedState);
};
export const isJobLoading = (jobState: string, datafeedState: string): boolean => {
return loadingStates.includes(jobState) || loadingStates.includes(datafeedState);
};
export const isFailure = (jobState: string, datafeedState: string): boolean => {
return failureStates.includes(jobState) || failureStates.includes(datafeedState);
};
export const JobSwitchComponent = ({
job,
isSiemJobsLoading,
@ -64,8 +48,8 @@ export const JobSwitchComponent = ({
) : (
<StaticSwitch
data-test-subj="job-switch"
disabled={isFailure(job.jobState, job.datafeedState)}
checked={isChecked(job.jobState, job.datafeedState)}
disabled={isJobFailed(job.jobState, job.datafeedState)}
checked={isJobStarted(job.jobState, job.datafeedState)}
onChange={handleChange}
showLabel={false}
label=""

View file

@ -7,13 +7,12 @@
import { EuiButtonEmpty, EuiCallOut, EuiPopover, EuiPopoverTitle, EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import moment from 'moment';
import React, { useContext, useReducer, useState } from 'react';
import React, { useReducer, useState } from 'react';
import styled from 'styled-components';
import { useKibana } from '../../lib/kibana';
import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../lib/telemetry';
import { hasMlAdminPermissions } from '../ml/permissions/has_ml_admin_permissions';
import { MlCapabilitiesContext } from '../ml/permissions/ml_capabilities_provider';
import { errorToToaster, useStateToaster } from '../toasters';
import { setupMlJob, startDatafeeds, stopDatafeeds } from './api';
import { filterJobs } from './helpers';
@ -25,6 +24,7 @@ import { PopoverDescription } from './popover_description';
import * as i18n from './translations';
import { JobsFilters, JobSummary, SiemJob } from './types';
import { UpgradeContents } from './upgrade_contents';
import { useMlCapabilities } from './hooks/use_ml_capabilities';
const PopoverContentsDiv = styled.div`
max-width: 684px;
@ -97,7 +97,7 @@ export const MlPopover = React.memo(() => {
const [filterProperties, setFilterProperties] = useState(defaultFilterProps);
const [isLoadingSiemJobs, siemJobs] = useSiemJobs(refreshToggle);
const [, dispatchToaster] = useStateToaster();
const capabilities = useContext(MlCapabilitiesContext);
const capabilities = useMlCapabilities();
const docLinks = useKibana().services.docLinks;
// Enable/Disable Job & Datafeed -- passed to JobsTable for use as callback on JobSwitch

View file

@ -5,6 +5,7 @@
*/
import { MlError } from '../ml/types';
import { AuditMessageBase } from '../../../../../../plugins/ml/common/types/audit_message';
export interface Group {
id: string;
@ -101,6 +102,7 @@ export interface MlSetupArgs {
* Representation of an ML Job as returned from the `ml/jobs/jobs_summary` API
*/
export interface JobSummary {
auditMessage?: AuditMessageBase;
datafeedId: string;
datafeedIndices: string[];
datafeedState: string;

View file

@ -8,7 +8,7 @@ import { EuiFlexItem } from '@elastic/eui';
import darkTheme from '@elastic/eui/dist/eui_theme_dark.json';
import lightTheme from '@elastic/eui/dist/eui_theme_light.json';
import { getOr } from 'lodash/fp';
import React, { useContext } from 'react';
import React from 'react';
import { DEFAULT_DARK_MODE } from '../../../../../common/constants';
import { DescriptionList } from '../../../../../common/utility_types';
@ -19,8 +19,8 @@ import { InspectButton, InspectButtonContainer } from '../../../inspect';
import { HostItem } from '../../../../graphql/types';
import { Loader } from '../../../loader';
import { IPDetailsLink } from '../../../links';
import { MlCapabilitiesContext } from '../../../ml/permissions/ml_capabilities_provider';
import { hasMlUserPermissions } from '../../../ml/permissions/has_ml_user_permissions';
import { useMlCapabilities } from '../../../ml_popover/hooks/use_ml_capabilities';
import { AnomalyScores } from '../../../ml/score/anomaly_scores';
import { Anomalies, NarrowDateRange } from '../../../ml/types';
import { DescriptionListStyled, OverviewWrapper } from '../../index';
@ -56,7 +56,7 @@ export const HostOverview = React.memo<HostSummaryProps>(
anomaliesData,
narrowDateRange,
}) => {
const capabilities = useContext(MlCapabilitiesContext);
const capabilities = useMlCapabilities();
const userPermissions = hasMlUserPermissions(capabilities);
const [darkMode] = useUiSetting$<boolean>(DEFAULT_DARK_MODE);

View file

@ -7,7 +7,7 @@
import { EuiFlexItem } from '@elastic/eui';
import darkTheme from '@elastic/eui/dist/eui_theme_dark.json';
import lightTheme from '@elastic/eui/dist/eui_theme_light.json';
import React, { useContext } from 'react';
import React from 'react';
import { DEFAULT_DARK_MODE } from '../../../../../common/constants';
import { DescriptionList } from '../../../../../common/utility_types';
@ -30,7 +30,7 @@ import { DescriptionListStyled, OverviewWrapper } from '../../index';
import { Loader } from '../../../loader';
import { Anomalies, NarrowDateRange } from '../../../ml/types';
import { AnomalyScores } from '../../../ml/score/anomaly_scores';
import { MlCapabilitiesContext } from '../../../ml/permissions/ml_capabilities_provider';
import { useMlCapabilities } from '../../../ml_popover/hooks/use_ml_capabilities';
import { hasMlUserPermissions } from '../../../ml/permissions/has_ml_user_permissions';
import { InspectButton, InspectButtonContainer } from '../../../inspect';
@ -71,7 +71,7 @@ export const IpOverview = React.memo<IpOverviewProps>(
anomaliesData,
narrowDateRange,
}) => {
const capabilities = useContext(MlCapabilitiesContext);
const capabilities = useMlCapabilities();
const userPermissions = hasMlUserPermissions(capabilities);
const [darkMode] = useUiSetting$<boolean>(DEFAULT_DARK_MODE);
const typeData: Overview = data[flowTarget]!;

View file

@ -5,7 +5,7 @@
*/
import React, { useCallback } from 'react';
import { EuiFlexGrid, EuiFlexItem, EuiRange, EuiFormRow } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiRange, EuiFormRow } from '@elastic/eui';
import { FieldHook } from '../../../../../shared_imports';
@ -31,12 +31,11 @@ export const AnomalyThresholdSlider: React.FC<AnomalyThresholdSliderProps> = ({
return (
<EuiFormRow
fullWidth
label={field.label}
data-test-subj="anomalyThresholdSlider"
describedByIds={describedByIds}
>
<EuiFlexGrid columns={2}>
<EuiFlexGroup>
<EuiFlexItem>
<EuiRange
value={threshold}
@ -48,7 +47,7 @@ export const AnomalyThresholdSlider: React.FC<AnomalyThresholdSliderProps> = ({
tickInterval={25}
/>
</EuiFlexItem>
</EuiFlexGrid>
</EuiFlexGroup>
</EuiFormRow>
);
};

View file

@ -22,6 +22,7 @@ import {
buildSeverityDescription,
buildUrlsDescription,
buildNoteDescription,
buildRuleTypeDescription,
} from './helpers';
import { ListItems } from './types';
@ -385,4 +386,30 @@ describe('helpers', () => {
expect(result).toHaveLength(0);
});
});
describe('buildRuleTypeDescription', () => {
it('returns the label for a machine_learning type', () => {
const [result]: ListItems[] = buildRuleTypeDescription('Test label', 'machine_learning');
expect(result.title).toEqual('Test label');
});
it('returns a humanized description for a machine_learning type', () => {
const [result]: ListItems[] = buildRuleTypeDescription('Test label', 'machine_learning');
expect(result.description).toEqual('Machine Learning');
});
it('returns the label for a query type', () => {
const [result]: ListItems[] = buildRuleTypeDescription('Test label', 'query');
expect(result.title).toEqual('Test label');
});
it('returns a humanized description for a query type', () => {
const [result]: ListItems[] = buildRuleTypeDescription('Test label', 'query');
expect(result.description).toEqual('Query');
});
});
});

View file

@ -27,6 +27,8 @@ import * as i18n from './translations';
import { BuildQueryBarDescription, BuildThreatDescription, ListItems } from './types';
import { SeverityBadge } from '../severity_badge';
import ListTreeIcon from './assets/list_tree_icon.svg';
import { RuleType } from '../../../../../containers/detection_engine/rules';
import { assertUnreachable } from '../../../../../lib/helpers';
const NoteDescriptionContainer = styled(EuiFlexItem)`
height: 105px;
@ -266,3 +268,27 @@ export const buildNoteDescription = (label: string, note: string): ListItems[] =
}
return [];
};
export const buildRuleTypeDescription = (label: string, ruleType: RuleType): ListItems[] => {
switch (ruleType) {
case 'machine_learning': {
return [
{
title: label,
description: i18n.ML_TYPE_DESCRIPTION,
},
];
}
case 'query':
case 'saved_query': {
return [
{
title: label,
description: i18n.QUERY_TYPE_DESCRIPTION,
},
];
}
default:
return assertUnreachable(ruleType);
}
};

View file

@ -27,6 +27,8 @@ import { schema } from '../step_about_rule/schema';
import { ListItems } from './types';
import { AboutStepRule } from '../../types';
jest.mock('../../../../../lib/kibana');
describe('description_step', () => {
const setupMock = coreMock.createSetup();
const uiSettingsMock = (pinnedByDefault: boolean) => (key: string) => {
@ -41,13 +43,6 @@ describe('description_step', () => {
let mockAboutStep: AboutStepRule;
beforeEach(() => {
// jest carries state between mocked implementations when using
// spyOn. So now we're doing all three of these.
// https://github.com/facebook/jest/issues/7136#issuecomment-565976599
jest.resetAllMocks();
jest.restoreAllMocks();
jest.clearAllMocks();
setupMock.uiSettings.get.mockImplementation(uiSettingsMock(true));
mockFilterManager = new FilterManager(setupMock.uiSettings);
mockAboutStep = mockAboutStepRule();

View file

@ -15,6 +15,7 @@ import {
esFilters,
FilterManager,
} from '../../../../../../../../../../src/plugins/data/public';
import { RuleType } from '../../../../../containers/detection_engine/rules';
import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/translations';
import { useKibana } from '../../../../../lib/kibana';
import { IMitreEnterpriseAttack } from '../../types';
@ -29,7 +30,10 @@ import {
buildUnorderedListArrayDescription,
buildUrlsDescription,
buildNoteDescription,
buildRuleTypeDescription,
} from './helpers';
import { useSiemJobs } from '../../../../../components/ml_popover/hooks/use_siem_jobs';
import { buildMlJobDescription } from './ml_job_description';
const DescriptionListContainer = styled(EuiDescriptionList)`
&.euiDescriptionList--column .euiDescriptionList__title {
@ -55,15 +59,22 @@ export const StepRuleDescriptionComponent: React.FC<StepRuleDescriptionProps> =
}) => {
const kibana = useKibana();
const [filterManager] = useState<FilterManager>(new FilterManager(kibana.services.uiSettings));
const [, siemJobs] = useSiemJobs(true);
const keys = Object.keys(schema);
const listItems = keys.reduce(
(acc: ListItems[], key: string) => [
...acc,
...buildListItems(data, pick(key, schema), filterManager, indexPatterns),
],
[]
);
const listItems = keys.reduce((acc: ListItems[], key: string) => {
if (key === 'machineLearningJobId') {
return [
...acc,
buildMlJobDescription(
get(key, data) as string,
(get(key, schema) as { label: string }).label,
siemJobs
),
];
}
return [...acc, ...buildListItems(data, pick(key, schema), filterManager, indexPatterns)];
}, []);
if (columns === 'multi') {
return (
@ -176,6 +187,9 @@ export const getDescriptionItem = (
} else if (field === 'note') {
const val: string = get(field, data);
return buildNoteDescription(label, val);
} else if (field === 'ruleType') {
const ruleType: RuleType = get(field, data);
return buildRuleTypeDescription(label, ruleType);
}
const description: string = get(field, data);

View file

@ -0,0 +1,93 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import styled from 'styled-components';
import { EuiBadge, EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui';
import { useKibana } from '../../../../../lib/kibana';
import { SiemJob } from '../../../../../components/ml_popover/types';
import { ListItems } from './types';
import { isJobStarted } from '../../../../../components/ml/helpers';
import { ML_JOB_STARTED, ML_JOB_STOPPED } from './translations';
enum MessageLevels {
info = 'info',
warning = 'warning',
error = 'error',
}
const AuditIcon: React.FC<{
message: SiemJob['auditMessage'];
}> = ({ message }) => {
if (!message) {
return null;
}
let color = 'primary';
let icon = 'alert';
if (message.level === MessageLevels.info) {
icon = 'iInCircle';
} else if (message.level === MessageLevels.warning) {
color = 'warning';
} else if (message.level === MessageLevels.error) {
color = 'danger';
}
return (
<EuiToolTip content={message.text}>
<EuiIcon type={icon} color={color} />
</EuiToolTip>
);
};
export const JobStatusBadge: React.FC<{ job: SiemJob }> = ({ job }) => {
const isStarted = isJobStarted(job.jobState, job.datafeedState);
return isStarted ? (
<EuiBadge color="secondary">{ML_JOB_STARTED}</EuiBadge>
) : (
<EuiBadge color="danger">{ML_JOB_STOPPED}</EuiBadge>
);
};
const JobLink = styled(EuiLink)`
margin-right: ${({ theme }) => theme.eui.euiSizeS};
`;
const Wrapper = styled.div`
overflow: hidden;
`;
export const MlJobDescription: React.FC<{ job: SiemJob }> = ({ job }) => {
const jobUrl = useKibana().services.application.getUrlForApp('ml#/jobs');
return (
<Wrapper>
<div>
<JobLink href={jobUrl} target="_blank">
{job.id}
</JobLink>
<AuditIcon message={job.auditMessage} />
</div>
<JobStatusBadge job={job} />
</Wrapper>
);
};
export const buildMlJobDescription = (
jobId: string,
label: string,
siemJobs: SiemJob[]
): ListItems => {
const siemJob = siemJobs.find(job => job.id === jobId);
return {
title: label,
description: siemJob ? <MlJobDescription job={siemJob} /> : jobId,
};
};

View file

@ -17,3 +17,31 @@ export const QUERY_LABEL = i18n.translate('xpack.siem.detectionEngine.createRule
export const SAVED_ID_LABEL = i18n.translate('xpack.siem.detectionEngine.createRule.savedIdLabel', {
defaultMessage: 'Saved query name',
});
export const ML_TYPE_DESCRIPTION = i18n.translate(
'xpack.siem.detectionEngine.createRule.mlRuleTypeDescription',
{
defaultMessage: 'Machine Learning',
}
);
export const QUERY_TYPE_DESCRIPTION = i18n.translate(
'xpack.siem.detectionEngine.createRule.queryRuleTypeDescription',
{
defaultMessage: 'Query',
}
);
export const ML_JOB_STARTED = i18n.translate(
'xpack.siem.detectionEngine.ruleDescription.mlJobStartedDescription',
{
defaultMessage: 'Started',
}
);
export const ML_JOB_STOPPED = i18n.translate(
'xpack.siem.detectionEngine.ruleDescription.mlJobStoppedDescription',
{
defaultMessage: 'Stopped',
}
);

View file

@ -5,12 +5,39 @@
*/
import React, { useCallback } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSuperSelect, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiLink,
EuiSuperSelect,
EuiText,
} from '@elastic/eui';
import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../../shared_imports';
import { useSiemJobs } from '../../../../../components/ml_popover/hooks/use_siem_jobs';
import { useKibana } from '../../../../../lib/kibana';
import { ML_JOB_SELECT_PLACEHOLDER_TEXT } from '../step_define_rule/translations';
const JobDisplay = ({ title, description }: { title: string; description: string }) => (
const HelpText: React.FC<{ href: string }> = ({ href }) => (
<FormattedMessage
id="xpack.siem.detectionEngine.createRule.stepDefineRule.machineLearningJobIdHelpText"
defaultMessage="We've provided a few common jobs to get you started. To add your own custom jobs, assign a group of “siem” to those jobs in the {machineLearning} application to make them appear here."
values={{
machineLearning: (
<EuiLink href={href} target="_blank">
<FormattedMessage
id="xpack.siem.components.mlJobSelect.machineLearningLink"
defaultMessage="Machine Learning"
/>
</EuiLink>
),
}}
/>
);
const JobDisplay: React.FC<{ title: string; description: string }> = ({ title, description }) => (
<>
<strong>{title}</strong>
<EuiText size="xs" color="subdued">
@ -28,23 +55,32 @@ export const MlJobSelect: React.FC<MlJobSelectProps> = ({ describedByIds = [], f
const jobId = field.value as string;
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
const [isLoading, siemJobs] = useSiemJobs(false);
const mlUrl = useKibana().services.application.getUrlForApp('ml');
const handleJobChange = useCallback(
(machineLearningJobId: string) => {
field.setValue(machineLearningJobId);
},
[field]
);
const placeholderOption = {
value: 'placeholder',
inputDisplay: ML_JOB_SELECT_PLACEHOLDER_TEXT,
dropdownDisplay: ML_JOB_SELECT_PLACEHOLDER_TEXT,
disabled: true,
};
const options = siemJobs.map(job => ({
const jobOptions = siemJobs.map(job => ({
value: job.id,
inputDisplay: job.id,
dropdownDisplay: <JobDisplay title={job.id} description={job.description} />,
}));
const options = [placeholderOption, ...jobOptions];
return (
<EuiFormRow
fullWidth
label={field.label}
helpText={<HelpText href={mlUrl} />}
isInvalid={isInvalid}
error={errorMessage}
data-test-subj="mlJobSelect"
@ -57,7 +93,7 @@ export const MlJobSelect: React.FC<MlJobSelectProps> = ({ describedByIds = [], f
isLoading={isLoading}
onChange={handleJobChange}
options={options}
valueOfSelected={jobId}
valueOfSelected={jobId || 'placeholder'}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -59,7 +59,6 @@ export const PickTimeline = ({
helpText={field.helpText}
error={errorMessage}
isInvalid={isInvalid}
fullWidth
data-test-subj={dataTestSubj}
describedByIds={idAria ? [idAria] : undefined}
>

View file

@ -48,14 +48,16 @@ interface SelectRuleTypeProps {
describedByIds?: string[];
field: FieldHook;
hasValidLicense?: boolean;
isMlAdmin?: boolean;
isReadOnly?: boolean;
}
export const SelectRuleType: React.FC<SelectRuleTypeProps> = ({
describedByIds = [],
field,
hasValidLicense = false,
isReadOnly = false,
hasValidLicense = false,
isMlAdmin = false,
}) => {
const ruleType = field.value as RuleType;
const setType = useCallback(
@ -66,7 +68,7 @@ export const SelectRuleType: React.FC<SelectRuleTypeProps> = ({
);
const setMl = useCallback(() => setType('machine_learning'), [setType]);
const setQuery = useCallback(() => setType('query'), [setType]);
const mlCardDisabled = isReadOnly || !hasValidLicense;
const mlCardDisabled = isReadOnly || !hasValidLicense || !isMlAdmin;
return (
<EuiFormRow

View file

@ -15,19 +15,14 @@ import { HeaderSection } from '../../../../../components/header_section';
import { StepAboutRule } from '../step_about_rule/';
import { AboutStepRule } from '../../types';
jest.mock('../../../../../lib/kibana');
const theme = () => ({ eui: euiDarkVars, darkMode: true });
describe('StepAboutRuleToggleDetails', () => {
let mockRule: AboutStepRule;
beforeEach(() => {
// jest carries state between mocked implementations when using
// spyOn. So now we're doing all three of these.
// https://github.com/facebook/jest/issues/7136#issuecomment-565976599
jest.resetAllMocks();
jest.restoreAllMocks();
jest.clearAllMocks();
mockRule = mockAboutStepRule();
});

View file

@ -5,7 +5,7 @@
*/
import { EuiButtonEmpty, EuiFormRow } from '@elastic/eui';
import React, { FC, memo, useCallback, useState, useEffect, useContext } from 'react';
import React, { FC, memo, useCallback, useState, useEffect } from 'react';
import styled from 'styled-components';
import deepEqual from 'fast-deep-equal';
@ -13,7 +13,7 @@ import { IIndexPattern } from '../../../../../../../../../../src/plugins/data/pu
import { useFetchIndexPatterns } from '../../../../../containers/detection_engine/rules';
import { DEFAULT_INDEX_KEY } from '../../../../../../common/constants';
import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/translations';
import { MlCapabilitiesContext } from '../../../../../components/ml/permissions/ml_capabilities_provider';
import { useMlCapabilities } from '../../../../../components/ml_popover/hooks/use_ml_capabilities';
import { useUiSetting$ } from '../../../../../lib/kibana';
import { setFieldValue, isMlRule } from '../../helpers';
import { DefineStepRule, RuleStep, RuleStepProps } from '../../types';
@ -37,6 +37,7 @@ import {
import { schema } from './schema';
import * as i18n from './translations';
import { filterRuleFieldsForType, RuleFields } from '../../create/helpers';
import { hasMlAdminPermissions } from '../../../../../components/ml/permissions/has_ml_admin_permissions';
const CommonUseField = getUseField({ component: Field });
@ -85,7 +86,7 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
setForm,
setStepData,
}) => {
const mlCapabilities = useContext(MlCapabilitiesContext);
const mlCapabilities = useMlCapabilities();
const [openTimelineSearch, setOpenTimelineSearch] = useState(false);
const [indexModified, setIndexModified] = useState(false);
const [localIsMlRule, setIsMlRule] = useState(false);
@ -162,8 +163,9 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
component={SelectRuleType}
componentProps={{
describedByIds: ['detectionEngineStepDefineRuleType'],
hasValidLicense: mlCapabilities.isPlatinumOrTrialLicense,
isReadOnly: isUpdateView,
hasValidLicense: mlCapabilities.isPlatinumOrTrialLicense,
isMlAdmin: hasMlAdminPermissions(mlCapabilities),
}}
/>
<EuiFormRow fullWidth style={{ display: localIsMlRule ? 'none' : 'flex' }}>

View file

@ -55,3 +55,10 @@ export const IMPORT_TIMELINE_QUERY = i18n.translate(
defaultMessage: 'Import query from saved timeline',
}
);
export const ML_JOB_SELECT_PLACEHOLDER_TEXT = i18n.translate(
'xpack.siem.detectionEngine.createRule.stepDefineRule.mlJobSelectPlaceholderText',
{
defaultMessage: 'Select a job',
}
);

View file

@ -5,7 +5,7 @@
*/
import { EuiHorizontalRule, EuiSpacer } from '@elastic/eui';
import React, { useContext, useEffect, useCallback, useMemo } from 'react';
import React, { useEffect, useCallback, useMemo } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { StickyContainer } from 'react-sticky';
@ -15,7 +15,7 @@ import { LastEventTime } from '../../../components/last_event_time';
import { AnomalyTableProvider } from '../../../components/ml/anomaly/anomaly_table_provider';
import { hostToCriteria } from '../../../components/ml/criteria/host_to_criteria';
import { hasMlUserPermissions } from '../../../components/ml/permissions/has_ml_user_permissions';
import { MlCapabilitiesContext } from '../../../components/ml/permissions/ml_capabilities_provider';
import { useMlCapabilities } from '../../../components/ml_popover/hooks/use_ml_capabilities';
import { scoreIntervalToDateTime } from '../../../components/ml/score/score_interval_to_datetime';
import { SiemNavigation } from '../../../components/navigation';
import { KpiHostsComponent } from '../../../components/page/hosts';
@ -62,7 +62,7 @@ const HostDetailsComponent = React.memo<HostDetailsProps & PropsFromRedux>(
useEffect(() => {
setHostDetailsTablesActivePageToZero();
}, [setHostDetailsTablesActivePageToZero, detailName]);
const capabilities = useContext(MlCapabilitiesContext);
const capabilities = useMlCapabilities();
const kibana = useKibana();
const hostDetailsPageFilters: Filter[] = useMemo(() => getHostDetailsPageFilters(detailName), [
detailName,

View file

@ -14,7 +14,6 @@ import { FiltersGlobal } from '../../components/filters_global';
import { HeaderPage } from '../../components/header_page';
import { LastEventTime } from '../../components/last_event_time';
import { hasMlUserPermissions } from '../../components/ml/permissions/has_ml_user_permissions';
import { MlCapabilitiesContext } from '../../components/ml/permissions/ml_capabilities_provider';
import { SiemNavigation } from '../../components/navigation';
import { KpiHostsComponent } from '../../components/page/hosts';
import { manageQuery } from '../../components/page/manage_query';
@ -30,6 +29,7 @@ import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from
import { SpyRoute } from '../../utils/route/spy_routes';
import { esQuery } from '../../../../../../../src/plugins/data/public';
import { useMlCapabilities } from '../../components/ml_popover/hooks/use_ml_capabilities';
import { HostsEmptyPage } from './hosts_empty_page';
import { HostsTabs } from './hosts_tabs';
import { navTabsHosts } from './nav_tabs';
@ -52,7 +52,7 @@ export const HostsComponent = React.memo<HostsComponentProps & PropsFromRedux>(
to,
hostsPagePath,
}) => {
const capabilities = React.useContext(MlCapabilitiesContext);
const capabilities = useMlCapabilities();
const kibana = useKibana();
const { tabName } = useParams();
const tabsFilters = React.useMemo(() => {

View file

@ -4,10 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useContext, useMemo } from 'react';
import React, { useMemo } from 'react';
import { Redirect, Route, Switch, RouteComponentProps } from 'react-router-dom';
import { MlCapabilitiesContext } from '../../components/ml/permissions/ml_capabilities_provider';
import { useMlCapabilities } from '../../components/ml_popover/hooks/use_ml_capabilities';
import { hasMlUserPermissions } from '../../components/ml/permissions/has_ml_user_permissions';
import { FlowTarget } from '../../graphql/types';
@ -24,7 +24,7 @@ const networkPagePath = `/:pageName(${SiemPageName.network})`;
const ipDetailsPageBasePath = `${networkPagePath}/ip/:detailName`;
const NetworkContainerComponent: React.FC<Props> = () => {
const capabilities = useContext(MlCapabilitiesContext);
const capabilities = useMlCapabilities();
const capabilitiesFetched = capabilities.capabilitiesFetched;
const userHasMlUserPermissions = useMemo(() => hasMlUserPermissions(capabilities), [
capabilities,