mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
[ML] Migrate Anomaly Explorer to React (except job selector) (#28234)
* [ML] Move Anomaly Explorer Loading indicator to React. * [ML] Move no-jobs message to React/Eui. * [ML] Move no-results message to React/Eui. * [ML] Refactored explorer.js to return earlier. * [ML] Refactored influencers column to react. * [ML] Refactored Overall Swimlane and view-by dropdown to react/eui. * [ML] Refactored limit dropdown to react/eui. * [ML] Refactored view-by swimlanes to React/Eui. * [ML] Refactored annotations table to React/Eui. * [ML] Refactored table controls to React/Eui. * [ML] Refactored explorer charts to use React/Eui. * [ML] Refactored anomalies table to React/Eui. * [ML] Move explorer charts data listener to ExplorerChartsContainer component. * [ML] Make AppState dependent services importable by React components. * [ML] Removes deprecated code. * [ML] Simplify state handling for anomaly charts. * [ML] Simplify swimlaneCellClick(). * [ML] Review feedback: Fix file structure, add propTypes. * [ML] Review feedback: Avoid anonymous inline functions. * [ML] Fixes tests to reflect code changes. * [ML] Fixes InfluencerList DOM position. * [ML] Show a loading indicator when the view-by swimlane updates. * [ML] Review feedback: Import only relevant lodash bits. Use querySelector instead of jQuery. * [ML] Adds snapshot tests for new smallish components. * [ML] Fix test stub. * [ML] More resilient getChartContainerWidth(). * [ML] Review feedback: Comment on legacy utils and dropdown widths.
This commit is contained in:
parent
38163f2517
commit
c58c357115
47 changed files with 1023 additions and 726 deletions
|
@ -17,12 +17,18 @@ import {
|
|||
|
||||
import makeId from '@elastic/eui/lib/components/form/form_row/make_id';
|
||||
|
||||
// This service will be populated by the corresponding angularjs based one.
|
||||
export const mlCheckboxShowChartsService = {
|
||||
intialized: false,
|
||||
state: null
|
||||
};
|
||||
|
||||
class CheckboxShowCharts extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// Restore the checked setting from the state.
|
||||
this.mlCheckboxShowChartsService = this.props.mlCheckboxShowChartsService;
|
||||
this.mlCheckboxShowChartsService = mlCheckboxShowChartsService;
|
||||
const showCharts = this.mlCheckboxShowChartsService.state.get('showCharts');
|
||||
|
||||
this.state = {
|
||||
|
|
|
@ -12,22 +12,21 @@ import { stateFactoryProvider } from 'plugins/ml/factories/state_factory';
|
|||
import { uiModules } from 'ui/modules';
|
||||
const module = uiModules.get('apps/ml', ['react']);
|
||||
|
||||
import { CheckboxShowCharts } from './checkbox_showcharts';
|
||||
import { CheckboxShowCharts, mlCheckboxShowChartsService } from './checkbox_showcharts';
|
||||
|
||||
module.service('mlCheckboxShowChartsService', function (Private) {
|
||||
const stateFactory = Private(stateFactoryProvider);
|
||||
this.state = stateFactory('mlCheckboxShowCharts', {
|
||||
this.state = mlCheckboxShowChartsService.state = stateFactory('mlCheckboxShowCharts', {
|
||||
showCharts: true
|
||||
});
|
||||
mlCheckboxShowChartsService.initialized = true;
|
||||
})
|
||||
.directive('mlCheckboxShowCharts', function ($injector) {
|
||||
const reactDirective = $injector.get('reactDirective');
|
||||
const mlCheckboxShowChartsService = $injector.get('mlCheckboxShowChartsService');
|
||||
|
||||
return reactDirective(
|
||||
CheckboxShowCharts,
|
||||
undefined,
|
||||
{ restrict: 'E' },
|
||||
{ mlCheckboxShowChartsService }
|
||||
);
|
||||
});
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
/*
|
||||
* React component for rendering a select element with various aggregation interval levels.
|
||||
*/
|
||||
import _ from 'lodash';
|
||||
import { get } from 'lodash';
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import {
|
||||
|
@ -38,14 +38,20 @@ function optionValueToInterval(value) {
|
|||
return interval;
|
||||
}
|
||||
|
||||
// This service will be populated by the corresponding angularjs based one.
|
||||
export const mlSelectIntervalService = {
|
||||
intialized: false,
|
||||
state: null
|
||||
};
|
||||
|
||||
class SelectInterval extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// Restore the interval from the state, or default to auto.
|
||||
this.mlSelectIntervalService = this.props.mlSelectIntervalService;
|
||||
this.mlSelectIntervalService = mlSelectIntervalService;
|
||||
const intervalState = this.mlSelectIntervalService.state.get('interval');
|
||||
const intervalValue = _.get(intervalState, 'val', 'auto');
|
||||
const intervalValue = get(intervalState, 'val', 'auto');
|
||||
const interval = optionValueToInterval(intervalValue);
|
||||
this.mlSelectIntervalService.state.set('interval', interval);
|
||||
|
||||
|
|
|
@ -12,22 +12,21 @@ import { stateFactoryProvider } from 'plugins/ml/factories/state_factory';
|
|||
import { uiModules } from 'ui/modules';
|
||||
const module = uiModules.get('apps/ml', ['react']);
|
||||
|
||||
import { SelectInterval } from './select_interval';
|
||||
import { SelectInterval, mlSelectIntervalService } from './select_interval';
|
||||
|
||||
module.service('mlSelectIntervalService', function (Private) {
|
||||
const stateFactory = Private(stateFactoryProvider);
|
||||
this.state = stateFactory('mlSelectInterval', {
|
||||
this.state = mlSelectIntervalService.state = stateFactory('mlSelectInterval', {
|
||||
interval: { display: 'Auto', val: 'auto' }
|
||||
});
|
||||
mlSelectIntervalService.initialized = true;
|
||||
})
|
||||
.directive('mlSelectInterval', function ($injector) {
|
||||
const reactDirective = $injector.get('reactDirective');
|
||||
const mlSelectIntervalService = $injector.get('mlSelectIntervalService');
|
||||
|
||||
return reactDirective(
|
||||
SelectInterval,
|
||||
undefined,
|
||||
{ restrict: 'E' },
|
||||
{ mlSelectIntervalService }
|
||||
{ restrict: 'E' }
|
||||
);
|
||||
});
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
* React component for rendering a select element with threshold levels.
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
import _ from 'lodash';
|
||||
import { get } from 'lodash';
|
||||
import React, { Component, Fragment } from 'react';
|
||||
|
||||
import {
|
||||
|
@ -30,7 +30,7 @@ const optionsMap = {
|
|||
'critical': 75,
|
||||
};
|
||||
|
||||
const SEVERITY_OPTIONS = [
|
||||
export const SEVERITY_OPTIONS = [
|
||||
{ val: 0, display: 'warning', color: getSeverityColor(0) },
|
||||
{ val: 25, display: 'minor', color: getSeverityColor(25) },
|
||||
{ val: 50, display: 'major', color: getSeverityColor(50) },
|
||||
|
@ -49,13 +49,19 @@ function optionValueToThreshold(value) {
|
|||
return threshold;
|
||||
}
|
||||
|
||||
// This service will be populated by the corresponding angularjs based one.
|
||||
export const mlSelectSeverityService = {
|
||||
intialized: false,
|
||||
state: null
|
||||
};
|
||||
|
||||
class SelectSeverity extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// Restore the threshold from the state, or default to warning.
|
||||
if (this.props.mlSelectSeverityService) {
|
||||
this.mlSelectSeverityService = this.props.mlSelectSeverityService;
|
||||
if (mlSelectSeverityService.intialized) {
|
||||
this.mlSelectSeverityService = mlSelectSeverityService;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
|
@ -67,7 +73,7 @@ class SelectSeverity extends Component {
|
|||
// set initial state from service if available
|
||||
if (this.mlSelectSeverityService !== undefined) {
|
||||
const thresholdState = this.mlSelectSeverityService.state.get('threshold');
|
||||
const thresholdValue = _.get(thresholdState, 'val', 0);
|
||||
const thresholdValue = get(thresholdState, 'val', 0);
|
||||
const threshold = optionValueToThreshold(thresholdValue);
|
||||
// set initial selected option equal to threshold value
|
||||
const selectedOption = SEVERITY_OPTIONS.find(opt => (opt.val === threshold.val));
|
||||
|
|
|
@ -12,22 +12,21 @@ import { stateFactoryProvider } from 'plugins/ml/factories/state_factory';
|
|||
import { uiModules } from 'ui/modules';
|
||||
const module = uiModules.get('apps/ml', ['react']);
|
||||
|
||||
import { SelectSeverity } from './select_severity';
|
||||
import { SelectSeverity, mlSelectSeverityService } from './select_severity';
|
||||
|
||||
module.service('mlSelectSeverityService', function (Private) {
|
||||
const stateFactory = Private(stateFactoryProvider);
|
||||
this.state = stateFactory('mlSelectSeverity', {
|
||||
this.state = mlSelectSeverityService.state = stateFactory('mlSelectSeverity', {
|
||||
threshold: { display: 'warning', val: 0 }
|
||||
});
|
||||
mlSelectSeverityService.intialized = true;
|
||||
})
|
||||
.directive('mlSelectSeverity', function ($injector) {
|
||||
const reactDirective = $injector.get('reactDirective');
|
||||
const mlSelectSeverityService = $injector.get('mlSelectSeverityService');
|
||||
|
||||
return reactDirective(
|
||||
SelectSeverity,
|
||||
undefined,
|
||||
{ restrict: 'E' },
|
||||
{ mlSelectSeverityService }
|
||||
);
|
||||
});
|
||||
|
|
|
@ -4,5 +4,4 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
|
||||
import './influencers_list_directive';
|
||||
export * from './influencers_list';
|
||||
|
|
|
@ -1,23 +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 { InfluencersList } from './influencers_list';
|
||||
|
||||
module.directive('mlInfluencersList', function ($injector) {
|
||||
const reactDirective = $injector.get('reactDirective');
|
||||
|
||||
return reactDirective(
|
||||
InfluencersList,
|
||||
undefined,
|
||||
{ restrict: 'E' }
|
||||
);
|
||||
});
|
|
@ -9,14 +9,18 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
export function LoadingIndicator({ height }) {
|
||||
export function LoadingIndicator({ height, label }) {
|
||||
height = height ? +height : 100;
|
||||
return (
|
||||
<div className="ml-loading-indicator" style={{ height: `${height}px` }}>
|
||||
<div className="loading-spinner"><i className="fa fa-spinner fa-spin" /></div>
|
||||
{label &&
|
||||
<div ml-loading-indicator-label="true">{label}</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
LoadingIndicator.propTypes = {
|
||||
height: PropTypes.number
|
||||
height: PropTypes.number,
|
||||
label: PropTypes.string
|
||||
};
|
||||
|
|
|
@ -62,7 +62,6 @@
|
|||
|
||||
.panel-sub-title {
|
||||
color: $euiColorDarkShade;
|
||||
display: inline-block;
|
||||
font-size: $euiFontSizeXS;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ExplorerNoInfluencersFound snapshot 1`] = `
|
||||
<EuiEmptyPrompt
|
||||
iconColor="subdued"
|
||||
iconType="alert"
|
||||
title={
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
defaultMessage="No {swimlaneViewByFieldName} influencers found"
|
||||
id="xpack.ml.explorer.noInfluencersFoundTitle"
|
||||
values={
|
||||
Object {
|
||||
"swimlaneViewByFieldName": "field_name",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</h2>
|
||||
}
|
||||
titleSize="xs"
|
||||
/>
|
||||
`;
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* 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 influencers were found.
|
||||
*/
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { EuiEmptyPrompt } from '@elastic/eui';
|
||||
|
||||
export const ExplorerNoInfluencersFound = ({ swimlaneViewByFieldName }) => (
|
||||
<EuiEmptyPrompt
|
||||
iconType="alert"
|
||||
titleSize="xs"
|
||||
title={
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.noInfluencersFoundTitle"
|
||||
defaultMessage="No {swimlaneViewByFieldName} influencers found"
|
||||
values={{ swimlaneViewByFieldName }}
|
||||
/>
|
||||
</h2>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
ExplorerNoInfluencersFound.propTypes = {
|
||||
swimlaneViewByFieldName: PropTypes.string.isRequired,
|
||||
};
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { ExplorerNoInfluencersFound } from './explorer_no_influencers_found';
|
||||
|
||||
describe('ExplorerNoInfluencersFound', () => {
|
||||
|
||||
test('snapshot', () => {
|
||||
const wrapper = shallow(<ExplorerNoInfluencersFound swimlaneViewByFieldName="field_name" />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
});
|
|
@ -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 * from './explorer_no_influencers_found';
|
|
@ -0,0 +1,32 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ExplorerNoInfluencersFound snapshot 1`] = `
|
||||
<EuiEmptyPrompt
|
||||
actions={
|
||||
<EuiButton
|
||||
color="primary"
|
||||
fill={true}
|
||||
href="ml#/jobs"
|
||||
iconSide="left"
|
||||
type="button"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Create new job"
|
||||
id="xpack.ml.explorer.createNewJobLinkText"
|
||||
values={Object {}}
|
||||
/>
|
||||
</EuiButton>
|
||||
}
|
||||
iconColor="subdued"
|
||||
iconType="alert"
|
||||
title={
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
defaultMessage="No jobs found"
|
||||
id="xpack.ml.explorer.noJobsFoundLabel"
|
||||
values={Object {}}
|
||||
/>
|
||||
</h2>
|
||||
}
|
||||
/>
|
||||
`;
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* 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 ExplorerNoJobsFound = () => (
|
||||
<EuiEmptyPrompt
|
||||
iconType="alert"
|
||||
title={
|
||||
<h2>
|
||||
<FormattedMessage id="xpack.ml.explorer.noJobsFoundLabel" defaultMessage="No jobs found" />
|
||||
</h2>
|
||||
}
|
||||
actions={
|
||||
<EuiButton color="primary" fill href="ml#/jobs">
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.createNewJobLinkText"
|
||||
defaultMessage="Create new job"
|
||||
/>
|
||||
</EuiButton>
|
||||
}
|
||||
/>
|
||||
);
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { ExplorerNoJobsFound } from './explorer_no_jobs_found';
|
||||
|
||||
describe('ExplorerNoInfluencersFound', () => {
|
||||
|
||||
test('snapshot', () => {
|
||||
const wrapper = shallow(<ExplorerNoJobsFound />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
});
|
|
@ -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 * from './explorer_no_jobs_found';
|
|
@ -0,0 +1,28 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ExplorerNoInfluencersFound snapshot 1`] = `
|
||||
<EuiEmptyPrompt
|
||||
body={
|
||||
<React.Fragment>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
defaultMessage="Try widening the time selection or moving further back in time"
|
||||
id="xpack.ml.explorer.tryWideningTimeSelectionLabel"
|
||||
values={Object {}}
|
||||
/>
|
||||
</p>
|
||||
</React.Fragment>
|
||||
}
|
||||
iconColor="subdued"
|
||||
iconType="iInCircle"
|
||||
title={
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
defaultMessage="No results found"
|
||||
id="xpack.ml.explorer.noResultsFoundLabel"
|
||||
values={Object {}}
|
||||
/>
|
||||
</h2>
|
||||
}
|
||||
/>
|
||||
`;
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* 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 { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
import { EuiEmptyPrompt } from '@elastic/eui';
|
||||
|
||||
export const ExplorerNoResultsFound = () => (
|
||||
<EuiEmptyPrompt
|
||||
iconType="iInCircle"
|
||||
title={
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.noResultsFoundLabel"
|
||||
defaultMessage="No results found"
|
||||
/>
|
||||
</h2>
|
||||
}
|
||||
body={
|
||||
<React.Fragment>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.tryWideningTimeSelectionLabel"
|
||||
defaultMessage="Try widening the time selection or moving further back in time"
|
||||
/>
|
||||
</p>
|
||||
</React.Fragment>
|
||||
}
|
||||
/>
|
||||
);
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { ExplorerNoResultsFound } from './explorer_no_results_found';
|
||||
|
||||
describe('ExplorerNoInfluencersFound', () => {
|
||||
|
||||
test('snapshot', () => {
|
||||
const wrapper = shallow(<ExplorerNoResultsFound />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
});
|
|
@ -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 * from './explorer_no_results_found';
|
9
x-pack/plugins/ml/public/explorer/components/index.js
Normal file
9
x-pack/plugins/ml/public/explorer/components/index.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* 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 * from './explorer_no_influencers_found';
|
||||
export * from './explorer_no_jobs_found';
|
||||
export * from './explorer_no_results_found';
|
|
@ -10,205 +10,5 @@
|
|||
state="appState">
|
||||
</filter-bar>
|
||||
|
||||
<ml-loading-indicator
|
||||
label="{{ ::'xpack.ml.explorer.loadingLabel' | i18n: {defaultMessage: 'Loading'} }}"
|
||||
is-loading="loading === true"
|
||||
/>
|
||||
|
||||
<div class="no-results-container" ng-if="jobs.length === 0 && loading === false">
|
||||
<div class="no-results euiText">
|
||||
<div
|
||||
i18n-id="xpack.ml.explorer.noJobsFoundLabel"
|
||||
i18n-default-message="{icon} No jobs found"
|
||||
i18n-values="{ html_icon: '<i class=\'fa fa-exclamation-triangle\' ></i>' }"
|
||||
></div>
|
||||
<div>
|
||||
<a
|
||||
href="ml#/jobs"
|
||||
i18n-id="xpack.ml.explorer.createNewJobLinkText"
|
||||
i18n-default-message="Create new job"
|
||||
></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="no-results-container" ng-show="jobs.length > 0 && loading === false && hasResults === false">
|
||||
<div class="no-results euiText">
|
||||
<div
|
||||
i18n-id="xpack.ml.explorer.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.explorer.tryWideningTimeSelectionLabel"
|
||||
i18n-default-message="Try widening the time selection or moving further back in time"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="jobs.length > 0 && loading === false && hasResults === true">
|
||||
<div class="results-container">
|
||||
|
||||
<div ng-if="noInfluencersConfigured === true" class="no-influencers-warning">
|
||||
<i aria-hidden="true" class="fa fa-info-circle" tooltip-placement="right" tooltip-append-to-body="true"
|
||||
tooltip-html-unsafe="{{ ::'xpack.ml.explorer.noConfiguredInfluencersTooltip' | i18n: {defaultMessage: 'The Top Influencers list is hidden because no influencers have been configured for the selected jobs.'} }}" ></i>
|
||||
</div>
|
||||
|
||||
<div ng-if="noInfluencersConfigured === false" class="column col-xs-2 euiText">
|
||||
<span
|
||||
class="panel-title"
|
||||
i18n-id="xpack.ml.explorer.topInfuencersTitle"
|
||||
i18n-default-message="Top Influencers"
|
||||
></span>
|
||||
<ml-influencers-list
|
||||
influencers="influencers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="column" ng-class="noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10'">
|
||||
<span class="panel-title euiText"
|
||||
i18n-id="xpack.ml.explorer.anomalyTimelineTitle"
|
||||
i18n-default-message="Anomaly timeline"
|
||||
></span>
|
||||
<div
|
||||
class="ml-explorer-swimlane euiText"
|
||||
ng-mouseenter="setSwimlaneSelectActive(true)"
|
||||
ng-mouseleave="setSwimlaneSelectActive(false)"
|
||||
>
|
||||
<ml-explorer-swimlane swimlane-type="overall" />
|
||||
</div>
|
||||
|
||||
<div ng-if="viewBySwimlaneOptions.length > 0">
|
||||
<div class="ml-controls">
|
||||
<label
|
||||
for="selectViewBy"
|
||||
class="kuiLabel"
|
||||
i18n-id="xpack.ml.explorer.viewByLabel"
|
||||
i18n-default-message="View by:"
|
||||
></label>
|
||||
<div class="kuiButtonGroup" dropdown>
|
||||
<button id="selectViewBy" type="button" class="form-control dropdown-toggle" dropdown-toggle ng-disabled="disabled">
|
||||
<span>{{swimlaneViewByFieldName}}</span> <span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<li ng-repeat="swimlaneOption in viewBySwimlaneOptions">
|
||||
<a href="" ng-click="setSwimlaneViewBy(swimlaneOption)">{{swimlaneOption}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ml-select-limit />
|
||||
|
||||
<span
|
||||
ng-if="viewByLoadedForTimeFormatted"
|
||||
class="panel-sub-title"
|
||||
i18n-id="xpack.ml.explorer.sortedByMaxAnomalyScoreForTimeFormattedLabel"
|
||||
i18n-default-message="(Sorted by max anomaly score for {viewByLoadedForTimeFormatted})"
|
||||
i18n-values="{ viewByLoadedForTimeFormatted }"
|
||||
></span>
|
||||
<span
|
||||
ng-if="!viewByLoadedForTimeFormatted"
|
||||
class="panel-sub-title"
|
||||
i18n-id="xpack.ml.explorer.sortedByMaxAnomalyScoreLabel"
|
||||
i18n-default-message="(Sorted by max anomaly score)"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="ml-explorer-swimlane euiText"
|
||||
ng-if="showViewBySwimlane"
|
||||
ng-mouseenter="setSwimlaneSelectActive(true)"
|
||||
ng-mouseleave="setSwimlaneSelectActive(false)"
|
||||
>
|
||||
<ml-explorer-swimlane swimlane-type="viewBy" />
|
||||
</div>
|
||||
|
||||
<div ng-if="!showViewBySwimlane" class="text-center visError euiText">
|
||||
<div class="item top"></div>
|
||||
<div class="item">
|
||||
<h4
|
||||
class="euiTitle euiTitle--small"
|
||||
i18n-id="xpack.ml.explorer.noInfluencersFoundTitle"
|
||||
i18n-default-message="No {swimlaneViewByFieldName} influencers found"
|
||||
i18n-values="{ swimlaneViewByFieldName }"
|
||||
></h4>
|
||||
</div>
|
||||
<div class="item bottom"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div ng-show="annotationsData.length > 0">
|
||||
<span
|
||||
class="panel-title euiText"
|
||||
i18n-id="xpack.ml.explorer.annotationsTitle"
|
||||
i18n-default-message="Annotations"
|
||||
></span>
|
||||
|
||||
<ml-annotation-table
|
||||
annotations="annotationsData"
|
||||
drill-down="true"
|
||||
number-badge="false"
|
||||
/>
|
||||
|
||||
<br /><br />
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="panel-title euiText"
|
||||
i18n-id="xpack.ml.explorer.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.explorer.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.explorer.intervalLabel"
|
||||
i18n-default-message="Interval"
|
||||
></label>
|
||||
<div class="euiFormControlLayout">
|
||||
<ml-select-interval id="select_interval" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="euiFlexItem euiFlexItem--flexGrowZero" ng-show="anomalyChartRecords.length > 0">
|
||||
<div class="euiFormRow" id="show_charts_checkbox_control">
|
||||
<div class="euiFormControlLayout">
|
||||
<ml-checkbox-show-charts id="show_charts_checkbox" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="euiSpacer euiSpacer--m"></div>
|
||||
|
||||
<div class="euiText explorer-charts">
|
||||
<ml-explorer-charts-container />
|
||||
</div>
|
||||
|
||||
<ml-anomalies-table
|
||||
table-data="tableData"
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ml-explorer-react-wrapper />
|
||||
</div>
|
||||
|
|
302
x-pack/plugins/ml/public/explorer/explorer.js
Normal file
302
x-pack/plugins/ml/public/explorer/explorer.js
Normal file
|
@ -0,0 +1,302 @@
|
|||
/*
|
||||
* 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 Explorer dashboard swimlanes.
|
||||
*/
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { FormattedMessage, injectI18n } from '@kbn/i18n/react';
|
||||
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFormRow,
|
||||
EuiIconTip,
|
||||
EuiSelect,
|
||||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { AnnotationsTable } from '../components/annotations_table';
|
||||
import { CheckboxShowCharts } from '../components/controls/checkbox_showcharts/checkbox_showcharts';
|
||||
import {
|
||||
ExplorerNoInfluencersFound,
|
||||
ExplorerNoJobsFound,
|
||||
ExplorerNoResultsFound,
|
||||
} from './components';
|
||||
import { ExplorerSwimlane } from './explorer_swimlane';
|
||||
import { InfluencersList } from '../components/influencers_list';
|
||||
import { LoadingIndicator } from '../components/loading_indicator/loading_indicator';
|
||||
import { SelectInterval } from '../components/controls/select_interval/select_interval';
|
||||
import { SelectLimit } from './select_limit/select_limit';
|
||||
import { SelectSeverity } from '../components/controls/select_severity/select_severity';
|
||||
|
||||
// Explorer Charts
|
||||
import { ExplorerChartsContainer } from './explorer_charts/explorer_charts_container';
|
||||
|
||||
// Anomalies Table
|
||||
import { AnomaliesTable } from '../components/anomalies_table/anomalies_table';
|
||||
import { timefilter } from 'ui/timefilter';
|
||||
|
||||
function mapSwimlaneOptionsToEuiOptions(options) {
|
||||
return options.map(option => ({
|
||||
value: option,
|
||||
text: option,
|
||||
}));
|
||||
}
|
||||
|
||||
export const Explorer = injectI18n(
|
||||
class Explorer extends React.Component {
|
||||
static propTypes = {
|
||||
annotationsData: PropTypes.array,
|
||||
anomalyChartRecords: PropTypes.array,
|
||||
hasResults: PropTypes.bool,
|
||||
influencers: PropTypes.object,
|
||||
jobs: PropTypes.array,
|
||||
loading: PropTypes.bool,
|
||||
noInfluencersConfigured: PropTypes.bool,
|
||||
setSwimlaneSelectActive: PropTypes.func,
|
||||
setSwimlaneViewBy: PropTypes.func,
|
||||
showViewBySwimlane: PropTypes.bool,
|
||||
swimlaneOverall: PropTypes.object,
|
||||
swimlaneViewByFieldName: PropTypes.string,
|
||||
tableData: PropTypes.object,
|
||||
viewByLoadedForTimeFormatted: PropTypes.any,
|
||||
viewBySwimlaneOptions: PropTypes.array,
|
||||
};
|
||||
|
||||
viewByChangeHandler = e => this.props.setSwimlaneViewBy(e.target.value);
|
||||
|
||||
onSwimlaneEnterHandler = () => this.props.setSwimlaneSelectActive(true);
|
||||
onSwimlaneLeaveHandler = () => this.props.setSwimlaneSelectActive(false);
|
||||
|
||||
render() {
|
||||
const {
|
||||
annotationsData,
|
||||
anomalyChartRecords,
|
||||
chartsData,
|
||||
influencers,
|
||||
intl,
|
||||
hasResults,
|
||||
jobs,
|
||||
loading,
|
||||
noInfluencersConfigured,
|
||||
showViewBySwimlane,
|
||||
swimlaneOverall,
|
||||
swimlaneViewBy,
|
||||
swimlaneViewByFieldName,
|
||||
tableData,
|
||||
viewByLoadedForTimeFormatted,
|
||||
viewBySwimlaneDataLoading,
|
||||
viewBySwimlaneOptions,
|
||||
} = this.props;
|
||||
|
||||
if (loading === true) {
|
||||
return (
|
||||
<LoadingIndicator
|
||||
label={intl.formatMessage({
|
||||
id: 'xpack.ml.explorer.loadingLabel',
|
||||
defaultMessage: 'Loading',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (jobs.length === 0) {
|
||||
return <ExplorerNoJobsFound />;
|
||||
}
|
||||
|
||||
if (jobs.length > 0 && hasResults === false) {
|
||||
return <ExplorerNoResultsFound />;
|
||||
}
|
||||
|
||||
const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10';
|
||||
const mainColumnClasses = `column ${mainColumnWidthClassName}`;
|
||||
|
||||
return (
|
||||
<div className="results-container">
|
||||
{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>
|
||||
)}
|
||||
|
||||
{noInfluencersConfigured === false && (
|
||||
<div className="column col-xs-2 euiText">
|
||||
<span className="panel-title">
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.topInfuencersTitle"
|
||||
defaultMessage="Top Influencers"
|
||||
/>
|
||||
</span>
|
||||
<InfluencersList influencers={influencers} />
|
||||
</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}
|
||||
>
|
||||
<ExplorerSwimlane {...swimlaneOverall} />
|
||||
</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)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
{showViewBySwimlane && (
|
||||
<div
|
||||
className="ml-explorer-swimlane euiText"
|
||||
onMouseEnter={this.onSwimlaneEnterHandler}
|
||||
onMouseLeave={this.onSwimlaneLeaveHandler}
|
||||
>
|
||||
<ExplorerSwimlane {...swimlaneViewBy} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewBySwimlaneDataLoading && (
|
||||
<LoadingIndicator/>
|
||||
)}
|
||||
|
||||
{!showViewBySwimlane && !viewBySwimlaneDataLoading && (
|
||||
<ExplorerNoInfluencersFound swimlaneViewByFieldName={swimlaneViewByFieldName} />
|
||||
)}
|
||||
</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}
|
||||
/>
|
||||
<br />
|
||||
<br />
|
||||
</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 && (
|
||||
<EuiFlexItem grow={false} style={{ alignSelf: 'center' }}>
|
||||
<EuiFormRow label="​">
|
||||
<CheckboxShowCharts />
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<div className="euiText explorer-charts">
|
||||
<ExplorerChartsContainer {...chartsData} />
|
||||
</div>
|
||||
|
||||
<AnomaliesTable tableData={tableData} timefilter={timefilter} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
exports[`explorerChartsContainerService call anomalyChangeListener with actual series config 1`] = `
|
||||
Object {
|
||||
"chartsPerRow": 1,
|
||||
"seriesToPlot": Array [],
|
||||
"timeFieldName": "timestamp",
|
||||
"tooManyBuckets": false,
|
||||
|
|
|
@ -33,6 +33,7 @@ import { LoadingIndicator } from '../../components/loading_indicator/loading_ind
|
|||
import { mlEscape } from '../../util/string_utils';
|
||||
import { mlFieldFormatService } from '../../services/field_format_service';
|
||||
import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service';
|
||||
import { mlSelectSeverityService, SEVERITY_OPTIONS } from '../../components/controls/select_severity/select_severity';
|
||||
|
||||
import { CHART_TYPE } from '../explorer_constants';
|
||||
|
||||
|
@ -50,7 +51,6 @@ const Y_AXIS_LABEL_THRESHOLD = 10;
|
|||
export const ExplorerChartDistribution = injectI18n(class ExplorerChartDistribution extends React.Component {
|
||||
static propTypes = {
|
||||
seriesConfig: PropTypes.object,
|
||||
mlSelectSeverityService: PropTypes.object.isRequired
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -64,7 +64,6 @@ export const ExplorerChartDistribution = injectI18n(class ExplorerChartDistribut
|
|||
renderChart() {
|
||||
const {
|
||||
tooManyBuckets,
|
||||
mlSelectSeverityService,
|
||||
intl,
|
||||
} = this.props;
|
||||
|
||||
|
@ -372,7 +371,7 @@ export const ExplorerChartDistribution = injectI18n(class ExplorerChartDistribut
|
|||
.on('mouseout', () => mlChartTooltipService.hide());
|
||||
|
||||
// Update all dots to new positions.
|
||||
const threshold = mlSelectSeverityService.state.get('threshold');
|
||||
const threshold = (mlSelectSeverityService.initiliazed) ? mlSelectSeverityService.state.get('threshold') : SEVERITY_OPTIONS[0];
|
||||
dots.attr('cx', d => lineChartXScale(d.date))
|
||||
.attr('cy', d => lineChartYScale(d[CHART_Y_ATTRIBUTE]))
|
||||
.attr('class', (d) => {
|
||||
|
|
|
@ -41,6 +41,7 @@ import { LoadingIndicator } from '../../components/loading_indicator/loading_ind
|
|||
import { mlEscape } from '../../util/string_utils';
|
||||
import { mlFieldFormatService } from '../../services/field_format_service';
|
||||
import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service';
|
||||
import { mlSelectSeverityService, SEVERITY_OPTIONS } from '../../components/controls/select_severity/select_severity';
|
||||
|
||||
import { injectI18n } from '@kbn/i18n/react';
|
||||
|
||||
|
@ -51,7 +52,6 @@ export const ExplorerChartSingleMetric = injectI18n(class ExplorerChartSingleMet
|
|||
static propTypes = {
|
||||
tooManyBuckets: PropTypes.bool,
|
||||
seriesConfig: PropTypes.object,
|
||||
mlSelectSeverityService: PropTypes.object.isRequired
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -65,7 +65,6 @@ export const ExplorerChartSingleMetric = injectI18n(class ExplorerChartSingleMet
|
|||
renderChart() {
|
||||
const {
|
||||
tooManyBuckets,
|
||||
mlSelectSeverityService,
|
||||
intl,
|
||||
} = this.props;
|
||||
|
||||
|
@ -286,7 +285,7 @@ export const ExplorerChartSingleMetric = injectI18n(class ExplorerChartSingleMet
|
|||
.on('mouseout', () => mlChartTooltipService.hide());
|
||||
|
||||
// Update all dots to new positions.
|
||||
const threshold = mlSelectSeverityService.state.get('threshold');
|
||||
const threshold = (mlSelectSeverityService.initiliazed) ? mlSelectSeverityService.state.get('threshold') : SEVERITY_OPTIONS[0];
|
||||
dots.attr('cx', d => lineChartXScale(d.date))
|
||||
.attr('cy', d => lineChartYScale(d.value))
|
||||
.attr('class', (d) => {
|
||||
|
|
|
@ -4,7 +4,8 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import $ from 'jquery';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
|
@ -54,7 +55,6 @@ function getChartId(series) {
|
|||
function ExplorerChartContainer({
|
||||
series,
|
||||
tooManyBuckets,
|
||||
mlSelectSeverityService,
|
||||
wrapLabel
|
||||
}) {
|
||||
const {
|
||||
|
@ -134,7 +134,6 @@ function ExplorerChartContainer({
|
|||
<ExplorerChartDistribution
|
||||
tooManyBuckets={tooManyBuckets}
|
||||
seriesConfig={series}
|
||||
mlSelectSeverityService={mlSelectSeverityService}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -142,7 +141,6 @@ function ExplorerChartContainer({
|
|||
<ExplorerChartSingleMetric
|
||||
tooManyBuckets={tooManyBuckets}
|
||||
seriesConfig={series}
|
||||
mlSelectSeverityService={mlSelectSeverityService}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
@ -151,37 +149,44 @@ function ExplorerChartContainer({
|
|||
}
|
||||
|
||||
// Flex layout wrapper for all explorer charts
|
||||
export function ExplorerChartsContainer({
|
||||
chartsPerRow,
|
||||
seriesToPlot,
|
||||
tooManyBuckets,
|
||||
mlSelectSeverityService
|
||||
}) {
|
||||
// <EuiFlexGrid> doesn't allow a setting of `columns={1}` when chartsPerRow would be 1.
|
||||
// If that's the case we trick it doing that with the following settings:
|
||||
const chartsWidth = (chartsPerRow === 1) ? 'calc(100% - 20px)' : 'auto';
|
||||
const chartsColumns = (chartsPerRow === 1) ? 0 : chartsPerRow;
|
||||
export class ExplorerChartsContainer extends React.Component {
|
||||
componentDidMount() {
|
||||
// Create a div for the tooltip.
|
||||
$('.ml-explorer-charts-tooltip').remove();
|
||||
$('body').append('<div class="ml-explorer-tooltip ml-explorer-charts-tooltip" style="opacity:0; display: none;">');
|
||||
}
|
||||
|
||||
const wrapLabel = seriesToPlot.some((series) => isLabelLengthAboveThreshold(series));
|
||||
componentWillUnmount() {
|
||||
// Remove div for the tooltip.
|
||||
$('.ml-explorer-charts-tooltip').remove();
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiFlexGrid columns={chartsColumns}>
|
||||
{(seriesToPlot.length > 0) && seriesToPlot.map((series) => (
|
||||
<EuiFlexItem key={getChartId(series)} className="ml-explorer-chart-container" style={{ minWidth: chartsWidth }}>
|
||||
<ExplorerChartContainer
|
||||
series={series}
|
||||
tooManyBuckets={tooManyBuckets}
|
||||
mlSelectSeverityService={mlSelectSeverityService}
|
||||
wrapLabel={wrapLabel}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</EuiFlexGrid>
|
||||
);
|
||||
render() {
|
||||
const {
|
||||
chartsPerRow,
|
||||
seriesToPlot,
|
||||
tooManyBuckets
|
||||
} = this.props;
|
||||
|
||||
// <EuiFlexGrid> doesn't allow a setting of `columns={1}` when chartsPerRow would be 1.
|
||||
// If that's the case we trick it doing that with the following settings:
|
||||
const chartsWidth = (chartsPerRow === 1) ? 'calc(100% - 20px)' : 'auto';
|
||||
const chartsColumns = (chartsPerRow === 1) ? 0 : chartsPerRow;
|
||||
|
||||
const wrapLabel = seriesToPlot.some((series) => isLabelLengthAboveThreshold(series));
|
||||
|
||||
return (
|
||||
<EuiFlexGrid columns={chartsColumns}>
|
||||
{(seriesToPlot.length > 0) && seriesToPlot.map((series) => (
|
||||
<EuiFlexItem key={getChartId(series)} className="ml-explorer-chart-container" style={{ minWidth: chartsWidth }}>
|
||||
<ExplorerChartContainer
|
||||
series={series}
|
||||
tooManyBuckets={tooManyBuckets}
|
||||
wrapLabel={wrapLabel}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</EuiFlexGrid>
|
||||
);
|
||||
}
|
||||
}
|
||||
ExplorerChartsContainer.propTypes = {
|
||||
seriesToPlot: PropTypes.array.isRequired,
|
||||
tooManyBuckets: PropTypes.bool.isRequired,
|
||||
mlSelectSeverityService: PropTypes.object.isRequired,
|
||||
mlChartTooltipService: PropTypes.object.isRequired
|
||||
};
|
||||
|
|
|
@ -22,17 +22,17 @@ jest.mock('../../services/field_format_service', () => ({
|
|||
getFieldFormat: jest.fn()
|
||||
}
|
||||
}));
|
||||
jest.mock('ui/chrome', () => ({
|
||||
getBasePath: (path) => path,
|
||||
getUiSettingsClient: () => ({
|
||||
get: () => null
|
||||
}),
|
||||
jest.mock('../../services/job_service', () => ({
|
||||
mlJobService: {
|
||||
getJob: jest.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
// The mocks for ui/chrome and ui/timefilter are copied from charts_utils.test.js
|
||||
// TODO: Refactor the involved tests to avoid this duplication
|
||||
jest.mock('ui/chrome',
|
||||
() => ({
|
||||
addBasePath: () => '/api/ml',
|
||||
getBasePath: () => {
|
||||
return '<basepath>';
|
||||
},
|
||||
|
@ -66,19 +66,11 @@ import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers';
|
|||
import React from 'react';
|
||||
|
||||
import { chartLimits } from '../../util/chart_utils';
|
||||
import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service';
|
||||
import { getDefaultChartsData } from './explorer_charts_container_service';
|
||||
|
||||
import { ExplorerChartsContainer } from './explorer_charts_container';
|
||||
|
||||
describe('ExplorerChartsContainer', () => {
|
||||
const mlSelectSeverityServiceMock = {
|
||||
state: {
|
||||
get: () => ({
|
||||
val: ''
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
const mockedGetBBox = { x: 0, y: -11.5, width: 12.1875, height: 14.5 };
|
||||
const originalGetBBox = SVGElement.prototype.getBBox;
|
||||
const rareChartUniqueString = 'y-axis event distribution split by';
|
||||
|
@ -86,29 +78,23 @@ describe('ExplorerChartsContainer', () => {
|
|||
afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox));
|
||||
|
||||
test('Minimal Initialization', () => {
|
||||
const wrapper = shallowWithIntl(<ExplorerChartsContainer
|
||||
seriesToPlot={[]}
|
||||
chartsPerRow={1}
|
||||
tooManyBuckets={false}
|
||||
mlSelectSeverityService={mlSelectSeverityServiceMock}
|
||||
mlChartTooltipService={mlChartTooltipService}
|
||||
/>);
|
||||
const wrapper = shallowWithIntl(<ExplorerChartsContainer {...getDefaultChartsData()} />);
|
||||
|
||||
expect(wrapper.html()).toBe('<div class=\"euiFlexGrid euiFlexGrid--gutterLarge euiFlexGrid--wrap euiFlexGrid--responsive\"></div>');
|
||||
});
|
||||
|
||||
test('Initialization with chart data', () => {
|
||||
const wrapper = mountWithIntl(<ExplorerChartsContainer
|
||||
seriesToPlot={[{
|
||||
const props = {
|
||||
...getDefaultChartsData(),
|
||||
seriesToPlot: [{
|
||||
...seriesConfig,
|
||||
chartData,
|
||||
chartLimits: chartLimits(chartData)
|
||||
}]}
|
||||
chartsPerRow={1}
|
||||
tooManyBuckets={false}
|
||||
mlSelectSeverityService={mlSelectSeverityServiceMock}
|
||||
mlChartTooltipService={mlChartTooltipService}
|
||||
/>);
|
||||
}],
|
||||
chartsPerRow: 1,
|
||||
tooManyBuckets: false
|
||||
};
|
||||
const wrapper = mountWithIntl(<ExplorerChartsContainer {...props} />);
|
||||
|
||||
// We test child components with snapshots separately
|
||||
// so we just do some high level sanity check here.
|
||||
|
@ -119,17 +105,17 @@ describe('ExplorerChartsContainer', () => {
|
|||
});
|
||||
|
||||
test('Initialization with rare detector', () => {
|
||||
const wrapper = mountWithIntl(<ExplorerChartsContainer
|
||||
seriesToPlot={[{
|
||||
const props = {
|
||||
...getDefaultChartsData(),
|
||||
seriesToPlot: [{
|
||||
...seriesConfigRare,
|
||||
chartData,
|
||||
chartLimits: chartLimits(chartData)
|
||||
}]}
|
||||
chartsPerRow={1}
|
||||
tooManyBuckets={false}
|
||||
mlSelectSeverityService={mlSelectSeverityServiceMock}
|
||||
mlChartTooltipService={mlChartTooltipService}
|
||||
/>);
|
||||
}],
|
||||
chartsPerRow: 1,
|
||||
tooManyBuckets: false
|
||||
};
|
||||
const wrapper = mountWithIntl(<ExplorerChartsContainer {...props} />);
|
||||
|
||||
// We test child components with snapshots separately
|
||||
// so we just do some high level sanity check here.
|
||||
|
|
|
@ -1,80 +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 for rendering the containing div for the charts of
|
||||
* anomalies in the raw data in the Machine Learning Explorer dashboard.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import $ from 'jquery';
|
||||
import { ExplorerChartsContainer } from './explorer_charts_container';
|
||||
import { explorerChartsContainerServiceFactory } from './explorer_charts_container_service';
|
||||
import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service';
|
||||
import { mlExplorerDashboardService } from '../explorer_dashboard_service';
|
||||
|
||||
import { I18nProvider } from '@kbn/i18n/react';
|
||||
|
||||
import { uiModules } from 'ui/modules';
|
||||
const module = uiModules.get('apps/ml');
|
||||
|
||||
module.directive('mlExplorerChartsContainer', function (
|
||||
mlSelectSeverityService
|
||||
) {
|
||||
|
||||
function link(scope, element) {
|
||||
const anomalyDataChangeListener = explorerChartsContainerServiceFactory(
|
||||
mlSelectSeverityService,
|
||||
updateComponent,
|
||||
$('.explorer-charts')
|
||||
);
|
||||
|
||||
mlExplorerDashboardService.anomalyDataChange.watch(anomalyDataChangeListener);
|
||||
|
||||
scope.$on('$destroy', () => {
|
||||
mlExplorerDashboardService.anomalyDataChange.unwatch(anomalyDataChangeListener);
|
||||
});
|
||||
|
||||
// Create a div for the tooltip.
|
||||
$('.ml-explorer-charts-tooltip').remove();
|
||||
$('body').append('<div class="ml-explorer-tooltip ml-explorer-charts-tooltip" style="opacity:0; display: none;">');
|
||||
|
||||
element.on('$destroy', function () {
|
||||
scope.$destroy();
|
||||
});
|
||||
|
||||
function updateComponent(data) {
|
||||
const props = {
|
||||
chartsPerRow: data.chartsPerRow,
|
||||
seriesToPlot: data.seriesToPlot,
|
||||
// convert truthy/falsy value to Boolean
|
||||
tooManyBuckets: !!data.tooManyBuckets,
|
||||
mlSelectSeverityService,
|
||||
mlChartTooltipService
|
||||
};
|
||||
|
||||
ReactDOM.render(
|
||||
<I18nProvider>
|
||||
{React.createElement(ExplorerChartsContainer, props)}
|
||||
</I18nProvider>,
|
||||
element[0]
|
||||
);
|
||||
}
|
||||
|
||||
mlExplorerDashboardService.chartsInitDone.changed();
|
||||
}
|
||||
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: false,
|
||||
scope: false,
|
||||
link: link
|
||||
};
|
||||
});
|
|
@ -4,8 +4,6 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* Service for the container for the anomaly charts in the
|
||||
* Machine Learning Explorer dashboard.
|
||||
|
@ -23,47 +21,52 @@ import {
|
|||
import { isTimeSeriesViewDetector } from '../../../common/util/job_utils';
|
||||
import { mlResultsService } from '../../services/results_service';
|
||||
import { mlJobService } from '../../services/job_service';
|
||||
import {
|
||||
mlSelectSeverityService,
|
||||
SEVERITY_OPTIONS,
|
||||
} from '../../components/controls/select_severity/select_severity';
|
||||
import { getChartContainerWidth } from './legacy_utils';
|
||||
|
||||
import { CHART_TYPE } from '../explorer_constants';
|
||||
|
||||
export function explorerChartsContainerServiceFactory(
|
||||
mlSelectSeverityService,
|
||||
callback,
|
||||
$chartContainer
|
||||
) {
|
||||
export function getDefaultChartsData() {
|
||||
return {
|
||||
chartsPerRow: 1,
|
||||
seriesToPlot: [],
|
||||
// default values, will update on every re-render
|
||||
tooManyBuckets: false,
|
||||
timeFieldName: 'timestamp'
|
||||
};
|
||||
}
|
||||
|
||||
export function explorerChartsContainerServiceFactory(callback) {
|
||||
const FUNCTION_DESCRIPTIONS_TO_PLOT = ['mean', 'min', 'max', 'sum', 'count', 'distinct_count', 'median', 'rare'];
|
||||
const CHART_MAX_POINTS = 500;
|
||||
const ANOMALIES_MAX_RESULTS = 500;
|
||||
const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket.
|
||||
const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket.
|
||||
const ML_TIME_FIELD_NAME = 'timestamp';
|
||||
const USE_OVERALL_CHART_LIMITS = false;
|
||||
const MAX_CHARTS_PER_ROW = 4;
|
||||
|
||||
function getDefaultData() {
|
||||
return {
|
||||
seriesToPlot: [],
|
||||
// default values, will update on every re-render
|
||||
tooManyBuckets: false,
|
||||
timeFieldName: 'timestamp'
|
||||
};
|
||||
}
|
||||
|
||||
callback(getDefaultData());
|
||||
callback(getDefaultChartsData());
|
||||
|
||||
let requestCount = 0;
|
||||
const anomalyDataChangeListener = function (anomalyRecords, earliestMs, latestMs) {
|
||||
const anomalyDataChange = function (anomalyRecords, earliestMs, latestMs) {
|
||||
const newRequestCount = ++requestCount;
|
||||
requestCount = newRequestCount;
|
||||
|
||||
const data = getDefaultData();
|
||||
const data = getDefaultChartsData();
|
||||
|
||||
const threshold = (mlSelectSeverityService.initalized)
|
||||
? mlSelectSeverityService.state.get('threshold')
|
||||
: SEVERITY_OPTIONS[0];
|
||||
|
||||
const threshold = mlSelectSeverityService.state.get('threshold');
|
||||
const filteredRecords = anomalyRecords.filter((record) => {
|
||||
return Number(record.record_score) >= threshold.val;
|
||||
});
|
||||
const allSeriesRecords = processRecordsForDisplay(filteredRecords);
|
||||
// Calculate the number of charts per row, depending on the width available, to a max of 4.
|
||||
const chartsContainerWidth = Math.floor($chartContainer.width());
|
||||
const chartsContainerWidth = getChartContainerWidth();
|
||||
let chartsPerRow = Math.min(Math.max(Math.floor(chartsContainerWidth / 550), 1), MAX_CHARTS_PER_ROW);
|
||||
if (allSeriesRecords.length === 1) {
|
||||
chartsPerRow = 1;
|
||||
|
@ -573,6 +576,6 @@ export function explorerChartsContainerServiceFactory(
|
|||
};
|
||||
}
|
||||
|
||||
return anomalyDataChangeListener;
|
||||
return anomalyDataChange;
|
||||
|
||||
}
|
||||
|
|
|
@ -81,6 +81,10 @@ jest.mock('../../util/string_utils', () => ({
|
|||
mlEscape(d) { return d; }
|
||||
}));
|
||||
|
||||
jest.mock('./legacy_utils', () => ({
|
||||
getChartContainerWidth() { return 1140; }
|
||||
}));
|
||||
|
||||
jest.mock('ui/chrome', () => ({
|
||||
getBasePath: (path) => path,
|
||||
getUiSettingsClient: () => ({
|
||||
|
@ -88,35 +92,14 @@ jest.mock('ui/chrome', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
const mockMlSelectSeverityService = {
|
||||
state: {
|
||||
get() { return { display: 'warning', val: 0 }; }
|
||||
}
|
||||
};
|
||||
|
||||
const mockChartContainer = {
|
||||
width() { return 1140; }
|
||||
};
|
||||
|
||||
function mockGetDefaultData() {
|
||||
return {
|
||||
seriesToPlot: [],
|
||||
tooManyBuckets: false,
|
||||
timeFieldName: 'timestamp'
|
||||
};
|
||||
}
|
||||
|
||||
import { explorerChartsContainerServiceFactory } from './explorer_charts_container_service';
|
||||
import { explorerChartsContainerServiceFactory, getDefaultChartsData } from './explorer_charts_container_service';
|
||||
|
||||
describe('explorerChartsContainerService', () => {
|
||||
test('Initialize factory', (done) => {
|
||||
explorerChartsContainerServiceFactory(
|
||||
mockMlSelectSeverityService,
|
||||
callback
|
||||
);
|
||||
explorerChartsContainerServiceFactory(callback);
|
||||
|
||||
function callback(data) {
|
||||
expect(data).toEqual(mockGetDefaultData());
|
||||
expect(data).toEqual(getDefaultChartsData());
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
@ -125,17 +108,13 @@ describe('explorerChartsContainerService', () => {
|
|||
// callback will be called multiple times.
|
||||
// the callbackData array contains the expected data values for each consecutive call.
|
||||
const callbackData = [];
|
||||
callbackData.push(mockGetDefaultData());
|
||||
callbackData.push(getDefaultChartsData());
|
||||
callbackData.push({
|
||||
...mockGetDefaultData(),
|
||||
...getDefaultChartsData(),
|
||||
chartsPerRow: 2
|
||||
});
|
||||
|
||||
const anomalyDataChangeListener = explorerChartsContainerServiceFactory(
|
||||
mockMlSelectSeverityService,
|
||||
callback,
|
||||
mockChartContainer
|
||||
);
|
||||
const anomalyDataChangeListener = explorerChartsContainerServiceFactory(callback);
|
||||
|
||||
anomalyDataChangeListener(
|
||||
[],
|
||||
|
@ -159,11 +138,7 @@ describe('explorerChartsContainerService', () => {
|
|||
let callbackCount = 0;
|
||||
const expectedTestCount = 3;
|
||||
|
||||
const anomalyDataChangeListener = explorerChartsContainerServiceFactory(
|
||||
mockMlSelectSeverityService,
|
||||
callback,
|
||||
mockChartContainer
|
||||
);
|
||||
const anomalyDataChangeListener = explorerChartsContainerServiceFactory(callback);
|
||||
|
||||
anomalyDataChangeListener(
|
||||
mockAnomalyChartRecords,
|
||||
|
@ -184,11 +159,7 @@ describe('explorerChartsContainerService', () => {
|
|||
let callbackCount = 0;
|
||||
const expectedTestCount = 3;
|
||||
|
||||
const anomalyDataChangeListener = explorerChartsContainerServiceFactory(
|
||||
mockMlSelectSeverityService,
|
||||
callback,
|
||||
mockChartContainer
|
||||
);
|
||||
const anomalyDataChangeListener = explorerChartsContainerServiceFactory(callback);
|
||||
|
||||
const mockAnomalyChartRecordsClone = _.cloneDeep(mockAnomalyChartRecords).map((d) => {
|
||||
d.job_id = 'mock-job-id-distribution';
|
||||
|
@ -228,11 +199,7 @@ describe('explorerChartsContainerService', () => {
|
|||
let callbackCount = 0;
|
||||
const expectedTestCount = 3;
|
||||
|
||||
const anomalyDataChangeListener = explorerChartsContainerServiceFactory(
|
||||
mockMlSelectSeverityService,
|
||||
callback,
|
||||
mockChartContainer
|
||||
);
|
||||
const anomalyDataChangeListener = explorerChartsContainerServiceFactory(callback);
|
||||
|
||||
const mockAnomalyChartRecordsClone = _.cloneDeep(mockAnomalyChartRecords);
|
||||
mockAnomalyChartRecordsClone[1].partition_field_value = 'AAL.';
|
||||
|
|
|
@ -4,7 +4,4 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
import './explorer_charts_container_directive.js';
|
||||
import 'plugins/ml/components/chart_tooltip';
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// This file includes utils which should eventuelly become obsolete once Anomaly Explorer
|
||||
// is fully migrated to React. Their purpose is to retain functionality while we migrate step by step.
|
||||
|
||||
export function getChartContainerWidth() {
|
||||
const chartContainer = document.querySelector('.explorer-charts');
|
||||
return Math.floor(chartContainer && chartContainer.clientWidth || 0);
|
||||
}
|
|
@ -20,7 +20,6 @@ import moment from 'moment-timezone';
|
|||
import 'plugins/ml/components/annotations_table';
|
||||
import 'plugins/ml/components/anomalies_table';
|
||||
import 'plugins/ml/components/controls';
|
||||
import 'plugins/ml/components/influencers_list';
|
||||
import 'plugins/ml/components/job_select_list';
|
||||
|
||||
import { FilterBarQueryFilterProvider } from 'ui/filter_bar/query_filter';
|
||||
|
@ -43,6 +42,7 @@ import { JobSelectServiceProvider } from 'plugins/ml/components/job_select_list/
|
|||
import { isTimeSeriesViewDetector } from 'plugins/ml/../common/util/job_utils';
|
||||
import { timefilter } from 'ui/timefilter';
|
||||
import { formatHumanReadableDateTime } from '../util/date_utils';
|
||||
import { explorerChartsContainerServiceFactory, getDefaultChartsData } from './explorer_charts/explorer_charts_container_service';
|
||||
import {
|
||||
DRAG_SELECT_ACTION,
|
||||
SWIMLANE_DEFAULT_LIMIT,
|
||||
|
@ -53,7 +53,6 @@ import {
|
|||
ANOMALIES_TABLE_DEFAULT_QUERY_SIZE
|
||||
} from '../../common/constants/search';
|
||||
|
||||
// TODO Fully support Annotations in Anomaly Explorer
|
||||
import chrome from 'ui/chrome';
|
||||
const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false);
|
||||
|
||||
|
@ -80,7 +79,6 @@ function getDefaultViewBySwimlaneData() {
|
|||
};
|
||||
}
|
||||
|
||||
|
||||
module.controller('MlExplorerController', function (
|
||||
$scope,
|
||||
$timeout,
|
||||
|
@ -95,6 +93,7 @@ module.controller('MlExplorerController', function (
|
|||
|
||||
$scope.annotationsData = [];
|
||||
$scope.anomalyChartRecords = [];
|
||||
$scope.chartsData = getDefaultChartsData();
|
||||
$scope.timeFieldName = 'timestamp';
|
||||
$scope.loading = true;
|
||||
timefilter.enableTimeRangeSelector();
|
||||
|
@ -122,6 +121,17 @@ module.controller('MlExplorerController', function (
|
|||
let skipCellClicks = true;
|
||||
$scope.queryFilters = [];
|
||||
|
||||
const anomalyDataChange = explorerChartsContainerServiceFactory((data) => {
|
||||
$scope.chartsData = {
|
||||
...getDefaultChartsData(),
|
||||
chartsPerRow: data.chartsPerRow,
|
||||
seriesToPlot: data.seriesToPlot,
|
||||
// convert truthy/falsy value to Boolean
|
||||
tooManyBuckets: !!data.tooManyBuckets,
|
||||
};
|
||||
$scope.$applyAsync();
|
||||
});
|
||||
|
||||
const dragSelect = new DragSelect({
|
||||
selectables: document.getElementsByClassName('sl-cell'),
|
||||
callback(elements) {
|
||||
|
@ -164,16 +174,7 @@ module.controller('MlExplorerController', function (
|
|||
|
||||
$scope.viewBySwimlaneOptions = [];
|
||||
$scope.viewBySwimlaneData = getDefaultViewBySwimlaneData();
|
||||
|
||||
|
||||
let isChartsContainerInitialized = false;
|
||||
let chartsCallback = () => {};
|
||||
function initializeAfterChartsContainerDone() {
|
||||
if (isChartsContainerInitialized === false) {
|
||||
chartsCallback();
|
||||
}
|
||||
isChartsContainerInitialized = true;
|
||||
}
|
||||
$scope.viewBySwimlaneDataLoading = false;
|
||||
|
||||
$scope.initializeVis = function () {
|
||||
// Initialize the AppState in which to store filters.
|
||||
|
@ -203,7 +204,6 @@ module.controller('MlExplorerController', function (
|
|||
});
|
||||
|
||||
mlExplorerDashboardService.init();
|
||||
mlExplorerDashboardService.chartsInitDone.watch(initializeAfterChartsContainerDone);
|
||||
};
|
||||
|
||||
// create new job objects based on standard job config objects
|
||||
|
@ -366,38 +366,16 @@ module.controller('MlExplorerController', function (
|
|||
$scope.appState.save();
|
||||
}
|
||||
|
||||
function getSwimlaneData(swimlaneType) {
|
||||
switch (swimlaneType) {
|
||||
case SWIMLANE_TYPE.OVERALL:
|
||||
return $scope.overallSwimlaneData;
|
||||
case SWIMLANE_TYPE.VIEW_BY:
|
||||
return $scope.viewBySwimlaneData;
|
||||
}
|
||||
}
|
||||
|
||||
function mapScopeToSwimlaneProps(swimlaneType) {
|
||||
return {
|
||||
chartWidth: $scope.swimlaneWidth,
|
||||
MlTimeBuckets: TimeBuckets,
|
||||
swimlaneData: getSwimlaneData(swimlaneType),
|
||||
swimlaneType,
|
||||
selection: $scope.appState.mlExplorerSwimlane
|
||||
};
|
||||
}
|
||||
|
||||
function redrawOnResize() {
|
||||
$scope.swimlaneWidth = getSwimlaneContainerWidth();
|
||||
$scope.$apply();
|
||||
|
||||
mlExplorerDashboardService.swimlaneDataChange.changed(mapScopeToSwimlaneProps(SWIMLANE_TYPE.OVERALL));
|
||||
mlExplorerDashboardService.swimlaneDataChange.changed(mapScopeToSwimlaneProps(SWIMLANE_TYPE.VIEW_BY));
|
||||
|
||||
if (
|
||||
mlCheckboxShowChartsService.state.get('showCharts') &&
|
||||
$scope.anomalyChartRecords.length > 0
|
||||
) {
|
||||
const timerange = getSelectionTimeRange($scope.cellData);
|
||||
mlExplorerDashboardService.anomalyDataChange.changed(
|
||||
anomalyDataChange(
|
||||
$scope.anomalyChartRecords, timerange.earliestMs, timerange.latestMs
|
||||
);
|
||||
}
|
||||
|
@ -418,6 +396,7 @@ module.controller('MlExplorerController', function (
|
|||
$scope.viewBySwimlaneData.laneLabels &&
|
||||
$scope.viewBySwimlaneData.laneLabels.length > 0
|
||||
);
|
||||
$scope.$applyAsync();
|
||||
}
|
||||
|
||||
function getSelectionTimeRange(cellData) {
|
||||
|
@ -465,12 +444,12 @@ module.controller('MlExplorerController', function (
|
|||
// an update of the viewby swimlanes. If we'd just ignored click events
|
||||
// during the loading, we could miss programmatically triggered events like
|
||||
// those coming via AppState when a selection is part of the URL.
|
||||
const swimlaneCellClickListenerQueue = [];
|
||||
const swimlaneCellClickQueue = [];
|
||||
|
||||
// Listener for click events in the swimlane to load corresponding anomaly data.
|
||||
const swimlaneCellClickListener = function (cellData) {
|
||||
$scope.swimlaneCellClick = function (cellData) {
|
||||
if (skipCellClicks === true) {
|
||||
swimlaneCellClickListenerQueue.push(cellData);
|
||||
swimlaneCellClickQueue.push(cellData);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -492,7 +471,6 @@ module.controller('MlExplorerController', function (
|
|||
updateExplorer();
|
||||
}
|
||||
};
|
||||
mlExplorerDashboardService.swimlaneCellClick.watch(swimlaneCellClickListener);
|
||||
|
||||
const checkboxShowChartsListener = function () {
|
||||
const showCharts = mlCheckboxShowChartsService.state.get('showCharts');
|
||||
|
@ -500,7 +478,7 @@ module.controller('MlExplorerController', function (
|
|||
updateExplorer();
|
||||
} else {
|
||||
const timerange = getSelectionTimeRange($scope.cellData);
|
||||
mlExplorerDashboardService.anomalyDataChange.changed(
|
||||
anomalyDataChange(
|
||||
[], timerange.earliestMs, timerange.latestMs
|
||||
);
|
||||
}
|
||||
|
@ -511,7 +489,7 @@ module.controller('MlExplorerController', function (
|
|||
const showCharts = mlCheckboxShowChartsService.state.get('showCharts');
|
||||
if (showCharts && $scope.cellData !== undefined) {
|
||||
const timerange = getSelectionTimeRange($scope.cellData);
|
||||
mlExplorerDashboardService.anomalyDataChange.changed(
|
||||
anomalyDataChange(
|
||||
$scope.anomalyChartRecords, timerange.earliestMs, timerange.latestMs
|
||||
);
|
||||
}
|
||||
|
@ -540,13 +518,11 @@ module.controller('MlExplorerController', function (
|
|||
$scope.$on('$destroy', () => {
|
||||
dragSelect.stop();
|
||||
mlCheckboxShowChartsService.state.unwatch(checkboxShowChartsListener);
|
||||
mlExplorerDashboardService.swimlaneCellClick.unwatch(swimlaneCellClickListener);
|
||||
mlExplorerDashboardService.swimlaneRenderDone.unwatch(swimlaneRenderDoneListener);
|
||||
mlSelectSeverityService.state.unwatch(anomalyChartsSeverityListener);
|
||||
mlSelectIntervalService.state.unwatch(tableControlsListener);
|
||||
mlSelectSeverityService.state.unwatch(tableControlsListener);
|
||||
mlSelectLimitService.state.unwatch(swimlaneLimitListener);
|
||||
mlExplorerDashboardService.chartsInitDone.unwatch(initializeAfterChartsContainerDone);
|
||||
delete $scope.cellData;
|
||||
refreshWatcher.cancel();
|
||||
$(window).off('resize', jqueryRedrawOnResize);
|
||||
|
@ -582,7 +558,7 @@ module.controller('MlExplorerController', function (
|
|||
console.log('Explorer anomaly charts data set:', $scope.anomalyChartRecords);
|
||||
|
||||
if (mlCheckboxShowChartsService.state.get('showCharts')) {
|
||||
mlExplorerDashboardService.anomalyDataChange.changed(
|
||||
anomalyDataChange(
|
||||
$scope.anomalyChartRecords, earliestMs, latestMs
|
||||
);
|
||||
}
|
||||
|
@ -807,7 +783,6 @@ module.controller('MlExplorerController', function (
|
|||
// Tell the result components directives to render.
|
||||
// Need to use $timeout to ensure the broadcast happens after the child scope is updated with the new data.
|
||||
$timeout(() => {
|
||||
mlExplorerDashboardService.swimlaneDataChange.changed(mapScopeToSwimlaneProps(SWIMLANE_TYPE.OVERALL));
|
||||
loadViewBySwimlane([]);
|
||||
}, 0);
|
||||
});
|
||||
|
@ -837,6 +812,7 @@ module.controller('MlExplorerController', function (
|
|||
function loadViewBySwimlane(fieldValues) {
|
||||
// reset the swimlane data to avoid flickering where the old dataset would briefly show up.
|
||||
$scope.viewBySwimlaneData = getDefaultViewBySwimlaneData();
|
||||
$scope.viewBySwimlaneDataLoading = true;
|
||||
|
||||
skipCellClicks = true;
|
||||
// finish() function, called after each data set has been loaded and processed.
|
||||
|
@ -860,20 +836,18 @@ module.controller('MlExplorerController', function (
|
|||
}
|
||||
}
|
||||
|
||||
$scope.viewBySwimlaneDataLoading = false;
|
||||
|
||||
skipCellClicks = false;
|
||||
console.log('Explorer view by swimlane data set:', $scope.viewBySwimlaneData);
|
||||
if (swimlaneCellClickListenerQueue.length > 0) {
|
||||
const cellData = swimlaneCellClickListenerQueue.pop();
|
||||
swimlaneCellClickListenerQueue.length = 0;
|
||||
swimlaneCellClickListener(cellData);
|
||||
if (swimlaneCellClickQueue.length > 0) {
|
||||
const cellData = swimlaneCellClickQueue.pop();
|
||||
swimlaneCellClickQueue.length = 0;
|
||||
$scope.swimlaneCellClick(cellData);
|
||||
return;
|
||||
}
|
||||
// Fire event to indicate swimlane data has changed.
|
||||
// Need to use $timeout to ensure this happens after the child scope is updated with the new data.
|
||||
|
||||
setShowViewBySwimlane();
|
||||
$timeout(() => {
|
||||
mlExplorerDashboardService.swimlaneDataChange.changed(mapScopeToSwimlaneProps(SWIMLANE_TYPE.VIEW_BY));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
if (
|
||||
|
@ -1068,13 +1042,7 @@ module.controller('MlExplorerController', function (
|
|||
await loadAnnotationsTableData();
|
||||
|
||||
$timeout(() => {
|
||||
if ($scope.overallSwimlaneData !== undefined) {
|
||||
mlExplorerDashboardService.swimlaneDataChange.changed(mapScopeToSwimlaneProps(SWIMLANE_TYPE.OVERALL));
|
||||
}
|
||||
if ($scope.viewBySwimlaneData !== undefined) {
|
||||
mlExplorerDashboardService.swimlaneDataChange.changed(mapScopeToSwimlaneProps(SWIMLANE_TYPE.VIEW_BY));
|
||||
}
|
||||
mlExplorerDashboardService.anomalyDataChange.changed($scope.anomalyChartRecords || [], timerange.earliestMs, timerange.latestMs);
|
||||
anomalyDataChange($scope.anomalyChartRecords || [], timerange.earliestMs, timerange.latestMs);
|
||||
|
||||
if (cellData !== undefined && cellData.fieldName === undefined) {
|
||||
// Click is in one of the cells in the Overall swimlane - reload the 'view by' swimlane
|
||||
|
@ -1093,11 +1061,7 @@ module.controller('MlExplorerController', function (
|
|||
}, 0);
|
||||
}
|
||||
|
||||
if (isChartsContainerInitialized) {
|
||||
finish();
|
||||
} else {
|
||||
chartsCallback = finish;
|
||||
}
|
||||
finish();
|
||||
}
|
||||
|
||||
function clearSelectedAnomalies() {
|
||||
|
|
|
@ -20,19 +20,12 @@ function mlExplorerDashboardServiceFactory() {
|
|||
|
||||
const listenerFactory = listenerFactoryProvider();
|
||||
const dragSelect = service.dragSelect = listenerFactory();
|
||||
const swimlaneCellClick = service.swimlaneCellClick = listenerFactory();
|
||||
const swimlaneDataChange = service.swimlaneDataChange = listenerFactory();
|
||||
const swimlaneRenderDone = service.swimlaneRenderDone = listenerFactory();
|
||||
const chartsInitDone = service.chartsInitDone = listenerFactory();
|
||||
service.anomalyDataChange = listenerFactory();
|
||||
|
||||
service.init = function () {
|
||||
// Clear out any old listeners.
|
||||
dragSelect.unwatchAll();
|
||||
swimlaneCellClick.unwatchAll();
|
||||
swimlaneDataChange.unwatchAll();
|
||||
swimlaneRenderDone.unwatchAll();
|
||||
chartsInitDone.unwatchAll();
|
||||
};
|
||||
|
||||
return service;
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* 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 Anomaly Explorer's React component.
|
||||
*/
|
||||
|
||||
import { pick } from 'lodash';
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { Explorer } from './explorer';
|
||||
|
||||
import { IntervalHelperProvider } from 'plugins/ml/util/ml_time_buckets';
|
||||
import { SWIMLANE_TYPE } from './explorer_constants';
|
||||
|
||||
import { uiModules } from 'ui/modules';
|
||||
const module = uiModules.get('apps/ml');
|
||||
|
||||
import { I18nProvider } from '@kbn/i18n/react';
|
||||
|
||||
module.directive('mlExplorerReactWrapper', function (Private) {
|
||||
const TimeBuckets = Private(IntervalHelperProvider);
|
||||
|
||||
function link(scope, element) {
|
||||
function getSwimlaneData(swimlaneType) {
|
||||
switch (swimlaneType) {
|
||||
case SWIMLANE_TYPE.OVERALL:
|
||||
return scope.overallSwimlaneData;
|
||||
case SWIMLANE_TYPE.VIEW_BY:
|
||||
return scope.viewBySwimlaneData;
|
||||
}
|
||||
}
|
||||
|
||||
function mapScopeToSwimlaneProps(swimlaneType) {
|
||||
return {
|
||||
chartWidth: scope.swimlaneWidth,
|
||||
MlTimeBuckets: TimeBuckets,
|
||||
swimlaneCellClick: scope.swimlaneCellClick,
|
||||
swimlaneData: getSwimlaneData(swimlaneType),
|
||||
swimlaneType,
|
||||
selection: scope.appState.mlExplorerSwimlane,
|
||||
};
|
||||
}
|
||||
|
||||
function render() {
|
||||
const props = pick(scope, [
|
||||
'annotationsData',
|
||||
'anomalyChartRecords',
|
||||
'chartsData',
|
||||
'hasResults',
|
||||
'influencers',
|
||||
'jobs',
|
||||
'loading',
|
||||
'noInfluencersConfigured',
|
||||
'setSwimlaneSelectActive',
|
||||
'setSwimlaneViewBy',
|
||||
'showViewBySwimlane',
|
||||
'swimlaneViewByFieldName',
|
||||
'tableData',
|
||||
'viewByLoadedForTimeFormatted',
|
||||
'viewBySwimlaneDataLoading',
|
||||
'viewBySwimlaneOptions',
|
||||
]);
|
||||
|
||||
props.swimlaneOverall = mapScopeToSwimlaneProps(SWIMLANE_TYPE.OVERALL);
|
||||
props.swimlaneViewBy = mapScopeToSwimlaneProps(SWIMLANE_TYPE.VIEW_BY);
|
||||
|
||||
ReactDOM.render(
|
||||
<I18nProvider>{React.createElement(Explorer, props)}</I18nProvider>,
|
||||
element[0]
|
||||
);
|
||||
}
|
||||
|
||||
render();
|
||||
|
||||
scope.$watch(() => {
|
||||
render();
|
||||
});
|
||||
|
||||
element.on('$destroy', () => {
|
||||
ReactDOM.unmountComponentAtNode(element[0]);
|
||||
scope.$destroy();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
scope: false,
|
||||
link,
|
||||
};
|
||||
});
|
|
@ -32,6 +32,7 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
|
|||
static propTypes = {
|
||||
chartWidth: PropTypes.number.isRequired,
|
||||
MlTimeBuckets: PropTypes.func.isRequired,
|
||||
swimlaneCellClick: PropTypes.func.isRequired,
|
||||
swimlaneData: PropTypes.shape({
|
||||
laneLabels: PropTypes.array.isRequired
|
||||
}).isRequired,
|
||||
|
@ -124,6 +125,7 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
|
|||
selectCell(cellsToSelect, { laneLabels, bucketScore, times }) {
|
||||
const {
|
||||
selection,
|
||||
swimlaneCellClick,
|
||||
swimlaneData,
|
||||
swimlaneType
|
||||
} = this.props;
|
||||
|
@ -156,7 +158,7 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
|
|||
}
|
||||
|
||||
if (triggerNewSelection === false) {
|
||||
mlExplorerDashboardService.swimlaneCellClick.changed({});
|
||||
swimlaneCellClick({});
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -166,7 +168,7 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
|
|||
times: d3.extent(times),
|
||||
type: swimlaneType
|
||||
};
|
||||
mlExplorerDashboardService.swimlaneCellClick.changed(cellData);
|
||||
swimlaneCellClick(cellData);
|
||||
}
|
||||
|
||||
highlightSelection(cellsToSelect, laneLabels, times) {
|
||||
|
@ -219,6 +221,7 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
|
|||
const {
|
||||
chartWidth,
|
||||
MlTimeBuckets,
|
||||
swimlaneCellClick,
|
||||
swimlaneData,
|
||||
swimlaneType,
|
||||
selection,
|
||||
|
@ -307,7 +310,7 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
|
|||
.html(label => mlEscape(label))
|
||||
.on('click', () => {
|
||||
if (typeof selection.selectedLanes !== 'undefined') {
|
||||
mlExplorerDashboardService.swimlaneCellClick.changed({});
|
||||
swimlaneCellClick({});
|
||||
}
|
||||
})
|
||||
.each(function () {
|
||||
|
|
|
@ -1,61 +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 Anomaly Explorer's ExplorerSwimlane React component.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { ExplorerSwimlane } from './explorer_swimlane';
|
||||
|
||||
import { uiModules } from 'ui/modules';
|
||||
const module = uiModules.get('apps/ml');
|
||||
|
||||
import { mlExplorerDashboardService } from './explorer_dashboard_service';
|
||||
import { I18nProvider } from '@kbn/i18n/react';
|
||||
|
||||
module.directive('mlExplorerSwimlane', function () {
|
||||
|
||||
function link(scope, element) {
|
||||
function swimlaneDataChangeListener(props) {
|
||||
if (
|
||||
props.swimlaneType !== scope.swimlaneType ||
|
||||
props.swimlaneData === undefined ||
|
||||
props.swimlaneData.earliest === undefined ||
|
||||
props.swimlaneData.latest === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
ReactDOM.render(
|
||||
<I18nProvider>
|
||||
{React.createElement(ExplorerSwimlane, props)}
|
||||
</I18nProvider>,
|
||||
element[0]
|
||||
);
|
||||
}
|
||||
mlExplorerDashboardService.swimlaneDataChange.watch(swimlaneDataChangeListener);
|
||||
|
||||
element.on('$destroy', () => {
|
||||
mlExplorerDashboardService.swimlaneDataChange.unwatch(swimlaneDataChangeListener);
|
||||
// unmountComponentAtNode() needs to be called so dragSelectListener within
|
||||
// the ExplorerSwimlane component gets unwatched properly.
|
||||
ReactDOM.unmountComponentAtNode(element[0]);
|
||||
scope.$destroy();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
scope: {
|
||||
swimlaneType: '@'
|
||||
},
|
||||
link
|
||||
};
|
||||
});
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import 'plugins/ml/explorer/explorer_controller';
|
||||
import 'plugins/ml/explorer/explorer_dashboard_service';
|
||||
import 'plugins/ml/explorer/explorer_swimlane_directive';
|
||||
import 'plugins/ml/explorer/explorer_react_wrapper_directive';
|
||||
import 'plugins/ml/explorer/explorer_charts';
|
||||
import 'plugins/ml/explorer/select_limit';
|
||||
import 'plugins/ml/components/job_select_list';
|
||||
|
|
|
@ -1,44 +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 'expect.js';
|
||||
|
||||
describe('ML - <ml-select-limit>', () => {
|
||||
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('Initialization doesn\'t throw an error', () => {
|
||||
expect(function () {
|
||||
$compile('<ml-select-limit />')($scope);
|
||||
}).to.not.throwError('Not initialized.');
|
||||
|
||||
expect($scope.setLimit).to.be.a('function');
|
||||
expect($scope.limit).to.eql({ display: '10', val: 10 });
|
||||
expect($scope.limitOptions).to.eql([
|
||||
{ display: '5', val: 5 },
|
||||
{ display: '10', val: 10 },
|
||||
{ display: '25', val: 25 },
|
||||
{ display: '50', val: 50 }
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
|
@ -6,4 +6,4 @@
|
|||
|
||||
|
||||
|
||||
import './select_limit.js';
|
||||
import './select_limit_service.js';
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
<ml-controls-select
|
||||
identifier="Limit"
|
||||
label="{{ ::'xpack.ml.explorer.limitLabel' | i18n: {defaultMessage: 'Limit'} }}"
|
||||
narrow-style="true"
|
||||
options="limitOptions"
|
||||
selected="limit"
|
||||
show-icons="false"
|
||||
update-fn="setLimit"
|
||||
/>
|
|
@ -7,65 +7,110 @@
|
|||
|
||||
|
||||
/*
|
||||
* AngularJS directive for rendering a select element with limit levels.
|
||||
*/
|
||||
* React component for rendering a select element with limit options.
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
import { get } from 'lodash';
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import _ from 'lodash';
|
||||
import {
|
||||
EuiSelect,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { stateFactoryProvider } from 'plugins/ml/factories/state_factory';
|
||||
const optionsMap = {
|
||||
'5': 5,
|
||||
'10': 10,
|
||||
'25': 25,
|
||||
'50': 50,
|
||||
};
|
||||
|
||||
import template from './select_limit.html';
|
||||
import 'plugins/ml/components/controls/controls_select';
|
||||
const LIMIT_OPTIONS = [
|
||||
{ val: 5, display: '5' },
|
||||
{ val: 10, display: '10' },
|
||||
{ val: 25, display: '25' },
|
||||
{ val: 50, display: '50' },
|
||||
];
|
||||
|
||||
import { uiModules } from 'ui/modules';
|
||||
const module = uiModules.get('apps/ml');
|
||||
function optionValueToLimit(value) {
|
||||
// Get corresponding limit object with required display and val properties from the specified value.
|
||||
let limit = LIMIT_OPTIONS.find(opt => (opt.val === value));
|
||||
|
||||
module
|
||||
.service('mlSelectLimitService', function (Private) {
|
||||
const stateFactory = Private(stateFactoryProvider);
|
||||
this.state = stateFactory('mlSelectLimit', {
|
||||
limit: { display: '10', val: 10 }
|
||||
});
|
||||
})
|
||||
.directive('mlSelectLimit', function (mlSelectLimitService) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template,
|
||||
link: function (scope, element) {
|
||||
scope.limitOptions = [
|
||||
{ display: '5', val: 5 },
|
||||
{ display: '10', val: 10 },
|
||||
{ display: '25', val: 25 },
|
||||
{ display: '50', val: 50 }
|
||||
];
|
||||
// Default to 10 if supplied value doesn't map to one of the options.
|
||||
if (limit === undefined) {
|
||||
limit = LIMIT_OPTIONS[1];
|
||||
}
|
||||
|
||||
const limitState = mlSelectLimitService.state.get('limit');
|
||||
const limitValue = _.get(limitState, 'val', 0);
|
||||
let limitOption = scope.limitOptions.find(d => d.val === limitValue);
|
||||
if (limitOption === undefined) {
|
||||
// Attempt to set value in URL which doesn't map to one of the options.
|
||||
limitOption = scope.limitOptions.find(d => d.val === 10);
|
||||
}
|
||||
scope.limit = limitOption;
|
||||
mlSelectLimitService.state.set('limit', scope.limit);
|
||||
return limit;
|
||||
}
|
||||
|
||||
scope.setLimit = function (limit) {
|
||||
if (!_.isEqual(scope.limit, limit)) {
|
||||
scope.limit = limit;
|
||||
mlSelectLimitService.state.set('limit', scope.limit).changed();
|
||||
}
|
||||
};
|
||||
// This service will be populated by the corresponding angularjs based one.
|
||||
export const mlSelectLimitService = {
|
||||
initialized: false,
|
||||
state: null
|
||||
};
|
||||
|
||||
function setLimit() {
|
||||
scope.setLimit(mlSelectLimitService.state.get('limit'));
|
||||
}
|
||||
class SelectLimit extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
mlSelectLimitService.state.watch(setLimit);
|
||||
// Restore the limit from the state, or default to 10.
|
||||
if (mlSelectLimitService.initialized) {
|
||||
this.mlSelectLimitService = mlSelectLimitService;
|
||||
}
|
||||
|
||||
element.on('$destroy', () => {
|
||||
mlSelectLimitService.state.unwatch(setLimit);
|
||||
scope.$destroy();
|
||||
});
|
||||
}
|
||||
this.state = {
|
||||
valueDisplay: LIMIT_OPTIONS[1].display,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// set initial state from service if available
|
||||
if (this.mlSelectLimitService !== undefined) {
|
||||
const limitState = this.mlSelectLimitService.state.get('limit');
|
||||
const limitValue = get(limitState, 'val', 10);
|
||||
const limit = optionValueToLimit(limitValue);
|
||||
// set initial selected option equal to limit value
|
||||
const selectedOption = LIMIT_OPTIONS.find(opt => (opt.val === limit.val));
|
||||
this.mlSelectLimitService.state.set('limit', limit);
|
||||
this.setState({ valueDisplay: selectedOption.display, });
|
||||
}
|
||||
}
|
||||
|
||||
onChange = (e) => {
|
||||
const valueDisplay = e.target.value;
|
||||
this.setState({ valueDisplay });
|
||||
const limit = optionValueToLimit(optionsMap[valueDisplay]);
|
||||
|
||||
if (this.mlSelectLimitService !== undefined) {
|
||||
this.mlSelectLimitService.state.set('limit', limit).changed();
|
||||
} else {
|
||||
this.props.onChangeHandler(limit);
|
||||
}
|
||||
}
|
||||
|
||||
getOptions = () =>
|
||||
LIMIT_OPTIONS.map(({ display, val }) => ({
|
||||
value: display,
|
||||
text: val,
|
||||
}));
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EuiSelect
|
||||
options={this.getOptions()}
|
||||
onChange={this.onChange}
|
||||
value={this.state.valueDisplay}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SelectLimit.propTypes = {
|
||||
onChangeHandler: PropTypes.func,
|
||||
};
|
||||
|
||||
SelectLimit.defaultProps = {
|
||||
onChangeHandler: () => {},
|
||||
};
|
||||
|
||||
export { SelectLimit };
|
||||
|
|
|
@ -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 React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { SelectLimit } from './select_limit';
|
||||
|
||||
describe('SelectLimit', () => {
|
||||
|
||||
test('creates correct initial selected value', () => {
|
||||
const wrapper = shallow(<SelectLimit/>);
|
||||
const defaultSelectedValue = wrapper.state().valueDisplay;
|
||||
expect(defaultSelectedValue).toBe('10');
|
||||
});
|
||||
|
||||
test('state for currently selected value is updated correctly on click', () => {
|
||||
const wrapper = shallow(<SelectLimit/>);
|
||||
|
||||
const defaultSelectedValue = wrapper.state().valueDisplay;
|
||||
expect(defaultSelectedValue).toBe('10');
|
||||
|
||||
wrapper.simulate('change', { target: { value: '25' } });
|
||||
const updatedSelectedValue = wrapper.state().valueDisplay;
|
||||
expect(updatedSelectedValue).toBe('25');
|
||||
});
|
||||
|
||||
});
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* 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 service for storing limit values in AppState.
|
||||
*/
|
||||
|
||||
import { stateFactoryProvider } from '../../factories/state_factory';
|
||||
import { mlSelectLimitService } from './select_limit';
|
||||
|
||||
import { uiModules } from 'ui/modules';
|
||||
const module = uiModules.get('apps/ml');
|
||||
|
||||
module.service('mlSelectLimitService', function (Private) {
|
||||
const stateFactory = Private(stateFactoryProvider);
|
||||
this.state = mlSelectLimitService.state = stateFactory('mlSelectLimit', {
|
||||
limit: { display: '10', val: 10 }
|
||||
});
|
||||
mlSelectLimitService.initialized = true;
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue