mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[ML] Performance improvements to annotations editing in Single Metric Viewer & buttons placement (#83216)
This commit is contained in:
parent
21c0258e6b
commit
7a7057eba7
16 changed files with 239 additions and 114 deletions
|
@ -1,3 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AnnotationFlyout Initialization. 1`] = `""`;
|
|
@ -5,58 +5,102 @@
|
|||
*/
|
||||
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
|
||||
import mockAnnotations from '../annotations_table/__mocks__/mock_annotations.json';
|
||||
|
||||
import React from 'react';
|
||||
import { mountWithIntl, shallowWithIntl } from '@kbn/test/jest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
|
||||
import { Annotation } from '../../../../../common/types/annotations';
|
||||
import { annotation$ } from '../../../services/annotations_service';
|
||||
import { AnnotationUpdatesService } from '../../../services/annotations_service';
|
||||
|
||||
import { AnnotationFlyout } from './index';
|
||||
import { MlAnnotationUpdatesContext } from '../../../contexts/ml/ml_annotation_updates_context';
|
||||
|
||||
jest.mock('../../../util/dependency_cache', () => ({
|
||||
getToastNotifications: () => ({ addSuccess: jest.fn(), addDanger: jest.fn() }),
|
||||
}));
|
||||
|
||||
const MlAnnotationUpdatesContextProvider = ({
|
||||
annotationUpdatesService,
|
||||
children,
|
||||
}: {
|
||||
annotationUpdatesService: AnnotationUpdatesService;
|
||||
children: React.ReactElement;
|
||||
}) => {
|
||||
return (
|
||||
<MlAnnotationUpdatesContext.Provider value={annotationUpdatesService}>
|
||||
<IntlProvider>{children}</IntlProvider>
|
||||
</MlAnnotationUpdatesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const ObservableComponent = (props: any) => {
|
||||
const { annotationUpdatesService } = props;
|
||||
const annotationProp = useObservable(annotationUpdatesService!.isAnnotationInitialized$());
|
||||
if (annotationProp === undefined) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<AnnotationFlyout
|
||||
annotation={annotationProp}
|
||||
annotationUpdatesService={annotationUpdatesService}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
describe('AnnotationFlyout', () => {
|
||||
test('Initialization.', () => {
|
||||
const wrapper = shallowWithIntl(<AnnotationFlyout />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
let annotationUpdatesService: AnnotationUpdatesService | null = null;
|
||||
beforeEach(() => {
|
||||
annotationUpdatesService = new AnnotationUpdatesService();
|
||||
});
|
||||
|
||||
test('Update button is disabled with empty annotation', () => {
|
||||
test('Update button is disabled with empty annotation', async () => {
|
||||
const annotation = mockAnnotations[1] as Annotation;
|
||||
annotation$.next(annotation);
|
||||
|
||||
// useObservable wraps the observable in a new component
|
||||
const ObservableComponent = (props: any) => {
|
||||
const annotationProp = useObservable(annotation$);
|
||||
if (annotationProp === undefined) {
|
||||
return null;
|
||||
}
|
||||
return <AnnotationFlyout annotation={annotationProp} {...props} />;
|
||||
};
|
||||
annotationUpdatesService!.setValue(annotation);
|
||||
|
||||
const wrapper = mountWithIntl(<ObservableComponent />);
|
||||
const updateBtn = wrapper.find('EuiButton').first();
|
||||
expect(updateBtn.prop('isDisabled')).toEqual(true);
|
||||
const { getByTestId } = render(
|
||||
<MlAnnotationUpdatesContextProvider annotationUpdatesService={annotationUpdatesService!}>
|
||||
<ObservableComponent annotationUpdatesService={annotationUpdatesService!} />
|
||||
</MlAnnotationUpdatesContextProvider>
|
||||
);
|
||||
const updateBtn = getByTestId('annotationFlyoutUpdateButton');
|
||||
expect(updateBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
test('Error displayed and update button displayed if annotation text is longer than max chars', () => {
|
||||
test('Error displayed and update button displayed if annotation text is longer than max chars', async () => {
|
||||
const annotation = mockAnnotations[2] as Annotation;
|
||||
annotation$.next(annotation);
|
||||
annotationUpdatesService!.setValue(annotation);
|
||||
|
||||
// useObservable wraps the observable in a new component
|
||||
const ObservableComponent = (props: any) => {
|
||||
const annotationProp = useObservable(annotation$);
|
||||
if (annotationProp === undefined) {
|
||||
return null;
|
||||
}
|
||||
return <AnnotationFlyout annotation={annotationProp} {...props} />;
|
||||
};
|
||||
const { getByTestId } = render(
|
||||
<MlAnnotationUpdatesContextProvider annotationUpdatesService={annotationUpdatesService!}>
|
||||
<ObservableComponent annotationUpdatesService={annotationUpdatesService!} />
|
||||
</MlAnnotationUpdatesContextProvider>
|
||||
);
|
||||
const updateBtn = getByTestId('annotationFlyoutUpdateButton');
|
||||
expect(updateBtn).toBeDisabled();
|
||||
await waitFor(() => {
|
||||
const errorText = screen.queryByText(/characters above maximum length/);
|
||||
expect(errorText).not.toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
const wrapper = mountWithIntl(<ObservableComponent />);
|
||||
const updateBtn = wrapper.find('EuiButton').first();
|
||||
expect(updateBtn.prop('isDisabled')).toEqual(true);
|
||||
test('Flyout disappears when annotation is updated', async () => {
|
||||
const annotation = mockAnnotations[0] as Annotation;
|
||||
|
||||
expect(wrapper.find('EuiFormErrorText')).toHaveLength(1);
|
||||
annotationUpdatesService!.setValue(annotation);
|
||||
|
||||
const { getByTestId } = render(
|
||||
<MlAnnotationUpdatesContextProvider annotationUpdatesService={annotationUpdatesService!}>
|
||||
<ObservableComponent annotationUpdatesService={annotationUpdatesService!} />
|
||||
</MlAnnotationUpdatesContextProvider>
|
||||
);
|
||||
const updateBtn = getByTestId('annotationFlyoutUpdateButton');
|
||||
expect(updateBtn).not.toBeDisabled();
|
||||
expect(screen.queryByTestId('mlAnnotationFlyout')).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(updateBtn);
|
||||
expect(screen.queryByTestId('mlAnnotationFlyout')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { Component, FC, ReactNode, useCallback } from 'react';
|
||||
import React, { Component, FC, ReactNode, useCallback, useContext } from 'react';
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
import * as Rx from 'rxjs';
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
@ -28,15 +28,14 @@ import {
|
|||
import { CommonProps } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { distinctUntilChanged } from 'rxjs/operators';
|
||||
import {
|
||||
ANNOTATION_MAX_LENGTH_CHARS,
|
||||
ANNOTATION_EVENT_USER,
|
||||
} from '../../../../../common/constants/annotations';
|
||||
import {
|
||||
annotation$,
|
||||
annotationsRefreshed,
|
||||
AnnotationState,
|
||||
AnnotationUpdatesService,
|
||||
} from '../../../services/annotations_service';
|
||||
import { AnnotationDescriptionList } from '../annotation_description_list';
|
||||
import { DeleteAnnotationModal } from '../delete_annotation_modal';
|
||||
|
@ -48,6 +47,7 @@ import {
|
|||
} from '../../../../../common/types/annotations';
|
||||
import { PartitionFieldsType } from '../../../../../common/types/anomalies';
|
||||
import { PARTITION_FIELDS } from '../../../../../common/constants/anomalies';
|
||||
import { MlAnnotationUpdatesContext } from '../../../contexts/ml/ml_annotation_updates_context';
|
||||
|
||||
interface ViewableDetector {
|
||||
index: number;
|
||||
|
@ -67,6 +67,7 @@ interface Props {
|
|||
};
|
||||
detectorIndex: number;
|
||||
detectors: ViewableDetector[];
|
||||
annotationUpdatesService: AnnotationUpdatesService;
|
||||
}
|
||||
|
||||
interface State {
|
||||
|
@ -85,7 +86,8 @@ export class AnnotationFlyoutUI extends Component<CommonProps & Props> {
|
|||
public annotationSub: Rx.Subscription | null = null;
|
||||
|
||||
componentDidMount() {
|
||||
this.annotationSub = annotation$.subscribe((v) => {
|
||||
const { annotationUpdatesService } = this.props;
|
||||
this.annotationSub = annotationUpdatesService.update$().subscribe((v) => {
|
||||
this.setState({
|
||||
annotationState: v,
|
||||
});
|
||||
|
@ -100,15 +102,17 @@ export class AnnotationFlyoutUI extends Component<CommonProps & Props> {
|
|||
if (this.state.annotationState === null) {
|
||||
return;
|
||||
}
|
||||
const { annotationUpdatesService } = this.props;
|
||||
|
||||
annotation$.next({
|
||||
annotationUpdatesService.setValue({
|
||||
...this.state.annotationState,
|
||||
annotation: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
public cancelEditingHandler = () => {
|
||||
annotation$.next(null);
|
||||
const { annotationUpdatesService } = this.props;
|
||||
annotationUpdatesService.setValue(null);
|
||||
};
|
||||
|
||||
public deleteConfirmHandler = () => {
|
||||
|
@ -148,7 +152,10 @@ export class AnnotationFlyoutUI extends Component<CommonProps & Props> {
|
|||
}
|
||||
|
||||
this.closeDeleteModal();
|
||||
annotation$.next(null);
|
||||
|
||||
const { annotationUpdatesService } = this.props;
|
||||
|
||||
annotationUpdatesService.setValue(null);
|
||||
annotationsRefreshed();
|
||||
};
|
||||
|
||||
|
@ -193,7 +200,8 @@ export class AnnotationFlyoutUI extends Component<CommonProps & Props> {
|
|||
|
||||
public saveOrUpdateAnnotation = () => {
|
||||
const { annotationState: originalAnnotation } = this.state;
|
||||
const { chartDetails, detectorIndex } = this.props;
|
||||
const { chartDetails, detectorIndex, annotationUpdatesService } = this.props;
|
||||
|
||||
if (originalAnnotation === null) {
|
||||
return;
|
||||
}
|
||||
|
@ -218,8 +226,7 @@ export class AnnotationFlyoutUI extends Component<CommonProps & Props> {
|
|||
}
|
||||
// Mark the annotation created by `user` if and only if annotation is being created, not updated
|
||||
annotation.event = annotation.event ?? ANNOTATION_EVENT_USER;
|
||||
|
||||
annotation$.next(null);
|
||||
annotationUpdatesService.setValue(null);
|
||||
|
||||
ml.annotations
|
||||
.indexAnnotation(annotation)
|
||||
|
@ -356,16 +363,16 @@ export class AnnotationFlyoutUI extends Component<CommonProps & Props> {
|
|||
</EuiFormRow>
|
||||
</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty iconType="cross" onClick={this.cancelEditingHandler} flush="left">
|
||||
<EuiButtonEmpty onClick={this.cancelEditingHandler} flush="left">
|
||||
<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.annotationFlyout.cancelButtonLabel"
|
||||
defaultMessage="Cancel"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexItem grow={false} style={{ marginLeft: 'auto' }}>
|
||||
{isExistingAnnotation && (
|
||||
<EuiButtonEmpty color="danger" onClick={this.deleteConfirmHandler}>
|
||||
<FormattedMessage
|
||||
|
@ -376,7 +383,12 @@ export class AnnotationFlyoutUI extends Component<CommonProps & Props> {
|
|||
)}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton fill isDisabled={isInvalid === true} onClick={this.saveOrUpdateAnnotation}>
|
||||
<EuiButton
|
||||
fill
|
||||
isDisabled={isInvalid === true}
|
||||
onClick={this.saveOrUpdateAnnotation}
|
||||
data-test-subj={'annotationFlyoutUpdateButton'}
|
||||
>
|
||||
{isExistingAnnotation ? (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.annotationFlyout.updateButtonLabel"
|
||||
|
@ -403,17 +415,11 @@ export class AnnotationFlyoutUI extends Component<CommonProps & Props> {
|
|||
}
|
||||
|
||||
export const AnnotationFlyout: FC<any> = (props) => {
|
||||
const annotationProp = useObservable(
|
||||
annotation$.pipe(
|
||||
distinctUntilChanged((prev, curr) => {
|
||||
// prevent re-rendering
|
||||
return prev !== null && curr !== null;
|
||||
})
|
||||
)
|
||||
);
|
||||
const annotationUpdatesService = useContext(MlAnnotationUpdatesContext);
|
||||
const annotationProp = useObservable(annotationUpdatesService.isAnnotationInitialized$());
|
||||
|
||||
const cancelEditingHandler = useCallback(() => {
|
||||
annotation$.next(null);
|
||||
annotationUpdatesService.setValue(null);
|
||||
}, []);
|
||||
|
||||
if (annotationProp === undefined || annotationProp === null) {
|
||||
|
@ -423,7 +429,12 @@ export const AnnotationFlyout: FC<any> = (props) => {
|
|||
const isExistingAnnotation = typeof annotationProp._id !== 'undefined';
|
||||
|
||||
return (
|
||||
<EuiFlyout onClose={cancelEditingHandler} size="m" aria-labelledby="Add annotation">
|
||||
<EuiFlyout
|
||||
onClose={cancelEditingHandler}
|
||||
size="m"
|
||||
aria-labelledby="Add annotation"
|
||||
data-test-subj={'mlAnnotationFlyout'}
|
||||
>
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle size="s">
|
||||
<h2 id="mlAnnotationFlyoutTitle">
|
||||
|
@ -441,7 +452,7 @@ export const AnnotationFlyout: FC<any> = (props) => {
|
|||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<AnnotationFlyoutUI {...props} />
|
||||
<AnnotationFlyoutUI {...props} annotationUpdatesService={annotationUpdatesService} />
|
||||
</EuiFlyout>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AnnotationsTable Initialization with annotations prop. 1`] = `
|
||||
<AnnotationsTableUI
|
||||
<Component
|
||||
annotations={
|
||||
Array [
|
||||
Object {
|
||||
|
@ -141,7 +141,7 @@ exports[`AnnotationsTable Initialization with annotations prop. 1`] = `
|
|||
`;
|
||||
|
||||
exports[`AnnotationsTable Initialization with job config prop. 1`] = `
|
||||
<AnnotationsTableUI
|
||||
<Component
|
||||
intl={
|
||||
Object {
|
||||
"defaultFormats": Object {},
|
||||
|
@ -403,7 +403,7 @@ exports[`AnnotationsTable Initialization with job config prop. 1`] = `
|
|||
`;
|
||||
|
||||
exports[`AnnotationsTable Minimal initialization without props. 1`] = `
|
||||
<AnnotationsTableUI
|
||||
<Component
|
||||
intl={
|
||||
Object {
|
||||
"defaultFormats": Object {},
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
import { uniq } from 'lodash';
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component, Fragment } from 'react';
|
||||
import React, { Component, Fragment, useContext } from 'react';
|
||||
import memoizeOne from 'memoize-one';
|
||||
import {
|
||||
EuiBadge,
|
||||
|
@ -41,11 +41,7 @@ import {
|
|||
isTimeSeriesViewJob,
|
||||
} from '../../../../../common/util/job_utils';
|
||||
|
||||
import {
|
||||
annotation$,
|
||||
annotationsRefresh$,
|
||||
annotationsRefreshed,
|
||||
} from '../../../services/annotations_service';
|
||||
import { annotationsRefresh$, annotationsRefreshed } from '../../../services/annotations_service';
|
||||
import {
|
||||
ANNOTATION_EVENT_USER,
|
||||
ANNOTATION_EVENT_DELAYED_DATA,
|
||||
|
@ -54,6 +50,7 @@ import { withKibana } from '../../../../../../../../src/plugins/kibana_react/pub
|
|||
import { ML_APP_URL_GENERATOR, ML_PAGES } from '../../../../../common/constants/ml_url_generator';
|
||||
import { PLUGIN_ID } from '../../../../../common/constants/app';
|
||||
import { timeFormatter } from '../../../../../common/util/date_utils';
|
||||
import { MlAnnotationUpdatesContext } from '../../../contexts/ml/ml_annotation_updates_context';
|
||||
|
||||
const CURRENT_SERIES = 'current_series';
|
||||
/**
|
||||
|
@ -319,7 +316,11 @@ class AnnotationsTableUI extends Component {
|
|||
};
|
||||
|
||||
render() {
|
||||
const { isSingleMetricViewerLinkVisible = true, isNumberBadgeVisible = false } = this.props;
|
||||
const {
|
||||
isSingleMetricViewerLinkVisible = true,
|
||||
isNumberBadgeVisible = false,
|
||||
annotationUpdatesService,
|
||||
} = this.props;
|
||||
|
||||
const { queryText, searchError } = this.state;
|
||||
|
||||
|
@ -474,7 +475,7 @@ class AnnotationsTableUI extends Component {
|
|||
return (
|
||||
<EuiToolTip position="bottom" content={editAnnotationsTooltipText}>
|
||||
<EuiButtonIcon
|
||||
onClick={() => annotation$.next(originalAnnotation ?? annotation)}
|
||||
onClick={() => annotationUpdatesService.setValue(originalAnnotation ?? annotation)}
|
||||
iconType="pencil"
|
||||
aria-label={editAnnotationsTooltipAriaLabelText}
|
||||
/>
|
||||
|
@ -693,4 +694,7 @@ class AnnotationsTableUI extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
export const AnnotationsTable = withKibana(AnnotationsTableUI);
|
||||
export const AnnotationsTable = withKibana((props) => {
|
||||
const annotationUpdatesService = useContext(MlAnnotationUpdatesContext);
|
||||
return <AnnotationsTableUI annotationUpdatesService={annotationUpdatesService} {...props} />;
|
||||
});
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { createContext } from 'react';
|
||||
import { AnnotationUpdatesService } from '../../services/annotations_service';
|
||||
|
||||
export type MlAnnotationUpdatesContextValue = AnnotationUpdatesService;
|
||||
|
||||
export const MlAnnotationUpdatesContext = createContext<MlAnnotationUpdatesContextValue>(
|
||||
new AnnotationUpdatesService()
|
||||
);
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { FC, useEffect, useState, useCallback } from 'react';
|
||||
import React, { FC, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
@ -34,6 +34,8 @@ import { getBreadcrumbWithUrlForApp } from '../breadcrumbs';
|
|||
import { useTimefilter } from '../../contexts/kibana';
|
||||
import { isViewBySwimLaneData } from '../../explorer/swimlane_container';
|
||||
import { JOB_ID } from '../../../../common/constants/anomalies';
|
||||
import { MlAnnotationUpdatesContext } from '../../contexts/ml/ml_annotation_updates_context';
|
||||
import { AnnotationUpdatesService } from '../../services/annotations_service';
|
||||
|
||||
export const explorerRouteFactory = (
|
||||
navigateToPath: NavigateToPath,
|
||||
|
@ -59,10 +61,13 @@ const PageWrapper: FC<PageProps> = ({ deps }) => {
|
|||
jobs: mlJobService.loadJobsWrapper,
|
||||
jobsWithTimeRange: () => ml.jobs.jobsWithTimerange(getDateFormatTz()),
|
||||
});
|
||||
const annotationUpdatesService = useMemo(() => new AnnotationUpdatesService(), []);
|
||||
|
||||
return (
|
||||
<PageLoader context={context}>
|
||||
<ExplorerUrlStateManager jobsWithTimeRange={results.jobsWithTimeRange.jobs} />
|
||||
<MlAnnotationUpdatesContext.Provider value={annotationUpdatesService}>
|
||||
<ExplorerUrlStateManager jobsWithTimeRange={results.jobsWithTimeRange.jobs} />
|
||||
</MlAnnotationUpdatesContext.Provider>
|
||||
</PageLoader>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useEffect, FC } from 'react';
|
||||
import React, { useEffect, FC, useMemo } from 'react';
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
|
@ -19,6 +19,8 @@ import { basicResolvers } from '../resolvers';
|
|||
import { JobsPage } from '../../jobs/jobs_list';
|
||||
import { useTimefilter } from '../../contexts/kibana';
|
||||
import { getBreadcrumbWithUrlForApp } from '../breadcrumbs';
|
||||
import { AnnotationUpdatesService } from '../../services/annotations_service';
|
||||
import { MlAnnotationUpdatesContext } from '../../contexts/ml/ml_annotation_updates_context';
|
||||
|
||||
export const jobListRouteFactory = (navigateToPath: NavigateToPath, basePath: string): MlRoute => ({
|
||||
path: '/jobs',
|
||||
|
@ -57,10 +59,13 @@ const PageWrapper: FC<PageProps> = ({ deps }) => {
|
|||
setGlobalState({ refreshInterval });
|
||||
timefilter.setRefreshInterval(refreshInterval);
|
||||
}, []);
|
||||
const annotationUpdatesService = useMemo(() => new AnnotationUpdatesService(), []);
|
||||
|
||||
return (
|
||||
<PageLoader context={context}>
|
||||
<JobsPage blockRefresh={blockRefresh} lastRefresh={lastRefresh} />
|
||||
<MlAnnotationUpdatesContext.Provider value={annotationUpdatesService}>
|
||||
<JobsPage blockRefresh={blockRefresh} lastRefresh={lastRefresh} />
|
||||
</MlAnnotationUpdatesContext.Provider>
|
||||
</PageLoader>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { isEqual } from 'lodash';
|
||||
import React, { FC, useCallback, useEffect, useState } from 'react';
|
||||
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import usePrevious from 'react-use/lib/usePrevious';
|
||||
import moment from 'moment';
|
||||
|
||||
|
@ -39,7 +39,8 @@ import { basicResolvers } from '../resolvers';
|
|||
import { getBreadcrumbWithUrlForApp } from '../breadcrumbs';
|
||||
import { useTimefilter } from '../../contexts/kibana';
|
||||
import { useToastNotificationService } from '../../services/toast_notification_service';
|
||||
|
||||
import { AnnotationUpdatesService } from '../../services/annotations_service';
|
||||
import { MlAnnotationUpdatesContext } from '../../contexts/ml/ml_annotation_updates_context';
|
||||
export const timeSeriesExplorerRouteFactory = (
|
||||
navigateToPath: NavigateToPath,
|
||||
basePath: string
|
||||
|
@ -64,13 +65,16 @@ const PageWrapper: FC<PageProps> = ({ deps }) => {
|
|||
jobs: mlJobService.loadJobsWrapper,
|
||||
jobsWithTimeRange: () => ml.jobs.jobsWithTimerange(getDateFormatTz()),
|
||||
});
|
||||
const annotationUpdatesService = useMemo(() => new AnnotationUpdatesService(), []);
|
||||
|
||||
return (
|
||||
<PageLoader context={context}>
|
||||
<TimeSeriesExplorerUrlStateManager
|
||||
config={deps.config}
|
||||
jobsWithTimeRange={results.jobsWithTimeRange.jobs}
|
||||
/>
|
||||
<MlAnnotationUpdatesContext.Provider value={annotationUpdatesService}>
|
||||
<TimeSeriesExplorerUrlStateManager
|
||||
config={deps.config}
|
||||
jobsWithTimeRange={results.jobsWithTimeRange.jobs}
|
||||
/>
|
||||
</MlAnnotationUpdatesContext.Provider>
|
||||
</PageLoader>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -7,20 +7,29 @@
|
|||
import mockAnnotations from '../components/annotations/annotations_table/__mocks__/mock_annotations.json';
|
||||
|
||||
import { Annotation } from '../../../common/types/annotations';
|
||||
import { annotation$, annotationsRefresh$, annotationsRefreshed } from './annotations_service';
|
||||
|
||||
import {
|
||||
annotationsRefresh$,
|
||||
annotationsRefreshed,
|
||||
AnnotationUpdatesService,
|
||||
} from './annotations_service';
|
||||
describe('annotations_service', () => {
|
||||
test('annotation$', () => {
|
||||
let annotationUpdatesService: AnnotationUpdatesService | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
annotationUpdatesService = new AnnotationUpdatesService();
|
||||
});
|
||||
|
||||
test('annotationUpdatesService', () => {
|
||||
const subscriber = jest.fn();
|
||||
|
||||
annotation$.subscribe(subscriber);
|
||||
annotationUpdatesService!.update$().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);
|
||||
annotationUpdatesService!.setValue(annotation);
|
||||
|
||||
// the subscriber should have been triggered with the updated annotation value
|
||||
expect(subscriber.mock.calls).toHaveLength(2);
|
||||
|
|
|
@ -4,8 +4,8 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { distinctUntilChanged } from 'rxjs/operators';
|
||||
import { Annotation } from '../../../common/types/annotations';
|
||||
|
||||
/*
|
||||
|
@ -79,3 +79,25 @@ export const annotation$ = new BehaviorSubject<AnnotationState>(null);
|
|||
*/
|
||||
export const annotationsRefresh$ = new BehaviorSubject(Date.now());
|
||||
export const annotationsRefreshed = () => annotationsRefresh$.next(Date.now());
|
||||
|
||||
export class AnnotationUpdatesService {
|
||||
private _annotation$: BehaviorSubject<AnnotationState> = new BehaviorSubject<AnnotationState>(
|
||||
null
|
||||
);
|
||||
|
||||
public update$() {
|
||||
return this._annotation$.asObservable();
|
||||
}
|
||||
public isAnnotationInitialized$(): Observable<AnnotationState> {
|
||||
return this._annotation$.asObservable().pipe(
|
||||
distinctUntilChanged((prev, curr) => {
|
||||
// prevent re-rendering
|
||||
return prev !== null && curr !== null;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public setValue(annotation: AnnotationState) {
|
||||
this._annotation$.next(annotation);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import React from 'react';
|
|||
import { Annotation } from '../../../../../common/types/annotations';
|
||||
import { CombinedJob } from '../../../../../common/types/anomaly_detection_jobs';
|
||||
import { ChartTooltipService } from '../../../components/chart_tooltip';
|
||||
import { AnnotationState, AnnotationUpdatesService } from '../../../services/annotations_service';
|
||||
|
||||
interface Props {
|
||||
selectedJob: CombinedJob;
|
||||
|
@ -47,6 +48,11 @@ interface TimeseriesChartProps {
|
|||
tooltipService: object;
|
||||
}
|
||||
|
||||
declare class TimeseriesChart extends React.Component<Props, any> {
|
||||
interface TimeseriesChartIntProps {
|
||||
annotationUpdatesService: AnnotationUpdatesService;
|
||||
annotationProps: AnnotationState;
|
||||
}
|
||||
|
||||
declare class TimeseriesChart extends React.Component<Props & TimeseriesChartIntProps, any> {
|
||||
focusXScale: d3.scale.Ordinal<{}, number>;
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
*/
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import React, { Component, useContext } from 'react';
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
import { isEqual, reduce, each, get } from 'lodash';
|
||||
import d3 from 'd3';
|
||||
|
@ -21,7 +21,6 @@ import {
|
|||
getSeverityWithLow,
|
||||
getMultiBucketImpactLabel,
|
||||
} from '../../../../../common/util/anomaly_utils';
|
||||
import { annotation$ } from '../../../services/annotations_service';
|
||||
import { formatValue } from '../../../formatters/format_value';
|
||||
import {
|
||||
LINE_CHART_ANOMALY_RADIUS,
|
||||
|
@ -51,7 +50,7 @@ import {
|
|||
unhighlightFocusChartAnnotation,
|
||||
ANNOTATION_MIN_WIDTH,
|
||||
} from './timeseries_chart_annotations';
|
||||
import { distinctUntilChanged } from 'rxjs/operators';
|
||||
import { MlAnnotationUpdatesContext } from '../../../contexts/ml/ml_annotation_updates_context';
|
||||
|
||||
const focusZoomPanelHeight = 25;
|
||||
const focusChartHeight = 310;
|
||||
|
@ -571,7 +570,6 @@ class TimeseriesChartIntl extends Component {
|
|||
}
|
||||
|
||||
renderFocusChart() {
|
||||
console.log('renderFocusChart');
|
||||
const {
|
||||
focusAggregationInterval,
|
||||
focusAnnotationData: focusAnnotationDataOriginalPropValue,
|
||||
|
@ -742,7 +740,8 @@ class TimeseriesChartIntl extends Component {
|
|||
this.focusXScale,
|
||||
showAnnotations,
|
||||
showFocusChartTooltip,
|
||||
hideFocusChartTooltip
|
||||
hideFocusChartTooltip,
|
||||
this.props.annotationUpdatesService
|
||||
);
|
||||
|
||||
// disable brushing (creation of annotations) when annotations aren't shown
|
||||
|
@ -1800,17 +1799,17 @@ class TimeseriesChartIntl extends Component {
|
|||
}
|
||||
|
||||
export const TimeseriesChart = (props) => {
|
||||
const annotationProp = useObservable(
|
||||
annotation$.pipe(
|
||||
distinctUntilChanged((prev, curr) => {
|
||||
// prevent re-rendering
|
||||
return prev !== null && curr !== null;
|
||||
})
|
||||
)
|
||||
);
|
||||
const annotationUpdatesService = useContext(MlAnnotationUpdatesContext);
|
||||
const annotationProp = useObservable(annotationUpdatesService.isAnnotationInitialized$());
|
||||
|
||||
if (annotationProp === undefined) {
|
||||
return null;
|
||||
}
|
||||
return <TimeseriesChartIntl annotation={annotationProp} {...props} />;
|
||||
return (
|
||||
<TimeseriesChartIntl
|
||||
annotation={annotationProp}
|
||||
{...props}
|
||||
annotationUpdatesService={annotationUpdatesService}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -13,13 +13,14 @@ import { Dictionary } from '../../../../../common/types/common';
|
|||
|
||||
import { TimeseriesChart } from './timeseries_chart';
|
||||
|
||||
import { annotation$ } from '../../../services/annotations_service';
|
||||
import { AnnotationUpdatesService } from '../../../services/annotations_service';
|
||||
|
||||
export const ANNOTATION_MASK_ID = 'mlAnnotationMask';
|
||||
|
||||
// getAnnotationBrush() is expected to be called like getAnnotationBrush.call(this)
|
||||
// so it gets passed on the context of the component it gets called from.
|
||||
export function getAnnotationBrush(this: TimeseriesChart) {
|
||||
const { annotationUpdatesService } = this.props;
|
||||
const focusXScale = this.focusXScale;
|
||||
|
||||
const annotateBrush = d3.svg.brush().x(focusXScale).on('brushend', brushend.bind(this));
|
||||
|
@ -35,7 +36,7 @@ export function getAnnotationBrush(this: TimeseriesChart) {
|
|||
const endTimestamp = extent[1].getTime();
|
||||
|
||||
if (timestamp === endTimestamp) {
|
||||
annotation$.next(null);
|
||||
annotationUpdatesService.setValue(null);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -47,7 +48,7 @@ export function getAnnotationBrush(this: TimeseriesChart) {
|
|||
type: ANNOTATION_TYPE.ANNOTATION,
|
||||
};
|
||||
|
||||
annotation$.next(annotation);
|
||||
annotationUpdatesService.setValue(annotation);
|
||||
}
|
||||
|
||||
return annotateBrush;
|
||||
|
@ -105,7 +106,8 @@ export function renderAnnotations(
|
|||
focusXScale: TimeseriesChart['focusXScale'],
|
||||
showAnnotations: boolean,
|
||||
showFocusChartTooltip: (d: Annotation, t: object) => {},
|
||||
hideFocusChartTooltip: () => void
|
||||
hideFocusChartTooltip: () => void,
|
||||
annotationUpdatesService: AnnotationUpdatesService
|
||||
) {
|
||||
const upperRectMargin = ANNOTATION_UPPER_RECT_MARGIN;
|
||||
const upperTextMargin = ANNOTATION_UPPER_TEXT_MARGIN;
|
||||
|
@ -153,9 +155,9 @@ export function renderAnnotations(
|
|||
// 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);
|
||||
annotationUpdatesService.setValue(null);
|
||||
// set the actual annotation and trigger the flyout
|
||||
annotation$.next(d);
|
||||
annotationUpdatesService.setValue(d);
|
||||
});
|
||||
|
||||
rects
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { FC, useEffect, useState, useCallback } from 'react';
|
||||
import React, { FC, useEffect, useState, useCallback, useContext } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { MlTooltipComponent } from '../../../components/chart_tooltip';
|
||||
import { TimeseriesChart } from './timeseries_chart';
|
||||
|
@ -16,6 +16,7 @@ import { useMlKibana, useNotifications } from '../../../contexts/kibana';
|
|||
import { getBoundsRoundedToInterval } from '../../../util/time_buckets';
|
||||
import { ANNOTATION_EVENT_USER } from '../../../../../common/constants/annotations';
|
||||
import { getControlsForDetector } from '../../get_controls_for_detector';
|
||||
import { MlAnnotationUpdatesContext } from '../../../contexts/ml/ml_annotation_updates_context';
|
||||
|
||||
interface TimeSeriesChartWithTooltipsProps {
|
||||
bounds: any;
|
||||
|
@ -50,6 +51,8 @@ export const TimeSeriesChartWithTooltips: FC<TimeSeriesChartWithTooltipsProps> =
|
|||
},
|
||||
} = useMlKibana();
|
||||
|
||||
const annotationUpdatesService = useContext(MlAnnotationUpdatesContext);
|
||||
|
||||
const [annotationData, setAnnotationData] = useState<Annotation[]>([]);
|
||||
|
||||
const showAnnotationErrorToastNotification = useCallback((error?: string) => {
|
||||
|
@ -123,6 +126,7 @@ export const TimeSeriesChartWithTooltips: FC<TimeSeriesChartWithTooltipsProps> =
|
|||
{(tooltipService) => (
|
||||
<TimeseriesChart
|
||||
{...chartProps}
|
||||
annotationUpdatesService={annotationUpdatesService}
|
||||
annotationData={annotationData}
|
||||
bounds={bounds}
|
||||
detectorIndex={detectorIndex}
|
||||
|
|
|
@ -1014,7 +1014,6 @@ export class TimeSeriesExplorer extends React.Component {
|
|||
this.previousShowForecast = showForecast;
|
||||
this.previousShowModelBounds = showModelBounds;
|
||||
|
||||
console.log('Timeseriesexplorer rerendered');
|
||||
return (
|
||||
<TimeSeriesExplorerPage dateFormatTz={dateFormatTz} resizeRef={this.resizeRef}>
|
||||
{fieldNamesWithEmptyValues.length > 0 && (
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue