[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:
Walter Rafelsberger 2019-02-05 21:03:44 +01:00 committed by GitHub
parent fd061abd4a
commit 3db812b08e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1049 additions and 698 deletions

View file

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

View file

@ -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,
}
}
/>
`;

View file

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

View file

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

View file

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

View file

@ -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 {}}
/>
}
/>
`;

View file

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

View file

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

View file

@ -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: [] })

View file

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

View file

@ -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 {}}
/>
}
/>
`;

View file

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

View file

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

View file

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

View file

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

View file

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

View 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();

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

View file

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

View file

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

View file

@ -94,6 +94,7 @@
fill: none;
stroke: $euiBorderColor;
shape-rendering: crispEdges;
pointer-events: none;
}
.axis text {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"
/>
`;

View 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();
});
});

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
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;
}