Skip to content

Commit

Permalink
[ML] Anomaly Detection: add anomalies map to explorer for jobs with '…
Browse files Browse the repository at this point in the history
…lat_long' function (#88416)

* wip: create embedded map component for explorer

* add embeddedMap component to explorer

* use geo_results

* remove charts callout when map is shown

* add translation, round geo coordinates

* create GEO_MAP chart type and move embedded map to charts area

* remove embedded map that is no longer used

* fix type and fail silently if plugin not available

* fix multiple type of jobs charts view

* fix tooltip function and remove single viewer link for latlong

* ensure diff types of jobs show correct charts. fix jest test

* show errorCallout if maps not enabled and is lat_long job

* use shared MlEmbeddedMapComponent in explorer

* ensure latLong jobs not viewable in single metric viewer

* update jest test
  • Loading branch information
alvarezmelissa87 committed Jan 29, 2021
1 parent 7622da0 commit 2eb170b
Show file tree
Hide file tree
Showing 12 changed files with 364 additions and 57 deletions.
12 changes: 12 additions & 0 deletions x-pack/plugins/ml/common/util/job_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,18 @@ export function isTimeSeriesViewDetector(job: CombinedJob, detectorIndex: number
);
}

// Returns a flag to indicate whether the specified job is suitable for embedded map viewing.
export function isMappableJob(job: CombinedJob, detectorIndex: number): boolean {
let isMappable = false;
const { detectors } = job.analysis_config;
if (detectorIndex >= 0 && detectorIndex < detectors.length) {
const dtr = detectors[detectorIndex];
const functionName = dtr.function;
isMappable = functionName === ML_JOB_AGGREGATION.LAT_LONG;
}
return isMappable;
}

// Returns a flag to indicate whether the source data can be plotted in a time
// series chart for the specified detector.
export function isSourceDataChartableForDetector(job: CombinedJob, detectorIndex: number): boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import React, { useEffect, useRef, useState } from 'react';

import { htmlIdGenerator } from '@elastic/eui';
import { LayerDescriptor } from '../../../../../maps/common/descriptor_types';
import { INITIAL_LOCATION } from '../../../../../maps/common/constants';
import {
MapEmbeddable,
MapEmbeddableInput,
Expand Down Expand Up @@ -81,21 +82,13 @@ export function MlEmbeddedMapComponent({
viewMode: ViewMode.VIEW,
isLayerTOCOpen: false,
hideFilterActions: true,
// Zoom Lat/Lon values are set to make sure map is in center in the panel
// It will also omit Greenland/Antarctica etc. NOTE: Can be removed when initialLocation is set
mapCenter: {
lon: 11,
lat: 20,
zoom: 1,
},
// can use mapSettings to center map on anomalies
mapSettings: {
disableInteractive: false,
hideToolbarOverlay: false,
hideLayerControl: false,
hideViewControl: false,
// Doesn't currently work with GEO_JSON. Will uncomment when https://github.com/elastic/kibana/pull/88294 is in
// initialLocation: INITIAL_LOCATION.AUTO_FIT_TO_BOUNDS, // this will startup based on data-extent
initialLocation: INITIAL_LOCATION.AUTO_FIT_TO_BOUNDS, // this will startup based on data-extent
autoFitToDataBounds: true, // this will auto-fit when there are changes to the filter and/or query
},
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* 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, { useState, useEffect } from 'react';
import { Dictionary } from '../../../../common/types/common';
import { LayerDescriptor } from '../../../../../maps/common/descriptor_types';
import { getMLAnomaliesActualLayer, getMLAnomaliesTypicalLayer } from './map_config';
import { MlEmbeddedMapComponent } from '../../components/ml_embedded_map';
interface Props {
seriesConfig: Dictionary<any>;
}

export function EmbeddedMapComponentWrapper({ seriesConfig }: Props) {
const [layerList, setLayerList] = useState<LayerDescriptor[]>([]);

useEffect(() => {
if (seriesConfig.mapData && seriesConfig.mapData.length > 0) {
setLayerList([
getMLAnomaliesActualLayer(seriesConfig.mapData),
getMLAnomaliesTypicalLayer(seriesConfig.mapData),
]);
}
}, [seriesConfig]);

return (
<div data-test-subj="xpack.ml.explorer.embeddedMap" style={{ width: '100%', height: 300 }}>
<MlEmbeddedMapComponent layerList={layerList} />
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from '../../util/chart_utils';
import { ExplorerChartDistribution } from './explorer_chart_distribution';
import { ExplorerChartSingleMetric } from './explorer_chart_single_metric';
import { EmbeddedMapComponentWrapper } from './explorer_chart_embedded_map';
import { ExplorerChartLabel } from './components/explorer_chart_label';

import { CHART_TYPE } from '../explorer_constants';
Expand All @@ -30,6 +31,7 @@ import { FormattedMessage } from '@kbn/i18n/react';
import { MlTooltipComponent } from '../../components/chart_tooltip';
import { withKibana } from '../../../../../../../src/plugins/kibana_react/public';
import { ML_APP_URL_GENERATOR } from '../../../../common/constants/ml_url_generator';
import { ML_JOB_AGGREGATION } from '../../../../common/constants/aggregation_types';
import { addItemToRecentlyAccessed } from '../../util/recently_accessed';
import { ExplorerChartsErrorCallOuts } from './explorer_charts_error_callouts';

Expand All @@ -43,6 +45,9 @@ const textViewButton = i18n.translate(
defaultMessage: 'Open in Single Metric Viewer',
}
);
const mapsPluginMessage = i18n.translate('xpack.ml.explorer.charts.mapsPluginMissingMessage', {
defaultMessage: 'maps or embeddable start plugin not found',
});

// create a somewhat unique ID
// from charts metadata for React's key attribute
Expand All @@ -67,8 +72,8 @@ function ExplorerChartContainer({
useEffect(() => {
let isCancelled = false;
const generateLink = async () => {
const singleMetricViewerLink = await getExploreSeriesLink(mlUrlGenerator, series);
if (!isCancelled) {
if (!isCancelled && series.functionDescription !== ML_JOB_AGGREGATION.LAT_LONG) {
const singleMetricViewerLink = await getExploreSeriesLink(mlUrlGenerator, series);
setExplorerSeriesLink(singleMetricViewerLink);
}
};
Expand Down Expand Up @@ -150,6 +155,18 @@ function ExplorerChartContainer({
</EuiFlexItem>
</EuiFlexGroup>
{(() => {
if (chartType === CHART_TYPE.GEO_MAP) {
return (
<MlTooltipComponent>
{(tooltipService) => (
<EmbeddedMapComponentWrapper
seriesConfig={series}
tooltipService={tooltipService}
/>
)}
</MlTooltipComponent>
);
}
if (
chartType === CHART_TYPE.EVENT_DISTRIBUTION ||
chartType === CHART_TYPE.POPULATION_DISTRIBUTION
Expand All @@ -167,18 +184,20 @@ function ExplorerChartContainer({
</MlTooltipComponent>
);
}
return (
<MlTooltipComponent>
{(tooltipService) => (
<ExplorerChartSingleMetric
tooManyBuckets={tooManyBuckets}
seriesConfig={series}
severity={severity}
tooltipService={tooltipService}
/>
)}
</MlTooltipComponent>
);
if (chartType === CHART_TYPE.SINGLE_METRIC) {
return (
<MlTooltipComponent>
{(tooltipService) => (
<ExplorerChartSingleMetric
tooManyBuckets={tooManyBuckets}
seriesConfig={series}
severity={severity}
tooltipService={tooltipService}
/>
)}
</MlTooltipComponent>
);
}
})()}
</React.Fragment>
);
Expand All @@ -199,22 +218,45 @@ export const ExplorerChartsContainerUI = ({
share: {
urlGenerators: { getUrlGenerator },
},
embeddable: embeddablePlugin,
maps: mapsPlugin,
},
} = kibana;

let seriesToPlotFiltered;

if (!embeddablePlugin || !mapsPlugin) {
seriesToPlotFiltered = [];
// Show missing plugin callout
seriesToPlot.forEach((series) => {
if (series.functionDescription === 'lat_long') {
if (errorMessages[mapsPluginMessage] === undefined) {
errorMessages[mapsPluginMessage] = new Set([series.jobId]);
} else {
errorMessages[mapsPluginMessage].add(series.jobId);
}
} else {
seriesToPlotFiltered.push(series);
}
});
}

const seriesToUse = seriesToPlotFiltered !== undefined ? seriesToPlotFiltered : seriesToPlot;

const mlUrlGenerator = useMemo(() => getUrlGenerator(ML_APP_URL_GENERATOR), [getUrlGenerator]);

// <EuiFlexGrid> doesn't allow a setting of `columns={1}` when chartsPerRow would be 1.
// If that's the case we trick it doing that with the following settings:
const chartsWidth = chartsPerRow === 1 ? 'calc(100% - 20px)' : 'auto';
const chartsColumns = chartsPerRow === 1 ? 0 : chartsPerRow;

const wrapLabel = seriesToPlot.some((series) => isLabelLengthAboveThreshold(series));
const wrapLabel = seriesToUse.some((series) => isLabelLengthAboveThreshold(series));
return (
<>
<ExplorerChartsErrorCallOuts errorMessagesByType={errorMessages} />
<EuiFlexGrid columns={chartsColumns}>
{seriesToPlot.length > 0 &&
seriesToPlot.map((series) => (
{seriesToUse.length > 0 &&
seriesToUse.map((series) => (
<EuiFlexItem
key={getChartId(series)}
className="ml-explorer-chart-container"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ import {
isSourceDataChartableForDetector,
isModelPlotChartableForDetector,
isModelPlotEnabled,
isMappableJob,
} from '../../../../common/util/job_utils';
import { mlResultsService } from '../../services/results_service';
import { mlJobService } from '../../services/job_service';
import { explorerService } from '../explorer_dashboard_service';

import { CHART_TYPE } from '../explorer_constants';
import { ML_JOB_AGGREGATION } from '../../../../common/constants/aggregation_types';
import { i18n } from '@kbn/i18n';
import { SWIM_LANE_LABEL_WIDTH } from '../swimlane_container';

Expand Down Expand Up @@ -77,7 +79,50 @@ export const anomalyDataChange = function (
// For now just take first 6 (or 8 if 4 charts per row).
const maxSeriesToPlot = Math.max(chartsPerRow * 2, 6);
const recordsToPlot = allSeriesRecords.slice(0, maxSeriesToPlot);
const hasGeoData = recordsToPlot.find(
(record) =>
(record.function_description || recordsToPlot.function) === ML_JOB_AGGREGATION.LAT_LONG
);

const seriesConfigs = recordsToPlot.map(buildConfig);
const seriesConfigsNoGeoData = [];

// initialize the charts with loading indicators
data.seriesToPlot = seriesConfigs.map((config) => ({
...config,
loading: true,
chartData: null,
}));

const mapData = [];

if (hasGeoData !== undefined) {
for (let i = 0; i < seriesConfigs.length; i++) {
const config = seriesConfigs[i];
let records;
if (config.detectorLabel.includes(ML_JOB_AGGREGATION.LAT_LONG)) {
if (config.entityFields.length) {
records = [
recordsToPlot.find((record) => {
const entityFieldName = config.entityFields[0].fieldName;
const entityFieldValue = config.entityFields[0].fieldValue;
return (record[entityFieldName] && record[entityFieldName][0]) === entityFieldValue;
}),
];
} else {
records = recordsToPlot;
}

mapData.push({
...config,
loading: false,
mapData: records,
});
} else {
seriesConfigsNoGeoData.push(config);
}
}
}

// Calculate the time range of the charts, which is a function of the chart width and max job bucket span.
data.tooManyBuckets = false;
Expand All @@ -92,13 +137,6 @@ export const anomalyDataChange = function (
);
data.tooManyBuckets = tooManyBuckets;

// initialize the charts with loading indicators
data.seriesToPlot = seriesConfigs.map((config) => ({
...config,
loading: true,
chartData: null,
}));

data.errorMessages = errorMessages;

explorerService.setCharts({ ...data });
Expand Down Expand Up @@ -269,22 +307,27 @@ export const anomalyDataChange = function (
// only after that trigger data processing and page render.
// TODO - if query returns no results e.g. source data has been deleted,
// display a message saying 'No data between earliest/latest'.
const seriesPromises = seriesConfigs.map((seriesConfig) =>
Promise.all([
getMetricData(seriesConfig, chartRange),
getRecordsForCriteria(seriesConfig, chartRange),
getScheduledEvents(seriesConfig, chartRange),
getEventDistribution(seriesConfig, chartRange),
])
);
const seriesPromises = [];
// Use seriesConfigs list without geo data config so indices match up after seriesPromises are resolved and we map through the responses
const seriesCongifsForPromises = hasGeoData ? seriesConfigsNoGeoData : seriesConfigs;
seriesCongifsForPromises.forEach((seriesConfig) => {
seriesPromises.push(
Promise.all([
getMetricData(seriesConfig, chartRange),
getRecordsForCriteria(seriesConfig, chartRange),
getScheduledEvents(seriesConfig, chartRange),
getEventDistribution(seriesConfig, chartRange),
])
);
});

function processChartData(response, seriesIndex) {
const metricData = response[0].results;
const records = response[1].records;
const jobId = seriesConfigs[seriesIndex].jobId;
const jobId = seriesCongifsForPromises[seriesIndex].jobId;
const scheduledEvents = response[2].events[jobId];
const eventDistribution = response[3];
const chartType = getChartType(seriesConfigs[seriesIndex]);
const chartType = getChartType(seriesCongifsForPromises[seriesIndex]);

// Sort records in ascending time order matching up with chart data
records.sort((recordA, recordB) => {
Expand Down Expand Up @@ -409,16 +452,25 @@ export const anomalyDataChange = function (
);
const overallChartLimits = chartLimits(allDataPoints);

data.seriesToPlot = response.map((d, i) => ({
...seriesConfigs[i],
loading: false,
chartData: processedData[i],
plotEarliest: chartRange.min,
plotLatest: chartRange.max,
selectedEarliest: selectedEarliestMs,
selectedLatest: selectedLatestMs,
chartLimits: USE_OVERALL_CHART_LIMITS ? overallChartLimits : chartLimits(processedData[i]),
}));
data.seriesToPlot = response.map((d, i) => {
return {
...seriesCongifsForPromises[i],
loading: false,
chartData: processedData[i],
plotEarliest: chartRange.min,
plotLatest: chartRange.max,
selectedEarliest: selectedEarliestMs,
selectedLatest: selectedLatestMs,
chartLimits: USE_OVERALL_CHART_LIMITS
? overallChartLimits
: chartLimits(processedData[i]),
};
});

if (mapData.length) {
// push map data in if it's available
data.seriesToPlot.push(...mapData);
}
explorerService.setCharts({ ...data });
})
.catch((error) => {
Expand Down Expand Up @@ -447,7 +499,10 @@ function processRecordsForDisplay(anomalyRecords) {
return;
}

let isChartable = isSourceDataChartableForDetector(job, record.detector_index);
let isChartable =
isSourceDataChartableForDetector(job, record.detector_index) ||
isMappableJob(job, record.detector_index);

if (isChartable === false) {
if (isModelPlotChartableForDetector(job, record.detector_index)) {
// Check if model plot is enabled for this job.
Expand Down
Loading

0 comments on commit 2eb170b

Please sign in to comment.