Improve ML Job management UX for prebuilt ML rules/jobs (#1912) (#144080)

## Summary

This PR adds improvements to ML Job management UX within rules:
* ML job will be automatically enabled on rule creation, enabling rule
in Rules Table or Rule's details page
* ML rule details page has switch button to enable/disable the ML job
* Rules page shows the warning icon for the ML rules with non-running
jobs. Pressing the icon will show the list of the non-running jobs and
suggestion to go to the rule’s details page for further investigations

Main ticket:
[#1912](https://github.com/elastic/security-team/issues/1912)



https://user-images.githubusercontent.com/2700761/200577590-0fe03b25-b486-4ae7-8ca9-625047daddbc.mov

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Ievgen Sorokopud 2022-11-10 16:20:42 +01:00 committed by GitHub
parent b308e9e69c
commit d673726541
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 428 additions and 73 deletions

View file

@ -26,7 +26,7 @@ import {
FALSE_POSITIVES_DETAILS,
removeExternalLinkText,
MACHINE_LEARNING_JOB_ID,
MACHINE_LEARNING_JOB_STATUS,
// MACHINE_LEARNING_JOB_STATUS,
MITRE_ATTACK_DETAILS,
REFERENCE_URLS_DETAILS,
RISK_SCORE_DETAILS,
@ -107,7 +107,11 @@ describe('Detection rules, machine learning', () => {
);
getDetails(RULE_TYPE_DETAILS).should('have.text', 'Machine Learning');
getDetails(TIMELINE_TEMPLATE_DETAILS).should('have.text', 'None');
cy.get(MACHINE_LEARNING_JOB_STATUS).should('have.text', 'StoppedStopped');
// With the #1912 ML rule improvement changes we enable jobs on rule creation.
// Though, in cypress jobs enabling does not work reliably and job can be started or stopped.
// Thus, we disable next check till we fix the issue with enabling jobs in cypress.
// Relevant ticket: https://github.com/elastic/security-team/issues/5389
// cy.get(MACHINE_LEARNING_JOB_STATUS).should('have.text', 'StoppedStopped');
cy.get(MACHINE_LEARNING_JOB_ID).should(
'have.text',
getMachineLearningRule().machineLearningJobs.join('')

View file

@ -395,8 +395,8 @@ export const getNewTermsRule = (): NewTermsRule => ({
export const getMachineLearningRule = (): MachineLearningRule => ({
machineLearningJobs: [
'v3_linux_anomalous_process_all_hosts',
'v3_linux_anomalous_network_activity',
'v3_linux_anomalous_process_all_hosts',
],
anomalyScoreThreshold: 20,
name: 'New ML Rule Test',

View file

@ -18,7 +18,8 @@ import React, { useCallback, useRef, useState, useMemo, useEffect } from 'react'
import styled from 'styled-components';
import type { DataViewListItem } from '@kbn/data-views-plugin/common';
import { isThreatMatchRule } from '../../../../../common/detection_engine/utils';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { isMlRule, isThreatMatchRule } from '../../../../../common/detection_engine/utils';
import { useCreateRule } from '../../../rule_management/logic';
import type { RuleCreateProps } from '../../../../../common/detection_engine/rule_schema';
import { useListsConfig } from '../../../../detections/containers/detection_engine/lists/use_lists_config';
@ -30,7 +31,6 @@ import {
getRulesUrl,
} from '../../../../common/components/link_to/redirect_to_detection_engine';
import { SecuritySolutionPageWrapper } from '../../../../common/components/page_wrapper';
import { displaySuccessToast, useStateToaster } from '../../../../common/components/toasters';
import { SpyRoute } from '../../../../common/utils/route/spy_routes';
import { useUserData } from '../../../../detections/components/user_info';
import { AccordionTitle } from '../../../../detections/components/rules/accordion_title';
@ -71,6 +71,7 @@ import {
import { useKibana, useUiSetting$ } from '../../../../common/lib/kibana';
import { HeaderPage } from '../../../../common/components/header_page';
import { PreviewFlyout } from '../../../../detections/pages/detection_engine/rules/preview';
import { useStartMlJobs } from '../../../rule_management/logic/use_start_ml_jobs';
const formHookNoop = async (): Promise<undefined> => undefined;
@ -114,10 +115,10 @@ const CreateRulePageComponent: React.FC = () => {
] = useUserData();
const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration } =
useListsConfig();
const { addSuccess } = useAppToasts();
const { navigateToApp } = useKibana().services.application;
const { data: dataServices } = useKibana().services;
const loading = userInfoLoading || listsConfigLoading;
const [, dispatchToaster] = useStateToaster();
const [activeStep, setActiveStep] = useState<RuleStep>(RuleStep.defineRule);
const getNextStep = (step: RuleStep): RuleStep | undefined =>
ruleStepsOrder[ruleStepsOrder.indexOf(step) + 1];
@ -206,6 +207,8 @@ const CreateRulePageComponent: React.FC = () => {
[activeStep]
);
const { starting: isStartingJobs, startMlJobs } = useStartMlJobs();
useEffect(() => {
const fetchDataViews = async () => {
const dataViewsRefs = await dataServices.dataViews.getIdsWithTitle();
@ -285,16 +288,26 @@ const CreateRulePageComponent: React.FC = () => {
stepIsValid(scheduleStep) &&
stepIsValid(actionsStep)
) {
const createdRule = await createRule(
formatRule<RuleCreateProps>(
defineStep.data,
aboutStep.data,
scheduleStep.data,
actionsStep.data
)
);
const startMlJobsIfNeeded = async () => {
if (!isMlRule(defineStep.data.ruleType) || !actionsStep.data.enabled) {
return;
}
await startMlJobs(defineStep.data.machineLearningJobId);
};
const [, createdRule] = await Promise.all([
startMlJobsIfNeeded(),
createRule(
formatRule<RuleCreateProps>(
defineStep.data,
aboutStep.data,
scheduleStep.data,
actionsStep.data
)
),
]);
addSuccess(i18n.SUCCESSFULLY_CREATED_RULES(createdRule.name));
displaySuccessToast(i18n.SUCCESSFULLY_CREATED_RULES(createdRule.name), dispatchToaster);
navigateToApp(APP_UI_ID, {
deepLinkId: SecurityPageName.rules,
path: getRuleDetailsUrl(createdRule.id),
@ -303,7 +316,7 @@ const CreateRulePageComponent: React.FC = () => {
}
}
},
[updateCurrentDataState, goToStep, createRule, dispatchToaster, navigateToApp]
[updateCurrentDataState, goToStep, createRule, navigateToApp, startMlJobs, addSuccess]
);
const getAccordionType = useCallback(
@ -533,7 +546,7 @@ const CreateRulePageComponent: React.FC = () => {
addPadding={true}
defaultValues={stepsData.current[RuleStep.ruleActions].data}
isReadOnlyView={activeStep !== RuleStep.ruleActions}
isLoading={isLoading || loading}
isLoading={isLoading || loading || isStartingJobs}
setForm={setFormHook}
onSubmit={() => submitStep(RuleStep.ruleActions)}
actionMessageParams={actionMessageParams}

View file

@ -132,6 +132,7 @@ import { HeaderPage } from '../../../../common/components/header_page';
import { ExceptionsViewer } from '../../../rule_exceptions/components/all_exception_items_table';
import type { NavTab } from '../../../../common/components/navigation/types';
import { EditRuleSettingButtonLink } from '../../../../detections/pages/detection_engine/rules/details/components/edit_rule_settings_button_link';
import { useStartMlJobs } from '../../../rule_management/logic/use_start_ml_jobs';
/**
* Need a 100% height here to account for the graph/analyze tool, which sets no explicit height parameters, but fills the available space.
@ -239,6 +240,11 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
const [rule, setRule] = useState<Rule | null>(null);
const isLoading = ruleLoading && rule == null;
const { starting: isStartingJobs, startMlJobs } = useStartMlJobs();
const startMlJobsIfNeeded = useCallback(async () => {
await startMlJobs(rule?.machine_learning_job_id);
}, [rule, startMlJobs]);
const ruleDetailTabs = useMemo(
(): Record<RuleDetailTabs, NavTab> => ({
[RuleDetailTabs.alerts]: {
@ -696,6 +702,7 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
(isMlRule(rule?.type) && !hasMlPermissions)
}
enabled={isExistingRule && (rule?.enabled ?? false)}
startMlJobsIfNeeded={startMlJobsIfNeeded}
onChange={handleOnChangeEnabledRule}
/>
<EuiFlexItem>{i18n.ENABLE_RULE}</EuiFlexItem>
@ -754,7 +761,7 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
loading={isLoading || isSavedQueryLoading}
title={ruleI18n.DEFINITION}
>
{defineRuleData != null && !isSavedQueryLoading && (
{defineRuleData != null && !isSavedQueryLoading && !isStartingJobs && (
<StepDefineRule
descriptionColumns="singleSplit"
isReadOnlyView={true}

View file

@ -0,0 +1,56 @@
/*
* 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 { useCallback, useState } from 'react';
import type { SecurityJob } from '../../../common/components/ml_popover/types';
import { isJobStarted } from '../../../../common/machine_learning/helpers';
import { useSecurityJobs } from '../../../common/components/ml_popover/hooks/use_security_jobs';
import { useEnableDataFeed } from '../../../common/components/ml_popover/hooks/use_enable_data_feed';
export interface ReturnUseStartMlJobs {
loading: boolean;
starting: boolean;
jobs: SecurityJob[];
startMlJobs: (jobIds: string[] | undefined) => Promise<void>;
}
export const useStartMlJobs = (): ReturnUseStartMlJobs => {
const { enableDatafeed, isLoading: isLoadingEnableDataFeed } = useEnableDataFeed();
const { loading: isLoadingJobs, jobs: mlJobs, refetch: refetchJobs } = useSecurityJobs();
const [isStartingJobs, setIsStartingJobs] = useState(false);
const startMlJobs = useCallback(
async (jobIds: string[] | undefined) => {
if (isLoadingJobs || isLoadingEnableDataFeed) {
return;
}
if (!jobIds || !jobIds.length) {
return;
}
// The error handling happens inside `enableDatafeed`, so no need to do try/catch here
setIsStartingJobs(true);
const ruleJobs = mlJobs.filter((job) => jobIds.includes(job.id));
await Promise.all(
ruleJobs.map(async (job) => {
if (isJobStarted(job.jobState, job.datafeedState)) {
return true;
}
const latestTimestampMs = job.latestTimestampMs ?? 0;
await enableDatafeed(job, latestTimestampMs, true);
})
);
refetchJobs();
setIsStartingJobs(false);
},
[enableDatafeed, isLoadingEnableDataFeed, isLoadingJobs, mlJobs, refetchJobs]
);
return { loading: isLoadingJobs, jobs: mlJobs, starting: isStartingJobs, startMlJobs };
};

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 {
EuiPopover,
EuiText,
EuiPopoverTitle,
EuiSpacer,
EuiPopoverFooter,
EuiButtonIcon,
} from '@elastic/eui';
import { RuleExecutionStatus } from '../../../../../common/detection_engine/rule_monitoring';
import type { SecurityJob } from '../../../../common/components/ml_popover/types';
import * as i18n from './translations';
import { useBoolState } from '../../../../common/hooks/use_bool_state';
import { getRuleDetailsTabUrl } from '../../../../common/components/link_to/redirect_to_detection_engine';
import { SecurityPageName } from '../../../../../common/constants';
import { SecuritySolutionLinkButton } from '../../../../common/components/links';
import { isMlRule } from '../../../../../common/detection_engine/utils';
import { getCapitalizedStatusText } from '../../../../detections/components/rules/rule_execution_status/utils';
import type { Rule } from '../../../rule_management/logic';
import { isJobStarted } from '../../../../../common/machine_learning/helpers';
import { RuleDetailTabs } from '../../../rule_details_ui/pages/rule_details';
const POPOVER_WIDTH = '340px';
interface MlRuleWarningPopoverComponentProps {
rule: Rule;
loadingJobs: boolean;
jobs: SecurityJob[];
}
const MlRuleWarningPopoverComponent: React.FC<MlRuleWarningPopoverComponentProps> = ({
rule,
loadingJobs,
jobs,
}) => {
const [isPopoverOpen, , closePopover, togglePopover] = useBoolState();
if (!isMlRule(rule.type) || loadingJobs || !rule.machine_learning_job_id) {
return null;
}
const jobIds = rule.machine_learning_job_id;
const notRunningJobs = jobs.filter(
(job) => jobIds.includes(job.id) && !isJobStarted(job.jobState, job.datafeedState)
);
if (!notRunningJobs.length) {
return null;
}
const button = (
<EuiButtonIcon display={'empty'} color={'warning'} iconType={'alert'} onClick={togglePopover} />
);
const popoverTitle = getCapitalizedStatusText(RuleExecutionStatus['partial failure']);
return (
<EuiPopover
button={button}
isOpen={isPopoverOpen}
closePopover={closePopover}
anchorPosition="leftCenter"
>
<EuiPopoverTitle>{popoverTitle}</EuiPopoverTitle>
<div style={{ width: POPOVER_WIDTH }}>
<EuiText size="s">
<p>{i18n.ML_RULE_JOBS_WARNING_DESCRIPTION}</p>
</EuiText>
</div>
<EuiSpacer size="s" />
{notRunningJobs.map((job) => (
<EuiText>{job.id}</EuiText>
))}
<EuiPopoverFooter>
<SecuritySolutionLinkButton
data-test-subj="open-rule-details"
fullWidth
deepLinkId={SecurityPageName.rules}
path={getRuleDetailsTabUrl(rule.id, RuleDetailTabs.alerts)}
>
{i18n.ML_RULE_JOBS_WARNING_BUTTON_LABEL}
</SecuritySolutionLinkButton>
</EuiPopoverFooter>
</EuiPopover>
);
};
export const MlRuleWarningPopover = React.memo(MlRuleWarningPopoverComponent);
MlRuleWarningPopover.displayName = 'MlRuleWarningPopover';

View file

@ -36,6 +36,7 @@ import { RulesTableUtilityBar } from './rules_table_utility_bar';
import { useMonitoringColumns, useRulesColumns } from './use_columns';
import { useUserData } from '../../../../detections/components/user_info';
import { hasUserCRUDPermission } from '../../../../common/utils/privileges';
import { useStartMlJobs } from '../../../rule_management/logic/use_start_ml_jobs';
const INITIAL_SORT_FIELD = 'enabled';
@ -140,8 +141,19 @@ export const RulesTables = React.memo<RulesTableProps>(({ selectedTab }) => {
[setPage, setPerPage, setSortingOptions]
);
const rulesColumns = useRulesColumns({ hasCRUDPermissions: hasPermissions });
const monitoringColumns = useMonitoringColumns({ hasCRUDPermissions: hasPermissions });
const { loading: isLoadingJobs, jobs: mlJobs, startMlJobs } = useStartMlJobs();
const rulesColumns = useRulesColumns({
hasCRUDPermissions: hasPermissions,
isLoadingJobs,
mlJobs,
startMlJobs,
});
const monitoringColumns = useMonitoringColumns({
hasCRUDPermissions: hasPermissions,
isLoadingJobs,
mlJobs,
startMlJobs,
});
const isSelectAllCalled = useRef(false);

View file

@ -0,0 +1,23 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const ML_RULE_JOBS_WARNING_DESCRIPTION = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleManagementUi.rulesTable.mlJobsWarning.popover.description',
{
defaultMessage:
'The following jobs are not running and might cause the rule to generate wrong results:',
}
);
export const ML_RULE_JOBS_WARNING_BUTTON_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleManagementUi.rulesTable.mlJobsWarning.popover.buttonLabel',
{
defaultMessage: 'Visit rule details page to investigate',
}
);

View file

@ -6,10 +6,11 @@
*/
import type { EuiBasicTableColumn, EuiTableActionsColumnType } from '@elastic/eui';
import { EuiBadge, EuiLink, EuiText, EuiToolTip } from '@elastic/eui';
import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiLink, EuiText, EuiToolTip } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import moment from 'moment';
import React, { useMemo } from 'react';
import type { SecurityJob } from '../../../../common/components/ml_popover/types';
import {
DEFAULT_RELATIVE_DATE_THRESHOLD,
SecurityPageName,
@ -43,14 +44,18 @@ import { TableHeaderTooltipCell } from './table_header_tooltip_cell';
import { useHasActionsPrivileges } from './use_has_actions_privileges';
import { useHasMlPermissions } from './use_has_ml_permissions';
import { useRulesTableActions } from './use_rules_table_actions';
import { MlRuleWarningPopover } from './ml_rule_warning_popover';
export type TableColumn = EuiBasicTableColumn<Rule> | EuiTableActionsColumnType<Rule>;
interface ColumnsProps {
hasCRUDPermissions: boolean;
isLoadingJobs: boolean;
mlJobs: SecurityJob[];
startMlJobs: (jobIds: string[] | undefined) => Promise<void>;
}
const useEnabledColumn = ({ hasCRUDPermissions }: ColumnsProps): TableColumn => {
const useEnabledColumn = ({ hasCRUDPermissions, startMlJobs }: ColumnsProps): TableColumn => {
const hasMlPermissions = useHasMlPermissions();
const hasActionsPrivileges = useHasActionsPrivileges();
const { loadingRulesAction, loadingRuleIds } = useRulesTableContext().state;
@ -77,6 +82,7 @@ const useEnabledColumn = ({ hasCRUDPermissions }: ColumnsProps): TableColumn =>
<RuleSwitch
id={rule.id}
enabled={rule.enabled}
startMlJobsIfNeeded={() => startMlJobs(rule.machine_learning_job_id)}
isDisabled={
!canEditRuleWithActions(rule, hasActionsPrivileges) ||
!hasCRUDPermissions ||
@ -89,7 +95,7 @@ const useEnabledColumn = ({ hasCRUDPermissions }: ColumnsProps): TableColumn =>
width: '95px',
sortable: true,
}),
[hasActionsPrivileges, hasMlPermissions, hasCRUDPermissions, loadingIds]
[hasMlPermissions, hasActionsPrivileges, hasCRUDPermissions, loadingIds, startMlJobs]
);
};
@ -121,6 +127,44 @@ const useRuleNameColumn = (): TableColumn => {
);
};
const useRuleExecutionStatusColumn = ({
sortable,
width,
isLoadingJobs,
mlJobs,
}: {
sortable: boolean;
width: string;
isLoadingJobs: boolean;
mlJobs: SecurityJob[];
}): TableColumn => {
return useMemo(
() => ({
field: 'execution_summary.last_execution.status',
name: i18n.COLUMN_LAST_RESPONSE,
render: (value: RuleExecutionSummary['last_execution']['status'] | undefined, item: Rule) => {
return (
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<RuleStatusBadge
status={value}
message={item.execution_summary?.last_execution.message}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<MlRuleWarningPopover rule={item} loadingJobs={isLoadingJobs} jobs={mlJobs} />
</EuiFlexItem>
</EuiFlexGroup>
);
},
sortable,
truncateText: true,
width,
}),
[isLoadingJobs, mlJobs, sortable, width]
);
};
const TAGS_COLUMN: TableColumn = {
field: 'tags',
name: null,
@ -171,12 +215,28 @@ const useActionsColumn = (): EuiTableActionsColumnType<Rule> => {
return useMemo(() => ({ actions, width: '40px' }), [actions]);
};
export const useRulesColumns = ({ hasCRUDPermissions }: ColumnsProps): TableColumn[] => {
export const useRulesColumns = ({
hasCRUDPermissions,
isLoadingJobs,
mlJobs,
startMlJobs,
}: ColumnsProps): TableColumn[] => {
const actionsColumn = useActionsColumn();
const enabledColumn = useEnabledColumn({ hasCRUDPermissions });
const ruleNameColumn = useRuleNameColumn();
const { isInMemorySorting } = useRulesTableContext().state;
const [showRelatedIntegrations] = useUiSetting$<boolean>(SHOW_RELATED_INTEGRATIONS_SETTING);
const enabledColumn = useEnabledColumn({
hasCRUDPermissions,
isLoadingJobs,
mlJobs,
startMlJobs,
});
const executionStatusColumn = useRuleExecutionStatusColumn({
sortable: !!isInMemorySorting,
width: '16%',
isLoadingJobs,
mlJobs,
});
return useMemo(
() => [
@ -222,16 +282,7 @@ export const useRulesColumns = ({ hasCRUDPermissions }: ColumnsProps): TableColu
truncateText: true,
width: '16%',
},
{
field: 'execution_summary.last_execution.status',
name: i18n.COLUMN_LAST_RESPONSE,
render: (value: RuleExecutionSummary['last_execution']['status'] | undefined) => (
<RuleStatusBadge status={value} />
),
sortable: !!isInMemorySorting,
truncateText: true,
width: '16%',
},
executionStatusColumn,
{
field: 'updated_at',
name: i18n.COLUMN_LAST_UPDATE,
@ -273,6 +324,7 @@ export const useRulesColumns = ({ hasCRUDPermissions }: ColumnsProps): TableColu
[
actionsColumn,
enabledColumn,
executionStatusColumn,
hasCRUDPermissions,
isInMemorySorting,
ruleNameColumn,
@ -281,13 +333,29 @@ export const useRulesColumns = ({ hasCRUDPermissions }: ColumnsProps): TableColu
);
};
export const useMonitoringColumns = ({ hasCRUDPermissions }: ColumnsProps): TableColumn[] => {
export const useMonitoringColumns = ({
hasCRUDPermissions,
isLoadingJobs,
mlJobs,
startMlJobs,
}: ColumnsProps): TableColumn[] => {
const docLinks = useKibana().services.docLinks;
const actionsColumn = useActionsColumn();
const enabledColumn = useEnabledColumn({ hasCRUDPermissions });
const ruleNameColumn = useRuleNameColumn();
const { isInMemorySorting } = useRulesTableContext().state;
const [showRelatedIntegrations] = useUiSetting$<boolean>(SHOW_RELATED_INTEGRATIONS_SETTING);
const enabledColumn = useEnabledColumn({
hasCRUDPermissions,
isLoadingJobs,
mlJobs,
startMlJobs,
});
const executionStatusColumn = useRuleExecutionStatusColumn({
sortable: !!isInMemorySorting,
width: '12%',
isLoadingJobs,
mlJobs,
});
return useMemo(
() => [
@ -371,16 +439,7 @@ export const useMonitoringColumns = ({ hasCRUDPermissions }: ColumnsProps): Tabl
truncateText: true,
width: '14%',
},
{
field: 'execution_summary.last_execution.status',
name: i18n.COLUMN_LAST_RESPONSE,
render: (value: RuleExecutionSummary['last_execution']['status'] | undefined) => (
<RuleStatusBadge status={value} />
),
sortable: !!isInMemorySorting,
truncateText: true,
width: '12%',
},
executionStatusColumn,
{
field: 'execution_summary.last_execution.date',
name: i18n.COLUMN_LAST_COMPLETE_RUN,
@ -407,6 +466,7 @@ export const useMonitoringColumns = ({ hasCRUDPermissions }: ColumnsProps): Tabl
actionsColumn,
docLinks.links.siem.troubleshootGaps,
enabledColumn,
executionStatusColumn,
hasCRUDPermissions,
isInMemorySorting,
ruleNameColumn,

View file

@ -8,14 +8,16 @@
import React from 'react';
import { shallow } from 'enzyme';
import { mockOpenedJob } from '../../../../common/components/ml_popover/api.mock';
import { mockOpenedJob, mockSecurityJobs } from '../../../../common/components/ml_popover/api.mock';
import { MlJobDescription, AuditIcon, JobStatusBadge } from './ml_job_description';
jest.mock('../../../../common/lib/kibana');
describe('MlJobDescription', () => {
it('renders correctly', () => {
const wrapper = shallow(<MlJobDescription jobId={'myJobId'} />);
const wrapper = shallow(
<MlJobDescription job={mockSecurityJobs[0]} loading={false} refreshJob={() => {}} />
);
expect(wrapper.find('[data-test-subj="machineLearningJobId"]')).toHaveLength(1);
});

View file

@ -5,17 +5,20 @@
* 2.0.
*/
import React from 'react';
import React, { useCallback } from 'react';
import styled from 'styled-components';
import { EuiBadge, EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui';
import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui';
import type { MlSummaryJob } from '@kbn/ml-plugin/public';
import { ML_PAGES, useMlHref } from '@kbn/ml-plugin/public';
import { isJobStarted } from '../../../../../common/machine_learning/helpers';
import { useEnableDataFeed } from '../../../../common/components/ml_popover/hooks/use_enable_data_feed';
import type { SecurityJob } from '../../../../common/components/ml_popover/types';
import { JobSwitch } from '../../../../common/components/ml_popover/jobs_table/job_switch';
import { useSecurityJobs } from '../../../../common/components/ml_popover/hooks/use_security_jobs';
import { useKibana } from '../../../../common/lib/kibana';
import type { ListItems } from './types';
import { ML_JOB_STARTED, ML_JOB_STOPPED } from './translations';
import * as i18n from './translations';
enum MessageLevels {
info = 'info',
@ -53,7 +56,7 @@ export const AuditIcon = React.memo(AuditIconComponent);
const JobStatusBadgeComponent: React.FC<{ job: MlSummaryJob }> = ({ job }) => {
const isStarted = isJobStarted(job.jobState, job.datafeedState);
const color = isStarted ? 'success' : 'danger';
const text = isStarted ? ML_JOB_STARTED : ML_JOB_STOPPED;
const text = isStarted ? i18n.ML_JOB_STARTED : i18n.ML_JOB_STOPPED;
return (
<EuiBadge data-test-subj="machineLearningJobStatus" color={color}>
@ -72,11 +75,16 @@ const Wrapper = styled.div`
overflow: hidden;
`;
const MlJobDescriptionComponent: React.FC<{ jobId: string }> = ({ jobId }) => {
const { jobs } = useSecurityJobs();
const MlJobDescriptionComponent: React.FC<{
job: SecurityJob;
loading: boolean;
refreshJob: (job: SecurityJob) => void;
}> = ({ job, loading, refreshJob }) => {
const {
services: { http, ml },
} = useKibana();
const { enableDatafeed, isLoading: isLoadingEnableDataFeed } = useEnableDataFeed();
const jobId = job.id;
const jobUrl = useMlHref(ml, http.basePath.get(), {
page: ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE,
pageState: {
@ -84,10 +92,16 @@ const MlJobDescriptionComponent: React.FC<{ jobId: string }> = ({ jobId }) => {
},
});
const job = jobs.find(({ id }) => id === jobId);
const jobIdSpan = <span data-test-subj="machineLearningJobId">{jobId}</span>;
const handleJobStateChange = useCallback(
async (_, latestTimestampMs: number, enable: boolean) => {
await enableDatafeed(job, latestTimestampMs, enable);
refreshJob(job);
},
[enableDatafeed, job, refreshJob]
);
return job != null ? (
<Wrapper>
<div>
@ -96,7 +110,21 @@ const MlJobDescriptionComponent: React.FC<{ jobId: string }> = ({ jobId }) => {
</JobLink>
<AuditIcon message={job.auditMessage} />
</div>
<JobStatusBadge job={job} />
<EuiFlexGroup justifyContent="flexStart">
<EuiFlexItem grow={false} style={{ marginRight: '0' }}>
<JobStatusBadge job={job} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<JobSwitch
job={job}
isSecurityJobsLoading={loading || isLoadingEnableDataFeed}
onJobStateChange={handleJobStateChange}
/>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ marginLeft: '0' }}>
{i18n.ML_RUN_JOB_LABEL}
</EuiFlexItem>
</EuiFlexGroup>
</Wrapper>
) : (
jobIdSpan
@ -105,13 +133,17 @@ const MlJobDescriptionComponent: React.FC<{ jobId: string }> = ({ jobId }) => {
export const MlJobDescription = React.memo(MlJobDescriptionComponent);
const MlJobsDescription: React.FC<{ jobIds: string[] }> = ({ jobIds }) => (
<>
{jobIds.map((jobId) => (
<MlJobDescription key={jobId} jobId={jobId} />
))}
</>
);
const MlJobsDescription: React.FC<{ jobIds: string[] }> = ({ jobIds }) => {
const { loading, jobs, refetch: refreshJobs } = useSecurityJobs();
const relevantJobs = jobs.filter((job) => jobIds.includes(job.id));
return (
<>
{relevantJobs.map((job) => (
<MlJobDescription key={job.id} job={job} loading={loading} refreshJob={refreshJobs} />
))}
</>
);
};
export const buildMlJobsDescription = (jobIds: string[], label: string): ListItems => ({
title: label,

View file

@ -91,6 +91,13 @@ export const NEW_TERMS_TYPE_DESCRIPTION = i18n.translate(
}
);
export const ML_RUN_JOB_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDescription.mlRunJobLabel',
{
defaultMessage: 'Run job',
}
);
export const ML_JOB_STARTED = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDescription.mlJobStartedDescription',
{

View file

@ -8,6 +8,7 @@
import React, { useCallback, useMemo } from 'react';
import type { EuiComboBoxOptionOption } from '@elastic/eui';
import {
EuiButton,
EuiComboBox,
EuiFlexGroup,
EuiFlexItem,
@ -25,6 +26,8 @@ import { useKibana } from '../../../../common/lib/kibana';
import { ML_JOB_SELECT_PLACEHOLDER_TEXT } from '../step_define_rule/translations';
import { HelpText } from './help_text';
import * as i18n from './translations';
interface MlJobValue {
id: string;
description: string;
@ -43,6 +46,10 @@ const MlJobSelectEuiFlexGroup = styled(EuiFlexGroup)`
margin-bottom: 5px;
`;
const MlJobEuiButton = styled(EuiButton)`
margin-top: 20px;
`;
const JobDisplay: React.FC<MlJobValue> = ({ id, description }) => (
<JobDisplayContainer>
<strong>{id}</strong>
@ -68,7 +75,8 @@ export const MlJobSelect: React.FC<MlJobSelectProps> = ({ describedByIds = [], f
const jobIds = field.value as string[];
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
const { loading, jobs } = useSecurityJobs();
const mlUrl = useKibana().services.application.getUrlForApp('ml');
const { getUrlForApp, navigateToApp } = useKibana().services.application;
const mlUrl = getUrlForApp('ml');
const handleJobSelect = useCallback(
(selectedJobOptions: MlJobOption[]): void => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@ -99,8 +107,8 @@ export const MlJobSelect: React.FC<MlJobSelectProps> = ({ describedByIds = [], f
}, [jobs, jobIds]);
return (
<MlJobSelectEuiFlexGroup>
<EuiFlexItem>
<MlJobSelectEuiFlexGroup justifyContent="flexStart">
<EuiFlexItem grow={false}>
<EuiFormRow
label={field.label}
helpText={<HelpText href={mlUrl} notRunningJobIds={notRunningJobIds} />}
@ -124,6 +132,15 @@ export const MlJobSelect: React.FC<MlJobSelectProps> = ({ describedByIds = [], f
</EuiFlexGroup>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<MlJobEuiButton
iconType="popout"
iconSide="right"
onClick={() => navigateToApp('ml', { openInNewTab: true })}
>
{i18n.CREATE_CUSTOM_JOB_BUTTON_TITLE}
</MlJobEuiButton>
</EuiFlexItem>
</MlJobSelectEuiFlexGroup>
);
};

View file

@ -0,0 +1,15 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const CREATE_CUSTOM_JOB_BUTTON_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.mlSelectJob.createCustomJobButtonTitle',
{
defaultMessage: 'Create custom job',
}
);

View file

@ -11,22 +11,26 @@ import { getEmptyTagValue } from '../../../../common/components/empty_value';
import { HealthTruncateText } from '../../../../common/components/health_truncate_text';
import { getCapitalizedStatusText, getStatusColor } from './utils';
import type { RuleExecutionStatus } from '../../../../../common/detection_engine/rule_monitoring';
import { RuleExecutionStatus } from '../../../../../common/detection_engine/rule_monitoring';
interface RuleStatusBadgeProps {
status: RuleExecutionStatus | null | undefined;
message?: string;
}
/**
* Shows rule execution status
* @param status - rule execution status
*/
const RuleStatusBadgeComponent = ({ status }: RuleStatusBadgeProps) => {
const RuleStatusBadgeComponent = ({ status, message }: RuleStatusBadgeProps) => {
const isFailedStatus =
status === RuleExecutionStatus.failed || status === RuleExecutionStatus['partial failure'];
const statusText = getCapitalizedStatusText(status);
const statusTooltip = isFailedStatus && message ? message : statusText;
const statusColor = getStatusColor(status);
return (
<HealthTruncateText
tooltipContent={statusText}
tooltipContent={statusTooltip}
healthColor={statusColor}
dataTestSubj="ruleExecutionStatus"
>

View file

@ -29,6 +29,7 @@ export interface RuleSwitchProps {
enabled: boolean;
isDisabled?: boolean;
isLoading?: boolean;
startMlJobsIfNeeded?: () => Promise<void>;
onChange?: (enabled: boolean) => void;
}
@ -40,6 +41,7 @@ export const RuleSwitchComponent = ({
isDisabled,
isLoading,
enabled,
startMlJobsIfNeeded,
onChange,
}: RuleSwitchProps) => {
const [myIsLoading, setMyIsLoading] = useState(false);
@ -53,8 +55,12 @@ export const RuleSwitchComponent = ({
startTransaction({
name: enabled ? SINGLE_RULE_ACTIONS.DISABLE : SINGLE_RULE_ACTIONS.ENABLE,
});
const enableRule = event.target.checked;
if (enableRule) {
await startMlJobsIfNeeded?.();
}
const bulkActionResponse = await executeBulkAction({
type: event.target.checked ? BulkActionType.enable : BulkActionType.disable,
type: enableRule ? BulkActionType.enable : BulkActionType.disable,
ids: [id],
});
if (bulkActionResponse?.attributes.results.updated.length) {
@ -63,7 +69,7 @@ export const RuleSwitchComponent = ({
}
setMyIsLoading(false);
},
[enabled, executeBulkAction, id, onChange, startTransaction]
[enabled, executeBulkAction, id, onChange, startMlJobsIfNeeded, startTransaction]
);
const showLoader = useMemo((): boolean => {