mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
## 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:
parent
b308e9e69c
commit
d673726541
16 changed files with 428 additions and 73 deletions
|
@ -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('')
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 };
|
||||
};
|
|
@ -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';
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
{
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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"
|
||||
>
|
||||
|
|
|
@ -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 => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue