diff --git a/x-pack/plugins/ml/common/constants/annotations.ts b/x-pack/plugins/ml/common/constants/annotations.ts new file mode 100644 index 000000000000..96367682ecf0 --- /dev/null +++ b/x-pack/plugins/ml/common/constants/annotations.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export enum ANNOTATION_TYPE { + ANNOTATION = 'annotation', + COMMENT = 'comment', +} diff --git a/x-pack/plugins/ml/common/constants/feature_flags.ts b/x-pack/plugins/ml/common/constants/feature_flags.ts new file mode 100644 index 000000000000..96a46c92cb60 --- /dev/null +++ b/x-pack/plugins/ml/common/constants/feature_flags.ts @@ -0,0 +1,14 @@ +/* + * 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. + */ + +// This flag is used on the server side as the default setting. +// Plugin initialization does some additional integrity checks and tests if the necessary +// indices and aliases exist. Based on that the final setting will be available +// as an injectedVar on the client side and can be accessed like: +// +// import chrome from 'ui/chrome'; +// const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false); +export const FEATURE_ANNOTATIONS_ENABLED = true; diff --git a/x-pack/plugins/ml/common/constants/index_patterns.ts b/x-pack/plugins/ml/common/constants/index_patterns.ts new file mode 100644 index 000000000000..429cf8454325 --- /dev/null +++ b/x-pack/plugins/ml/common/constants/index_patterns.ts @@ -0,0 +1,12 @@ +/* + * 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. + */ + +export const ML_ANNOTATIONS_INDEX_ALIAS_READ = '.ml-annotations-read'; +export const ML_ANNOTATIONS_INDEX_ALIAS_WRITE = '.ml-annotations-write'; +export const ML_ANNOTATIONS_INDEX_PATTERN = '.ml-annotations-6'; + +export const ML_RESULTS_INDEX_PATTERN = '.ml-anomalies-*'; +export const ML_NOTIFICATION_INDEX_PATTERN = '.ml-notifications'; diff --git a/x-pack/plugins/ml/common/constants/search.ts b/x-pack/plugins/ml/common/constants/search.ts new file mode 100644 index 000000000000..2ea27c5b5322 --- /dev/null +++ b/x-pack/plugins/ml/common/constants/search.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export const ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE = 500; +export const ANOMALIES_TABLE_DEFAULT_QUERY_SIZE = 500; diff --git a/x-pack/plugins/ml/common/types/annotations.ts b/x-pack/plugins/ml/common/types/annotations.ts new file mode 100644 index 000000000000..443f69604d44 --- /dev/null +++ b/x-pack/plugins/ml/common/types/annotations.ts @@ -0,0 +1,94 @@ +/* + * 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. + */ + +// The Annotation interface is based on annotation documents stored in the +// `.ml-annotations-6` index, accessed via the `.ml-annotations-[read|write]` aliases. + +// Annotation document mapping: +// PUT .ml-annotations-6 +// { +// "mappings": { +// "annotation": { +// "properties": { +// "annotation": { +// "type": "text" +// }, +// "create_time": { +// "type": "date", +// "format": "epoch_millis" +// }, +// "create_username": { +// "type": "keyword" +// }, +// "timestamp": { +// "type": "date", +// "format": "epoch_millis" +// }, +// "end_timestamp": { +// "type": "date", +// "format": "epoch_millis" +// }, +// "job_id": { +// "type": "keyword" +// }, +// "modified_time": { +// "type": "date", +// "format": "epoch_millis" +// }, +// "modified_username": { +// "type": "keyword" +// }, +// "type": { +// "type": "keyword" +// } +// } +// } +// } +// } + +// Alias +// POST /_aliases +// { +// "actions" : [ +// { "add" : { "index" : ".ml-annotations-6", "alias" : ".ml-annotations-read" } }, +// { "add" : { "index" : ".ml-annotations-6", "alias" : ".ml-annotations-write" } } +// ] +// } + +import { ANNOTATION_TYPE } from '../constants/annotations'; + +export interface Annotation { + _id?: string; + create_time?: number; + create_username?: string; + modified_time?: number; + modified_username?: string; + key?: string; + + timestamp: number; + end_timestamp?: number; + annotation: string; + job_id: string; + type: ANNOTATION_TYPE.ANNOTATION | ANNOTATION_TYPE.COMMENT; +} + +export function isAnnotation(arg: any): arg is Annotation { + return ( + arg.timestamp !== undefined && + typeof arg.annotation === 'string' && + typeof arg.job_id === 'string' && + (arg.type === ANNOTATION_TYPE.ANNOTATION || arg.type === ANNOTATION_TYPE.COMMENT) + ); +} + +export interface Annotations extends Array {} + +export function isAnnotations(arg: any): arg is Annotations { + if (Array.isArray(arg) === false) { + return false; + } + return arg.every((d: Annotation) => isAnnotation(d)); +} diff --git a/x-pack/plugins/ml/common/types/common.ts b/x-pack/plugins/ml/common/types/common.ts new file mode 100644 index 000000000000..e9d3aa53b2ec --- /dev/null +++ b/x-pack/plugins/ml/common/types/common.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export interface Dictionary { + [id: string]: TValue; +} diff --git a/x-pack/plugins/ml/common/types/jobs.ts b/x-pack/plugins/ml/common/types/jobs.ts new file mode 100644 index 000000000000..5484a2883082 --- /dev/null +++ b/x-pack/plugins/ml/common/types/jobs.ts @@ -0,0 +1,53 @@ +/* + * 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. + */ + +// TS TODO: This is not yet a fully fledged representation of the job data structure, +// but it fulfills some basic TypeScript related needs. +export interface MlJob { + analysis_config: { + bucket_span: string; + detectors: object[]; + influencers: string[]; + }; + analysis_limits: { + categorization_examples_limit: number; + model_memory_limit: string; + }; + create_time: number; + custom_settings: object; + data_counts: object; + data_description: { + time_field: string; + time_format: string; + }; + datafeed_config: object; + description: string; + established_model_memory: number; + finished_time: number; + job_id: string; + job_type: string; + job_version: string; + model_plot_config: object; + model_size_stats: object; + model_snapshot_id: string; + model_snapshot_min_version: string; + model_snapshot_retention_days: number; + results_index_name: string; + state: string; +} + +export function isMlJob(arg: any): arg is MlJob { + return typeof arg.job_id === 'string'; +} + +export interface MlJobs extends Array {} + +export function isMlJobs(arg: any): arg is MlJobs { + if (Array.isArray(arg) === false) { + return false; + } + return arg.every((d: MlJob) => isMlJob(d)); +} diff --git a/x-pack/plugins/ml/index.js b/x-pack/plugins/ml/index.js index c84bf9d56eb5..b718039d4d46 100644 --- a/x-pack/plugins/ml/index.js +++ b/x-pack/plugins/ml/index.js @@ -9,7 +9,9 @@ import { resolve } from 'path'; import Boom from 'boom'; import { checkLicense } from './server/lib/check_license'; +import { isAnnotationsFeatureAvailable } from './server/lib/check_annotations'; import { mirrorPluginStatus } from '../../server/lib/mirror_plugin_status'; +import { annotationRoutes } from './server/routes/annotations'; import { jobRoutes } from './server/routes/anomaly_detectors'; import { dataFeedRoutes } from './server/routes/datafeeds'; import { indicesRoutes } from './server/routes/indices'; @@ -52,8 +54,7 @@ export const ml = (kibana) => { }, }, - - init: function (server) { + init: async function (server) { const thisPlugin = this; const xpackMainPlugin = server.plugins.xpack_main; mirrorPluginStatus(xpackMainPlugin, thisPlugin); @@ -77,14 +78,18 @@ export const ml = (kibana) => { ] }; + const mlAnnotationsEnabled = await isAnnotationsFeatureAvailable(server); + server.injectUiAppVars('ml', () => { const config = server.config(); return { kbnIndex: config.get('kibana.index'), esServerUrl: config.get('elasticsearch.url'), + mlAnnotationsEnabled, }; }); + annotationRoutes(server, commonRouteConfig); jobRoutes(server, commonRouteConfig); dataFeedRoutes(server, commonRouteConfig); indicesRoutes(server, commonRouteConfig); diff --git a/x-pack/plugins/ml/public/components/annotations_table/annotations_table.js b/x-pack/plugins/ml/public/components/annotations_table/annotations_table.js new file mode 100644 index 000000000000..ca2e00c4a3ba --- /dev/null +++ b/x-pack/plugins/ml/public/components/annotations_table/annotations_table.js @@ -0,0 +1,336 @@ +/* + * 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. + */ + +/* + * Table for displaying annotations. This is mostly a copy of the forecasts table. + * This version supports both fetching the annotations by itself (used in the jobs list) and + * getting the annotations via props (used in Anomaly Explorer and Single Series Viewer). + */ + +import PropTypes from 'prop-types'; +import rison from 'rison-node'; + +import React, { + Component +} from 'react'; + +import { + EuiBadge, + EuiButtonIcon, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiInMemoryTable, + EuiLink, + EuiLoadingSpinner, + EuiToolTip, +} from '@elastic/eui'; + +import { + RIGHT_ALIGNMENT, +} from '@elastic/eui/lib/services'; + +import { formatDate } from '@elastic/eui/lib/services/format'; +import chrome from 'ui/chrome'; + +import { addItemToRecentlyAccessed } from '../../util/recently_accessed'; +import { ml } from '../../services/ml_api_service'; +import { mlTableService } from '../../services/table_service'; +import { ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE } from '../../../common/constants/search'; + +const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'; + +/** + * Table component for rendering the lists of annotations for an ML job. + */ +class AnnotationsTable extends Component { + constructor(props) { + super(props); + this.state = { + annotations: [], + isLoading: false + }; + } + + getAnnotations() { + const job = this.props.jobs[0]; + const dataCounts = job.data_counts; + + this.setState({ + isLoading: true + }); + + if (dataCounts.processed_record_count > 0) { + // Load annotations for the selected job. + ml.annotations.getAnnotations({ + jobIds: [job.job_id], + earliestMs: dataCounts.earliest_record_timestamp, + latestMs: dataCounts.latest_record_timestamp, + maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE + }).then((resp) => { + this.setState((prevState, props) => ({ + annotations: resp.annotations[props.jobs[0].job_id] || [], + errorMessage: undefined, + isLoading: false, + jobId: props.jobs[0].job_id + })); + }).catch((resp) => { + console.log('Error loading list of annoations for jobs list:', resp); + this.setState({ + annotations: [], + errorMessage: 'Error loading the list of annotations for this job', + isLoading: false, + jobId: undefined + }); + }); + } + } + + componentDidMount() { + if (this.props.annotations === undefined) { + this.getAnnotations(); + } + } + + componentWillUpdate() { + if ( + this.props.annotations === undefined && + this.state.isLoading === false && + this.state.jobId !== this.props.jobs[0].job_id + ) { + this.getAnnotations(); + } + } + + openSingleMetricView(annotation) { + // Creates the link to the Single Metric Viewer. + // Set the total time range from the start to the end of the annotation, + const dataCounts = this.props.jobs[0].data_counts; + const from = new Date(dataCounts.earliest_record_timestamp).toISOString(); + const to = new Date(dataCounts.latest_record_timestamp).toISOString(); + + const _g = rison.encode({ + ml: { + jobIds: [this.props.jobs[0].job_id] + }, + refreshInterval: { + display: 'Off', + pause: false, + value: 0 + }, + time: { + from, + to, + mode: 'absolute' + } + }); + + const appState = { + filters: [], + query: { + query_string: { + analyze_wildcard: true, + query: '*' + } + } + }; + + if (annotation !== undefined) { + appState.mlTimeSeriesExplorer = { + zoom: { + from: new Date(annotation.timestamp).toISOString(), + to: new Date(annotation.end_timestamp).toISOString() + } + }; + } + + const _a = rison.encode(appState); + + const url = `?_g=${_g}&_a=${_a}`; + addItemToRecentlyAccessed('timeseriesexplorer', this.props.jobs[0].job_id, url); + window.open(`${chrome.getBasePath()}/app/ml#/timeseriesexplorer${url}`, '_self'); + } + + onMouseOverRow = (record) => { + if (this.mouseOverRecord !== undefined) { + if (this.mouseOverRecord.rowId !== record.rowId) { + // Mouse is over a different row, fire mouseleave on the previous record. + mlTableService.rowMouseleave.changed(this.mouseOverRecord, 'annotation'); + + // fire mouseenter on the new record. + mlTableService.rowMouseenter.changed(record, 'annotation'); + } + } else { + // Mouse is now over a row, fire mouseenter on the record. + mlTableService.rowMouseenter.changed(record, 'annotation'); + } + + this.mouseOverRecord = record; + } + + onMouseLeaveRow = () => { + if (this.mouseOverRecord !== undefined) { + mlTableService.rowMouseleave.changed(this.mouseOverRecord, 'annotation'); + this.mouseOverRecord = undefined; + } + }; + + render() { + const { + isSingleMetricViewerLinkVisible = true, + isNumberBadgeVisible = false + } = this.props; + + if (this.props.annotations === undefined) { + if (this.state.isLoading === true) { + return ( + + + + ); + } + + if (this.state.errorMessage !== undefined) { + return ( + + ); + } + } + + const annotations = this.props.annotations || this.state.annotations; + + if (annotations.length === 0) { + return ( + +

+ To create an annotation, + open the Single Metric Viewer +

+
+ ); + } + + function renderDate(date) { return formatDate(date, TIME_FORMAT); } + + const columns = [ + { + field: 'annotation', + name: 'Annotation', + sortable: true + }, + { + field: 'timestamp', + name: 'From', + dataType: 'date', + render: renderDate, + sortable: true, + }, + { + field: 'end_timestamp', + name: 'To', + dataType: 'date', + render: renderDate, + sortable: true, + }, + { + field: 'create_time', + name: 'Creation date', + dataType: 'date', + render: renderDate, + sortable: true, + }, + { + field: 'create_username', + name: 'Created by', + sortable: true, + }, + { + field: 'modified_time', + name: 'Last modified date', + dataType: 'date', + render: renderDate, + sortable: true, + }, + { + field: 'modified_username', + name: 'Last modified by', + sortable: true, + }, + ]; + + if (isNumberBadgeVisible) { + columns.unshift({ + field: 'key', + name: '', + width: '50px', + render: (key) => { + return ( + + {key} + + ); + } + }); + } + + if (isSingleMetricViewerLinkVisible) { + const openInSingleMetricViewerText = 'Open in Single Metric Viewer'; + columns.push({ + align: RIGHT_ALIGNMENT, + width: '60px', + name: 'View', + render: (annotation) => ( + + this.openSingleMetricView(annotation)} + iconType="stats" + aria-label={openInSingleMetricViewerText} + /> + + ) + }); + } + + const getRowProps = (item) => { + return { + onMouseOver: () => this.onMouseOverRow(item), + onMouseLeave: () => this.onMouseLeaveRow() + }; + }; + + return ( + + ); + } +} +AnnotationsTable.propTypes = { + annotations: PropTypes.array, + jobs: PropTypes.array, + isSingleMetricViewerLinkVisible: PropTypes.bool, + isNumberBadgeVisible: PropTypes.bool +}; + +export { AnnotationsTable }; diff --git a/x-pack/plugins/ml/public/components/annotations_table/annotations_table_directive.js b/x-pack/plugins/ml/public/components/annotations_table/annotations_table_directive.js new file mode 100644 index 000000000000..6d9bffc05a6f --- /dev/null +++ b/x-pack/plugins/ml/public/components/annotations_table/annotations_table_directive.js @@ -0,0 +1,76 @@ +/* + * 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. + */ + + + +/* + * angularjs wrapper directive for the AnnotationsTable React component. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { AnnotationsTable } from './annotations_table'; + +import 'angular'; + +import { uiModules } from 'ui/modules'; +const module = uiModules.get('apps/ml'); + +import chrome from 'ui/chrome'; +const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false); + +module.directive('mlAnnotationTable', function () { + + function link(scope, element) { + function renderReactComponent() { + if (typeof scope.jobs === 'undefined') { + return; + } + + const props = { + annotations: scope.annotations, + jobs: scope.jobs, + isSingleMetricViewerLinkVisible: scope.drillDown, + isNumberBadgeVisible: true + }; + + ReactDOM.render( + React.createElement(AnnotationsTable, props), + element[0] + ); + } + + renderReactComponent(); + + scope.$on('render', () => { + renderReactComponent(); + }); + + function renderFocusChart() { + renderReactComponent(); + } + + if (mlAnnotationsEnabled) { + scope.$watchCollection('annotations', renderFocusChart); + } + + element.on('$destroy', () => { + ReactDOM.unmountComponentAtNode(element[0]); + scope.$destroy(); + }); + + } + + return { + scope: { + annotations: '=', + drillDown: '=', + jobs: '=' + }, + link: link + }; +}); diff --git a/x-pack/plugins/ml/common/constants/index_patterns.js b/x-pack/plugins/ml/public/components/annotations_table/index.js similarity index 65% rename from x-pack/plugins/ml/common/constants/index_patterns.js rename to x-pack/plugins/ml/public/components/annotations_table/index.js index 98f24ced0e44..6364275ce2e5 100644 --- a/x-pack/plugins/ml/common/constants/index_patterns.js +++ b/x-pack/plugins/ml/public/components/annotations_table/index.js @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ +export { AnnotationsTable } from './annotations_table'; - -export const ML_RESULTS_INDEX_PATTERN = '.ml-anomalies-*'; -export const ML_NOTIFICATION_INDEX_PATTERN = '.ml-notifications'; +import './annotations_table_directive'; diff --git a/x-pack/plugins/ml/public/components/anomalies_table/anomalies_table.js b/x-pack/plugins/ml/public/components/anomalies_table/anomalies_table.js index f88178351e17..80683d2f050a 100644 --- a/x-pack/plugins/ml/public/components/anomalies_table/anomalies_table.js +++ b/x-pack/plugins/ml/public/components/anomalies_table/anomalies_table.js @@ -35,7 +35,7 @@ import { AnomalyDetails } from './anomaly_details'; import { LinksMenu } from './links_menu'; import { checkPermission } from 'plugins/ml/privilege/check_privilege'; -import { mlAnomaliesTableService } from './anomalies_table_service'; +import { mlTableService } from '../../services/table_service'; import { mlFieldFormatService } from 'plugins/ml/services/field_format_service'; import { getSeverityColor, isRuleSupported } from 'plugins/ml/../common/util/anomaly_utils'; import { formatValue } from 'plugins/ml/formatters/format_value'; @@ -314,14 +314,14 @@ class AnomaliesTable extends Component { if (this.mouseOverRecord !== undefined) { if (this.mouseOverRecord.rowId !== record.rowId) { // Mouse is over a different row, fire mouseleave on the previous record. - mlAnomaliesTableService.anomalyRecordMouseleave.changed(this.mouseOverRecord); + mlTableService.rowMouseleave.changed(this.mouseOverRecord); // fire mouseenter on the new record. - mlAnomaliesTableService.anomalyRecordMouseenter.changed(record); + mlTableService.rowMouseenter.changed(record); } } else { // Mouse is now over a row, fire mouseenter on the record. - mlAnomaliesTableService.anomalyRecordMouseenter.changed(record); + mlTableService.rowMouseenter.changed(record); } this.mouseOverRecord = record; @@ -329,7 +329,7 @@ class AnomaliesTable extends Component { onMouseLeaveRow = () => { if (this.mouseOverRecord !== undefined) { - mlAnomaliesTableService.anomalyRecordMouseleave.changed(this.mouseOverRecord); + mlTableService.rowMouseleave.changed(this.mouseOverRecord); this.mouseOverRecord = undefined; } }; diff --git a/x-pack/plugins/ml/public/components/anomalies_table/index.js b/x-pack/plugins/ml/public/components/anomalies_table/index.js index e2750dff9ee6..d16092d1f618 100644 --- a/x-pack/plugins/ml/public/components/anomalies_table/index.js +++ b/x-pack/plugins/ml/public/components/anomalies_table/index.js @@ -6,4 +6,3 @@ import './anomalies_table_directive'; -import './anomalies_table_service.js'; \ No newline at end of file diff --git a/x-pack/plugins/ml/public/explorer/explorer.html b/x-pack/plugins/ml/public/explorer/explorer.html index 49188f6754f0..2100123a8f4b 100644 --- a/x-pack/plugins/ml/public/explorer/explorer.html +++ b/x-pack/plugins/ml/public/explorer/explorer.html @@ -101,6 +101,20 @@ +
+ + Annotations + + + + +

+
+ Anomalies diff --git a/x-pack/plugins/ml/public/explorer/explorer_controller.js b/x-pack/plugins/ml/public/explorer/explorer_controller.js index 3b83d478cb86..ed0f7c8284fc 100644 --- a/x-pack/plugins/ml/public/explorer/explorer_controller.js +++ b/x-pack/plugins/ml/public/explorer/explorer_controller.js @@ -17,6 +17,7 @@ import $ from 'jquery'; import DragSelect from 'dragselect'; import moment from 'moment-timezone'; +import 'plugins/ml/components/annotations_table'; import 'plugins/ml/components/anomalies_table'; import 'plugins/ml/components/controls'; import 'plugins/ml/components/influencers_list'; @@ -46,6 +47,15 @@ import { SWIMLANE_DEFAULT_LIMIT, SWIMLANE_TYPE } from './explorer_constants'; +import { + ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE, + ANOMALIES_TABLE_DEFAULT_QUERY_SIZE +} from '../../common/constants/search'; + +// TODO Fully support Annotations in Anomaly Explorer +// import chrome from 'ui/chrome'; +// const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false); +const mlAnnotationsEnabled = false; uiRoutes .when('/explorer/?', { @@ -82,6 +92,7 @@ module.controller('MlExplorerController', function ( mlSelectIntervalService, mlSelectSeverityService) { + $scope.annotationsData = []; $scope.anomalyChartRecords = []; $scope.timeFieldName = 'timestamp'; $scope.loading = true; @@ -938,6 +949,42 @@ module.controller('MlExplorerController', function ( } } + async function loadAnnotationsTableData() { + $scope.annotationsData = []; + + const cellData = $scope.cellData; + const jobIds = ($scope.cellData !== undefined && cellData.fieldName === VIEW_BY_JOB_LABEL) ? + cellData.lanes : $scope.getSelectedJobIds(); + const timeRange = getSelectionTimeRange(cellData); + + + if (mlAnnotationsEnabled) { + const resp = await ml.annotations.getAnnotations({ + jobIds, + earliestMs: timeRange.earliestMs, + latestMs: timeRange.latestMs, + maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE + }); + + $scope.$evalAsync(() => { + const annotationsData = resp.annotations[jobIds[0]]; + + if (annotationsData === undefined) { + return; + } + + $scope.annotationsData = annotationsData + .sort((a, b) => { + return a.timestamp - b.timestamp; + }) + .map((d, i) => { + d.key = String.fromCharCode(65 + i); + return d; + }); + }); + } + } + function loadAnomaliesTableData() { const cellData = $scope.cellData; const jobIds = ($scope.cellData !== undefined && cellData.fieldName === VIEW_BY_JOB_LABEL) ? @@ -954,7 +1001,7 @@ module.controller('MlExplorerController', function ( timeRange.earliestMs, timeRange.latestMs, dateFormatTz, - 500, + ANOMALIES_TABLE_DEFAULT_QUERY_SIZE, MAX_CATEGORY_EXAMPLES ).then((resp) => { const anomalies = resp.anomalies; @@ -1007,9 +1054,11 @@ module.controller('MlExplorerController', function ( // The following is to avoid running into a race condition where loading a swimlane selection from URL/AppState // would fail because the Explorer Charts Container's directive wasn't linked yet and not being subscribed // to the anomalyDataChange listener used in loadDataForCharts(). - function finish() { + async function finish() { setShowViewBySwimlane(); + await loadAnnotationsTableData(); + $timeout(() => { if ($scope.overallSwimlaneData !== undefined) { mlExplorerDashboardService.swimlaneDataChange.changed(mapScopeToSwimlaneProps(SWIMLANE_TYPE.OVERALL)); diff --git a/x-pack/plugins/ml/public/index.scss b/x-pack/plugins/ml/public/index.scss index db496606f02a..61b4ee5a0b38 100644 --- a/x-pack/plugins/ml/public/index.scss +++ b/x-pack/plugins/ml/public/index.scss @@ -46,6 +46,6 @@ @import 'components/nav_menu/index'; @import 'components/rule_editor/index'; // SASSTODO: This file overwrites EUI directly - // Hacks are last so they can ovwerite anything above if needed + // Hacks are last so they can overwrite anything above if needed @import 'hacks'; } diff --git a/x-pack/plugins/ml/public/jobs/jobs_list/components/job_details/_index.scss b/x-pack/plugins/ml/public/jobs/jobs_list/components/job_details/_index.scss index ae8b160f3f53..babb5b5fb43e 100644 --- a/x-pack/plugins/ml/public/jobs/jobs_list/components/job_details/_index.scss +++ b/x-pack/plugins/ml/public/jobs/jobs_list/components/job_details/_index.scss @@ -1,2 +1,2 @@ @import 'job_details'; -@import 'forecasts_table/index'; \ No newline at end of file +@import 'forecasts_table/index'; diff --git a/x-pack/plugins/ml/public/jobs/jobs_list/components/job_details/job_details.js b/x-pack/plugins/ml/public/jobs/jobs_list/components/job_details/job_details.js index bfdb8fec1a90..1de224133a82 100644 --- a/x-pack/plugins/ml/public/jobs/jobs_list/components/job_details/job_details.js +++ b/x-pack/plugins/ml/public/jobs/jobs_list/components/job_details/job_details.js @@ -18,11 +18,15 @@ import { import { extractJobDetails } from './extract_job_details'; import { JsonPane } from './json_tab'; import { DatafeedPreviewPane } from './datafeed_preview_tab'; +import { AnnotationsTable } from '../../../../components/annotations_table'; import { ForecastsTable } from './forecasts_table'; import { JobDetailsPane } from './job_details_pane'; import { JobMessagesPane } from './job_messages_pane'; import { injectI18n } from '@kbn/i18n/react'; +import chrome from 'ui/chrome'; +const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false); + class JobDetailsUI extends Component { constructor(props) { super(props); @@ -126,6 +130,14 @@ class JobDetailsUI extends Component { } ]; + if (mlAnnotationsEnabled) { + tabs.push({ + id: 'annotations', + name: 'Annotations', + content: , + }); + } + return (
', () => { let $scope; diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/_index.scss b/x-pack/plugins/ml/public/timeseriesexplorer/_index.scss index 22707a81d66f..c1da7480f707 100644 --- a/x-pack/plugins/ml/public/timeseriesexplorer/_index.scss +++ b/x-pack/plugins/ml/public/timeseriesexplorer/_index.scss @@ -1,2 +1,4 @@ +@import 'components/annotation_description_list/index'; +@import 'components/forecasting_modal/index'; @import 'timeseriesexplorer'; -@import 'forecasting_modal/index'; \ No newline at end of file +@import 'timeseriesexplorer_annotations'; diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/_timeseriesexplorer_annotations.scss b/x-pack/plugins/ml/public/timeseriesexplorer/_timeseriesexplorer_annotations.scss new file mode 100644 index 000000000000..97bb2ad8f4a7 --- /dev/null +++ b/x-pack/plugins/ml/public/timeseriesexplorer/_timeseriesexplorer_annotations.scss @@ -0,0 +1,82 @@ +// SASS TODO: This uses non-BEM styles to be in line with the existing +// legacy Time Series Viewer style. Where applicable it tries to avoid +// overrides. The one override with `.extent` is because of d3. + +$mlAnnotationBorderWidth: 2px; + +// Replicates $euiBorderEditable for SVG +.mlAnnotationBrush .extent { + stroke: $euiColorLightShade; + stroke-width: $mlAnnotationBorderWidth; + stroke-dasharray: 2 2; + fill: $euiColorLightestShade; + shape-rendering: geometricPrecision; +} + +// Instead of different EUI colors we use opacity settings +// here to avoid opaque layers on top of existing chart elements. +$mlAnnotationRectDefaultStrokeOpacity: 0.2; +$mlAnnotationRectDefaultFillOpacity: 0.05; + +.mlAnnotationRect { + stroke: $euiColorFullShade; + stroke-width: $mlAnnotationBorderWidth; + stroke-opacity: $mlAnnotationRectDefaultStrokeOpacity; + transition: stroke-opacity $euiAnimSpeedFast; + + fill: $euiColorFullShade; + fill-opacity: $mlAnnotationRectDefaultFillOpacity; + transition: fill-opacity $euiAnimSpeedFast; + + shape-rendering: geometricPrecision; +} + +.mlAnnotationRect-isHighlight { + stroke-opacity: $mlAnnotationRectDefaultStrokeOpacity * 2;; + transition: stroke-opacity $euiAnimSpeedFast; + + fill-opacity: $mlAnnotationRectDefaultFillOpacity * 2; + transition: fill-opacity $euiAnimSpeedFast; +} + +.mlAnnotationRect-isBlur { + stroke-opacity: $mlAnnotationRectDefaultStrokeOpacity / 2; + transition: stroke-opacity $euiAnimSpeedFast; + + fill-opacity: $mlAnnotationRectDefaultFillOpacity / 2; + transition: fill-opacity $euiAnimSpeedFast; +} + +// Replace the EuiBadge text style for SVG +.mlAnnotationText { + text-anchor: middle; + font-size: $euiFontSizeXS; + font-family: $euiFontFamily; + font-weight: $euiFontWeightMedium; + + fill: $euiColorFullShade; + transition: fill $euiAnimSpeedFast; +} + +.mlAnnotationText-isBlur { + fill: $euiColorMediumShade; + transition: fill $euiAnimSpeedFast; +} + +.mlAnnotationTextRect { + fill: $euiColorLightShade; + transition: fill $euiAnimSpeedFast; + // TODO This is hard-coded for now for labels A-Z + // Needs to support dynamic SVG width. + width: $euiSizeL; + height: $euiSize + $euiSizeXS; +} + +.mlAnnotationTextRect-isBlur { + fill: $euiColorLightestShade; + transition: fill $euiAnimSpeedFast; +} + +.mlAnnotationHidden { + display: none; +} diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/components/annotation_description_list/_index.scss b/x-pack/plugins/ml/public/timeseriesexplorer/components/annotation_description_list/_index.scss new file mode 100644 index 000000000000..465190875c18 --- /dev/null +++ b/x-pack/plugins/ml/public/timeseriesexplorer/components/annotation_description_list/_index.scss @@ -0,0 +1,17 @@ +// SASSTODO: This is based on the overwrites used in the Filters flyout to match the existing style. + +// SASSTODO: Dangerous EUI overwrites +.euiDescriptionList.euiDescriptionList--column.ml-annotation-description-list { + .euiDescriptionList__title { + flex-basis: 30%; + } + + .euiDescriptionList__description { + flex-basis: 70%; + } +} + +// SASSTODO: Dangerous EUI overwrites +.euiDescriptionList.euiDescriptionList--column.ml-annotation-description-list > * { + margin-top: $euiSizeXS; +} diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/components/annotation_description_list/index.tsx b/x-pack/plugins/ml/public/timeseriesexplorer/components/annotation_description_list/index.tsx new file mode 100644 index 000000000000..716da77ebb92 --- /dev/null +++ b/x-pack/plugins/ml/public/timeseriesexplorer/components/annotation_description_list/index.tsx @@ -0,0 +1,73 @@ +/* + * 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. + */ + +/* + * React component for listing pairs of information about the detector for which + * rules are being edited. + */ + +import React from 'react'; + +import { EuiDescriptionList } from '@elastic/eui'; + +// @ts-ignore +import { formatDate } from '@elastic/eui/lib/services/format'; +import { Annotation } from '../../../../common/types/annotations'; + +interface Props { + annotation: Annotation; +} + +function formatListDate(ts: number) { + return formatDate(ts, 'MMMM Do YYYY, HH:mm:ss'); +} + +export const AnnotationDescriptionList: React.SFC = ({ annotation }) => { + const listItems = [ + { + title: 'Job ID', + description: annotation.job_id, + }, + { + title: 'Start', + description: formatListDate(annotation.timestamp), + }, + ]; + + if (annotation.end_timestamp !== undefined) { + listItems.push({ + title: 'End', + description: formatListDate(annotation.end_timestamp), + }); + } + + if (annotation.create_time !== undefined && annotation.modified_time !== undefined) { + listItems.push({ + title: 'Created', + description: formatListDate(annotation.create_time), + }); + listItems.push({ + title: 'Created by', + description: annotation.create_username, + }); + listItems.push({ + title: 'Last modified', + description: formatListDate(annotation.modified_time), + }); + listItems.push({ + title: 'Modified by', + description: annotation.modified_username, + }); + } + + return ( + + ); +}; diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/components/annotation_flyout/index.tsx b/x-pack/plugins/ml/public/timeseriesexplorer/components/annotation_flyout/index.tsx new file mode 100644 index 000000000000..e2b954ceeead --- /dev/null +++ b/x-pack/plugins/ml/public/timeseriesexplorer/components/annotation_flyout/index.tsx @@ -0,0 +1,91 @@ +/* + * 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 from 'react'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiFormRow, + EuiSpacer, + EuiTextArea, + EuiTitle, +} from '@elastic/eui'; + +import { AnnotationDescriptionList } from '../annotation_description_list'; + +import { Annotation } from '../../../../common/types/annotations'; + +interface Props { + annotation: Annotation; + cancelAction: () => {}; + controlFunc: () => {}; + deleteAction: (annotation: Annotation) => {}; + saveAction: (annotation: Annotation) => {}; +} + +export const AnnotationFlyout: React.SFC = ({ + annotation, + cancelAction, + controlFunc, + deleteAction, + saveAction, +}) => { + const saveActionWrapper = () => saveAction(annotation); + const deleteActionWrapper = () => deleteAction(annotation); + const isExistingAnnotation = typeof annotation._id !== 'undefined'; + const titlePrefix = isExistingAnnotation ? 'Edit' : 'Add'; + + return ( + + + +

{titlePrefix} annotation

+
+
+ + + + + + + + + + + + Cancel + + + + {isExistingAnnotation && ( + + Delete + + )} + + + + {isExistingAnnotation ? 'Update' : 'Create'} + + + + +
+ ); +}; diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/context_chart_mask.js b/x-pack/plugins/ml/public/timeseriesexplorer/components/context_chart_mask/context_chart_mask.js similarity index 95% rename from x-pack/plugins/ml/public/timeseriesexplorer/context_chart_mask.js rename to x-pack/plugins/ml/public/timeseriesexplorer/components/context_chart_mask/context_chart_mask.js index 3925f2d59a94..ae69a2775736 100644 --- a/x-pack/plugins/ml/public/timeseriesexplorer/context_chart_mask.js +++ b/x-pack/plugins/ml/public/timeseriesexplorer/components/context_chart_mask/context_chart_mask.js @@ -8,14 +8,13 @@ import d3 from 'd3'; -import { drawLineChartDots } from '../util/chart_utils'; +import { drawLineChartDots } from '../../../util/chart_utils'; /* * Creates a mask over sections of the context chart and swimlane * which fall outside the extent of the selection brush used for zooming. */ -// eslint-disable-next-line kibana-custom/no-default-export -export default function ContextChartMask(contextGroup, data, drawBounds, swimlaneHeight) { +export function ContextChartMask(contextGroup, data, drawBounds, swimlaneHeight) { this.contextGroup = contextGroup; this.data = data; this.drawBounds = drawBounds; diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/components/context_chart_mask/index.js b/x-pack/plugins/ml/public/timeseriesexplorer/components/context_chart_mask/index.js new file mode 100644 index 000000000000..95dfd9325f5a --- /dev/null +++ b/x-pack/plugins/ml/public/timeseriesexplorer/components/context_chart_mask/index.js @@ -0,0 +1,8 @@ +/* + * 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. + */ + + +export * from './context_chart_mask'; diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/_forecasting_modal.scss b/x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/_forecasting_modal.scss similarity index 100% rename from x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/_forecasting_modal.scss rename to x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/_forecasting_modal.scss diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/_index.scss b/x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/_index.scss similarity index 100% rename from x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/_index.scss rename to x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/_index.scss diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/forecast_progress.js b/x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/forecast_progress.js similarity index 100% rename from x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/forecast_progress.js rename to x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/forecast_progress.js diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/forecasting_modal.js b/x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/forecasting_modal.js similarity index 97% rename from x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/forecasting_modal.js rename to x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/forecasting_modal.js index dca0837338bd..9fa30fe56ea7 100644 --- a/x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/forecasting_modal.js +++ b/x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/forecasting_modal.js @@ -24,10 +24,10 @@ import { // don't use something like plugins/ml/../common // because it won't work with the jest tests -import { FORECAST_REQUEST_STATE, JOB_STATE } from '../../../common/constants/states'; -import { MESSAGE_LEVEL } from '../../../common/constants/message_levels'; -import { isJobVersionGte } from '../../../common/util/job_utils'; -import { parseInterval } from '../../../common/util/parse_interval'; +import { FORECAST_REQUEST_STATE, JOB_STATE } from '../../../../common/constants/states'; +import { MESSAGE_LEVEL } from '../../../../common/constants/message_levels'; +import { isJobVersionGte } from '../../../../common/util/job_utils'; +import { parseInterval } from '../../../../common/util/parse_interval'; import { Modal } from './modal'; import { PROGRESS_STATES } from './progress_states'; import { ml } from 'plugins/ml/services/ml_api_service'; diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/forecasting_modal_directive.js b/x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/forecasting_modal_directive.js similarity index 100% rename from x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/forecasting_modal_directive.js rename to x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/forecasting_modal_directive.js diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/forecasts_list.js b/x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/forecasts_list.js similarity index 100% rename from x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/forecasts_list.js rename to x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/forecasts_list.js diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/index.js b/x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/index.js similarity index 100% rename from x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/index.js rename to x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/index.js diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/modal.js b/x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/modal.js similarity index 100% rename from x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/modal.js rename to x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/modal.js diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/progress_icon.js b/x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/progress_icon.js similarity index 100% rename from x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/progress_icon.js rename to x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/progress_icon.js diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/progress_states.js b/x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/progress_states.js similarity index 100% rename from x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/progress_states.js rename to x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/progress_states.js diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/run_controls.js b/x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/run_controls.js similarity index 98% rename from x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/run_controls.js rename to x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/run_controls.js index 24a9e5e66640..53f96aee8e37 100644 --- a/x-pack/plugins/ml/public/timeseriesexplorer/forecasting_modal/run_controls.js +++ b/x-pack/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/run_controls.js @@ -28,7 +28,7 @@ import { // don't use something like plugins/ml/../common // because it won't work with the jest tests -import { JOB_STATE } from '../../../common/constants/states'; +import { JOB_STATE } from '../../../../common/constants/states'; import { ForecastProgress } from './forecast_progress'; import { mlNodesAvailable } from 'plugins/ml/ml_nodes_check/check_ml_nodes'; import { checkPermission, createPermissionFailureMessage } from 'plugins/ml/privilege/check_privilege'; diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart.d.ts b/x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart.d.ts new file mode 100644 index 000000000000..8a041a94b2d5 --- /dev/null +++ b/x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart.d.ts @@ -0,0 +1,25 @@ +/* + * 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 d3 from 'd3'; + +import { Annotation } from '../../../../common/types/annotations'; +import { MlJob } from '../../../../common/types/jobs'; + +interface Props { + selectedJob: MlJob; +} + +interface State { + annotation: Annotation; +} + +export interface TimeseriesChart extends React.Component { + closeFlyout: () => {}; + showFlyout: (annotation: Annotation) => {}; + + focusXScale: d3.scale.Ordinal<{}, number>; +} diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/timeseries_chart.js b/x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js similarity index 86% rename from x-pack/plugins/ml/public/timeseriesexplorer/timeseries_chart.js rename to x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js index f9d9db59eea4..9a96f3d8b154 100644 --- a/x-pack/plugins/ml/public/timeseriesexplorer/timeseries_chart.js +++ b/x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js @@ -19,11 +19,14 @@ import _ from 'lodash'; import d3 from 'd3'; import moment from 'moment'; +import chrome from 'ui/chrome'; + import { getSeverityWithLow, getMultiBucketImpactLabel, -} from '../../common/util/anomaly_utils'; -import { formatValue } from '../formatters/format_value'; +} from '../../../../common/util/anomaly_utils'; +import { AnnotationFlyout } from '../annotation_flyout'; +import { formatValue } from '../../../formatters/format_value'; import { LINE_CHART_ANOMALY_RADIUS, MULTI_BUCKET_SYMBOL_SIZE, @@ -33,14 +36,24 @@ import { numTicksForDateFormat, showMultiBucketAnomalyMarker, showMultiBucketAnomalyTooltip, -} from '../util/chart_utils'; +} from '../../../util/chart_utils'; import { TimeBuckets } from 'ui/time_buckets'; -import { mlAnomaliesTableService } from '../components/anomalies_table/anomalies_table_service'; -import ContextChartMask from './context_chart_mask'; -import { findChartPointForAnomalyTime } from './timeseriesexplorer_utils'; -import { mlEscape } from '../util/string_utils'; -import { mlFieldFormatService } from '../services/field_format_service'; -import { mlChartTooltipService } from '../components/chart_tooltip/chart_tooltip_service'; +import { mlTableService } from '../../../services/table_service'; +import { ContextChartMask } from '../context_chart_mask'; +import { findChartPointForAnomalyTime } from '../../timeseriesexplorer_utils'; +import { mlEscape } from '../../../util/string_utils'; +import { mlFieldFormatService } from '../../../services/field_format_service'; +import { mlChartTooltipService } from '../../../components/chart_tooltip/chart_tooltip_service'; +import { + getAnnotationBrush, + getAnnotationLevels, + renderAnnotations, + + highlightFocusChartAnnotation, + unhighlightFocusChartAnnotation +} from './timeseries_chart_annotations'; + +const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false); const focusZoomPanelHeight = 25; const focusChartHeight = 310; @@ -75,16 +88,20 @@ function getSvgHeight() { export class TimeseriesChart extends React.Component { static propTypes = { + indexAnnotation: PropTypes.func, autoZoomDuration: PropTypes.number, contextAggregationInterval: PropTypes.object, contextChartData: PropTypes.array, contextForecastData: PropTypes.array, contextChartSelected: PropTypes.func.isRequired, + deleteAnnotation: PropTypes.func, detectorIndex: PropTypes.string, focusAggregationInterval: PropTypes.object, + focusAnnotationData: PropTypes.array, focusChartData: PropTypes.array, focusForecastData: PropTypes.array, modelPlotEnabled: PropTypes.bool.isRequired, + refresh: PropTypes.func, renderFocusChartOnly: PropTypes.bool.isRequired, selectedJob: PropTypes.object, showForecast: PropTypes.bool.isRequired, @@ -92,16 +109,94 @@ export class TimeseriesChart extends React.Component { svgWidth: PropTypes.number.isRequired, swimlaneData: PropTypes.array, timefilter: PropTypes.object.isRequired, + toastNotifications: PropTypes.object, zoomFrom: PropTypes.object, zoomTo: PropTypes.object + }; + + constructor(props) { + super(props); + + this.state = { + annotation: {}, + isFlyoutVisible: false, + isSwitchChecked: true, + }; + } + + closeFlyout = () => { + const chartElement = d3.select(this.rootNode); + chartElement.select('g.mlAnnotationBrush').call(this.annotateBrush.extent([0, 0])); + this.setState({ isFlyoutVisible: false, annotation: {} }); + } + + showFlyout = (annotation) => { + this.setState({ isFlyoutVisible: true, annotation }); + } + + handleAnnotationChange = (e) => { + // e is a React Syntethic Event, we need to cast it to + // a placeholder variable so it's still valid in the + // setState() asynchronous callback + const annotation = e.target.value; + this.setState((state) => { + state.annotation.annotation = annotation; + return state; + }); + } + + deleteAnnotation = (annotation) => { + const { + deleteAnnotation, + refresh, + toastNotifications + } = this.props; + + this.closeFlyout(); + + deleteAnnotation(annotation._id) + .then(() => { + refresh(); + toastNotifications.addSuccess(`Deleted annotation for job with ID ${annotation.job_id}.`); + }) + .catch((resp) => { + toastNotifications + .addDanger(`An error occured deleting the annotation for job with ID ${annotation.job_id}: ${JSON.stringify(resp)}`); + }); + } + + indexAnnotation = (annotation) => { + const { + indexAnnotation, + refresh, + toastNotifications + } = this.props; + + this.closeFlyout(); + + indexAnnotation(annotation) + .then(() => { + refresh(); + const action = (typeof annotation._id === 'undefined') ? 'Added an' : 'Updated'; + if (typeof annotation._id === 'undefined') { + toastNotifications.addSuccess(`${action} annotation for job with ID ${annotation.job_id}.`); + } else { + toastNotifications.addSuccess(`${action} annotation for job with ID ${annotation.job_id}.`); + } + }) + .catch((resp) => { + const action = (typeof annotation._id === 'undefined') ? 'creating' : 'updating'; + toastNotifications + .addDanger(`An error occured ${action} the annotation for job with ID ${annotation.job_id}: ${JSON.stringify(resp)}`); + }); } componentWillUnmount() { const element = d3.select(this.rootNode); element.html(''); - mlAnomaliesTableService.anomalyRecordMouseenter.unwatch(this.tableRecordMousenterListener); - mlAnomaliesTableService.anomalyRecordMouseleave.unwatch(this.tableRecordMouseleaveListener); + mlTableService.rowMouseenter.unwatch(this.tableRecordMousenterListener); + mlTableService.rowMouseleave.unwatch(this.tableRecordMouseleaveListener); } componentDidMount() { @@ -137,6 +232,12 @@ export class TimeseriesChart extends React.Component { this.fieldFormat = undefined; + // Annotations Brush + if (mlAnnotationsEnabled) { + this.annotateBrush = getAnnotationBrush.call(this); + } + + // brush for focus brushing this.brush = d3.svg.brush(); this.mask = undefined; @@ -144,17 +245,27 @@ export class TimeseriesChart extends React.Component { // Listeners for mouseenter/leave events for rows in the table // to highlight the corresponding anomaly mark in the focus chart. const highlightFocusChartAnomaly = this.highlightFocusChartAnomaly.bind(this); - this.tableRecordMousenterListener = function (record) { - highlightFocusChartAnomaly(record); + const boundHighlightFocusChartAnnotation = highlightFocusChartAnnotation.bind(this); + this.tableRecordMousenterListener = function (record, type = 'anomaly') { + if (type === 'anomaly') { + highlightFocusChartAnomaly(record); + } else if (type === 'annotation') { + boundHighlightFocusChartAnnotation(record); + } }; const unhighlightFocusChartAnomaly = this.unhighlightFocusChartAnomaly.bind(this); - this.tableRecordMouseleaveListener = function (record) { - unhighlightFocusChartAnomaly(record); + const boundUnhighlightFocusChartAnnotation = unhighlightFocusChartAnnotation.bind(this); + this.tableRecordMouseleaveListener = function (record, type = 'anomaly') { + if (type === 'anomaly') { + unhighlightFocusChartAnomaly(record); + } else { + boundUnhighlightFocusChartAnnotation(record); + } }; - mlAnomaliesTableService.anomalyRecordMouseenter.watch(this.tableRecordMousenterListener); - mlAnomaliesTableService.anomalyRecordMouseleave.watch(this.tableRecordMouseleaveListener); + mlTableService.rowMouseenter.watch(this.tableRecordMousenterListener); + mlTableService.rowMouseleave.watch(this.tableRecordMouseleaveListener); this.renderChart(); this.drawContextChartSelection(); @@ -349,6 +460,18 @@ export class TimeseriesChart extends React.Component { .attr('class', 'chart-border'); this.createZoomInfoElements(zoomGroup, fcsWidth); + if (mlAnnotationsEnabled) { + const annotateBrush = this.annotateBrush.bind(this); + + fcsGroup.append('g') + .attr('class', 'mlAnnotationBrush') + .call(annotateBrush) + .selectAll('rect') + .attr('x', 0) + .attr('y', focusZoomPanelHeight) + .attr('height', focusChartHeight); + } + // Add border round plot area. fcsGroup.append('rect') .attr('x', 0) @@ -407,6 +530,11 @@ export class TimeseriesChart extends React.Component { .attr('class', 'focus-chart-markers forecast'); } + // Create the elements for annotations + if (mlAnnotationsEnabled) { + fcsGroup.append('g').classed('mlAnnotations', true); + } + fcsGroup.append('rect') .attr('x', 0) .attr('y', 0) @@ -418,10 +546,12 @@ export class TimeseriesChart extends React.Component { renderFocusChart() { const { focusAggregationInterval, + focusAnnotationData, focusChartData, focusForecastData, modelPlotEnabled, selectedJob, + showAnnotations, showForecast, showModelBounds } = this.props; @@ -433,6 +563,7 @@ export class TimeseriesChart extends React.Component { const data = focusChartData; const contextYScale = this.contextYScale; + const showFlyout = this.showFlyout.bind(this); const showFocusChartTooltip = this.showFocusChartTooltip.bind(this); const focusChart = d3.select('.focus-chart'); @@ -499,6 +630,14 @@ 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) { + const levels = getAnnotationLevels(focusAnnotationData); + const maxLevel = d3.max(Object.keys(levels).map(key => levels[key])); + // TODO needs revisting to be a more robust normalization + yMax = yMax * (1 + (maxLevel + 1) / 5); + } this.focusYScale.domain([yMin, yMax]); } else { @@ -529,6 +668,23 @@ export class TimeseriesChart extends React.Component { .classed('hidden', !showModelBounds); } + if (mlAnnotationsEnabled) { + renderAnnotations( + focusChart, + focusAnnotationData, + focusZoomPanelHeight, + focusChartHeight, + this.focusXScale, + showAnnotations, + showFocusChartTooltip, + showFlyout + ); + + // disable brushing (creation of annotations) when annotations aren't shown + focusChart.select('.mlAnnotationBrush') + .style('pointer-events', (showAnnotations) ? 'all' : 'none'); + } + focusChart.select('.values-line') .attr('d', this.focusValuesLine(data)); drawLineChartDots(data, focusChart, this.focusValuesLine); @@ -1167,6 +1323,15 @@ export class TimeseriesChart extends React.Component { contents += `

Scheduled events:
${marker.scheduledEvents.map(mlEscape).join('
')}`; } + if (mlAnnotationsEnabled && _.has(marker, 'annotation')) { + contents = marker.annotation; + contents += `
${moment(marker.timestamp).format('MMMM Do YYYY, HH:mm')}`; + + if (typeof marker.end_timestamp !== 'undefined') { + contents += ` - ${moment(marker.end_timestamp).format('MMMM Do YYYY, HH:mm')}`; + } + } + mlChartTooltipService.show(contents, circle, { x: LINE_CHART_ANOMALY_RADIUS * 2, y: 0 @@ -1234,6 +1399,21 @@ export class TimeseriesChart extends React.Component { } render() { - return
; + const { annotation, isFlyoutVisible } = this.state; + + return ( + +
+ {mlAnnotationsEnabled && isFlyoutVisible && + + } + + ); } } diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/timeseries_chart.test.js b/x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js similarity index 93% rename from x-pack/plugins/ml/public/timeseriesexplorer/timeseries_chart.test.js rename to x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js index ea0c648fa565..0fa2c0c9cbf8 100644 --- a/x-pack/plugins/ml/public/timeseriesexplorer/timeseries_chart.test.js +++ b/x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js @@ -16,6 +16,8 @@ import { TimeseriesChart } from './timeseries_chart'; // code which the jest setup isn't happy with. jest.mock('ui/chrome', () => ({ getBasePath: path => path, + // returns false for mlAnnotationsEnabled + getInjected: () => false, getUiSettingsClient: () => ({ get: jest.fn() }), @@ -29,7 +31,7 @@ jest.mock('ui/time_buckets', () => ({ } })); -jest.mock('../services/field_format_service', () => ({ +jest.mock('../../../services/field_format_service', () => ({ mlFieldFormatService: {} })); diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart_annotations.ts b/x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart_annotations.ts new file mode 100644 index 000000000000..aaed24d9029c --- /dev/null +++ b/x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart_annotations.ts @@ -0,0 +1,269 @@ +/* + * 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 d3 from 'd3'; +import moment from 'moment'; + +import { ANNOTATION_TYPE } from '../../../../common/constants/annotations'; +import { Annotation, Annotations } from '../../../../common/types/annotations'; +import { Dictionary } from '../../../../common/types/common'; + +// @ts-ignore +import { mlChartTooltipService } from '../../../components/chart_tooltip/chart_tooltip_service'; + +import { TimeseriesChart } from './timeseries_chart'; + +// getAnnotationBrush() is expected to be called like getAnnotationBrush.call(this) +// so it gets passed on the context of the component it gets called from. +export function getAnnotationBrush(this: TimeseriesChart) { + const focusXScale = this.focusXScale; + + const annotateBrush = d3.svg + .brush() + .x(focusXScale) + .on('brushend', brushend.bind(this)); + + // cast a reference to this so we get the latest state when brushend() gets called + function brushend(this: TimeseriesChart) { + const { selectedJob } = this.props; + + // TS TODO make this work with the actual types. + const extent = annotateBrush.extent() as any; + + const timestamp = extent[0].getTime(); + const endTimestamp = extent[1].getTime(); + + if (timestamp === endTimestamp) { + this.closeFlyout(); + return; + } + + const annotation: Annotation = { + timestamp, + end_timestamp: endTimestamp, + annotation: this.state.annotation.annotation || '', + job_id: selectedJob.job_id, + type: ANNOTATION_TYPE.ANNOTATION, + }; + + this.showFlyout(annotation); + } + + return annotateBrush; +} + +// Used to resolve overlapping annotations in the UI. +// The returned levels can be used to create a vertical offset. +export function getAnnotationLevels(focusAnnotationData: Annotations) { + const levels: Dictionary = {}; + focusAnnotationData.forEach((d, i) => { + if (d.key !== undefined) { + const longerAnnotations = focusAnnotationData.filter((d2, i2) => i2 < i); + levels[d.key] = longerAnnotations.reduce((level, d2) => { + // For now we only support overlap removal for annotations which have both + // `timestamp` and `end_timestamp` set. + if ( + d.end_timestamp === undefined || + d2.end_timestamp === undefined || + d2.key === undefined + ) { + return level; + } + + if ( + // d2 is completely before d + (d2.timestamp < d.timestamp && d2.end_timestamp < d.timestamp) || + // d2 is completely after d + (d2.timestamp > d.end_timestamp && d2.end_timestamp > d.end_timestamp) + ) { + return level; + } + return levels[d2.key] + 1; + }, 0); + } + }); + return levels; +} + +const ANNOTATION_DEFAULT_LEVEL = 1; +const ANNOTATION_LEVEL_HEIGHT = 28; +const ANNOTATION_UPPER_RECT_MARGIN = 0; +const ANNOTATION_UPPER_TEXT_MARGIN = -7; +const ANNOTATION_MIN_WIDTH = 2; +const ANNOTATION_RECT_BORDER_RADIUS = 2; +const ANNOTATION_TEXT_VERTICAL_OFFSET = 26; +const ANNOTATION_TEXT_RECT_VERTICAL_OFFSET = 12; + +export function renderAnnotations( + focusChart: d3.Selection<[]>, + focusAnnotationData: Annotations, + focusZoomPanelHeight: number, + focusChartHeight: number, + focusXScale: TimeseriesChart['focusXScale'], + showAnnotations: boolean, + showFocusChartTooltip: (d: Annotation, t: object) => {}, + showFlyout: TimeseriesChart['showFlyout'] +) { + const upperRectMargin = ANNOTATION_UPPER_RECT_MARGIN; + const upperTextMargin = ANNOTATION_UPPER_TEXT_MARGIN; + + const durations: Dictionary = {}; + focusAnnotationData.forEach(d => { + if (d.key !== undefined) { + const duration = (d.end_timestamp || 0) - d.timestamp; + durations[d.key] = duration; + } + }); + + // sort by duration + focusAnnotationData.sort((a, b) => { + if (a.key === undefined || b.key === undefined) { + return 0; + } + return durations[b.key] - durations[a.key]; + }); + + const levelHeight = ANNOTATION_LEVEL_HEIGHT; + const levels = getAnnotationLevels(focusAnnotationData); + + const annotations = focusChart + .select('.mlAnnotations') + .selectAll('g.mlAnnotation') + .data(focusAnnotationData || [], (d: Annotation) => d._id || ''); + + annotations + .enter() + .append('g') + .classed('mlAnnotation', true); + + const rects = annotations.selectAll('.mlAnnotationRect').data((d: Annotation) => [d]); + + rects + .enter() + .append('rect') + .attr('rx', ANNOTATION_RECT_BORDER_RADIUS) + .attr('ry', ANNOTATION_RECT_BORDER_RADIUS) + .classed('mlAnnotationRect', true) + .on('mouseover', function(this: object, d: Annotation) { + showFocusChartTooltip(d, this); + }) + .on('mouseout', () => mlChartTooltipService.hide()) + .on('click', (d: Annotation) => { + showFlyout(d); + }); + + rects + .attr('x', (d: Annotation) => { + const date = moment(d.timestamp); + return focusXScale(date); + }) + .attr('y', (d: Annotation) => { + const level = d.key !== undefined ? levels[d.key] : ANNOTATION_DEFAULT_LEVEL; + return focusZoomPanelHeight + 1 + upperRectMargin + level * levelHeight; + }) + .attr('height', (d: Annotation) => { + const level = d.key !== undefined ? levels[d.key] : ANNOTATION_DEFAULT_LEVEL; + return focusChartHeight - 2 - upperRectMargin - level * levelHeight; + }) + .attr('width', (d: Annotation) => { + const s = focusXScale(moment(d.timestamp)) + 1; + const e = + typeof d.end_timestamp !== 'undefined' + ? focusXScale(moment(d.end_timestamp)) - 1 + : s + ANNOTATION_MIN_WIDTH; + const width = Math.max(ANNOTATION_MIN_WIDTH, e - s); + return width; + }); + + rects.exit().remove(); + + const textRects = annotations.selectAll('.mlAnnotationTextRect').data(d => [d]); + const texts = annotations.selectAll('.mlAnnotationText').data(d => [d]); + + textRects + .enter() + .append('rect') + .classed('mlAnnotationTextRect', true) + .attr('rx', ANNOTATION_RECT_BORDER_RADIUS) + .attr('ry', ANNOTATION_RECT_BORDER_RADIUS); + + texts + .enter() + .append('text') + .classed('mlAnnotationText', true); + + texts + .attr('x', (d: Annotation) => { + const date = moment(d.timestamp); + const x = focusXScale(date); + return x + 17; + }) + .attr('y', (d: Annotation) => { + const level = d.key !== undefined ? levels[d.key] : ANNOTATION_DEFAULT_LEVEL; + return ( + focusZoomPanelHeight + + upperTextMargin + + ANNOTATION_TEXT_VERTICAL_OFFSET + + level * levelHeight + ); + }) + .text((d: Annotation) => d.key as any); + + textRects + .attr('x', (d: Annotation) => { + const date = moment(d.timestamp); + const x = focusXScale(date); + return x + 5; + }) + .attr('y', (d: Annotation) => { + const level = d.key !== undefined ? levels[d.key] : ANNOTATION_DEFAULT_LEVEL; + return ( + focusZoomPanelHeight + + upperTextMargin + + ANNOTATION_TEXT_RECT_VERTICAL_OFFSET + + level * levelHeight + ); + }); + + textRects.exit().remove(); + texts.exit().remove(); + + annotations.classed('mlAnnotationHidden', !showAnnotations); + annotations.exit().remove(); +} + +export function highlightFocusChartAnnotation(annotation: Annotation) { + const annotations = d3.selectAll('.mlAnnotation'); + + annotations.each(function(d) { + // @ts-ignore + const element = d3.select(this); + + if (d._id === annotation._id) { + element.selectAll('.mlAnnotationRect').classed('mlAnnotationRect-isHighlight', true); + } else { + element.selectAll('.mlAnnotationTextRect').classed('mlAnnotationTextRect-isBlur', true); + element.selectAll('.mlAnnotationText').classed('mlAnnotationText-isBlur', true); + element.selectAll('.mlAnnotationRect').classed('mlAnnotationRect-isBlur', true); + } + }); +} + +export function unhighlightFocusChartAnnotation() { + const annotations = d3.selectAll('.mlAnnotation'); + + annotations.each(function() { + // @ts-ignore + const element = d3.select(this); + + element.selectAll('.mlAnnotationTextRect').classed('mlAnnotationTextRect-isBlur', false); + element + .selectAll('.mlAnnotationRect') + .classed('mlAnnotationRect-isHighlight', false) + .classed('mlAnnotationRect-isBlur', false); + element.selectAll('.mlAnnotationText').classed('mlAnnotationText-isBlur', false); + }); +} diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/timeseries_chart_directive.js b/x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart_directive.js similarity index 81% rename from x-pack/plugins/ml/public/timeseriesexplorer/timeseries_chart_directive.js rename to x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart_directive.js index 3bd9c1d107e7..a3b51b21b4a5 100644 --- a/x-pack/plugins/ml/public/timeseriesexplorer/timeseries_chart_directive.js +++ b/x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart_directive.js @@ -18,12 +18,18 @@ import { TimeseriesChart } from './timeseries_chart'; import angular from 'angular'; import { timefilter } from 'ui/timefilter'; +import { toastNotifications } from 'ui/notify'; import { ResizeChecker } from 'ui/resize_checker'; import { uiModules } from 'ui/modules'; 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 () { function link(scope, element) { @@ -39,23 +45,29 @@ module.directive('mlTimeseriesChart', function () { svgWidth = Math.max(angular.element('.results-container').width(), 0); const props = { + indexAnnotation: ml.annotations.indexAnnotation, autoZoomDuration: scope.autoZoomDuration, contextAggregationInterval: scope.contextAggregationInterval, contextChartData: scope.contextChartData, contextForecastData: scope.contextForecastData, contextChartSelected: contextChartSelected, + deleteAnnotation: ml.annotations.deleteAnnotation, detectorIndex: scope.detectorIndex, + focusAnnotationData: scope.focusAnnotationData, focusChartData: scope.focusChartData, focusForecastData: scope.focusForecastData, focusAggregationInterval: scope.focusAggregationInterval, modelPlotEnabled: scope.modelPlotEnabled, + refresh: scope.refresh, renderFocusChartOnly, selectedJob: scope.selectedJob, + showAnnotations: scope.showAnnotations, showForecast: scope.showForecast, showModelBounds: scope.showModelBounds, svgWidth, swimlaneData: scope.swimlaneData, timefilter, + toastNotifications, zoomFrom: scope.zoomFrom, zoomTo: scope.zoomTo }; @@ -79,6 +91,10 @@ module.directive('mlTimeseriesChart', function () { scope.$watchCollection('focusForecastData', renderFocusChart); scope.$watchCollection('focusChartData', renderFocusChart); scope.$watchGroup(['showModelBounds', 'showForecast'], renderFocusChart); + if (mlAnnotationsEnabled) { + scope.$watchCollection('focusAnnotationData', renderFocusChart); + scope.$watch('showAnnotations', renderFocusChart); + } // Redraw the charts when the container is resize. const resizeChecker = new ResizeChecker(angular.element('.ml-timeseries-chart')); @@ -90,7 +106,7 @@ module.directive('mlTimeseriesChart', function () { element.on('$destroy', () => { resizeChecker.destroy(); - // unmountComponentAtNode() needs to be called so mlAnomaliesTableService listeners within + // unmountComponentAtNode() needs to be called so mlTableService listeners within // the TimeseriesChart component get unwatched properly. ReactDOM.unmountComponentAtNode(element[0]); scope.$destroy(); @@ -108,12 +124,15 @@ module.directive('mlTimeseriesChart', function () { contextChartAnomalyData: '=', focusChartData: '=', swimlaneData: '=', + focusAnnotationData: '=', focusForecastData: '=', contextAggregationInterval: '=', focusAggregationInterval: '=', zoomFrom: '=', zoomTo: '=', autoZoomDuration: '=', + refresh: '=', + showAnnotations: '=', showModelBounds: '=', showForecast: '=' }, diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/index.js b/x-pack/plugins/ml/public/timeseriesexplorer/index.js index d291275bc914..a5e7c4a66d44 100644 --- a/x-pack/plugins/ml/public/timeseriesexplorer/index.js +++ b/x-pack/plugins/ml/public/timeseriesexplorer/index.js @@ -4,11 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ - - -import './forecasting_modal'; +import './components/forecasting_modal'; +import './components/timeseries_chart/timeseries_chart_directive'; import './timeseriesexplorer_controller.js'; import './timeseries_search_service.js'; -import './timeseries_chart_directive'; import 'plugins/ml/components/job_select_list'; import 'plugins/ml/components/chart_tooltip'; diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.html b/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.html index f0d243f8f916..a8d53b4e3092 100644 --- a/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.html +++ b/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.html @@ -86,6 +86,15 @@
+
+ + +
+
- + auto-zoom-duration="autoZoomDuration" + refresh="refresh">
+
+ + Annotations + + + + +

+
+ Anomalies diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_controller.js b/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_controller.js index 44b7d59fe504..8c67fe2e0816 100644 --- a/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_controller.js +++ b/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_controller.js @@ -15,6 +15,7 @@ import _ from 'lodash'; import moment from 'moment-timezone'; +import 'plugins/ml/components/annotations_table'; import 'plugins/ml/components/anomalies_table'; import 'plugins/ml/components/controls'; @@ -49,6 +50,14 @@ import { JobSelectServiceProvider } from 'plugins/ml/components/job_select_list/ import { mlForecastService } from 'plugins/ml/services/forecast_service'; import { mlTimeSeriesSearchService } from 'plugins/ml/timeseriesexplorer/timeseries_search_service'; import { initPromise } from 'plugins/ml/util/promise'; +import { + ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE, + ANOMALIES_TABLE_DEFAULT_QUERY_SIZE +} from '../../common/constants/search'; + + +import chrome from 'ui/chrome'; +const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false); uiRoutes .when('/timeseriesexplorer/?', { @@ -67,7 +76,6 @@ const module = uiModules.get('apps/ml'); module.controller('MlTimeSeriesExplorerController', function ( $scope, - $route, $timeout, Private, AppState, @@ -80,7 +88,6 @@ module.controller('MlTimeSeriesExplorerController', function ( timefilter.enableAutoRefreshSelector(); const CHARTS_POINT_TARGET = 500; - const ANOMALIES_MAX_RESULTS = 500; const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket. const TimeBuckets = Private(IntervalHelperProvider); const mlJobSelectService = Private(JobSelectServiceProvider); @@ -96,9 +103,13 @@ module.controller('MlTimeSeriesExplorerController', function ( $scope.modelPlotEnabled = false; $scope.showModelBounds = true; // Toggles display of model bounds in the focus chart $scope.showModelBoundsCheckbox = false; + $scope.showAnnotations = mlAnnotationsEnabled;// Toggles display of annotations in the focus chart + $scope.showAnnotationsCheckbox = mlAnnotationsEnabled; $scope.showForecast = true; // Toggles display of forecast data in the focus chart $scope.showForecastCheckbox = false; + $scope.focusAnnotationData = []; + // Pass the timezone to the server for use when aggregating anomalies (by day / hour) for the table. const tzConfig = config.get('dateFormat:tz'); const dateFormatTz = (tzConfig !== 'Browser') ? tzConfig : moment.tz.guess(); @@ -107,7 +118,6 @@ module.controller('MlTimeSeriesExplorerController', function ( canForecastJob: checkPermission('canForecastJob') }; - $scope.initializeVis = function () { // Initialize the AppState in which to store the zoom range. const stateDefaults = { @@ -349,7 +359,7 @@ module.controller('MlTimeSeriesExplorerController', function ( $scope.refreshFocusData = function (fromDate, toDate) { // Counter to keep track of the queries to populate the chart. - let awaitingCount = 3; + let awaitingCount = 4; // This object is used to store the results of individual remote requests // before we transform it into the final data and apply it to $scope. Otherwise @@ -419,7 +429,7 @@ module.controller('MlTimeSeriesExplorerController', function ( 0, searchBounds.min.valueOf(), searchBounds.max.valueOf(), - ANOMALIES_MAX_RESULTS + ANOMALIES_TABLE_DEFAULT_QUERY_SIZE ).then((resp) => { // Sort in descending time order before storing in scope. refreshFocusData.anomalyRecords = _.chain(resp.records) @@ -445,6 +455,33 @@ module.controller('MlTimeSeriesExplorerController', function ( console.log('Time series explorer - error getting scheduled events from elasticsearch:', resp); }); + // Query 4 - load any annotations for the selected job. + if (mlAnnotationsEnabled) { + ml.annotations.getAnnotations({ + jobIds: [$scope.selectedJob.job_id], + earliestMs: searchBounds.min.valueOf(), + latestMs: searchBounds.max.valueOf(), + maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE + }).then((resp) => { + 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 + refreshFocusData.focusAnnotationData = []; + finish(); + }); + } else { + finish(); + } + // Plus query for forecast data if there is a forecastId stored in the appState. const forecastId = _.get($scope, 'appState.mlTimeSeriesExplorer.forecastId'); if (forecastId !== undefined) { @@ -558,6 +595,14 @@ module.controller('MlTimeSeriesExplorerController', function ( }, 0); }; + if (mlAnnotationsEnabled) { + $scope.toggleShowAnnotations = function () { + $timeout(() => { + $scope.showAnnotations = !$scope.showAnnotations; + }, 0); + }; + } + $scope.toggleShowForecast = function () { $timeout(() => { $scope.showForecast = !$scope.showForecast; @@ -697,7 +742,7 @@ module.controller('MlTimeSeriesExplorerController', function ( earliestMs, latestMs, dateFormatTz, - ANOMALIES_MAX_RESULTS + ANOMALIES_TABLE_DEFAULT_QUERY_SIZE ).then((resp) => { const anomalies = resp.anomalies; const detectorsByJob = mlJobService.detectorsByJob; @@ -782,7 +827,7 @@ module.controller('MlTimeSeriesExplorerController', function ( 0, bounds.min.valueOf(), bounds.max.valueOf(), - ANOMALIES_MAX_RESULTS) + ANOMALIES_TABLE_DEFAULT_QUERY_SIZE) .then((resp) => { if (resp.records && resp.records.length > 0) { const firstRec = resp.records[0]; diff --git a/x-pack/plugins/ml/server/client/elasticsearch_ml.js b/x-pack/plugins/ml/server/client/elasticsearch_ml.js index 496111646ca4..24f9432eb220 100644 --- a/x-pack/plugins/ml/server/client/elasticsearch_ml.js +++ b/x-pack/plugins/ml/server/client/elasticsearch_ml.js @@ -613,4 +613,3 @@ export const elasticsearchJsPlugin = (Client, config, components) => { }); }; - diff --git a/x-pack/plugins/ml/server/lib/check_annotations/index.js b/x-pack/plugins/ml/server/lib/check_annotations/index.js new file mode 100644 index 000000000000..f088adc5c7ca --- /dev/null +++ b/x-pack/plugins/ml/server/lib/check_annotations/index.js @@ -0,0 +1,50 @@ +/* + * 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 { callWithInternalUserFactory } from '../../client/call_with_internal_user_factory'; + +import { + ML_ANNOTATIONS_INDEX_ALIAS_READ, + ML_ANNOTATIONS_INDEX_ALIAS_WRITE, + ML_ANNOTATIONS_INDEX_PATTERN +} from '../../../common/constants/index_patterns'; + +import { FEATURE_ANNOTATIONS_ENABLED } from '../../../common/constants/feature_flags'; + +// Annotations Feature is available if: +// - FEATURE_ANNOTATIONS_ENABLED is set to `true` +// - ML_ANNOTATIONS_INDEX_PATTERN index is present +// - ML_ANNOTATIONS_INDEX_ALIAS_READ alias is present +// - ML_ANNOTATIONS_INDEX_ALIAS_WRITE alias is present +export async function isAnnotationsFeatureAvailable(server) { + if (!FEATURE_ANNOTATIONS_ENABLED) return false; + + try { + const callWithInternalUser = callWithInternalUserFactory(server); + + const indexParams = { index: ML_ANNOTATIONS_INDEX_PATTERN }; + + const annotationsIndexExists = await callWithInternalUser('indices.exists', indexParams); + if (!annotationsIndexExists) return false; + + const annotationsReadAliasExists = await callWithInternalUser('indices.existsAlias', { + name: ML_ANNOTATIONS_INDEX_ALIAS_READ + }); + + if (!annotationsReadAliasExists) return false; + + const annotationsWriteAliasExists = await callWithInternalUser('indices.existsAlias', { + name: ML_ANNOTATIONS_INDEX_ALIAS_WRITE + }); + if (!annotationsWriteAliasExists) return false; + + } catch (err) { + server.log(['info', 'ml'], 'Disabling ML annotations feature because the index/alias integrity check failed.'); + return false; + } + + return true; +} diff --git a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts new file mode 100644 index 000000000000..c45b5b991094 --- /dev/null +++ b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts @@ -0,0 +1,245 @@ +/* + * 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 Boom from 'boom'; +import _ from 'lodash'; + +import { ANNOTATION_TYPE } from '../../../common/constants/annotations'; +import { + ML_ANNOTATIONS_INDEX_ALIAS_READ, + ML_ANNOTATIONS_INDEX_ALIAS_WRITE, +} from '../../../common/constants/index_patterns'; + +import { + Annotation, + Annotations, + isAnnotation, + isAnnotations, +} from '../../../common/types/annotations'; + +interface EsResult { + _source: object; + _id: string; +} + +interface IndexAnnotationArgs { + jobIds: string[]; + earliestMs: Date; + latestMs: Date; + maxAnnotations: number; +} + +interface GetParams { + index: string; + size: number; + body: object; +} + +interface GetResponse { + success: true; + annotations: { + [key: string]: Annotations; + }; +} + +interface IndexParams { + index: string; + type: string; + body: Annotation; + refresh?: string; + id?: string; +} + +interface DeleteParams { + index: string; + type: string; + refresh?: string; + id: string; +} + +export function annotationProvider( + callWithRequest: (action: string, params: IndexParams | DeleteParams | GetParams) => Promise +) { + async function indexAnnotation(annotation: Annotation) { + if (isAnnotation(annotation) === false) { + return Promise.reject(new Error('invalid annotation format')); + } + + if (annotation.create_time === undefined) { + annotation.create_time = new Date().getTime(); + annotation.create_username = ''; + } + + annotation.modified_time = new Date().getTime(); + annotation.modified_username = ''; + + const params: IndexParams = { + index: ML_ANNOTATIONS_INDEX_ALIAS_WRITE, + type: 'annotation', + body: annotation, + refresh: 'wait_for', + }; + + if (typeof annotation._id !== 'undefined') { + params.id = annotation._id; + delete params.body._id; + } + + return await callWithRequest('index', params); + } + + async function getAnnotations({ + jobIds, + earliestMs, + latestMs, + maxAnnotations, + }: IndexAnnotationArgs) { + const obj: GetResponse = { + success: true, + annotations: {}, + }; + + // Build the criteria to use in the bool filter part of the request. + // Adds criteria for the time range plus any specified job IDs. + // The nested must_not time range filter queries make sure that we fetch: + // - annotations with start and end within the time range + // - annotations that either start or end within the time range + // - annotations that start before and end after the given time range + // - but skip annotation that are completely outside the time range + // (the ones that start and end before or after the time range) + const boolCriteria: object[] = [ + { + bool: { + must_not: [ + { + bool: { + filter: [ + { + range: { + timestamp: { + lte: earliestMs, + format: 'epoch_millis', + }, + }, + }, + { + range: { + end_timestamp: { + lte: earliestMs, + format: 'epoch_millis', + }, + }, + }, + ], + }, + }, + { + bool: { + filter: [ + { + range: { + timestamp: { + gte: latestMs, + format: 'epoch_millis', + }, + }, + }, + { + range: { + end_timestamp: { + gte: latestMs, + format: 'epoch_millis', + }, + }, + }, + ], + }, + }, + ], + }, + }, + { + exists: { field: 'annotation' }, + }, + ]; + + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i: number) => { + jobIdFilterStr += `${i! > 0 ? ' OR ' : ''}job_id:${jobId}`; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, + }, + }); + } + + const params: GetParams = { + index: ML_ANNOTATIONS_INDEX_ALIAS_READ, + size: maxAnnotations, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: `type:${ANNOTATION_TYPE.ANNOTATION}`, + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], + }, + }, + }, + }; + + 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 (isAnnotations(docs) === false) { + throw Boom.badRequest(`Annotations didn't pass integrity check.`); + } + + 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; + } + + async function deleteAnnotation(id: string) { + const param: DeleteParams = { + index: ML_ANNOTATIONS_INDEX_ALIAS_WRITE, + type: 'annotation', + id, + refresh: 'wait_for', + }; + + return await callWithRequest('delete', param); + } + + return { + getAnnotations, + indexAnnotation, + deleteAnnotation, + }; +} diff --git a/x-pack/plugins/ml/server/models/annotation_service/index.js b/x-pack/plugins/ml/server/models/annotation_service/index.js new file mode 100644 index 000000000000..5c517f828934 --- /dev/null +++ b/x-pack/plugins/ml/server/models/annotation_service/index.js @@ -0,0 +1,14 @@ +/* + * 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 { annotationProvider } from './annotation'; + +export function annotationServiceProvider(callWithRequest) { + return { + ...annotationProvider(callWithRequest) + }; +} diff --git a/x-pack/plugins/ml/server/models/results_service/results_service.js b/x-pack/plugins/ml/server/models/results_service/results_service.js index 3c33979a9ad1..12e8959062a8 100644 --- a/x-pack/plugins/ml/server/models/results_service/results_service.js +++ b/x-pack/plugins/ml/server/models/results_service/results_service.js @@ -11,12 +11,12 @@ import moment from 'moment'; import { buildAnomalyTableItems } from './build_anomaly_table_items'; import { ML_RESULTS_INDEX_PATTERN } from '../../../common/constants/index_patterns'; +import { ANOMALIES_TABLE_DEFAULT_QUERY_SIZE } from '../../../common/constants/search'; // Service for carrying out Elasticsearch queries to obtain data for the // ML Results dashboards. -const DEFAULT_QUERY_SIZE = 500; const DEFAULT_MAX_EXAMPLES = 500; export function resultsServiceProvider(callWithRequest) { @@ -36,7 +36,7 @@ export function resultsServiceProvider(callWithRequest) { earliestMs, latestMs, dateFormatTz, - maxRecords = DEFAULT_QUERY_SIZE, + maxRecords = ANOMALIES_TABLE_DEFAULT_QUERY_SIZE, maxExamples = DEFAULT_MAX_EXAMPLES) { // Build the query to return the matching anomaly record results. @@ -203,7 +203,7 @@ export function resultsServiceProvider(callWithRequest) { const resp = await callWithRequest('search', { index: ML_RESULTS_INDEX_PATTERN, rest_total_hits_as_int: true, - size: DEFAULT_QUERY_SIZE, // Matches size of records in anomaly summary table. + size: ANOMALIES_TABLE_DEFAULT_QUERY_SIZE, // Matches size of records in anomaly summary table. body: { query: { bool: { diff --git a/x-pack/plugins/ml/server/routes/annotations.js b/x-pack/plugins/ml/server/routes/annotations.js new file mode 100644 index 000000000000..f9f319957681 --- /dev/null +++ b/x-pack/plugins/ml/server/routes/annotations.js @@ -0,0 +1,57 @@ +/* + * 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 { callWithRequestFactory } from '../client/call_with_request_factory'; +import { wrapError } from '../client/errors'; +import { annotationServiceProvider } from '../models/annotation_service'; + +export function annotationRoutes(server, commonRouteConfig) { + server.route({ + method: 'POST', + path: '/api/ml/annotations', + handler(request) { + const callWithRequest = callWithRequestFactory(server, request); + const { getAnnotations } = annotationServiceProvider(callWithRequest); + return getAnnotations(request.payload) + .catch(resp => wrapError(resp)); + }, + config: { + ...commonRouteConfig + } + }); + + server.route({ + method: 'PUT', + path: '/api/ml/annotations/index', + handler(request) { + const callWithRequest = callWithRequestFactory(server, request); + const { indexAnnotation } = annotationServiceProvider(callWithRequest); + return indexAnnotation(request.payload) + .catch(resp => wrapError(resp)); + }, + config: { + ...commonRouteConfig + } + }); + + server.route({ + method: 'DELETE', + path: '/api/ml/annotations/delete/{annotationId}', + handler(request) { + const callWithRequest = callWithRequestFactory(server, request); + const annotationId = request.params.annotationId; + const { deleteAnnotation } = annotationServiceProvider(callWithRequest); + return deleteAnnotation(annotationId) + .catch(resp => wrapError(resp)); + }, + config: { + ...commonRouteConfig + } + }); + +} diff --git a/x-pack/plugins/ml/tsconfig.json b/x-pack/plugins/ml/tsconfig.json new file mode 100644 index 000000000000..4082f16a5d91 --- /dev/null +++ b/x-pack/plugins/ml/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +}