diff --git a/x-pack/plugins/ml/public/explorer/explorer_controller.js b/x-pack/plugins/ml/public/explorer/explorer_controller.js index 2476628187e14..da03387642e7d 100644 --- a/x-pack/plugins/ml/public/explorer/explorer_controller.js +++ b/x-pack/plugins/ml/public/explorer/explorer_controller.js @@ -953,7 +953,7 @@ module.controller('MlExplorerController', function ( } } - async function loadAnnotationsTableData() { + function loadAnnotationsTableData() { $scope.annotationsData = []; const cellData = $scope.cellData; @@ -962,30 +962,35 @@ module.controller('MlExplorerController', function ( const timeRange = getSelectionTimeRange(cellData); if (mlAnnotationsEnabled) { - const resp = await ml.annotations.getAnnotations({ + ml.annotations.getAnnotations({ jobIds, earliestMs: timeRange.earliestMs, latestMs: timeRange.latestMs, maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE - }); + }).then((resp) => { + $scope.$evalAsync(() => { + const annotationsData = []; + jobIds.forEach((jobId) => { + const jobAnnotations = resp.annotations[jobId]; + if (jobAnnotations !== undefined) { + annotationsData.push(...jobAnnotations); + } + }); - $scope.$evalAsync(() => { - const annotationsData = []; - jobIds.forEach((jobId) => { - const jobAnnotations = resp.annotations[jobId]; - if (jobAnnotations !== undefined) { - annotationsData.push(...jobAnnotations); - } + $scope.annotationsData = annotationsData + .sort((a, b) => { + return a.timestamp - b.timestamp; + }) + .map((d, i) => { + d.key = String.fromCharCode(65 + i); + return d; + }); }); - - $scope.annotationsData = annotationsData - .sort((a, b) => { - return a.timestamp - b.timestamp; - }) - .map((d, i) => { - d.key = String.fromCharCode(65 + i); - return d; - }); + }).catch((resp) => { + console.log('Error loading list of annotations for jobs list:', resp); + // Silently fail and just return an empty array for annotations to not break the UI. + $scope.annotationsData = []; + $scope.$applyAsync(); }); } } diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js b/x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js index 94e66606f0af1..1bddcf50d1572 100644 --- a/x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js +++ b/x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js @@ -19,8 +19,6 @@ import _ from 'lodash'; import d3 from 'd3'; import moment from 'moment'; -import chrome from 'ui/chrome'; - import { getSeverityWithLow, getMultiBucketImpactLabel, @@ -56,8 +54,6 @@ import { unhighlightFocusChartAnnotation } from './timeseries_chart_annotations'; -const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false); - const focusZoomPanelHeight = 25; const focusChartHeight = 310; const focusHeight = focusZoomPanelHeight + focusChartHeight; @@ -91,6 +87,7 @@ function getSvgHeight() { export class TimeseriesChart extends React.Component { static propTypes = { + annotationsEnabled: PropTypes.bool, indexAnnotation: PropTypes.func, autoZoomDuration: PropTypes.number, contextAggregationInterval: PropTypes.object, @@ -215,6 +212,7 @@ export class TimeseriesChart extends React.Component { componentDidMount() { const { + annotationsEnabled, svgWidth } = this.props; @@ -247,7 +245,7 @@ export class TimeseriesChart extends React.Component { this.fieldFormat = undefined; // Annotations Brush - if (mlAnnotationsEnabled) { + if (annotationsEnabled) { this.annotateBrush = getAnnotationBrush.call(this); } @@ -297,6 +295,7 @@ export class TimeseriesChart extends React.Component { renderChart() { const { + annotationsEnabled, contextChartData, contextForecastData, detectorIndex, @@ -398,7 +397,7 @@ export class TimeseriesChart extends React.Component { .attr('transform', 'translate(' + margin.left + ',' + (focusHeight + margin.top + chartSpacing) + ')'); // Mask to hide annotations overflow - if (mlAnnotationsEnabled) { + if (annotationsEnabled) { const annotationsMask = svg .append('defs') .append('mask') @@ -474,6 +473,7 @@ export class TimeseriesChart extends React.Component { // as we want to re-render the paths and points when the zoom area changes. const { + annotationsEnabled, contextForecastData } = this.props; @@ -490,7 +490,7 @@ export class TimeseriesChart extends React.Component { this.createZoomInfoElements(zoomGroup, fcsWidth); // Create the elements for annotations - if (mlAnnotationsEnabled) { + if (annotationsEnabled) { const annotateBrush = this.annotateBrush.bind(this); fcsGroup.append('g') @@ -572,6 +572,7 @@ export class TimeseriesChart extends React.Component { renderFocusChart() { const { + annotationsEnabled, focusAggregationInterval, focusAnnotationData, focusChartData, @@ -659,7 +660,7 @@ export class TimeseriesChart extends React.Component { // if annotations are present, we extend yMax to avoid overlap // between annotation labels, chart lines and anomalies. - if (mlAnnotationsEnabled && focusAnnotationData && focusAnnotationData.length > 0) { + if (annotationsEnabled && focusAnnotationData && focusAnnotationData.length > 0) { const levels = getAnnotationLevels(focusAnnotationData); const maxLevel = d3.max(Object.keys(levels).map(key => levels[key])); // TODO needs revisting to be a more robust normalization @@ -695,7 +696,7 @@ export class TimeseriesChart extends React.Component { .classed('hidden', !showModelBounds); } - if (mlAnnotationsEnabled) { + if (annotationsEnabled) { renderAnnotations( focusChart, focusAnnotationData, @@ -709,7 +710,7 @@ export class TimeseriesChart extends React.Component { // disable brushing (creation of annotations) when annotations aren't shown focusChart.select('.mlAnnotationBrush') - .style('pointer-events', (showAnnotations) ? 'all' : 'none'); + .style('display', (showAnnotations) ? null : 'none'); } focusChart.select('.values-line') @@ -1285,6 +1286,7 @@ export class TimeseriesChart extends React.Component { showFocusChartTooltip(marker, circle) { const { + annotationsEnabled, modelPlotEnabled } = this.props; @@ -1350,7 +1352,7 @@ export class TimeseriesChart extends React.Component { contents += `

Scheduled events:
${marker.scheduledEvents.map(mlEscape).join('
')}`; } - if (mlAnnotationsEnabled && _.has(marker, 'annotation')) { + if (annotationsEnabled && _.has(marker, 'annotation')) { contents = mlEscape(marker.annotation); contents += `
${moment(marker.timestamp).format('MMMM Do YYYY, HH:mm')}`; diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart_directive.js b/x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart_directive.js index 3e36b799751f0..cb31f1dec82fd 100644 --- a/x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart_directive.js +++ b/x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart_directive.js @@ -27,9 +27,6 @@ const module = uiModules.get('apps/ml'); import { ml } from 'plugins/ml/services/ml_api_service'; -import chrome from 'ui/chrome'; -const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false); - module.directive('mlTimeseriesChart', function ($timeout) { function link(scope, element) { @@ -45,6 +42,7 @@ module.directive('mlTimeseriesChart', function ($timeout) { svgWidth = Math.max(angular.element('.results-container').width(), 0); const props = { + annotationsEnabled: scope.annotationsEnabled, indexAnnotation: ml.annotations.indexAnnotation, autoZoomDuration: scope.autoZoomDuration, contextAggregationInterval: scope.contextAggregationInterval, @@ -93,7 +91,8 @@ module.directive('mlTimeseriesChart', function ($timeout) { scope.$watchCollection('focusForecastData', renderFocusChart); scope.$watchCollection('focusChartData', renderFocusChart); scope.$watchGroup(['showModelBounds', 'showForecast'], renderFocusChart); - if (mlAnnotationsEnabled) { + scope.$watch('annotationsEnabled', renderReactComponent); + if (scope.annotationsEnabled) { scope.$watchCollection('focusAnnotationData', renderFocusChart); scope.$watch('showAnnotations', renderFocusChart); } @@ -118,6 +117,7 @@ module.directive('mlTimeseriesChart', function ($timeout) { return { scope: { + annotationsEnabled: '=', selectedJob: '=', detectorIndex: '=', modelPlotEnabled: '=', diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.html b/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.html index cb2b92fc7ece9..85c7e40b55b30 100644 --- a/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.html +++ b/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.html @@ -108,6 +108,7 @@
{ - refreshFocusData.focusAnnotationData = resp.annotations[$scope.selectedJob.job_id] - .sort((a, b) => { - return a.timestamp - b.timestamp; - }) - .map((d, i) => { - d.key = String.fromCharCode(65 + i); - return d; - }); + refreshFocusData.focusAnnotationData = []; + + if (Array.isArray(resp.annotations[$scope.selectedJob.job_id])) { + refreshFocusData.focusAnnotationData = resp.annotations[$scope.selectedJob.job_id] + .sort((a, b) => { + return a.timestamp - b.timestamp; + }) + .map((d, i) => { + d.key = String.fromCharCode(65 + i); + return d; + }); + } finish(); }).catch(() => { - // silent fail + // silently fail and disable annotations feature if loading annotations fails. refreshFocusData.focusAnnotationData = []; + mlAnnotationsEnabled = false; finish(); }); } else { diff --git a/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts b/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts index 0b7f37137d554..b9874198524a2 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts +++ b/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts @@ -16,6 +16,8 @@ import { annotationServiceProvider } from './index'; const acknowledgedResponseMock = { acknowledged: true }; +const jobIdMock = 'jobIdMock'; + describe('annotation_service', () => { let callWithRequestSpy: jest.Mock; @@ -56,8 +58,6 @@ describe('annotation_service', () => { it('should get annotations for specific job', async done => { const { getAnnotations } = annotationServiceProvider(callWithRequestSpy); - const jobIdMock = 'jobIdMock'; - const indexAnnotationArgsMock: IndexAnnotationArgs = { jobIds: [jobIdMock], earliestMs: 1454804100000, @@ -74,13 +74,37 @@ describe('annotation_service', () => { expect(isAnnotations(response.annotations[jobIdMock])).toBeTruthy(); done(); }); + + it('should throw and catch an error', async () => { + const mockEsError = { + statusCode: 404, + error: 'Not Found', + message: 'mock error message', + }; + + const callWithRequestSpyError = jest.fn(() => { + return Promise.resolve(mockEsError); + }); + + const { getAnnotations } = annotationServiceProvider(callWithRequestSpyError); + + const indexAnnotationArgsMock: IndexAnnotationArgs = { + jobIds: [jobIdMock], + earliestMs: 1454804100000, + latestMs: 1455233399999, + maxAnnotations: 500, + }; + + await expect(getAnnotations(indexAnnotationArgsMock)).rejects.toEqual( + Error(`Annotations couldn't be retrieved from Elasticsearch.`) + ); + }); }); describe('indexAnnotation()', () => { it('should index annotation', async done => { const { indexAnnotation } = annotationServiceProvider(callWithRequestSpy); - const jobIdMock = 'jobIdMock'; const annotationMock: Annotation = { annotation: 'Annotation text', job_id: jobIdMock, @@ -108,7 +132,6 @@ describe('annotation_service', () => { it('should remove ._id and .key before updating annotation', async done => { const { indexAnnotation } = annotationServiceProvider(callWithRequestSpy); - const jobIdMock = 'jobIdMock'; const annotationMock: Annotation = { _id: 'mockId', annotation: 'Updated annotation text', @@ -140,8 +163,6 @@ describe('annotation_service', () => { it('should update annotation text and the username for modified_username', async done => { const { getAnnotations, indexAnnotation } = annotationServiceProvider(callWithRequestSpy); - const jobIdMock = 'jobIdMock'; - const indexAnnotationArgsMock: IndexAnnotationArgs = { jobIds: [jobIdMock], earliestMs: 1454804100000, diff --git a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts index 237f6426dd8d5..2473c4c5c2ff1 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts +++ b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts @@ -72,6 +72,7 @@ export type callWithRequestType = ( export function annotationProvider(callWithRequest: callWithRequestType) { async function indexAnnotation(annotation: Annotation, username: string) { if (isAnnotation(annotation) === false) { + // No need to translate, this will not be exposed in the UI. return Promise.reject(new Error('invalid annotation format')); } @@ -214,27 +215,37 @@ export function annotationProvider(callWithRequest: callWithRequestType) { }, }; - const resp = await callWithRequest('search', params); + try { + const resp = await callWithRequest('search', params); - const docs: Annotations = _.get(resp, ['hits', 'hits'], []).map((d: EsResult) => { - // get the original source document and the document id, we need it - // to identify the annotation when editing/deleting it. - return { ...d._source, _id: d._id } as Annotation; - }); + if (resp.error !== undefined && resp.message !== undefined) { + // No need to translate, this will not be exposed in the UI. + throw new Error(`Annotations couldn't be retrieved from Elasticsearch.`); + } - if (isAnnotations(docs) === false) { - throw Boom.badRequest(`Annotations didn't pass integrity check.`); - } + const docs: Annotations = _.get(resp, ['hits', 'hits'], []).map((d: EsResult) => { + // get the original source document and the document id, we need it + // to identify the annotation when editing/deleting it. + return { ...d._source, _id: d._id } as Annotation; + }); - docs.forEach((doc: Annotation) => { - const jobId = doc.job_id; - if (typeof obj.annotations[jobId] === 'undefined') { - obj.annotations[jobId] = []; + if (isAnnotations(docs) === false) { + // No need to translate, this will not be exposed in the UI. + throw new Error(`Annotations didn't pass integrity check.`); } - obj.annotations[jobId].push(doc); - }); - return obj; + docs.forEach((doc: Annotation) => { + const jobId = doc.job_id; + if (typeof obj.annotations[jobId] === 'undefined') { + obj.annotations[jobId] = []; + } + obj.annotations[jobId].push(doc); + }); + + return obj; + } catch (error) { + throw Boom.badRequest(error); + } } async function deleteAnnotation(id: string) {