mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
Migrates the overall page of Single Metric Viewer to React.
This commit is contained in:
parent
d77145c3c6
commit
82ac64dbe3
67 changed files with 2387 additions and 2434 deletions
|
@ -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
|
||||
};
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
|
||||
});
|
|
@ -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
|
||||
};
|
||||
});
|
|
@ -5,5 +5,3 @@
|
|||
*/
|
||||
|
||||
export { AnnotationsTable } from './annotations_table';
|
||||
|
||||
import './annotations_table_directive';
|
||||
|
|
|
@ -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
|
||||
}
|
||||
);
|
||||
});
|
|
@ -5,4 +5,4 @@
|
|||
*/
|
||||
|
||||
|
||||
import './anomalies_table_directive';
|
||||
export { AnomaliesTable } from './anomalies_table';
|
||||
|
|
|
@ -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());
|
||||
});
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
|
@ -5,4 +5,4 @@
|
|||
*/
|
||||
|
||||
|
||||
import './select_interval_directive';
|
||||
export { interval$, SelectInterval } from './select_interval';
|
||||
|
|
|
@ -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' }
|
||||
);
|
||||
});
|
|
@ -5,4 +5,4 @@
|
|||
*/
|
||||
|
||||
|
||||
import './select_severity_directive';
|
||||
export { SelectSeverity, severity$, SEVERITY_OPTIONS } from './select_severity';
|
||||
|
|
|
@ -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' },
|
||||
);
|
||||
});
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
});
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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', () => {
|
|||
});
|
||||
|
||||
});
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
|
@ -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="​">
|
||||
<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="​">
|
||||
<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="​">
|
||||
<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="​">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -5,4 +5,10 @@
|
|||
*/
|
||||
|
||||
|
||||
export * from './utils';
|
||||
export {
|
||||
createUrlOverrides,
|
||||
hasImportPermission,
|
||||
processResults,
|
||||
readFile,
|
||||
reduceData,
|
||||
} from './utils';
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -5,4 +5,4 @@
|
|||
*/
|
||||
|
||||
|
||||
export * from './context_chart_mask';
|
||||
export { ContextChartMask } from './context_chart_mask';
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
|
@ -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';
|
|
@ -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"
|
||||
|
|
|
@ -6,4 +6,7 @@
|
|||
|
||||
|
||||
|
||||
import './forecasting_modal_directive';
|
||||
export {
|
||||
ForecastingModal,
|
||||
FORECAST_DURATION_MAX_DAYS,
|
||||
}from './forecasting_modal';
|
||||
|
|
|
@ -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')}`;
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
};
|
||||
});
|
|
@ -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';
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
|
@ -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';
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
|
@ -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';
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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
|
@ -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';
|
File diff suppressed because it is too large
Load diff
|
@ -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,
|
||||
};
|
||||
});
|
|
@ -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
|
||||
}
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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 を使います。",
|
||||
|
|
|
@ -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 表示周。",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue