[ML] Categorization wizard (#53009)

* [ML] Categorization wizard

* fixing js prettier issues

* adding basic category field validation

* adding rare or count selection

* fixing types

* category examples changes

* improving results search

* adding analyzer editing

* improving callout

* updating callout text

* fixing import path

* resetting cat analyser json on flyout open

* disabling model plot by default

* minor refactoring

* fixing types

* hide estimate bucket span

* setting default bucket span

* removing ml_classic workaround

* changing style of detector selection

* fixing convert to advanced issue

* removing sparse data checkbox

* changes based on review

* use default mml

* fixing job cloning

* changes based on review

* removing categorization_analyzer from job if it is same as default

* fixing translations

* disabling model plot for rare jobs

* removing console.error in useResolver
This commit is contained in:
James Gowdy 2020-01-09 15:21:40 +00:00 committed by GitHub
parent 9befff1236
commit 36abed3496
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 1702 additions and 104 deletions

View file

@ -9,16 +9,24 @@ export enum JOB_TYPE {
MULTI_METRIC = 'multi_metric',
POPULATION = 'population',
ADVANCED = 'advanced',
CATEGORIZATION = 'categorization',
}
export enum CREATED_BY_LABEL {
SINGLE_METRIC = 'single-metric-wizard',
MULTI_METRIC = 'multi-metric-wizard',
POPULATION = 'population-wizard',
CATEGORIZATION = 'categorization-wizard',
}
export const DEFAULT_MODEL_MEMORY_LIMIT = '10MB';
export const DEFAULT_BUCKET_SPAN = '15m';
export const DEFAULT_RARE_BUCKET_SPAN = '1h';
export const DEFAULT_QUERY_DELAY = '60s';
export const SHARED_RESULTS_INDEX_NAME = 'shared';
export const NUMBER_OF_CATEGORY_EXAMPLES = 5;
export const CATEGORY_EXAMPLES_MULTIPLIER = 20;
export const CATEGORY_EXAMPLES_WARNING_LIMIT = 0.75;
export const CATEGORY_EXAMPLES_ERROR_LIMIT = 0.2;

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export type CategoryId = number;
export interface Category {
job_id: string;
category_id: CategoryId;
terms: string;
regex: string;
max_matching_length: number;
examples: string[];
grok_pattern: string;
}
export interface Token {
token: string;
start_offset: number;
end_offset: number;
type: string;
position: number;
}

View file

@ -0,0 +1,159 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { isEqual } from 'lodash';
import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public';
import { SavedSearchSavedObject } from '../../../../../../common/types/kibana';
import { JobCreator } from './job_creator';
import { Field, Aggregation, mlCategory } from '../../../../../../common/types/fields';
import { Job, Datafeed, Detector } from './configs';
import { createBasicDetector } from './util/default_configs';
import {
JOB_TYPE,
CREATED_BY_LABEL,
DEFAULT_BUCKET_SPAN,
DEFAULT_RARE_BUCKET_SPAN,
} from '../../../../../../common/constants/new_job';
import { ML_JOB_AGGREGATION } from '../../../../../../common/constants/aggregation_types';
import { getRichDetectors } from './util/general';
import { CategorizationExamplesLoader, CategoryExample } from '../results_loader';
import { CategorizationAnalyzer, getNewJobDefaults } from '../../../../services/ml_server_info';
type CategorizationAnalyzerType = CategorizationAnalyzer | null;
export class CategorizationJobCreator extends JobCreator {
protected _type: JOB_TYPE = JOB_TYPE.CATEGORIZATION;
private _createCountDetector: () => void = () => {};
private _createRareDetector: () => void = () => {};
private _examplesLoader: CategorizationExamplesLoader;
private _categoryFieldExamples: CategoryExample[] = [];
private _categoryFieldValid: number = 0;
private _detectorType: ML_JOB_AGGREGATION.COUNT | ML_JOB_AGGREGATION.RARE =
ML_JOB_AGGREGATION.COUNT;
private _categorizationAnalyzer: CategorizationAnalyzerType = null;
private _defaultCategorizationAnalyzer: CategorizationAnalyzerType;
constructor(
indexPattern: IndexPattern,
savedSearch: SavedSearchSavedObject | null,
query: object
) {
super(indexPattern, savedSearch, query);
this.createdBy = CREATED_BY_LABEL.CATEGORIZATION;
this._examplesLoader = new CategorizationExamplesLoader(this, indexPattern, query);
const { anomaly_detectors: anomalyDetectors } = getNewJobDefaults();
this._defaultCategorizationAnalyzer = anomalyDetectors.categorization_analyzer || null;
}
public setDefaultDetectorProperties(
count: Aggregation | null,
rare: Aggregation | null,
eventRate: Field | null
) {
if (count === null || rare === null || eventRate === null) {
return;
}
this._createCountDetector = () => {
this._createDetector(count, eventRate);
};
this._createRareDetector = () => {
this._createDetector(rare, eventRate);
};
}
private _createDetector(agg: Aggregation, field: Field) {
const dtr: Detector = createBasicDetector(agg, field);
dtr.by_field_name = mlCategory.id;
this._addDetector(dtr, agg, mlCategory);
}
public setDetectorType(type: ML_JOB_AGGREGATION.COUNT | ML_JOB_AGGREGATION.RARE) {
this._detectorType = type;
this.removeAllDetectors();
if (type === ML_JOB_AGGREGATION.COUNT) {
this._createCountDetector();
this.bucketSpan = DEFAULT_BUCKET_SPAN;
} else {
this._createRareDetector();
this.bucketSpan = DEFAULT_RARE_BUCKET_SPAN;
this.modelPlot = false;
}
}
public set categorizationFieldName(fieldName: string | null) {
if (fieldName !== null) {
this._job_config.analysis_config.categorization_field_name = fieldName;
this.setDetectorType(this._detectorType);
this.addInfluencer(mlCategory.id);
} else {
delete this._job_config.analysis_config.categorization_field_name;
this._categoryFieldExamples = [];
this._categoryFieldValid = 0;
}
}
public get categorizationFieldName(): string | null {
return this._job_config.analysis_config.categorization_field_name || null;
}
public async loadCategorizationFieldExamples() {
const { valid, examples } = await this._examplesLoader.loadExamples();
this._categoryFieldExamples = examples;
this._categoryFieldValid = valid;
return { valid, examples };
}
public get categoryFieldExamples() {
return this._categoryFieldExamples;
}
public get categoryFieldValid() {
return this._categoryFieldValid;
}
public get selectedDetectorType() {
return this._detectorType;
}
public set categorizationAnalyzer(analyzer: CategorizationAnalyzerType) {
this._categorizationAnalyzer = analyzer;
if (
analyzer === null ||
isEqual(this._categorizationAnalyzer, this._defaultCategorizationAnalyzer)
) {
delete this._job_config.analysis_config.categorization_analyzer;
} else {
this._job_config.analysis_config.categorization_analyzer = analyzer;
}
}
public get categorizationAnalyzer() {
return this._categorizationAnalyzer;
}
public cloneFromExistingJob(job: Job, datafeed: Datafeed) {
this._overrideConfigs(job, datafeed);
this.createdBy = CREATED_BY_LABEL.CATEGORIZATION;
const detectors = getRichDetectors(job, datafeed, this.scriptFields, false);
const dtr = detectors[0];
if (detectors.length && dtr.agg !== null && dtr.field !== null) {
this._detectorType =
dtr.agg.id === ML_JOB_AGGREGATION.COUNT
? ML_JOB_AGGREGATION.COUNT
: ML_JOB_AGGREGATION.RARE;
const bs = job.analysis_config.bucket_span;
this.setDetectorType(this._detectorType);
// set the bucketspan back to the original value
// as setDetectorType applies a default
this.bucketSpan = bs;
}
}
}

View file

@ -9,11 +9,13 @@ export { SingleMetricJobCreator } from './single_metric_job_creator';
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 {
JobCreatorType,
isSingleMetricJobCreator,
isMultiMetricJobCreator,
isPopulationJobCreator,
isAdvancedJobCreator,
isCategorizationJobCreator,
} from './type_guards';
export { jobCreatorFactory } from './job_creator_factory';

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 { IndexPattern } from '../../../../../../../../../../src/plugins/data/public';
import { CategorizationJobCreator } from './categorization_job_creator';
import { JOB_TYPE } from '../../../../../../common/constants/new_job';
@ -32,6 +33,9 @@ export const jobCreatorFactory = (jobType: JOB_TYPE) => (
case JOB_TYPE.ADVANCED:
jc = AdvancedJobCreator;
break;
case JOB_TYPE.CATEGORIZATION:
jc = CategorizationJobCreator;
break;
default:
jc = SingleMetricJobCreator;
break;

View file

@ -8,13 +8,15 @@ import { SingleMetricJobCreator } from './single_metric_job_creator';
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 { JOB_TYPE } from '../../../../../../common/constants/new_job';
export type JobCreatorType =
| SingleMetricJobCreator
| MultiMetricJobCreator
| PopulationJobCreator
| AdvancedJobCreator;
| AdvancedJobCreator
| CategorizationJobCreator;
export function isSingleMetricJobCreator(
jobCreator: JobCreatorType
@ -37,3 +39,9 @@ export function isPopulationJobCreator(
export function isAdvancedJobCreator(jobCreator: JobCreatorType): jobCreator is AdvancedJobCreator {
return jobCreator.type === JOB_TYPE.ADVANCED;
}
export function isCategorizationJobCreator(
jobCreator: JobCreatorType
): jobCreator is CategorizationJobCreator {
return jobCreator.type === JOB_TYPE.CATEGORIZATION;
}

View file

@ -19,7 +19,12 @@ import {
mlCategory,
} from '../../../../../../../common/types/fields';
import { mlJobService } from '../../../../../services/job_service';
import { JobCreatorType, isMultiMetricJobCreator, isPopulationJobCreator } from '../index';
import {
JobCreatorType,
isMultiMetricJobCreator,
isPopulationJobCreator,
isCategorizationJobCreator,
} from '../index';
import { CREATED_BY_LABEL, JOB_TYPE } from '../../../../../../../common/constants/new_job';
const getFieldByIdFactory = (scriptFields: Field[]) => (id: string) => {
@ -251,6 +256,8 @@ export function convertToAdvancedJob(jobCreator: JobCreatorType) {
jobType = JOB_TYPE.MULTI_METRIC;
} else if (isPopulationJobCreator(jobCreator)) {
jobType = JOB_TYPE.POPULATION;
} else if (isCategorizationJobCreator(jobCreator)) {
jobType = JOB_TYPE.CATEGORIZATION;
}
window.location.href = window.location.href.replace(jobType, JOB_TYPE.ADVANCED);

View file

@ -12,10 +12,11 @@ import {
basicDatafeedValidation,
} from '../../../../../../common/util/job_utils';
import { getNewJobLimits } from '../../../../services/ml_server_info';
import { JobCreator, JobCreatorType } from '../job_creator';
import { JobCreator, JobCreatorType, isCategorizationJobCreator } from '../job_creator';
import { populateValidationMessages, checkForExistingJobAndGroupIds } from './util';
import { ExistingJobsAndGroups } from '../../../../services/job_service';
import { cardinalityValidator, CardinalityValidatorResult } from './validators';
import { CATEGORY_EXAMPLES_ERROR_LIMIT } from '../../../../../../common/constants/new_job';
// delay start of validation to allow the user to make changes
// e.g. if they are typing in a new value, try not to validate
@ -51,6 +52,10 @@ export interface BasicValidations {
scrollSize: Validation;
}
export interface AdvancedValidations {
categorizationFieldValid: Validation;
}
export class JobValidator {
private _jobCreator: JobCreatorType;
private _validationSummary: ValidationSummary;
@ -71,6 +76,9 @@ export class JobValidator {
frequency: { valid: true },
scrollSize: { valid: true },
};
private _advancedValidations: AdvancedValidations = {
categorizationFieldValid: { valid: true },
};
private _validating: boolean = false;
private _basicValidationResult$ = new ReplaySubject<JobValidationResult>(2);
@ -141,6 +149,7 @@ export class JobValidator {
this._lastDatafeedConfig = formattedDatafeedConfig;
this._validateTimeout = setTimeout(() => {
this._runBasicValidation();
this._runAdvancedValidation();
this._jobCreatorSubject$.next(this._jobCreator);
@ -195,6 +204,13 @@ export class JobValidator {
this._basicValidationResult$.next(this._basicValidations);
}
private _runAdvancedValidation() {
if (isCategorizationJobCreator(this._jobCreator)) {
this._advancedValidations.categorizationFieldValid.valid =
this._jobCreator.categoryFieldValid > CATEGORY_EXAMPLES_ERROR_LIMIT;
}
}
private _isOverallBasicValid() {
return Object.values(this._basicValidations).some(v => v.valid === false) === false;
}
@ -246,4 +262,12 @@ export class JobValidator {
public get validating(): boolean {
return this._validating;
}
public get categorizationField() {
return this._advancedValidations.categorizationFieldValid.valid;
}
public set categorizationField(valid: boolean) {
this._advancedValidations.categorizationFieldValid.valid = valid;
}
}

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public';
import { IndexPatternTitle } from '../../../../../../common/types/kibana';
import { Token } from '../../../../../../common/types/categories';
import { CategorizationJobCreator } from '../job_creator';
import { ml } from '../../../../services/ml_api_service';
import { NUMBER_OF_CATEGORY_EXAMPLES } from '../../../../../../common/constants/new_job';
export interface CategoryExample {
text: string;
tokens: Token[];
}
export class CategorizationExamplesLoader {
private _jobCreator: CategorizationJobCreator;
private _indexPatternTitle: IndexPatternTitle = '';
private _timeFieldName: string = '';
private _query: object = {};
constructor(jobCreator: CategorizationJobCreator, indexPattern: IndexPattern, query: object) {
this._jobCreator = jobCreator;
this._indexPatternTitle = indexPattern.title;
this._query = query;
if (typeof indexPattern.timeFieldName === 'string') {
this._timeFieldName = indexPattern.timeFieldName;
}
}
public async loadExamples() {
const analyzer = this._jobCreator.categorizationAnalyzer;
const categorizationFieldName = this._jobCreator.categorizationFieldName;
if (categorizationFieldName === null) {
return { valid: 0, examples: [] };
}
const start = Math.floor(
this._jobCreator.start + (this._jobCreator.end - this._jobCreator.start) / 2
);
const resp = await ml.jobs.categorizationFieldExamples(
this._indexPatternTitle,
this._query,
NUMBER_OF_CATEGORY_EXAMPLES,
categorizationFieldName,
this._timeFieldName,
start,
0,
analyzer
);
return resp;
}
}

View file

@ -5,3 +5,4 @@
*/
export { ResultsLoader, Results, ModelItem, Anomaly } from './results_loader';
export { CategorizationExamplesLoader, CategoryExample } from './categorization_examples_loader';

View file

@ -7,7 +7,7 @@
import React, { FC } from 'react';
import { Chart, Settings, TooltipType } from '@elastic/charts';
import { ModelItem, Anomaly } from '../../../../common/results_loader';
import { Anomalies } from './anomalies';
import { Anomalies } from '../common/anomalies';
import { ModelBounds } from './model_bounds';
import { Line } from './line';
import { Scatter } from './scatter';

View file

@ -20,6 +20,7 @@ const themeName = IS_DARK_THEME ? darkTheme : lightTheme;
export const LINE_COLOR = themeName.euiColorPrimary;
export const MODEL_COLOR = themeName.euiColorPrimary;
export const EVENT_RATE_COLOR = themeName.euiColorPrimary;
export const EVENT_RATE_COLOR_WITH_ANOMALIES = themeName.euiColorLightShade;
export interface ChartSettings {
width: string;

View file

@ -8,24 +8,32 @@ import React, { FC } from 'react';
import { BarSeries, Chart, ScaleType, Settings, TooltipType } from '@elastic/charts';
import { Axes } from '../common/axes';
import { LineChartPoint } from '../../../../common/chart_loader';
import { EVENT_RATE_COLOR } from '../common/settings';
import { Anomaly } from '../../../../common/results_loader';
import { EVENT_RATE_COLOR, EVENT_RATE_COLOR_WITH_ANOMALIES } from '../common/settings';
import { LoadingWrapper } from '../loading_wrapper';
import { Anomalies } from '../common/anomalies';
interface Props {
eventRateChartData: LineChartPoint[];
anomalyData?: Anomaly[];
height: string;
width: string;
showAxis?: boolean;
loading?: boolean;
fadeChart?: boolean;
}
export const EventRateChart: FC<Props> = ({
eventRateChartData,
anomalyData,
height,
width,
showAxis,
loading = false,
fadeChart,
}) => {
const barColor = fadeChart ? EVENT_RATE_COLOR_WITH_ANOMALIES : EVENT_RATE_COLOR;
return (
<div
style={{ width, height }}
@ -36,6 +44,7 @@ export const EventRateChart: FC<Props> = ({
{showAxis === true && <Axes />}
<Settings tooltip={TooltipType.None} />
<Anomalies anomalyData={anomalyData} />
<BarSeries
id="event_rate"
xScaleType={ScaleType.Time}
@ -43,7 +52,7 @@ export const EventRateChart: FC<Props> = ({
xAccessor={'time'}
yAccessors={['value']}
data={eventRateChartData}
customSeriesColors={[EVENT_RATE_COLOR]}
customSeriesColors={[barColor]}
/>
</Chart>
</LoadingWrapper>

View file

@ -9,7 +9,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
interface Props {
hasData: boolean;
height: string;
height?: string;
loading?: boolean;
}
@ -31,7 +31,7 @@ export const LoadingWrapper: FC<Props> = ({ hasData, loading = false, height, ch
<EuiFlexGroup
justifyContent="spaceAround"
alignItems="center"
style={{ height, marginTop: `-${height}` }}
style={height !== undefined ? { height, marginTop: `-${height}` } : {}}
>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="xl" />

View file

@ -0,0 +1,147 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, FC, useEffect, useState, useContext } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiFlyout,
EuiFlyoutFooter,
EuiFlexGroup,
EuiFlexItem,
EuiButton,
EuiButtonEmpty,
EuiTitle,
EuiFlyoutBody,
EuiSpacer,
} from '@elastic/eui';
import { MLJobEditor } from '../../../../../jobs_list/components/ml_job_editor';
import { isValidJson } from '../../../../../../../../common/util/validation_utils';
import { JobCreatorContext } from '../../job_creator_context';
import { CategorizationJobCreator } from '../../../../common/job_creator';
import { getNewJobDefaults } from '../../../../../../services/ml_server_info';
const EDITOR_HEIGHT = '800px';
export const EditCategorizationAnalyzerFlyout: FC = () => {
const { jobCreator: jc, jobCreatorUpdate } = useContext(JobCreatorContext);
const jobCreator = jc as CategorizationJobCreator;
const [showJsonFlyout, setShowJsonFlyout] = useState(false);
const [saveable, setSaveable] = useState(false);
const [categorizationAnalyzerString, setCategorizationAnalyzerString] = useState(
JSON.stringify(jobCreator.categorizationAnalyzer, null, 2)
);
useEffect(() => {
if (showJsonFlyout === true) {
setCategorizationAnalyzerString(JSON.stringify(jobCreator.categorizationAnalyzer, null, 2));
}
}, [showJsonFlyout]);
function toggleJsonFlyout() {
setSaveable(false);
setShowJsonFlyout(!showJsonFlyout);
}
function onJSONChange(json: string) {
setCategorizationAnalyzerString(json);
const valid = isValidJson(json);
setSaveable(valid);
}
function onSave() {
jobCreator.categorizationAnalyzer = JSON.parse(categorizationAnalyzerString);
jobCreatorUpdate();
setShowJsonFlyout(false);
}
function onUseDefault() {
const { anomaly_detectors: anomalyDetectors } = getNewJobDefaults();
const analyzerString = JSON.stringify(anomalyDetectors.categorization_analyzer!, null, 2);
onJSONChange(analyzerString);
}
return (
<Fragment>
<FlyoutButton onClick={toggleJsonFlyout} />
{showJsonFlyout === true && (
<EuiFlyout onClose={() => setShowJsonFlyout(false)} hideCloseButton size="m">
<EuiFlyoutBody>
<Contents
onChange={onJSONChange}
title={i18n.translate('xpack.ml.newJob.wizard.categorizationAnalyzerFlyout.title', {
defaultMessage: 'Edit categorization analyzer JSON',
})}
value={categorizationAnalyzerString}
/>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
onClick={() => setShowJsonFlyout(false)}
flush="left"
>
<FormattedMessage
id="xpack.ml.newJob.wizard.categorizationAnalyzerFlyout.closeButton"
defaultMessage="Close"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={true} />
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onUseDefault}>
<FormattedMessage
id="xpack.ml.newJob.wizard.categorizationAnalyzerFlyout.useDefaultButton"
defaultMessage="Use default ML analyzer"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton onClick={onSave} fill isDisabled={saveable === false}>
<FormattedMessage
id="xpack.ml.newJob.wizard.categorizationAnalyzerFlyout.saveButton"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
)}
</Fragment>
);
};
const FlyoutButton: FC<{ onClick(): void }> = ({ onClick }) => {
return (
<EuiButtonEmpty onClick={onClick} flush="left" data-test-subj="mlJobWizardButtonPreviewJobJson">
<FormattedMessage
id="xpack.ml.newJob.wizard.editCategorizationAnalyzerFlyoutButton"
defaultMessage="Edit categorization analyzer"
/>
</EuiButtonEmpty>
);
};
const Contents: FC<{
title: string;
value: string;
onChange(s: string): void;
}> = ({ title, value, onChange }) => {
return (
<EuiFlexItem>
<EuiTitle size="s">
<h5>{title}</h5>
</EuiTitle>
<EuiSpacer size="s" />
<MLJobEditor value={value} height={EDITOR_HEIGHT} readOnly={false} onChange={onChange} />
</EuiFlexItem>
);
};

View file

@ -0,0 +1,6 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { EditCategorizationAnalyzerFlyout } from './edit_categorization_analyzer_flyout';

View file

@ -10,16 +10,29 @@ import { EuiSpacer, EuiSwitch } from '@elastic/eui';
import { JobCreatorContext } from '../../../../../job_creator_context';
import { Description } from './description';
import { MMLCallout } from '../mml_callout';
import { ML_JOB_AGGREGATION } from '../../../../../../../../../../../common/constants/aggregation_types';
import { isCategorizationJobCreator } from '../../../../../../../common/job_creator';
export const ModelPlotSwitch: FC = () => {
const { jobCreator, jobCreatorUpdate } = useContext(JobCreatorContext);
const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext);
const [modelPlotEnabled, setModelPlotEnabled] = useState(jobCreator.modelPlot);
const [enabled, setEnabled] = useState(false);
useEffect(() => {
jobCreator.modelPlot = modelPlotEnabled;
jobCreatorUpdate();
}, [modelPlotEnabled]);
useEffect(() => {
const aggs = [ML_JOB_AGGREGATION.RARE];
// disable model plot switch if the wizard is creating a categorization job
// and a rare detector is being used.
const isRareCategoryJob =
isCategorizationJobCreator(jobCreator) &&
jobCreator.aggregations.some(agg => aggs.includes(agg.id));
setEnabled(isRareCategoryJob === false);
}, [jobCreatorUpdated]);
function toggleModelPlot() {
setModelPlotEnabled(!modelPlotEnabled);
}
@ -29,6 +42,7 @@ export const ModelPlotSwitch: FC = () => {
<Description>
<EuiSwitch
name="switch"
disabled={enabled === false}
checked={modelPlotEnabled}
onChange={toggleModelPlot}
data-test-subj="mlJobWizardSwitchModelPlot"

View file

@ -4,10 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, FC, useContext, useEffect, useState } from 'react';
import React, { Fragment, FC } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { JobCreatorContext } from '../../../job_creator_context';
import { BucketSpan } from '../bucket_span';
import { Influencers } from '../influencers';
import { ModelMemoryLimitInput } from '../../../common/model_memory_limit';
@ -17,19 +16,6 @@ interface Props {
}
export const AdvancedSettings: FC<Props> = ({ setIsValid }) => {
const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext);
const [bucketSpan, setBucketSpan] = useState(jobCreator.bucketSpan);
useEffect(() => {
jobCreator.bucketSpan = bucketSpan;
jobCreatorUpdate();
setIsValid(bucketSpan !== '');
}, [bucketSpan]);
useEffect(() => {
setBucketSpan(jobCreator.bucketSpan);
}, [jobCreatorUpdated]);
return (
<Fragment>
<EuiFlexGroup gutterSize="xl">

View file

@ -14,9 +14,10 @@ import { BucketSpanEstimator } from '../bucket_span_estimator';
interface Props {
setIsValid: (proceed: boolean) => void;
hideEstimateButton?: boolean;
}
export const BucketSpan: FC<Props> = ({ setIsValid }) => {
export const BucketSpan: FC<Props> = ({ setIsValid, hideEstimateButton = false }) => {
const {
jobCreator,
jobCreatorUpdate,
@ -56,9 +57,11 @@ export const BucketSpan: FC<Props> = ({ setIsValid }) => {
disabled={estimating}
/>
</EuiFlexItem>
<EuiFlexItem>
<BucketSpanEstimator setEstimating={setEstimating} />
</EuiFlexItem>
{hideEstimateButton === false && (
<EuiFlexItem>
<BucketSpanEstimator setEstimating={setEstimating} />
</EuiFlexItem>
)}
</EuiFlexGroup>
</Description>
);

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, useContext, useEffect, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiFlexGroup, EuiSpacer, EuiTitle } from '@elastic/eui';
import { ML_JOB_AGGREGATION } from '../../../../../../../../../common/constants/aggregation_types';
import { JobCreatorContext } from '../../../job_creator_context';
import { CategorizationJobCreator } from '../../../../../common/job_creator';
import { CountCard, RareCard } from './detector_cards';
export const CategorizationDetector: FC = () => {
const { jobCreator: jc, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext);
const jobCreator = jc as CategorizationJobCreator;
const [categorizationDetectorType, setCategorizationDetectorType] = useState(
jobCreator.selectedDetectorType
);
useEffect(() => {
if (categorizationDetectorType !== jobCreator.selectedDetectorType) {
jobCreator.setDetectorType(categorizationDetectorType);
jobCreatorUpdate();
}
}, [categorizationDetectorType]);
useEffect(() => {
setCategorizationDetectorType(jobCreator.selectedDetectorType);
}, [jobCreatorUpdated]);
function onCountSelection() {
setCategorizationDetectorType(ML_JOB_AGGREGATION.COUNT);
}
function onRareSelection() {
setCategorizationDetectorType(ML_JOB_AGGREGATION.RARE);
}
return (
<>
<EuiTitle size="xs">
<h3>
<FormattedMessage
id="xpack.ml.newJob.wizard.pickFieldsStep.categorizationDetectorSelect.title"
defaultMessage="Categorization detector"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFlexGroup gutterSize="l" style={{ maxWidth: '824px' }}>
<CountCard
onClick={onCountSelection}
isSelected={categorizationDetectorType === ML_JOB_AGGREGATION.COUNT}
/>
<RareCard
onClick={onRareSelection}
isSelected={categorizationDetectorType === ML_JOB_AGGREGATION.RARE}
/>
</EuiFlexGroup>
</>
);
};

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { 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 CountCard: FC<CardProps> = ({ onClick, isSelected }) => (
<EuiFlexItem>
<EuiCard
title={i18n.translate(
'xpack.ml.newJob.wizard.pickFieldsStep.categorizationDetectorSelect.countCard.title',
{
defaultMessage: 'Count',
}
)}
description={
<>
<FormattedMessage
id="xpack.ml.newJob.wizard.pickFieldsStep.categorizationDetectorSelect.countCard.description"
defaultMessage="Look for anomalies in the event rate of a particular category."
/>
</>
}
selectable={{ onClick, isSelected }}
/>
</EuiFlexItem>
);
export const RareCard: FC<CardProps> = ({ onClick, isSelected }) => (
<EuiFlexItem>
<EuiCard
title={i18n.translate(
'xpack.ml.newJob.wizard.pickFieldsStep.categorizationDetectorSelect.rareCard.title',
{
defaultMessage: 'Rare',
}
)}
description={
<>
<FormattedMessage
id="xpack.ml.newJob.wizard.pickFieldsStep.categorizationDetectorSelect.rareCard.description"
defaultMessage="Look for categories that occur rarely in time."
/>
</>
}
selectable={{ onClick, isSelected }}
/>
</EuiFlexItem>
);

View file

@ -0,0 +1,6 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { CategorizationDetector } from './categorization_detector';

View file

@ -25,8 +25,10 @@ export const CategorizationField: FC = () => {
);
useEffect(() => {
jobCreator.categorizationFieldName = categorizationFieldName;
jobCreatorUpdate();
if (jobCreator.categorizationFieldName !== categorizationFieldName) {
jobCreator.categorizationFieldName = categorizationFieldName;
jobCreatorUpdate();
}
}, [categorizationFieldName]);
useEffect(() => {

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, useEffect, useState } from 'react';
import { EuiHorizontalRule } from '@elastic/eui';
import { CategorizationDetectors } from './metric_selection';
import { CategorizationDetectorsSummary } from './metric_selection_summary';
import { CategorizationSettings } from './settings';
interface Props {
isActive: boolean;
setCanProceed?: (proceed: boolean) => void;
}
export const CategorizationView: FC<Props> = ({ isActive, setCanProceed }) => {
const [categoryFieldValid, setCategoryFieldValid] = useState(false);
const [settingsValid, setSettingsValid] = useState(false);
useEffect(() => {
if (typeof setCanProceed === 'function') {
setCanProceed(categoryFieldValid && settingsValid);
}
}, [categoryFieldValid, settingsValid]);
return isActive === false ? (
<CategorizationDetectorsSummary />
) : (
<>
<CategorizationDetectors setIsValid={setCategoryFieldValid} />
{categoryFieldValid && (
<>
<EuiHorizontalRule margin="l" />
<CategorizationSettings setIsValid={setSettingsValid} />
</>
)}
</>
);
};

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;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC } from 'react';
import { EuiCallOut, EuiSpacer, EuiCallOutProps } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { CategorizationAnalyzer } from '../../../../../../../services/ml_server_info';
import { EditCategorizationAnalyzerFlyout } from '../../../common/edit_categorization_analyzer_flyout';
import {
NUMBER_OF_CATEGORY_EXAMPLES,
CATEGORY_EXAMPLES_MULTIPLIER,
CATEGORY_EXAMPLES_ERROR_LIMIT,
CATEGORY_EXAMPLES_WARNING_LIMIT,
} from '../../../../../../../../../common/constants/new_job';
type CategorizationAnalyzerType = CategorizationAnalyzer | null;
interface Props {
examplesValid: number;
categorizationAnalyzer: CategorizationAnalyzerType;
}
export const ExamplesValidCallout: FC<Props> = ({ examplesValid, categorizationAnalyzer }) => {
const percentageText = <PercentageText examplesValid={examplesValid} />;
const analyzerUsed = <AnalyzerUsed categorizationAnalyzer={categorizationAnalyzer} />;
let color: EuiCallOutProps['color'] = 'success';
let title = i18n.translate(
'xpack.ml.newJob.wizard.pickFieldsStep.categorizationFieldCalloutTitle.valid',
{
defaultMessage: 'Selected category field is valid',
}
);
if (examplesValid < CATEGORY_EXAMPLES_ERROR_LIMIT) {
color = 'danger';
title = i18n.translate(
'xpack.ml.newJob.wizard.pickFieldsStep.categorizationFieldCalloutTitle.invalid',
{
defaultMessage: 'Selected category field is invalid',
}
);
} else if (examplesValid < CATEGORY_EXAMPLES_WARNING_LIMIT) {
color = 'warning';
title = i18n.translate(
'xpack.ml.newJob.wizard.pickFieldsStep.categorizationFieldCalloutTitle.possiblyInvalid',
{
defaultMessage: 'Selected category field is possibly invalid',
}
);
}
return (
<EuiCallOut color={color} title={title}>
{percentageText}
<EuiSpacer size="s" />
{analyzerUsed}
</EuiCallOut>
);
};
const PercentageText: FC<{ examplesValid: number }> = ({ examplesValid }) => (
<div>
<FormattedMessage
id="xpack.ml.newJob.wizard.pickFieldsStep.categorizationFieldPercentage"
defaultMessage="{number} field values analyzed, {percentage}% contain valid tokens."
values={{
number: NUMBER_OF_CATEGORY_EXAMPLES * CATEGORY_EXAMPLES_MULTIPLIER,
percentage: Math.floor(examplesValid * 100),
}}
/>
</div>
);
const AnalyzerUsed: FC<{ categorizationAnalyzer: CategorizationAnalyzerType }> = ({
categorizationAnalyzer,
}) => {
let analyzer = '';
if (typeof categorizationAnalyzer === null) {
return null;
}
if (typeof categorizationAnalyzer === 'string') {
analyzer = categorizationAnalyzer;
} else {
if (categorizationAnalyzer?.tokenizer !== undefined) {
analyzer = categorizationAnalyzer?.tokenizer!;
} else if (categorizationAnalyzer?.analyzer !== undefined) {
analyzer = categorizationAnalyzer?.analyzer!;
}
}
return (
<>
<div>
<FormattedMessage
id="xpack.ml.newJob.wizard.pickFieldsStep.categorizationFieldAnalyzer"
defaultMessage="Analyzer used: {analyzer}"
values={{ analyzer }}
/>
</div>
<div>
<EditCategorizationAnalyzerFlyout />
</div>
</>
);
};

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiBasicTable, EuiText } from '@elastic/eui';
import { CategoryExample } from '../../../../../common/results_loader';
interface Props {
fieldExamples: CategoryExample[] | null;
}
const TOKEN_HIGHLIGHT_COLOR = '#b0ccf7';
export const FieldExamples: FC<Props> = ({ fieldExamples }) => {
if (fieldExamples === null || fieldExamples.length === 0) {
return null;
}
const columns = [
{
field: 'example',
name: i18n.translate(
'xpack.ml.newJob.wizard.pickFieldsStep.categorizationFieldExamples.title',
{
defaultMessage: 'Examples',
}
),
render: (example: any) => (
<EuiText size="s">
<code>{example}</code>
</EuiText>
),
},
];
const items = fieldExamples.map((example, i) => {
const txt = [];
let tokenCounter = 0;
let buffer = '';
let charCount = 0;
while (charCount < example.text.length) {
const token = example.tokens[tokenCounter];
if (token && charCount === token.start_offset) {
txt.push(buffer);
buffer = '';
txt.push(<Token key={`${i}${charCount}`}>{token.token}</Token>);
charCount += token.end_offset - token.start_offset;
tokenCounter++;
} else {
buffer += example.text[charCount];
charCount++;
}
}
txt.push(buffer);
return { example: txt };
});
return <EuiBasicTable columns={columns} items={items} />;
};
const Token: FC = ({ children }) => (
<span style={{ backgroundColor: TOKEN_HIGHLIGHT_COLOR }}>{children}</span>
);

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { CategorizationView } from './categorization_view';

View file

@ -0,0 +1,108 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, useContext, useEffect, useState } from 'react';
import { EuiHorizontalRule } from '@elastic/eui';
import { JobCreatorContext } from '../../../job_creator_context';
import { CategorizationJobCreator } from '../../../../../common/job_creator';
import { CategorizationField } from '../categorization_field';
import { CategorizationDetector } from '../categorization_detector';
import { FieldExamples } from './field_examples';
import { ExamplesValidCallout } from './examples_valid_callout';
import { CategoryExample } from '../../../../../common/results_loader';
import { LoadingWrapper } from '../../../charts/loading_wrapper';
interface Props {
setIsValid: (na: boolean) => void;
}
export const CategorizationDetectors: FC<Props> = ({ setIsValid }) => {
const { jobCreator: jc, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext);
const jobCreator = jc as CategorizationJobCreator;
const [loadingData, setLoadingData] = useState(false);
const [start, setStart] = useState(jobCreator.start);
const [end, setEnd] = useState(jobCreator.end);
const [categorizationAnalyzerString, setCategorizationAnalyzerString] = useState(
JSON.stringify(jobCreator.categorizationAnalyzer)
);
const [fieldExamples, setFieldExamples] = useState<CategoryExample[] | null>(null);
const [examplesValid, setExamplesValid] = useState(0);
const [categorizationFieldName, setCategorizationFieldName] = useState(
jobCreator.categorizationFieldName
);
useEffect(() => {
if (jobCreator.categorizationFieldName !== categorizationFieldName) {
jobCreator.categorizationFieldName = categorizationFieldName;
jobCreatorUpdate();
}
loadFieldExamples();
}, [categorizationFieldName]);
useEffect(() => {
let updateExamples = false;
if (jobCreator.start !== start || jobCreator.end !== end) {
setStart(jobCreator.start);
setEnd(jobCreator.end);
updateExamples = true;
}
const tempCategorizationAnalyzerString = JSON.stringify(jobCreator.categorizationAnalyzer);
if (tempCategorizationAnalyzerString !== categorizationAnalyzerString) {
setCategorizationAnalyzerString(tempCategorizationAnalyzerString);
updateExamples = true;
}
if (updateExamples) {
loadFieldExamples();
}
if (jobCreator.categorizationFieldName !== categorizationFieldName) {
setCategorizationFieldName(jobCreator.categorizationFieldName);
}
}, [jobCreatorUpdated]);
async function loadFieldExamples() {
if (categorizationFieldName !== null) {
setLoadingData(true);
const { valid, examples } = await jobCreator.loadCategorizationFieldExamples();
setFieldExamples(examples);
setExamplesValid(valid);
setLoadingData(false);
} else {
setFieldExamples(null);
setExamplesValid(0);
}
setIsValid(categorizationFieldName !== null);
}
useEffect(() => {
jobCreatorUpdate();
}, [examplesValid]);
return (
<>
<CategorizationDetector />
<EuiHorizontalRule />
<CategorizationField />
{loadingData === true && (
<LoadingWrapper hasData={false} loading={true}>
<div />
</LoadingWrapper>
)}
{fieldExamples !== null && loadingData === false && (
<>
<ExamplesValidCallout
examplesValid={examplesValid}
categorizationAnalyzer={jobCreator.categorizationAnalyzer}
/>
<FieldExamples fieldExamples={fieldExamples} />
</>
)}
</>
);
};

View file

@ -0,0 +1,78 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, useContext, useEffect, useState } from 'react';
import { JobCreatorContext } from '../../../job_creator_context';
import { CategorizationJobCreator } 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 { TopCategories } from './top_categories';
const DTR_IDX = 0;
export const CategorizationDetectorsSummary: FC = () => {
const { jobCreator: jc, chartLoader, resultsLoader, chartInterval } = useContext(
JobCreatorContext
);
const jobCreator = jc as CategorizationJobCreator;
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()
);
setEventRateChartData(resp);
} catch (error) {
setEventRateChartData([]);
}
setLoadingData(false);
}
return (
<>
<EventRateChart
eventRateChartData={eventRateChartData}
anomalyData={anomalyData}
height="300px"
width="100%"
showAxis={true}
loading={loadingData}
fadeChart={jobIsRunning}
/>
<TopCategories />
</>
);
};

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { BucketSpan } from '../bucket_span';
interface Props {
setIsValid: (proceed: boolean) => void;
}
export const CategorizationSettings: FC<Props> = ({ setIsValid }) => {
return (
<>
<EuiFlexGroup gutterSize="xl">
<EuiFlexItem>
<BucketSpan setIsValid={setIsValid} hideEstimateButton={true} />
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};

View file

@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, useContext, useEffect, useState } from 'react';
import { EuiBasicTable, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { JobCreatorContext } from '../../../job_creator_context';
import { CategorizationJobCreator } from '../../../../../common/job_creator';
import { Results } from '../../../../../common/results_loader';
import { ml } from '../../../../../../../services/ml_api_service';
import { NUMBER_OF_CATEGORY_EXAMPLES } from '../../../../../../../../../common/constants/new_job';
export const TopCategories: FC = () => {
const { jobCreator: jc, resultsLoader } = useContext(JobCreatorContext);
const jobCreator = jc as CategorizationJobCreator;
const [tableRow, setTableRow] = useState<Array<{ count?: number; example: string }>>([]);
const [totalCategories, setTotalCategories] = useState(0);
function setResultsWrapper(results: Results) {
loadTopCats();
}
async function loadTopCats() {
const results = await ml.jobs.topCategories(jobCreator.jobId, NUMBER_OF_CATEGORY_EXAMPLES);
setTableRow(
results.categories.map(c => ({
count: c.count,
example: c.category.examples?.length ? c.category.examples[0] : '',
}))
);
setTotalCategories(results.total);
}
useEffect(() => {
// subscribe to result updates
const resultsSubscription = resultsLoader.subscribeToResults(setResultsWrapper);
return () => {
resultsSubscription.unsubscribe();
};
}, []);
const columns = [
// only include counts if model plot is enabled
...(jobCreator.modelPlot
? [
{
field: 'count',
name: 'count',
width: '100px',
render: (count: any) => (
<EuiText size="s">
<code>{count}</code>
</EuiText>
),
},
]
: []),
{
field: 'example',
name: 'Example',
render: (example: any) => (
<EuiText size="s">
<code>{example}</code>
</EuiText>
),
},
];
return (
<>
{totalCategories > 0 && (
<>
<div>
<FormattedMessage
id="xpack.ml.newJob.wizard.pickFieldsStep.categorizationTotalCategories"
defaultMessage="Total categories: {totalCategories}"
values={{ totalCategories }}
/>
</div>
<EuiBasicTable columns={columns} items={tableRow} />
</>
)}
</>
);
};

View file

@ -4,10 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, FC, useContext, useEffect, useState } from 'react';
import React, { Fragment, FC } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { JobCreatorContext } from '../../../job_creator_context';
import { BucketSpan } from '../bucket_span';
import { SplitFieldSelector } from '../split_field';
import { Influencers } from '../influencers';
@ -18,19 +17,6 @@ interface Props {
}
export const MultiMetricSettings: FC<Props> = ({ setIsValid }) => {
const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext);
const [bucketSpan, setBucketSpan] = useState(jobCreator.bucketSpan);
useEffect(() => {
jobCreator.bucketSpan = bucketSpan;
jobCreatorUpdate();
setIsValid(bucketSpan !== '');
}, [bucketSpan]);
useEffect(() => {
setBucketSpan(jobCreator.bucketSpan);
}, [jobCreatorUpdated]);
return (
<Fragment>
<EuiFlexGroup gutterSize="xl">

View file

@ -4,10 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, FC, useContext, useEffect, useState } from 'react';
import React, { Fragment, FC } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { JobCreatorContext } from '../../../job_creator_context';
import { BucketSpan } from '../bucket_span';
import { Influencers } from '../influencers';
@ -16,19 +15,6 @@ interface Props {
}
export const PopulationSettings: FC<Props> = ({ setIsValid }) => {
const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext);
const [bucketSpan, setBucketSpan] = useState(jobCreator.bucketSpan);
useEffect(() => {
jobCreator.bucketSpan = bucketSpan;
jobCreatorUpdate();
setIsValid(bucketSpan !== '');
}, [bucketSpan]);
useEffect(() => {
setBucketSpan(jobCreator.bucketSpan);
}, [jobCreatorUpdated]);
return (
<Fragment>
<EuiFlexGroup gutterSize="xl">

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, FC, useContext, useEffect, useState } from 'react';
import React, { Fragment, FC, useContext } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui';
@ -19,18 +19,7 @@ interface Props {
}
export const SingleMetricSettings: FC<Props> = ({ setIsValid }) => {
const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext);
const [bucketSpan, setBucketSpan] = useState(jobCreator.bucketSpan);
useEffect(() => {
jobCreator.bucketSpan = bucketSpan;
jobCreatorUpdate();
setIsValid(bucketSpan !== '');
}, [bucketSpan]);
useEffect(() => {
setBucketSpan(jobCreator.bucketSpan);
}, [jobCreatorUpdated]);
const { jobCreator } = useContext(JobCreatorContext);
const convertToMultiMetric = () => {
convertToMultiMetricJob(jobCreator);

View file

@ -15,6 +15,7 @@ import { SingleMetricView } from './components/single_metric_view';
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 { JsonEditorFlyout, EDITOR_MODE } from '../common/json_editor_flyout';
import { DatafeedPreviewFlyout } from '../common/datafeed_preview_flyout';
@ -30,7 +31,9 @@ export const PickFieldsStep: FC<StepProps> = ({ setCurrentStep, isCurrentStep })
(jobCreator.type === JOB_TYPE.ADVANCED && jobValidator.modelMemoryLimit.valid)) &&
jobValidator.bucketSpan.valid &&
jobValidator.duplicateDetectors.valid &&
jobValidator.validating === false;
jobValidator.validating === false &&
(jobCreator.type !== JOB_TYPE.CATEGORIZATION ||
(jobCreator.type === JOB_TYPE.CATEGORIZATION && jobValidator.categorizationField));
setNextActive(active);
}, [jobValidatorUpdated]);
@ -50,6 +53,9 @@ export const PickFieldsStep: FC<StepProps> = ({ setCurrentStep, isCurrentStep })
{jobType === JOB_TYPE.ADVANCED && (
<AdvancedView isActive={isCurrentStep} setCanProceed={setNextActive} />
)}
{jobType === JOB_TYPE.CATEGORIZATION && (
<CategorizationView isActive={isCurrentStep} setCanProceed={setNextActive} />
)}
<WizardNav
previous={() =>
setCurrentStep(

View file

@ -11,6 +11,7 @@ import { SingleMetricView } from '../../../pick_fields_step/components/single_me
import { MultiMetricView } from '../../../pick_fields_step/components/multi_metric_view';
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';
export const DetectorChart: FC = () => {
const { jobCreator } = useContext(JobCreatorContext);
@ -21,6 +22,7 @@ export const DetectorChart: FC = () => {
{jobCreator.type === JOB_TYPE.MULTI_METRIC && <MultiMetricView isActive={false} />}
{jobCreator.type === JOB_TYPE.POPULATION && <PopulationView isActive={false} />}
{jobCreator.type === JOB_TYPE.ADVANCED && <AdvancedView isActive={false} />}
{jobCreator.type === JOB_TYPE.CATEGORIZATION && <CategorizationView isActive={false} />}
</Fragment>
);
};

View file

@ -32,15 +32,24 @@ function getWizardUrlFromCloningJob(job: CombinedJob) {
const created = job?.custom_settings?.created_by;
let page = '';
if (created === CREATED_BY_LABEL.SINGLE_METRIC) {
page = JOB_TYPE.SINGLE_METRIC;
} else if (created === CREATED_BY_LABEL.MULTI_METRIC) {
page = JOB_TYPE.MULTI_METRIC;
} else if (created === CREATED_BY_LABEL.POPULATION) {
page = JOB_TYPE.POPULATION;
} else {
page = JOB_TYPE.ADVANCED;
switch (created) {
case CREATED_BY_LABEL.SINGLE_METRIC:
page = JOB_TYPE.SINGLE_METRIC;
break;
case CREATED_BY_LABEL.MULTI_METRIC:
page = JOB_TYPE.MULTI_METRIC;
break;
case CREATED_BY_LABEL.POPULATION:
page = JOB_TYPE.POPULATION;
break;
case CREATED_BY_LABEL.CATEGORIZATION:
page = JOB_TYPE.CATEGORIZATION;
break;
default:
page = JOB_TYPE.ADVANCED;
break;
}
const indexPatternId = getIndexPatternIdFromName(job.datafeed_config.indices[0]);
return `jobs/new_job/${page}?index=${indexPatternId}&_g=()`;

View file

@ -151,6 +151,22 @@ export const Page: FC = () => {
}),
id: 'mlJobTypeLinkAdvancedJob',
},
{
href: getUrl('#jobs/new_job/categorization'),
icon: {
type: 'createAdvancedJob',
ariaLabel: i18n.translate('xpack.ml.newJob.wizard.jobType.categorizationAriaLabel', {
defaultMessage: 'Categorization job',
}),
},
title: i18n.translate('xpack.ml.newJob.wizard.jobType.categorizationTitle', {
defaultMessage: 'Categorization',
}),
description: i18n.translate('xpack.ml.newJob.wizard.jobType.categorizationDescription', {
defaultMessage: 'Group log messages into categories and detect anomalies within them.',
}),
id: 'mlJobTypeLinkCategorizationJob',
},
];
return (

View file

@ -19,8 +19,12 @@ import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { Wizard } from './wizard';
import { WIZARD_STEPS } from '../components/step_types';
import { jobCreatorFactory, isAdvancedJobCreator } from '../../common/job_creator';
import { getJobCreatorTitle } from '../../common/job_creator/util/general';
import {
jobCreatorFactory,
isAdvancedJobCreator,
isCategorizationJobCreator,
} from '../../common/job_creator';
import {
JOB_TYPE,
DEFAULT_MODEL_MEMORY_LIMIT,
@ -34,6 +38,9 @@ import { getTimeFilterRange } from '../../../../components/full_time_range_selec
import { TimeBuckets } from '../../../../util/time_buckets';
import { ExistingJobsAndGroups, mlJobService } from '../../../../services/job_service';
import { expandCombinedJobConfig } from '../../common/job_creator/configs';
import { newJobCapsService } from '../../../../services/new_job_capabilities_service';
import { EVENT_RATE_FIELD_ID } from '../../../../../../common/types/fields';
import { getNewJobDefaults } from '../../../../services/ml_server_info';
const PAGE_WIDTH = 1200; // document.querySelector('.single-metric-job-container').width();
const BAR_TARGET = PAGE_WIDTH > 2000 ? 1000 : PAGE_WIDTH / 2;
@ -66,6 +73,7 @@ export const Page: FC<PageProps> = ({ existingJobsAndGroups, jobType }) => {
// cloning a job
const clonedJob = mlJobService.cloneJob(mlJobService.tempJobCloningObjects.job);
const { job, datafeed } = expandCombinedJobConfig(clonedJob);
initCategorizationSettings();
jobCreator.cloneFromExistingJob(job, datafeed);
// if we're not skipping the time range, this is a standard job clone, so wipe the jobId
@ -103,7 +111,11 @@ export const Page: FC<PageProps> = ({ existingJobsAndGroups, jobType }) => {
// creating a new job
jobCreator.bucketSpan = DEFAULT_BUCKET_SPAN;
if (jobCreator.type !== JOB_TYPE.POPULATION && jobCreator.type !== JOB_TYPE.ADVANCED) {
if (
jobCreator.type !== JOB_TYPE.POPULATION &&
jobCreator.type !== JOB_TYPE.ADVANCED &&
jobCreator.type !== JOB_TYPE.CATEGORIZATION
) {
// for all other than population or advanced, use 10MB
jobCreator.modelMemoryLimit = DEFAULT_MODEL_MEMORY_LIMIT;
}
@ -120,6 +132,7 @@ export const Page: FC<PageProps> = ({ existingJobsAndGroups, jobType }) => {
// auto set the time range if creating a new advanced job
autoSetTimeRange = isAdvancedJobCreator(jobCreator);
initCategorizationSettings();
}
if (autoSetTimeRange && isAdvancedJobCreator(jobCreator)) {
@ -137,6 +150,20 @@ export const Page: FC<PageProps> = ({ existingJobsAndGroups, jobType }) => {
}
}
function initCategorizationSettings() {
if (isCategorizationJobCreator(jobCreator)) {
// categorization job will always use a count agg, so give it
// to the job creator now
const count = newJobCapsService.getAggById('count');
const rare = newJobCapsService.getAggById('rare');
const eventRate = newJobCapsService.getFieldById(EVENT_RATE_FIELD_ID);
jobCreator.setDefaultDetectorProperties(count, rare, eventRate);
const { anomaly_detectors: anomalyDetectors } = getNewJobDefaults();
jobCreator.categorizationAnalyzer = anomalyDetectors.categorization_analyzer!;
}
}
const chartInterval = new TimeBuckets();
chartInterval.setBarTarget(BAR_TARGET);
chartInterval.setMaxBars(MAX_BARS);

View file

@ -4,12 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
IndexPattern,
esQuery,
Query,
esKuery,
} from '../../../../../../../../../src/plugins/data/public';
import { esQuery, Query, esKuery } from '../../../../../../../../../src/plugins/data/public';
import { IIndexPattern } from '../../../../../../../../../src/plugins/data/common/index_patterns';
import { KibanaConfigTypeFix } from '../../../contexts/kibana';
import { SEARCH_QUERY_LANGUAGE } from '../../../../../common/constants/search';
import { SavedSearchSavedObject } from '../../../../../common/types/kibana';
@ -19,7 +15,7 @@ import { getQueryFromSavedSearch } from '../../../util/index_utils';
export function createSearchItems(
kibanaConfig: KibanaConfigTypeFix,
indexPattern: IndexPattern,
indexPattern: IIndexPattern,
savedSearch: SavedSearchSavedObject | null
) {
// query is only used by the data visualizer as it needs

View file

@ -72,6 +72,16 @@ const advancedBreadcrumbs = [
},
];
const categorizationBreadcrumbs = [
...baseBreadcrumbs,
{
text: i18n.translate('xpack.ml.jobsBreadcrumbs.categorizationLabel', {
defaultMessage: 'Categorization',
}),
href: '',
},
];
export const singleMetricRoute: MlRoute = {
path: '/jobs/new_job/single_metric',
render: (props, config, deps) => (
@ -104,6 +114,14 @@ export const advancedRoute: MlRoute = {
breadcrumbs: advancedBreadcrumbs,
};
export const categorizationRoute: MlRoute = {
path: '/jobs/new_job/categorization',
render: (props, config, deps) => (
<PageWrapper config={config} {...props} jobType={JOB_TYPE.CATEGORIZATION} deps={deps} />
),
breadcrumbs: categorizationBreadcrumbs,
};
const PageWrapper: FC<WizardPageProps> = ({ location, config, jobType, deps }) => {
const { index, savedSearchId } = queryString.parse(location.search);
const { context, results } = useResolver(index, savedSearchId, config, {

View file

@ -60,8 +60,6 @@ export const useResolver = (
}
} catch (error) {
// quietly fail. Let the resolvers handle the redirection if any fail to resolve
// eslint-disable-next-line no-console
console.error('ML page loading resolver', error);
}
})();
}, []);

View file

@ -7,6 +7,7 @@
import { Observable } from 'rxjs';
import { Annotation } from '../../../../common/types/annotations';
import { AggFieldNamePair } from '../../../../common/types/fields';
import { Category } from '../../../../common/types/categories';
import { ExistingJobsAndGroups } from '../job_service';
import { PrivilegesResponse } from '../../../../common/types/privileges';
import { MlSummaryJobs } from '../../../../common/types/jobs';
@ -107,7 +108,7 @@ declare interface Ml {
checkManageMLPrivileges(): Promise<PrivilegesResponse>;
getJobStats(obj: object): Promise<any>;
getDatafeedStats(obj: object): Promise<any>;
esSearch(obj: object): any;
esSearch(obj: object): Promise<any>;
esSearch$(obj: object): Observable<any>;
getIndices(): Promise<EsIndex[]>;
dataRecognizerModuleJobsExist(obj: { moduleId: string }): Promise<any>;
@ -171,6 +172,20 @@ declare interface Ml {
start: number,
end: number
): Promise<{ progress: number; isRunning: boolean; isJobClosed: boolean }>;
categorizationFieldExamples(
indexPatternTitle: string,
query: object,
size: number,
field: string,
timeField: string | undefined,
start: number,
end: number,
analyzer: any
): Promise<{ valid: number; examples: any[] }>;
topCategories(
jobId: string,
count: number
): Promise<{ total: number; categories: Array<{ count?: number; category: Category }> }>;
};
estimateBucketSpan(data: BucketSpanEstimatorData): Promise<BucketSpanEstimatorResponse>;

View file

@ -206,4 +206,41 @@ export const jobs = {
},
});
},
categorizationFieldExamples(
indexPatternTitle,
query,
size,
field,
timeField,
start,
end,
analyzer
) {
return http({
url: `${basePath}/jobs/categorization_field_examples`,
method: 'POST',
data: {
indexPatternTitle,
query,
size,
field,
timeField,
start,
end,
analyzer,
},
});
},
topCategories(jobId, count) {
return http({
url: `${basePath}/jobs/top_categories`,
method: 'POST',
data: {
jobId,
count,
},
});
},
};

View file

@ -11,10 +11,18 @@ export interface MlServerDefaults {
categorization_examples_limit?: number;
model_memory_limit?: string;
model_snapshot_retention_days?: number;
categorization_analyzer?: CategorizationAnalyzer;
};
datafeeds: { scroll_size?: number };
}
export interface CategorizationAnalyzer {
char_filter?: any[];
tokenizer?: string;
filter?: any[];
analyzer?: string;
}
export interface MlServerLimits {
max_model_memory_limit?: string;
}

View file

@ -15,7 +15,6 @@ import {
import {
ES_FIELD_TYPES,
IIndexPattern,
IndexPattern,
IndexPatternsContract,
} from '../../../../../../../src/plugins/data/public';
import { ml } from './ml_api_service';
@ -31,7 +30,7 @@ export function loadNewJobCapabilities(
return new Promise(async (resolve, reject) => {
if (indexPatternId !== undefined) {
// index pattern is being used
const indexPattern: IndexPattern = await indexPatterns.get(indexPatternId);
const indexPattern: IIndexPattern = await indexPatterns.get(indexPatternId);
await newJobCapsService.initializeFromIndexPattern(indexPattern);
resolve(newJobCapsService.newJobCaps);
} else if (savedSearchId !== undefined) {

View file

@ -8,7 +8,11 @@ import { toastNotifications } from 'ui/notify';
import { i18n } from '@kbn/i18n';
import chrome from 'ui/chrome';
import { Query } from 'src/plugins/data/public';
import { IndexPattern, IndexPatternsContract } from '../../../../../../../src/plugins/data/public';
import {
IndexPattern,
IIndexPattern,
IndexPatternsContract,
} from '../../../../../../../src/plugins/data/public';
import { IndexPatternSavedObject, SavedSearchSavedObject } from '../../../common/types/kibana';
let indexPatternCache: IndexPatternSavedObject[] = [];
@ -71,7 +75,7 @@ export function getIndexPatternIdFromName(name: string) {
}
export async function getIndexPatternAndSavedSearch(savedSearchId: string) {
const resp: { savedSearch: SavedSearchSavedObject | null; indexPattern: IndexPattern | null } = {
const resp: { savedSearch: SavedSearchSavedObject | null; indexPattern: IIndexPattern | null } = {
savedSearch: null,
indexPattern: null,
};

View file

@ -753,4 +753,29 @@ export const elasticsearchJsPlugin = (Client, config, components) => {
],
method: 'GET',
});
ml.categories = ca({
urls: [
{
fmt: '/_xpack/ml/anomaly_detectors/<%=jobId%>/results/categories/<%=categoryId%>',
req: {
jobId: {
type: 'string',
},
categoryId: {
type: 'string',
},
},
},
{
fmt: '/_xpack/ml/anomaly_detectors/<%=jobId%>/results/categories',
req: {
jobId: {
type: 'string',
},
},
},
],
method: 'GET',
});
};

View file

@ -8,7 +8,7 @@ import { datafeedsProvider } from './datafeeds';
import { jobsProvider } from './jobs';
import { groupsProvider } from './groups';
import { newJobCapsProvider } from './new_job_caps';
import { newJobChartsProvider } from './new_job';
import { newJobChartsProvider, categorizationExamplesProvider } from './new_job';
export function jobServiceProvider(callWithRequest, request) {
return {
@ -17,5 +17,6 @@ export function jobServiceProvider(callWithRequest, request) {
...groupsProvider(callWithRequest),
...newJobCapsProvider(callWithRequest, request),
...newJobChartsProvider(callWithRequest, request),
...categorizationExamplesProvider(callWithRequest, request),
};
}

View file

@ -0,0 +1,294 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ML_RESULTS_INDEX_PATTERN } from '../../../../common/constants/index_patterns';
import { CATEGORY_EXAMPLES_MULTIPLIER } from '../../../../common/constants/new_job';
import { CategoryId, Category, Token } from '../../../../common/types/categories';
import { callWithRequestType } from '../../../../common/types/kibana';
export function categorizationExamplesProvider(callWithRequest: callWithRequestType) {
async function categorizationExamples(
indexPatternTitle: string,
query: any,
size: number,
categorizationFieldName: string,
timeField: string | undefined,
start: number,
end: number,
analyzer?: any
) {
if (timeField !== undefined) {
const range = {
range: {
[timeField]: {
gte: start,
format: 'epoch_millis',
},
},
};
if (query.bool === undefined) {
query.bool = {};
}
if (query.bool.filter === undefined) {
query.bool.filter = range;
} else {
if (Array.isArray(query.bool.filter)) {
query.bool.filter.push(range);
} else {
query.bool.filter.range = range;
}
}
}
const results = await callWithRequest('search', {
index: indexPatternTitle,
size,
body: {
_source: categorizationFieldName,
query,
},
});
const examples: string[] = results.hits?.hits
?.map((doc: any) => doc._source[categorizationFieldName])
.filter((example: string | undefined) => example !== undefined);
let tokens: Token[] = [];
try {
const { tokens: tempTokens } = await callWithRequest('indices.analyze', {
body: {
...getAnalyzer(analyzer),
text: examples,
},
});
tokens = tempTokens;
} catch (error) {
// fail silently, the tokens could not be loaded
// an empty list of tokens will be returned for each example
}
const lengths = examples.map(e => e.length);
const sumLengths = lengths.map((s => (a: number) => (s += a))(0));
const tokensPerExample: Token[][] = examples.map(e => []);
tokens.forEach((t, i) => {
for (let g = 0; g < sumLengths.length; g++) {
if (t.start_offset <= sumLengths[g] + g) {
const offset = g > 0 ? sumLengths[g - 1] + g : 0;
tokensPerExample[g].push({
...t,
start_offset: t.start_offset - offset,
end_offset: t.end_offset - offset,
});
break;
}
}
});
return examples.map((e, i) => ({ text: e, tokens: tokensPerExample[i] }));
}
function getAnalyzer(analyzer: any) {
if (typeof analyzer === 'object' && analyzer.tokenizer !== undefined) {
return analyzer;
} else {
return { analyzer: 'standard' };
}
}
async function validateCategoryExamples(
indexPatternTitle: string,
query: any,
size: number,
categorizationFieldName: string,
timeField: string | undefined,
start: number,
end: number,
analyzer?: any
) {
const examples = await categorizationExamples(
indexPatternTitle,
query,
size * CATEGORY_EXAMPLES_MULTIPLIER,
categorizationFieldName,
timeField,
start,
end,
analyzer
);
const sortedExamples = examples
.map((e, i) => ({ ...e, origIndex: i }))
.sort((a, b) => b.tokens.length - a.tokens.length);
const validExamples = sortedExamples.filter(e => e.tokens.length > 1);
return {
valid: sortedExamples.length === 0 ? 0 : validExamples.length / sortedExamples.length,
examples: sortedExamples
.filter(
(e, i) =>
i / CATEGORY_EXAMPLES_MULTIPLIER - Math.floor(i / CATEGORY_EXAMPLES_MULTIPLIER) === 0
)
.sort((a, b) => a.origIndex - b.origIndex)
.map(e => ({ text: e.text, tokens: e.tokens })),
};
}
async function getTotalCategories(jobId: string): Promise<{ total: number }> {
const totalResp = await callWithRequest('search', {
index: ML_RESULTS_INDEX_PATTERN,
size: 0,
body: {
query: {
bool: {
filter: [
{
term: {
job_id: jobId,
},
},
{
exists: {
field: 'category_id',
},
},
],
},
},
},
});
return totalResp?.hits?.total?.value ?? 0;
}
async function getTopCategoryCounts(jobId: string, numberOfCategories: number) {
const top = await callWithRequest('search', {
index: ML_RESULTS_INDEX_PATTERN,
size: 0,
body: {
query: {
bool: {
filter: [
{
term: {
job_id: jobId,
},
},
{
term: {
result_type: 'model_plot',
},
},
{
term: {
by_field_name: 'mlcategory',
},
},
],
},
},
aggs: {
cat_count: {
terms: {
field: 'by_field_value',
size: numberOfCategories,
},
},
},
},
});
const catCounts: Array<{
id: CategoryId;
count: number;
}> = top.aggregations?.cat_count?.buckets.map((c: any) => ({
id: c.key,
count: c.doc_count,
}));
return catCounts || [];
}
async function getCategories(
jobId: string,
catIds: CategoryId[],
size: number
): Promise<Category[]> {
const categoryFilter = catIds.length
? {
terms: {
category_id: catIds,
},
}
: {
exists: {
field: 'category_id',
},
};
const result = await callWithRequest('search', {
index: ML_RESULTS_INDEX_PATTERN,
size,
body: {
query: {
bool: {
filter: [
{
term: {
job_id: jobId,
},
},
categoryFilter,
],
},
},
},
});
return result.hits.hits?.map((c: { _source: Category }) => c._source) || [];
}
async function topCategories(jobId: string, numberOfCategories: number) {
const catCounts = await getTopCategoryCounts(jobId, numberOfCategories);
const categories = await getCategories(
jobId,
catCounts.map(c => c.id),
catCounts.length || numberOfCategories
);
const catsById = categories.reduce((p, c) => {
p[c.category_id] = c;
return p;
}, {} as { [id: number]: Category });
const total = await getTotalCategories(jobId);
if (catCounts.length) {
return {
total,
categories: catCounts.map(({ id, count }) => {
return {
count,
category: catsById[id] ?? null,
};
}),
};
} else {
return {
total,
categories: categories.map(category => {
return {
category,
};
}),
};
}
}
return {
categorizationExamples,
validateCategoryExamples,
topCategories,
};
}

View file

@ -6,7 +6,7 @@
import { newJobLineChartProvider } from './line_chart';
import { newJobPopulationChartProvider } from './population_chart';
export type callWithRequestType = (action: string, params: any) => Promise<any>;
import { callWithRequestType } from '../../../../common/types/kibana';
export function newJobChartsProvider(callWithRequest: callWithRequestType) {
const { newJobLineChart } = newJobLineChartProvider(callWithRequest);

View file

@ -5,3 +5,4 @@
*/
export { newJobChartsProvider } from './charts';
export { categorizationExamplesProvider } from './categorization';

View file

@ -6,10 +6,9 @@
import { get } from 'lodash';
import { AggFieldNamePair, EVENT_RATE_FIELD_ID } from '../../../../common/types/fields';
import { callWithRequestType } from '../../../../common/types/kibana';
import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils';
export type callWithRequestType = (action: string, params: any) => Promise<any>;
type DtrIndex = number;
type TimeStamp = number;
type Value = number | undefined | null;

View file

@ -6,10 +6,9 @@
import { get } from 'lodash';
import { AggFieldNamePair, EVENT_RATE_FIELD_ID } from '../../../../common/types/fields';
import { callWithRequestType } from '../../../../common/types/kibana';
import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils';
export type callWithRequestType = (action: string, params: any) => Promise<any>;
const OVER_FIELD_EXAMPLES_COUNT = 40;
type DtrIndex = number;

View file

@ -181,4 +181,22 @@ export function jobRoutes({ commonRouteConfig, elasticsearchPlugin, route }) {
...commonRouteConfig,
},
});
route({
method: 'GET',
path: '/api/ml/anomaly_detectors/{jobId}/results/categories/{categoryId}',
handler(request, reply) {
const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request);
const options = {
jobId: request.params.jobId,
categoryId: request.params.categoryId,
};
return callWithRequest('ml.categories', options)
.then(resp => reply(resp))
.catch(resp => reply(wrapError(resp)));
},
config: {
...commonRouteConfig,
},
});
}

View file

@ -270,4 +270,50 @@ export function jobServiceRoutes({ commonRouteConfig, elasticsearchPlugin, route
...commonRouteConfig,
},
});
route({
method: 'POST',
path: '/api/ml/jobs/categorization_field_examples',
handler(request) {
const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request);
const { validateCategoryExamples } = jobServiceProvider(callWithRequest);
const {
indexPatternTitle,
timeField,
query,
size,
field,
start,
end,
analyzer,
} = request.payload;
return validateCategoryExamples(
indexPatternTitle,
query,
size,
field,
timeField,
start,
end,
analyzer
).catch(resp => wrapError(resp));
},
config: {
...commonRouteConfig,
},
});
route({
method: 'POST',
path: '/api/ml/jobs/top_categories',
handler(request) {
const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request);
const { topCategories } = jobServiceProvider(callWithRequest);
const { jobId, count } = request.payload;
return topCategories(jobId, count).catch(resp => wrapError(resp));
},
config: {
...commonRouteConfig,
},
});
}