mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
[ML] Convert Explorer Influencers List to EUI/React (#18773)
* [ML] Convert Explorer Influencers List to EUI/React * [ML] Remove unused abbreviate_whole_number Angular filter * [ML] Convert React Influencers List to stateless function
This commit is contained in:
parent
bf71836c51
commit
5de7909350
8 changed files with 208 additions and 233 deletions
|
@ -5,7 +5,5 @@
|
|||
*/
|
||||
|
||||
|
||||
|
||||
|
||||
import './influencers_list_directive';
|
||||
import './styles/main.less';
|
||||
|
|
|
@ -1,51 +0,0 @@
|
|||
<div class="ml-influencers-list">
|
||||
|
||||
<div ng-if="showNoResultsMessage()" class="text-center visualize-error">
|
||||
<div class="item top"></div>
|
||||
<div class="item">
|
||||
<h4 class="euiTitle euiTitle--small">No influencers found</h4>
|
||||
</div>
|
||||
<div class="item bottom"></div>
|
||||
</div>
|
||||
|
||||
<div ng-repeat="(influencerfieldname, groupData) in influencers">
|
||||
|
||||
<div class="section-label">{{influencerfieldname}}</div>
|
||||
|
||||
<div ng-repeat="influencer in groupData" class="influencer-content">
|
||||
|
||||
<div class="field-label">
|
||||
<div ng-if="influencerfieldname !== 'mlcategory'" class="influencerfieldvalue">{{influencer.influencerFieldValue}}</div>
|
||||
<div ng-if="influencerfieldname === 'mlcategory'" class="influencerfieldvalue">mlcategory {{influencer.influencerFieldValue}}</div>
|
||||
<div class="filter-buttons" style="display: none;">
|
||||
<i ng-click="filter(influencerfieldname, influencer.influencerFieldValue, '+')"
|
||||
tooltip="Filter for value" tooltip-append-to-body="1" class="fa fa-search-plus"></i>
|
||||
<i ng-click="filter(influencerfieldname, influencer.influencerFieldValue, '-')"
|
||||
tooltip="Filter out value" tooltip-append-to-body="1" class="fa fa-search-minus"></i>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="progress" value="{{influencer.barScore}}" max="100">
|
||||
<div class="progress-bar-holder">
|
||||
<div class="progress-bar {{influencer.severity}}" ng-attr-style="width: {{influencer.barScore}}%;"
|
||||
tooltip-placement="{{tooltipPlacement}}" tooltip-html-unsafe="{{influencer.tooltip}}" tooltip-append-to-body="true">
|
||||
</div>
|
||||
</div>
|
||||
<span class="score-label">{{influencer.maxScoreLabel}}</span>
|
||||
|
||||
</div>
|
||||
|
||||
<div ng-if="influencer.totalScore > 0" class="score-label total-score-label" tooltip-placement="{{tooltipPlacement}}" tooltip-html-unsafe="{{influencer.tooltip}}" tooltip-append-to-body="true">
|
||||
{{influencer.totalScore | abbreviateWholeNumber:4}}
|
||||
</div>
|
||||
<div ng-if="influencer.totalScore === 0" class="score-label total-score-label" tooltip-placement="{{tooltipPlacement}}" tooltip-html-unsafe="{{influencer.tooltip}}" tooltip-append-to-body="true">
|
||||
< 1
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
/*
|
||||
* 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 a list of Machine Learning influencers.
|
||||
*/
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
EuiToolTip
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { abbreviateWholeNumber } from 'plugins/ml/formatters/abbreviate_whole_number';
|
||||
import { getSeverity } from 'plugins/ml/util/anomaly_utils';
|
||||
|
||||
|
||||
function getTooltipContent(maxScoreLabel, totalScoreLabel) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<p>Maximum anomaly score: {maxScoreLabel}</p>
|
||||
<p>Total anomaly score: {totalScoreLabel}</p>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function Influencer({ influencerFieldName, valueData }) {
|
||||
const maxScorePrecise = valueData.maxAnomalyScore;
|
||||
const maxScore = parseInt(maxScorePrecise);
|
||||
const maxScoreLabel = (maxScore !== 0) ? maxScore : '< 1';
|
||||
const severity = getSeverity(maxScore);
|
||||
const totalScore = parseInt(valueData.sumAnomalyScore);
|
||||
const totalScoreLabel = (totalScore !== 0) ? totalScore : '< 1';
|
||||
|
||||
// Ensure the bar has some width for 0 scores.
|
||||
const barScore = (maxScore !== 0) ? maxScore : 1;
|
||||
const barStyle = {
|
||||
width: `${barScore}%`
|
||||
};
|
||||
|
||||
const tooltipContent = getTooltipContent(maxScoreLabel, totalScoreLabel);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="field-label">
|
||||
{(influencerFieldName !== 'mlcategory') ? (
|
||||
<div className="field-value">{valueData.influencerFieldValue}</div>
|
||||
) : (
|
||||
<div className="field-value">mlcategory {valueData.influencerFieldValue}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`progress ${severity}`} value="{valueData.maxAnomalyScore}" max="100">
|
||||
<div className="progress-bar-holder">
|
||||
<div className="progress-bar" style={barStyle}/>
|
||||
</div>
|
||||
<div className="score-label">
|
||||
<EuiToolTip
|
||||
position="right"
|
||||
className="ml-influencers-list-tooltip"
|
||||
title={`${influencerFieldName}: ${valueData.influencerFieldValue}`}
|
||||
content={tooltipContent}
|
||||
>
|
||||
<span>{maxScoreLabel}</span>
|
||||
</EuiToolTip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="total-score-label">
|
||||
<EuiToolTip
|
||||
position="right"
|
||||
className="ml-influencers-list-tooltip"
|
||||
title={`${influencerFieldName}: ${valueData.influencerFieldValue}`}
|
||||
content={tooltipContent}
|
||||
>
|
||||
<span>{(totalScore > 0) ? abbreviateWholeNumber(totalScore, 4) : totalScoreLabel}</span>
|
||||
</EuiToolTip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Influencer.propTypes = {
|
||||
influencerFieldName: PropTypes.string.isRequired,
|
||||
valueData: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
function InfluencersByName({ influencerFieldName, fieldValues }) {
|
||||
const influencerValues = fieldValues.map(valueData => (
|
||||
<Influencer
|
||||
key={valueData.influencerFieldValue}
|
||||
influencerFieldName={influencerFieldName}
|
||||
valueData={valueData}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<React.Fragment key={influencerFieldName}>
|
||||
<EuiTitle size="xs">
|
||||
<h4>{influencerFieldName}</h4>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="xs"/>
|
||||
{influencerValues}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
InfluencersByName.propTypes = {
|
||||
influencerFieldName: PropTypes.string.isRequired,
|
||||
fieldValues: PropTypes.array.isRequired
|
||||
};
|
||||
|
||||
export function InfluencersList({ influencers }) {
|
||||
|
||||
if (influencers === undefined || Object.keys(influencers).length === 0) {
|
||||
return (
|
||||
<EuiFlexGroup justifyContent="spaceAround">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiSpacer size="xxl" />
|
||||
<EuiText>
|
||||
<h4>No influencers found</h4>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
|
||||
const influencersByName = Object.keys(influencers).map(influencerFieldName => (
|
||||
<InfluencersByName
|
||||
key={influencerFieldName}
|
||||
influencerFieldName={influencerFieldName}
|
||||
fieldValues={influencers[influencerFieldName]}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<div className="ml-influencers-list">
|
||||
{influencersByName}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
InfluencersList.propTypes = {
|
||||
influencers: PropTypes.object
|
||||
};
|
|
@ -5,113 +5,19 @@
|
|||
*/
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* AngularJS directive for rendering a list of Machine Learning influencers.
|
||||
*/
|
||||
|
||||
import _ from 'lodash';
|
||||
|
||||
import 'plugins/ml/lib/angular_bootstrap_patch';
|
||||
import 'plugins/ml/formatters/abbreviate_whole_number';
|
||||
|
||||
import template from './influencers_list.html';
|
||||
import { getSeverity } from 'plugins/ml/util/anomaly_utils';
|
||||
import { mlEscape } from 'plugins/ml/util/string_utils';
|
||||
|
||||
import { FilterManagerProvider } from 'ui/filter_manager';
|
||||
import 'ngreact';
|
||||
|
||||
import { uiModules } from 'ui/modules';
|
||||
const module = uiModules.get('apps/ml');
|
||||
const module = uiModules.get('apps/ml', ['react']);
|
||||
|
||||
module.directive('mlInfluencersList', function (Private) {
|
||||
import { InfluencersList } from './influencers_list';
|
||||
|
||||
const filterManager = Private(FilterManagerProvider);
|
||||
module.directive('mlInfluencersList', function ($injector) {
|
||||
const reactDirective = $injector.get('reactDirective');
|
||||
|
||||
function link(scope, element) {
|
||||
|
||||
scope.$on('render', function () {
|
||||
render();
|
||||
});
|
||||
|
||||
element.on('$destroy', function () {
|
||||
scope.$destroy();
|
||||
});
|
||||
|
||||
scope.tooltipPlacement = scope.tooltipPlacement === undefined ? 'top' : scope.tooltipPlacement;
|
||||
|
||||
function render() {
|
||||
if (scope.influencersData === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dataByViewBy = {};
|
||||
|
||||
// TODO - position tooltip so it doesn't go off edge of window.
|
||||
const compiledTooltip = _.template(
|
||||
'<div class="ml-influencers-list-tooltip"><%= influencerFieldName %>: <%= influencerFieldValue %>' +
|
||||
'<hr/>Max anomaly score: <%= maxScoreLabel %>' +
|
||||
'<hr/>Total anomaly score: <%= totalScoreLabel %></div>');
|
||||
|
||||
_.each(scope.influencersData, (fieldValues, influencerFieldName) => {
|
||||
const valuesForViewBy = [];
|
||||
|
||||
_.each(fieldValues, function (valueData) {
|
||||
const influencerFieldValue = valueData.influencerFieldValue;
|
||||
const maxScorePrecise = valueData.maxAnomalyScore;
|
||||
const maxScore = parseInt(maxScorePrecise);
|
||||
const totalScore = parseInt(valueData.sumAnomalyScore);
|
||||
const barScore = maxScore !== 0 ? maxScore : 1;
|
||||
const maxScoreLabel = maxScore !== 0 ? maxScore : '< 1';
|
||||
const totalScoreLabel = totalScore !== 0 ? totalScore : '< 1';
|
||||
const severity = getSeverity(maxScore);
|
||||
|
||||
// Store the data for each influencerfieldname in an array to ensure
|
||||
// reliable sorting by max score.
|
||||
// If it was sorted as an object, the order when rendered using the AngularJS
|
||||
// ngRepeat directive could not be relied upon to be the same as they were
|
||||
// returned in the ES aggregation e.g. for numeric keys from a mlcategory influencer.
|
||||
valuesForViewBy.push({
|
||||
influencerFieldValue,
|
||||
maxScorePrecise,
|
||||
barScore,
|
||||
maxScoreLabel,
|
||||
totalScore,
|
||||
severity,
|
||||
tooltip: compiledTooltip({
|
||||
influencerFieldName: mlEscape(influencerFieldName),
|
||||
influencerFieldValue: mlEscape(influencerFieldValue),
|
||||
maxScoreLabel,
|
||||
totalScoreLabel
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
dataByViewBy[influencerFieldName] = _.sortBy(valuesForViewBy, 'maxScorePrecise').reverse();
|
||||
});
|
||||
|
||||
scope.influencers = dataByViewBy;
|
||||
}
|
||||
|
||||
// Provide a filter function so filters can be added.
|
||||
scope.filter = function (field, value, operator) {
|
||||
filterManager.add(field, value, operator, scope.indexPatternId);
|
||||
};
|
||||
|
||||
scope.showNoResultsMessage = function () {
|
||||
return (scope.influencersData === undefined) || (_.keys(scope.influencersData).length === 0);
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
return {
|
||||
scope: {
|
||||
influencersData: '=',
|
||||
indexPatternId: '=',
|
||||
tooltipPlacement: '@'
|
||||
},
|
||||
template,
|
||||
link: link
|
||||
};
|
||||
return reactDirective(
|
||||
InfluencersList,
|
||||
undefined,
|
||||
{ restrict: 'E' }
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1,59 +1,26 @@
|
|||
.ml-influencers-list {
|
||||
width: 100%;
|
||||
padding: 0px 0px;
|
||||
line-height: 1.45;
|
||||
|
||||
.visualize-error {
|
||||
display: inline;
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
margin-top: 65px;
|
||||
}
|
||||
}
|
||||
|
||||
.section-label {
|
||||
background-color: #9c9fa6;
|
||||
color: #ffffff;
|
||||
font-size:13px;
|
||||
/* eui h3 equivalent font-weight */
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
.influencer-content {
|
||||
padding-right: 4px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
padding-left: 2px;
|
||||
text-align: left;
|
||||
|
||||
.influencerfieldvalue {
|
||||
max-width: calc(~"100% - 40px");
|
||||
.field-value {
|
||||
max-width: calc(~"100% - 34px");
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
padding-left: 5px;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
}
|
||||
|
||||
.progress {
|
||||
display:inline-block;
|
||||
width: calc(~"100% - 40px");
|
||||
height: 20px;
|
||||
width: calc(~"100% - 34px");
|
||||
height: 22px;
|
||||
min-width: 70px;
|
||||
margin-bottom: 2px;
|
||||
margin-bottom: 0px;
|
||||
color: #555;
|
||||
background-color : transparent;
|
||||
|
||||
|
@ -62,63 +29,76 @@
|
|||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
margin: 6px 0px;
|
||||
height: 2px;
|
||||
margin-top: 8px;
|
||||
text-align: right;
|
||||
line-height: 18px;
|
||||
border-radius: 5px;
|
||||
display: inline-block;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar.critical {
|
||||
.progress.critical {
|
||||
.progress-bar {
|
||||
background-color: #fe5050;
|
||||
}
|
||||
.score-label {
|
||||
border-color: #fe5050;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar.major {
|
||||
.progress.major {
|
||||
.progress-bar {
|
||||
background-color: #fba740;
|
||||
}
|
||||
.score-label {
|
||||
border-color: #fba740;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar.minor {
|
||||
.progress.minor {
|
||||
.progress-bar {
|
||||
background-color: #ffdd00;
|
||||
}
|
||||
.score-label {
|
||||
border-color: #ffdd00;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar.warning {
|
||||
.progress.warning {
|
||||
.progress-bar {
|
||||
background-color: #8bc8fb;
|
||||
}
|
||||
|
||||
.score-label {
|
||||
border-color: #8bc8fb;
|
||||
}
|
||||
}
|
||||
|
||||
.score-label {
|
||||
margin-left: 3px;
|
||||
text-align: center;
|
||||
color: #444444;
|
||||
padding: 2px 2px 2px 2px;
|
||||
border-radius: 4px;
|
||||
line-height: 14px;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
display: inline-block;
|
||||
display: inline;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.score-label.total-score-label {
|
||||
.total-score-label {
|
||||
width: 32px;
|
||||
vertical-align: top;
|
||||
background-color: #444444;
|
||||
color: white;
|
||||
text-align: center;
|
||||
color: #555;
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
margin-top: 1px;
|
||||
display: inline-block;
|
||||
border: 1px solid #bbbbbb;
|
||||
}
|
||||
}
|
||||
|
||||
.ml-influencers-list-tooltip {
|
||||
color: #ffffff;
|
||||
font-family: Roboto, Droid, Helvetica Neue, Helvetica, Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
|
||||
hr {
|
||||
margin-top: 3px;
|
||||
margin-bottom: 3px;
|
||||
border-color: #95a5a6;
|
||||
}
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
|
|
|
@ -37,10 +37,8 @@
|
|||
Top Influencers
|
||||
</span>
|
||||
<ml-influencers-list
|
||||
influencers-data="influencersData"
|
||||
index-pattern-id="indexPatternId"
|
||||
tooltip-placement="right">
|
||||
</ml-influencers-list>
|
||||
influencers="influencers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="column col-xs-10">
|
||||
|
|
|
@ -588,8 +588,8 @@ module.controller('MlExplorerController', function (
|
|||
MAX_INFLUENCER_FIELD_VALUES
|
||||
).then((resp) => {
|
||||
// TODO - sort the influencers keys so that the partition field(s) are first.
|
||||
$scope.influencersData = resp.influencers;
|
||||
console.log('Explorer top influencers data set:', $scope.influencersData);
|
||||
$scope.influencers = resp.influencers;
|
||||
console.log('Explorer top influencers data set:', $scope.influencers);
|
||||
finish(counter);
|
||||
});
|
||||
|
||||
|
|
|
@ -12,9 +12,6 @@
|
|||
*/
|
||||
import numeral from '@elastic/numeral';
|
||||
|
||||
import { uiModules } from 'ui/modules';
|
||||
const module = uiModules.get('apps/ml');
|
||||
|
||||
export function abbreviateWholeNumber(value, maxDigits) {
|
||||
const maxNumDigits = (maxDigits !== undefined ? maxDigits : 3);
|
||||
if (Math.abs(value) < Math.pow(10, maxNumDigits)) {
|
||||
|
@ -23,6 +20,3 @@ export function abbreviateWholeNumber(value, maxDigits) {
|
|||
return numeral(value).format('0a');
|
||||
}
|
||||
}
|
||||
|
||||
// TODO - remove the filter once all uses of the abbreviateWholeNumber Angular filter have been removed.
|
||||
module.filter('abbreviateWholeNumber', () => abbreviateWholeNumber);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue