mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[ML] User Annotations V2 (#29448)
- Fixes a bug where it's not possible to start dragging to create an annotation over a chart tick element. - Adds an option to edit annotations via the annotations table.
This commit is contained in:
parent
fd061abd4a
commit
3db812b08e
40 changed files with 1049 additions and 698 deletions
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import mockAnnotations from '../../../components/annotations_table/__mocks__/mock_annotations.json';
|
||||
import mockAnnotations from '../annotations_table/__mocks__/mock_annotations.json';
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
import React from 'react';
|
|
@ -0,0 +1,109 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AnnotationFlyout Initialization. 1`] = `
|
||||
<InjectIntl(AnnotationFlyoutIntl)
|
||||
annotation={null}
|
||||
intl={
|
||||
Object {
|
||||
"defaultFormats": Object {},
|
||||
"defaultLocale": "en",
|
||||
"formatDate": [Function],
|
||||
"formatHTMLMessage": [Function],
|
||||
"formatMessage": [Function],
|
||||
"formatNumber": [Function],
|
||||
"formatPlural": [Function],
|
||||
"formatRelative": [Function],
|
||||
"formatTime": [Function],
|
||||
"formats": Object {
|
||||
"date": Object {
|
||||
"full": Object {
|
||||
"day": "numeric",
|
||||
"month": "long",
|
||||
"weekday": "long",
|
||||
"year": "numeric",
|
||||
},
|
||||
"long": Object {
|
||||
"day": "numeric",
|
||||
"month": "long",
|
||||
"year": "numeric",
|
||||
},
|
||||
"medium": Object {
|
||||
"day": "numeric",
|
||||
"month": "short",
|
||||
"year": "numeric",
|
||||
},
|
||||
"short": Object {
|
||||
"day": "numeric",
|
||||
"month": "numeric",
|
||||
"year": "2-digit",
|
||||
},
|
||||
},
|
||||
"number": Object {
|
||||
"currency": Object {
|
||||
"style": "currency",
|
||||
},
|
||||
"percent": Object {
|
||||
"style": "percent",
|
||||
},
|
||||
},
|
||||
"relative": Object {
|
||||
"days": Object {
|
||||
"units": "day",
|
||||
},
|
||||
"hours": Object {
|
||||
"units": "hour",
|
||||
},
|
||||
"minutes": Object {
|
||||
"units": "minute",
|
||||
},
|
||||
"months": Object {
|
||||
"units": "month",
|
||||
},
|
||||
"seconds": Object {
|
||||
"units": "second",
|
||||
},
|
||||
"years": Object {
|
||||
"units": "year",
|
||||
},
|
||||
},
|
||||
"time": Object {
|
||||
"full": Object {
|
||||
"hour": "numeric",
|
||||
"minute": "numeric",
|
||||
"second": "numeric",
|
||||
"timeZoneName": "short",
|
||||
},
|
||||
"long": Object {
|
||||
"hour": "numeric",
|
||||
"minute": "numeric",
|
||||
"second": "numeric",
|
||||
"timeZoneName": "short",
|
||||
},
|
||||
"medium": Object {
|
||||
"hour": "numeric",
|
||||
"minute": "numeric",
|
||||
"second": "numeric",
|
||||
},
|
||||
"short": Object {
|
||||
"hour": "numeric",
|
||||
"minute": "numeric",
|
||||
},
|
||||
},
|
||||
},
|
||||
"formatters": Object {
|
||||
"getDateTimeFormat": [Function],
|
||||
"getMessageFormat": [Function],
|
||||
"getNumberFormat": [Function],
|
||||
"getPluralFormat": [Function],
|
||||
"getRelativeFormat": [Function],
|
||||
},
|
||||
"locale": "en",
|
||||
"messages": Object {},
|
||||
"now": [Function],
|
||||
"onError": [Function],
|
||||
"textComponent": Symbol(react.fragment),
|
||||
"timeZone": null,
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* 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 { AnnotationFlyout } from './index';
|
||||
|
||||
import 'angular';
|
||||
|
||||
import { uiModules } from 'ui/modules';
|
||||
const module = uiModules.get('apps/ml');
|
||||
|
||||
import { I18nProvider } from '@kbn/i18n/react';
|
||||
|
||||
module.directive('mlAnnotationFlyout', function () {
|
||||
|
||||
function link(scope, element) {
|
||||
ReactDOM.render(
|
||||
<I18nProvider>
|
||||
{React.createElement(AnnotationFlyout)}
|
||||
</I18nProvider>,
|
||||
element[0]
|
||||
);
|
||||
|
||||
element.on('$destroy', () => {
|
||||
ReactDOM.unmountComponentAtNode(element[0]);
|
||||
scope.$destroy();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
scope: false,
|
||||
link: link
|
||||
};
|
||||
});
|
|
@ -4,8 +4,6 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import mockAnnotations from '../../../components/annotations_table/__mocks__/mock_annotations.json';
|
||||
|
||||
import React from 'react';
|
||||
import { shallowWithIntl } from 'test_utils/enzyme_helpers';
|
||||
|
||||
|
@ -13,15 +11,7 @@ import { AnnotationFlyout } from './index';
|
|||
|
||||
describe('AnnotationFlyout', () => {
|
||||
test('Initialization.', () => {
|
||||
const props = {
|
||||
annotation: mockAnnotations[0],
|
||||
cancelAction: jest.fn(),
|
||||
controlFunc: jest.fn(),
|
||||
deleteAction: jest.fn(),
|
||||
saveAction: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = shallowWithIntl(<AnnotationFlyout {...props} />);
|
||||
const wrapper = shallowWithIntl(<AnnotationFlyout />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,287 @@
|
|||
/*
|
||||
* 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, { Component, ComponentType, Fragment, ReactNode } from 'react';
|
||||
import * as Rx from 'rxjs';
|
||||
|
||||
import {
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFlyout,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutFooter,
|
||||
EuiFlyoutHeader,
|
||||
EuiFormRow,
|
||||
EuiSpacer,
|
||||
EuiTextArea,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import {
|
||||
annotation$,
|
||||
annotationsRefresh$,
|
||||
AnnotationState,
|
||||
} from '../../../services/annotations_service';
|
||||
import { injectObservablesAsProps } from '../../../util/observable_utils';
|
||||
import { AnnotationDescriptionList } from '../annotation_description_list';
|
||||
import { DeleteAnnotationModal } from '../delete_annotation_modal';
|
||||
|
||||
import { CommonProps } from '@elastic/eui';
|
||||
import { FormattedMessage, injectI18n } from '@kbn/i18n/react';
|
||||
import { InjectedIntlProps } from 'react-intl';
|
||||
import { toastNotifications } from 'ui/notify';
|
||||
|
||||
import { ml } from '../../../services/ml_api_service';
|
||||
|
||||
interface Props {
|
||||
annotation: AnnotationState;
|
||||
}
|
||||
|
||||
interface State {
|
||||
isDeleteModalVisible: boolean;
|
||||
}
|
||||
|
||||
class AnnotationFlyoutIntl extends Component<CommonProps & Props & InjectedIntlProps> {
|
||||
public state: State = {
|
||||
isDeleteModalVisible: false,
|
||||
};
|
||||
|
||||
public annotationSub: Rx.Subscription | null = null;
|
||||
|
||||
public annotationTextChangeHandler = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (this.props.annotation === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
annotation$.next({
|
||||
...this.props.annotation,
|
||||
annotation: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
public cancelEditingHandler = () => {
|
||||
annotation$.next(null);
|
||||
};
|
||||
|
||||
public deleteConfirmHandler = () => {
|
||||
this.setState({ isDeleteModalVisible: true });
|
||||
};
|
||||
|
||||
public deleteHandler = async () => {
|
||||
const { annotation, intl } = this.props;
|
||||
|
||||
if (annotation === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ml.annotations.deleteAnnotation(annotation._id);
|
||||
toastNotifications.addSuccess(
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.deletedAnnotationNotificationMessage',
|
||||
defaultMessage: 'Deleted annotation for job with ID {jobId}.',
|
||||
},
|
||||
{ jobId: annotation.job_id }
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
toastNotifications.addDanger(
|
||||
intl.formatMessage(
|
||||
{
|
||||
id:
|
||||
'xpack.ml.timeSeriesExplorer.timeSeriesChart.errorWithDeletingAnnotationNotificationErrorMessage',
|
||||
defaultMessage:
|
||||
'An error occurred deleting the annotation for job with ID {jobId}: {error}',
|
||||
},
|
||||
{ jobId: annotation.job_id, error: JSON.stringify(err) }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
this.closeDeleteModal();
|
||||
annotation$.next(null);
|
||||
annotationsRefresh$.next();
|
||||
};
|
||||
|
||||
public closeDeleteModal = () => {
|
||||
this.setState({ isDeleteModalVisible: false });
|
||||
};
|
||||
|
||||
public saveOrUpdateAnnotation = () => {
|
||||
const { annotation, intl } = this.props;
|
||||
|
||||
if (annotation === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
annotation$.next(null);
|
||||
|
||||
ml.annotations
|
||||
.indexAnnotation(annotation)
|
||||
.then(() => {
|
||||
annotationsRefresh$.next();
|
||||
if (typeof annotation._id === 'undefined') {
|
||||
toastNotifications.addSuccess(
|
||||
intl.formatMessage(
|
||||
{
|
||||
id:
|
||||
'xpack.ml.timeSeriesExplorer.timeSeriesChart.addedAnnotationNotificationMessage',
|
||||
defaultMessage: 'Added an annotation for job with ID {jobId}.',
|
||||
},
|
||||
{ jobId: annotation.job_id }
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toastNotifications.addSuccess(
|
||||
intl.formatMessage(
|
||||
{
|
||||
id:
|
||||
'xpack.ml.timeSeriesExplorer.timeSeriesChart.updatedAnnotationNotificationMessage',
|
||||
defaultMessage: 'Updated annotation for job with ID {jobId}.',
|
||||
},
|
||||
{ jobId: annotation.job_id }
|
||||
)
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(resp => {
|
||||
if (typeof annotation._id === 'undefined') {
|
||||
toastNotifications.addDanger(
|
||||
intl.formatMessage(
|
||||
{
|
||||
id:
|
||||
'xpack.ml.timeSeriesExplorer.timeSeriesChart.errorWithCreatingAnnotationNotificationErrorMessage',
|
||||
defaultMessage:
|
||||
'An error occurred creating the annotation for job with ID {jobId}: {error}',
|
||||
},
|
||||
{ jobId: annotation.job_id, error: JSON.stringify(resp) }
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toastNotifications.addDanger(
|
||||
intl.formatMessage(
|
||||
{
|
||||
id:
|
||||
'xpack.ml.timeSeriesExplorer.timeSeriesChart.errorWithUpdatingAnnotationNotificationErrorMessage',
|
||||
defaultMessage:
|
||||
'An error occurred updating the annotation for job with ID {jobId}: {error}',
|
||||
},
|
||||
{ jobId: annotation.job_id, error: JSON.stringify(resp) }
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
public render(): ReactNode {
|
||||
const { annotation } = this.props;
|
||||
const { isDeleteModalVisible } = this.state;
|
||||
|
||||
if (annotation === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isExistingAnnotation = typeof annotation._id !== 'undefined';
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiFlyout onClose={this.cancelEditingHandler} size="s" aria-labelledby="Add annotation">
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle size="s">
|
||||
<h2 id="mlAnnotationFlyoutTitle">
|
||||
{isExistingAnnotation ? (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.annotationFlyout.editAnnotationTitle"
|
||||
defaultMessage="Edit annotation"
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.annotationFlyout.addAnnotationTitle"
|
||||
defaultMessage="Add annotation"
|
||||
/>
|
||||
)}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<AnnotationDescriptionList annotation={annotation} />
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFormRow
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.annotationFlyout.annotationTextLabel"
|
||||
defaultMessage="Annotation text"
|
||||
/>
|
||||
}
|
||||
fullWidth
|
||||
>
|
||||
<EuiTextArea
|
||||
fullWidth
|
||||
isInvalid={annotation.annotation === ''}
|
||||
onChange={this.annotationTextChangeHandler}
|
||||
placeholder="..."
|
||||
value={annotation.annotation}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty iconType="cross" onClick={this.cancelEditingHandler} flush="left">
|
||||
<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.annotationFlyout.cancelButtonLabel"
|
||||
defaultMessage="Cancel"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
{isExistingAnnotation && (
|
||||
<EuiButtonEmpty color="danger" onClick={this.deleteConfirmHandler}>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.annotationFlyout.deleteButtonLabel"
|
||||
defaultMessage="Delete"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
fill
|
||||
isDisabled={annotation.annotation === ''}
|
||||
onClick={this.saveOrUpdateAnnotation}
|
||||
>
|
||||
{isExistingAnnotation ? (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.annotationFlyout.updateButtonLabel"
|
||||
defaultMessage="Update"
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.annotationFlyout.createButtonLabel"
|
||||
defaultMessage="Create"
|
||||
/>
|
||||
)}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
<DeleteAnnotationModal
|
||||
cancelAction={this.closeDeleteModal}
|
||||
deleteAction={this.deleteHandler}
|
||||
isVisible={isDeleteModalVisible}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const AnnotationFlyout = injectObservablesAsProps({ annotation: annotation$ }, (injectI18n(
|
||||
AnnotationFlyoutIntl
|
||||
) as any) as ComponentType);
|
|
@ -0,0 +1,142 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AnnotationsTable Initialization with annotations prop. 1`] = `
|
||||
<Fragment>
|
||||
<EuiInMemoryTable
|
||||
className="eui-textOverflowWrap"
|
||||
columns={
|
||||
Array [
|
||||
Object {
|
||||
"field": "annotation",
|
||||
"name": "Annotation",
|
||||
"sortable": true,
|
||||
},
|
||||
Object {
|
||||
"dataType": "date",
|
||||
"field": "timestamp",
|
||||
"name": "From",
|
||||
"render": [Function],
|
||||
"sortable": true,
|
||||
},
|
||||
Object {
|
||||
"dataType": "date",
|
||||
"field": "end_timestamp",
|
||||
"name": "To",
|
||||
"render": [Function],
|
||||
"sortable": true,
|
||||
},
|
||||
Object {
|
||||
"dataType": "date",
|
||||
"field": "create_time",
|
||||
"name": "Creation date",
|
||||
"render": [Function],
|
||||
"sortable": true,
|
||||
},
|
||||
Object {
|
||||
"field": "create_username",
|
||||
"name": "Created by",
|
||||
"sortable": true,
|
||||
},
|
||||
Object {
|
||||
"dataType": "date",
|
||||
"field": "modified_time",
|
||||
"name": "Last modified date",
|
||||
"render": [Function],
|
||||
"sortable": true,
|
||||
},
|
||||
Object {
|
||||
"field": "modified_username",
|
||||
"name": "Last modified by",
|
||||
"sortable": true,
|
||||
},
|
||||
Object {
|
||||
"actions": Array [
|
||||
Object {
|
||||
"render": [Function],
|
||||
},
|
||||
Object {
|
||||
"render": [Function],
|
||||
},
|
||||
],
|
||||
"align": "right",
|
||||
"name": "Actions",
|
||||
"width": "60px",
|
||||
},
|
||||
]
|
||||
}
|
||||
compressed={true}
|
||||
executeQueryOptions={Object {}}
|
||||
items={
|
||||
Array [
|
||||
Object {
|
||||
"_id": "KCCkDWgB_ZdQ1MFDSYPi",
|
||||
"annotation": "Major spike.",
|
||||
"create_time": 1546417097181,
|
||||
"create_username": "<user unknown>",
|
||||
"end_timestamp": 1455041968976,
|
||||
"job_id": "farequote",
|
||||
"modified_time": 1546417097181,
|
||||
"modified_username": "<user unknown>",
|
||||
"timestamp": 1455026177994,
|
||||
"type": "annotation",
|
||||
},
|
||||
]
|
||||
}
|
||||
pagination={
|
||||
Object {
|
||||
"pageSizeOptions": Array [
|
||||
5,
|
||||
10,
|
||||
25,
|
||||
],
|
||||
}
|
||||
}
|
||||
responsive={true}
|
||||
rowProps={[Function]}
|
||||
sorting={
|
||||
Object {
|
||||
"sort": Object {
|
||||
"direction": "asc",
|
||||
"field": "timestamp",
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</Fragment>
|
||||
`;
|
||||
|
||||
exports[`AnnotationsTable Initialization with job config prop. 1`] = `
|
||||
<EuiFlexGroup
|
||||
alignItems="stretch"
|
||||
component="div"
|
||||
direction="row"
|
||||
gutterSize="l"
|
||||
justifyContent="spaceAround"
|
||||
responsive={true}
|
||||
wrap={false}
|
||||
>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiLoadingSpinner
|
||||
size="l"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
`;
|
||||
|
||||
exports[`AnnotationsTable Minimal initialization without props. 1`] = `
|
||||
<EuiCallOut
|
||||
color="primary"
|
||||
iconType="iInCircle"
|
||||
size="m"
|
||||
title={
|
||||
<FormattedMessage
|
||||
defaultMessage="No annotations created for this job"
|
||||
id="xpack.ml.annotationsTable.annotationsNotCreatedTitle"
|
||||
values={Object {}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
`;
|
|
@ -4,13 +4,13 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import jobConfig from '../../../../common/types/__mocks__/job_config_farequote';
|
||||
import jobConfig from '../../../../../common/types/__mocks__/job_config_farequote';
|
||||
|
||||
import ngMock from 'ng_mock';
|
||||
import expect from 'expect.js';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import { ml } from '../../../services/ml_api_service';
|
||||
import { ml } from '../../../../services/ml_api_service';
|
||||
|
||||
describe('ML - <ml-annotation-table>', () => {
|
||||
let $scope;
|
|
@ -15,7 +15,7 @@ import PropTypes from 'prop-types';
|
|||
import rison from 'rison-node';
|
||||
|
||||
import React, {
|
||||
Component
|
||||
Component, Fragment
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
|
@ -37,16 +37,17 @@ import {
|
|||
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 { mlJobService } from '../../services/job_service';
|
||||
import { mlTableService } from '../../services/table_service';
|
||||
import { ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE } from '../../../common/constants/search';
|
||||
import { isTimeSeriesViewJob } from '../../../common/util/job_utils';
|
||||
import { addItemToRecentlyAccessed } from '../../../util/recently_accessed';
|
||||
import { ml } from '../../../services/ml_api_service';
|
||||
import { mlJobService } from '../../../services/job_service';
|
||||
import { mlTableService } from '../../../services/table_service';
|
||||
import { ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE } from '../../../../common/constants/search';
|
||||
import { isTimeSeriesViewJob } from '../../../../common/util/job_utils';
|
||||
|
||||
import { annotation$, annotationsRefresh$ } from '../../../services/annotations_service';
|
||||
|
||||
import { FormattedMessage, injectI18n } from '@kbn/i18n/react';
|
||||
|
||||
|
||||
const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
|
||||
|
||||
/**
|
||||
|
@ -117,12 +118,15 @@ const AnnotationsTable = injectI18n(class AnnotationsTable extends Component {
|
|||
return mlJobService.getJob(jobId);
|
||||
}
|
||||
|
||||
annotationsRefreshSubscription = null;
|
||||
|
||||
componentDidMount() {
|
||||
if (
|
||||
this.props.annotations === undefined &&
|
||||
Array.isArray(this.props.jobs) && this.props.jobs.length > 0
|
||||
) {
|
||||
this.getAnnotations();
|
||||
this.annotationsRefreshSubscription = annotationsRefresh$.subscribe(() => this.getAnnotations());
|
||||
annotationsRefresh$.next();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -133,7 +137,13 @@ const AnnotationsTable = injectI18n(class AnnotationsTable extends Component {
|
|||
Array.isArray(this.props.jobs) && this.props.jobs.length > 0 &&
|
||||
this.state.jobId !== this.props.jobs[0].job_id
|
||||
) {
|
||||
this.getAnnotations();
|
||||
annotationsRefresh$.next();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.annotationsRefreshSubscription !== null) {
|
||||
this.annotationsRefreshSubscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -379,14 +389,39 @@ const AnnotationsTable = injectI18n(class AnnotationsTable extends Component {
|
|||
});
|
||||
}
|
||||
|
||||
const actions = [];
|
||||
|
||||
actions.push({
|
||||
render: (annotation) => {
|
||||
const editAnnotationsTooltipText = (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.annotationsTable.editAnnotationsTooltip"
|
||||
defaultMessage="Edit annotation"
|
||||
/>
|
||||
);
|
||||
const editAnnotationsTooltipAriaLabelText = (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.annotationsTable.editAnnotationsTooltipAriaLabel"
|
||||
defaultMessage="Edit annotation"
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<EuiToolTip
|
||||
position="bottom"
|
||||
content={editAnnotationsTooltipText}
|
||||
>
|
||||
<EuiButtonIcon
|
||||
onClick={() => annotation$.next(annotation)}
|
||||
iconType="pencil"
|
||||
aria-label={editAnnotationsTooltipAriaLabelText}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (isSingleMetricViewerLinkVisible) {
|
||||
columns.push({
|
||||
align: RIGHT_ALIGNMENT,
|
||||
width: '60px',
|
||||
name: intl.formatMessage({
|
||||
id: 'xpack.ml.annotationsTable.viewColumnName',
|
||||
defaultMessage: 'View',
|
||||
}),
|
||||
actions.push({
|
||||
render: (annotation) => {
|
||||
const isDrillDownAvailable = isTimeSeriesViewJob(this.getJob(annotation.job_id));
|
||||
const openInSingleMetricViewerTooltipText = isDrillDownAvailable ? (
|
||||
|
@ -429,6 +464,16 @@ const AnnotationsTable = injectI18n(class AnnotationsTable extends Component {
|
|||
});
|
||||
}
|
||||
|
||||
columns.push({
|
||||
align: RIGHT_ALIGNMENT,
|
||||
width: '60px',
|
||||
name: intl.formatMessage({
|
||||
id: 'xpack.ml.annotationsTable.actionsColumnName',
|
||||
defaultMessage: 'Actions',
|
||||
}),
|
||||
actions
|
||||
});
|
||||
|
||||
const getRowProps = (item) => {
|
||||
return {
|
||||
onMouseOver: () => this.onMouseOverRow(item),
|
||||
|
@ -437,21 +482,23 @@ const AnnotationsTable = injectI18n(class AnnotationsTable extends Component {
|
|||
};
|
||||
|
||||
return (
|
||||
<EuiInMemoryTable
|
||||
className="eui-textOverflowWrap"
|
||||
compressed={true}
|
||||
items={annotations}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
pageSizeOptions: [5, 10, 25]
|
||||
}}
|
||||
sorting={{
|
||||
sort: {
|
||||
field: 'timestamp', direction: 'asc'
|
||||
}
|
||||
}}
|
||||
rowProps={getRowProps}
|
||||
/>
|
||||
<Fragment>
|
||||
<EuiInMemoryTable
|
||||
className="eui-textOverflowWrap"
|
||||
compressed={true}
|
||||
items={annotations}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
pageSizeOptions: [5, 10, 25]
|
||||
}}
|
||||
sorting={{
|
||||
sort: {
|
||||
field: 'timestamp', direction: 'asc'
|
||||
}
|
||||
}}
|
||||
rowProps={getRowProps}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
});
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import jobConfig from '../../../common/types/__mocks__/job_config_farequote';
|
||||
import jobConfig from '../../../../common/types/__mocks__/job_config_farequote';
|
||||
import mockAnnotations from './__mocks__/mock_annotations.json';
|
||||
|
||||
import { shallowWithIntl } from 'test_utils/enzyme_helpers';
|
||||
|
@ -17,13 +17,13 @@ jest.mock('ui/chrome', () => ({
|
|||
addBasePath: () => {}
|
||||
}));
|
||||
|
||||
jest.mock('../../services/job_service', () => ({
|
||||
jest.mock('../../../services/job_service', () => ({
|
||||
mlJobService: {
|
||||
getJob: jest.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
jest.mock('../../services/ml_api_service', () => ({
|
||||
jest.mock('../../../services/ml_api_service', () => ({
|
||||
ml: {
|
||||
annotations: {
|
||||
getAnnotations: jest.fn().mockResolvedValue({ annotations: [] })
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* 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 PropTypes from 'prop-types';
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import { EUI_MODAL_CONFIRM_BUTTON, EuiConfirmModal, EuiOverlayMask } from '@elastic/eui';
|
||||
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
interface Props {
|
||||
cancelAction: () => void;
|
||||
deleteAction: () => void;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export const DeleteAnnotationModal: React.SFC<Props> = ({
|
||||
cancelAction,
|
||||
deleteAction,
|
||||
isVisible,
|
||||
}) => {
|
||||
return (
|
||||
<Fragment>
|
||||
{isVisible === true && (
|
||||
<EuiOverlayMask>
|
||||
<EuiConfirmModal
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.deleteAnnotationModal.deleteAnnotationTitle"
|
||||
defaultMessage="Delete this annotation?"
|
||||
/>
|
||||
}
|
||||
onCancel={cancelAction}
|
||||
onConfirm={deleteAction}
|
||||
cancelButtonText={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.deleteAnnotationModal.cancelButtonLabel"
|
||||
defaultMessage="Cancel"
|
||||
/>
|
||||
}
|
||||
confirmButtonText={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.deleteAnnotationModal.deleteButtonLabel"
|
||||
defaultMessage="Delete"
|
||||
/>
|
||||
}
|
||||
buttonColor="danger"
|
||||
defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON}
|
||||
className="eui-textBreakWord"
|
||||
/>
|
||||
</EuiOverlayMask>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
DeleteAnnotationModal.propTypes = {
|
||||
cancelAction: PropTypes.func.isRequired,
|
||||
deleteAction: PropTypes.func.isRequired,
|
||||
isVisible: PropTypes.bool.isRequired,
|
||||
};
|
|
@ -1,133 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AnnotationsTable Initialization with annotations prop. 1`] = `
|
||||
<EuiInMemoryTable
|
||||
className="eui-textOverflowWrap"
|
||||
columns={
|
||||
Array [
|
||||
Object {
|
||||
"field": "annotation",
|
||||
"name": "Annotation",
|
||||
"sortable": true,
|
||||
},
|
||||
Object {
|
||||
"dataType": "date",
|
||||
"field": "timestamp",
|
||||
"name": "From",
|
||||
"render": [Function],
|
||||
"sortable": true,
|
||||
},
|
||||
Object {
|
||||
"dataType": "date",
|
||||
"field": "end_timestamp",
|
||||
"name": "To",
|
||||
"render": [Function],
|
||||
"sortable": true,
|
||||
},
|
||||
Object {
|
||||
"dataType": "date",
|
||||
"field": "create_time",
|
||||
"name": "Creation date",
|
||||
"render": [Function],
|
||||
"sortable": true,
|
||||
},
|
||||
Object {
|
||||
"field": "create_username",
|
||||
"name": "Created by",
|
||||
"sortable": true,
|
||||
},
|
||||
Object {
|
||||
"dataType": "date",
|
||||
"field": "modified_time",
|
||||
"name": "Last modified date",
|
||||
"render": [Function],
|
||||
"sortable": true,
|
||||
},
|
||||
Object {
|
||||
"field": "modified_username",
|
||||
"name": "Last modified by",
|
||||
"sortable": true,
|
||||
},
|
||||
Object {
|
||||
"align": "right",
|
||||
"name": "View",
|
||||
"render": [Function],
|
||||
"width": "60px",
|
||||
},
|
||||
]
|
||||
}
|
||||
compressed={true}
|
||||
executeQueryOptions={Object {}}
|
||||
items={
|
||||
Array [
|
||||
Object {
|
||||
"_id": "KCCkDWgB_ZdQ1MFDSYPi",
|
||||
"annotation": "Major spike.",
|
||||
"create_time": 1546417097181,
|
||||
"create_username": "<user unknown>",
|
||||
"end_timestamp": 1455041968976,
|
||||
"job_id": "farequote",
|
||||
"modified_time": 1546417097181,
|
||||
"modified_username": "<user unknown>",
|
||||
"timestamp": 1455026177994,
|
||||
"type": "annotation",
|
||||
},
|
||||
]
|
||||
}
|
||||
pagination={
|
||||
Object {
|
||||
"pageSizeOptions": Array [
|
||||
5,
|
||||
10,
|
||||
25,
|
||||
],
|
||||
}
|
||||
}
|
||||
responsive={true}
|
||||
rowProps={[Function]}
|
||||
sorting={
|
||||
Object {
|
||||
"sort": Object {
|
||||
"direction": "asc",
|
||||
"field": "timestamp",
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`AnnotationsTable Initialization with job config prop. 1`] = `
|
||||
<EuiFlexGroup
|
||||
alignItems="stretch"
|
||||
component="div"
|
||||
direction="row"
|
||||
gutterSize="l"
|
||||
justifyContent="spaceAround"
|
||||
responsive={true}
|
||||
wrap={false}
|
||||
>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiLoadingSpinner
|
||||
size="l"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
`;
|
||||
|
||||
exports[`AnnotationsTable Minimal initialization without props. 1`] = `
|
||||
<EuiCallOut
|
||||
color="primary"
|
||||
iconType="iInCircle"
|
||||
size="m"
|
||||
title={
|
||||
<FormattedMessage
|
||||
defaultMessage="No annotations created for this job"
|
||||
id="xpack.ml.annotationsTable.annotationsNotCreatedTitle"
|
||||
values={Object {}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
`;
|
|
@ -23,7 +23,9 @@ import {
|
|||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { AnnotationsTable } from '../components/annotations_table';
|
||||
import { annotationsRefresh$ } from '../services/annotations_service';
|
||||
import { AnnotationFlyout } from '../components/annotations/annotation_flyout';
|
||||
import { AnnotationsTable } from '../components/annotations/annotations_table';
|
||||
import {
|
||||
ExplorerNoInfluencersFound,
|
||||
ExplorerNoJobsFound,
|
||||
|
@ -303,6 +305,8 @@ export const Explorer = injectI18n(
|
|||
this.dragSelect.setSelectables(document.getElementsByClassName('sl-cell'));
|
||||
};
|
||||
|
||||
annotationsRefreshSub = null;
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
mlExplorerDashboardService.explorer.watch(this.dashboardListener);
|
||||
|
@ -311,6 +315,12 @@ export const Explorer = injectI18n(
|
|||
mlSelectSeverityService.state.watch(this.anomalyChartsSeverityListener);
|
||||
mlSelectIntervalService.state.watch(this.tableControlsListener);
|
||||
mlSelectSeverityService.state.watch(this.tableControlsListener);
|
||||
this.annotationsRefreshSub = annotationsRefresh$.subscribe(() => {
|
||||
// clear the annotations cache and trigger an update
|
||||
this.annotationsTablePreviousArgs = null;
|
||||
this.annotationsTablePreviousData = null;
|
||||
this.updateExplorer();
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
|
@ -321,6 +331,7 @@ export const Explorer = injectI18n(
|
|||
mlSelectSeverityService.state.unwatch(this.anomalyChartsSeverityListener);
|
||||
mlSelectIntervalService.state.unwatch(this.tableControlsListener);
|
||||
mlSelectSeverityService.state.unwatch(this.tableControlsListener);
|
||||
this.annotationsRefreshSub.unsubscribe();
|
||||
}
|
||||
|
||||
getSwimlaneBucketInterval(selectedJobs) {
|
||||
|
@ -609,7 +620,7 @@ export const Explorer = injectI18n(
|
|||
anomaliesTablePreviousData = null;
|
||||
annotationsTablePreviousArgs = null;
|
||||
annotationsTablePreviousData = null;
|
||||
async updateExplorer(stateUpdate, showOverallLoadingIndicator = true) {
|
||||
async updateExplorer(stateUpdate = {}, showOverallLoadingIndicator = true) {
|
||||
const {
|
||||
noInfluencersConfigured,
|
||||
noJobsFound,
|
||||
|
@ -1067,8 +1078,8 @@ export const Explorer = injectI18n(
|
|||
drillDown={true}
|
||||
numberBadge={false}
|
||||
/>
|
||||
<br />
|
||||
<br />
|
||||
<AnnotationFlyout />
|
||||
<EuiSpacer size="l" />
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
import $ from 'jquery';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
import '../components/annotations_table';
|
||||
import '../components/annotations/annotations_table';
|
||||
import '../components/anomalies_table';
|
||||
import '../components/controls';
|
||||
import '../components/job_select_list';
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
@import 'timeseriesexplorer/index';
|
||||
|
||||
// Components
|
||||
@import 'components/annotations/annotation_description_list/index'; // SASSTODO: This file overwrites EUI directly
|
||||
@import 'components/anomalies_table/index'; // SASSTODO: This file overwrites EUI directly
|
||||
@import 'components/chart_tooltip/index';
|
||||
@import 'components/confirm_modal/index';
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import PropTypes from 'prop-types';
|
||||
import React, {
|
||||
Component
|
||||
Component, Fragment
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
|
@ -18,7 +18,8 @@ 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 { AnnotationsTable } from '../../../../components/annotations/annotations_table';
|
||||
import { AnnotationFlyout } from '../../../../components/annotations/annotation_flyout';
|
||||
import { ForecastsTable } from './forecasts_table';
|
||||
import { JobDetailsPane } from './job_details_pane';
|
||||
import { JobMessagesPane } from './job_messages_pane';
|
||||
|
@ -137,7 +138,12 @@ class JobDetailsUI extends Component {
|
|||
id: 'xpack.ml.jobsList.jobDetails.tabs.annotationsLabel',
|
||||
defaultMessage: 'Annotations'
|
||||
}),
|
||||
content: <AnnotationsTable jobs={[job]} drillDown={true} />,
|
||||
content: (
|
||||
<Fragment>
|
||||
<AnnotationsTable jobs={[job]} drillDown={true} />
|
||||
<AnnotationFlyout />
|
||||
</Fragment>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* 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 mockAnnotations from '../components/annotations/annotations_table/__mocks__/mock_annotations.json';
|
||||
|
||||
import { Annotation } from '../../common/types/annotations';
|
||||
import { annotation$, annotationsRefresh$ } from './annotations_service';
|
||||
|
||||
describe('annotations_service', () => {
|
||||
test('annotation$', () => {
|
||||
const subscriber = jest.fn();
|
||||
|
||||
annotation$.subscribe(subscriber);
|
||||
|
||||
// the subscriber should have been triggered with the initial value of null
|
||||
expect(subscriber.mock.calls).toHaveLength(1);
|
||||
expect(subscriber.mock.calls[0][0]).toBe(null);
|
||||
|
||||
const annotation = mockAnnotations[0] as Annotation;
|
||||
annotation$.next(annotation);
|
||||
|
||||
// the subscriber should have been triggered with the updated annotation value
|
||||
expect(subscriber.mock.calls).toHaveLength(2);
|
||||
expect(subscriber.mock.calls[1][0]).toEqual(annotation);
|
||||
});
|
||||
|
||||
test('annotationsRefresh$', () => {
|
||||
const subscriber = jest.fn();
|
||||
|
||||
annotationsRefresh$.subscribe(subscriber);
|
||||
|
||||
expect(subscriber.mock.calls).toHaveLength(0);
|
||||
|
||||
annotationsRefresh$.next();
|
||||
|
||||
expect(subscriber.mock.calls).toHaveLength(1);
|
||||
});
|
||||
});
|
77
x-pack/plugins/ml/public/services/annotations_service.tsx
Normal file
77
x-pack/plugins/ml/public/services/annotations_service.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* 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 { BehaviorSubject, Subject } from 'rxjs';
|
||||
|
||||
import { Annotation } from '../../common/types/annotations';
|
||||
|
||||
/*
|
||||
A TypeScript helper type to allow a given component state attribute to be either an annotation or null.
|
||||
*/
|
||||
export type AnnotationState = Annotation | null;
|
||||
|
||||
/*
|
||||
This observable offers a way to share state between components
|
||||
that don't have a direct parent -> * -> child relationship.
|
||||
It's also useful in mixed angularjs/React environments.
|
||||
|
||||
For example, we want to trigger the flyout for editing annotations from both
|
||||
the timeseries_chart and the annotations_table. Since we don't want two flyout instances,
|
||||
we cannot simply add the flyout component as a child to each of the other two components.
|
||||
|
||||
The directive/component/DOM structure may look somewhat like this:
|
||||
|
||||
-> <TimeseriesChart />
|
||||
/
|
||||
<timeseriesexplorer.html> --> <AnnotationsTable />
|
||||
\
|
||||
-> <AnnotationsFlyout />
|
||||
|
||||
In this mixed angular/react environment,
|
||||
we want the siblings (chart, table and flyout) to be
|
||||
able to communicate with each other.
|
||||
|
||||
The observable can be used as follows to achieve this:
|
||||
|
||||
- To trigger an update, use `annotation$.next(<Annotation>)`
|
||||
- To reset the currently editable annotation, use `annotation$.next(null)`
|
||||
|
||||
There are two ways to deal with updates of the observable:
|
||||
|
||||
1. Inline subscription in an existing component.
|
||||
This requires the component to be a class component and manage its own state.
|
||||
|
||||
- To react to an update, use `annotation$.subscribe(annotation => { <callback> })`.
|
||||
- To add it to a given components state, just use
|
||||
`annotation$.subscribe(annotation => this.setState({ annotation }));` in `componentDidMount()`.
|
||||
|
||||
2. injectObservablesAsProps() from public/utils/observable_utils.tsx, as the name implies, offers
|
||||
a way to wrap observables into another component which passes on updated values as props.
|
||||
|
||||
- To subscribe to updates this way, wrap your component like:
|
||||
|
||||
const MyOriginalComponent = ({ annotation }) => {
|
||||
// don't render if the annotation isn't set
|
||||
if (annotation === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <span>{annotation.annotation}</span>;
|
||||
}
|
||||
|
||||
export const MyObservableComponent = injectObservablesAsProps(
|
||||
{ annotation: annotaton$ },
|
||||
MyOriginalComponent
|
||||
);
|
||||
*/
|
||||
export const annotation$ = new BehaviorSubject<AnnotationState>(null);
|
||||
|
||||
/*
|
||||
This observable provides a way to trigger a reload of annotations based on a given event.
|
||||
Instead of passing around callbacks or deeply nested props, it can be imported for both
|
||||
angularjs controllers/directives and React components.
|
||||
*/
|
||||
export const annotationsRefresh$ = new Subject();
|
20
x-pack/plugins/ml/public/services/ml_api_service/index.d.ts
vendored
Normal file
20
x-pack/plugins/ml/public/services/ml_api_service/index.d.ts
vendored
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* 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 { Annotation } from '../../../common/types/annotations';
|
||||
|
||||
// TODO This is not a complete representation of all methods of `ml.*`.
|
||||
// It just satisfies needs for other parts of the code area which use
|
||||
// TypeScript and rely on the methods typed in here.
|
||||
// This allows the import of `ml` into TypeScript code.
|
||||
declare interface Ml {
|
||||
annotations: {
|
||||
deleteAnnotation(id: string | undefined): Promise<any>;
|
||||
indexAnnotation(annotation: Annotation): Promise<object>;
|
||||
};
|
||||
}
|
||||
|
||||
declare const ml: Ml;
|
|
@ -36,9 +36,9 @@ describe('ML - <ml-timeseries-chart>', () => {
|
|||
document.getElementsByTagName('body')[0].append(mockClassedElement);
|
||||
|
||||
// spy the TimeseriesChart component's unmount method to be able to test if it was called
|
||||
const componentWillUnmountSpy = sinon.spy(TimeseriesChart.WrappedComponent.prototype, 'componentWillUnmount');
|
||||
const componentWillUnmountSpy = sinon.spy(TimeseriesChart.prototype, 'componentWillUnmount');
|
||||
|
||||
$element = $compile('<ml-timeseries-chart show-forecast="true" />')($scope);
|
||||
$element = $compile('<ml-timeseries-chart show-forecast="true" model-plot-enabled="false" show-model-bounds="false" />')($scope);
|
||||
const scope = $element.isolateScope();
|
||||
|
||||
// sanity test to check if directive picked up the attribute for its scope
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
@import 'components/annotation_description_list/index';
|
||||
@import 'components/forecasting_modal/index';
|
||||
@import 'timeseriesexplorer';
|
||||
@import 'timeseriesexplorer_annotations';
|
||||
|
|
|
@ -94,6 +94,7 @@
|
|||
fill: none;
|
||||
stroke: $euiBorderColor;
|
||||
shape-rendering: crispEdges;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.axis text {
|
||||
|
|
|
@ -1,141 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AnnotationFlyout Initialization. 1`] = `
|
||||
<EuiFlyout
|
||||
aria-labelledby="Add annotation"
|
||||
closeButtonAriaLabel="Closes this dialog"
|
||||
hideCloseButton={false}
|
||||
maxWidth={false}
|
||||
onClose={[MockFunction]}
|
||||
ownFocus={false}
|
||||
size="s"
|
||||
>
|
||||
<EuiFlyoutHeader
|
||||
hasBorder={true}
|
||||
>
|
||||
<EuiTitle
|
||||
size="s"
|
||||
textTransform="none"
|
||||
>
|
||||
<h2
|
||||
id="mlAnnotationFlyoutTitle"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Edit annotation"
|
||||
id="xpack.ml.timeSeriesExplorer.annotationFlyout.editAnnotationTitle"
|
||||
values={Object {}}
|
||||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<InjectIntl(Component)
|
||||
annotation={
|
||||
Object {
|
||||
"_id": "KCCkDWgB_ZdQ1MFDSYPi",
|
||||
"annotation": "Major spike.",
|
||||
"create_time": 1546417097181,
|
||||
"create_username": "<user unknown>",
|
||||
"end_timestamp": 1455041968976,
|
||||
"job_id": "farequote",
|
||||
"modified_time": 1546417097181,
|
||||
"modified_username": "<user unknown>",
|
||||
"timestamp": 1455026177994,
|
||||
"type": "annotation",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<EuiSpacer
|
||||
size="m"
|
||||
/>
|
||||
<EuiFormRow
|
||||
describedByIds={Array []}
|
||||
fullWidth={true}
|
||||
hasEmptyLabelSpace={false}
|
||||
label={
|
||||
<FormattedMessage
|
||||
defaultMessage="Annotation text"
|
||||
id="xpack.ml.timeSeriesExplorer.annotationFlyout.annotationTextLabel"
|
||||
values={Object {}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiTextArea
|
||||
fullWidth={true}
|
||||
isInvalid={false}
|
||||
onChange={[MockFunction]}
|
||||
placeholder="..."
|
||||
resize="vertical"
|
||||
value="Major spike."
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup
|
||||
alignItems="stretch"
|
||||
component="div"
|
||||
direction="row"
|
||||
gutterSize="l"
|
||||
justifyContent="spaceBetween"
|
||||
responsive={true}
|
||||
wrap={false}
|
||||
>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiButtonEmpty
|
||||
color="primary"
|
||||
flush="left"
|
||||
iconSide="left"
|
||||
iconType="cross"
|
||||
onClick={[MockFunction]}
|
||||
type="button"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Cancel"
|
||||
id="xpack.ml.timeSeriesExplorer.annotationFlyout.cancelButtonLabel"
|
||||
values={Object {}}
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiButtonEmpty
|
||||
color="danger"
|
||||
iconSide="left"
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Delete"
|
||||
id="xpack.ml.timeSeriesExplorer.annotationFlyout.deleteButtonLabel"
|
||||
values={Object {}}
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiButton
|
||||
color="primary"
|
||||
fill={true}
|
||||
iconSide="left"
|
||||
isDisabled={false}
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Update"
|
||||
id="xpack.ml.timeSeriesExplorer.annotationFlyout.updateButtonLabel"
|
||||
values={Object {}}
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
`;
|
|
@ -1,127 +0,0 @@
|
|||
/*
|
||||
* 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';
|
||||
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
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';
|
||||
return (
|
||||
<EuiFlyout onClose={cancelAction} size="s" aria-labelledby="Add annotation">
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle size="s">
|
||||
<h2 id="mlAnnotationFlyoutTitle">
|
||||
{isExistingAnnotation ? (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.annotationFlyout.editAnnotationTitle"
|
||||
defaultMessage="Edit annotation"
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.annotationFlyout.addAnnotationTitle"
|
||||
defaultMessage="Add annotation"
|
||||
/>
|
||||
)}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<AnnotationDescriptionList annotation={annotation} />
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFormRow
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.annotationFlyout.annotationTextLabel"
|
||||
defaultMessage="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">
|
||||
<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.annotationFlyout.cancelButtonLabel"
|
||||
defaultMessage="Cancel"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
{isExistingAnnotation && (
|
||||
<EuiButtonEmpty color="danger" onClick={deleteActionWrapper}>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.annotationFlyout.deleteButtonLabel"
|
||||
defaultMessage="Delete"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton fill isDisabled={annotation.annotation === ''} onClick={saveActionWrapper}>
|
||||
{isExistingAnnotation ? (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.annotationFlyout.updateButtonLabel"
|
||||
defaultMessage="Update"
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.annotationFlyout.createButtonLabel"
|
||||
defaultMessage="Create"
|
||||
/>
|
||||
)}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
);
|
||||
};
|
|
@ -1,57 +0,0 @@
|
|||
/*
|
||||
* 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 PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
EuiConfirmModal,
|
||||
EuiOverlayMask,
|
||||
EUI_MODAL_CONFIRM_BUTTON,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
export function DeleteAnnotationModal({
|
||||
cancelAction,
|
||||
deleteAction,
|
||||
isVisible
|
||||
}) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{isVisible === true &&
|
||||
<EuiOverlayMask>
|
||||
<EuiConfirmModal
|
||||
title={<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.deleteAnnotationModal.deleteAnnotationTitle"
|
||||
defaultMessage="Delete this annotation?"
|
||||
/>}
|
||||
onCancel={cancelAction}
|
||||
onConfirm={deleteAction}
|
||||
cancelButtonText={<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.deleteAnnotationModal.cancelButtonLabel"
|
||||
defaultMessage="Cancel"
|
||||
/>}
|
||||
confirmButtonText={<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.deleteAnnotationModal.deleteButtonLabel"
|
||||
defaultMessage="Delete"
|
||||
/>}
|
||||
buttonColor="danger"
|
||||
defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON}
|
||||
className="eui-textBreakWord"
|
||||
/>
|
||||
</EuiOverlayMask>
|
||||
}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
DeleteAnnotationModal.propTypes = {
|
||||
cancelAction: PropTypes.func.isRequired,
|
||||
deleteAction: PropTypes.func.isRequired,
|
||||
isVisible: PropTypes.bool.isRequired
|
||||
};
|
|
@ -14,12 +14,9 @@ interface Props {
|
|||
}
|
||||
|
||||
interface State {
|
||||
annotation: Annotation;
|
||||
annotation: Annotation | null;
|
||||
}
|
||||
|
||||
export interface TimeseriesChart extends React.Component<Props, State> {
|
||||
closeFlyout: () => {};
|
||||
showFlyout: (annotation: Annotation) => {};
|
||||
|
||||
focusXScale: d3.scale.Ordinal<{}, number>;
|
||||
}
|
||||
|
|
|
@ -25,8 +25,8 @@ import {
|
|||
getSeverityWithLow,
|
||||
getMultiBucketImpactLabel,
|
||||
} from '../../../../common/util/anomaly_utils';
|
||||
import { AnnotationFlyout } from '../annotation_flyout';
|
||||
import { DeleteAnnotationModal } from '../delete_annotation_modal';
|
||||
import { annotation$ } from '../../../services/annotations_service';
|
||||
import { injectObservablesAsProps } from '../../../util/observable_utils';
|
||||
import { formatValue } from '../../../formatters/format_value';
|
||||
import {
|
||||
LINE_CHART_ANOMALY_RADIUS,
|
||||
|
@ -91,22 +91,20 @@ function getSvgHeight() {
|
|||
return focusHeight + contextChartHeight + swimlaneHeight + chartSpacing + margin.top + margin.bottom;
|
||||
}
|
||||
|
||||
export const TimeseriesChart = injectI18n(class TimeseriesChart extends React.Component {
|
||||
const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Component {
|
||||
static propTypes = {
|
||||
indexAnnotation: PropTypes.func,
|
||||
annotation: PropTypes.object,
|
||||
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,
|
||||
|
@ -114,132 +112,10 @@ export const TimeseriesChart = injectI18n(class TimeseriesChart extends React.Co
|
|||
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,
|
||||
isDeleteModalVisible: false,
|
||||
};
|
||||
}
|
||||
|
||||
closeDeleteModal = () => {
|
||||
this.setState({ isDeleteModalVisible: false });
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
this.setState({ isDeleteModalVisible: true });
|
||||
}
|
||||
|
||||
deleteAnnotationConfirmation = async () => {
|
||||
const {
|
||||
deleteAnnotation,
|
||||
refresh,
|
||||
toastNotifications,
|
||||
intl
|
||||
} = this.props;
|
||||
|
||||
const { annotation } = this.state;
|
||||
|
||||
try {
|
||||
await deleteAnnotation(annotation._id);
|
||||
toastNotifications.addSuccess(
|
||||
intl.formatMessage({
|
||||
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.deletedAnnotationNotificationMessage',
|
||||
defaultMessage: 'Deleted annotation for job with ID {jobId}.',
|
||||
}, { jobId: annotation.job_id })
|
||||
);
|
||||
} catch (err) {
|
||||
toastNotifications
|
||||
.addDanger(
|
||||
intl.formatMessage({
|
||||
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.errorWithDeletingAnnotationNotificationErrorMessage',
|
||||
defaultMessage: 'An error occurred deleting the annotation for job with ID {jobId}: {error}',
|
||||
}, { jobId: annotation.job_id, error: JSON.stringify(err) })
|
||||
);
|
||||
}
|
||||
|
||||
this.closeDeleteModal();
|
||||
this.closeFlyout();
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
indexAnnotation = (annotation) => {
|
||||
const {
|
||||
indexAnnotation,
|
||||
refresh,
|
||||
toastNotifications,
|
||||
intl
|
||||
} = this.props;
|
||||
|
||||
this.closeFlyout();
|
||||
|
||||
indexAnnotation(annotation)
|
||||
.then(() => {
|
||||
refresh();
|
||||
if (typeof annotation._id === 'undefined') {
|
||||
toastNotifications.addSuccess(
|
||||
intl.formatMessage({
|
||||
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.addedAnnotationNotificationMessage',
|
||||
defaultMessage: 'Added an annotation for job with ID {jobId}.',
|
||||
}, { jobId: annotation.job_id })
|
||||
);
|
||||
} else {
|
||||
toastNotifications.addSuccess(
|
||||
intl.formatMessage({
|
||||
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.updatedAnnotationNotificationMessage',
|
||||
defaultMessage: 'Updated annotation for job with ID {jobId}.',
|
||||
}, { jobId: annotation.job_id })
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((resp) => {
|
||||
if (typeof annotation._id === 'undefined') {
|
||||
toastNotifications.addDanger(
|
||||
intl.formatMessage({
|
||||
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.errorWithCreatingAnnotationNotificationErrorMessage',
|
||||
defaultMessage: 'An error occurred creating the annotation for job with ID {jobId}: {error}',
|
||||
}, { jobId: annotation.job_id, error: JSON.stringify(resp) })
|
||||
);
|
||||
} else {
|
||||
toastNotifications.addDanger(
|
||||
intl.formatMessage({
|
||||
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.errorWithUpdatingAnnotationNotificationErrorMessage',
|
||||
defaultMessage: 'An error occurred updating the annotation for job with ID {jobId}: {error}',
|
||||
}, { jobId: annotation.job_id, error: JSON.stringify(resp) })
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const element = d3.select(this.rootNode);
|
||||
element.html('');
|
||||
|
@ -328,6 +204,11 @@ export const TimeseriesChart = injectI18n(class TimeseriesChart extends React.Co
|
|||
}
|
||||
|
||||
this.renderFocusChart();
|
||||
|
||||
if (mlAnnotationsEnabled && this.props.annotation === null) {
|
||||
const chartElement = d3.select(this.rootNode);
|
||||
chartElement.select('g.mlAnnotationBrush').call(this.annotateBrush.extent([0, 0]));
|
||||
}
|
||||
}
|
||||
|
||||
renderChart() {
|
||||
|
@ -626,7 +507,6 @@ export const TimeseriesChart = injectI18n(class TimeseriesChart extends React.Co
|
|||
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');
|
||||
|
@ -715,7 +595,6 @@ export const TimeseriesChart = injectI18n(class TimeseriesChart extends React.Co
|
|||
// TODO needs revisiting to be a more robust normalization
|
||||
yMax = yMax * (1 + (maxLevel + 1) / 5);
|
||||
}
|
||||
|
||||
this.focusYScale.domain([yMin, yMax]);
|
||||
|
||||
} else {
|
||||
|
@ -754,8 +633,7 @@ export const TimeseriesChart = injectI18n(class TimeseriesChart extends React.Co
|
|||
focusChartHeight,
|
||||
this.focusXScale,
|
||||
showAnnotations,
|
||||
showFocusChartTooltip,
|
||||
showFlyout
|
||||
showFocusChartTooltip
|
||||
);
|
||||
|
||||
// disable brushing (creation of annotations) when annotations aren't shown
|
||||
|
@ -796,7 +674,6 @@ export const TimeseriesChart = injectI18n(class TimeseriesChart extends React.Co
|
|||
return markerClass;
|
||||
});
|
||||
|
||||
|
||||
// Render cross symbols for any multi-bucket anomalies.
|
||||
const multiBucketMarkers = d3.select('.focus-chart-markers').selectAll('.multi-bucket')
|
||||
.data(data.filter(d => (d.anomalyScore !== null && showMultiBucketAnomalyMarker(d) === true)));
|
||||
|
@ -1568,29 +1445,11 @@ export const TimeseriesChart = injectI18n(class TimeseriesChart extends React.Co
|
|||
}
|
||||
|
||||
render() {
|
||||
const { annotation, isDeleteModalVisible, isFlyoutVisible } = this.state;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="ml-timeseries-chart-react" ref={this.setRef.bind(this)} />
|
||||
{mlAnnotationsEnabled && isFlyoutVisible &&
|
||||
<React.Fragment>
|
||||
<AnnotationFlyout
|
||||
annotation={annotation}
|
||||
cancelAction={this.closeFlyout}
|
||||
controlFunc={this.handleAnnotationChange}
|
||||
deleteAction={this.deleteAnnotation}
|
||||
saveAction={this.indexAnnotation}
|
||||
/>
|
||||
<DeleteAnnotationModal
|
||||
annotation={annotation}
|
||||
cancelAction={this.closeDeleteModal}
|
||||
deleteAction={this.deleteAnnotationConfirmation}
|
||||
isVisible={isDeleteModalVisible}
|
||||
/>
|
||||
</React.Fragment>
|
||||
}
|
||||
</React.Fragment>
|
||||
);
|
||||
return <div className="ml-timeseries-chart-react" ref={this.setRef.bind(this)} />;
|
||||
}
|
||||
});
|
||||
|
||||
export const TimeseriesChart = injectObservablesAsProps(
|
||||
{ annotation: annotation$ },
|
||||
TimeseriesChartIntl
|
||||
);
|
||||
|
|
|
@ -62,7 +62,7 @@ describe('TimeseriesChart', () => {
|
|||
test('Minimal initialization', () => {
|
||||
const props = getTimeseriesChartPropsMock();
|
||||
|
||||
const wrapper = mountWithIntl(<TimeseriesChart.WrappedComponent {...props}/>);
|
||||
const wrapper = mountWithIntl(<TimeseriesChart {...props}/>);
|
||||
|
||||
expect(wrapper.html()).toBe(
|
||||
`<div class="ml-timeseries-chart-react"></div>`
|
||||
|
|
|
@ -16,6 +16,8 @@ import { mlChartTooltipService } from '../../../components/chart_tooltip/chart_t
|
|||
|
||||
import { TimeseriesChart } from './timeseries_chart';
|
||||
|
||||
import { annotation$ } from '../../../services/annotations_service';
|
||||
|
||||
export const ANNOTATION_MASK_ID = 'mlAnnotationMask';
|
||||
|
||||
// getAnnotationBrush() is expected to be called like getAnnotationBrush.call(this)
|
||||
|
@ -39,19 +41,19 @@ export function getAnnotationBrush(this: TimeseriesChart) {
|
|||
const endTimestamp = extent[1].getTime();
|
||||
|
||||
if (timestamp === endTimestamp) {
|
||||
this.closeFlyout();
|
||||
annotation$.next(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const annotation: Annotation = {
|
||||
timestamp,
|
||||
end_timestamp: endTimestamp,
|
||||
annotation: this.state.annotation.annotation || '',
|
||||
annotation: '',
|
||||
job_id: selectedJob.job_id,
|
||||
type: ANNOTATION_TYPE.ANNOTATION,
|
||||
};
|
||||
|
||||
this.showFlyout(annotation);
|
||||
annotation$.next(annotation);
|
||||
}
|
||||
|
||||
return annotateBrush;
|
||||
|
@ -108,8 +110,7 @@ export function renderAnnotations(
|
|||
focusChartHeight: number,
|
||||
focusXScale: TimeseriesChart['focusXScale'],
|
||||
showAnnotations: boolean,
|
||||
showFocusChartTooltip: (d: Annotation, t: object) => {},
|
||||
showFlyout: TimeseriesChart['showFlyout']
|
||||
showFocusChartTooltip: (d: Annotation, t: object) => {}
|
||||
) {
|
||||
const upperRectMargin = ANNOTATION_UPPER_RECT_MARGIN;
|
||||
const upperTextMargin = ANNOTATION_UPPER_TEXT_MARGIN;
|
||||
|
@ -157,7 +158,12 @@ export function renderAnnotations(
|
|||
})
|
||||
.on('mouseout', () => mlChartTooltipService.hide())
|
||||
.on('click', (d: Annotation) => {
|
||||
showFlyout(d);
|
||||
// clear a possible existing annotation set up for editing before setting the new one.
|
||||
// this needs to be done explicitly here because a new annotation created using the brush tool
|
||||
// could still be present in the chart.
|
||||
annotation$.next(null);
|
||||
// set the actual annotation and trigger the flyout
|
||||
annotation$.next(d);
|
||||
});
|
||||
|
||||
rects
|
||||
|
|
|
@ -18,15 +18,12 @@ 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 { I18nContext } from 'ui/i18n';
|
||||
|
||||
import chrome from 'ui/chrome';
|
||||
|
@ -47,13 +44,11 @@ module.directive('mlTimeseriesChart', function ($timeout) {
|
|||
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,
|
||||
|
@ -69,14 +64,13 @@ module.directive('mlTimeseriesChart', function ($timeout) {
|
|||
svgWidth,
|
||||
swimlaneData: scope.swimlaneData,
|
||||
timefilter,
|
||||
toastNotifications,
|
||||
zoomFrom: scope.zoomFrom,
|
||||
zoomTo: scope.zoomTo
|
||||
};
|
||||
|
||||
ReactDOM.render(
|
||||
<I18nContext>
|
||||
{React.createElement(TimeseriesChart, props)}
|
||||
<TimeseriesChart {...props} />
|
||||
</I18nContext>,
|
||||
element[0]
|
||||
);
|
||||
|
|
|
@ -178,8 +178,7 @@
|
|||
zoom-from="zoomFrom"
|
||||
zoom-to="zoomTo"
|
||||
auto-zoom-duration="autoZoomDuration"
|
||||
refresh="refresh">
|
||||
</ml-timeseries-chart>
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
|
@ -196,9 +195,11 @@
|
|||
number-badge="true"
|
||||
/>
|
||||
|
||||
<br /><br />
|
||||
<div class="euiSpacer euiSpacer--l"></div>
|
||||
</div>
|
||||
|
||||
<ml-annotation-flyout />
|
||||
|
||||
<span
|
||||
class="panel-title euiText"
|
||||
i18n-id="xpack.ml.timeSeriesExplorer.anomaliesTitle"
|
||||
|
|
|
@ -15,7 +15,8 @@
|
|||
import _ from 'lodash';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
import 'plugins/ml/components/annotations_table';
|
||||
import 'plugins/ml/components/annotations/annotation_flyout/annotation_flyout_directive';
|
||||
import 'plugins/ml/components/annotations/annotations_table';
|
||||
import 'plugins/ml/components/anomalies_table';
|
||||
import 'plugins/ml/components/controls';
|
||||
|
||||
|
@ -54,6 +55,7 @@ import {
|
|||
ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE,
|
||||
ANOMALIES_TABLE_DEFAULT_QUERY_SIZE
|
||||
} from '../../common/constants/search';
|
||||
import { annotationsRefresh$ } from '../services/annotations_service';
|
||||
|
||||
|
||||
import chrome from 'ui/chrome';
|
||||
|
@ -652,11 +654,13 @@ module.controller('MlTimeSeriesExplorerController', function (
|
|||
mlSelectIntervalService.state.watch(tableControlsListener);
|
||||
mlSelectSeverityService.state.watch(tableControlsListener);
|
||||
|
||||
const annotationsRefreshSub = annotationsRefresh$.subscribe($scope.refresh);
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
refreshWatcher.cancel();
|
||||
mlSelectIntervalService.state.unwatch(tableControlsListener);
|
||||
mlSelectSeverityService.state.unwatch(tableControlsListener);
|
||||
annotationsRefreshSub.unsubscribe();
|
||||
});
|
||||
|
||||
// Listen for changes to job selection.
|
||||
|
@ -1008,5 +1012,4 @@ module.controller('MlTimeSeriesExplorerController', function (
|
|||
}
|
||||
|
||||
$scope.initializeVis();
|
||||
|
||||
});
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`observable_utils injectObservablesAsProps() 1`] = `
|
||||
<TestComponent
|
||||
testProp="initial text"
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`observable_utils injectObservablesAsProps() 2`] = `
|
||||
<TestComponent
|
||||
testProp="updated text"
|
||||
/>
|
||||
`;
|
43
x-pack/plugins/ml/public/util/observable_utils.test.tsx
Normal file
43
x-pack/plugins/ml/public/util/observable_utils.test.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* 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 { shallow } from 'enzyme';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { injectObservablesAsProps } from './observable_utils';
|
||||
|
||||
interface Props {
|
||||
testProp: string;
|
||||
}
|
||||
|
||||
describe('observable_utils', () => {
|
||||
test('injectObservablesAsProps()', () => {
|
||||
// an observable that allows us to trigger updating some text.
|
||||
const observable$ = new BehaviorSubject('initial text');
|
||||
|
||||
// a simple stateless component that just renders some text
|
||||
const TestComponent: React.SFC<Props> = ({ testProp }) => {
|
||||
return <span>{testProp}</span>;
|
||||
};
|
||||
|
||||
// injectObservablesAsProps wraps the observable in a new component
|
||||
const ObservableComponent = injectObservablesAsProps(
|
||||
{ testProp: observable$ },
|
||||
(TestComponent as any) as ComponentType
|
||||
);
|
||||
|
||||
const wrapper = shallow(<ObservableComponent />);
|
||||
|
||||
// the component should render with "initial text"
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
|
||||
observable$.next('updated text');
|
||||
|
||||
// the component should render with "updated text"
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
53
x-pack/plugins/ml/public/util/observable_utils.tsx
Normal file
53
x-pack/plugins/ml/public/util/observable_utils.tsx
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.
|
||||
*/
|
||||
|
||||
import React, { Component, ComponentType } from 'react';
|
||||
|
||||
import { BehaviorSubject, Subscription } from 'rxjs';
|
||||
import { Dictionary } from '../../common/types/common';
|
||||
|
||||
// Sets up a ObservableComponent which subscribes to given observable updates and
|
||||
// and passes them on as prop values to the given WrappedComponent.
|
||||
// This give us the benefit of abstracting away the need to set up subscribers and callbacks,
|
||||
// and the passed down props can be used in pure/functional components without
|
||||
// the need for their own state management.
|
||||
export function injectObservablesAsProps(
|
||||
observables: Dictionary<BehaviorSubject<any>>,
|
||||
WrappedComponent: ComponentType
|
||||
): ComponentType {
|
||||
const observableKeys = Object.keys(observables);
|
||||
|
||||
class ObservableComponent extends Component<any, any> {
|
||||
public state = observableKeys.reduce((reducedState: Dictionary<any>, key: string) => {
|
||||
reducedState[key] = observables[key].value;
|
||||
return reducedState;
|
||||
}, {});
|
||||
|
||||
public subscriptions = {} as Dictionary<Subscription>;
|
||||
|
||||
public componentDidMount() {
|
||||
observableKeys.forEach(k => {
|
||||
this.subscriptions[k] = observables[k].subscribe(v => this.setState({ [k]: v }));
|
||||
});
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
Object.keys(this.subscriptions).forEach((key: string) =>
|
||||
this.subscriptions[key].unsubscribe()
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<WrappedComponent {...this.props} {...this.state}>
|
||||
{this.props.children}
|
||||
</WrappedComponent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return ObservableComponent as ComponentType;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue