From af0cfdfdeae8fd6109e216d8e7405a89a8f48223 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Wed, 12 Jan 2022 11:06:13 -0700 Subject: [PATCH] wip: create anomalies layer in maps --- x-pack/plugins/ml/common/util/job_utils.ts | 13 + .../services/ml_api_service/jobs.ts | 7 + .../ml/public/maps/anomaly_job_selector.tsx | 86 +++++ .../ml/public/maps/anomaly_layer_wizard.tsx | 29 ++ .../maps/anomaly_layer_wizard_factory.tsx | 102 ++++++ .../plugins/ml/public/maps/anomaly_source.tsx | 346 ++++++++++++++++++ .../ml/public/maps/anomaly_source_factory.ts | 45 +++ .../maps/create_anomaly_source_editor.tsx | 87 +++++ .../plugins/ml/public/maps/layer_selector.tsx | 63 ++++ .../ml/public/maps/record_score_field.ts | 150 ++++++++ .../ml/public/maps/register_map_extension.ts | 31 ++ .../maps/update_anomaly_source_editor.tsx | 49 +++ x-pack/plugins/ml/public/maps/util.ts | 89 +++++ x-pack/plugins/ml/public/plugin.ts | 20 +- .../ml/public/register_helper/index.ts | 1 + .../ml/server/models/job_service/jobs.ts | 18 + .../plugins/ml/server/routes/job_service.ts | 38 ++ 17 files changed, 1171 insertions(+), 3 deletions(-) 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_layer_wizard_factory.tsx create mode 100644 x-pack/plugins/ml/public/maps/anomaly_source.tsx create mode 100644 x-pack/plugins/ml/public/maps/anomaly_source_factory.ts create mode 100644 x-pack/plugins/ml/public/maps/create_anomaly_source_editor.tsx create mode 100644 x-pack/plugins/ml/public/maps/layer_selector.tsx create mode 100644 x-pack/plugins/ml/public/maps/record_score_field.ts create mode 100644 x-pack/plugins/ml/public/maps/register_map_extension.ts 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/ml/common/util/job_utils.ts b/x-pack/plugins/ml/common/util/job_utils.ts index e66d8de5bd15e7f..0eae428a51bf0fa 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..b5d3eb4f5cecd04 --- /dev/null +++ b/x-pack/plugins/ml/public/maps/anomaly_job_selector.tsx @@ -0,0 +1,86 @@ +/* + * 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'; + +interface Props { + onJobChange: (jobId: string) => void; + mlJobsService: any; // todo: update types +} + +interface State { + jobId?: string; + jobIdList?: Array>; +} + +export class AnomalyJobSelector extends Component { + private _isMounted: boolean = false; + + state: State = {}; + + private async _loadJobs() { + const jobIdList = await this.props.mlJobsService.jobsWithGeo(); + // TODO update types - remove any + const options = jobIdList.map((jobId: any) => { + 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; + } + + 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..5689eac983bd6d2 --- /dev/null +++ b/x-pack/plugins/ml/public/maps/anomaly_layer_wizard.tsx @@ -0,0 +1,29 @@ +/* + * 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 { LAYER_WIZARD_CATEGORY } from '../../../maps/common'; +import type { LayerWizard } from '../../../maps/public'; + +export const anomalyLayerWizard: Partial = { + 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: () => { + // return false by default + return false; + }, + title: i18n.translate('xpack.ml.maps.anomalyLayerTitle', { + defaultMessage: 'ML Anomalies', + }), +}; diff --git a/x-pack/plugins/ml/public/maps/anomaly_layer_wizard_factory.tsx b/x-pack/plugins/ml/public/maps/anomaly_layer_wizard_factory.tsx new file mode 100644 index 000000000000000..2ab9adc6b22f6ad --- /dev/null +++ b/x-pack/plugins/ml/public/maps/anomaly_layer_wizard_factory.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import uuid from 'uuid'; +import type { StartServicesAccessor } from 'kibana/public'; +import type { LayerWizard, RenderWizardArguments } from '../../../maps/public'; +import { COLOR_MAP_TYPE, FIELD_ORIGIN, LAYER_TYPE, STYLE_TYPE } from '../../../maps/common'; +import { CreateAnomalySourceEditor } from './create_anomaly_source_editor'; +import { + VectorLayerDescriptor, + VectorStylePropertiesDescriptor, +} from '../../../maps/common/descriptor_types'; +import { AnomalySource, AnomalySourceDescriptor } from './anomaly_source'; + +import { HttpService } from '../application/services/http_service'; +import type { MlPluginStart, MlStartDependencies } from '../plugin'; +import type { MlDependencies } from '../application/app'; + +export const ML_ANOMALY = 'ML_ANOMALIES'; + +export class AnomalyLayerWizardFactory { + public readonly type = ML_ANOMALY; + + constructor( + private getStartServices: StartServicesAccessor, + private canGetJobs: any + ) { + this.canGetJobs = canGetJobs; + } + + private async getServices(): Promise { + const [coreStart, pluginsStart] = await this.getStartServices(); + const { jobsApiProvider } = await import('../application/services/ml_api_service/jobs'); + + const httpService = new HttpService(coreStart.http); + const mlJobsService = jobsApiProvider(httpService); + + return [coreStart, pluginsStart as MlDependencies, { mlJobsService }]; + } + + public async create(): Promise { + const services = await this.getServices(); + const mlJobsService = services[2].mlJobsService; + const { anomalyLayerWizard } = await import('./anomaly_layer_wizard'); + + anomalyLayerWizard.getIsDisabled = () => !this.canGetJobs; + + anomalyLayerWizard.renderWizard = ({ previewLayers }: RenderWizardArguments) => { + const onSourceConfigChange = (sourceConfig: Partial | null) => { + if (!sourceConfig) { + previewLayers([]); + return; + } + + const anomalyLayerDescriptor: VectorLayerDescriptor = { + id: uuid(), + type: LAYER_TYPE.GEOJSON_VECTOR, + sourceDescriptor: AnomalySource.createDescriptor({ + jobId: sourceConfig.jobId, + typicalActual: sourceConfig.typicalActual, + }), + style: { + type: 'VECTOR', + properties: { + fillColor: { + type: STYLE_TYPE.DYNAMIC, + options: { + color: 'Blue to Red', + colorCategory: 'palette_0', + fieldMetaOptions: { isEnabled: true, sigma: 3 }, + type: COLOR_MAP_TYPE.ORDINAL, + field: { + name: 'record_score', + origin: FIELD_ORIGIN.SOURCE, + }, + useCustomColorRamp: false, + }, + }, + } as unknown as VectorStylePropertiesDescriptor, + isTimeAware: false, + }, + }; + + previewLayers([anomalyLayerDescriptor]); + }; + + return ( + + ); + }; + + return anomalyLayerWizard as LayerWizard; + } +} 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..7907c43fea561a3 --- /dev/null +++ b/x-pack/plugins/ml/public/maps/anomaly_source.tsx @@ -0,0 +1,346 @@ +/* + * 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 { IVectorSource, SourceStatus } from '../../../maps/public'; +import { ML_ANOMALY } from './anomaly_source_factory'; +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 services: any; // TODO fix these types + static canGetJobs: any; + + static createDescriptor(descriptor: Partial) { + // TODO: 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); + } + // TODO: implement Time filter functionality and query awareness + 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( + AnomalySource.services[2].mlResultsService, + this._descriptor.jobId, + this._descriptor.typicalActual + ); + + return { + data: results, + meta: { + // Set this to true if data is incomplete (e.g. capping number of results to first 10k) + areResultsTrimmed: false, + }, + }; + } + + 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; + } + + getSourceStatus() { + return { tooltipContent: null, areResultsTrimmed: false }; + } + + getType(): string { + return this._descriptor.type; + } + + isMvt() { + return true; + } + + 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): SourceStatus { + 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/anomaly_source_factory.ts b/x-pack/plugins/ml/public/maps/anomaly_source_factory.ts new file mode 100644 index 000000000000000..bf1ff15ce193665 --- /dev/null +++ b/x-pack/plugins/ml/public/maps/anomaly_source_factory.ts @@ -0,0 +1,45 @@ +/* + * 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 type { StartServicesAccessor } from 'kibana/public'; +import { HttpService } from '../application/services/http_service'; +import type { MlPluginStart, MlStartDependencies } from '../plugin'; +import type { MlDependencies } from '../application/app'; + +export const ML_ANOMALY = 'ML_ANOMALIES'; + +export class AnomalySourceFactory { + public readonly type = ML_ANOMALY; + + constructor( + private getStartServices: StartServicesAccessor, + private canGetJobs: any + ) { + this.canGetJobs = canGetJobs; + } + + private async getServices(): Promise { + const [coreStart, pluginsStart] = await this.getStartServices(); + const { mlApiServicesProvider } = await import('../application/services/ml_api_service'); + // const { resultsApiProvider } = await import('../application/services/ml_api_service/results'); + + const httpService = new HttpService(coreStart.http); + // const mlApiService = mlApiServicesProvider(httpService); + const mlResultsService = mlApiServicesProvider(httpService).results; + // const mlResultsService = resultsApiProvider(httpService); + + return [coreStart, pluginsStart as MlDependencies, { mlResultsService }]; + } + + public async create(): Promise { + const services = await this.getServices(); + const { AnomalySource } = await import('./anomaly_source'); + AnomalySource.services = services; + AnomalySource.canGetJobs = this.canGetJobs; + return AnomalySource; + } +} 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..5fc60585e064119 --- /dev/null +++ b/x-pack/plugins/ml/public/maps/create_anomaly_source_editor.tsx @@ -0,0 +1,87 @@ +/* + * 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 { LayerSelector } from './layer_selector'; + +interface Props { + onSourceConfigChange: (sourceConfig: Partial | null) => void; + mlJobsService: any; // todo: update types +} + +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/layer_selector.tsx b/x-pack/plugins/ml/public/maps/layer_selector.tsx new file mode 100644 index 000000000000000..0697cc88f248ccb --- /dev/null +++ b/x-pack/plugins/ml/public/maps/layer_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 LayerSelector 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/record_score_field.ts b/x-pack/plugins/ml/public/maps/record_score_field.ts new file mode 100644 index 000000000000000..d10762659744703 --- /dev/null +++ b/x-pack/plugins/ml/public/maps/record_score_field.ts @@ -0,0 +1,150 @@ +/* + * 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 { TileMetaFeature } from '../../../maps/common/descriptor_types'; +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'; + } + + getMbFieldName(): string { + return this.getName(); + } + + getOrigin(): FIELD_ORIGIN { + return FIELD_ORIGIN.SOURCE; + } + + getRootName(): string { + return this.getName(); + } + + getSource(): IVectorSource { + return this._source; + } + + isCount() { + return false; + } + + isEqual(field: IField): boolean { + return this.getName() === field.getName(); + } + + isValid(): boolean { + return true; + } + + pluckRangeFromTileMetaFeature(metaFeature: TileMetaFeature) { + return null; + } + + // NA + canReadFromGeoJson(): boolean { + return false; + } + + // NA + canValueBeFormatted(): boolean { + return false; + } + + // NA + supportsAutoDomain(): boolean { + return false; + } + + // NA + supportsFieldMeta(): boolean { + return false; + } + + supportsFieldMetaFromLocalData(): boolean { + return false; + } + + supportsFieldMetaFromEs(): 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_map_extension.ts b/x-pack/plugins/ml/public/maps/register_map_extension.ts new file mode 100644 index 000000000000000..3617c04da7d450b --- /dev/null +++ b/x-pack/plugins/ml/public/maps/register_map_extension.ts @@ -0,0 +1,31 @@ +/* + * 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 type { MlCoreSetup } from '../plugin'; +import { AnomalySourceFactory } from './anomaly_source_factory'; +import { AnomalyLayerWizardFactory } from './anomaly_layer_wizard_factory'; + +export async function registerMapExtension( + mapsSetupApi: MapsSetupApi, + core: MlCoreSetup, + canGetJobs: any // update this type +) { + const anomalySourceFactory = new AnomalySourceFactory(core.getStartServices, canGetJobs); + const anomalyLayerWizardFactory = new AnomalyLayerWizardFactory( + core.getStartServices, + canGetJobs + ); + const anomalylayerWizard = await anomalyLayerWizardFactory.create(); + + mapsSetupApi.registerSource({ + type: anomalySourceFactory.type, + ConstructorFunction: await anomalySourceFactory.create(), + }); + + mapsSetupApi.registerLayerWizard(anomalylayerWizard); +} 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..38993a899ded536 --- /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 { LayerSelector } from './layer_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..0630b35d72edbb5 --- /dev/null +++ b/x-pack/plugins/ml/public/maps/util.ts @@ -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 + * 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'; + +export async function getResultsForJobId( + mlResultsService: any, + 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 mlResultsService.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 59419303d7a6f51..2b84704e46ff837 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -36,7 +36,7 @@ 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, @@ -64,6 +64,7 @@ export interface MlStartDependencies { export interface MlSetupDependencies { security?: SecurityPluginSetup; + maps?: MapsSetupApi; licensing: LicensingPluginSetup; management?: ManagementSetup; licenseManagement?: LicenseManagementUIPluginSetup; @@ -113,6 +114,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, @@ -155,11 +157,23 @@ export class MlPlugin implements Plugin { // register various ML plugin features which require a full license // note including registerFeature in register_helper would cause the page bundle size to increase significantly - const { registerEmbeddables, registerMlUiActions, registerSearchLinks, registerMlAlerts } = - await import('./register_helper'); + const { + registerEmbeddables, + registerMlUiActions, + registerSearchLinks, + registerMlAlerts, + registerMapExtension, + } = await import('./register_helper'); const mlEnabled = isMlEnabled(license); const fullLicense = isFullLicense(license); + + if (pluginsSetup.maps) { + // Pass capabilites.ml.canGetJobs as minimum permission to show anomalies card in maps layers + const canGetJobs = capabilities.ml?.canGetJobs || false; + await registerMapExtension(pluginsSetup.maps, core, canGetJobs); + } + if (mlEnabled) { registerSearchLinks(this.appUpdater$, fullLicense); diff --git a/x-pack/plugins/ml/public/register_helper/index.ts b/x-pack/plugins/ml/public/register_helper/index.ts index 278f32f683053da..47d9bad31997a25 100644 --- a/x-pack/plugins/ml/public/register_helper/index.ts +++ b/x-pack/plugins/ml/public/register_helper/index.ts @@ -10,3 +10,4 @@ export { registerManagementSection } from '../application/management'; export { registerMlUiActions } from '../ui_actions'; export { registerSearchLinks } from './register_search_links'; export { registerMlAlerts } from '../alerting'; +export { registerMapExtension } from '../maps/register_map_extension'; 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 ffc07e06db0f93d..e1318d7d43c5cee 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 { @@ -271,6 +272,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[] } = {}; @@ -661,5 +678,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 96ca56baa38da79..0b13fdee2521c34 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 *