Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ML] Anomaly Detection: add anomalies map to explorer for jobs with 'lat_long' function #88416

Merged
merged 15 commits into from
Jan 29, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably one for a follow up, but would be nice if the SINGLE_METRIC and DISTRIBUTION charts expanded to the height of the map in this situation (preferable than shrinking the map height to match those chart heights I think).

image

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