mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[ML] User Annotations (#26034)
Allows users to add/edit/delete annotations in the Single Series Viewer.
This commit is contained in:
parent
7866544e62
commit
26c77eb25e
56 changed files with 2000 additions and 70 deletions
10
x-pack/plugins/ml/common/constants/annotations.ts
Normal file
10
x-pack/plugins/ml/common/constants/annotations.ts
Normal 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',
|
||||
}
|
14
x-pack/plugins/ml/common/constants/feature_flags.ts
Normal file
14
x-pack/plugins/ml/common/constants/feature_flags.ts
Normal 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;
|
12
x-pack/plugins/ml/common/constants/index_patterns.ts
Normal file
12
x-pack/plugins/ml/common/constants/index_patterns.ts
Normal 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';
|
8
x-pack/plugins/ml/common/constants/search.ts
Normal file
8
x-pack/plugins/ml/common/constants/search.ts
Normal 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;
|
94
x-pack/plugins/ml/common/types/annotations.ts
Normal file
94
x-pack/plugins/ml/common/types/annotations.ts
Normal 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));
|
||||
}
|
9
x-pack/plugins/ml/common/types/common.ts
Normal file
9
x-pack/plugins/ml/common/types/common.ts
Normal 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;
|
||||
}
|
53
x-pack/plugins/ml/common/types/jobs.ts
Normal file
53
x-pack/plugins/ml/common/types/jobs.ts
Normal 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));
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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 };
|
|
@ -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
|
||||
};
|
||||
});
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -6,4 +6,3 @@
|
|||
|
||||
|
||||
import './anomalies_table_directive';
|
||||
import './anomalies_table_service.js';
|
|
@ -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>
|
||||
|
|
|
@ -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';
|
||||
|
@ -47,6 +48,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/?', {
|
||||
|
@ -84,6 +94,7 @@ module.controller('MlExplorerController', function (
|
|||
mlSelectIntervalService,
|
||||
mlSelectSeverityService) {
|
||||
|
||||
$scope.annotationsData = [];
|
||||
$scope.anomalyChartRecords = [];
|
||||
$scope.timeFieldName = 'timestamp';
|
||||
$scope.loading = true;
|
||||
|
@ -940,6 +951,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) ?
|
||||
|
@ -956,7 +1003,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;
|
||||
|
@ -1009,9 +1056,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));
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
@import 'job_details';
|
||||
@import 'forecasts_table/index';
|
||||
@import 'forecasts_table/index';
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
});
|
||||
}
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
|
@ -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;
|
||||
|
|
|
@ -1,2 +1,4 @@
|
|||
@import 'components/annotation_description_list/index';
|
||||
@import 'components/forecasting_modal/index';
|
||||
@import 'timeseriesexplorer';
|
||||
@import 'forecasting_modal/index';
|
||||
@import 'timeseriesexplorer_annotations';
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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';
|
|
@ -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';
|
|
@ -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';
|
||||
|
@ -171,4 +171,3 @@ RunControls.propType = {
|
|||
jobOpeningState: PropTypes.number,
|
||||
jobClosingState: PropTypes.number,
|
||||
};
|
||||
|
25
x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart.d.ts
vendored
Normal file
25
x-pack/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart.d.ts
vendored
Normal 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>;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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: {}
|
||||
}));
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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: '='
|
||||
},
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
@ -50,6 +51,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/?', {
|
||||
|
@ -69,7 +78,6 @@ const module = uiModules.get('apps/ml');
|
|||
|
||||
module.controller('MlTimeSeriesExplorerController', function (
|
||||
$scope,
|
||||
$route,
|
||||
$timeout,
|
||||
Private,
|
||||
AppState,
|
||||
|
@ -82,7 +90,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);
|
||||
|
@ -98,9 +105,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();
|
||||
|
@ -109,7 +120,6 @@ module.controller('MlTimeSeriesExplorerController', function (
|
|||
canForecastJob: checkPermission('canForecastJob')
|
||||
};
|
||||
|
||||
|
||||
$scope.initializeVis = function () {
|
||||
// Initialize the AppState in which to store the zoom range.
|
||||
const stateDefaults = {
|
||||
|
@ -351,7 +361,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
|
||||
|
@ -421,7 +431,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)
|
||||
|
@ -447,6 +457,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) {
|
||||
|
@ -560,6 +597,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;
|
||||
|
@ -699,7 +744,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;
|
||||
|
@ -784,7 +829,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];
|
||||
|
|
|
@ -613,4 +613,3 @@ export const elasticsearchJsPlugin = (Client, config, components) => {
|
|||
});
|
||||
|
||||
};
|
||||
|
||||
|
|
50
x-pack/plugins/ml/server/lib/check_annotations/index.js
Normal file
50
x-pack/plugins/ml/server/lib/check_annotations/index.js
Normal 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;
|
||||
}
|
245
x-pack/plugins/ml/server/models/annotation_service/annotation.ts
Normal file
245
x-pack/plugins/ml/server/models/annotation_service/annotation.ts
Normal 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,
|
||||
};
|
||||
}
|
14
x-pack/plugins/ml/server/models/annotation_service/index.js
Normal file
14
x-pack/plugins/ml/server/models/annotation_service/index.js
Normal 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)
|
||||
};
|
||||
}
|
|
@ -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: {
|
||||
|
|
57
x-pack/plugins/ml/server/routes/annotations.js
Normal file
57
x-pack/plugins/ml/server/routes/annotations.js
Normal 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
|
||||
}
|
||||
});
|
||||
|
||||
}
|
3
x-pack/plugins/ml/tsconfig.json
Normal file
3
x-pack/plugins/ml/tsconfig.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json"
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue