[ML] Rare anomaly detection job wizard (#100390)

* [ML] Rare anomaly detection job wizard

* fixing fields selection

* small improvements

* adding event rate chart to summary step

* [ML] Changes UI text for rare wizard.

* improving detector summary

* fixing translations

* removing comments

* fixing field selection

* fixing advanced wizard

* updating detector text

* fixing bucketspan estimator

* bug fixes

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: István Zoltán Szabó <istvan.szabo@elastic.co>
This commit is contained in:
James Gowdy 2021-06-29 11:02:17 +01:00 committed by GitHub
parent 824463ace5
commit 2e00e9c11b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 1276 additions and 119 deletions

View file

@ -11,6 +11,7 @@ export enum JOB_TYPE {
POPULATION = 'population',
ADVANCED = 'advanced',
CATEGORIZATION = 'categorization',
RARE = 'rare',
}
export enum CREATED_BY_LABEL {
@ -18,6 +19,7 @@ export enum CREATED_BY_LABEL {
MULTI_METRIC = 'multi-metric-wizard',
POPULATION = 'population-wizard',
CATEGORIZATION = 'categorization-wizard',
RARE = 'rare-wizard',
APM_TRANSACTION = 'ml-module-apm-transaction',
}

View file

@ -66,7 +66,7 @@ export class CategorizationJobCreator extends JobCreator {
eventRate: Field | null
) {
if (count === null || rare === null || eventRate === null) {
return;
throw Error('event_rate field or count or rare aggregations missing');
}
this._createCountDetector = () => {

View file

@ -11,6 +11,7 @@ export { MultiMetricJobCreator } from './multi_metric_job_creator';
export { PopulationJobCreator } from './population_job_creator';
export { AdvancedJobCreator } from './advanced_job_creator';
export { CategorizationJobCreator } from './categorization_job_creator';
export { RareJobCreator } from './rare_job_creator';
export {
JobCreatorType,
isSingleMetricJobCreator,
@ -18,5 +19,6 @@ export {
isPopulationJobCreator,
isAdvancedJobCreator,
isCategorizationJobCreator,
isRareJobCreator,
} from './type_guards';
export { jobCreatorFactory } from './job_creator_factory';

View file

@ -395,6 +395,9 @@ export class JobCreator {
// change the detector to be a non-zer or non-null count or sum.
// note, the aggregations will always be a standard count or sum and not a non-null or non-zero version
this._detectors.forEach((d, i) => {
if (this._aggs[i] === undefined) {
return;
}
switch (this._aggs[i].id) {
case ML_JOB_AGGREGATION.COUNT:
d.function = this._sparseData

View file

@ -12,6 +12,7 @@ import { PopulationJobCreator } from './population_job_creator';
import { AdvancedJobCreator } from './advanced_job_creator';
import { IndexPattern } from '../../../../../../../../../src/plugins/data/public';
import { CategorizationJobCreator } from './categorization_job_creator';
import { RareJobCreator } from './rare_job_creator';
import { JOB_TYPE } from '../../../../../../common/constants/new_job';
@ -37,6 +38,9 @@ export const jobCreatorFactory = (jobType: JOB_TYPE) => (
case JOB_TYPE.CATEGORIZATION:
jc = CategorizationJobCreator;
break;
case JOB_TYPE.RARE:
jc = RareJobCreator;
break;
default:
jc = SingleMetricJobCreator;
break;

View file

@ -22,7 +22,7 @@ import { IndexPattern } from '../../../../../../../../../src/plugins/data/public
export class PopulationJobCreator extends JobCreator {
// a population job has one overall over (split) field, which is the same for all detectors
// each detector has an optional by field
private _splitField: SplitField = null;
private _populatonField: SplitField = null;
private _byFields: SplitField[] = [];
protected _type: JOB_TYPE = JOB_TYPE.POPULATION;
@ -65,27 +65,27 @@ export class PopulationJobCreator extends JobCreator {
}
// add an over field to all detectors
public setSplitField(field: SplitField) {
this._splitField = field;
public setPopulationField(field: SplitField) {
this._populatonField = field;
if (this._splitField === null) {
this.removeSplitField();
if (this._populatonField === null) {
this.removePopulationField();
} else {
for (let i = 0; i < this._detectors.length; i++) {
this._detectors[i].over_field_name = this._splitField.id;
this._detectors[i].over_field_name = this._populatonField.id;
}
}
}
// remove over field from all detectors
public removeSplitField() {
public removePopulationField() {
this._detectors.forEach((d) => {
delete d.over_field_name;
});
}
public get splitField(): SplitField {
return this._splitField;
public get populationField(): SplitField {
return this._populatonField;
}
public addDetector(agg: Aggregation, field: Field) {
@ -112,8 +112,8 @@ export class PopulationJobCreator extends JobCreator {
private _createDetector(agg: Aggregation, field: Field) {
const dtr: Detector = createBasicDetector(agg, field);
if (this._splitField !== null) {
dtr.over_field_name = this._splitField.id;
if (this._populatonField !== null) {
dtr.over_field_name = this._populatonField.id;
}
return dtr;
}
@ -143,7 +143,7 @@ export class PopulationJobCreator extends JobCreator {
if (detectors.length) {
if (detectors[0].overField !== null) {
this.setSplitField(detectors[0].overField);
this.setPopulationField(detectors[0].overField);
}
}
detectors.forEach((d, i) => {

View file

@ -0,0 +1,173 @@
/*
* 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 { SavedSearchSavedObject } from '../../../../../../common/types/kibana';
import { JobCreator } from './job_creator';
import { Field, SplitField, Aggregation } from '../../../../../../common/types/fields';
import { Job, Datafeed, Detector } from '../../../../../../common/types/anomaly_detection_jobs';
import { JOB_TYPE, CREATED_BY_LABEL } from '../../../../../../common/constants/new_job';
import { getRichDetectors } from './util/general';
import { IndexPattern } from '../../../../../../../../../src/plugins/data/public';
import { isSparseDataJob } from './util/general';
import { ML_JOB_AGGREGATION } from '../../../../../../common/constants/aggregation_types';
export class RareJobCreator extends JobCreator {
private _rareField: Field | null = null;
private _populationField: SplitField = null;
private _splitField: SplitField = null;
protected _type: JOB_TYPE = JOB_TYPE.RARE;
private _rareInPopulation: boolean = false;
private _frequentlyRare: boolean = false;
private _rareAgg: Aggregation;
private _freqRareAgg: Aggregation;
constructor(
indexPattern: IndexPattern,
savedSearch: SavedSearchSavedObject | null,
query: object
) {
super(indexPattern, savedSearch, query);
this.createdBy = CREATED_BY_LABEL.RARE;
this._wizardInitialized$.next(true);
this._rareAgg = {} as Aggregation;
this._freqRareAgg = {} as Aggregation;
}
public setDefaultDetectorProperties(rare: Aggregation | null, freqRare: Aggregation | null) {
if (rare === null || freqRare === null) {
throw Error('rare or freq_rare aggregations missing');
}
this._rareAgg = rare;
this._freqRareAgg = freqRare;
}
public setRareField(field: Field | null) {
this._rareField = field;
if (field === null) {
this.removePopulationField();
this.removeSplitField();
this._removeDetector(0);
this._detectors.length = 0;
this._fields.length = 0;
return;
}
const agg = this._frequentlyRare ? this._freqRareAgg : this._rareAgg;
const dtr: Detector = {
function: agg.id,
};
if (this._detectors.length === 0) {
this._addDetector(dtr, agg, field);
} else {
this._editDetector(dtr, agg, field, 0);
}
this._detectors[0].by_field_name = field.id;
}
public get rareField() {
return this._rareField;
}
public get rareInPopulation() {
return this._rareInPopulation;
}
public set rareInPopulation(bool: boolean) {
this._rareInPopulation = bool;
if (bool === false) {
this.removePopulationField();
}
}
public get frequentlyRare() {
return this._frequentlyRare;
}
public set frequentlyRare(bool: boolean) {
this._frequentlyRare = bool;
if (this._detectors.length) {
const agg = bool ? this._freqRareAgg : this._rareAgg;
this._detectors[0].function = agg.id;
this._aggs[0] = agg;
}
}
// set the population field, applying it to each detector
public setPopulationField(field: SplitField) {
this._populationField = field;
if (this._populationField === null) {
this.removePopulationField();
} else {
for (let i = 0; i < this._detectors.length; i++) {
this._detectors[i].over_field_name = this._populationField.id;
}
}
}
public removePopulationField() {
this._populationField = null;
this._detectors.forEach((d) => {
delete d.over_field_name;
});
}
public get populationField(): SplitField {
return this._populationField;
}
// set the split field, applying it to each detector
public setSplitField(field: SplitField) {
this._splitField = field;
if (this._splitField === null) {
this.removeSplitField();
} else {
for (let i = 0; i < this._detectors.length; i++) {
this._detectors[i].partition_field_name = this._splitField.id;
}
}
}
public removeSplitField() {
this._detectors.forEach((d) => {
delete d.partition_field_name;
});
}
public get splitField(): SplitField {
return this._splitField;
}
public cloneFromExistingJob(job: Job, datafeed: Datafeed) {
this._overrideConfigs(job, datafeed);
this.createdBy = CREATED_BY_LABEL.RARE;
this._sparseData = isSparseDataJob(job, datafeed);
const detectors = getRichDetectors(job, datafeed, this.additionalFields, false);
this.removeSplitField();
this.removePopulationField();
this.removeAllDetectors();
if (detectors.length) {
this.setRareField(detectors[0].byField);
this.frequentlyRare = detectors[0].agg?.id === ML_JOB_AGGREGATION.FREQ_RARE;
if (detectors[0].overField !== null) {
this.setPopulationField(detectors[0].overField);
this.rareInPopulation = true;
}
if (detectors[0].partitionField !== null) {
this.setSplitField(detectors[0].partitionField);
}
}
}
}

View file

@ -10,6 +10,7 @@ import { MultiMetricJobCreator } from './multi_metric_job_creator';
import { PopulationJobCreator } from './population_job_creator';
import { AdvancedJobCreator } from './advanced_job_creator';
import { CategorizationJobCreator } from './categorization_job_creator';
import { RareJobCreator } from './rare_job_creator';
import { JOB_TYPE } from '../../../../../../common/constants/new_job';
export type JobCreatorType =
@ -17,7 +18,8 @@ export type JobCreatorType =
| MultiMetricJobCreator
| PopulationJobCreator
| AdvancedJobCreator
| CategorizationJobCreator;
| CategorizationJobCreator
| RareJobCreator;
export function isSingleMetricJobCreator(
jobCreator: JobCreatorType
@ -46,3 +48,7 @@ export function isCategorizationJobCreator(
): jobCreator is CategorizationJobCreator {
return jobCreator.type === JOB_TYPE.CATEGORIZATION;
}
export function isRareJobCreator(jobCreator: JobCreatorType): jobCreator is RareJobCreator {
return jobCreator.type === JOB_TYPE.RARE;
}

View file

@ -311,6 +311,10 @@ export function getJobCreatorTitle(jobCreator: JobCreatorType) {
return i18n.translate('xpack.ml.newJob.wizard.jobCreatorTitle.categorization', {
defaultMessage: 'Categorization',
});
case JOB_TYPE.RARE:
return i18n.translate('xpack.ml.newJob.wizard.jobCreatorTitle.rare', {
defaultMessage: 'Rare',
});
default:
return '';
}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { Fragment, FC, useContext, useState } from 'react';
import React, { Fragment, FC, useContext, useState, useEffect } from 'react';
import { JobCreatorContext } from '../../../job_creator_context';
import { AdvancedJobCreator } from '../../../../../common/job_creator';
@ -33,12 +33,16 @@ const emptyRichDetector: RichDetector = {
};
export const AdvancedDetectors: FC<Props> = ({ setIsValid }) => {
const { jobCreator: jc, jobCreatorUpdate } = useContext(JobCreatorContext);
const { jobCreator: jc, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext);
const jobCreator = jc as AdvancedJobCreator;
const { fields, aggs } = newJobCapsService;
const [modalPayload, setModalPayload] = useState<ModalPayload | null>(null);
useEffect(() => {
setIsValid(jobCreator.detectors.length > 0);
}, [jobCreatorUpdated]);
function closeModal() {
setModalPayload(null);
}

View file

@ -14,6 +14,7 @@ import {
isMultiMetricJobCreator,
isPopulationJobCreator,
isAdvancedJobCreator,
isRareJobCreator,
} from '../../../../../common/job_creator';
import { ml } from '../../../../../../../services/ml_api_service';
import { useMlContext } from '../../../../../../../contexts/ml';
@ -45,11 +46,17 @@ export function useEstimateBucketSpan() {
indicesOptions: jobCreator.datafeedConfig.indices_options,
};
if (
(isMultiMetricJobCreator(jobCreator) || isPopulationJobCreator(jobCreator)) &&
jobCreator.splitField !== null
) {
if (isMultiMetricJobCreator(jobCreator) && jobCreator.splitField !== null) {
data.splitField = jobCreator.splitField.id;
} else if (isPopulationJobCreator(jobCreator) && jobCreator.populationField !== null) {
data.splitField = jobCreator.populationField.id;
} else if (isRareJobCreator(jobCreator)) {
data.fields = [null];
if (jobCreator.populationField) {
data.splitField = jobCreator.populationField.id;
} else {
data.splitField = jobCreator.rareField?.id;
}
} else if (isAdvancedJobCreator(jobCreator)) {
jobCreator.richDetectors.some((d) => {
if (d.partitionField !== null) {

View file

@ -8,14 +8,14 @@
import React, { FC, useContext, useEffect, useState, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { SplitFieldSelect } from './split_field_select';
import { SplitFieldSelect } from '../split_field_select';
import { JobCreatorContext } from '../../../job_creator_context';
import { Field } from '../../../../../../../../../common/types/fields';
import {
newJobCapsService,
filterCategoryFields,
} from '../../../../../../../services/new_job_capabilities/new_job_capabilities_service';
import { MultiMetricJobCreator, PopulationJobCreator } from '../../../../../common/job_creator';
import { PopulationJobCreator } from '../../../../../common/job_creator';
interface Props {
detectorIndex: number;
@ -69,18 +69,18 @@ export const ByFieldSelector: FC<Props> = ({ detectorIndex }) => {
);
};
// remove the split (over) field from the by field options
// remove the population (over) field from the by field options
function useFilteredCategoryFields(
allCategoryFields: Field[],
jobCreator: MultiMetricJobCreator | PopulationJobCreator,
jobCreator: PopulationJobCreator,
jobCreatorUpdated: number
) {
const [fields, setFields] = useState(allCategoryFields);
useEffect(() => {
const sf = jobCreator.splitField;
if (sf !== null) {
setFields(allCategoryFields.filter((f) => f.name !== sf.name));
const pf = jobCreator.populationField;
if (pf !== null) {
setFields(allCategoryFields.filter(({ name }) => name !== pf.name));
} else {
setFields(allCategoryFields);
}

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { ByFieldSelector } from './by_field';

View file

@ -0,0 +1,32 @@
/*
* 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, { memo, FC } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui';
export const Description: FC = memo(({ children }) => {
const title = i18n.translate('xpack.ml.newJob.wizard.pickFieldsStep.populationField.title', {
defaultMessage: 'Population field',
});
return (
<EuiDescribedFormGroup
title={<h3>{title}</h3>}
description={
<FormattedMessage
id="xpack.ml.newJob.wizard.pickFieldsStep.populationField.description"
defaultMessage="All values in the selected field will be modeled together as a population. This analysis type is recommended for high cardinality data."
/>
}
>
<EuiFormRow label={title}>
<>{children}</>
</EuiFormRow>
</EuiDescribedFormGroup>
);
});

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { PopulationFieldSelector } from './population_field';

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useContext, useEffect, useState, useMemo } from 'react';
import { SplitFieldSelect } from '../split_field_select';
import { JobCreatorContext } from '../../../job_creator_context';
import { Field } from '../../../../../../../../../common/types/fields';
import {
newJobCapsService,
filterCategoryFields,
} from '../../../../../../../services/new_job_capabilities/new_job_capabilities_service';
import { Description } from './description';
import {
PopulationJobCreator,
RareJobCreator,
isPopulationJobCreator,
} from '../../../../../common/job_creator';
export const PopulationFieldSelector: FC = () => {
const { jobCreator: jc, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext);
const jobCreator = jc as PopulationJobCreator | RareJobCreator;
const runtimeCategoryFields = useMemo(() => filterCategoryFields(jobCreator.runtimeFields), []);
const allCategoryFields = useMemo(
() => [...newJobCapsService.categoryFields, ...runtimeCategoryFields],
[]
);
const categoryFields = useFilteredCategoryFields(
allCategoryFields,
jobCreator,
jobCreatorUpdated
);
const [populationField, setPopulationField] = useState(jobCreator.populationField);
useEffect(() => {
jobCreator.setPopulationField(populationField);
// add the split field to the influencers
if (
populationField !== null &&
jobCreator.influencers.includes(populationField.name) === false
) {
jobCreator.addInfluencer(populationField.name);
}
jobCreatorUpdate();
}, [populationField]);
useEffect(() => {
setPopulationField(jobCreator.populationField);
}, [jobCreatorUpdated]);
return (
<Description>
<SplitFieldSelect
fields={categoryFields}
changeHandler={setPopulationField}
selectedField={populationField}
isClearable={false}
testSubject="mlPopulationSplitFieldSelect"
/>
</Description>
);
};
// remove the rare (by) field from the by field options in the rare wizard
function useFilteredCategoryFields(
allCategoryFields: Field[],
jobCreator: PopulationJobCreator | RareJobCreator,
jobCreatorUpdated: number
) {
const [fields, setFields] = useState(allCategoryFields);
useEffect(() => {
if (isPopulationJobCreator(jobCreator)) {
setFields(allCategoryFields);
} else {
const rf = jobCreator.rareField;
const sf = jobCreator.splitField;
if (rf !== null || sf !== null) {
setFields(allCategoryFields.filter(({ name }) => name !== rf?.name && name !== sf?.name));
} else {
setFields(allCategoryFields);
}
}
}, [jobCreatorUpdated]);
return fields;
}

View file

@ -15,7 +15,7 @@ import { ModelItem, Anomaly } from '../../../../../common/results_loader';
import { JOB_TYPE } from '../../../../../../../../../common/constants/new_job';
import { SplitCards, useAnimateSplit } from '../split_cards';
import { DetectorTitle } from '../detector_title';
import { ByFieldSelector } from '../split_field';
import { ByFieldSelector } from '../by_field';
import { AnomalyChart, CHART_TYPE } from '../../../charts/anomaly_chart';
type DetectorFieldValues = Record<number, string[]>;

View file

@ -17,7 +17,7 @@ import { Field, AggFieldPair } from '../../../../../../../../../common/types/fie
import { sortFields } from '../../../../../../../../../common/util/fields_utils';
import { getChartSettings, defaultChartSettings } from '../../../charts/common/settings';
import { MetricSelector } from './metric_selector';
import { SplitFieldSelector } from '../split_field';
import { PopulationFieldSelector } from '../population_field';
import { ChartGrid } from './chart_grid';
import { getToastNotificationService } from '../../../../../../../services/toast_notification_service';
@ -51,7 +51,7 @@ export const PopulationDetectors: FC<Props> = ({ setIsValid }) => {
const [end, setEnd] = useState(jobCreator.end);
const [bucketSpanMs, setBucketSpanMs] = useState(jobCreator.bucketSpanMs);
const [chartSettings, setChartSettings] = useState(defaultChartSettings);
const [splitField, setSplitField] = useState(jobCreator.splitField);
const [populationField, setPopulationField] = useState(jobCreator.populationField);
const [fieldValuesPerDetector, setFieldValuesPerDetector] = useState<DetectorFieldValues>({});
const [byFieldsUpdated, setByFieldsUpdated] = useReducer<(s: number, action: any) => number>(
(s) => s + 1,
@ -108,7 +108,7 @@ export const PopulationDetectors: FC<Props> = ({ setIsValid }) => {
// if the split field or by fields have changed
useEffect(() => {
loadCharts();
}, [JSON.stringify(fieldValuesPerDetector), splitField, pageReady]);
}, [JSON.stringify(fieldValuesPerDetector), populationField, pageReady]);
// watch for change in jobCreator
useEffect(() => {
@ -123,7 +123,7 @@ export const PopulationDetectors: FC<Props> = ({ setIsValid }) => {
loadCharts();
}
setSplitField(jobCreator.splitField);
setPopulationField(jobCreator.populationField);
// update by fields and their by fields
let update = false;
@ -146,7 +146,7 @@ export const PopulationDetectors: FC<Props> = ({ setIsValid }) => {
// changes to fieldValues here will trigger the card effect via setFieldValuesPerDetector
useEffect(() => {
loadFieldExamples();
}, [splitField, byFieldsUpdated]);
}, [populationField, byFieldsUpdated]);
async function loadCharts() {
if (allDataReady()) {
@ -158,7 +158,7 @@ export const PopulationDetectors: FC<Props> = ({ setIsValid }) => {
jobCreator.start,
jobCreator.end,
aggFieldPairList,
jobCreator.splitField,
jobCreator.populationField,
cs.intervalMs,
jobCreator.runtimeMappings,
jobCreator.datafeedConfig.indices_options
@ -225,14 +225,14 @@ export const PopulationDetectors: FC<Props> = ({ setIsValid }) => {
return (
<Fragment>
<SplitFieldSelector />
{splitField !== null && <EuiHorizontalRule margin="l" />}
<PopulationFieldSelector />
{populationField !== null && <EuiHorizontalRule margin="l" />}
{splitField !== null && (
{populationField !== null && (
<ChartGrid
aggFieldPairList={aggFieldPairList}
chartSettings={chartSettings}
splitField={splitField}
splitField={populationField}
lineChartsData={lineChartsData}
modelData={[]}
anomalyData={[]}
@ -242,7 +242,7 @@ export const PopulationDetectors: FC<Props> = ({ setIsValid }) => {
loading={loadingData}
/>
)}
{splitField !== null && (
{populationField !== null && (
<MetricSelector
fields={fields}
detectorChangeHandler={detectorChangeHandler}

View file

@ -57,14 +57,14 @@ export const PopulationDetectorsSummary: FC = () => {
if (allDataReady()) {
loadCharts();
}
}, [JSON.stringify(fieldValuesPerDetector), jobCreator.splitField]);
}, [JSON.stringify(fieldValuesPerDetector), jobCreator.populationField]);
// watch for changes in split field or by fields.
// load example field values
// changes to fieldValues here will trigger the card effect via setFieldValuesPerDetector
useEffect(() => {
loadFieldExamples();
}, [jobCreator.splitField]);
}, [jobCreator.populationField]);
async function loadCharts() {
if (allDataReady()) {
@ -76,7 +76,7 @@ export const PopulationDetectorsSummary: FC = () => {
jobCreator.start,
jobCreator.end,
aggFieldPairList,
jobCreator.splitField,
jobCreator.populationField,
cs.intervalMs,
jobCreator.runtimeMappings,
jobCreator.datafeedConfig.indices_options
@ -143,18 +143,18 @@ export const PopulationDetectorsSummary: FC = () => {
return (
<Fragment>
{jobCreator.splitField !== null && (
{jobCreator.populationField !== null && (
<Fragment>
<FormattedMessage
id="xpack.ml.newJob.wizard.pickFieldsStep.populationView.splitFieldTitle"
defaultMessage="Population split by {field}"
values={{ field: jobCreator.splitField.name }}
values={{ field: jobCreator.populationField.name }}
/>
<EuiSpacer />
<ChartGrid
aggFieldPairList={jobCreator.aggFieldPairs}
chartSettings={chartSettings}
splitField={jobCreator.splitField}
splitField={jobCreator.populationField}
lineChartsData={lineChartsData}
modelData={modelData}
anomalyData={anomalyData}

View file

@ -0,0 +1,85 @@
/*
* 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, { FC } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { EuiFlexItem, EuiCard } from '@elastic/eui';
interface CardProps {
onClick: () => void;
isSelected: boolean;
}
export const RareCard: FC<CardProps> = ({ onClick, isSelected }) => (
<EuiFlexItem>
<EuiCard
data-test-subj={`mlJobWizardCategorizationDetectorRareCard${isSelected ? ' selected' : ''}`}
title={i18n.translate(
'xpack.ml.newJob.wizard.pickFieldsStep.rareDetectorSelect.rareCard.title',
{
defaultMessage: 'Rare',
}
)}
description={
<>
<FormattedMessage
id="xpack.ml.newJob.wizard.pickFieldsStep.rareDetectorSelect.rareCard.description"
defaultMessage="Look for distinct rare values in data over time."
/>
</>
}
selectable={{ onClick, isSelected }}
/>
</EuiFlexItem>
);
export const RareInPopulationCard: FC<CardProps> = ({ onClick, isSelected }) => (
<EuiFlexItem>
<EuiCard
data-test-subj={`mlJobWizardCategorizationDetectorRareCard${isSelected ? ' selected' : ''}`}
title={i18n.translate(
'xpack.ml.newJob.wizard.pickFieldsStep.rareDetectorSelect.rarePopulationCard.title',
{
defaultMessage: 'Rare in population',
}
)}
description={
<>
<FormattedMessage
id="xpack.ml.newJob.wizard.pickFieldsStep.rareDetectorSelect.rarePopulationCard.description"
defaultMessage="Look for rare values in a population."
/>
</>
}
selectable={{ onClick, isSelected }}
/>
</EuiFlexItem>
);
export const FrequentlyRareInPopulationCard: FC<CardProps> = ({ onClick, isSelected }) => (
<EuiFlexItem>
<EuiCard
data-test-subj={`mlJobWizardCategorizationDetectorRareCard${isSelected ? ' selected' : ''}`}
title={i18n.translate(
'xpack.ml.newJob.wizard.pickFieldsStep.rareDetectorSelect.freqRareCard.title',
{
defaultMessage: 'Frequently rare in population',
}
)}
description={
<>
<FormattedMessage
id="xpack.ml.newJob.wizard.pickFieldsStep.rareDetectorSelect.freqRareCard.description"
defaultMessage="Look for frequently rare values in a population."
/>
</>
}
selectable={{ onClick, isSelected }}
/>
</EuiFlexItem>
);

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { RareDetector } from './rare_detector';

View file

@ -0,0 +1,90 @@
/*
* 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, { FC, useContext, useEffect, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiFlexGroup, EuiSpacer, EuiTitle } from '@elastic/eui';
import { JobCreatorContext } from '../../../job_creator_context';
import { RareJobCreator } from '../../../../../common/job_creator';
import { RareCard, RareInPopulationCard, FrequentlyRareInPopulationCard } from './detector_cards';
import { RARE_DETECTOR_TYPE } from '../rare_view';
interface Props {
onChange(d: RARE_DETECTOR_TYPE): void;
}
export const RareDetector: FC<Props> = ({ onChange }) => {
const { jobCreator: jc, jobCreatorUpdate } = useContext(JobCreatorContext);
const jobCreator = jc as RareJobCreator;
const [rareDetectorType, setRareDetectorType] = useState<RARE_DETECTOR_TYPE | null>(null);
useEffect(() => {
if (jobCreator.rareField !== null) {
if (jobCreator.populationField === null) {
setRareDetectorType(RARE_DETECTOR_TYPE.RARE);
} else {
setRareDetectorType(
jobCreator.frequentlyRare
? RARE_DETECTOR_TYPE.FREQ_RARE_POPULATION
: RARE_DETECTOR_TYPE.RARE_POPULATION
);
}
} else {
setRareDetectorType(RARE_DETECTOR_TYPE.RARE);
}
}, []);
useEffect(() => {
if (rareDetectorType !== null) {
onChange(rareDetectorType);
if (rareDetectorType === RARE_DETECTOR_TYPE.RARE && jobCreator.populationField !== null) {
jobCreator.removePopulationField();
}
jobCreator.frequentlyRare = rareDetectorType === RARE_DETECTOR_TYPE.FREQ_RARE_POPULATION;
jobCreatorUpdate();
}
}, [rareDetectorType]);
function onRareSelection() {
setRareDetectorType(RARE_DETECTOR_TYPE.RARE);
}
function onRareInPopulationSelection() {
setRareDetectorType(RARE_DETECTOR_TYPE.RARE_POPULATION);
}
function onFreqRareInPopulationSelection() {
setRareDetectorType(RARE_DETECTOR_TYPE.FREQ_RARE_POPULATION);
}
return (
<>
<EuiTitle size="xs">
<h3>
<FormattedMessage
id="xpack.ml.newJob.wizard.pickFieldsStep.rareDetectorSelect.title"
defaultMessage="Rare detector"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFlexGroup gutterSize="l" style={{ maxWidth: '824px' }}>
<RareCard
onClick={onRareSelection}
isSelected={rareDetectorType === RARE_DETECTOR_TYPE.RARE}
/>
<RareInPopulationCard
onClick={onRareInPopulationSelection}
isSelected={rareDetectorType === RARE_DETECTOR_TYPE.RARE_POPULATION}
/>
<FrequentlyRareInPopulationCard
onClick={onFreqRareInPopulationSelection}
isSelected={rareDetectorType === RARE_DETECTOR_TYPE.FREQ_RARE_POPULATION}
/>
</EuiFlexGroup>
</>
);
};

View file

@ -0,0 +1,32 @@
/*
* 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, { memo, FC } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui';
export const Description: FC = memo(({ children }) => {
const title = i18n.translate('xpack.ml.newJob.wizard.pickFieldsStep.splitRareField.title', {
defaultMessage: 'Rare field',
});
return (
<EuiDescribedFormGroup
title={<h3>{title}</h3>}
description={
<FormattedMessage
id="xpack.ml.newJob.wizard.pickFieldsStep.rareField.description"
defaultMessage="Select a field in which to detect rare values."
/>
}
>
<EuiFormRow label={title}>
<>{children}</>
</EuiFormRow>
</EuiDescribedFormGroup>
);
});

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { RareFieldSelector } from './rare_field';

View file

@ -0,0 +1,81 @@
/*
* 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, { FC, useContext, useEffect, useState, useMemo } from 'react';
import { RareFieldSelect } from './rare_field_select';
import { JobCreatorContext } from '../../../job_creator_context';
import {
newJobCapsService,
filterCategoryFields,
} from '../../../../../../../services/new_job_capabilities/new_job_capabilities_service';
import { Description } from './description';
import { Field } from '../../../../../../../../../common/types/fields';
import { RareJobCreator } from '../../../../../common/job_creator';
export const RareFieldSelector: FC = () => {
const { jobCreator: jc, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext);
const jobCreator = jc as RareJobCreator;
const runtimeCategoryFields = useMemo(() => filterCategoryFields(jobCreator.runtimeFields), []);
const allCategoryFields = useMemo(
() => [...newJobCapsService.categoryFields, ...runtimeCategoryFields],
[]
);
const categoryFields = useFilteredCategoryFields(
allCategoryFields,
jobCreator,
jobCreatorUpdated
);
const [rareField, setRareField] = useState(jobCreator.rareField);
useEffect(() => {
jobCreator.setRareField(rareField);
// add the split field to the influencers
if (rareField !== null && jobCreator.influencers.includes(rareField.name) === false) {
jobCreator.addInfluencer(rareField.name);
}
jobCreatorUpdate();
}, [rareField]);
useEffect(() => {
setRareField(jobCreator.rareField);
}, [jobCreatorUpdated]);
return (
<Description>
<RareFieldSelect
fields={categoryFields}
changeHandler={setRareField}
selectedField={rareField}
testSubject="mlRareFieldSelect"
/>
</Description>
);
};
// remove the rare (by) field from the by field options in the rare wizard
function useFilteredCategoryFields(
allCategoryFields: Field[],
jobCreator: RareJobCreator,
jobCreatorUpdated: number
) {
const [fields, setFields] = useState(allCategoryFields);
useEffect(() => {
const pf = jobCreator.populationField;
const sf = jobCreator.splitField;
if (pf !== null || sf !== null) {
setFields(allCategoryFields.filter(({ name }) => name !== pf?.name && name !== sf?.name));
} else {
setFields(allCategoryFields);
}
}, [jobCreatorUpdated]);
return fields;
}

View file

@ -0,0 +1,66 @@
/*
* 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, { FC } from 'react';
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import { Field, SplitField } from '../../../../../../../../../common/types/fields';
interface DropDownLabel {
label: string;
field: Field;
}
interface Props {
fields: Field[];
changeHandler(f: SplitField): void;
selectedField: SplitField;
testSubject?: string;
placeholder?: string;
}
export const RareFieldSelect: FC<Props> = ({
fields,
changeHandler,
selectedField,
testSubject,
placeholder,
}) => {
const options: EuiComboBoxOptionOption[] = fields.map(
(f) =>
({
label: f.name,
field: f,
} as DropDownLabel)
);
const selection: EuiComboBoxOptionOption[] = [];
if (selectedField !== null) {
selection.push({ label: selectedField.name, field: selectedField } as DropDownLabel);
}
function onChange(selectedOptions: EuiComboBoxOptionOption[]) {
const option = selectedOptions[0] as DropDownLabel;
if (typeof option !== 'undefined') {
changeHandler(option.field);
} else {
changeHandler(null);
}
}
return (
<EuiComboBox
singleSelection={{ asPlainText: true }}
options={options}
selectedOptions={selection}
onChange={onChange}
placeholder={placeholder}
data-test-subj={testSubject}
isClearable={false}
/>
);
};

View file

@ -0,0 +1,112 @@
/*
* 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';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { FC, useContext, useEffect, useState } from 'react';
import { EuiCallOut } from '@elastic/eui';
import { JobCreatorContext } from '../../../job_creator_context';
import { RareJobCreator } from '../../../../../common/job_creator';
import { RARE_DETECTOR_TYPE } from './rare_view';
interface Props {
detectorType: RARE_DETECTOR_TYPE;
}
export const DetectorDescription: FC<Props> = ({ detectorType }) => {
const { jobCreator: jc, jobCreatorUpdated } = useContext(JobCreatorContext);
const jobCreator = jc as RareJobCreator;
const [description, setDescription] = useState<string[] | null>(null);
useEffect(() => {
const desc = createDetectorDescription(jobCreator, detectorType);
setDescription(desc);
}, [jobCreatorUpdated]);
if (description === null) {
return null;
}
return (
<EuiCallOut
title={i18n.translate(
'xpack.ml.newJob.wizard.pickFieldsStep.rareField.plainText.calloutTitle',
{
defaultMessage: 'Detector summary',
}
)}
>
<FormattedMessage
id="xpack.ml.newJob.wizard.pickFieldsStep.rareField.plainText.title"
defaultMessage="This job:"
/>
<ul>
{description.map((d) => (
<li>{d}</li>
))}
</ul>
</EuiCallOut>
);
};
function createDetectorDescription(jobCreator: RareJobCreator, detectorType: RARE_DETECTOR_TYPE) {
if (jobCreator.rareField === null) {
return null;
}
const rareFieldName = jobCreator.rareField.id;
const populationFieldName = jobCreator.populationField?.id;
const splitFieldName = jobCreator.splitField?.id;
const beginningSummary = i18n.translate(
'xpack.ml.newJob.wizard.pickFieldsStep.rareField.plainText.beginningSummary',
{
defaultMessage: 'detects rare values of {rareFieldName}',
values: { rareFieldName },
}
);
const beginningSummaryFreq = i18n.translate(
'xpack.ml.newJob.wizard.pickFieldsStep.rareField.plainText.beginningSummaryFreq',
{
defaultMessage: 'detects frequently rare values of {rareFieldName}',
values: { rareFieldName },
}
);
const population = i18n.translate(
'xpack.ml.newJob.wizard.pickFieldsStep.rareField.plainText.population',
{
defaultMessage: 'compared to the population of {populationFieldName}',
values: { populationFieldName },
}
);
const split = i18n.translate('xpack.ml.newJob.wizard.pickFieldsStep.rareField.plainText.split', {
defaultMessage: 'for each value of {splitFieldName}',
values: { splitFieldName },
});
const desc = [];
if (detectorType === RARE_DETECTOR_TYPE.FREQ_RARE_POPULATION) {
desc.push(beginningSummaryFreq);
} else {
desc.push(beginningSummary);
}
if (populationFieldName !== undefined) {
desc.push(population);
}
if (splitFieldName !== undefined) {
desc.push(split);
}
return desc;
}

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export { RareView } from './rare_view';
export { RARE_DETECTOR_TYPE } from './rare_view';

View file

@ -0,0 +1,67 @@
/*
* 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, { FC, useContext, useEffect, useState } from 'react';
import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { RareFieldSelector } from '../rare_field';
import { JobCreatorContext } from '../../../job_creator_context';
import { RareJobCreator } from '../../../../../common/job_creator';
import { RareDetector } from '../rare_detector';
import { PopulationFieldSelector } from '../population_field';
import { DetectorDescription } from './detector_description';
import { RARE_DETECTOR_TYPE } from './rare_view';
interface Props {
setIsValid: (na: boolean) => void;
setRareDetectorType(t: RARE_DETECTOR_TYPE): void;
rareDetectorType: RARE_DETECTOR_TYPE;
}
export const RareDetectors: FC<Props> = ({ setIsValid, rareDetectorType, setRareDetectorType }) => {
const { jobCreator: jc, jobCreatorUpdated } = useContext(JobCreatorContext);
const jobCreator = jc as RareJobCreator;
const [detectorValid, setDetectorValid] = useState(false);
useEffect(() => {
let valid = false;
if (jobCreator.rareField !== null) {
if (rareDetectorType === RARE_DETECTOR_TYPE.RARE) {
// Rare only requires a rare field to be set
valid = true;
} else if (jobCreator.populationField !== null) {
// all others need a need the population field to be set
valid = true;
}
}
setIsValid(valid);
setDetectorValid(valid);
}, [jobCreatorUpdated]);
return (
<>
<RareDetector onChange={setRareDetectorType} />
<>
<EuiHorizontalRule />
<EuiFlexGroup>
<EuiFlexItem>
<RareFieldSelector />
</EuiFlexItem>
<EuiFlexItem>
{rareDetectorType !== RARE_DETECTOR_TYPE.RARE && <PopulationFieldSelector />}
</EuiFlexItem>
</EuiFlexGroup>
{detectorValid && (
<>
<EuiSpacer size="m" />
<DetectorDescription detectorType={rareDetectorType} />
</>
)}
</>
</>
);
};

View file

@ -0,0 +1,88 @@
/*
* 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, { FC, useContext, useEffect, useState } from 'react';
import { EuiSpacer } from '@elastic/eui';
import { JobCreatorContext } from '../../../job_creator_context';
import { RareJobCreator } from '../../../../../common/job_creator';
import { Results, Anomaly } from '../../../../../common/results_loader';
import { LineChartPoint } from '../../../../../common/chart_loader';
import { EventRateChart } from '../../../charts/event_rate_chart';
import { RARE_DETECTOR_TYPE } from './rare_view';
import { DetectorDescription } from './detector_description';
const DTR_IDX = 0;
interface Props {
rareDetectorType: RARE_DETECTOR_TYPE;
}
export const RareDetectorsSummary: FC<Props> = ({ rareDetectorType }) => {
const { jobCreator: jc, chartLoader, resultsLoader, chartInterval } = useContext(
JobCreatorContext
);
const jobCreator = jc as RareJobCreator;
const [loadingData, setLoadingData] = useState(false);
const [anomalyData, setAnomalyData] = useState<Anomaly[]>([]);
const [eventRateChartData, setEventRateChartData] = useState<LineChartPoint[]>([]);
const [jobIsRunning, setJobIsRunning] = useState(false);
function setResultsWrapper(results: Results) {
const anomalies = results.anomalies[DTR_IDX];
if (anomalies !== undefined) {
setAnomalyData(anomalies);
}
}
function watchProgress(progress: number) {
setJobIsRunning(progress > 0);
}
useEffect(() => {
// subscribe to progress and results
const resultsSubscription = resultsLoader.subscribeToResults(setResultsWrapper);
jobCreator.subscribeToProgress(watchProgress);
loadChart();
return () => {
resultsSubscription.unsubscribe();
};
}, []);
async function loadChart() {
setLoadingData(true);
try {
const resp = await chartLoader.loadEventRateChart(
jobCreator.start,
jobCreator.end,
chartInterval.getInterval().asMilliseconds(),
jobCreator.runtimeMappings ?? undefined,
jobCreator.datafeedConfig.indices_options
);
setEventRateChartData(resp);
} catch (error) {
setEventRateChartData([]);
}
setLoadingData(false);
}
return (
<>
<DetectorDescription detectorType={rareDetectorType} />
<EuiSpacer size="s" />
<EventRateChart
eventRateChartData={eventRateChartData}
anomalyData={anomalyData}
height="300px"
width="100%"
showAxis={true}
loading={loadingData}
fadeChart={jobIsRunning}
/>
</>
);
};

View file

@ -0,0 +1,54 @@
/*
* 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, { FC, useEffect, useState } from 'react';
import { EuiHorizontalRule } from '@elastic/eui';
import { RareDetectors } from './metric_selection';
import { RareDetectorsSummary } from './metric_selection_summary';
import { RareSettings } from './settings';
export enum RARE_DETECTOR_TYPE {
RARE,
RARE_POPULATION,
FREQ_RARE_POPULATION,
}
interface Props {
isActive: boolean;
setCanProceed?: (proceed: boolean) => void;
}
export const RareView: FC<Props> = ({ isActive, setCanProceed }) => {
const [rareFieldValid, setRareFieldValid] = useState(false);
const [settingsValid, setSettingsValid] = useState(false);
const [rareDetectorType, setRareDetectorType] = useState(RARE_DETECTOR_TYPE.RARE);
useEffect(() => {
if (typeof setCanProceed === 'function') {
setCanProceed(rareFieldValid && settingsValid);
}
}, [rareFieldValid, settingsValid]);
return isActive === false ? (
<RareDetectorsSummary rareDetectorType={rareDetectorType} />
) : (
<>
<RareDetectors
setIsValid={setRareFieldValid}
rareDetectorType={rareDetectorType}
setRareDetectorType={setRareDetectorType}
/>
{rareFieldValid && (
<>
<EuiHorizontalRule margin="l" />
<RareSettings setIsValid={setSettingsValid} />
</>
)}
</>
);
};

View file

@ -0,0 +1,38 @@
/*
* 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, { FC } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { BucketSpan } from '../bucket_span';
import { SplitFieldSelector } from '../split_field';
import { Influencers } from '../influencers';
interface Props {
setIsValid: (proceed: boolean) => void;
}
export const RareSettings: FC<Props> = ({ setIsValid }) => {
return (
<>
<EuiFlexGroup gutterSize="xl">
<EuiFlexItem>
<SplitFieldSelector />
</EuiFlexItem>
<EuiFlexItem>
<Influencers />
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup gutterSize="xl">
<EuiFlexItem>
<BucketSpan setIsValid={setIsValid} />
</EuiFlexItem>
<EuiFlexItem />
</EuiFlexGroup>
</>
);
};

View file

@ -10,52 +10,23 @@ import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui';
import { JOB_TYPE } from '../../../../../../../../../common/constants/new_job';
interface Props {
jobType: JOB_TYPE;
}
export const Description: FC<Props> = memo(({ children, jobType }) => {
if (jobType === JOB_TYPE.MULTI_METRIC) {
const title = i18n.translate('xpack.ml.newJob.wizard.pickFieldsStep.splitField.title', {
defaultMessage: 'Split field',
});
return (
<EuiDescribedFormGroup
title={<h3>{title}</h3>}
description={
<FormattedMessage
id="xpack.ml.newJob.wizard.pickFieldsStep.splitField.description"
defaultMessage="Select a field to partition analysis by. Each value of this field will be modeled independently individually."
/>
}
>
<EuiFormRow label={title}>
<>{children}</>
</EuiFormRow>
</EuiDescribedFormGroup>
);
} else if (jobType === JOB_TYPE.POPULATION) {
const title = i18n.translate('xpack.ml.newJob.wizard.pickFieldsStep.populationField.title', {
defaultMessage: 'Population field',
});
return (
<EuiDescribedFormGroup
title={<h3>{title}</h3>}
description={
<FormattedMessage
id="xpack.ml.newJob.wizard.pickFieldsStep.populationField.description"
defaultMessage="All values in the selected field will be modeled together as a population. This analysis type is recommended for high cardinality data."
/>
}
>
<EuiFormRow label={title}>
<>{children}</>
</EuiFormRow>
</EuiDescribedFormGroup>
);
} else {
return null;
}
export const Description: FC = memo(({ children }) => {
const title = i18n.translate('xpack.ml.newJob.wizard.pickFieldsStep.splitField.title', {
defaultMessage: 'Split field',
});
return (
<EuiDescribedFormGroup
title={<h3>{title}</h3>}
description={
<FormattedMessage
id="xpack.ml.newJob.wizard.pickFieldsStep.splitField.description"
defaultMessage="Select a field to split analysis by. Each value of this field will be modeled independently."
/>
}
>
<EuiFormRow label={title}>
<>{children}</>
</EuiFormRow>
</EuiDescribedFormGroup>
);
});

View file

@ -5,5 +5,4 @@
* 2.0.
*/
export { ByFieldSelector } from './by_field';
export { SplitFieldSelector } from './split_field';

View file

@ -7,30 +7,34 @@
import React, { FC, useContext, useEffect, useState, useMemo } from 'react';
import { SplitFieldSelect } from './split_field_select';
import { SplitFieldSelect } from '../split_field_select';
import { JobCreatorContext } from '../../../job_creator_context';
import {
newJobCapsService,
filterCategoryFields,
} from '../../../../../../../services/new_job_capabilities/new_job_capabilities_service';
import { Description } from './description';
import { Field } from '../../../../../../../../../common/types/fields';
import {
MultiMetricJobCreator,
RareJobCreator,
isMultiMetricJobCreator,
PopulationJobCreator,
isPopulationJobCreator,
} from '../../../../../common/job_creator';
export const SplitFieldSelector: FC = () => {
const { jobCreator: jc, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext);
const jobCreator = jc as MultiMetricJobCreator | PopulationJobCreator;
const canClearSelection = isMultiMetricJobCreator(jc);
const jobCreator = jc as MultiMetricJobCreator | RareJobCreator;
const runtimeCategoryFields = useMemo(() => filterCategoryFields(jobCreator.runtimeFields), []);
const categoryFields = useMemo(
const allCategoryFields = useMemo(
() => [...newJobCapsService.categoryFields, ...runtimeCategoryFields],
[]
);
const categoryFields = useFilteredCategoryFields(
allCategoryFields,
jobCreator,
jobCreatorUpdated
);
const [splitField, setSplitField] = useState(jobCreator.splitField);
useEffect(() => {
@ -47,20 +51,39 @@ export const SplitFieldSelector: FC = () => {
}, [jobCreatorUpdated]);
return (
<Description jobType={jobCreator.type}>
<Description>
<SplitFieldSelect
fields={categoryFields}
changeHandler={setSplitField}
selectedField={splitField}
isClearable={canClearSelection}
testSubject={
isMultiMetricJobCreator(jc)
? 'mlMultiMetricSplitFieldSelect'
: isPopulationJobCreator(jc)
? 'mlPopulationSplitFieldSelect'
: undefined
}
isClearable={true}
testSubject="mlMultiMetricSplitFieldSelect"
/>
</Description>
);
};
// remove the rare (by) and population (over) fields from the by field options in the rare wizard
function useFilteredCategoryFields(
allCategoryFields: Field[],
jobCreator: MultiMetricJobCreator | RareJobCreator,
jobCreatorUpdated: number
) {
const [fields, setFields] = useState(allCategoryFields);
useEffect(() => {
if (isMultiMetricJobCreator(jobCreator)) {
setFields(allCategoryFields);
} else {
const rf = jobCreator.rareField;
const pf = jobCreator.populationField;
if (rf !== null || pf !== null) {
setFields(allCategoryFields.filter(({ name }) => name !== rf?.name && name !== pf?.name));
} else {
setFields(allCategoryFields);
}
}
}, [jobCreatorUpdated]);
return fields;
}

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { SplitFieldSelect } from './split_field_select';

View file

@ -15,6 +15,7 @@ import { MultiMetricView } from './components/multi_metric_view';
import { PopulationView } from './components/population_view';
import { AdvancedView } from './components/advanced_view';
import { CategorizationView } from './components/categorization_view';
import { RareView } from './components/rare_view';
import { JsonEditorFlyout, EDITOR_MODE } from '../common/json_editor_flyout';
import {
isSingleMetricJobCreator,
@ -22,34 +23,39 @@ import {
isPopulationJobCreator,
isCategorizationJobCreator,
isAdvancedJobCreator,
isRareJobCreator,
} from '../../../common/job_creator';
export const PickFieldsStep: FC<StepProps> = ({ setCurrentStep, isCurrentStep }) => {
const { jobCreator, jobValidator, jobValidatorUpdated } = useContext(JobCreatorContext);
const [nextActive, setNextActive] = useState(false);
const [selectionValid, setSelectionValid] = useState(false);
useEffect(() => {
setNextActive(jobValidator.isPickFieldsStepValid);
}, [jobValidatorUpdated]);
setNextActive(selectionValid && jobValidator.isPickFieldsStepValid);
}, [jobValidatorUpdated, selectionValid]);
return (
<Fragment>
{isCurrentStep && (
<Fragment>
{isSingleMetricJobCreator(jobCreator) && (
<SingleMetricView isActive={isCurrentStep} setCanProceed={setNextActive} />
<SingleMetricView isActive={isCurrentStep} setCanProceed={setSelectionValid} />
)}
{isMultiMetricJobCreator(jobCreator) && (
<MultiMetricView isActive={isCurrentStep} setCanProceed={setNextActive} />
<MultiMetricView isActive={isCurrentStep} setCanProceed={setSelectionValid} />
)}
{isPopulationJobCreator(jobCreator) && (
<PopulationView isActive={isCurrentStep} setCanProceed={setNextActive} />
<PopulationView isActive={isCurrentStep} setCanProceed={setSelectionValid} />
)}
{isAdvancedJobCreator(jobCreator) && (
<AdvancedView isActive={isCurrentStep} setCanProceed={setNextActive} />
<AdvancedView isActive={isCurrentStep} setCanProceed={setSelectionValid} />
)}
{isCategorizationJobCreator(jobCreator) && (
<CategorizationView isActive={isCurrentStep} setCanProceed={setNextActive} />
<CategorizationView isActive={isCurrentStep} setCanProceed={setSelectionValid} />
)}
{isRareJobCreator(jobCreator) && (
<RareView isActive={isCurrentStep} setCanProceed={setSelectionValid} />
)}
<WizardNav
previous={() =>

View file

@ -13,6 +13,7 @@ import { MultiMetricView } from '../../../pick_fields_step/components/multi_metr
import { PopulationView } from '../../../pick_fields_step/components/population_view';
import { AdvancedView } from '../../../pick_fields_step/components/advanced_view';
import { CategorizationView } from '../../../pick_fields_step/components/categorization_view';
import { RareView } from '../../../pick_fields_step/components/rare_view';
export const DetectorChart: FC = () => {
const { jobCreator } = useContext(JobCreatorContext);
@ -24,6 +25,7 @@ export const DetectorChart: FC = () => {
{jobCreator.type === JOB_TYPE.POPULATION && <PopulationView isActive={false} />}
{jobCreator.type === JOB_TYPE.ADVANCED && <AdvancedView isActive={false} />}
{jobCreator.type === JOB_TYPE.CATEGORIZATION && <CategorizationView isActive={false} />}
{jobCreator.type === JOB_TYPE.RARE && <RareView isActive={false} />}
</Fragment>
);
};

View file

@ -111,10 +111,10 @@ export const JobDetails: FC = () => {
defaultMessage: 'Population field',
}),
description:
isPopulationJobCreator(jobCreator) && jobCreator.splitField !== null ? (
jobCreator.splitField.name
isPopulationJobCreator(jobCreator) && jobCreator.populationField !== null ? (
jobCreator.populationField.name
) : (
<span style={{ fontStyle: jobCreator.splitField !== null ? 'inherit' : 'italic' }}>
<span style={{ fontStyle: jobCreator.populationField !== null ? 'inherit' : 'italic' }}>
<FormattedMessage
id="xpack.ml.newJob.wizard.summaryStep.jobDetails.populationField.placeholder"
defaultMessage="No population field selected"

View file

@ -51,6 +51,9 @@ function getWizardUrlFromCloningJob(createdBy: string | undefined, job: Job, dat
case CREATED_BY_LABEL.CATEGORIZATION:
page = JOB_TYPE.CATEGORIZATION;
break;
case CREATED_BY_LABEL.RARE:
page = JOB_TYPE.RARE;
break;
default:
page = JOB_TYPE.ADVANCED;
break;

View file

@ -28,6 +28,7 @@ import { addItemToRecentlyAccessed } from '../../../../util/recently_accessed';
import { timeBasedIndexCheck } from '../../../../util/index_utils';
import { LinkCard } from '../../../../components/link_card';
import { CategorizationIcon } from './categorization_job_icon';
import { RareIcon } from './rare_job_icon';
import { ML_PAGES } from '../../../../../../common/constants/ml_url_generator';
import { useCreateAndNavigateToMlLink } from '../../../../contexts/kibana/use_create_url';
@ -176,6 +177,22 @@ export const Page: FC = () => {
}),
id: 'mlJobTypeLinkCategorizationJob',
},
{
onClick: () => navigateToPath(`/jobs/new_job/rare${getUrlParams()}`),
icon: {
type: RareIcon,
ariaLabel: i18n.translate('xpack.ml.newJob.wizard.jobType.rareAriaLabel', {
defaultMessage: 'Rare job',
}),
},
title: i18n.translate('xpack.ml.newJob.wizard.jobType.rareTitle', {
defaultMessage: 'Rare',
}),
description: i18n.translate('xpack.ml.newJob.wizard.jobType.rareDescription', {
defaultMessage: 'Detect rare values in time series data.',
}),
id: 'mlJobTypeLinkrareJob',
},
];
return (

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 React from 'react';
export const RareIcon = (
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M16 32C7.16344 32 0 24.8366 0 16C0 7.16344 7.16344 0 16 0C24.8366 0 32 7.16344 32 16H30C30 8.26801 23.732 2 16 2C8.26801 2 2 8.26801 2 16C2 23.732 8.26801 30 16 30V32Z"
fill="#343741"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M17 15H23V17H17V23H15V17H9V15H15V9H17V15ZM32 30V32H20V30H32ZM32 22L20 22V24L32 24V22ZM32 26V28H20V26H32Z"
fill="#017D73"
/>
</svg>
);

View file

@ -24,6 +24,7 @@ import {
jobCreatorFactory,
isAdvancedJobCreator,
isCategorizationJobCreator,
isRareJobCreator,
} from '../../common/job_creator';
import {
JOB_TYPE,
@ -171,6 +172,10 @@ export const Page: FC<PageProps> = ({ existingJobsAndGroups, jobType }) => {
const { anomaly_detectors: anomalyDetectors } = getNewJobDefaults();
jobCreator.categorizationAnalyzer = anomalyDetectors.categorization_analyzer!;
} else if (isRareJobCreator(jobCreator)) {
const rare = newJobCapsService.getAggById('rare');
const freqRare = newJobCapsService.getAggById('freq_rare');
jobCreator.setDefaultDetectorProperties(rare, freqRare);
}
}

View file

@ -86,6 +86,16 @@ const getCategorizationBreadcrumbs = (navigateToPath: NavigateToPath, basePath:
},
];
const getRareBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) => [
...getBaseBreadcrumbs(navigateToPath, basePath),
{
text: i18n.translate('xpack.ml.jobsBreadcrumbs.rareLabel', {
defaultMessage: 'Rare',
}),
href: '',
},
];
export const singleMetricRouteFactory = (
navigateToPath: NavigateToPath,
basePath: string
@ -131,6 +141,12 @@ export const categorizationRouteFactory = (
breadcrumbs: getCategorizationBreadcrumbs(navigateToPath, basePath),
});
export const rareRouteFactory = (navigateToPath: NavigateToPath, basePath: string): MlRoute => ({
path: '/jobs/new_job/rare',
render: (props, deps) => <PageWrapper {...props} jobType={JOB_TYPE.RARE} deps={deps} />,
breadcrumbs: getRareBreadcrumbs(navigateToPath, basePath),
});
const PageWrapper: FC<WizardPageProps> = ({ location, jobType, deps }) => {
const redirectToJobsManagementPage = useCreateAndNavigateToMlLink(
ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE