[ML] Migrates single metric viewer to React. (#41739) (#42166)

Migrates the overall page of Single Metric Viewer to React.
This commit is contained in:
Walter Rafelsberger 2019-07-29 20:09:16 +02:00 committed by GitHub
parent d77145c3c6
commit 82ac64dbe3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 2387 additions and 2434 deletions

View file

@ -1,45 +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.
*/
/*
* 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

@ -1,55 +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 jobConfig from '../../../../../common/types/__mocks__/job_config_farequote';
import ngMock from 'ng_mock';
import expect from '@kbn/expect';
import sinon from 'sinon';
import { ml } from '../../../../services/ml_api_service';
describe('ML - <ml-annotation-table>', () => {
let $scope;
let $compile;
beforeEach(ngMock.module('kibana'));
beforeEach(() => {
ngMock.inject(function ($injector) {
$compile = $injector.get('$compile');
const $rootScope = $injector.get('$rootScope');
$scope = $rootScope.$new();
});
});
afterEach(() => {
$scope.$destroy();
});
it('Plain initialization doesn\'t throw an error', () => {
expect(() => {
$compile('<ml-annotation-table />')($scope);
}).to.not.throwError();
});
it('Initialization with empty annotations array doesn\'t throw an error', () => {
expect(() => {
$compile('<ml-annotation-table annotations="[]" />')($scope);
}).to.not.throwError();
});
it('Initialization with job config doesn\'t throw an error', () => {
const getAnnotationsStub = sinon.stub(ml.annotations, 'getAnnotations').resolves({ annotations: [] });
expect(() => {
$scope.jobs = [jobConfig];
$compile('<ml-annotation-table jobs="jobs" />')($scope);
}).to.not.throwError();
getAnnotationsStub.restore();
});
});

View file

@ -1,81 +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.
*/
/*
* angularjs wrapper directive for the AnnotationsTable React component.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import { AnnotationsTable } from './annotations_table';
import 'angular';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
import chrome from 'ui/chrome';
const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false);
import { I18nContext } from 'ui/i18n';
module.directive('mlAnnotationTable', function () {
function link(scope, element) {
function renderReactComponent() {
if (typeof scope.jobs === 'undefined' && typeof scope.annotations === 'undefined') {
return;
}
const props = {
annotations: scope.annotations,
jobs: scope.jobs,
isSingleMetricViewerLinkVisible: scope.drillDown,
isNumberBadgeVisible: scope.numberBadge
};
ReactDOM.render(
<I18nContext>
{React.createElement(AnnotationsTable, props)}
</I18nContext>,
element[0]
);
}
renderReactComponent();
scope.$on('render', () => {
renderReactComponent();
});
function renderFocusChart() {
renderReactComponent();
}
if (mlAnnotationsEnabled) {
scope.$watchCollection('annotations', renderFocusChart);
}
element.on('$destroy', () => {
ReactDOM.unmountComponentAtNode(element[0]);
scope.$destroy();
});
}
return {
scope: {
annotations: '=',
drillDown: '=',
jobs: '=',
numberBadge: '='
},
link: link
};
});

View file

@ -5,5 +5,3 @@
*/
export { AnnotationsTable } from './annotations_table';
import './annotations_table_directive';

View file

@ -1,31 +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 'ngreact';
import { wrapInI18nContext } from 'ui/i18n';
import { uiModules } from 'ui/modules';
import { timefilter } from 'ui/timefilter';
const module = uiModules.get('apps/ml', ['react']);
import { AnomaliesTable } from './anomalies_table';
module.directive('mlAnomaliesTable', function ($injector) {
const reactDirective = $injector.get('reactDirective');
return reactDirective(
wrapInI18nContext(AnomaliesTable),
[
['filter', { watchDepth: 'reference' }],
['tableData', { watchDepth: 'reference' }]
],
{ restrict: 'E' },
{
timefilter
}
);
});

View file

@ -5,4 +5,4 @@
*/
import './anomalies_table_directive';
export { AnomaliesTable } from './anomalies_table';

View file

@ -1,15 +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 { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml', ['react']);
import { subscribeAppStateToObservable } from '../../../util/app_state_utils';
import { showCharts$ } from './checkbox_showcharts';
module.service('mlCheckboxShowChartsService', function (AppState, $rootScope) {
subscribeAppStateToObservable(AppState, 'mlShowCharts', showCharts$, () => $rootScope.$applyAsync());
});

View file

@ -4,5 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
import './checkbox_showcharts_service';
export { CheckboxShowCharts, showCharts$ } from './checkbox_showcharts';

View file

@ -4,8 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
import './checkbox_showcharts';
import './select_interval';
import './select_severity';
export { CheckboxShowCharts, showCharts$ } from './checkbox_showcharts';
export { interval$, SelectInterval } from './select_interval';
export { SelectSeverity, severity$, SEVERITY_OPTIONS } from './select_severity';

View file

@ -1,53 +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 expect from '@kbn/expect';
import ngMock from 'ng_mock';
import { interval$ } from '../select_interval';
describe('ML - mlSelectIntervalService', () => {
let appState;
beforeEach(ngMock.module('kibana', (stateManagementConfigProvider) => {
stateManagementConfigProvider.enable();
}));
beforeEach(ngMock.module(($provide) => {
appState = {
fetch: () => {},
save: () => {}
};
$provide.factory('AppState', () => () => appState);
}));
it('initializes AppState with correct default value', (done) => {
ngMock.inject(($injector) => {
$injector.get('mlSelectIntervalService');
const defaultValue = { display: 'Auto', val: 'auto' };
expect(appState.mlSelectInterval).to.eql(defaultValue);
expect(interval$.getValue()).to.eql(defaultValue);
done();
});
});
it('restores AppState to interval$ observable', (done) => {
ngMock.inject(($injector) => {
const restoreValue = { display: '1 day', val: 'day' };
appState.mlSelectInterval = restoreValue;
$injector.get('mlSelectIntervalService');
expect(appState.mlSelectInterval).to.eql(restoreValue);
expect(interval$.getValue()).to.eql(restoreValue);
done();
});
});
});

View file

@ -5,4 +5,4 @@
*/
import './select_interval_directive';
export { interval$, SelectInterval } from './select_interval';

View file

@ -1,27 +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 'ngreact';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml', ['react']);
import { subscribeAppStateToObservable } from '../../../util/app_state_utils';
import { SelectInterval, interval$ } from './select_interval';
module.service('mlSelectIntervalService', function (AppState, $rootScope) {
subscribeAppStateToObservable(AppState, 'mlSelectInterval', interval$, () => $rootScope.$applyAsync());
})
.directive('mlSelectInterval', function ($injector) {
const reactDirective = $injector.get('reactDirective');
return reactDirective(
SelectInterval,
undefined,
{ restrict: 'E' }
);
});

View file

@ -5,4 +5,4 @@
*/
import './select_severity_directive';
export { SelectSeverity, severity$, SEVERITY_OPTIONS } from './select_severity';

View file

@ -1,29 +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 'ngreact';
import { wrapInI18nContext } from 'ui/i18n';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml', ['react']);
import { subscribeAppStateToObservable } from '../../../util/app_state_utils';
import { SelectSeverity, severity$ } from './select_severity';
module.service('mlSelectSeverityService', function (AppState, $rootScope) {
subscribeAppStateToObservable(AppState, 'mlSelectSeverity', severity$, () => $rootScope.$applyAsync());
})
.directive('mlSelectSeverity', function ($injector) {
const reactDirective = $injector.get('reactDirective');
return reactDirective(
wrapInI18nContext(SelectSeverity),
undefined,
{ restrict: 'E' },
);
});

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export * from './influencers_list';
export { InfluencersList } from './influencers_list';

View file

@ -387,7 +387,7 @@ CustomSelectionTable.propTypes = {
items: PropTypes.array.isRequired,
onTableChange: PropTypes.func.isRequired,
selectedId: PropTypes.array,
singleSelection: PropTypes.string,
singleSelection: PropTypes.bool,
sortableProperties: PropTypes.object,
timeseriesOnly: PropTypes.string
timeseriesOnly: PropTypes.bool
};

View file

@ -4,7 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
import './job_selector_react_wrapper_directive';
export { JobSelector } from './job_selector';

View file

@ -4,13 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { difference } from 'lodash';
import { difference, isEqual } from 'lodash';
import { BehaviorSubject } from 'rxjs';
import { toastNotifications } from 'ui/notify';
import { mlJobService } from '../../services/job_service';
import { i18n } from '@kbn/i18n';
import moment from 'moment';
import d3 from 'd3';
import { mlJobService } from '../../services/job_service';
function warnAboutInvalidJobIds(invalidIds) {
if (invalidIds.length > 0) {
@ -34,6 +35,30 @@ function getInvalidJobIds(ids) {
});
}
export const jobSelectServiceFactory = (globalState) => {
const { jobIds, selectedGroups } = getSelectedJobIds(globalState);
const jobSelectService = new BehaviorSubject({ selection: jobIds, groups: selectedGroups, resetSelection: false });
// Subscribe to changes to globalState and trigger
// a jobSelectService update if the job selection changed.
const listener = () => {
const { jobIds: newJobIds, selectedGroups: newSelectedGroups } = getSelectedJobIds(globalState);
const oldSelectedJobIds = jobSelectService.getValue().selection;
if (newJobIds && !(isEqual(oldSelectedJobIds, newJobIds))) {
jobSelectService.next({ selection: newJobIds, groups: newSelectedGroups });
}
};
globalState.on('save_with_changes', listener);
const unsubscribeFromGlobalState = () => {
globalState.off('save_with_changes', listener);
};
return { jobSelectService, unsubscribeFromGlobalState };
};
function loadJobIdsFromGlobalState(globalState) { // jobIds, groups
// fetch to get the latest state
globalState.fetch();

View file

@ -72,13 +72,13 @@ const BADGE_LIMIT = 10;
const DEFAULT_GANTT_BAR_WIDTH = 299; // pixels
export function JobSelector({
config,
dateFormatTz,
globalState,
jobSelectService,
selectedJobIds,
selectedGroups,
singleSelection,
timeseriesOnly
timeseriesOnly,
}) {
const [jobs, setJobs] = useState([]);
const [groups, setGroups] = useState([]);
@ -114,8 +114,6 @@ export function JobSelector({
// Not wrapping it would cause this dependency to change on every render
const handleResize = useCallback(() => {
if (jobs.length > 0 && flyoutEl && flyoutEl.current && flyoutEl.current.flyout) {
const tzConfig = config.get('dateFormat:tz');
const dateFormatTz = (tzConfig !== 'Browser') ? tzConfig : moment.tz.guess();
// get all cols in flyout table
const tableHeaderCols = flyoutEl.current.flyout.querySelectorAll('table thead th');
// get the width of the last col
@ -126,7 +124,7 @@ export function JobSelector({
setGroups(updatedGroups);
setGanttBarWidth(derivedWidth);
}
}, [config, jobs]);
}, [dateFormatTz, jobs]);
useEffect(() => {
// Ensure ganttBar width gets calculated on resize
@ -151,8 +149,6 @@ export function JobSelector({
function handleJobSelectionClick() {
showFlyout();
const tzConfig = config.get('dateFormat:tz');
const dateFormatTz = (tzConfig !== 'Browser') ? tzConfig : moment.tz.guess();
ml.jobs.jobsWithTimerange(dateFormatTz)
.then((resp) => {
@ -381,6 +377,6 @@ JobSelector.propTypes = {
globalState: PropTypes.object,
jobSelectService: PropTypes.object,
selectedJobIds: PropTypes.array,
singleSelection: PropTypes.string,
timeseriesOnly: PropTypes.string
singleSelection: PropTypes.bool,
timeseriesOnly: PropTypes.bool
};

View file

@ -1,67 +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.
*/
/*
* AngularJS directive wrapper for rendering Job Selector React component.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import _ from 'lodash';
import { JobSelector } from './job_selector';
import { getSelectedJobIds } from './job_select_service_utils';
import { BehaviorSubject } from 'rxjs';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
module
.directive('mlJobSelectorReactWrapper', function (globalState, config, mlJobSelectService) {
function link(scope, element, attrs) {
const { jobIds, selectedGroups } = getSelectedJobIds(globalState);
const props = {
config,
globalState,
jobSelectService: mlJobSelectService,
selectedJobIds: jobIds,
selectedGroups,
timeseriesOnly: attrs.timeseriesonly,
singleSelection: attrs.singleselection
};
ReactDOM.render(React.createElement(JobSelector, props),
element[0]
);
element.on('$destroy', () => {
ReactDOM.unmountComponentAtNode(element[0]);
scope.$destroy();
});
}
return {
scope: false,
link,
};
})
.service('mlJobSelectService', function (globalState) {
const { jobIds, selectedGroups } = getSelectedJobIds(globalState);
const mlJobSelectService = new BehaviorSubject({ selection: jobIds, groups: selectedGroups, resetSelection: false });
// Subscribe to changes to globalState and trigger
// a mlJobSelectService update if the job selection changed.
globalState.on('save_with_changes', () => {
const { jobIds: newJobIds, selectedGroups: newSelectedGroups } = getSelectedJobIds(globalState);
const oldSelectedJobIds = mlJobSelectService.getValue().selection;
if (newJobIds && !(_.isEqual(oldSelectedJobIds, newJobIds))) {
mlJobSelectService.next({ selection: newJobIds, groups: newSelectedGroups });
}
});
return mlJobSelectService;
});

View file

@ -232,7 +232,7 @@ export function JobSelectorTable({
return (
<Fragment>
{jobs.length === 0 && <EuiLoadingSpinner size="l" />}
{jobs.length !== 0 && singleSelection === 'true' && renderJobsTable()}
{jobs.length !== 0 && singleSelection === true && renderJobsTable()}
{jobs.length !== 0 && singleSelection === undefined && renderTabs()}
</Fragment>
);
@ -244,6 +244,6 @@ JobSelectorTable.propTypes = {
jobs: PropTypes.array,
onSelection: PropTypes.func.isRequired,
selectedIds: PropTypes.array.isRequired,
singleSelection: PropTypes.string,
timeseriesOnly: PropTypes.string
singleSelection: PropTypes.bool,
timeseriesOnly: PropTypes.bool
};

View file

@ -109,21 +109,21 @@ describe('JobSelectorTable', () => {
describe('Single Selection', () => {
test('Does not render tabs', () => {
const singleSelectionProps = { ...props, singleSelection: 'true' };
const singleSelectionProps = { ...props, singleSelection: true };
const { queryByRole } = render(<JobSelectorTable {...singleSelectionProps} />);
const tabs = queryByRole('tab');
expect(tabs).toBeNull();
});
test('incoming selectedId is selected in the table', () => {
const singleSelectionProps = { ...props, singleSelection: 'true' };
const singleSelectionProps = { ...props, singleSelection: true };
const { getByTestId } = render(<JobSelectorTable {...singleSelectionProps} />);
const radioButton = getByTestId('price-by-day-radio-button');
expect(radioButton.firstChild.checked).toEqual(true);
});
test('job cannot be selected if it is not a single metric viewer job', () => {
const timeseriesOnlyProps = { ...props, singleSelection: 'true', timeseriesOnly: 'true' };
const timeseriesOnlyProps = { ...props, singleSelection: true, timeseriesOnly: true };
const { getByTestId } = render(<JobSelectorTable {...timeseriesOnlyProps} />);
const radioButton = getByTestId('non-timeseries-job-radio-button');
expect(radioButton.firstChild.disabled).toEqual(true);
@ -179,4 +179,3 @@ describe('JobSelectorTable', () => {
});
});

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export * from './explorer_no_influencers_found';
export { ExplorerNoInfluencersFound } from './explorer_no_influencers_found';

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export * from './explorer_no_jobs_found';
export { ExplorerNoJobsFound } from './explorer_no_jobs_found';

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export * from './explorer_no_results_found';
export { ExplorerNoResultsFound } from './explorer_no_results_found';

View file

@ -4,6 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
export * from './explorer_no_influencers_found';
export * from './explorer_no_jobs_found';
export * from './explorer_no_results_found';
export { ExplorerNoInfluencersFound } from './explorer_no_influencers_found';
export { ExplorerNoJobsFound } from './explorer_no_jobs_found';
export { ExplorerNoResultsFound } from './explorer_no_results_found';

View file

@ -1,8 +0,0 @@
<ml-nav-menu name="explorer" />
<ml-chart-tooltip></ml-chart-tooltip>
<div class="ml-explorer" ng-controller="MlExplorerController" data-test-subj="mlPageAnomalyExplorer" >
<ml-job-selector-react-wrapper />
<ml-explorer-react-wrapper />
</div>

View file

@ -10,7 +10,7 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import React, { Fragment } from 'react';
import { FormattedMessage, injectI18n } from '@kbn/i18n/react';
import DragSelect from 'dragselect/dist/ds.min.js';
import { map } from 'rxjs/operators';
@ -37,11 +37,14 @@ import { ExplorerSwimlane } from './explorer_swimlane';
import { KqlFilterBar } from '../components/kql_filter_bar';
import { formatHumanReadableDateTime } from '../util/date_utils';
import { getBoundsRoundedToInterval } from 'plugins/ml/util/ml_time_buckets';
import { getSelectedJobIds } from '../components/job_selector/job_select_service_utils';
import { InfluencersList } from '../components/influencers_list';
import { ALLOW_CELL_RANGE_SELECTION, dragSelect$, explorer$ } from './explorer_dashboard_service';
import { mlResultsService } from 'plugins/ml/services/results_service';
import { LoadingIndicator } from '../components/loading_indicator/loading_indicator';
import { CheckboxShowCharts, showCharts$ } from '../components/controls/checkbox_showcharts/checkbox_showcharts';
import { NavigationMenu } from '../components/navigation_menu/navigation_menu';
import { CheckboxShowCharts, showCharts$ } from '../components/controls/checkbox_showcharts';
import { JobSelector } from '../components/job_selector';
import { SelectInterval, interval$ } from '../components/controls/select_interval/select_interval';
import { SelectLimit, limit$ } from './select_limit/select_limit';
import { SelectSeverity, severity$ } from '../components/controls/select_severity/select_severity';
@ -132,6 +135,14 @@ function mapSwimlaneOptionsToEuiOptions(options) {
}));
}
const ExplorerPage = ({ children, jobSelectorProps }) => (
<Fragment>
<NavigationMenu tabId="explorer" />
<JobSelector {...jobSelectorProps} />
{children}
</Fragment>
);
export const Explorer = injectI18n(injectObservablesAsProps(
{
annotationsRefresh: annotationsRefresh$,
@ -144,7 +155,10 @@ export const Explorer = injectI18n(injectObservablesAsProps(
class Explorer extends React.Component {
static propTypes = {
appStateHandler: PropTypes.func.isRequired,
config: PropTypes.object.isRequired,
dateFormatTz: PropTypes.string.isRequired,
globalState: PropTypes.object.isRequired,
jobSelectService: PropTypes.object.isRequired,
MlTimeBuckets: PropTypes.func.isRequired,
};
@ -1046,7 +1060,10 @@ export const Explorer = injectI18n(injectObservablesAsProps(
render() {
const {
dateFormatTz,
globalState,
intl,
jobSelectService,
MlTimeBuckets,
} = this.props;
@ -1077,23 +1094,34 @@ export const Explorer = injectI18n(injectObservablesAsProps(
const swimlaneWidth = getSwimlaneContainerWidth(noInfluencersConfigured);
const { jobIds: selectedJobIds, selectedGroups } = getSelectedJobIds(globalState);
const jobSelectorProps = {
dateFormatTz,
globalState,
jobSelectService,
selectedJobIds,
selectedGroups,
};
if (loading === true) {
return (
<LoadingIndicator
label={intl.formatMessage({
id: 'xpack.ml.explorer.loadingLabel',
defaultMessage: 'Loading',
})}
/>
<ExplorerPage jobSelectorProps={jobSelectorProps}>
<LoadingIndicator
label={intl.formatMessage({
id: 'xpack.ml.explorer.loadingLabel',
defaultMessage: 'Loading',
})}
/>
</ExplorerPage>
);
}
if (noJobsFound) {
return <ExplorerNoJobsFound />;
return <ExplorerPage jobSelectorProps={jobSelectorProps}><ExplorerNoJobsFound /></ExplorerPage>;
}
if (noJobsFound && hasResults === false) {
return <ExplorerNoResultsFound />;
return <ExplorerPage jobSelectorProps={jobSelectorProps}><ExplorerNoResultsFound /></ExplorerPage>;
}
const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10';
@ -1106,9 +1134,10 @@ export const Explorer = injectI18n(injectObservablesAsProps(
);
return (
<div className="results-container">
<ExplorerPage jobSelectorProps={jobSelectorProps}>
<div className="results-container">
{noInfluencersConfigured === false &&
{noInfluencersConfigured === false &&
influencers !== undefined &&
<div className="mlAnomalyExplorer__filterBar">
<KqlFilterBar
@ -1120,222 +1149,223 @@ export const Explorer = injectI18n(injectObservablesAsProps(
/>
</div>}
{noInfluencersConfigured && (
<div className="no-influencers-warning">
<EuiIconTip
content={intl.formatMessage({
id: 'xpack.ml.explorer.noConfiguredInfluencersTooltip',
defaultMessage:
{noInfluencersConfigured && (
<div className="no-influencers-warning">
<EuiIconTip
content={intl.formatMessage({
id: 'xpack.ml.explorer.noConfiguredInfluencersTooltip',
defaultMessage:
'The Top Influencers list is hidden because no influencers have been configured for the selected jobs.',
})}
position="right"
type="iInCircle"
/>
</div>
)}
})}
position="right"
type="iInCircle"
/>
</div>
)}
{noInfluencersConfigured === false && (
<div className="column col-xs-2 euiText" data-test-subj="mlAnomalyExplorerInfluencerList">
<span className="panel-title">
{noInfluencersConfigured === false && (
<div className="column col-xs-2 euiText" data-test-subj="mlAnomalyExplorerInfluencerList">
<span className="panel-title">
<FormattedMessage
id="xpack.ml.explorer.topInfuencersTitle"
defaultMessage="Top Influencers"
/>
</span>
<InfluencersList
influencers={influencers}
influencerFilter={this.applyFilter}
/>
</div>
)}
<div className={mainColumnClasses}>
<span className="panel-title euiText">
<FormattedMessage
id="xpack.ml.explorer.topInfuencersTitle"
defaultMessage="Top Influencers"
id="xpack.ml.explorer.anomalyTimelineTitle"
defaultMessage="Anomaly timeline"
/>
</span>
<InfluencersList
influencers={influencers}
<div
className="ml-explorer-swimlane euiText"
onMouseEnter={this.onSwimlaneEnterHandler}
onMouseLeave={this.onSwimlaneLeaveHandler}
data-test-subj="mlAnomalyExplorerSwimlaneOverall"
>
<ExplorerSwimlane
chartWidth={swimlaneWidth}
filterActive={filterActive}
maskAll={maskAll}
MlTimeBuckets={MlTimeBuckets}
swimlaneCellClick={this.swimlaneCellClick}
swimlaneData={overallSwimlaneData}
swimlaneType={SWIMLANE_TYPE.OVERALL}
selection={selectedCells}
swimlaneRenderDoneListener={this.swimlaneRenderDoneListener}
/>
</div>
{viewBySwimlaneOptions.length > 0 && (
<React.Fragment>
<EuiFlexGroup direction="row" gutterSize="l" responsive={true}>
<EuiFlexItem grow={false}>
<EuiFormRow
label={intl.formatMessage({
id: 'xpack.ml.explorer.viewByLabel',
defaultMessage: 'View by',
})}
>
<EuiSelect
id="selectViewBy"
options={mapSwimlaneOptionsToEuiOptions(viewBySwimlaneOptions)}
value={swimlaneViewByFieldName}
onChange={this.viewByChangeHandler}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormRow
label={intl.formatMessage({
id: 'xpack.ml.explorer.limitLabel',
defaultMessage: 'Limit',
})}
>
<SelectLimit />
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ alignSelf: 'center' }}>
<EuiFormRow label="&#8203;">
<div className="panel-sub-title">
{viewByLoadedForTimeFormatted && (
<FormattedMessage
id="xpack.ml.explorer.sortedByMaxAnomalyScoreForTimeFormattedLabel"
defaultMessage="(Sorted by max anomaly score for {viewByLoadedForTimeFormatted})"
values={{ viewByLoadedForTimeFormatted }}
/>
)}
{viewByLoadedForTimeFormatted === undefined && (
<FormattedMessage
id="xpack.ml.explorer.sortedByMaxAnomalyScoreLabel"
defaultMessage="(Sorted by max anomaly score)"
/>
)}
{filterActive === true &&
swimlaneViewByFieldName === 'job ID' && (
<FormattedMessage
id="xpack.ml.explorer.jobScoreAcrossAllInfluencersLabel"
defaultMessage="(Job score across all influencers)"
/>
)}
</div>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
{showViewBySwimlane && (
<div
className="ml-explorer-swimlane euiText"
onMouseEnter={this.onSwimlaneEnterHandler}
onMouseLeave={this.onSwimlaneLeaveHandler}
data-test-subj="mlAnomalyExplorerSwimlaneViewBy"
>
<ExplorerSwimlane
chartWidth={swimlaneWidth}
filterActive={filterActive}
maskAll={maskAll}
MlTimeBuckets={MlTimeBuckets}
swimlaneCellClick={this.swimlaneCellClick}
swimlaneData={viewBySwimlaneData}
swimlaneType={SWIMLANE_TYPE.VIEW_BY}
selection={selectedCells}
swimlaneRenderDoneListener={this.swimlaneRenderDoneListener}
/>
</div>
)}
{viewBySwimlaneDataLoading && (
<LoadingIndicator/>
)}
{!showViewBySwimlane && !viewBySwimlaneDataLoading && swimlaneViewByFieldName !== null && (
<ExplorerNoInfluencersFound
swimlaneViewByFieldName={swimlaneViewByFieldName}
showFilterMessage={(filterActive === true)}
/>
)}
</React.Fragment>
)}
{annotationsData.length > 0 && (
<React.Fragment>
<span className="panel-title euiText">
<FormattedMessage
id="xpack.ml.explorer.annotationsTitle"
defaultMessage="Annotations"
/>
</span>
<AnnotationsTable
annotations={annotationsData}
drillDown={true}
numberBadge={false}
/>
<AnnotationFlyout />
<EuiSpacer size="l" />
</React.Fragment>
)}
<span className="panel-title euiText">
<FormattedMessage id="xpack.ml.explorer.anomaliesTitle" defaultMessage="Anomalies" />
</span>
<EuiFlexGroup
direction="row"
gutterSize="l"
responsive={true}
className="ml-anomalies-controls"
>
<EuiFlexItem grow={false} style={{ width: '170px' }}>
<EuiFormRow
label={intl.formatMessage({
id: 'xpack.ml.explorer.severityThresholdLabel',
defaultMessage: 'Severity threshold',
})}
>
<SelectSeverity />
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ width: '170px' }}>
<EuiFormRow
label={intl.formatMessage({
id: 'xpack.ml.explorer.intervalLabel',
defaultMessage: 'Interval',
})}
>
<SelectInterval />
</EuiFormRow>
</EuiFlexItem>
{(anomalyChartRecords.length > 0 && selectedCells !== null) && (
<EuiFlexItem grow={false} style={{ alignSelf: 'center' }}>
<EuiFormRow label="&#8203;">
<CheckboxShowCharts />
</EuiFormRow>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer size="m" />
<div className="euiText explorer-charts">
{this.props.showCharts && <ExplorerChartsContainer {...chartsData} />}
</div>
<AnomaliesTable
tableData={tableData}
timefilter={timefilter}
influencerFilter={this.applyFilter}
/>
</div>
)}
<div className={mainColumnClasses}>
<span className="panel-title euiText">
<FormattedMessage
id="xpack.ml.explorer.anomalyTimelineTitle"
defaultMessage="Anomaly timeline"
/>
</span>
<div
className="ml-explorer-swimlane euiText"
onMouseEnter={this.onSwimlaneEnterHandler}
onMouseLeave={this.onSwimlaneLeaveHandler}
data-test-subj="mlAnomalyExplorerSwimlaneOverall"
>
<ExplorerSwimlane
chartWidth={swimlaneWidth}
filterActive={filterActive}
maskAll={maskAll}
MlTimeBuckets={MlTimeBuckets}
swimlaneCellClick={this.swimlaneCellClick}
swimlaneData={overallSwimlaneData}
swimlaneType={SWIMLANE_TYPE.OVERALL}
selection={selectedCells}
swimlaneRenderDoneListener={this.swimlaneRenderDoneListener}
/>
</div>
{viewBySwimlaneOptions.length > 0 && (
<React.Fragment>
<EuiFlexGroup direction="row" gutterSize="l" responsive={true}>
<EuiFlexItem grow={false}>
<EuiFormRow
label={intl.formatMessage({
id: 'xpack.ml.explorer.viewByLabel',
defaultMessage: 'View by',
})}
>
<EuiSelect
id="selectViewBy"
options={mapSwimlaneOptionsToEuiOptions(viewBySwimlaneOptions)}
value={swimlaneViewByFieldName}
onChange={this.viewByChangeHandler}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormRow
label={intl.formatMessage({
id: 'xpack.ml.explorer.limitLabel',
defaultMessage: 'Limit',
})}
>
<SelectLimit />
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ alignSelf: 'center' }}>
<EuiFormRow label="&#8203;">
<div className="panel-sub-title">
{viewByLoadedForTimeFormatted && (
<FormattedMessage
id="xpack.ml.explorer.sortedByMaxAnomalyScoreForTimeFormattedLabel"
defaultMessage="(Sorted by max anomaly score for {viewByLoadedForTimeFormatted})"
values={{ viewByLoadedForTimeFormatted }}
/>
)}
{viewByLoadedForTimeFormatted === undefined && (
<FormattedMessage
id="xpack.ml.explorer.sortedByMaxAnomalyScoreLabel"
defaultMessage="(Sorted by max anomaly score)"
/>
)}
{filterActive === true &&
swimlaneViewByFieldName === 'job ID' && (
<FormattedMessage
id="xpack.ml.explorer.jobScoreAcrossAllInfluencersLabel"
defaultMessage="(Job score across all influencers)"
/>
)}
</div>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
{showViewBySwimlane && (
<div
className="ml-explorer-swimlane euiText"
onMouseEnter={this.onSwimlaneEnterHandler}
onMouseLeave={this.onSwimlaneLeaveHandler}
data-test-subj="mlAnomalyExplorerSwimlaneViewBy"
>
<ExplorerSwimlane
chartWidth={swimlaneWidth}
filterActive={filterActive}
maskAll={maskAll}
MlTimeBuckets={MlTimeBuckets}
swimlaneCellClick={this.swimlaneCellClick}
swimlaneData={viewBySwimlaneData}
swimlaneType={SWIMLANE_TYPE.VIEW_BY}
selection={selectedCells}
swimlaneRenderDoneListener={this.swimlaneRenderDoneListener}
/>
</div>
)}
{viewBySwimlaneDataLoading && (
<LoadingIndicator/>
)}
{!showViewBySwimlane && !viewBySwimlaneDataLoading && swimlaneViewByFieldName !== null && (
<ExplorerNoInfluencersFound
swimlaneViewByFieldName={swimlaneViewByFieldName}
showFilterMessage={(filterActive === true)}
/>
)}
</React.Fragment>
)}
{annotationsData.length > 0 && (
<React.Fragment>
<span className="panel-title euiText">
<FormattedMessage
id="xpack.ml.explorer.annotationsTitle"
defaultMessage="Annotations"
/>
</span>
<AnnotationsTable
annotations={annotationsData}
drillDown={true}
numberBadge={false}
/>
<AnnotationFlyout />
<EuiSpacer size="l" />
</React.Fragment>
)}
<span className="panel-title euiText">
<FormattedMessage id="xpack.ml.explorer.anomaliesTitle" defaultMessage="Anomalies" />
</span>
<EuiFlexGroup
direction="row"
gutterSize="l"
responsive={true}
className="ml-anomalies-controls"
>
<EuiFlexItem grow={false} style={{ width: '170px' }}>
<EuiFormRow
label={intl.formatMessage({
id: 'xpack.ml.explorer.severityThresholdLabel',
defaultMessage: 'Severity threshold',
})}
>
<SelectSeverity />
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ width: '170px' }}>
<EuiFormRow
label={intl.formatMessage({
id: 'xpack.ml.explorer.intervalLabel',
defaultMessage: 'Interval',
})}
>
<SelectInterval />
</EuiFormRow>
</EuiFlexItem>
{(anomalyChartRecords.length > 0 && selectedCells !== null) && (
<EuiFlexItem grow={false} style={{ alignSelf: 'center' }}>
<EuiFormRow label="&#8203;">
<CheckboxShowCharts />
</EuiFormRow>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer size="m" />
<div className="euiText explorer-charts">
{this.props.showCharts && <ExplorerChartsContainer {...chartsData} />}
</div>
<AnomaliesTable
tableData={tableData}
timefilter={timefilter}
influencerFilter={this.applyFilter}
/>
</div>
</div>
</ExplorerPage>
);
}
}

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export * from './explorer_chart_label';
export { ExplorerChartLabel } from './explorer_chart_label';

View file

@ -12,15 +12,10 @@
*/
import $ from 'jquery';
import moment from 'moment-timezone';
import { Subscription } from 'rxjs';
import '../components/annotations/annotations_table';
import '../components/anomalies_table';
import '../components/controls';
import template from './explorer.html';
import uiRoutes from 'ui/routes';
import {
createJobs,
@ -34,14 +29,21 @@ import { explorer$ } from './explorer_dashboard_service';
import { mlTimefilterRefresh$ } from '../services/timefilter_refresh_service';
import { mlFieldFormatService } from 'plugins/ml/services/field_format_service';
import { mlJobService } from '../services/job_service';
import { refreshIntervalWatcher } from '../util/refresh_interval_watcher';
import { getSelectedJobIds } from '../components/job_selector/job_select_service_utils';
import { getSelectedJobIds, jobSelectServiceFactory } from '../components/job_selector/job_select_service_utils';
import { timefilter } from 'ui/timefilter';
import { interval$ } from '../components/controls/select_interval';
import { severity$ } from '../components/controls/select_severity';
import { showCharts$ } from '../components/controls/checkbox_showcharts';
import { subscribeAppStateToObservable } from '../util/app_state_utils';
import { APP_STATE_ACTION, EXPLORER_ACTION } from './explorer_constants';
const template = `<ml-chart-tooltip /><ml-explorer-react-wrapper class="ml-explorer" data-test-subj="mlPageAnomalyExplorer" />`;
uiRoutes
.when('/explorer/?', {
controller: 'MlExplorerController',
template,
k7Breadcrumbs: getAnomalyExplorerBreadcrumbs,
resolve: {
@ -57,24 +59,13 @@ import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
module.controller('MlExplorerController', function (
$injector,
$scope,
$timeout,
$rootScope,
AppState,
Private,
config,
globalState,
) {
// Even if they are not used directly anymore in this controller but via imports
// in React components, because of the use of AppState and its dependency on angularjs
// these services still need to be required here to properly initialize.
$injector.get('mlCheckboxShowChartsService');
$injector.get('mlSelectIntervalService');
$injector.get('mlSelectLimitService');
$injector.get('mlSelectSeverityService');
const mlJobSelectService = $injector.get('mlJobSelectService');
const { jobSelectService, unsubscribeFromGlobalState } = jobSelectServiceFactory(globalState);
const subscriptions = new Subscription();
// $scope should only contain what's actually still necessary for the angular part.
@ -83,10 +74,6 @@ module.controller('MlExplorerController', function (
timefilter.enableTimeRangeSelector();
timefilter.enableAutoRefreshSelector();
// Pass the timezone to the server for use when aggregating anomalies (by day / hour) for the table.
const tzConfig = config.get('dateFormat:tz');
$scope.dateFormatTz = (tzConfig !== 'Browser') ? tzConfig : moment.tz.guess();
$scope.MlTimeBuckets = MlTimeBuckets;
let resizeTimeout = null;
@ -185,7 +172,7 @@ module.controller('MlExplorerController', function (
swimlaneViewByFieldName: $scope.appState.mlExplorerSwimlane.viewByFieldName,
});
subscriptions.add(mlJobSelectService.subscribe(({ selection }) => {
subscriptions.add(jobSelectService.subscribe(({ selection }) => {
if (selection !== undefined) {
$scope.jobSelectionUpdateInProgress = true;
jobSelectionUpdate(EXPLORER_ACTION.JOB_SELECTION_CHANGE, { fullJobs: mlJobService.jobs, selectedJobIds: selection });
@ -223,12 +210,15 @@ module.controller('MlExplorerController', function (
});
// Add a watcher for auto-refresh of the time filter to refresh all the data.
const refreshWatcher = Private(refreshIntervalWatcher);
refreshWatcher.init(async () => {
subscriptions.add(mlTimefilterRefresh$.subscribe(() => {
if ($scope.jobSelectionUpdateInProgress === false) {
explorer$.next({ action: EXPLORER_ACTION.RELOAD });
}
});
}));
subscriptions.add(subscribeAppStateToObservable(AppState, 'mlShowCharts', showCharts$, () => $rootScope.$applyAsync()));
subscriptions.add(subscribeAppStateToObservable(AppState, 'mlSelectInterval', interval$, () => $rootScope.$applyAsync()));
subscriptions.add(subscribeAppStateToObservable(AppState, 'mlSelectSeverity', severity$, () => $rootScope.$applyAsync()));
// Redraw the swimlane when the window resizes or the global nav is toggled.
function jqueryRedrawOnResize() {
@ -298,9 +288,9 @@ module.controller('MlExplorerController', function (
$scope.$on('$destroy', () => {
subscriptions.unsubscribe();
refreshWatcher.cancel();
$(window).off('resize', jqueryRedrawOnResize);
// Cancel listening for updates to the global nav state.
navListener();
unsubscribeFromGlobalState();
});
});

View file

@ -11,30 +11,53 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Explorer } from './explorer';
import moment from 'moment-timezone';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
import { I18nContext } from 'ui/i18n';
import { mapScopeToProps } from './explorer_utils';
import chrome from 'ui/chrome';
import { timefilter } from 'ui/timefilter';
import { timeHistory } from 'ui/timefilter/time_history';
import { jobSelectServiceFactory } from '../components/job_selector/job_select_service_utils';
import { NavigationMenuContext } from '../util/context_utils';
import { Explorer } from './explorer';
import { EXPLORER_ACTION } from './explorer_constants';
import { explorer$ } from './explorer_dashboard_service';
module.directive('mlExplorerReactWrapper', function () {
module.directive('mlExplorerReactWrapper', function (config, globalState) {
function link(scope, element) {
const { jobSelectService, unsubscribeFromGlobalState } = jobSelectServiceFactory(globalState);
// Pass the timezone to the server for use when aggregating anomalies (by day / hour) for the table.
const tzConfig = config.get('dateFormat:tz');
const dateFormatTz = (tzConfig !== 'Browser') ? tzConfig : moment.tz.guess();
ReactDOM.render(
<I18nContext>{React.createElement(Explorer, mapScopeToProps(scope))}</I18nContext>,
<I18nContext>
<NavigationMenuContext.Provider value={{ chrome, timefilter, timeHistory }}>
<Explorer {...{
appStateHandler: scope.appStateHandler,
config,
dateFormatTz,
globalState,
jobSelectService,
MlTimeBuckets: scope.MlTimeBuckets,
}}
/>
</NavigationMenuContext.Provider>
</I18nContext>,
element[0]
);
explorer$.next({ action: EXPLORER_ACTION.LOAD_JOBS });
element.on('$destroy', () => {
ReactDOM.unmountComponentAtNode(element[0]);
scope.$destroy();
unsubscribeFromGlobalState();
});
}

View file

@ -58,15 +58,6 @@ export function getDefaultViewBySwimlaneData() {
};
}
export function mapScopeToProps(scope) {
return {
appStateHandler: scope.appStateHandler,
dateFormatTz: scope.dateFormatTz,
mlJobSelectService: scope.mlJobSelectService,
MlTimeBuckets: scope.MlTimeBuckets,
};
}
export async function getFilteredTopInfluencers(
jobIds,
earliestMs,

View file

@ -5,4 +5,10 @@
*/
export * from './utils';
export {
createUrlOverrides,
hasImportPermission,
processResults,
readFile,
reduceData,
} from './utils';

View file

@ -8,9 +8,9 @@
import _ from 'lodash';
import { ML_RESULTS_INDEX_PATTERN } from 'plugins/ml/../common/constants/index_patterns';
import { escapeForElasticsearchQuery } from 'plugins/ml/util/string_utils';
import { ml } from 'plugins/ml/services/ml_api_service';
import { ML_RESULTS_INDEX_PATTERN } from '../../../../../../common/constants/index_patterns';
import { escapeForElasticsearchQuery } from '../../../../../util/string_utils';
import { ml } from '../../../../../services/ml_api_service';
// detector swimlane search
function getScoresByRecord(jobId, earliestMs, latestMs, interval, firstSplitField) {
@ -163,5 +163,3 @@ export const mlSimpleJobSearchService = {
getScoresByRecord,
getCategoryFields
};

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ML_RESULTS_INDEX_PATTERN } from 'plugins/ml/../common/constants/index_patterns';
import { ML_RESULTS_INDEX_PATTERN } from '../../../../../../common/constants/index_patterns';
export const watch = {
trigger: {

View file

@ -10,10 +10,8 @@
// data on forecasts that have been performed.
import _ from 'lodash';
import { ML_RESULTS_INDEX_PATTERN } from 'plugins/ml/../common/constants/index_patterns';
import { ml } from 'plugins/ml/services/ml_api_service';
import { ML_RESULTS_INDEX_PATTERN } from '../../common/constants/index_patterns';
import { ml } from './ml_api_service';
// Gets a basic summary of the most recently run forecasts for the specified
// job, with results at or later than the supplied timestamp.
@ -382,4 +380,3 @@ export const mlForecastService = {
runForecast,
getForecastRequestStats
};

View file

@ -1,62 +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 ngMock from 'ng_mock';
import expect from '@kbn/expect';
import sinon from 'sinon';
import { TimeseriesChart } from '../components/timeseries_chart/timeseries_chart';
describe('ML - <ml-timeseries-chart>', () => {
let $scope;
let $compile;
let $element;
beforeEach(ngMock.module('kibana'));
beforeEach(() => {
ngMock.inject(function ($injector) {
$compile = $injector.get('$compile');
const $rootScope = $injector.get('$rootScope');
$scope = $rootScope.$new();
});
});
afterEach(() => {
$scope.$destroy();
});
it('Plain initialization doesn\'t throw an error', () => {
// this creates a dummy DOM element with class 'ml-timeseries-chart' as a direct child of
// the <body> tag so the directive can find it in the DOM to create the resizeChecker.
const mockClassedElement = document.createElement('div');
mockClassedElement.classList.add('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.prototype, 'componentWillUnmount');
$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
expect(scope.showForecast).to.equal(true);
// componentWillUnmount() should not have been called so far
expect(componentWillUnmountSpy.callCount).to.equal(0);
// remove $element to trigger $destroy() callback
$element.remove();
// componentWillUnmount() should now have been called once
expect(componentWillUnmountSpy.callCount).to.equal(1);
componentWillUnmountSpy.restore();
// clean up the dummy DOM element
mockClassedElement.parentNode.removeChild(mockClassedElement);
});
});

View file

@ -1,29 +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 ngMock from 'ng_mock';
import expect from '@kbn/expect';
describe('ML - Time Series Explorer Controller', () => {
beforeEach(() => {
ngMock.module('kibana');
});
it('Initialize Time Series Explorer Controller', (done) => {
ngMock.inject(function ($rootScope, $controller) {
const scope = $rootScope.$new();
expect(() => {
$controller('MlTimeSeriesExplorerController', { $scope: scope });
}).to.not.throwError();
expect(scope.timeFieldName).to.eql('timestamp');
done();
});
});
});

View file

@ -0,0 +1,40 @@
/*
* 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 ngMock from 'ng_mock';
import expect from '@kbn/expect';
describe('ML - Time Series Explorer Directive', () => {
let $scope;
let $compile;
let $element;
beforeEach(ngMock.module('kibana'));
beforeEach(() => {
ngMock.inject(function ($injector) {
$compile = $injector.get('$compile');
const $rootScope = $injector.get('$rootScope');
$scope = $rootScope.$new();
});
});
afterEach(() => {
$scope.$destroy();
});
it('Initialize Time Series Explorer Directive', (done) => {
ngMock.inject(function () {
expect(() => {
$element = $compile('<ml-timeseries-explorer />')($scope);
}).to.not.throwError();
// directive has scope: false
const scope = $element.isolateScope();
expect(scope).to.eql(undefined);
done();
});
});
});

View file

@ -60,22 +60,6 @@
}
}
.show-model-controls {
float: right;
position: relative;
top: 18px;
div {
display: inline;
padding-left: $euiSize;
}
.kuiCheckBoxLabel {
display: inline-block;
font-size: $euiFontSizeXS;
}
}
.forecast-controls {
float: right;
}

View file

@ -5,4 +5,4 @@
*/
export * from './context_chart_mask';
export { ContextChartMask } from './context_chart_mask';

View file

@ -0,0 +1,97 @@
/*
* 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 { injectI18n } from '@kbn/i18n/react';
import {
EuiComboBox,
EuiFlexItem,
EuiFormRow,
} from '@elastic/eui';
function getEntityControlOptions(entity) {
if (!Array.isArray(entity.fieldValues)) {
return [];
}
return entity.fieldValues.map((value) => {
return { label: value };
});
}
export const EntityControl = injectI18n(
class EntityControl extends React.Component {
static propTypes = {
entity: PropTypes.object.isRequired,
entityFieldValueChanged: PropTypes.func.isRequired,
};
state = {
selectedOptions: undefined
}
constructor(props) {
super(props);
}
componentDidUpdate() {
const { entity } = this.props;
const { selectedOptions } = this.state;
const fieldValue = entity.fieldValue;
if (
(selectedOptions === undefined && fieldValue.length > 0) ||
(Array.isArray(selectedOptions) && fieldValue.length > 0 && selectedOptions[0].label !== fieldValue)
) {
this.setState({
selectedOptions: [{ label: fieldValue }]
});
} else if (Array.isArray(selectedOptions) && fieldValue.length === 0) {
this.setState({
selectedOptions: undefined
});
}
}
onChange = (selectedOptions) => {
const options = (selectedOptions.length > 0) ? selectedOptions : undefined;
this.setState({
selectedOptions: options,
});
const fieldValue = (Array.isArray(options) && options[0].label.length > 0) ? options[0].label : '';
this.props.entityFieldValueChanged(this.props.entity, fieldValue);
};
render() {
const { entity, intl } = this.props;
const { selectedOptions } = this.state;
const options = getEntityControlOptions(entity);
return (
<EuiFlexItem grow={false}>
<EuiFormRow label={entity.fieldName}>
<EuiComboBox
style={{ minWidth: '300px' }}
placeholder={intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.enterValuePlaceholder',
defaultMessage: 'Enter value'
})}
singleSelection={{ asPlainText: true }}
options={options}
selectedOptions={selectedOptions}
onChange={this.onChange}
isClearable={false}
/>
</EuiFormRow>
</EuiFlexItem>
);
}
}
);

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { EntityControl } from './entity_control';

View file

@ -4,8 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
/*
* React modal dialog which allows the user to run and view time series forecasts.
*/
@ -22,6 +20,8 @@ import {
EuiToolTip
} from '@elastic/eui';
import { timefilter } from 'ui/timefilter';
// don't use something like plugins/ml/../common
// because it won't work with the jest tests
import { FORECAST_REQUEST_STATE, JOB_STATE } from '../../../../common/constants/states';
@ -44,7 +44,6 @@ const WARN_NUM_PARTITIONS = 100; // Warn about running a forecast with this n
const FORECAST_STATS_POLL_FREQUENCY = 250; // Frequency in ms at which to poll for forecast request stats.
const WARN_NO_PROGRESS_MS = 120000; // If no progress in forecast request, abort check and warn.
function getDefaultState() {
return {
isModalVisible: false,
@ -60,7 +59,6 @@ function getDefaultState() {
};
}
export const ForecastingModal = injectI18n(class ForecastingModal extends Component {
static propTypes = {
isDisabled: PropTypes.bool,
@ -68,7 +66,6 @@ export const ForecastingModal = injectI18n(class ForecastingModal extends Compon
detectorIndex: PropTypes.number,
entities: PropTypes.array,
loadForForecastId: PropTypes.func,
timefilter: PropTypes.object,
};
constructor(props) {
@ -348,7 +345,7 @@ export const ForecastingModal = injectI18n(class ForecastingModal extends Compon
if (typeof job === 'object') {
// Get the list of all the finished forecasts for this job with results at or later than the dashboard 'from' time.
const bounds = this.props.timefilter.getActiveBounds();
const bounds = timefilter.getActiveBounds();
const statusFinishedQuery = {
term: {
forecast_status: FORECAST_REQUEST_STATE.FINISHED
@ -458,7 +455,6 @@ export const ForecastingModal = injectI18n(class ForecastingModal extends Compon
const forecastButton = (
<EuiButton
onClick={this.openModal}
size="s"
isDisabled={isForecastingDisabled}
fill
data-test-subj="mlSingleMetricViewerButtonForecast"

View file

@ -6,4 +6,7 @@
import './forecasting_modal_directive';
export {
ForecastingModal,
FORECAST_DURATION_MAX_DAYS,
}from './forecasting_modal';

View file

@ -19,6 +19,8 @@ import _ from 'lodash';
import d3 from 'd3';
import moment from 'moment';
import chrome from 'ui/chrome';
import {
getSeverityWithLow,
getMultiBucketImpactLabel,
@ -64,6 +66,7 @@ const contextChartLineTopMargin = 3;
const chartSpacing = 25;
const swimlaneHeight = 30;
const margin = { top: 20, right: 10, bottom: 15, left: 40 };
const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false);
const ZOOM_INTERVAL_OPTIONS = [
{ duration: moment.duration(1, 'h'), label: '1h' },
@ -89,7 +92,6 @@ function getSvgHeight() {
const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Component {
static propTypes = {
annotationsEnabled: PropTypes.bool,
annotation: PropTypes.object,
autoZoomDuration: PropTypes.number,
contextAggregationInterval: PropTypes.object,
@ -129,10 +131,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
}
componentDidMount() {
const {
annotationsEnabled,
svgWidth
} = this.props;
const { svgWidth } = this.props;
this.vizWidth = svgWidth - margin.left - margin.right;
const vizWidth = this.vizWidth;
@ -163,7 +162,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
this.fieldFormat = undefined;
// Annotations Brush
if (annotationsEnabled) {
if (mlAnnotationsEnabled) {
this.annotateBrush = getAnnotationBrush.call(this);
}
@ -210,7 +209,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
this.renderFocusChart();
if (this.props.annotationsEnabled && this.props.annotation === null) {
if (mlAnnotationsEnabled && this.props.annotation === null) {
const chartElement = d3.select(this.rootNode);
chartElement.select('g.mlAnnotationBrush').call(this.annotateBrush.extent([0, 0]));
}
@ -218,7 +217,6 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
renderChart() {
const {
annotationsEnabled,
contextChartData,
contextForecastData,
detectorIndex,
@ -320,7 +318,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
.attr('transform', 'translate(' + margin.left + ',' + (focusHeight + margin.top + chartSpacing) + ')');
// Mask to hide annotations overflow
if (annotationsEnabled) {
if (mlAnnotationsEnabled) {
const annotationsMask = svg
.append('defs')
.append('mask')
@ -352,8 +350,6 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
return;
}
const setContextBrushExtent = this.setContextBrushExtent.bind(this);
// Make appropriate selection in the context chart to trigger loading of the focus chart.
let focusLoadFrom;
let focusLoadTo;
@ -381,7 +377,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
focusLoadTo = Math.min(focusLoadTo, contextXMax);
if ((focusLoadFrom !== contextXMin) || (focusLoadTo !== contextXMax)) {
setContextBrushExtent(new Date(focusLoadFrom), new Date(focusLoadTo), true);
this.setContextBrushExtent(new Date(focusLoadFrom), new Date(focusLoadTo), true);
} else {
// Don't set the brush if the selection is the full context chart domain.
this.setBrushVisibility(false);
@ -395,10 +391,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
// Split out creation of the focus chart from the rendering,
// as we want to re-render the paths and points when the zoom area changes.
const {
annotationsEnabled,
contextForecastData
} = this.props;
const { contextForecastData } = this.props;
// Add a group at the top to display info on the chart aggregation interval
// and links to set the brush span to 1h, 1d, 1w etc.
@ -413,7 +406,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
this.createZoomInfoElements(zoomGroup, fcsWidth);
// Create the elements for annotations
if (annotationsEnabled) {
if (mlAnnotationsEnabled) {
const annotateBrush = this.annotateBrush.bind(this);
let brushX = 0;
@ -505,7 +498,6 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
renderFocusChart() {
const {
annotationsEnabled,
focusAggregationInterval,
focusAnnotationData,
focusChartData,
@ -607,7 +599,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
// if annotations are present, we extend yMax to avoid overlap
// between annotation labels, chart lines and anomalies.
if (annotationsEnabled && focusAnnotationData && focusAnnotationData.length > 0) {
if (mlAnnotationsEnabled && focusAnnotationData && focusAnnotationData.length > 0) {
const levels = getAnnotationLevels(focusAnnotationData);
const maxLevel = d3.max(Object.keys(levels).map(key => levels[key]));
// TODO needs revisiting to be a more robust normalization
@ -643,7 +635,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
.classed('hidden', !showModelBounds);
}
if (annotationsEnabled) {
if (mlAnnotationsEnabled) {
renderAnnotations(
focusChart,
focusAnnotationData,
@ -853,12 +845,9 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
const data = contextChartData;
const calculateContextXAxisDomain = this.calculateContextXAxisDomain.bind(this);
const drawContextBrush = this.drawContextBrush.bind(this);
const drawSwimlane = this.drawSwimlane.bind(this);
this.contextXScale = d3.time.scale().range([0, cxtWidth])
.domain(calculateContextXAxisDomain());
.domain(this.calculateContextXAxisDomain());
const combinedData = contextForecastData === undefined ? data : data.concat(contextForecastData);
const valuesRange = { min: Number.MAX_VALUE, max: Number.MIN_VALUE };
@ -969,7 +958,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
.attr('class', 'swimlane')
.attr('transform', 'translate(0,' + cxtChartHeight + ')');
drawSwimlane(swimlane, cxtWidth, swlHeight);
this.drawSwimlane(swimlane, cxtWidth, swlHeight);
// Draw a mask over the sections of the context chart and swimlane
// which fall outside of the zoom brush selection area.
@ -988,17 +977,16 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
filterAxisLabels(cxtGroup.selectAll('.x.context-chart-axis'), cxtWidth);
drawContextBrush(cxtGroup);
this.drawContextBrush(cxtGroup);
}
drawContextBrush(contextGroup) {
drawContextBrush = (contextGroup) => {
const {
contextChartSelected
} = this.props;
const brush = this.brush;
const contextXScale = this.contextXScale;
const setBrushVisibility = this.setBrushVisibility.bind(this);
const mask = this.mask;
// Create the brush for zooming in to the focus area of interest.
@ -1023,6 +1011,8 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
.attr('x', 0)
.attr('width', 10);
const handleBrushExtent = brush.extent();
const topBorder = contextGroup.append('rect')
.attr('class', 'top-border')
.attr('y', -2)
@ -1034,16 +1024,16 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
.attr('width', 10)
.attr('height', 90)
.attr('class', 'brush-handle')
.attr('x', contextXScale(handleBrushExtent[0]) - 10)
.html('<div class="brush-handle-inner brush-handle-inner-left"><i class="fa fa-caret-left"></i></div>');
const rightHandle = contextGroup.append('foreignObject')
.attr('width', 10)
.attr('height', 90)
.attr('class', 'brush-handle')
.attr('x', contextXScale(handleBrushExtent[1]) + 0)
.html('<div class="brush-handle-inner brush-handle-inner-right"><i class="fa fa-caret-right"></i></div>');
setBrushVisibility(!brush.empty());
function showBrush(show) {
const showBrush = (show) => {
if (show === true) {
const brushExtent = brush.extent();
mask.reveal(brushExtent);
@ -1054,8 +1044,10 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
topBorder.attr('width', contextXScale(brushExtent[1]) - contextXScale(brushExtent[0]) - 2);
}
setBrushVisibility(show);
}
this.setBrushVisibility(show);
};
showBrush(!brush.empty());
function brushing() {
const isEmpty = brush.empty();
@ -1087,7 +1079,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
}
}
setBrushVisibility(show) {
setBrushVisibility = (show) => {
const mask = this.mask;
if (mask !== undefined) {
@ -1107,14 +1099,12 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
}
}
drawSwimlane(swlGroup, swlWidth, swlHeight) {
drawSwimlane = (swlGroup, swlWidth, swlHeight) => {
const {
contextAggregationInterval,
swimlaneData
} = this.props;
const calculateContextXAxisDomain = this.calculateContextXAxisDomain.bind(this);
const data = swimlaneData;
if (typeof data === 'undefined') {
@ -1126,7 +1116,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
// x-axis min to the start of the aggregation interval.
// Need to use the min(earliest) and max(earliest) of the context chart
// aggregation to align the axes of the chart and swimlane elements.
const xAxisDomain = calculateContextXAxisDomain();
const xAxisDomain = this.calculateContextXAxisDomain();
const x = d3.time.scale().range([0, swlWidth])
.domain(xAxisDomain);
@ -1182,7 +1172,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
}
calculateContextXAxisDomain() {
calculateContextXAxisDomain = () => {
const {
contextAggregationInterval,
swimlaneData,
@ -1211,9 +1201,19 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
// Sets the extent of the brush on the context chart to the
// supplied from and to Date objects.
setContextBrushExtent(from, to, fireEvent) {
setContextBrushExtent = (from, to, fireEvent) => {
const brush = this.brush;
brush.extent([from, to]);
const brushExtent = brush.extent();
const newExtent = [from, to];
if (
newExtent[0].getTime() === brushExtent[0].getTime() &&
newExtent[1].getTime() === brushExtent[1].getTime()
) {
fireEvent = false;
}
brush.extent(newExtent);
brush(d3.select('.brush'));
if (fireEvent) {
brush.event(d3.select('.brush'));
@ -1226,8 +1226,6 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
zoomTo
} = this.props;
const setContextBrushExtent = this.setContextBrushExtent.bind(this);
const bounds = timefilter.getActiveBounds();
const minBoundsMs = bounds.min.valueOf();
const maxBoundsMs = bounds.max.valueOf();
@ -1242,12 +1240,11 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
to = Math.min(minBoundsMs + millis, maxBoundsMs);
}
setContextBrushExtent(new Date(from), new Date(to), true);
this.setContextBrushExtent(new Date(from), new Date(to), true);
}
showFocusChartTooltip(marker, circle) {
const {
annotationsEnabled,
modelPlotEnabled,
intl
} = this.props;
@ -1388,7 +1385,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo
});
}
if (annotationsEnabled && _.has(marker, 'annotation')) {
if (mlAnnotationsEnabled && _.has(marker, 'annotation')) {
contents = mlEscape(marker.annotation);
contents += `<br />${moment(marker.timestamp).format('MMMM Do YYYY, HH:mm')}`;

View file

@ -16,6 +16,7 @@ import { TimeseriesChart } from './timeseries_chart';
// mocking the following files because they import some core kibana
// code which the jest setup isn't happy with.
jest.mock('ui/chrome', () => ({
addBasePath: path => path,
getBasePath: path => path,
// returns false for mlAnnotationsEnabled
getInjected: () => false,

View file

@ -1,146 +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.
*/
/*
* Chart plotting data from a single time series, with or without model plot enabled,
* annotated with anomalies.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import { TimeseriesChart } from './timeseries_chart';
import angular from 'angular';
import { timefilter } from 'ui/timefilter';
import { ResizeChecker } from 'ui/resize_checker';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
import { I18nContext } from 'ui/i18n';
module.directive('mlTimeseriesChart', function ($timeout) {
function link(scope, element) {
// Key dimensions for the viz and constituent charts.
let svgWidth = angular.element('.results-container').width();
function contextChartSelected(selection) {
scope.$root.$broadcast('contextChartSelected', selection);
}
function renderReactComponent(renderFocusChartOnly = false) {
// Set the size of the components according to the width of the parent container at render time.
svgWidth = Math.max(angular.element('.results-container').width(), 0);
const props = {
annotationsEnabled: scope.annotationsEnabled,
autoZoomDuration: scope.autoZoomDuration,
contextAggregationInterval: scope.contextAggregationInterval,
contextChartData: scope.contextChartData,
contextForecastData: scope.contextForecastData,
contextChartSelected: contextChartSelected,
detectorIndex: scope.detectorIndex,
focusAnnotationData: scope.focusAnnotationData,
focusChartData: scope.focusChartData,
focusForecastData: scope.focusForecastData,
focusAggregationInterval: scope.focusAggregationInterval,
modelPlotEnabled: scope.modelPlotEnabled,
refresh: scope.refresh,
renderFocusChartOnly,
selectedJob: scope.selectedJob,
showAnnotations: scope.showAnnotations,
showForecast: scope.showForecast,
showModelBounds: scope.showModelBounds,
svgWidth,
swimlaneData: scope.swimlaneData,
timefilter,
zoomFrom: scope.zoomFrom,
zoomTo: scope.zoomTo
};
ReactDOM.render(
<I18nContext>
<TimeseriesChart {...props} />
</I18nContext>,
element[0]
);
}
renderReactComponent();
scope.$on('render', () => {
$timeout(() => {
renderReactComponent();
});
});
function renderFocusChart() {
renderReactComponent(true);
}
scope.$watchCollection('focusForecastData', renderFocusChart);
scope.$watchCollection('focusChartData', renderFocusChart);
scope.$watchGroup(['showModelBounds', 'showForecast'], renderFocusChart);
scope.$watch('annotationsEnabled', renderReactComponent);
if (scope.annotationsEnabled) {
scope.$watchCollection('focusAnnotationData', renderFocusChart);
scope.$watch('showAnnotations', renderFocusChart);
}
// Redraw the charts when the container is resize.
const resizeChecker = new ResizeChecker(angular.element('.ml-timeseries-chart'));
resizeChecker.on('resize', () => {
scope.$evalAsync(() => {
renderReactComponent();
// Add a re-render of the focus chart to set renderFocusChartOnly back to true.
// Not efficient, but ensures adding annotations doesn't cause the whole chart
// to be re-rendered.
renderReactComponent(true);
});
});
element.on('$destroy', () => {
resizeChecker.destroy();
// unmountComponentAtNode() needs to be called so mlTableService listeners within
// the TimeseriesChart component get unwatched properly.
ReactDOM.unmountComponentAtNode(element[0]);
scope.$destroy();
});
}
return {
scope: {
annotationsEnabled: '=',
selectedJob: '=',
detectorIndex: '=',
modelPlotEnabled: '=',
contextChartData: '=',
contextForecastData: '=',
contextChartAnomalyData: '=',
focusChartData: '=',
swimlaneData: '=',
focusAnnotationData: '=',
focusForecastData: '=',
contextAggregationInterval: '=',
focusAggregationInterval: '=',
zoomFrom: '=',
zoomTo: '=',
autoZoomDuration: '=',
refresh: '=',
showAnnotations: '=',
showModelBounds: '=',
showForecast: '='
},
link: link
};
});

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { TimeseriesexplorerNoChartData } from './timeseriesexplorer_no_chart_data';

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/*
* React component for rendering EuiEmptyPrompt when no results were found.
*/
import React from 'react';
import { EuiEmptyPrompt } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export const TimeseriesexplorerNoChartData = ({ dataNotChartable, entities }) => (
<EuiEmptyPrompt
iconType="iInCircle"
title={
<h2>
{i18n.translate('xpack.ml.timeSeriesExplorer.noResultsFoundLabel', {
defaultMessage: 'No results found'
})}
</h2>
}
body={dataNotChartable
? (
<p>
{i18n.translate('xpack.ml.timeSeriesExplorer.dataNotChartableDescription', {
defaultMessage: `Model plot is not collected for the selected {entityCount, plural, one {entity} other {entities}}
and the source data cannot be plotted for this detector.`,
values: {
entityCount: entities.length
}
})}
</p>
)
: (
<p>
{i18n.translate('xpack.ml.timeSeriesExplorer.tryWideningTheTimeSelectionDescription', {
defaultMessage: 'Try widening the time selection or moving further back in time.'
})}
</p>
)
}
/>
);

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { TimeseriesexplorerNoJobsFound } from './timeseriesexplorer_no_jobs_found';

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/*
* React component for rendering EuiEmptyPrompt when no jobs were found.
*/
import React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiEmptyPrompt, EuiButton } from '@elastic/eui';
export const TimeseriesexplorerNoJobsFound = () => (
<EuiEmptyPrompt
data-test-subj="mlNoSingleMetricJobsFound"
iconType="alert"
title={
<h2>
<FormattedMessage id="xpack.ml.timeSeriesExplorer.noSingleMetricJobsFoundLabel" defaultMessage="No single metric jobs found" />
</h2>
}
actions={
<EuiButton color="primary" fill href="ml#/jobs">
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.createNewSingleMetricJobLinkText"
defaultMessage="Create new single metric job"
/>
</EuiButton>
}
/>
);

View file

@ -4,9 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import './components/forecasting_modal';
import './components/timeseries_chart/timeseries_chart_directive';
import './timeseriesexplorer_controller.js';
import './timeseriesexplorer_directive.js';
import './timeseriesexplorer_route.js';
import './timeseries_search_service.js';
import 'plugins/ml/components/job_selector';
import 'plugins/ml/components/chart_tooltip';
import '../components/job_selector';
import '../components/chart_tooltip';

View file

@ -8,10 +8,10 @@
import _ from 'lodash';
import { ml } from 'plugins/ml/services/ml_api_service';
import { isModelPlotEnabled } from 'plugins/ml/../common/util/job_utils';
import { buildConfigFromDetector } from 'plugins/ml/util/chart_config_builder';
import { mlResultsService } from 'plugins/ml/services/results_service';
import { ml } from '../services/ml_api_service';
import { isModelPlotEnabled } from '../../common/util/job_utils';
import { buildConfigFromDetector } from '../util/chart_config_builder';
import { mlResultsService } from '../services/results_service';
function getMetricData(job, detectorIndex, entityFields, earliestMs, latestMs, interval) {
if (isModelPlotEnabled(job, detectorIndex, entityFields)) {

View file

@ -1,263 +0,0 @@
<ml-nav-menu name="timeseriesexplorer" />
<ml-chart-tooltip></ml-chart-tooltip>
<div class="ml-time-series-explorer" ng-controller="MlTimeSeriesExplorerController" data-test-subj="mlPageSingleMetricViewer" >
<ml-job-selector-react-wrapper timeseriesonly="true" singleselection="true" />
<div class="no-results-container" ng-if="jobs.length === 0 && loading === false" data-test-subj="mlNoSingleMetricJobsFound">
<div class="no-results">
<div
i18n-id="xpack.ml.timeSeriesExplorer.noSingleMetricJobsFoundLabel"
i18n-default-message="{icon} No single metric jobs found"
i18n-values="{ html_icon: '<i class=\'fa fa-exclamation-triangle\'></i>' }">
</div>
<div>
<a
href="ml#/jobs"
i18n-id="xpack.ml.timeSeriesExplorer.createNewSingleMetricJobLinkText"
i18n-default-message="Create new single metric job"
></a></div>
</div>
</div>
<div class="series-controls" ng-show="jobs.length > 0" data-test-subj="mlSingleMetricViewerSeriesControls">
<label
for="selectDetector"
class="kuiLabel"
i18n-id="xpack.ml.timeSeriesExplorer.detectorLabel"
i18n-default-message="Detector:"
></label>
<select id="selectDetector" class="kuiSelect kuiSelect--large" ng-model="detectorId" ng-change="detectorIndexChanged()">
<option ng-repeat="detector in detectors track by detector.index" value="{{detector.index}}">{{detector.detector_description}}</option>
</select>
<div class="entity-controls" ng-repeat="entity in entities">
<datalist id="{{entity.fieldName}}_datalist">
<option ng-repeat="value in entity.fieldValues">{{value}}</option>
</datalist>
<label for="select{{entity.fieldName}}" class="kuiLabel">{{entity.fieldName}}:</label>
<input id="select{{entity.fieldName}}" class="kuiTextInput entity-input"
ng-class="{ 'entity-input-blank': entity.fieldValue.length === 0 }"
ng-model="entity.fieldValue" ng-model-options="{ updateOn: 'blur' }"
placeholder="{{ ::'xpack.ml.timeSeriesExplorer.enterValuePlaceholder' | i18n: {defaultMessage: 'Enter value'} }}"
list='{{entity.fieldName}}_datalist' />
</div>
<button
class="kuiButton kuiButton--primary"
ng-click="saveSeriesPropertiesAndRefresh()"
aria-label="{{ ::'xpack.ml.timeSeriesExplorer.refreshButtonAriLabel' | i18n: {defaultMessage: 'refresh'} }}"
>
<i class="fa fa-play" ></i>
</button>
<ml-forecasting-modal
job="selectedJob"
detectorIndex="+detectorId"
entities="entities"
loadForForecastId="loadForForecastId"
class="forecast-controls"
/>
</div>
<ml-loading-indicator
label="{{ ::'xpack.ml.timeSeriesExplorer.loadingLabel' | i18n: {defaultMessage: 'Loading'} }}"
is-loading="loading === true"
/>
<div class="no-results-container"
ng-show="jobs.length > 0 && loading === false && hasResults === false && dataNotChartable === false">
<div class="no-results">
<div
i18n-id="xpack.ml.timeSeriesExplorer.noResultsFoundLabel"
i18n-default-message="{icon} No results found"
i18n-values="{ html_icon: '<i class=\'fa fa-info-circle\'></i>' }"
></div>
<div
i18n-id="xpack.ml.timeSeriesExplorer.tryWideningTheTimeSelectionDescription"
i18n-default-message="Try widening the time selection or moving further back in time"
></div>
</div>
</div>
<div class="no-results-container"
ng-show="jobs.length > 0 && loading === false && hasResults === false && dataNotChartable === true">
<div class="no-results">
<div
i18n-id="xpack.ml.timeSeriesExplorer.noResultsFoundLabel"
i18n-default-message="{icon} No results found"
i18n-values="{ html_icon: '<i class=\'fa fa-info-circle\'></i>' }"
></div>
<div
i18n-id="xpack.ml.timeSeriesExplorer.dataNotChartableDescription"
i18n-default-message="Model plot is not collected for the selected {entityCount, plural, one {entity} other {entities}} and the source data cannot be plotted for this detector"
i18n-values="{
entityCount: entities.length
}"
></div>
</div>
</div>
<div ng-show="jobs.length > 0 && loading === false && hasResults === true">
<div class="results-container">
<span
class="panel-title euiText"
i18n-id="xpack.ml.timeSeriesExplorer.singleTimeSeriesAnalysisTitle"
i18n-default-message="Single time series analysis of {functionLabel}"
i18n-values="{ functionLabel: chartDetails.functionLabel }"
></span>
<span ng-if="chartDetails.entityData.count === 1" class="entity-count-text">
<span ng-repeat="entity in chartDetails.entityData.entities">
{{$first ? '(' : ''}}{{entity.fieldName}}: {{entity.fieldValue}}{{$last ? ')' : ', '}}
</span>
</span>
<span ng-if="chartDetails.entityData.count !== 1" class="entity-count-text">
<span
ng-repeat="countData in chartDetails.entityData.entities"
i18n-id="xpack.ml.timeSeriesExplorer.countDataInChartDetailsDescription"
i18n-default-message="{openBrace}{cardinalityValue} distinct {fieldName} {cardinality, plural, one {} other { values}}{closeBrace}"
i18n-values="{
openBrace: $first ? '(' : '',
closeBrace: $last ? ')' : ', ',
cardinalityValue: countData.cardinality === 0 ? allValuesLabel : countData.cardinality,
cardinality: countData.cardinality,
fieldName: countData.fieldName
}"
>
</span>
</span>
<div class="show-model-controls">
<div ng-show="showModelBoundsCheckbox === true">
<input id="toggleShowModelBoundsCheckbox"
type="checkbox"
class="kuiCheckBox"
ng-click="toggleShowModelBounds()"
ng-checked="showModelBounds === true">
<label
for="toggleShowModelBoundsCheckbox"
class="kuiCheckBoxLabel"
i18n-id="xpack.ml.timeSeriesExplorer.showModelBoundsLabel"
i18n-default-message="show model bounds"
></label>
</div>
<div ng-show="showAnnotationsCheckbox === true">
<input id="toggleAnnotationsCheckbox"
type="checkbox"
class="kuiCheckBox"
ng-click="toggleShowAnnotations()"
ng-checked="showAnnotations === true">
<label
for="toggleAnnotationsCheckbox"
class="kuiCheckBoxLabel"
i18n-id="xpack.ml.timeSeriesExplorer.annotationsLabel"
i18n-default-message="annotations"
></label>
</div>
<div ng-show="showForecastCheckbox === true">
<input id="toggleShowForecastCheckbox"
type="checkbox"
class="kuiCheckBox"
ng-click="toggleShowForecast()"
ng-checked="showForecast === true">
<label
for="toggleShowForecastCheckbox"
class="kuiCheckBoxLabel"
i18n-id="xpack.ml.timeSeriesExplorer.showForecastLabel"
i18n-default-message="show forecast"
></label>
</div>
</div>
<div class="ml-timeseries-chart" data-test-subj="mlSingleMetricViewerChart">
<ml-timeseries-chart style="width: 1200px; height: 400px;"
annotations-enabled="showAnnotationsCheckbox"
selected-job="selectedJob"
detector-index="detectorId"
model-plot-enabled="modelPlotEnabled"
context-chart-data="contextChartData"
context-forecast-data="contextForecastData"
context-aggregation-interval="contextAggregationInterval"
swimlane-data="swimlaneData"
focus-annotation-data="focusAnnotationData"
focus-chart-data="focusChartData"
focus-forecast-data="focusForecastData"
focus-aggregation-interval="focusAggregationInterval"
show-annotations="showAnnotations"
show-model-bounds="showModelBounds"
show-forecast="showForecast"
zoom-from="zoomFrom"
zoom-to="zoomTo"
auto-zoom-duration="autoZoomDuration"
/>
</div>
<div ng-show="showAnnotations && focusAnnotationData.length > 0">
<span
class="panel-title euiText"
i18n-id="xpack.ml.timeSeriesExplorer.annotationsTitle"
i18n-default-message="Annotations"
></span>
<ml-annotation-table
annotations="focusAnnotationData"
drill-down="false"
number-badge="true"
/>
<div class="euiSpacer euiSpacer--l"></div>
</div>
<ml-annotation-flyout />
<span
class="panel-title euiText"
i18n-id="xpack.ml.timeSeriesExplorer.anomaliesTitle"
i18n-default-message="Anomalies"
></span>
<div class="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--directionRow euiFlexGroup--responsive ml-anomalies-controls">
<div class="euiFlexItem euiFlexItem--flexGrowZero" style="width:170px">
<div class="euiFormRow" id="select_severity_control">
<label
class="euiFormLabel"
for="select_severity"
i18n-id="xpack.ml.timeSeriesExplorer.severityThresholdLabel"
i18n-default-message="Severity threshold"
></label>
<div class="euiFormControlLayout">
<ml-select-severity id="select_severity" />
</div>
</div>
</div>
<div class="euiFlexItem euiFlexItem--flexGrowZero" style="width:170px">
<div class="euiFormRow" id="select_interval_control">
<label
class="euiFormLabel"
for="select_interval"
i18n-id="xpack.ml.timeSeriesExplorer.intervalLabel"
i18n-default-message="Interval"
></label>
<div class="euiFormControlLayout">
<ml-select-interval id="select_interval" />
</div>
</div>
</div>
</div>
<ml-anomalies-table
table-data="tableData"
filter="filter"
/>
</div>
</div>
</div>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,30 @@
/*
* 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.
*/
/*
* Contains values for ML time series explorer.
*/
export const APP_STATE_ACTION = {
CLEAR: 'CLEAR',
GET_DETECTOR_INDEX: 'GET_DETECTOR_INDEX',
SET_DETECTOR_INDEX: 'SET_DETECTOR_INDEX',
GET_ENTITIES: 'GET_ENTITIES',
SET_ENTITIES: 'SET_ENTITIES',
GET_FORECAST_ID: 'GET_FORECAST_ID',
SET_FORECAST_ID: 'SET_FORECAST_ID',
GET_ZOOM: 'GET_ZOOM',
SET_ZOOM: 'SET_ZOOM',
UNSET_ZOOM: 'UNSET_ZOOM',
};
export const CHARTS_POINT_TARGET = 500;
// Max number of scheduled events displayed per bucket.
export const MAX_SCHEDULED_EVENTS = 10;
export const TIME_FIELD_NAME = 'timestamp';

View file

@ -0,0 +1,116 @@
/*
* 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 { get } from 'lodash';
import moment from 'moment-timezone';
import { Subscription } from 'rxjs';
import React from 'react';
import ReactDOM from 'react-dom';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
import chrome from 'ui/chrome';
import { timefilter } from 'ui/timefilter';
import { timeHistory } from 'ui/timefilter/time_history';
import { I18nContext } from 'ui/i18n';
import '../components/controls';
import { severity$ } from '../components/controls/select_severity/select_severity';
import { interval$ } from '../components/controls/select_interval/select_interval';
import { subscribeAppStateToObservable } from '../util/app_state_utils';
import { NavigationMenuContext } from '../util/context_utils';
import { TimeSeriesExplorer } from './timeseriesexplorer';
import { APP_STATE_ACTION } from './timeseriesexplorer_constants';
module.directive('mlTimeSeriesExplorer', function ($injector) {
function link($scope, $element) {
const globalState = $injector.get('globalState');
const AppState = $injector.get('AppState');
const config = $injector.get('config');
const subscriptions = new Subscription();
subscriptions.add(subscribeAppStateToObservable(AppState, 'mlSelectInterval', interval$));
subscriptions.add(subscribeAppStateToObservable(AppState, 'mlSelectSeverity', severity$));
$scope.appState = new AppState({ mlTimeSeriesExplorer: {} });
const appStateHandler = (action, payload) => {
$scope.appState.fetch();
switch (action) {
case APP_STATE_ACTION.CLEAR:
delete $scope.appState.mlTimeSeriesExplorer.detectorIndex;
delete $scope.appState.mlTimeSeriesExplorer.entities;
delete $scope.appState.mlTimeSeriesExplorer.forecastId;
break;
case APP_STATE_ACTION.GET_DETECTOR_INDEX:
return get($scope, 'appState.mlTimeSeriesExplorer.detectorIndex');
case APP_STATE_ACTION.SET_DETECTOR_INDEX:
$scope.appState.mlTimeSeriesExplorer.detectorIndex = payload;
break;
case APP_STATE_ACTION.GET_ENTITIES:
return get($scope, 'appState.mlTimeSeriesExplorer.entities', {});
case APP_STATE_ACTION.SET_ENTITIES:
$scope.appState.mlTimeSeriesExplorer.entities = payload;
break;
case APP_STATE_ACTION.GET_FORECAST_ID:
return get($scope, 'appState.mlTimeSeriesExplorer.forecastId');
case APP_STATE_ACTION.SET_FORECAST_ID:
$scope.appState.mlTimeSeriesExplorer.forecastId = payload;
break;
case APP_STATE_ACTION.GET_ZOOM:
return get($scope, 'appState.mlTimeSeriesExplorer.zoom');
case APP_STATE_ACTION.SET_ZOOM:
$scope.appState.mlTimeSeriesExplorer.zoom = payload;
break;
case APP_STATE_ACTION.UNSET_ZOOM:
delete $scope.appState.mlTimeSeriesExplorer.zoom;
break;
}
$scope.appState.save();
$scope.$applyAsync();
};
function updateComponent() {
// Pass the timezone to the server for use when aggregating anomalies (by day / hour) for the table.
const tzConfig = config.get('dateFormat:tz');
const dateFormatTz = (tzConfig !== 'Browser') ? tzConfig : moment.tz.guess();
ReactDOM.render(
<I18nContext>
<NavigationMenuContext.Provider value={{ chrome, timefilter, timeHistory }}>
<TimeSeriesExplorer {...{
appStateHandler,
dateFormatTz,
globalState,
timefilter,
}}
/>
</NavigationMenuContext.Provider>
</I18nContext>,
$element[0]
);
}
$element.on('$destroy', () => {
ReactDOM.unmountComponentAtNode($element[0]);
subscriptions.unsubscribe();
});
updateComponent();
}
return {
link,
};
});

View file

@ -0,0 +1,30 @@
/*
* 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 uiRoutes from 'ui/routes';
import '../components/controls';
import { checkFullLicense } from '../license/check_license';
import { getMlNodeCount } from '../ml_nodes_check/check_ml_nodes';
import { checkGetJobsPrivilege } from '../privilege/check_privilege';
import { mlJobService } from '../services/job_service';
import { loadIndexPatterns } from '../util/index_utils';
import { getSingleMetricViewerBreadcrumbs } from './breadcrumbs';
uiRoutes
.when('/timeseriesexplorer/?', {
template: '<ml-chart-tooltip /><ml-time-series-explorer data-test-subj="mlPageSingleMetricViewer" />',
k7Breadcrumbs: getSingleMetricViewerBreadcrumbs,
resolve: {
CheckLicense: checkFullLicense,
privileges: checkGetJobsPrivilege,
indexPatterns: loadIndexPatterns,
mlNodeCount: getMlNodeCount,
jobs: mlJobService.loadJobsWrapper
}
});

View file

@ -13,9 +13,34 @@
*/
import _ from 'lodash';
import moment from 'moment-timezone';
import { parseInterval } from 'ui/utils/parse_interval';
import { isTimeSeriesViewJob } from '../../common/util/job_utils';
import {
ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE,
ANOMALIES_TABLE_DEFAULT_QUERY_SIZE
} from '../../common/constants/search';
import {
isTimeSeriesViewJob,
mlFunctionToESAggregation,
} from '../../common/util/job_utils';
import { ml } from '../services/ml_api_service';
import { mlForecastService } from '../services/forecast_service';
import { mlResultsService } from '../services/results_service';
import { MlTimeBuckets, getBoundsRoundedToInterval } from '../util/ml_time_buckets';
import { mlTimeSeriesSearchService } from './timeseries_search_service';
import {
CHARTS_POINT_TARGET,
MAX_SCHEDULED_EVENTS,
TIME_FIELD_NAME,
} from './timeseriesexplorer_constants';
import chrome from 'ui/chrome';
const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false);
// create new job objects based on standard job config objects
// new job objects just contain job id, bucket span in seconds and a selected flag.
@ -97,7 +122,6 @@ export function processRecordScoreResults(scoreData) {
export function processDataForFocusAnomalies(
chartData,
anomalyRecords,
timeFieldName,
aggregationInterval,
modelPlotEnabled) {
@ -110,7 +134,7 @@ export function processDataForFocusAnomalies(
lastChartDataPointTime = chartData[chartData.length - 1].date.getTime();
}
anomalyRecords.forEach((record) => {
const recordTime = record[timeFieldName];
const recordTime = record[TIME_FIELD_NAME];
const chartPoint = findChartPointForAnomalyTime(chartData, recordTime, aggregationInterval);
if (chartPoint === undefined) {
const timeToAdd = (Math.floor(recordTime / intervalMs)) * intervalMs;
@ -141,7 +165,7 @@ export function processDataForFocusAnomalies(
// Look for a chart point with the same time as the record.
// If none found, find closest time in chartData set.
const recordTime = record[timeFieldName];
const recordTime = record[TIME_FIELD_NAME];
const chartPoint = findChartPointForAnomalyTime(chartData, recordTime, aggregationInterval);
if (chartPoint !== undefined) {
// If chart aggregation interval > bucket span, there may be more than
@ -277,3 +301,275 @@ export function findChartPointForAnomalyTime(chartData, anomalyTime, aggregation
return chartPoint;
}
export const getFocusData = function (
criteriaFields,
detectorIndex,
focusAggregationInterval,
forecastId,
modelPlotEnabled,
nonBlankEntities,
searchBounds,
selectedJob,
) {
return new Promise((resolve, reject) => {
// Counter to keep track of the queries to populate the chart.
let awaitingCount = 4;
// This object is used to store the results of individual remote requests
// before we transform it into the final data and apply it to $scope. Otherwise
// we might trigger multiple $digest cycles and depending on how deep $watches
// listen for changes we could miss updates.
const refreshFocusData = {};
// finish() function, called after each data set has been loaded and processed.
// The last one to call it will trigger the page render.
function finish() {
awaitingCount--;
if (awaitingCount === 0) {
// Tell the results container directives to render the focus chart.
refreshFocusData.focusChartData = processDataForFocusAnomalies(
refreshFocusData.focusChartData,
refreshFocusData.anomalyRecords,
focusAggregationInterval,
modelPlotEnabled,
);
refreshFocusData.focusChartData = processScheduledEventsForChart(
refreshFocusData.focusChartData,
refreshFocusData.scheduledEvents);
resolve(refreshFocusData);
}
}
// Query 1 - load metric data across selected time range.
mlTimeSeriesSearchService.getMetricData(
selectedJob,
detectorIndex,
nonBlankEntities,
searchBounds.min.valueOf(),
searchBounds.max.valueOf(),
focusAggregationInterval.expression
).then((resp) => {
refreshFocusData.focusChartData = processMetricPlotResults(resp.results, modelPlotEnabled);
finish();
}).catch((resp) => {
console.log('Time series explorer - error getting metric data from elasticsearch:', resp);
reject();
});
// Query 2 - load all the records across selected time range for the chart anomaly markers.
mlResultsService.getRecordsForCriteria(
[selectedJob.job_id],
criteriaFields,
0,
searchBounds.min.valueOf(),
searchBounds.max.valueOf(),
ANOMALIES_TABLE_DEFAULT_QUERY_SIZE
).then((resp) => {
// Sort in descending time order before storing in scope.
refreshFocusData.anomalyRecords = _.chain(resp.records)
.sortBy(record => record[TIME_FIELD_NAME])
.reverse()
.value();
finish();
});
// Query 3 - load any scheduled events for the selected job.
mlResultsService.getScheduledEventsByBucket(
[selectedJob.job_id],
searchBounds.min.valueOf(),
searchBounds.max.valueOf(),
focusAggregationInterval.expression,
1,
MAX_SCHEDULED_EVENTS
).then((resp) => {
refreshFocusData.scheduledEvents = resp.events[selectedJob.job_id];
finish();
}).catch((resp) => {
console.log('Time series explorer - error getting scheduled events from elasticsearch:', resp);
reject();
});
// Query 4 - load any annotations for the selected job.
if (mlAnnotationsEnabled) {
ml.annotations.getAnnotations({
jobIds: [selectedJob.job_id],
earliestMs: searchBounds.min.valueOf(),
latestMs: searchBounds.max.valueOf(),
maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE
}).then((resp) => {
refreshFocusData.focusAnnotationData = resp.annotations[selectedJob.job_id]
.sort((a, b) => {
return a.timestamp - b.timestamp;
})
.map((d, i) => {
d.key = String.fromCharCode(65 + i);
return d;
});
finish();
}).catch(() => {
// silent fail
refreshFocusData.focusAnnotationData = [];
finish();
});
} else {
finish();
}
// Plus query for forecast data if there is a forecastId stored in the appState.
if (forecastId !== undefined) {
awaitingCount++;
let aggType = undefined;
const detector = selectedJob.analysis_config.detectors[detectorIndex];
const esAgg = mlFunctionToESAggregation(detector.function);
if (modelPlotEnabled === false && (esAgg === 'sum' || esAgg === 'count')) {
aggType = { avg: 'sum', max: 'sum', min: 'sum' };
}
mlForecastService.getForecastData(
selectedJob,
detectorIndex,
forecastId,
nonBlankEntities,
searchBounds.min.valueOf(),
searchBounds.max.valueOf(),
focusAggregationInterval.expression,
aggType)
.then((resp) => {
refreshFocusData.focusForecastData = processForecastResults(resp.results);
refreshFocusData.showForecastCheckbox = (refreshFocusData.focusForecastData.length > 0);
finish();
}).catch((resp) => {
console.log(`Time series explorer - error loading data for forecast ID ${forecastId}`, resp);
reject();
});
}
});
};
export function calculateAggregationInterval(
bounds,
bucketsTarget,
jobs,
selectedJob,
) {
// Aggregation interval used in queries should be a function of the time span of the chart
// and the bucket span of the selected job(s).
const barTarget = (bucketsTarget !== undefined ? bucketsTarget : 100);
// Use a maxBars of 10% greater than the target.
const maxBars = Math.floor(1.1 * barTarget);
const buckets = new MlTimeBuckets();
buckets.setInterval('auto');
buckets.setBounds(bounds);
buckets.setBarTarget(Math.floor(barTarget));
buckets.setMaxBars(maxBars);
// Ensure the aggregation interval is always a multiple of the bucket span to avoid strange
// behaviour such as adjacent chart buckets holding different numbers of job results.
const bucketSpanSeconds = _.find(jobs, { 'id': selectedJob.job_id }).bucketSpanSeconds;
let aggInterval = buckets.getIntervalToNearestMultiple(bucketSpanSeconds);
// Set the interval back to the job bucket span if the auto interval is smaller.
const secs = aggInterval.asSeconds();
if (secs < bucketSpanSeconds) {
buckets.setInterval(bucketSpanSeconds + 's');
aggInterval = buckets.getInterval();
}
return aggInterval;
}
export function calculateDefaultFocusRange(
autoZoomDuration,
contextAggregationInterval,
contextChartData,
contextForecastData,
) {
const isForecastData = contextForecastData !== undefined && contextForecastData.length > 0;
const combinedData = (isForecastData === false) ?
contextChartData : contextChartData.concat(contextForecastData);
const earliestDataDate = _.first(combinedData).date;
const latestDataDate = _.last(combinedData).date;
let rangeEarliestMs;
let rangeLatestMs;
if (isForecastData === true) {
// Return a range centred on the start of the forecast range, depending
// on the time range of the forecast and data.
const earliestForecastDataDate = _.first(contextForecastData).date;
const latestForecastDataDate = _.last(contextForecastData).date;
rangeLatestMs = Math.min(earliestForecastDataDate.getTime() + (autoZoomDuration / 2), latestForecastDataDate.getTime());
rangeEarliestMs = Math.max(rangeLatestMs - autoZoomDuration, earliestDataDate.getTime());
} else {
// Returns the range that shows the most recent data at bucket span granularity.
rangeLatestMs = latestDataDate.getTime() + contextAggregationInterval.asMilliseconds();
rangeEarliestMs = Math.max(earliestDataDate.getTime(), rangeLatestMs - autoZoomDuration);
}
return [new Date(rangeEarliestMs), new Date(rangeLatestMs)];
}
export function calculateInitialFocusRange(zoomState, contextAggregationInterval, timefilter) {
if (zoomState !== undefined) {
// Check that the zoom times are valid.
// zoomFrom must be at or after context chart search bounds earliest,
// zoomTo must be at or before context chart search bounds latest.
const zoomFrom = moment(zoomState.from, 'YYYY-MM-DDTHH:mm:ss.SSSZ', true);
const zoomTo = moment(zoomState.to, 'YYYY-MM-DDTHH:mm:ss.SSSZ', true);
const bounds = timefilter.getActiveBounds();
const searchBounds = getBoundsRoundedToInterval(bounds, contextAggregationInterval, true);
const earliest = searchBounds.min;
const latest = searchBounds.max;
if (zoomFrom.isValid() && zoomTo.isValid &&
zoomTo.isAfter(zoomFrom) &&
zoomFrom.isBetween(earliest, latest, null, '[]') &&
zoomTo.isBetween(earliest, latest, null, '[]')) {
return [zoomFrom.toDate(), zoomTo.toDate()];
}
}
return undefined;
}
export function getAutoZoomDuration(jobs, selectedJob) {
// Calculate the 'auto' zoom duration which shows data at bucket span granularity.
// Get the minimum bucket span of selected jobs.
// TODO - only look at jobs for which data has been returned?
const bucketSpanSeconds = _.find(jobs, { 'id': selectedJob.job_id }).bucketSpanSeconds;
// In most cases the duration can be obtained by simply multiplying the points target
// Check that this duration returns the bucket span when run back through the
// TimeBucket interval calculation.
let autoZoomDuration = (bucketSpanSeconds * 1000) * (CHARTS_POINT_TARGET - 1);
// Use a maxBars of 10% greater than the target.
const maxBars = Math.floor(1.1 * CHARTS_POINT_TARGET);
const buckets = new MlTimeBuckets();
buckets.setInterval('auto');
buckets.setBarTarget(Math.floor(CHARTS_POINT_TARGET));
buckets.setMaxBars(maxBars);
// Set bounds from 'now' for testing the auto zoom duration.
const nowMs = new Date().getTime();
const max = moment(nowMs);
const min = moment(nowMs - autoZoomDuration);
buckets.setBounds({ min, max });
const calculatedInterval = buckets.getIntervalToNearestMultiple(bucketSpanSeconds);
const calculatedIntervalSecs = calculatedInterval.asSeconds();
if (calculatedIntervalSecs !== bucketSpanSeconds) {
// If we haven't got the span back, which may occur depending on the 'auto' ranges
// used in TimeBuckets and the bucket span of the job, then multiply by the ratio
// of the bucket span to the calculated interval.
autoZoomDuration = autoZoomDuration * (bucketSpanSeconds / calculatedIntervalSecs);
}
return autoZoomDuration;
}

View file

@ -50,12 +50,17 @@ export function initializeAppState(AppState, stateName, defaultState) {
return appState;
}
// Some components like the show-chart-checkbox or severity/interval-dropdowns
// emit their state change to an observable. This utility function can be used
// to persist these state changes to AppState and save the state to the url.
// distinctUntilChanged() makes sure the callback is only triggered upon changes
// of the state and filters consecutive triggers of the same value.
export function subscribeAppStateToObservable(AppState, appStateName, o$, callback) {
const appState = initializeAppState(AppState, appStateName, o$.getValue());
o$.next(appState[appStateName]);
o$.pipe(distinctUntilChanged()).subscribe(payload => {
const subscription = o$.pipe(distinctUntilChanged()).subscribe(payload => {
appState.fetch();
appState[appStateName] = payload;
appState.save();
@ -63,4 +68,6 @@ export function subscribeAppStateToObservable(AppState, appStateName, o$, callba
callback(payload);
}
});
return subscription;
}

View file

@ -16,8 +16,8 @@ import moment from 'moment';
import dateMath from '@elastic/datemath';
import chrome from 'ui/chrome';
import { timeBucketsCalcAutoIntervalProvider } from 'plugins/ml/util/ml_calc_auto_interval';
import { inherits } from 'plugins/ml/util/inherits';
import { timeBucketsCalcAutoIntervalProvider } from './ml_calc_auto_interval';
import { inherits } from './inherits';
const unitsDesc = dateMath.unitsDesc;
const largeMax = unitsDesc.indexOf('w'); // Multiple units of week or longer converted to days for ES intervals.
@ -183,4 +183,3 @@ export function calcEsInterval(duration) {
expression: ms + 'ms'
};
}

View file

@ -1,49 +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 { timefilter } from 'ui/timefilter';
/*
* Watches for changes to the refresh interval of the page time filter,
* so that listeners can be notified when the auto-refresh interval has elapsed.
*/
export function refreshIntervalWatcher($timeout) {
let refresher;
let listener;
const onRefreshIntervalChange = () => {
if (refresher) {
$timeout.cancel(refresher);
}
const interval = timefilter.getRefreshInterval();
if (interval.value > 0 && !interval.pause) {
function startRefresh() {
refresher = $timeout(() => {
startRefresh();
listener();
}, interval.value);
}
startRefresh();
}
};
function init(listenerCallback) {
listener = listenerCallback;
timefilter.on('refreshIntervalUpdate', onRefreshIntervalChange);
}
function cancel() {
$timeout.cancel(refresher);
timefilter.off('refreshIntervalUpdate', onRefreshIntervalChange);
}
return {
init,
cancel
};
}

View file

@ -4,5 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
export * from './ml_telemetry';
export {
createMlTelemetry,
getSavedObjectsClient,
incrementFileDataVisualizerIndexCreationCount,
storeMlTelemetry,
MlTelemetry,
MlTelemetrySavedObject,
ML_TELEMETRY_DOC_ID,
} from './ml_telemetry';
export { makeMlUsageCollector } from './make_ml_usage_collector';

View file

@ -7380,9 +7380,9 @@
"xpack.ml.timeSeriesExplorer.forecastsList.viewForecastAriaLabel": "{createdDate} に作成された予測を表示",
"xpack.ml.timeSeriesExplorer.intervalLabel": "間隔",
"xpack.ml.timeSeriesExplorer.loadingLabel": "読み込み中",
"xpack.ml.timeSeriesExplorer.noResultsFoundLabel": "{icon} 結果が見つかりませんでした",
"xpack.ml.timeSeriesExplorer.noSingleMetricJobsFoundLabel": "{icon} シングルメトリックジョブが見つかりませんでした",
"xpack.ml.timeSeriesExplorer.refreshButtonAriLabel": "更新",
"xpack.ml.timeSeriesExplorer.noResultsFoundLabel": "結果が見つかりませんでした",
"xpack.ml.timeSeriesExplorer.noSingleMetricJobsFoundLabel": "シングルメトリックジョブが見つかりませんでした",
"xpack.ml.timeSeriesExplorer.refreshButtonAriaLabel": "更新",
"xpack.ml.timeSeriesExplorer.requestedDetectorIndexNotValidWarningMessage": "リクエストされた検知器インデックス {detectorIndex} はジョブ {jobId} に有効ではありません",
"xpack.ml.timeSeriesExplorer.runControls.durationLabel": "期間",
"xpack.ml.timeSeriesExplorer.runControls.forecastMaximumLengthHelpText": "予想の長さで、最長 {maximumForecastDurationDays} 日です。秒には s、分には m、時間には h、日には d、週には w を使います。",

View file

@ -7380,9 +7380,9 @@
"xpack.ml.timeSeriesExplorer.forecastsList.viewForecastAriaLabel": "查看在 {createdDate} 创建的预测",
"xpack.ml.timeSeriesExplorer.intervalLabel": "时间间隔",
"xpack.ml.timeSeriesExplorer.loadingLabel": "正在加载",
"xpack.ml.timeSeriesExplorer.noResultsFoundLabel": "{icon} 找不到结果",
"xpack.ml.timeSeriesExplorer.noSingleMetricJobsFoundLabel": "{icon} 未找到单指标作业",
"xpack.ml.timeSeriesExplorer.refreshButtonAriLabel": "刷新",
"xpack.ml.timeSeriesExplorer.noResultsFoundLabel": "找不到结果",
"xpack.ml.timeSeriesExplorer.noSingleMetricJobsFoundLabel": "未找到单指标作业",
"xpack.ml.timeSeriesExplorer.refreshButtonAriaLabel": "刷新",
"xpack.ml.timeSeriesExplorer.requestedDetectorIndexNotValidWarningMessage": "请求的检测工具索引 {detectorIndex} 对于作业 {jobId} 无效",
"xpack.ml.timeSeriesExplorer.runControls.durationLabel": "持续时间",
"xpack.ml.timeSeriesExplorer.runControls.forecastMaximumLengthHelpText": "预测时长,最多 {maximumForecastDurationDays} 天。使用 s 表示秒m 表示分钟h 表示小时d 表示天w 表示周。",