[ML] User Annotations (#26034) (#27199)

Allows users to add/edit/delete annotations in the Single Series Viewer.
This commit is contained in:
Walter Rafelsberger 2018-12-14 14:51:21 +01:00 committed by GitHub
parent 80bfb15965
commit 9c8d75c94a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 2000 additions and 69 deletions

View file

@ -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',
}

View file

@ -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;

View file

@ -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';

View file

@ -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;

View file

@ -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<Annotation> {}
export function isAnnotations(arg: any): arg is Annotations {
if (Array.isArray(arg) === false) {
return false;
}
return arg.every((d: Annotation) => isAnnotation(d));
}

View file

@ -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<TValue> {
[id: string]: TValue;
}

View file

@ -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<MlJob> {}
export function isMlJobs(arg: any): arg is MlJobs {
if (Array.isArray(arg) === false) {
return false;
}
return arg.every((d: MlJob) => isMlJob(d));
}

View file

@ -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);

View file

@ -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 (
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false}><EuiLoadingSpinner size="l"/></EuiFlexItem>
</EuiFlexGroup>
);
}
if (this.state.errorMessage !== undefined) {
return (
<EuiCallOut
title={this.state.errorMessage}
color="danger"
iconType="cross"
/>
);
}
}
const annotations = this.props.annotations || this.state.annotations;
if (annotations.length === 0) {
return (
<EuiCallOut
title="No annotations created for this job"
iconType="iInCircle"
>
<p>
To create an annotation,
open the <EuiLink onClick={this.openSingleMetricView}>Single Metric Viewer</EuiLink>
</p>
</EuiCallOut>
);
}
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 (
<EuiBadge color="default">
{key}
</EuiBadge>
);
}
});
}
if (isSingleMetricViewerLinkVisible) {
const openInSingleMetricViewerText = 'Open in Single Metric Viewer';
columns.push({
align: RIGHT_ALIGNMENT,
width: '60px',
name: 'View',
render: (annotation) => (
<EuiToolTip
position="bottom"
content={openInSingleMetricViewerText}
>
<EuiButtonIcon
onClick={() => this.openSingleMetricView(annotation)}
iconType="stats"
aria-label={openInSingleMetricViewerText}
/>
</EuiToolTip>
)
});
}
const getRowProps = (item) => {
return {
onMouseOver: () => this.onMouseOverRow(item),
onMouseLeave: () => this.onMouseLeaveRow()
};
};
return (
<EuiInMemoryTable
className="eui-textOverflowWrap"
compressed={true}
items={annotations}
columns={columns}
pagination={{
pageSizeOptions: [5, 10, 25]
}}
sorting={true}
rowProps={getRowProps}
/>
);
}
}
AnnotationsTable.propTypes = {
annotations: PropTypes.array,
jobs: PropTypes.array,
isSingleMetricViewerLinkVisible: PropTypes.bool,
isNumberBadgeVisible: PropTypes.bool
};
export { AnnotationsTable };

View file

@ -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
};
});

View file

@ -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';

View file

@ -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;
}
};

View file

@ -6,4 +6,3 @@
import './anomalies_table_directive';
import './anomalies_table_service.js';

View file

@ -101,6 +101,20 @@
</div>
<div ng-show="annotationsData.length > 0">
<span class="panel-title euiText">
Annotations
</span>
<ml-annotation-table
annotations="annotationsData"
drill-down="true"
jobs="selectedJobs"
/>
<br /><br />
</div>
<span class="panel-title euiText">
Anomalies
</span>

View file

@ -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));

View file

@ -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';
}

View file

@ -1,2 +1,2 @@
@import 'job_details';
@import 'forecasts_table/index';
@import 'forecasts_table/index';

View file

@ -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: <AnnotationsTable jobs={[job]} drillDown={true} />,
});
}
return (
<div className="tab-contents">
<EuiTabbedContent

View file

@ -0,0 +1,36 @@
/*
* 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 chrome from 'ui/chrome';
import { http } from '../../services/http_service';
const basePath = chrome.addBasePath('/api/ml');
export const annotations = {
getAnnotations(obj) {
return http({
url: `${basePath}/annotations`,
method: 'POST',
data: obj
});
},
indexAnnotation(obj) {
return http({
url: `${basePath}/annotations/index`,
method: 'PUT',
data: obj
});
},
deleteAnnotation(id) {
return http({
url: `${basePath}/annotations/delete/${id}`,
method: 'DELETE'
});
}
};

View file

@ -11,6 +11,7 @@ import chrome from 'ui/chrome';
import { http } from '../../services/http_service';
import { annotations } from './annotations';
import { filters } from './filters';
import { results } from './results';
import { jobs } from './jobs';
@ -419,6 +420,7 @@ export const ml = {
});
},
annotations,
filters,
results,
jobs,

View file

@ -17,7 +17,6 @@ import { ML_RESULTS_INDEX_PATTERN } from '../../common/constants/index_patterns'
import { ml } from '../services/ml_api_service';
// Obtains the maximum bucket anomaly scores by job ID and time.
// Pass an empty array or ['*'] to search over all job IDs.
// Returned response contains a results property, with a key for job

View file

@ -8,17 +8,17 @@
/*
* Service for firing and registering for events in the
* anomalies table component.
* anomalies or annotations table component.
*/
import { listenerFactoryProvider } from '../../factories/listener_factory';
import { listenerFactoryProvider } from '../factories/listener_factory';
class AnomaliesTableService {
class TableService {
constructor() {
const listenerFactory = listenerFactoryProvider();
this.anomalyRecordMouseenter = listenerFactory();
this.anomalyRecordMouseleave = listenerFactory();
this.rowMouseenter = listenerFactory();
this.rowMouseleave = listenerFactory();
}
}
export const mlAnomaliesTableService = new AnomaliesTableService();
export const mlTableService = new TableService();

View file

@ -8,7 +8,7 @@ import ngMock from 'ng_mock';
import expect from 'expect.js';
import sinon from 'sinon';
import { TimeseriesChart } from '../timeseries_chart';
import { TimeseriesChart } from '../components/timeseries_chart/timeseries_chart';
describe('ML - <ml-timeseries-chart>', () => {
let $scope;

View file

@ -1,2 +1,4 @@
@import 'components/annotation_description_list/index';
@import 'components/forecasting_modal/index';
@import 'timeseriesexplorer';
@import 'forecasting_modal/index';
@import 'timeseriesexplorer_annotations';

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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<Props> = ({ 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 (
<EuiDescriptionList
className="ml-annotation-description-list"
type="column"
listItems={listItems}
/>
);
};

View file

@ -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<Props> = ({
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 (
<EuiFlyout onClose={cancelAction} size="s" aria-labelledby="Add annotation">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s">
<h2 id="mlAnnotationFlyoutTitle">{titlePrefix} annotation</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<AnnotationDescriptionList annotation={annotation} />
<EuiSpacer size="m" />
<EuiFormRow label="Annotation text" fullWidth>
<EuiTextArea
fullWidth
isInvalid={annotation.annotation === ''}
onChange={controlFunc}
placeholder="..."
value={annotation.annotation}
/>
</EuiFormRow>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="cross" onClick={cancelAction} flush="left">
Cancel
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{isExistingAnnotation && (
<EuiButtonEmpty color="danger" onClick={deleteActionWrapper}>
Delete
</EuiButtonEmpty>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton fill isDisabled={annotation.annotation === ''} onClick={saveActionWrapper}>
{isExistingAnnotation ? 'Update' : 'Create'}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
};

View file

@ -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;

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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<Props, State> {
closeFlyout: () => {};
showFlyout: (annotation: Annotation) => {};
focusXScale: d3.scale.Ordinal<{}, number>;
}

View file

@ -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 += `<br/><hr/>Scheduled events:<br/>${marker.scheduledEvents.map(mlEscape).join('<br/>')}`;
}
if (mlAnnotationsEnabled && _.has(marker, 'annotation')) {
contents = marker.annotation;
contents += `<br />${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 <div className="ml-timeseries-chart-react" ref={this.setRef.bind(this)} />;
const { annotation, isFlyoutVisible } = this.state;
return (
<React.Fragment>
<div className="ml-timeseries-chart-react" ref={this.setRef.bind(this)} />
{mlAnnotationsEnabled && isFlyoutVisible &&
<AnnotationFlyout
annotation={annotation}
cancelAction={this.closeFlyout}
controlFunc={this.handleAnnotationChange}
deleteAction={this.deleteAnnotation}
saveAction={this.indexAnnotation}
/>
}
</React.Fragment>
);
}
}

View file

@ -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: {}
}));

View file

@ -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<number> = {};
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<number> = {};
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);
});
}

View file

@ -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: '='
},

View file

@ -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';

View file

@ -86,6 +86,15 @@
<label for="toggleShowModelBoundsCheckbox" class="kuiCheckBoxLabel">show model bounds</label>
</div>
<div ng-show="showAnnotationsCheckbox === true">
<input id="toggleAnnotationsCheckbox"
type="checkbox"
class="kuiCheckBox"
ng-click="toggleShowAnnotations()"
ng-checked="showAnnotations === true">
<label for="toggleAnnotationsCheckbox" class="kuiCheckBoxLabel">annotations</label>
</div>
<div ng-show="showForecastCheckbox === true">
<input id="toggleShowForecastCheckbox"
type="checkbox"
@ -98,7 +107,7 @@
<div class="ml-timeseries-chart">
<ml-timeseries-chart style="width: 1200px; height: 400px;"
<ml-timeseries-chart style="width: 1200px; height: 400px;"
selected-job="selectedJob"
detector-index="detectorId"
model-plot-enabled="modelPlotEnabled"
@ -106,18 +115,35 @@
context-forecast-data="contextForecastData"
context-aggregation-interval="contextAggregationInterval"
swimlane-data="swimlaneData"
focus-annotation-data="focusAnnotationData"
focus-chart-data="focusChartData"
focus-forecast-data="focusForecastData"
focus-aggregation-interval="focusAggregationInterval"
show-annotations="showAnnotations"
show-model-bounds="showModelBounds"
show-forecast="showForecast"
zoom-from="zoomFrom"
zoom-to="zoomTo"
auto-zoom-duration="autoZoomDuration">
auto-zoom-duration="autoZoomDuration"
refresh="refresh">
</ml-timeseries-chart>
</div>
<div ng-show="showAnnotations && focusAnnotationData.length > 0">
<span class="panel-title euiText">
Annotations
</span>
<ml-annotation-table
annotations="focusAnnotationData"
drill-down="false"
jobs="[selectedJob]"
/>
<br /><br />
</div>
<span class="panel-title euiText">
Anomalies
</span>

View file

@ -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];

View file

@ -613,4 +613,3 @@ export const elasticsearchJsPlugin = (Client, config, components) => {
});
};

View file

@ -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;
}

View file

@ -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<any>
) {
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 = '<user unknown>';
}
annotation.modified_time = new Date().getTime();
annotation.modified_username = '<user unknown>';
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,
};
}

View file

@ -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)
};
}

View file

@ -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: {

View file

@ -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
}
});
}

View file

@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}