From ed7b2d7b04404746d62ef72d1e70f2ca42306492 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Mon, 4 Oct 2021 14:38:15 -0700 Subject: [PATCH] wip: create ml anomalies layer in maps --- x-pack/plugins/maps/common/index.ts | 5 + x-pack/plugins/ml/common/util/job_utils.ts | 13 + .../services/ml_api_service/jobs.ts | 7 + .../ml/public/maps/anomaly_job_selector.tsx | 84 +++++ .../ml/public/maps/anomaly_layer_wizard.tsx | 72 ++++ .../plugins/ml/public/maps/anomaly_source.tsx | 330 ++++++++++++++++++ .../maps/create_anomaly_source_editor.tsx | 83 +++++ .../ml/public/maps/record_score_field.ts | 129 +++++++ .../ml/public/maps/register_with_maps.ts | 21 ++ .../public/maps/typical_actual_selector.tsx | 63 ++++ .../maps/update_anomaly_source_editor.tsx | 49 +++ x-pack/plugins/ml/public/maps/util.ts | 126 +++++++ x-pack/plugins/ml/public/plugin.ts | 9 +- .../ml/server/models/job_service/jobs.ts | 18 + .../plugins/ml/server/routes/job_service.ts | 38 ++ 15 files changed, 1046 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/ml/public/maps/anomaly_job_selector.tsx create mode 100644 x-pack/plugins/ml/public/maps/anomaly_layer_wizard.tsx create mode 100644 x-pack/plugins/ml/public/maps/anomaly_source.tsx create mode 100644 x-pack/plugins/ml/public/maps/create_anomaly_source_editor.tsx create mode 100644 x-pack/plugins/ml/public/maps/record_score_field.ts create mode 100644 x-pack/plugins/ml/public/maps/register_with_maps.ts create mode 100644 x-pack/plugins/ml/public/maps/typical_actual_selector.tsx create mode 100644 x-pack/plugins/ml/public/maps/update_anomaly_source_editor.tsx create mode 100644 x-pack/plugins/ml/public/maps/util.ts diff --git a/x-pack/plugins/maps/common/index.ts b/x-pack/plugins/maps/common/index.ts index 8374a4d0dbaa3ea..e59a6a34262d634 100644 --- a/x-pack/plugins/maps/common/index.ts +++ b/x-pack/plugins/maps/common/index.ts @@ -9,14 +9,19 @@ export { AGG_TYPE, COLOR_MAP_TYPE, ES_GEO_FIELD_TYPE, + FieldFormatter, FIELD_ORIGIN, INITIAL_LOCATION, LABEL_BORDER_SIZES, LAYER_TYPE, + LAYER_WIZARD_CATEGORY, MAP_SAVED_OBJECT_TYPE, + MAX_ZOOM, + MIN_ZOOM, SOURCE_TYPES, STYLE_TYPE, SYMBOLIZE_AS_TYPES, + VECTOR_SHAPE_TYPE, } from './constants'; export { diff --git a/x-pack/plugins/ml/common/util/job_utils.ts b/x-pack/plugins/ml/common/util/job_utils.ts index 6d069cd4383ea09..7603d4c49842832 100644 --- a/x-pack/plugins/ml/common/util/job_utils.ts +++ b/x-pack/plugins/ml/common/util/job_utils.ts @@ -73,6 +73,19 @@ export function isMappableJob(job: CombinedJob, detectorIndex: number): boolean return isMappable; } +// Returns a boolean indicating whether the specified job is suitable for maps plugin. +export function isJobWithGeoData(job: Job): boolean { + let isMappable = false; + const { detectors } = job.analysis_config; + + detectors.forEach((detector) => { + if (detector.function === ML_JOB_AGGREGATION.LAT_LONG) { + isMappable = true; + } + }); + return isMappable; +} + /** * Validates that composite definition only have sources that are only terms and date_histogram * if composite is defined. diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts index 96c5e1abce17076..41bf3fc918626b8 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts @@ -45,6 +45,13 @@ export const jobsApiProvider = (httpService: HttpService) => ({ }); }, + jobsWithGeo() { + return httpService.http({ + path: `${ML_BASE_PATH}/jobs/jobs_with_geo`, + method: 'GET', + }); + }, + jobsWithTimerange(dateFormatTz: string) { const body = JSON.stringify({ dateFormatTz }); return httpService.http<{ diff --git a/x-pack/plugins/ml/public/maps/anomaly_job_selector.tsx b/x-pack/plugins/ml/public/maps/anomaly_job_selector.tsx new file mode 100644 index 000000000000000..cc92d376e0dd8f1 --- /dev/null +++ b/x-pack/plugins/ml/public/maps/anomaly_job_selector.tsx @@ -0,0 +1,84 @@ +/* + * 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, { Component } from 'react'; + +import { EuiComboBox, EuiFormRow, EuiComboBoxOptionOption } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { isEqual } from 'lodash'; +import { ml } from '../application/services/ml_api_service'; + +interface Props { + onJobChange: (jobId: string) => void; +} + +interface State { + jobId?: string; + jobIdList?: Array>; +} + +export class AnomalyJobSelector extends Component { + private _isMounted: boolean = false; + + state: State = {}; + + private async _loadJobs() { + const jobIdList = await ml.jobs.jobsWithGeo(); + const options = jobIdList.map((jobId) => { + return { label: jobId, value: jobId }; + }); + if (this._isMounted && !isEqual(options, this.state.jobIdList)) { + this.setState({ + jobIdList: options, + }); + } + } + + componentDidUpdate(prevProps: Readonly, prevState: Readonly, snapshot?: any): void { + this._loadJobs(); + } + + componentDidMount(): void { + this._isMounted = true; + this._loadJobs(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + onJobIdSelect = (selectedOptions: Array>) => { + const jobId: string = selectedOptions[0].value!; + if (this._isMounted) { + this.setState({ jobId }); + this.props.onJobChange(jobId); + } + }; + + render() { + if (!this.state.jobIdList) { + return null; + } + + const options = this.state.jobId ? [{ value: this.state.jobId, label: this.state.jobId }] : []; + return ( + + + + ); + } +} diff --git a/x-pack/plugins/ml/public/maps/anomaly_layer_wizard.tsx b/x-pack/plugins/ml/public/maps/anomaly_layer_wizard.tsx new file mode 100644 index 000000000000000..f633d970945ce9a --- /dev/null +++ b/x-pack/plugins/ml/public/maps/anomaly_layer_wizard.tsx @@ -0,0 +1,72 @@ +/* + * 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 React from 'react'; +import uuid from 'uuid'; +import { LAYER_TYPE, LAYER_WIZARD_CATEGORY, STYLE_TYPE } from '../../../maps/common'; +import { AnomalySource, AnomalySourceDescriptor } from './anomaly_source'; +import { CreateAnomalySourceEditor } from './create_anomaly_source_editor'; +import { + VectorLayerDescriptor, + VectorStylePropertiesDescriptor, +} from '../../../maps/common/descriptor_types'; +import type { LayerWizard, RenderWizardArguments } from '../../../maps/public'; + +export const anomalyLayerWizard: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.SOLUTIONS], + description: i18n.translate('xpack.ml.maps.anomalyLayerDescription', { + defaultMessage: 'Create anomalies layers', + }), + disabledReason: i18n.translate('xpack.ml.maps.anomalyLayerUnavailableMessage', { + defaultMessage: + 'Whatever reason the user cannot see ML card (likely because no enterprise license or no ML privileges)', + }), + icon: 'outlierDetectionJob', + getIsDisabled: () => { + // Do enterprise license check + // Do check if user has access to job (namespace chec or whatever) + return false; + }, + renderWizard: ({ previewLayers }: RenderWizardArguments) => { + const onSourceConfigChange = (sourceConfig: Partial | null) => { + if (!sourceConfig) { + previewLayers([]); + return; + } + + // remove usage of VectorLayer.createDescriptor. should be hardcoded to actual descriptor + const anomalyLayerDescriptor: VectorLayerDescriptor = { + id: uuid(), + type: LAYER_TYPE.VECTOR, + sourceDescriptor: AnomalySource.createDescriptor({ + jobId: sourceConfig.jobId, + typicalActual: sourceConfig.typicalActual, + }), + style: { + type: 'VECTOR', + properties: { + fillColor: { + type: STYLE_TYPE.STATIC, + options: { + color: 'rgb(255,0,0)', + }, + }, + } as unknown as VectorStylePropertiesDescriptor, + isTimeAware: false, + }, + }; + + previewLayers([anomalyLayerDescriptor]); + }; + + return ; + }, + title: i18n.translate('xpack.ml.maps.anomalyLayerTitle', { + defaultMessage: 'ML Anomalies', + }), +}; diff --git a/x-pack/plugins/ml/public/maps/anomaly_source.tsx b/x-pack/plugins/ml/public/maps/anomaly_source.tsx new file mode 100644 index 000000000000000..c9147391a964cce --- /dev/null +++ b/x-pack/plugins/ml/public/maps/anomaly_source.tsx @@ -0,0 +1,330 @@ +/* + * 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 React, { ReactElement } from 'react'; +import { FieldFormatter, MAX_ZOOM, MIN_ZOOM, VECTOR_SHAPE_TYPE } from '../../../maps/common'; +import { AbstractSourceDescriptor, MapExtent } from '../../../maps/common/descriptor_types'; +import { ITooltipProperty } from '../../../maps/public'; +import { RecordScoreField, RecordScoreTooltipProperty } from './record_score_field'; +import type { Adapters } from '../../../../../src/plugins/inspector/common/adapters'; +import type { GeoJsonWithMeta } from '../../../maps/public'; +import type { IField } from '../../../maps/public'; +import type { Attribution, ImmutableSourceProperty, PreIndexedShape } from '../../../maps/public'; +import type { SourceEditorArgs } from '../../../maps/public'; +import type { DataRequest } from '../../../maps/public'; +import type { SourceTooltipConfig } from '../../../maps/public'; +import type { IVectorSource } from '../../../maps/public'; +import { ML_ANOMALY } from './register_with_maps'; +import { getResultsForJobId } from './util'; +import { UpdateAnomalySourceEditor } from './update_anomaly_source_editor'; + +export interface AnomalySourceDescriptor extends AbstractSourceDescriptor { + jobId: string; + typicalActual: 'typical' | 'actual'; +} + +export class AnomalySource implements IVectorSource { + static createDescriptor(descriptor: Partial) { + // Fill in all the defaults + return { + type: ML_ANOMALY, + jobId: typeof descriptor.jobId === 'string' ? descriptor.jobId : 'foobar', + typicalActual: descriptor.typicalActual || 'typical', + }; + } + + private readonly _descriptor: AnomalySourceDescriptor; + + constructor(sourceDescriptor: Partial, adapters?: Adapters) { + this._descriptor = AnomalySource.createDescriptor(sourceDescriptor); + } + + async getGeoJsonWithMeta( + layerName: string, + searchFilters: any & { + applyGlobalQuery: boolean; + applyGlobalTime: boolean; + fieldNames: string[]; + geogridPrecision?: number; + sourceQuery?: object; + sourceMeta: object; + }, + registerCancelCallback: (callback: () => void) => void, + isRequestStillActive: () => boolean + ): Promise { + const results = await getResultsForJobId( + this._descriptor.jobId, + this._descriptor.typicalActual + ); + + return { + data: results, + meta: { + areResultsTrimmed: false, // only set this if data is incomplete (e.g. capping number of results to first 10k) + }, + }; + } + + canFormatFeatureProperties(): boolean { + return false; + } + + cloneDescriptor(): AnomalySourceDescriptor { + return { + type: this._descriptor.type, + jobId: this._descriptor.jobId, + typicalActual: this._descriptor.typicalActual, + }; + } + + createField({ fieldName }: { fieldName: string }): IField { + if (fieldName !== 'record_score') { + throw new Error('PEBKAC'); + } + return new RecordScoreField({ source: this }); + } + + async createFieldFormatter(field: IField): Promise { + return null; + } + + destroy(): void {} + + getApplyGlobalQuery(): boolean { + return false; + } + + getApplyForceRefresh(): boolean { + return false; + } + + getApplyGlobalTime(): boolean { + return false; + } + + async getAttributions(): Promise { + return []; + } + + async getBoundsForFilters( + boundsFilters: object, + registerCancelCallback: (callback: () => void) => void + ): Promise { + return null; + } + + async getDisplayName(): Promise { + return i18n.translate('xpack.ml.maps.anomalySource.displayLabel', { + defaultMessage: '{typicalActual} for {jobId}', + values: { + typicalActual: this._descriptor.typicalActual, + jobId: this._descriptor.jobId, + }, + }); + } + + getFieldByName(fieldName: string): IField | null { + if (fieldName === 'record_score') { + return new RecordScoreField({ source: this }); + } + return null; + } + + showJoinEditor(): boolean { + // Ignore, only show if joins are enabled for current configuration + return false; + } + + getFieldNames(): string[] { + return ['record_score']; + } + + async getFields(): Promise { + return [new RecordScoreField({ source: this })]; + } + + getGeoGridPrecision(zoom: number): number { + return 0; + } + + isBoundsAware(): boolean { + return false; + } + + async getImmutableProperties(): Promise { + return [ + { + label: i18n.translate('xpack.ml.maps.anomalySourcePropLabel', { + defaultMessage: 'Job Id', + }), + value: this._descriptor.jobId, + }, + ]; + } + + async isTimeAware(): Promise { + return true; + } + + renderSourceSettingsEditor({ onChange }: SourceEditorArgs): ReactElement | null { + return ( + + ); + } + + async supportsFitToBounds(): Promise { + // Return true if you can compute bounds of data + return true; + } + // Promise> + async getLicensedFeatures(): Promise { + return [{ name: 'layer from ML anomaly job', license: 'enterprise' }]; + } + + getMaxZoom(): number { + return MAX_ZOOM; + } + + getMinZoom(): number { + return MIN_ZOOM; + } + + getSourceTooltipContent(sourceDataRequest?: DataRequest): SourceTooltipConfig { + return { + tooltipContent: i18n.translate('xpack.ml.maps.sourceTooltip', { + defaultMessage: `Shows anomalies`, + }), + areResultsTrimmed: false, // set to true if data is incomplete + }; + } + + async getSupportedShapeTypes(): Promise { + return [VECTOR_SHAPE_TYPE.POINT]; + } + + getSyncMeta(): object | null { + return { + jobId: this._descriptor.jobId, + typicalActual: this._descriptor.typicalActual, + }; + } + + async getTooltipProperties(properties: { [p: string]: any } | null): Promise { + const tooltipProperties: ITooltipProperty[] = []; + for (const key in properties) { + if (key === 'record_score') { + tooltipProperties.push(new RecordScoreTooltipProperty('Record score', properties[key])); + } + } + return tooltipProperties; + } + + isFieldAware(): boolean { + return true; + } + + // This is for type-ahead support in the UX for by-value styling + async getValueSuggestions(field: IField, query: string): Promise { + return []; + } + + // ----------------- + // API ML probably can ignore + getAttributionProvider() { + return null; + } + + getIndexPatternIds(): string[] { + // IGNORE: This is only relevant if your source is backed by an index-pattern + return []; + } + + getInspectorAdapters(): Adapters | undefined { + // IGNORE: This is only relevant if your source is backed by an index-pattern + return undefined; + } + + getJoinsDisabledReason(): string | null { + // IGNORE: This is only relevant if your source can be joined to other data + return null; + } + + async getLeftJoinFields(): Promise { + // IGNORE: This is only relevant if your source can be joined to other data + return []; + } + + async getPreIndexedShape( + properties: { [p: string]: any } | null + ): Promise { + // IGNORE: This is only relevant if your source is backed by an index-pattern + return null; + } + + getQueryableIndexPatternIds(): string[] { + // IGNORE: This is only relevant if your source is backed by an index-pattern + return []; + } + + isESSource(): boolean { + // IGNORE: This is only relevant if your source is backed by an index-pattern + return false; + } + + isFilterByMapBounds(): boolean { + // Only implement if you can query this data with a bounding-box + return false; + } + + isGeoGridPrecisionAware(): boolean { + // Ignore: only implement if your data is scale-dependent (probably not) + return false; + } + + isQueryAware(): boolean { + // IGNORE: This is only relevant if your source is backed by an index-pattern + return false; + } + + isRefreshTimerAware(): boolean { + // Allow force-refresh when user clicks "refresh" button in the global time-picker + return true; + } + + async getTimesliceMaskFieldName() { + return null; + } + + async supportsFeatureEditing() { + return false; + } + + hasTooltipProperties() { + return true; + } + + async addFeature() { + // TODO + } + + async deleteFeature() { + // TODO + } + + getUpdateDueToTimeslice() { + // TODO + return true; + } + + async getDefaultFields(): Promise>> { + return {}; + } +} diff --git a/x-pack/plugins/ml/public/maps/create_anomaly_source_editor.tsx b/x-pack/plugins/ml/public/maps/create_anomaly_source_editor.tsx new file mode 100644 index 000000000000000..2982edae61f3a04 --- /dev/null +++ b/x-pack/plugins/ml/public/maps/create_anomaly_source_editor.tsx @@ -0,0 +1,83 @@ +/* + * 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, { Component } from 'react'; + +import { EuiPanel } from '@elastic/eui'; +import { AnomalySourceDescriptor } from './anomaly_source'; +import { AnomalyJobSelector } from './anomaly_job_selector'; +import { TypicalActualSelector } from './typical_actual_selector'; + +interface Props { + onSourceConfigChange: (sourceConfig: Partial | null) => void; +} + +interface State { + jobId?: string; + typicalActual?: 'typical' | 'actual'; +} + +export class CreateAnomalySourceEditor extends Component { + private _isMounted: boolean = false; + state: State = {}; + + private configChange() { + if (this.state.jobId) { + this.props.onSourceConfigChange({ + jobId: this.state.jobId, + typicalActual: this.state.typicalActual, + }); + } + } + + componentDidMount(): void { + this._isMounted = true; + } + + private onTypicalActualChange = (typicalActual: 'typical' | 'actual') => { + if (!this._isMounted) { + return; + } + this.setState( + { + typicalActual, + }, + () => { + this.configChange(); + } + ); + }; + + private previewLayer = (jobId: string) => { + if (!this._isMounted) { + return; + } + this.setState( + { + jobId, + }, + () => { + this.configChange(); + } + ); + }; + + render() { + const selector = this.state.jobId ? ( + + ) : null; + return ( + + + {selector} + + ); + } +} diff --git a/x-pack/plugins/ml/public/maps/record_score_field.ts b/x-pack/plugins/ml/public/maps/record_score_field.ts new file mode 100644 index 000000000000000..e9be3d86793602b --- /dev/null +++ b/x-pack/plugins/ml/public/maps/record_score_field.ts @@ -0,0 +1,129 @@ +/* + * 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. + */ + +// eslint-disable-next-line max-classes-per-file +import _ from 'lodash'; +import { IField, IVectorSource } from '../../../maps/public'; +import { FIELD_ORIGIN } from '../../../maps/common'; +import { AnomalySource } from './anomaly_source'; +import { ITooltipProperty } from '../../../maps/public'; +import { Filter } from '../../../../../src/plugins/data/public'; + +export class RecordScoreTooltipProperty implements ITooltipProperty { + private readonly _label: string; + private readonly _value: string; + + constructor(label: string, value: string) { + this._label = label; + this._value = value; + } + + async getESFilters(): Promise { + return []; + } + + getHtmlDisplayValue(): string { + return this._value.toString(); + } + + getPropertyKey(): string { + return this._label; + } + + getPropertyName(): string { + return this._label; + } + + getRawValue(): string | string[] | undefined { + return this._value.toString(); + } + + isFilterable(): boolean { + return false; + } +} + +export class RecordScoreField implements IField { + private readonly _source: AnomalySource; + + constructor({ source }: { source: AnomalySource }) { + this._source = source; + } + + async createTooltipProperty(value: string | string[] | undefined): Promise { + return new RecordScoreTooltipProperty( + await this.getLabel(), + _.escape(Array.isArray(value) ? value.join() : value ? value : '') + ); + } + + async getDataType(): Promise { + return 'number'; + } + + async getLabel(): Promise { + return 'Record score'; + } + + getName(): string { + return 'record_score'; + } + + getOrigin(): FIELD_ORIGIN { + return FIELD_ORIGIN.SOURCE; + } + + getRootName(): string { + return this.getName(); + } + + getSource(): IVectorSource { + return this._source; + } + + isEqual(field: IField): boolean { + return this.getName() === field.getName(); + } + + isValid(): boolean { + return true; + } + + // NA + canReadFromGeoJson(): boolean { + return false; + } + + // NA + canValueBeFormatted(): boolean { + return false; + } + + // NA + supportsAutoDomain(): boolean { + return false; + } + + // NA + supportsFieldMeta(): boolean { + return false; + } + + // NA + async getPercentilesFieldMetaRequest(percentiles: number[]): Promise { + return null; + } + + // NA + async getExtendedStatsFieldMetaRequest(): Promise { + return undefined; + } + // NA + async getCategoricalFieldMetaRequest(size: number): Promise { + return undefined; + } +} diff --git a/x-pack/plugins/ml/public/maps/register_with_maps.ts b/x-pack/plugins/ml/public/maps/register_with_maps.ts new file mode 100644 index 000000000000000..9757b310120239f --- /dev/null +++ b/x-pack/plugins/ml/public/maps/register_with_maps.ts @@ -0,0 +1,21 @@ +/* + * 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 { MapsSetupApi } from '../../../maps/public'; +import { AnomalySource } from './anomaly_source'; +import { anomalyLayerWizard } from './anomaly_layer_wizard'; + +export const ML_ANOMALY = 'ML_ANOMALIES'; + +export function registerWithMaps(mapsSetupApi: MapsSetupApi) { + mapsSetupApi.registerSource({ + type: ML_ANOMALY, + ConstructorFunction: AnomalySource, + }); + + mapsSetupApi.registerLayerWizard(anomalyLayerWizard); +} diff --git a/x-pack/plugins/ml/public/maps/typical_actual_selector.tsx b/x-pack/plugins/ml/public/maps/typical_actual_selector.tsx new file mode 100644 index 000000000000000..eaddb1504c0cac1 --- /dev/null +++ b/x-pack/plugins/ml/public/maps/typical_actual_selector.tsx @@ -0,0 +1,63 @@ +/* + * 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, { Component } from 'react'; + +import { EuiComboBox, EuiFormRow, EuiComboBoxOptionOption } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +interface Props { + onChange: (typicalActual: 'typical' | 'actual') => void; + typicalActual: 'typical' | 'actual'; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface State {} + +export class TypicalActualSelector extends Component { + private _isMounted: boolean = false; + + state: State = {}; + + componentDidMount(): void { + this._isMounted = true; + } + + componentWillUnmount() { + this._isMounted = false; + } + + onSelect = (selectedOptions: Array>) => { + const typicalActual: 'typical' | 'actual' = selectedOptions[0].value! as 'typical' | 'actual'; + if (this._isMounted) { + this.setState({ typicalActual }); + this.props.onChange(typicalActual); + } + }; + + render() { + const options = [{ value: this.props.typicalActual, label: this.props.typicalActual }]; + return ( + + + + ); + } +} diff --git a/x-pack/plugins/ml/public/maps/update_anomaly_source_editor.tsx b/x-pack/plugins/ml/public/maps/update_anomaly_source_editor.tsx new file mode 100644 index 000000000000000..5505df363801273 --- /dev/null +++ b/x-pack/plugins/ml/public/maps/update_anomaly_source_editor.tsx @@ -0,0 +1,49 @@ +/* + * 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, { Fragment, Component } from 'react'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { TypicalActualSelector } from './typical_actual_selector'; + +interface Props { + onChange: (...args: Array<{ propName: string; value: unknown }>) => void; + typicalActual: 'typical' | 'actual'; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface State {} + +export class UpdateAnomalySourceEditor extends Component { + state: State = {}; + + render() { + return ( + + + +
+ +
+
+ + { + this.props.onChange({ + propName: 'typicalActual', + value: typicalActual, + }); + }} + typicalActual={this.props.typicalActual} + /> +
+ +
+ ); + } +} diff --git a/x-pack/plugins/ml/public/maps/util.ts b/x-pack/plugins/ml/public/maps/util.ts new file mode 100644 index 000000000000000..a6e1856ad0a2a1b --- /dev/null +++ b/x-pack/plugins/ml/public/maps/util.ts @@ -0,0 +1,126 @@ +/* + * 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 { FeatureCollection, Feature } from 'geojson'; +import { ESSearchResponse } from '../../../../../src/core/types/elasticsearch'; +import { MLAnomalyDoc } from '../../common/types/anomalies'; +import { ml } from '../application/services/ml_api_service'; + +const DUMMY_JOB_LIST = [ + { + jobId: 'Nigeria incidents', + results: [ + { + typical: [10, 12], + actual: [14, 16], + record_score: 0.7, + }, + { + typical: [9, 11], + actual: [14, 16], + record_score: 0.6, + }, + ], + }, + { + jobId: 'USA incidents', + results: [ + { + typical: [-80, 40], + actual: [-81, 42], + record_score: 0.7, + }, + { + typical: [-79, 35], + actual: [-78, 34], + record_score: 0.3, + }, + ], + }, +]; + +export async function getAnomalyJobList(): Promise> { + return DUMMY_JOB_LIST; +} + +export async function getResultsForJobId( + jobId: string, + locationType: 'typical' | 'actual' +): Promise { + // Query to look for the highest scoring anomaly. + const body: any = { + query: { + bool: { + must: [{ term: { job_id: jobId } }, { term: { result_type: 'record' } }], + }, + }, + size: 1000, + _source: { + excludes: [], + }, + }; + + let resp: ESSearchResponse | null = null; + let hits: Array<{ typical: number[]; actual: number[]; record_score: number }> = []; + + try { + resp = await ml.results.anomalySearch( + { + body, + }, + [jobId] + ); + } catch (error) { + // search may fail if the job doesn't already exist + // ignore this error as the outer function call will raise a toast + } + if (resp !== null && resp.hits.total.value > 0) { + hits = resp.hits.hits.map(({ _source }) => { + const geoResults = _source.geo_results; + const actualCoordStr = geoResults && geoResults.actual_point; + const typicalCoordStr = geoResults && geoResults.typical_point; + let typical; + let actual; + // Must reverse coordinates here. Map expects [lon, lat] - anomalies are stored as [lat, lon] for lat_lon jobs + if (actualCoordStr !== undefined) { + actual = actualCoordStr + .split(',') + .map((point: string) => Number(point)) + .reverse(); + } + if (typicalCoordStr !== undefined) { + typical = typicalCoordStr + .split(',') + .map((point: string) => Number(point)) + .reverse(); + } + return { + typical, + actual, + record_score: Math.floor(_source.record_score), + }; + }); + } + + const features: Feature[] = hits!.map((result) => { + return { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: locationType === 'typical' ? result.typical : result.actual, + }, + properties: { + record_score: result.record_score, + }, + }; + }); + + return { + type: 'FeatureCollection', + features, + }; +} diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 60767ecc4c43edd..b79c121bff33485 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -37,13 +37,14 @@ import { isFullLicense, isMlEnabled } from '../common/license'; import { setDependencyCache } from './application/util/dependency_cache'; import { registerFeature } from './register_feature'; import { MlLocatorDefinition, MlLocator } from './locator'; -import type { MapsStartApi } from '../../maps/public'; +import type { MapsStartApi, MapsSetupApi } from '../../maps/public'; import { TriggersAndActionsUIPublicPluginSetup, TriggersAndActionsUIPublicPluginStart, } from '../../triggers_actions_ui/public'; import type { DataVisualizerPluginStart } from '../../data_visualizer/public'; import type { PluginSetupContract as AlertingSetup } from '../../alerting/public'; +import { registerWithMaps } from './maps/register_with_maps'; import { registerManagementSection } from './application/management'; import type { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; @@ -61,6 +62,7 @@ export interface MlStartDependencies { export interface MlSetupDependencies { security?: SecurityPluginSetup; + maps?: MapsSetupApi; licensing: LicensingPluginSetup; management?: ManagementSetup; licenseManagement?: LicenseManagementUIPluginSetup; @@ -110,6 +112,7 @@ export class MlPlugin implements Plugin { licenseManagement: pluginsSetup.licenseManagement, home: pluginsSetup.home, embeddable: { ...pluginsSetup.embeddable, ...pluginsStart.embeddable }, + // @ts-ignore maps: pluginsStart.maps, uiActions: pluginsStart.uiActions, kibanaVersion, @@ -122,6 +125,10 @@ export class MlPlugin implements Plugin { }, }); + if (pluginsSetup.maps) { + registerWithMaps(pluginsSetup.maps); + } + if (pluginsSetup.share) { this.locator = pluginsSetup.share.url.locators.create(new MlLocatorDefinition()); } diff --git a/x-pack/plugins/ml/server/models/job_service/jobs.ts b/x-pack/plugins/ml/server/models/job_service/jobs.ts index 4922608487f66c4..df31e2ae6e8421d 100644 --- a/x-pack/plugins/ml/server/models/job_service/jobs.ts +++ b/x-pack/plugins/ml/server/models/job_service/jobs.ts @@ -11,6 +11,7 @@ import { IScopedClusterClient } from 'kibana/server'; import { getSingleMetricViewerJobErrorMessage, parseTimeIntervalForJob, + isJobWithGeoData, } from '../../../common/util/job_utils'; import { JOB_STATE, DATAFEED_STATE } from '../../../common/constants/states'; import { @@ -269,6 +270,22 @@ export function jobsProvider( return jobs; } + async function getJobsWithGeo(): Promise { + const { body } = await mlClient.getJobs(); + + const geoJobs: string[] = []; + + if (body.count && body.count > 0) { + body.jobs.forEach((job) => { + if (isJobWithGeoData(job)) { + geoJobs.push(job.job_id); + } + }); + } + + return geoJobs; + } + async function jobsWithTimerange() { const fullJobsList = await createFullJobsList(); const jobsMap: { [id: string]: string[] } = {}; @@ -659,5 +676,6 @@ export function jobsProvider( getAllJobAndGroupIds, getLookBackProgress, bulkCreate, + getJobsWithGeo, }; } diff --git a/x-pack/plugins/ml/server/routes/job_service.ts b/x-pack/plugins/ml/server/routes/job_service.ts index da115a224d19ecf..8bc96d4854b01ba 100644 --- a/x-pack/plugins/ml/server/routes/job_service.ts +++ b/x-pack/plugins/ml/server/routes/job_service.ts @@ -285,6 +285,44 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { }) ); + /** + * @apiGroup JobService + * + * @api {post} /api/ml/jobs/jobs_with_geo Jobs summary + * @apiName JobsSummary + * @apiDescription Returns a list of anomaly detection jobs with analysis config with fields supported by maps. + * + * @apiSchema (body) optionalJobIdsSchema + * + * @apiSuccess {Array} jobIds list of job ids. + */ + router.get( + { + path: '/api/ml/jobs/jobs_with_geo', + validate: false, + options: { + tags: ['access:ml:canGetJobs'], + }, + }, + routeGuard.fullLicenseAPIGuard(async ({ client, mlClient, response, context }) => { + try { + const { getJobsWithGeo } = jobServiceProvider( + client, + mlClient, + context.alerting?.getRulesClient() + ); + + const resp = await getJobsWithGeo(); + + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + /** * @apiGroup JobService *