mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[ML] Convert anomalies table to EUI / React (#19352)
* [ML] Convert anomalies table to EUI / React * [ML] Edits to EUI / React anomalies table after review
This commit is contained in:
parent
47e57a8e94
commit
1365799a06
26 changed files with 1675 additions and 1585 deletions
|
@ -1,12 +0,0 @@
|
|||
<div class="anomalies-table">
|
||||
<div class="no-results-item" ng-if="table.rows.length === 0">
|
||||
<h4 class="euiTitle euiTitle--small">No matching results found</h4>
|
||||
</div>
|
||||
|
||||
<ml-paginated-table ng-hide="table.rows.length === 0"
|
||||
columns="table.columns"
|
||||
rows="table.rows"
|
||||
per-page="table.perPage">
|
||||
</ml-paginated-table>
|
||||
|
||||
</div>
|
|
@ -0,0 +1,394 @@
|
|||
/*
|
||||
* 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 table for displaying a list of anomalies.
|
||||
*/
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import _ from 'lodash';
|
||||
import $ from 'jquery';
|
||||
|
||||
import React, {
|
||||
Component
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
EuiButtonIcon,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiHealth,
|
||||
EuiInMemoryTable,
|
||||
EuiText
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { formatDate } from '@elastic/eui/lib/services/format';
|
||||
|
||||
import { DescriptionCell } from './description_cell';
|
||||
import { EntityCell } from './entity_cell';
|
||||
import { InfluencersCell } from './influencers_cell';
|
||||
import { AnomalyDetails } from './anomaly_details';
|
||||
import { LinksMenu } from './links_menu';
|
||||
|
||||
import { mlAnomaliesTableService } from './anomalies_table_service';
|
||||
import { mlFieldFormatService } from 'plugins/ml/services/field_format_service';
|
||||
import { getSeverityColor } from 'plugins/ml/../common/util/anomaly_utils';
|
||||
import { formatValue } from 'plugins/ml/formatters/format_value';
|
||||
|
||||
|
||||
const INFLUENCERS_LIMIT = 5; // Maximum number of influencers to display before a 'show more' link is added.
|
||||
|
||||
|
||||
function renderTime(date, aggregationInterval) {
|
||||
if (aggregationInterval === 'hour') {
|
||||
return formatDate(date, 'MMMM Do YYYY, HH:mm');
|
||||
} else if (aggregationInterval === 'second') {
|
||||
return formatDate(date, 'MMMM Do YYYY, HH:mm:ss');
|
||||
} else {
|
||||
return formatDate(date, 'MMMM Do YYYY');
|
||||
}
|
||||
}
|
||||
|
||||
function showLinksMenuForItem(item) {
|
||||
return item.isTimeSeriesViewDetector ||
|
||||
item.entityName === 'mlcategory' ||
|
||||
item.customUrls !== undefined;
|
||||
}
|
||||
|
||||
function getColumns(
|
||||
items,
|
||||
examplesByJobId,
|
||||
isAggregatedData,
|
||||
interval,
|
||||
timefilter,
|
||||
showViewSeriesLink,
|
||||
itemIdToExpandedRowMap,
|
||||
toggleRow,
|
||||
filter) {
|
||||
const columns = [
|
||||
{
|
||||
name: '',
|
||||
render: (item) => (
|
||||
<EuiButtonIcon
|
||||
onClick={() => toggleRow(item)}
|
||||
iconType={itemIdToExpandedRowMap[item.rowId] ? 'arrowDown' : 'arrowRight'}
|
||||
aria-label={itemIdToExpandedRowMap[item.rowId] ? 'Hide details' : 'Show details'}
|
||||
data-row-id={item.rowId}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
field: 'time',
|
||||
name: 'time',
|
||||
dataType: 'date',
|
||||
render: (date) => renderTime(date, interval),
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
field: 'severity',
|
||||
name: `${(isAggregatedData === true) ? 'max ' : ''}severity`,
|
||||
render: (score) => (
|
||||
<EuiHealth color={getSeverityColor(score)} compressed="true">
|
||||
{score >= 1 ? Math.floor(score) : '< 1'}
|
||||
</EuiHealth>
|
||||
),
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
field: 'detector',
|
||||
name: 'detector',
|
||||
sortable: true
|
||||
}
|
||||
];
|
||||
|
||||
if (items.some(item => item.entityValue !== undefined)) {
|
||||
columns.push({
|
||||
field: 'entityValue',
|
||||
name: 'found for',
|
||||
render: (entityValue, item) => (
|
||||
<EntityCell
|
||||
entityName={item.entityName}
|
||||
entityValue={entityValue}
|
||||
filter={filter}
|
||||
/>
|
||||
),
|
||||
sortable: true
|
||||
});
|
||||
}
|
||||
|
||||
if (items.some(item => item.influencers !== undefined)) {
|
||||
columns.push({
|
||||
field: 'influencers',
|
||||
name: 'influenced by',
|
||||
render: (influencers) => (
|
||||
<InfluencersCell
|
||||
limit={INFLUENCERS_LIMIT}
|
||||
influencers={influencers}
|
||||
/>
|
||||
),
|
||||
sortable: true
|
||||
});
|
||||
}
|
||||
|
||||
// Map the additional 'sort' fields to the actual, typical and description
|
||||
// fields to ensure sorting is done correctly on the underlying metric value
|
||||
// and not on e.g. the actual values array as a String.
|
||||
if (items.some(item => item.actual !== undefined)) {
|
||||
columns.push({
|
||||
field: 'actualSort',
|
||||
name: 'actual',
|
||||
render: (actual, item) => {
|
||||
const fieldFormat = mlFieldFormatService.getFieldFormat(item.jobId, item.source.detector_index);
|
||||
return formatValue(item.actual, item.source.function, fieldFormat);
|
||||
},
|
||||
sortable: true
|
||||
});
|
||||
}
|
||||
|
||||
if (items.some(item => item.typical !== undefined)) {
|
||||
columns.push({
|
||||
field: 'typicalSort',
|
||||
name: 'typical',
|
||||
render: (typical, item) => {
|
||||
const fieldFormat = mlFieldFormatService.getFieldFormat(item.jobId, item.source.detector_index);
|
||||
return formatValue(item.typical, item.source.function, fieldFormat);
|
||||
},
|
||||
sortable: true
|
||||
});
|
||||
|
||||
// Assume that if we are showing typical, there will be an actual too,
|
||||
// so we can add a column to describe how actual compares to typical.
|
||||
const nonTimeOfDayOrWeek = items.some((item) => {
|
||||
const summaryRecFunc = item.source.function;
|
||||
return summaryRecFunc !== 'time_of_day' && summaryRecFunc !== 'time_of_week';
|
||||
});
|
||||
if (nonTimeOfDayOrWeek === true) {
|
||||
columns.push({
|
||||
field: 'metricDescriptionSort',
|
||||
name: 'description',
|
||||
render: (metricDescriptionSort, item) => (
|
||||
<DescriptionCell
|
||||
actual={item.actual}
|
||||
typical={item.typical}
|
||||
/>
|
||||
),
|
||||
sortable: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
columns.push({
|
||||
field: 'jobId',
|
||||
name: 'job ID',
|
||||
sortable: true
|
||||
});
|
||||
|
||||
const showExamples = items.some(item => item.entityName === 'mlcategory');
|
||||
const showLinks = (showViewSeriesLink === true) || items.some(item => showLinksMenuForItem(item));
|
||||
|
||||
if (showLinks === true) {
|
||||
columns.push({
|
||||
name: 'links',
|
||||
render: (item) => {
|
||||
if (showLinksMenuForItem(item) === true) {
|
||||
return (
|
||||
<LinksMenu
|
||||
anomaly={item}
|
||||
showViewSeriesLink={showViewSeriesLink}
|
||||
isAggregatedData={isAggregatedData}
|
||||
interval={interval}
|
||||
timefilter={timefilter}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
sortable: false
|
||||
});
|
||||
}
|
||||
|
||||
if (showExamples === true) {
|
||||
columns.push({
|
||||
name: 'category examples',
|
||||
sortable: false,
|
||||
truncateText: true,
|
||||
render: (item) => {
|
||||
const examples = _.get(examplesByJobId, [item.jobId, item.entityValue], []);
|
||||
return (
|
||||
<EuiText size="xs">
|
||||
{examples.map((example, i) => {
|
||||
return <span key={`example${i}`} className="category-example">{example}</span>;
|
||||
}
|
||||
)}
|
||||
</EuiText>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
class AnomaliesTable extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
itemIdToExpandedRowMap: {}
|
||||
};
|
||||
}
|
||||
|
||||
isShowingAggregatedData = () => {
|
||||
return (this.props.tableData.interval !== 'second');
|
||||
};
|
||||
|
||||
static getDerivedStateFromProps(nextProps, prevState) {
|
||||
// Update the itemIdToExpandedRowMap state if a change to the table data has resulted
|
||||
// in an anomaly that was previously expanded no longer being in the data.
|
||||
const itemIdToExpandedRowMap = prevState.itemIdToExpandedRowMap;
|
||||
const prevExpandedNotInData = Object.keys(itemIdToExpandedRowMap).find((rowId) => {
|
||||
const matching = nextProps.tableData.anomalies.find((anomaly) => {
|
||||
return anomaly.rowId === rowId;
|
||||
});
|
||||
|
||||
return (matching === undefined);
|
||||
});
|
||||
|
||||
if (prevExpandedNotInData !== undefined) {
|
||||
// Anomaly data has changed and an anomaly that was previously expanded is no longer in the data.
|
||||
return {
|
||||
itemIdToExpandedRowMap: {}
|
||||
};
|
||||
}
|
||||
|
||||
// Return null to indicate no change to state.
|
||||
return null;
|
||||
}
|
||||
|
||||
toggleRow = (item) => {
|
||||
const itemIdToExpandedRowMap = { ...this.state.itemIdToExpandedRowMap };
|
||||
if (itemIdToExpandedRowMap[item.rowId]) {
|
||||
delete itemIdToExpandedRowMap[item.rowId];
|
||||
} else {
|
||||
const examples = (item.entityName === 'mlcategory') ?
|
||||
_.get(this.props.tableData, ['examplesByJobId', item.jobId, item.entityValue]) : undefined;
|
||||
itemIdToExpandedRowMap[item.rowId] = (
|
||||
<AnomalyDetails
|
||||
anomaly={item}
|
||||
examples={examples}
|
||||
isAggregatedData={this.isShowingAggregatedData()}
|
||||
filter={this.props.filter}
|
||||
influencersLimit={INFLUENCERS_LIMIT}
|
||||
/>
|
||||
);
|
||||
}
|
||||
this.setState({ itemIdToExpandedRowMap });
|
||||
};
|
||||
|
||||
onMouseOver = (event) => {
|
||||
// Triggered when the mouse is somewhere over the table.
|
||||
// Traverse through the table DOM to find the expand/collapse
|
||||
// button which stores the ID of the row.
|
||||
let mouseOverRecord = undefined;
|
||||
const target = $(event.target);
|
||||
const parentRow = target.closest('tr');
|
||||
const firstCell = parentRow.children('td').first();
|
||||
if (firstCell !== undefined) {
|
||||
const expandButton = firstCell.find('button').first();
|
||||
if (expandButton.length > 0) {
|
||||
const rowId = expandButton.attr('data-row-id');
|
||||
mouseOverRecord = this.props.tableData.anomalies.find((anomaly) => {
|
||||
return (anomaly.rowId === rowId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (this.mouseOverRecord !== undefined) {
|
||||
if (mouseOverRecord === undefined || this.mouseOverRecord.rowId !== mouseOverRecord.rowId) {
|
||||
// Mouse is over a different row, fire mouseleave on the previous record.
|
||||
mlAnomaliesTableService.anomalyRecordMouseleave.changed(this.mouseOverRecord);
|
||||
|
||||
if (mouseOverRecord !== undefined) {
|
||||
// Mouse is over a new row, fire mouseenter on the new record.
|
||||
mlAnomaliesTableService.anomalyRecordMouseenter.changed(mouseOverRecord);
|
||||
}
|
||||
}
|
||||
} else if (mouseOverRecord !== undefined) {
|
||||
// Mouse is now over a row, fire mouseenter on the record.
|
||||
mlAnomaliesTableService.anomalyRecordMouseenter.changed(mouseOverRecord);
|
||||
}
|
||||
|
||||
this.mouseOverRecord = mouseOverRecord;
|
||||
};
|
||||
|
||||
onMouseLeave = () => {
|
||||
if (this.mouseOverRecord !== undefined) {
|
||||
mlAnomaliesTableService.anomalyRecordMouseleave.changed(this.mouseOverRecord);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { timefilter, tableData, filter } = this.props;
|
||||
|
||||
if (tableData === undefined ||
|
||||
tableData.anomalies === undefined || tableData.anomalies.length === 0) {
|
||||
return (
|
||||
<EuiFlexGroup justifyContent="spaceAround">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText>
|
||||
<h4>No matching anomalies found</h4>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
|
||||
const columns = getColumns(
|
||||
tableData.anomalies,
|
||||
tableData.examplesByJobId,
|
||||
this.isShowingAggregatedData(),
|
||||
tableData.interval,
|
||||
timefilter,
|
||||
tableData.showViewSeriesLink,
|
||||
this.state.itemIdToExpandedRowMap,
|
||||
this.toggleRow,
|
||||
filter);
|
||||
|
||||
const sorting = {
|
||||
sort: {
|
||||
field: 'severity',
|
||||
direction: 'desc',
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<EuiInMemoryTable
|
||||
className="ml-anomalies-table"
|
||||
items={tableData.anomalies}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
pageSizeOptions: [10, 25, 100],
|
||||
initialPageSize: 25
|
||||
}}
|
||||
sorting={sorting}
|
||||
itemId="rowId"
|
||||
itemIdToExpandedRowMap={this.state.itemIdToExpandedRowMap}
|
||||
compressed={true}
|
||||
onMouseOver={this.onMouseOver}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
AnomaliesTable.propTypes = {
|
||||
timefilter: PropTypes.object.isRequired,
|
||||
tableData: PropTypes.object,
|
||||
filter: PropTypes.func
|
||||
};
|
||||
|
||||
export { AnomaliesTable };
|
|
@ -5,954 +5,26 @@
|
|||
*/
|
||||
|
||||
|
||||
import 'ngreact';
|
||||
|
||||
/*
|
||||
* AngularJS directive for rendering a table of Machine Learning anomalies.
|
||||
*/
|
||||
|
||||
import moment from 'moment';
|
||||
import _ from 'lodash';
|
||||
import rison from 'rison-node';
|
||||
|
||||
import { notify } from 'ui/notify';
|
||||
import { ES_FIELD_TYPES } from 'plugins/ml/../common/constants/field_types';
|
||||
import { parseInterval } from 'plugins/ml/../common/util/parse_interval';
|
||||
import { formatValue } from 'plugins/ml/formatters/format_value';
|
||||
import { getUrlForRecord } from 'plugins/ml/util/custom_url_utils';
|
||||
import { replaceStringTokens, mlEscape } from 'plugins/ml/util/string_utils';
|
||||
import { isTimeSeriesViewDetector } from 'plugins/ml/../common/util/job_utils';
|
||||
import { getIndexPatternIdFromName } from 'plugins/ml/util/index_utils';
|
||||
import {
|
||||
getEntityFieldName,
|
||||
getEntityFieldValue,
|
||||
showActualForFunction,
|
||||
showTypicalForFunction,
|
||||
getSeverity
|
||||
} from 'plugins/ml/../common/util/anomaly_utils';
|
||||
import { getFieldTypeFromMapping } from 'plugins/ml/services/mapping_service';
|
||||
import { ml } from 'plugins/ml/services/ml_api_service';
|
||||
import { mlJobService } from 'plugins/ml/services/job_service';
|
||||
import { mlFieldFormatService } from 'plugins/ml/services/field_format_service';
|
||||
import template from './anomalies_table.html';
|
||||
|
||||
import 'plugins/ml/components/controls';
|
||||
import 'plugins/ml/components/paginated_table';
|
||||
import 'plugins/ml/formatters/metric_change_description';
|
||||
import './expanded_row/expanded_row_directive';
|
||||
import './influencers_cell/influencers_cell_directive';
|
||||
|
||||
import linkControlsHtml from './anomalies_table_links.html';
|
||||
import chrome from 'ui/chrome';
|
||||
import openRowArrow from 'plugins/ml/components/paginated_table/open.html';
|
||||
import { uiModules } from 'ui/modules';
|
||||
const module = uiModules.get('apps/ml');
|
||||
|
||||
module.directive('mlAnomaliesTable', function (
|
||||
$window,
|
||||
$route,
|
||||
timefilter,
|
||||
Private,
|
||||
mlAnomaliesTableService,
|
||||
mlSelectIntervalService,
|
||||
mlSelectSeverityService) {
|
||||
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
anomalyRecords: '=',
|
||||
timeFieldName: '=',
|
||||
showViewSeriesLink: '=',
|
||||
filteringEnabled: '='
|
||||
},
|
||||
template,
|
||||
link: function (scope, element) {
|
||||
// Previously, we instantiated a new AppState here for the
|
||||
// severity threshold and interval setting, thus resetting it on every
|
||||
// reload. Now that this is handled differently via services and them
|
||||
// being singletons, we need to explicitly reset the setting's state,
|
||||
// otherwise the state would be retained across multiple instances of
|
||||
// these settings. Should we want to change this behavior, e.g. to
|
||||
// store the setting of the severity threshold across pages, we can
|
||||
// just remove these resets.
|
||||
mlSelectIntervalService.state.reset().changed();
|
||||
mlSelectSeverityService.state.reset().changed();
|
||||
|
||||
scope.momentInterval = 'second';
|
||||
|
||||
scope.table = {};
|
||||
scope.table.perPage = 25;
|
||||
scope.table.columns = [];
|
||||
scope.table.rows = [];
|
||||
scope.rowScopes = [];
|
||||
|
||||
scope.influencersLimit = 5;
|
||||
|
||||
scope.categoryExamplesByJob = {};
|
||||
const MAX_NUMBER_CATEGORY_EXAMPLES = 10; // Max number of examples to show in table cell or expanded row (engine default is to store 4).
|
||||
|
||||
mlSelectIntervalService.state.watch(updateTableData);
|
||||
mlSelectSeverityService.state.watch(updateTableData);
|
||||
|
||||
scope.$watchCollection('anomalyRecords', updateTableData);
|
||||
|
||||
element.on('$destroy', () => {
|
||||
mlSelectIntervalService.state.unwatch(updateTableData);
|
||||
mlSelectSeverityService.state.unwatch(updateTableData);
|
||||
scope.$destroy();
|
||||
});
|
||||
|
||||
scope.isShowingAggregatedData = function () {
|
||||
const interval = mlSelectIntervalService.state.get('interval');
|
||||
return (interval.display !== 'Show all');
|
||||
};
|
||||
|
||||
scope.getExamplesForCategory = function (jobId, categoryId) {
|
||||
return _.get(scope.categoryExamplesByJob, [jobId, categoryId], []);
|
||||
};
|
||||
|
||||
scope.viewSeries = function (record) {
|
||||
const bounds = timefilter.getActiveBounds();
|
||||
const from = bounds.min.toISOString(); // e.g. 2016-02-08T16:00:00.000Z
|
||||
const to = bounds.max.toISOString();
|
||||
|
||||
// Zoom to show 50 buckets either side of the record.
|
||||
const recordTime = moment(record[scope.timeFieldName]);
|
||||
const zoomFrom = recordTime.subtract(50 * record.bucket_span, 's').toISOString();
|
||||
const zoomTo = recordTime.add(100 * record.bucket_span, 's').toISOString();
|
||||
|
||||
// Extract the by, over and partition fields for the record.
|
||||
const entityCondition = {};
|
||||
|
||||
if (_.has(record, 'partition_field_value')) {
|
||||
entityCondition[record.partition_field_name] = record.partition_field_value;
|
||||
}
|
||||
|
||||
if (_.has(record, 'over_field_value')) {
|
||||
entityCondition[record.over_field_name] = record.over_field_value;
|
||||
}
|
||||
|
||||
if (_.has(record, 'by_field_value')) {
|
||||
// Note that analyses with by and over fields, will have a top-level by_field_name,
|
||||
// but the by_field_value(s) will be in the nested causes array.
|
||||
// TODO - drilldown from cause in expanded row only?
|
||||
entityCondition[record.by_field_name] = record.by_field_value;
|
||||
}
|
||||
|
||||
// Use rison to build the URL .
|
||||
const _g = rison.encode({
|
||||
ml: {
|
||||
jobIds: [record.job_id]
|
||||
},
|
||||
refreshInterval: {
|
||||
display: 'Off',
|
||||
pause: false,
|
||||
value: 0
|
||||
},
|
||||
time: {
|
||||
from: from,
|
||||
to: to,
|
||||
mode: 'absolute'
|
||||
}
|
||||
});
|
||||
|
||||
const _a = rison.encode({
|
||||
mlTimeSeriesExplorer: {
|
||||
zoom: {
|
||||
from: zoomFrom,
|
||||
to: zoomTo
|
||||
},
|
||||
detectorIndex: record.detector_index,
|
||||
entities: entityCondition,
|
||||
},
|
||||
filters: [],
|
||||
query: {
|
||||
query_string: {
|
||||
analyze_wildcard: true,
|
||||
query: '*'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Need to encode the _a parameter in case any entities contain unsafe characters such as '+'.
|
||||
let path = chrome.getBasePath();
|
||||
path += '/app/ml#/timeseriesexplorer';
|
||||
path += '?_g=' + _g;
|
||||
path += '&_a=' + encodeURIComponent(_a);
|
||||
$window.open(path, '_blank');
|
||||
};
|
||||
|
||||
scope.viewExamples = function (record) {
|
||||
const categoryId = getEntityFieldValue(record);
|
||||
const job = mlJobService.getJob(record.job_id);
|
||||
const categorizationFieldName = job.analysis_config.categorization_field_name;
|
||||
const datafeedIndices = job.datafeed_config.indices;
|
||||
// Find the type of the categorization field i.e. text (preferred) or keyword.
|
||||
// Uses the first matching field found in the list of indices in the datafeed_config.
|
||||
// attempt to load the field type using each index. we have to do it this way as _field_caps
|
||||
// doesn't specify which index a field came from unless there is a clash.
|
||||
let i = 0;
|
||||
findFieldType(datafeedIndices[i]);
|
||||
|
||||
function findFieldType(index) {
|
||||
getFieldTypeFromMapping(index, categorizationFieldName)
|
||||
.then((resp) => {
|
||||
if (resp !== '') {
|
||||
createAndOpenUrl(index, resp);
|
||||
} else {
|
||||
i++;
|
||||
if (i < datafeedIndices.length) {
|
||||
findFieldType(datafeedIndices[i]);
|
||||
} else {
|
||||
error();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
error();
|
||||
});
|
||||
}
|
||||
|
||||
function createAndOpenUrl(index, categorizationFieldType) {
|
||||
// Find the ID of the index pattern with a title attribute which matches the
|
||||
// index configured in the datafeed. If a Kibana index pattern has not been created
|
||||
// for this index, then the user will see a warning message on the Discover tab advising
|
||||
// them that no matching index pattern has been configured.
|
||||
const indexPatternId = getIndexPatternIdFromName(index);
|
||||
|
||||
// Get the definition of the category and use the terms or regex to view the
|
||||
// matching events in the Kibana Discover tab depending on whether the
|
||||
// categorization field is of mapping type text (preferred) or keyword.
|
||||
ml.results.getCategoryDefinition(record.job_id, categoryId)
|
||||
.then((resp) => {
|
||||
let query = null;
|
||||
// Build query using categorization regex (if keyword type) or terms (if text type).
|
||||
// Check for terms or regex in case categoryId represents an anomaly from the absence of the
|
||||
// categorization field in documents (usually indicated by a categoryId of -1).
|
||||
if (categorizationFieldType === ES_FIELD_TYPES.KEYWORD) {
|
||||
if (resp.regex) {
|
||||
query = `${categorizationFieldName}:/${resp.regex}/`;
|
||||
}
|
||||
} else {
|
||||
if (resp.terms) {
|
||||
query = `${categorizationFieldName}:` + resp.terms.split(' ').join(` AND ${categorizationFieldName}:`);
|
||||
}
|
||||
}
|
||||
|
||||
const recordTime = moment(record[scope.timeFieldName]);
|
||||
const from = recordTime.toISOString();
|
||||
const to = recordTime.add(record.bucket_span, 's').toISOString();
|
||||
|
||||
// Use rison to build the URL .
|
||||
const _g = rison.encode({
|
||||
refreshInterval: {
|
||||
display: 'Off',
|
||||
pause: false,
|
||||
value: 0
|
||||
},
|
||||
time: {
|
||||
from: from,
|
||||
to: to,
|
||||
mode: 'absolute'
|
||||
}
|
||||
});
|
||||
|
||||
const appStateProps = {
|
||||
index: indexPatternId,
|
||||
filters: []
|
||||
};
|
||||
if (query !== null) {
|
||||
appStateProps.query = {
|
||||
query_string: {
|
||||
analyze_wildcard: true,
|
||||
query: query
|
||||
}
|
||||
};
|
||||
}
|
||||
const _a = rison.encode(appStateProps);
|
||||
|
||||
// Need to encode the _a parameter as it will contain characters such as '+' if using the regex.
|
||||
let path = chrome.getBasePath();
|
||||
path += '/app/kibana#/discover';
|
||||
path += '?_g=' + _g;
|
||||
path += '&_a=' + encodeURIComponent(_a);
|
||||
$window.open(path, '_blank');
|
||||
|
||||
}).catch((resp) => {
|
||||
console.log('viewExamples(): error loading categoryDefinition:', resp);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function error() {
|
||||
console.log(`viewExamples(): error finding type of field ${categorizationFieldName} in indices:`,
|
||||
datafeedIndices);
|
||||
notify.error(`Unable to view examples of documents with mlcategory ${categoryId} ` +
|
||||
`as no mapping could be found for the categorization field ${categorizationFieldName}`,
|
||||
{ lifetime: 30000 });
|
||||
}
|
||||
};
|
||||
|
||||
scope.openCustomUrl = function (customUrl, record) {
|
||||
console.log('Anomalies Table - open customUrl for record:', customUrl, record);
|
||||
|
||||
// If url_value contains $earliest$ and $latest$ tokens, add in times to the source record.
|
||||
const timestamp = record[scope.timeFieldName];
|
||||
const configuredUrlValue = customUrl.url_value;
|
||||
const timeRangeInterval = parseInterval(customUrl.time_range);
|
||||
if (configuredUrlValue.includes('$earliest$')) {
|
||||
let earliestMoment = moment(timestamp);
|
||||
if (timeRangeInterval !== null) {
|
||||
earliestMoment.subtract(timeRangeInterval);
|
||||
} else {
|
||||
earliestMoment = moment(timestamp).startOf(scope.momentInterval);
|
||||
if (scope.momentInterval === 'hour') {
|
||||
// Start from the previous hour.
|
||||
earliestMoment.subtract(1, 'h');
|
||||
}
|
||||
}
|
||||
record.earliest = earliestMoment.toISOString(); // e.g. 2016-02-08T16:00:00.000Z
|
||||
}
|
||||
|
||||
if (configuredUrlValue.includes('$latest$')) {
|
||||
let latestMoment = moment(timestamp).add(record.bucket_span, 's');
|
||||
if (timeRangeInterval !== null) {
|
||||
latestMoment.add(timeRangeInterval);
|
||||
} else {
|
||||
if (scope.isShowingAggregatedData()) {
|
||||
latestMoment = moment(timestamp).endOf(scope.momentInterval);
|
||||
if (scope.momentInterval === 'hour') {
|
||||
// Show to the end of the next hour.
|
||||
latestMoment.add(1, 'h'); // e.g. 2016-02-08T18:59:59.999Z
|
||||
}
|
||||
}
|
||||
}
|
||||
record.latest = latestMoment.toISOString();
|
||||
}
|
||||
|
||||
// If url_value contains $mlcategoryterms$ or $mlcategoryregex$, add in the
|
||||
// terms and regex for the selected categoryId to the source record.
|
||||
if ((configuredUrlValue.includes('$mlcategoryterms$') || configuredUrlValue.includes('$mlcategoryregex$'))
|
||||
&& _.has(record, 'mlcategory')) {
|
||||
const jobId = record.job_id;
|
||||
|
||||
// mlcategory in the source record will be an array
|
||||
// - use first value (will only ever be more than one if influenced by category other than by/partition/over).
|
||||
const categoryId = record.mlcategory[0];
|
||||
ml.results.getCategoryDefinition(jobId, categoryId)
|
||||
.then((resp) => {
|
||||
// Prefix each of the terms with '+' so that the Elasticsearch Query String query
|
||||
// run in a drilldown Kibana dashboard has to match on all terms.
|
||||
const termsArray = _.map(resp.terms.split(' '), (term) => { return '+' + term; });
|
||||
record.mlcategoryterms = termsArray.join(' ');
|
||||
record.mlcategoryregex = resp.regex;
|
||||
|
||||
// Replace any tokens in the configured url_value with values from the source record,
|
||||
// and then open link in a new tab/window.
|
||||
const urlPath = replaceStringTokens(customUrl.url_value, record, true);
|
||||
$window.open(urlPath, '_blank');
|
||||
|
||||
}).catch((resp) => {
|
||||
console.log('openCustomUrl(): error loading categoryDefinition:', resp);
|
||||
});
|
||||
|
||||
} else {
|
||||
// Replace any tokens in the configured url_value with values from the source record,
|
||||
// and then open link in a new tab/window.
|
||||
const urlPath = getUrlForRecord(customUrl, record);
|
||||
$window.open(urlPath, '_blank');
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
scope.filter = function (field, value, operator) {
|
||||
mlAnomaliesTableService.filterChange.changed(field, value, operator);
|
||||
};
|
||||
|
||||
function updateTableData() {
|
||||
let summaryRecords = [];
|
||||
if (scope.isShowingAggregatedData()) {
|
||||
// Aggregate the anomaly data by time and detector, and entity (by/over).
|
||||
summaryRecords = aggregateAnomalies();
|
||||
} else {
|
||||
// Show all anomaly records.
|
||||
const interval = mlSelectIntervalService.state.get('interval');
|
||||
scope.momentInterval = interval.val;
|
||||
const threshold = mlSelectSeverityService.state.get('threshold');
|
||||
const filteredRecords = _.filter(scope.anomalyRecords, (record) => {
|
||||
return Number(record.record_score) >= threshold.val;
|
||||
});
|
||||
|
||||
_.each(filteredRecords, (record) => {
|
||||
const detectorIndex = record.detector_index;
|
||||
const jobId = record.job_id;
|
||||
let detector = record.function_description;
|
||||
if ((_.has(mlJobService.detectorsByJob, jobId)) && (detectorIndex < mlJobService.detectorsByJob[jobId].length)) {
|
||||
detector = mlJobService.detectorsByJob[jobId][detectorIndex].detector_description;
|
||||
}
|
||||
|
||||
const displayRecord = {
|
||||
'time': record[scope.timeFieldName],
|
||||
'max severity': record.record_score,
|
||||
'detector': detector,
|
||||
'jobId': jobId,
|
||||
'source': record
|
||||
};
|
||||
|
||||
const entityName = getEntityFieldName(record);
|
||||
if (entityName !== undefined) {
|
||||
displayRecord.entityName = entityName;
|
||||
displayRecord.entityValue = getEntityFieldValue(record);
|
||||
}
|
||||
|
||||
if (_.has(record, 'partition_field_name')) {
|
||||
displayRecord.partitionFieldName = record.partition_field_name;
|
||||
displayRecord.partitionFieldValue = record.partition_field_value;
|
||||
}
|
||||
|
||||
if (_.has(record, 'influencers')) {
|
||||
const influencers = [];
|
||||
const sourceInfluencers = _.sortBy(record.influencers, 'influencer_field_name');
|
||||
_.each(sourceInfluencers, (influencer) => {
|
||||
const influencerFieldName = influencer.influencer_field_name;
|
||||
_.each(influencer.influencer_field_values, (influencerFieldValue) => {
|
||||
const influencerToAdd = {};
|
||||
influencerToAdd[influencerFieldName] = influencerFieldValue;
|
||||
influencers.push(influencerToAdd);
|
||||
});
|
||||
});
|
||||
displayRecord.influencers = influencers;
|
||||
}
|
||||
|
||||
const functionDescription = _.get(record, 'function_description', '');
|
||||
if (showActualForFunction(functionDescription) === true) {
|
||||
if (_.has(record, 'actual')) {
|
||||
displayRecord.actual = record.actual;
|
||||
} else {
|
||||
// If only a single cause, copy values to the top level.
|
||||
if (_.get(record, 'causes', []).length === 1) {
|
||||
const cause = _.first(record.causes);
|
||||
displayRecord.actual = cause.actual;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showTypicalForFunction(functionDescription) === true) {
|
||||
if (_.has(record, 'typical')) {
|
||||
displayRecord.typical = record.typical;
|
||||
} else {
|
||||
// If only a single cause, copy values to the top level.
|
||||
if (_.get(record, 'causes', []).length === 1) {
|
||||
const cause = _.first(record.causes);
|
||||
displayRecord.typical = cause.typical;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_.has(mlJobService.customUrlsByJob, jobId)) {
|
||||
displayRecord.customUrls = mlJobService.customUrlsByJob[jobId];
|
||||
}
|
||||
|
||||
summaryRecords.push(displayRecord);
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
_.invoke(scope.rowScopes, '$destroy');
|
||||
scope.rowScopes.length = 0;
|
||||
|
||||
const showExamples = _.some(summaryRecords, { 'entityName': 'mlcategory' });
|
||||
if (showExamples) {
|
||||
// Obtain the list of categoryIds by jobId for which we need to obtain the examples.
|
||||
// Note category examples will not be displayed if mlcategory is used just an
|
||||
// influencer or as a partition field in a config with other by/over fields.
|
||||
const categoryRecords = _.where(summaryRecords, { entityName: 'mlcategory' });
|
||||
const categoryIdsByJobId = {};
|
||||
_.each(categoryRecords, (record) => {
|
||||
if (!_.has(categoryIdsByJobId, record.jobId)) {
|
||||
categoryIdsByJobId[record.jobId] = [];
|
||||
}
|
||||
categoryIdsByJobId[record.jobId].push(record.entityValue);
|
||||
});
|
||||
loadCategoryExamples(categoryIdsByJobId);
|
||||
} else {
|
||||
scope.categoryExamplesByJob = {};
|
||||
}
|
||||
|
||||
// Only show columns in the table which exist in the results.
|
||||
scope.table.columns = getPaginatedTableColumns(summaryRecords);
|
||||
|
||||
// Sort by severity by default.
|
||||
summaryRecords = (_.sortBy(summaryRecords, 'max severity')).reverse();
|
||||
scope.table.rows = summaryRecords.map((record) => {
|
||||
return createTableRow(record);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function aggregateAnomalies() {
|
||||
// Aggregate the anomaly data by time, detector, and entity (by/over/partition).
|
||||
// TODO - do we want to aggregate by job too, in cases where different jobs
|
||||
// have detectors with the same description.
|
||||
console.log('aggregateAnomalies(): number of anomalies to aggregate:', scope.anomalyRecords.length);
|
||||
|
||||
if (scope.anomalyRecords.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Determine the aggregation interval - records in scope are in descending time order.
|
||||
const interval = mlSelectIntervalService.state.get('interval');
|
||||
if (interval.val === 'auto') {
|
||||
const earliest = moment(_.last(scope.anomalyRecords)[scope.timeFieldName]);
|
||||
const latest = moment(_.first(scope.anomalyRecords)[scope.timeFieldName]);
|
||||
const daysDiff = latest.diff(earliest, 'days');
|
||||
scope.momentInterval = (daysDiff < 2 ? 'hour' : 'day');
|
||||
} else {
|
||||
scope.momentInterval = interval.val;
|
||||
}
|
||||
|
||||
// Only show records passing the severity threshold.
|
||||
const threshold = mlSelectSeverityService.state.get('threshold');
|
||||
const filteredRecords = _.filter(scope.anomalyRecords, (record) => {
|
||||
|
||||
return Number(record.record_score) >= threshold.val;
|
||||
});
|
||||
|
||||
const aggregatedData = {};
|
||||
_.each(filteredRecords, (record) => {
|
||||
|
||||
// Use moment.js to get start of interval. This will use browser timezone.
|
||||
// TODO - support choice of browser or UTC timezone once functionality is in Kibana.
|
||||
const roundedTime = moment(record[scope.timeFieldName]).startOf(scope.momentInterval).valueOf();
|
||||
if (!_.has(aggregatedData, roundedTime)) {
|
||||
aggregatedData[roundedTime] = {};
|
||||
}
|
||||
|
||||
// Aggregate by detector - default to functionDescription if no description available.
|
||||
const detectorIndex = record.detector_index;
|
||||
const jobId = record.job_id;
|
||||
let detector = record.function_description;
|
||||
if ((_.has(mlJobService.detectorsByJob, jobId)) && (detectorIndex < mlJobService.detectorsByJob[jobId].length)) {
|
||||
detector = mlJobService.detectorsByJob[jobId][detectorIndex].detector_description;
|
||||
}
|
||||
const detectorsAtTime = aggregatedData[roundedTime];
|
||||
if (!_.has(detectorsAtTime, detector)) {
|
||||
detectorsAtTime[detector] = {};
|
||||
}
|
||||
|
||||
// Now add an object for the anomaly with the highest anomaly score per entity.
|
||||
// For the choice of entity, look in order for byField, overField, partitionField.
|
||||
// If no by/over/partition, default to an empty String.
|
||||
const entitiesForDetector = detectorsAtTime[detector];
|
||||
|
||||
// TODO - are we worried about different byFields having the same
|
||||
// value e.g. host=server1 and machine=server1?
|
||||
let entity = getEntityFieldValue(record);
|
||||
if (entity === undefined) {
|
||||
entity = '';
|
||||
}
|
||||
if (!_.has(entitiesForDetector, entity)) {
|
||||
entitiesForDetector[entity] = record;
|
||||
} else {
|
||||
const score = record.record_score;
|
||||
if (score > entitiesForDetector[entity].record_score) {
|
||||
entitiesForDetector[entity] = record;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('aggregateAnomalies() aggregatedData is:', aggregatedData);
|
||||
|
||||
// Flatten the aggregatedData to give a list of records with the highest score per bucketed time / detector.
|
||||
const summaryRecords = [];
|
||||
_.each(aggregatedData, (timeDetectors, roundedTime) => {
|
||||
_.each(timeDetectors, (entityDetectors, detector) => {
|
||||
_.each(entityDetectors, (record, entity) => {
|
||||
const displayRecord = {
|
||||
'time': +roundedTime,
|
||||
'max severity': record.record_score,
|
||||
'detector': detector,
|
||||
'jobId': record.job_id,
|
||||
'source': record
|
||||
};
|
||||
|
||||
const entityName = getEntityFieldName(record);
|
||||
if (entityName !== undefined) {
|
||||
displayRecord.entityName = entityName;
|
||||
displayRecord.entityValue = entity;
|
||||
}
|
||||
|
||||
if (_.has(record, 'partition_field_name')) {
|
||||
displayRecord.partitionFieldName = record.partition_field_name;
|
||||
displayRecord.partitionFieldValue = record.partition_field_value;
|
||||
}
|
||||
|
||||
if (_.has(record, 'influencers')) {
|
||||
const influencers = [];
|
||||
const sourceInfluencers = _.sortBy(record.influencers, 'influencer_field_name');
|
||||
_.each(sourceInfluencers, (influencer) => {
|
||||
const influencerFieldName = influencer.influencer_field_name;
|
||||
_.each(influencer.influencer_field_values, (influencerFieldValue) => {
|
||||
const influencerToAdd = {};
|
||||
influencerToAdd[influencerFieldName] = influencerFieldValue;
|
||||
influencers.push(influencerToAdd);
|
||||
});
|
||||
});
|
||||
displayRecord.influencers = influencers;
|
||||
}
|
||||
|
||||
// Copy actual and typical values to the top level for display.
|
||||
const functionDescription = _.get(record, 'function_description', '');
|
||||
if (showActualForFunction(functionDescription) === true) {
|
||||
if (_.has(record, 'actual')) {
|
||||
displayRecord.actual = record.actual;
|
||||
} else {
|
||||
// If only a single cause, copy value to the top level.
|
||||
if (_.get(record, 'causes', []).length === 1) {
|
||||
const cause = _.first(record.causes);
|
||||
displayRecord.actual = cause.actual;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showTypicalForFunction(functionDescription) === true) {
|
||||
if (_.has(record, 'typical')) {
|
||||
displayRecord.typical = record.typical;
|
||||
} else {
|
||||
// If only a single cause, copy value to the top level.
|
||||
if (_.get(record, 'causes', []).length === 1) {
|
||||
const cause = _.first(record.causes);
|
||||
displayRecord.typical = cause.typical;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_.has(mlJobService.customUrlsByJob, record.job_id)) {
|
||||
displayRecord.customUrls = mlJobService.customUrlsByJob[record.job_id];
|
||||
}
|
||||
|
||||
summaryRecords.push(displayRecord);
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return summaryRecords;
|
||||
|
||||
}
|
||||
|
||||
function getPaginatedTableColumns(summaryRecords) {
|
||||
// Builds the list of columns for use in the paginated table:
|
||||
// row expand arrow
|
||||
// time
|
||||
// max severity
|
||||
// detector
|
||||
// found for (if by/over/partition)
|
||||
// influenced by (if influencers)
|
||||
// actual
|
||||
// typical
|
||||
// description (how actual compares to typical)
|
||||
// job_id
|
||||
// links (if custom URLs configured or drilldown functionality)
|
||||
// category examples (if by mlcategory)
|
||||
const paginatedTableColumns = [
|
||||
{ title: '', sortable: false, class: 'col-expand-arrow' },
|
||||
{ title: 'time', sortable: true }];
|
||||
|
||||
if (scope.isShowingAggregatedData()) {
|
||||
paginatedTableColumns.push({ title: 'max severity', sortable: true });
|
||||
} else {
|
||||
paginatedTableColumns.push({ title: 'severity', sortable: true });
|
||||
}
|
||||
|
||||
paginatedTableColumns.push({ title: 'detector', sortable: true });
|
||||
|
||||
const showEntity = _.some(summaryRecords, 'entityValue');
|
||||
const showInfluencers = _.some(summaryRecords, 'influencers');
|
||||
const showActual = _.some(summaryRecords, 'actual');
|
||||
const showTypical = _.some(summaryRecords, 'typical');
|
||||
const showExamples = _.some(summaryRecords, { 'entityName': 'mlcategory' });
|
||||
const showLinks = ((scope.showViewSeriesLink === true) &&
|
||||
_.some(summaryRecords, (record) => {
|
||||
const job = mlJobService.getJob(record.jobId);
|
||||
return isTimeSeriesViewDetector(job, record.source.detector_index);
|
||||
})) || showExamples === true || _.some(summaryRecords, 'customUrls');
|
||||
|
||||
if (showEntity === true) {
|
||||
paginatedTableColumns.push({ title: 'found for', sortable: true });
|
||||
}
|
||||
if (showInfluencers === true) {
|
||||
paginatedTableColumns.push({ title: 'influenced by', sortable: true });
|
||||
}
|
||||
if (showActual === true) {
|
||||
paginatedTableColumns.push({ title: 'actual', sortable: true });
|
||||
}
|
||||
if (showTypical === true) {
|
||||
paginatedTableColumns.push({ title: 'typical', sortable: true });
|
||||
|
||||
// Assume that if we are showing typical, there will be an actual too,
|
||||
// so we can add a column to describe how actual compares to typical.
|
||||
const nonTimeOfDayOrWeek = _.some(summaryRecords, (record) => {
|
||||
const summaryRecFunc = record.source.function;
|
||||
return summaryRecFunc !== 'time_of_day' && summaryRecFunc !== 'time_of_week';
|
||||
});
|
||||
if (nonTimeOfDayOrWeek === true) {
|
||||
paginatedTableColumns.push({ title: 'description', sortable: true });
|
||||
}
|
||||
}
|
||||
paginatedTableColumns.push({ title: 'job ID', sortable: true });
|
||||
if (showLinks === true) {
|
||||
paginatedTableColumns.push({ title: 'links', sortable: false });
|
||||
}
|
||||
if (showExamples === true) {
|
||||
paginatedTableColumns.push({ title: 'category examples', sortable: false });
|
||||
}
|
||||
|
||||
return paginatedTableColumns;
|
||||
}
|
||||
|
||||
|
||||
function createTableRow(record) {
|
||||
const rowScope = scope.$new();
|
||||
rowScope.expandable = true;
|
||||
rowScope.expandElement = 'ml-anomalies-table-expanded-row';
|
||||
rowScope.record = record;
|
||||
rowScope.isShowingAggregatedData = scope.isShowingAggregatedData();
|
||||
|
||||
rowScope.initRow = function () {
|
||||
if (_.has(record, 'entityValue') && record.entityName === 'mlcategory') {
|
||||
// Obtain the category definition and display the examples in the expanded row.
|
||||
ml.results.getCategoryDefinition(record.jobId, record.entityValue)
|
||||
.then((resp) => {
|
||||
rowScope.categoryDefinition = {
|
||||
'examples': _.slice(resp.examples, 0, Math.min(resp.examples.length, MAX_NUMBER_CATEGORY_EXAMPLES)) };
|
||||
}).catch((resp) => {
|
||||
console.log('Anomalies table createTableRow(): error loading categoryDefinition:', resp);
|
||||
});
|
||||
}
|
||||
|
||||
rowScope.$broadcast('initRow', record);
|
||||
};
|
||||
|
||||
rowScope.mouseenterRow = function () {
|
||||
// Publish that a record is being hovered over, so that the corresponding marker
|
||||
// in the model plot chart can be highlighted.
|
||||
mlAnomaliesTableService.anomalyRecordMouseenter.changed(record);
|
||||
};
|
||||
|
||||
rowScope.mouseleaveRow = function () {
|
||||
// Publish that a record is no longer being hovered over, so that the corresponding marker in the
|
||||
// model plot chart can be unhighlighted.
|
||||
mlAnomaliesTableService.anomalyRecordMouseleave.changed(record);
|
||||
};
|
||||
|
||||
// Create a table row with the following columns:
|
||||
// row expand arrow
|
||||
// time
|
||||
// max severity
|
||||
// detector
|
||||
// found for (if by/over/partition)
|
||||
// influenced by (if influencers)
|
||||
// actual
|
||||
// typical
|
||||
// description (how actual compares to typical)
|
||||
// job_id
|
||||
// links (if customUrls configured or drilldown to Single Metric)
|
||||
// category examples (if by mlcategory)
|
||||
const addEntity = _.findWhere(scope.table.columns, { 'title': 'found for' });
|
||||
const addInfluencers = _.findWhere(scope.table.columns, { 'title': 'influenced by' });
|
||||
|
||||
const addActual = _.findWhere(scope.table.columns, { 'title': 'actual' });
|
||||
const addTypical = _.findWhere(scope.table.columns, { 'title': 'typical' });
|
||||
const addDescription = _.findWhere(scope.table.columns, { 'title': 'description' });
|
||||
const addExamples = _.findWhere(scope.table.columns, { 'title': 'category examples' });
|
||||
const addLinks = _.findWhere(scope.table.columns, { 'title': 'links' });
|
||||
const fieldFormat = mlFieldFormatService.getFieldFormat(record.jobId, record.source.detector_index);
|
||||
|
||||
const tableRow = [
|
||||
{
|
||||
markup: openRowArrow,
|
||||
scope: rowScope
|
||||
},
|
||||
{
|
||||
markup: formatTimestamp(record.time),
|
||||
value: record.time
|
||||
},
|
||||
{
|
||||
markup: parseInt(record['max severity']) >= 1 ?
|
||||
'<i class="fa fa-exclamation-triangle ml-icon-severity-' + getSeverity(record['max severity']) +
|
||||
'" aria-hidden="true"></i> ' + Math.floor(record['max severity']) :
|
||||
'<i class="fa fa-exclamation-triangle ml-icon-severity-' + getSeverity(record['max severity']) +
|
||||
'" aria-hidden="true"></i> < 1',
|
||||
value: record['max severity']
|
||||
},
|
||||
{
|
||||
markup: mlEscape(record.detector),
|
||||
value: record.detector
|
||||
}
|
||||
];
|
||||
|
||||
if (addEntity !== undefined) {
|
||||
if (_.has(record, 'entityValue')) {
|
||||
if (record.entityName !== 'mlcategory') {
|
||||
// Escape single quotes and backslash characters in the HTML for the event handlers.
|
||||
const safeEntityName = mlEscape(record.entityName.replace(/(['\\])/g, '\\$1'));
|
||||
const safeEntityValue = mlEscape(record.entityValue.replace(/(['\\])/g, '\\$1'));
|
||||
|
||||
tableRow.push({
|
||||
markup: mlEscape(record.entityValue) +
|
||||
' <button ng-if="filteringEnabled"' +
|
||||
`ng-click="filter('${safeEntityName}', '${safeEntityValue}', '+')" ` +
|
||||
'tooltip="Add filter" tooltip-append-to-body="1" aria-label="Filter for value">' +
|
||||
'<i class="fa fa-search-plus" aria-hidden="true"></i></button>' +
|
||||
' <button ng-if="filteringEnabled"' +
|
||||
`ng-click="filter('${safeEntityName}', '${safeEntityValue}', '-')" ` +
|
||||
'tooltip="Remove filter" tooltip-append-to-body="1" aria-label="Remove filter">' +
|
||||
'<i class="fa fa-search-minus" aria-hidden="true"></i></button>',
|
||||
value: record.entityValue,
|
||||
scope: rowScope
|
||||
});
|
||||
} else {
|
||||
tableRow.push({
|
||||
markup: 'mlcategory ' + record.entityValue,
|
||||
value: record.entityValue
|
||||
});
|
||||
}
|
||||
} else {
|
||||
tableRow.push({
|
||||
markup: '',
|
||||
value: ''
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (addInfluencers !== undefined) {
|
||||
if (_.has(record, 'influencers')) {
|
||||
const cellMarkup = `<ml-influencers-cell influencers="record.influencers" ` +
|
||||
`limit="${scope.influencersLimit}"></ml-influencers-cell>`;
|
||||
tableRow.push({
|
||||
markup: cellMarkup,
|
||||
value: cellMarkup,
|
||||
scope: rowScope
|
||||
});
|
||||
} else {
|
||||
tableRow.push({
|
||||
markup: '',
|
||||
value: ''
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (addActual !== undefined) {
|
||||
if (_.has(record, 'actual')) {
|
||||
tableRow.push({
|
||||
markup: formatValue(record.actual, record.source.function, fieldFormat),
|
||||
// Store the unformatted value as a number so that sorting works correctly.
|
||||
// actual and typical values in anomaly record results will be arrays.
|
||||
value: Array.isArray(record.actual) && record.actual.length === 1 ?
|
||||
Number(record.actual[0]) : String(record.actual),
|
||||
scope: rowScope });
|
||||
} else {
|
||||
tableRow.push({ markup: '', value: '' });
|
||||
}
|
||||
}
|
||||
if (addTypical !== undefined) {
|
||||
if (_.has(record, 'typical')) {
|
||||
const typicalVal = Array.isArray(record.typical) && record.typical.length === 1 ?
|
||||
Number(record.typical[0]) : String(record.typical);
|
||||
tableRow.push({
|
||||
markup: formatValue(record.typical, record.source.function, fieldFormat),
|
||||
value: typicalVal,
|
||||
scope: rowScope });
|
||||
|
||||
if (addDescription !== undefined) {
|
||||
// Assume there is an actual value if there is a typical,
|
||||
// and add a description cell if not time_of_week/day.
|
||||
const detectorFunc = record.source.function;
|
||||
if (detectorFunc !== 'time_of_week' && detectorFunc !== 'time_of_day') {
|
||||
let factor = 0;
|
||||
if (Array.isArray(record.typical) && record.typical.length === 1 &&
|
||||
Array.isArray(record.actual) && record.actual.length === 1) {
|
||||
const actualVal = Number(record.actual[0]);
|
||||
factor = (actualVal > typicalVal) ? actualVal / typicalVal : typicalVal / actualVal;
|
||||
}
|
||||
|
||||
tableRow.push({
|
||||
markup: `<span ng-bind-html="[${record.actual}] | metricChangeDescription:[${typicalVal}]"></span>`,
|
||||
value: Math.abs(factor),
|
||||
scope: rowScope });
|
||||
} else {
|
||||
tableRow.push({ markup: '', value: '' });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tableRow.push({ markup: '', value: '' });
|
||||
if (addDescription !== undefined) {
|
||||
tableRow.push({ markup: '', value: '' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tableRow.push({ markup: record.jobId, value: record.jobId });
|
||||
|
||||
if (addLinks !== undefined) {
|
||||
const job = mlJobService.getJob(record.jobId);
|
||||
rowScope.showViewSeriesLink = scope.showViewSeriesLink === true &&
|
||||
isTimeSeriesViewDetector(job, record.source.detector_index);
|
||||
rowScope.showViewExamplesLink = (_.get(record, 'entityName') === 'mlcategory');
|
||||
if (_.has(record, 'customUrls') || rowScope.showViewSeriesLink === true
|
||||
|| rowScope.showViewExamplesLink) {
|
||||
rowScope.customUrls = record.customUrls;
|
||||
rowScope.source = record.source;
|
||||
|
||||
tableRow.push({
|
||||
markup: linkControlsHtml,
|
||||
scope: rowScope
|
||||
});
|
||||
} else {
|
||||
tableRow.push({
|
||||
markup: '',
|
||||
value: ''
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (addExamples !== undefined) {
|
||||
if (record.entityName === 'mlcategory') {
|
||||
tableRow.push({ markup: '<span style="display: block; white-space:nowrap;" ' +
|
||||
'ng-repeat="item in getExamplesForCategory(record.jobId, record.entityValue)">{{item}}</span>', scope: rowScope });
|
||||
} else {
|
||||
tableRow.push({ markup: '', value: '' });
|
||||
}
|
||||
}
|
||||
|
||||
scope.rowScopes.push(rowScope);
|
||||
|
||||
return tableRow;
|
||||
|
||||
}
|
||||
|
||||
function loadCategoryExamples(categoryIdsByJobId) {
|
||||
// Load the example events for the specified map of job_ids and categoryIds from Elasticsearch.
|
||||
scope.categoryExamplesByJob = {};
|
||||
_.each(categoryIdsByJobId, (categoryIds, jobId) => {
|
||||
ml.results.getCategoryExamples(jobId, categoryIds, MAX_NUMBER_CATEGORY_EXAMPLES)
|
||||
.then((resp) => {
|
||||
scope.categoryExamplesByJob[jobId] = resp;
|
||||
}).catch((resp) => {
|
||||
console.log('Anomalies table - error getting category examples:', resp);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function formatTimestamp(epochMs) {
|
||||
const time = moment(epochMs);
|
||||
if (scope.momentInterval === 'hour') {
|
||||
return time.format('MMMM Do YYYY, HH:mm');
|
||||
} else if (scope.momentInterval === 'second') {
|
||||
return time.format('MMMM Do YYYY, HH:mm:ss');
|
||||
} else {
|
||||
return time.format('MMMM Do YYYY');
|
||||
}
|
||||
}
|
||||
|
||||
const module = uiModules.get('apps/ml', ['react']);
|
||||
|
||||
import { AnomaliesTable } from './anomalies_table';
|
||||
|
||||
module.directive('mlAnomaliesTable', function ($injector) {
|
||||
const timefilter = $injector.get('timefilter');
|
||||
const reactDirective = $injector.get('reactDirective');
|
||||
|
||||
return reactDirective(
|
||||
AnomaliesTable,
|
||||
[
|
||||
['filter', { watchDepth: 'reference' }],
|
||||
['tableData', { watchDepth: 'reference' }]
|
||||
],
|
||||
{ restrict: 'E' },
|
||||
{
|
||||
timefilter
|
||||
}
|
||||
};
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
<div class="dropdown-group" dropdown dropdown-append-to-body>
|
||||
<a href='' class="dropdown-toggle" dropdown-toggle>
|
||||
Open link <span class="caret"></span>
|
||||
</a>
|
||||
<ul class="ml-anomalies-table dropdown-menu dropdown-menu-right" role="menu">
|
||||
<li ng-repeat="customUrl in customUrls"><a href="" ng-click="openCustomUrl(customUrl, source)">
|
||||
{{customUrl.url_name}}
|
||||
<i class="fa fa-external-link" aria-hidden="true"></i></a>
|
||||
</li>
|
||||
<li ng-if="showViewSeriesLink"><a href="" ng-click="viewSeries(source)">
|
||||
View series
|
||||
<i class="fa fa-external-link" aria-hidden="true"></i></a>
|
||||
</li>
|
||||
<li ng-if="showViewExamplesLink"><a href="" ng-click="viewExamples(source)">
|
||||
View examples
|
||||
<i class="fa fa-external-link" aria-hidden="true"></i></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -11,16 +11,14 @@
|
|||
* anomalies table component.
|
||||
*/
|
||||
|
||||
import { uiModules } from 'ui/modules';
|
||||
const module = uiModules.get('apps/ml');
|
||||
|
||||
import { listenerFactoryProvider } from 'plugins/ml/factories/listener_factory';
|
||||
|
||||
module.service('mlAnomaliesTableService', function () {
|
||||
class AnomaliesTableService {
|
||||
constructor() {
|
||||
const listenerFactory = listenerFactoryProvider();
|
||||
this.anomalyRecordMouseenter = listenerFactory();
|
||||
this.anomalyRecordMouseleave = listenerFactory();
|
||||
}
|
||||
}
|
||||
|
||||
const listenerFactory = listenerFactoryProvider();
|
||||
this.anomalyRecordMouseenter = listenerFactory();
|
||||
this.anomalyRecordMouseleave = listenerFactory();
|
||||
this.filterChange = listenerFactory();
|
||||
|
||||
});
|
||||
export const mlAnomaliesTableService = new AnomaliesTableService();
|
||||
|
|
|
@ -0,0 +1,326 @@
|
|||
/*
|
||||
* 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 displaying details of an anomaly in the expanded row section
|
||||
* of the anomalies table.
|
||||
*/
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import {
|
||||
EuiDescriptionList,
|
||||
EuiIcon,
|
||||
EuiLink,
|
||||
EuiSpacer,
|
||||
EuiText
|
||||
} from '@elastic/eui';
|
||||
import { formatDate } from '@elastic/eui/lib/services/format';
|
||||
|
||||
import { EntityCell } from './entity_cell';
|
||||
import {
|
||||
getSeverity,
|
||||
showActualForFunction,
|
||||
showTypicalForFunction
|
||||
} from 'plugins/ml/../common/util/anomaly_utils';
|
||||
import { formatValue } from 'plugins/ml/formatters/format_value';
|
||||
|
||||
const TIME_FIELD_NAME = 'timestamp';
|
||||
|
||||
|
||||
function getFilterEntity(entityName, entityValue, filter) {
|
||||
return (
|
||||
<EntityCell
|
||||
entityName={entityName}
|
||||
entityValue={entityValue}
|
||||
filter={filter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getDetailsItems(anomaly, examples, filter) {
|
||||
const source = anomaly.source;
|
||||
|
||||
// TODO - when multivariate analyses are more common,
|
||||
// look in each cause for a 'correlatedByFieldValue' field,
|
||||
let causes = [];
|
||||
const sourceCauses = source.causes || [];
|
||||
let singleCauseByFieldName = undefined;
|
||||
let singleCauseByFieldValue = undefined;
|
||||
if (sourceCauses.length === 1) {
|
||||
// Metrics and probability will already have been placed at the top level.
|
||||
// If cause has byFieldValue, move it to a top level fields for display.
|
||||
if (sourceCauses[0].by_field_name !== undefined) {
|
||||
singleCauseByFieldName = sourceCauses[0].by_field_name;
|
||||
singleCauseByFieldValue = sourceCauses[0].by_field_value;
|
||||
}
|
||||
} else {
|
||||
causes = sourceCauses.map((cause) => {
|
||||
const simplified = _.pick(cause, 'typical', 'actual', 'probability');
|
||||
// Get the 'entity field name/value' to display in the cause -
|
||||
// For by and over, use by_field_name/value (over_field_name/value are in the top level fields)
|
||||
// For just an 'over' field - the over_field_name/value appear in both top level and cause.
|
||||
simplified.entityName = _.has(cause, 'by_field_name') ? cause.by_field_name : cause.over_field_name;
|
||||
simplified.entityValue = _.has(cause, 'by_field_value') ? cause.by_field_value : cause.over_field_value;
|
||||
return simplified;
|
||||
});
|
||||
}
|
||||
|
||||
const items = [];
|
||||
if (source.partition_field_value !== undefined) {
|
||||
items.push({
|
||||
title: source.partition_field_name,
|
||||
description: getFilterEntity(source.partition_field_name, source.partition_field_value, filter)
|
||||
});
|
||||
}
|
||||
|
||||
if (source.by_field_value !== undefined) {
|
||||
items.push({
|
||||
title: source.by_field_name,
|
||||
description: getFilterEntity(source.by_field_name, source.by_field_value, filter)
|
||||
});
|
||||
}
|
||||
|
||||
if (singleCauseByFieldName !== undefined) {
|
||||
// Display byField of single cause.
|
||||
items.push({
|
||||
title: singleCauseByFieldName,
|
||||
description: getFilterEntity(singleCauseByFieldName, singleCauseByFieldValue, filter)
|
||||
});
|
||||
}
|
||||
|
||||
if (source.over_field_value !== undefined) {
|
||||
items.push({
|
||||
title: source.over_field_name,
|
||||
description: getFilterEntity(source.over_field_name, source.over_field_value, filter)
|
||||
});
|
||||
}
|
||||
|
||||
const anomalyTime = source[TIME_FIELD_NAME];
|
||||
let timeDesc = `${formatDate(anomalyTime, 'MMMM Do YYYY, HH:mm:ss')}`;
|
||||
if (source.bucket_span !== undefined) {
|
||||
const anomalyEndTime = anomalyTime + (source.bucket_span * 1000);
|
||||
timeDesc += ` to ${formatDate(anomalyEndTime, 'MMMM Do YYYY, HH:mm:ss')}`;
|
||||
}
|
||||
items.push({
|
||||
title: 'time',
|
||||
description: timeDesc
|
||||
});
|
||||
|
||||
if (examples !== undefined && examples.length > 0) {
|
||||
examples.forEach((example, index) => {
|
||||
const title = (index === 0) ? 'category examples' : '';
|
||||
items.push({ title, description: example });
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
title: 'function',
|
||||
description: (source.function !== 'metric') ? source.function : source.function_description
|
||||
});
|
||||
|
||||
if (source.field_name !== undefined) {
|
||||
items.push({
|
||||
title: 'fieldName',
|
||||
description: source.field_name
|
||||
});
|
||||
}
|
||||
|
||||
const functionDescription = source.function_description || '';
|
||||
if (anomaly.actual !== undefined && showActualForFunction(functionDescription) === true) {
|
||||
items.push({
|
||||
title: 'actual',
|
||||
description: formatValue(anomaly.actual, source.function)
|
||||
});
|
||||
}
|
||||
|
||||
if (anomaly.typical !== undefined && showTypicalForFunction(functionDescription) === true) {
|
||||
items.push({
|
||||
title: 'typical',
|
||||
description: formatValue(anomaly.typical, source.function)
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
title: 'job ID',
|
||||
description: anomaly.jobId
|
||||
});
|
||||
|
||||
items.push({
|
||||
title: 'probability',
|
||||
description: source.probability
|
||||
});
|
||||
|
||||
// If there was only one cause, the actual, typical and by_field
|
||||
// will already have been added for display.
|
||||
if (causes.length > 1) {
|
||||
causes.forEach((cause, index) => {
|
||||
const title = (index === 0) ? `${cause.entityName} values` : '';
|
||||
let description = `${cause.entityValue} (actual ${formatValue(cause.actual, source.function)}, `;
|
||||
description += `typical ${formatValue(cause.typical, source.function)}, probability ${cause.probability})`;
|
||||
items.push({ title, description });
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export class AnomalyDetails extends Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
showAllInfluencers: false
|
||||
};
|
||||
}
|
||||
|
||||
toggleAllInfluencers() {
|
||||
this.setState({ showAllInfluencers: !this.state.showAllInfluencers });
|
||||
}
|
||||
|
||||
renderDescription() {
|
||||
const anomaly = this.props.anomaly;
|
||||
const source = anomaly.source;
|
||||
|
||||
let anomalyDescription = `${getSeverity(anomaly.severity)} anomaly in ${anomaly.detector}`;
|
||||
if (anomaly.entityName !== undefined) {
|
||||
anomalyDescription += ` found for ${anomaly.entityName} ${anomaly.entityValue}`;
|
||||
}
|
||||
|
||||
if ((source.partition_field_name !== undefined) &&
|
||||
(source.partition_field_name !== anomaly.entityName)) {
|
||||
anomalyDescription += ` detected in ${source.partition_field_name}`;
|
||||
anomalyDescription += ` ${source.partition_field_value}`;
|
||||
}
|
||||
|
||||
// Check for a correlatedByFieldValue in the source which will be present for multivariate analyses
|
||||
// where the record is anomalous due to relationship with another 'by' field value.
|
||||
let mvDescription = undefined;
|
||||
if (source.correlated_by_field_value !== undefined) {
|
||||
mvDescription = `multivariate correlations found in ${source.by_field_name}; `;
|
||||
mvDescription += `${source.by_field_value} is considered anomalous given ${source.correlated_by_field_value}`;
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
<EuiText size="xs">
|
||||
<h5>Description</h5>
|
||||
{anomalyDescription}
|
||||
</EuiText>
|
||||
{(mvDescription !== undefined) &&
|
||||
<EuiText size="xs">
|
||||
{mvDescription}
|
||||
</EuiText>
|
||||
}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
renderDetails() {
|
||||
const detailItems = getDetailsItems(this.props.anomaly, this.props.examples, this.props.filter);
|
||||
const isInterimResult = _.get(this.props.anomaly, 'source.is_interim', false);
|
||||
return (
|
||||
<React.Fragment>
|
||||
<EuiText>
|
||||
{this.props.isAggregatedData === true ? (
|
||||
<h5>Details on highest severity anomaly</h5>
|
||||
) : (
|
||||
<h5>Anomaly details</h5>
|
||||
)}
|
||||
{isInterimResult === true &&
|
||||
<React.Fragment>
|
||||
<EuiIcon type="alert"/><span className="interim-result">Interim result</span>
|
||||
</React.Fragment>
|
||||
}
|
||||
</EuiText>
|
||||
<EuiDescriptionList
|
||||
type="column"
|
||||
listItems={detailItems}
|
||||
className="anomaly-description-list"
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
renderInfluencers() {
|
||||
const anomalyInfluencers = this.props.anomaly.influencers;
|
||||
const listItems = [];
|
||||
let othersCount = 0;
|
||||
let numToDisplay = 0;
|
||||
if (anomalyInfluencers !== undefined) {
|
||||
numToDisplay = (this.state.showAllInfluencers === true) ?
|
||||
anomalyInfluencers.length : Math.min(this.props.influencersLimit, anomalyInfluencers.length);
|
||||
othersCount = Math.max(anomalyInfluencers.length - numToDisplay, 0);
|
||||
|
||||
if (othersCount === 1) {
|
||||
// Display the 1 extra influencer as displaying "and 1 more" would also take up a line.
|
||||
numToDisplay++;
|
||||
othersCount = 0;
|
||||
}
|
||||
|
||||
for (let i = 0; i < numToDisplay; i++) {
|
||||
Object.keys(anomalyInfluencers[i]).forEach((influencerFieldName) => {
|
||||
listItems.push({
|
||||
title: influencerFieldName,
|
||||
description: anomalyInfluencers[i][influencerFieldName]
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (listItems.length > 0) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiText>
|
||||
<h5>Influencers</h5>
|
||||
</EuiText>
|
||||
<EuiDescriptionList
|
||||
type="column"
|
||||
listItems={listItems}
|
||||
className="anomaly-description-list"
|
||||
/>
|
||||
{othersCount > 0 &&
|
||||
<EuiLink
|
||||
onClick={() => this.toggleAllInfluencers()}
|
||||
>
|
||||
and {othersCount} more
|
||||
</EuiLink>
|
||||
}
|
||||
{numToDisplay > (this.props.influencersLimit + 1) &&
|
||||
<EuiLink
|
||||
onClick={() => this.toggleAllInfluencers()}
|
||||
>
|
||||
show less
|
||||
</EuiLink>
|
||||
}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
return (
|
||||
<div className="ml-anomalies-table-details">
|
||||
{this.renderDescription()}
|
||||
<EuiSpacer size="m" />
|
||||
{this.renderDetails()}
|
||||
{this.renderInfluencers()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AnomalyDetails.propTypes = {
|
||||
anomaly: PropTypes.object.isRequired,
|
||||
examples: PropTypes.array,
|
||||
isAggregatedData: PropTypes.bool,
|
||||
filter: PropTypes.func,
|
||||
influencersLimit: PropTypes.number
|
||||
};
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiIcon,
|
||||
EuiText
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { getMetricChangeDescription } from 'plugins/ml/formatters/metric_change_description';
|
||||
|
||||
/*
|
||||
* Component for rendering the description cell in the anomalies table, which provides a
|
||||
* concise description of how the actual value of an anomaly compares to the typical value.
|
||||
*/
|
||||
export function DescriptionCell({ actual, typical }) {
|
||||
|
||||
const {
|
||||
iconType,
|
||||
message
|
||||
} = getMetricChangeDescription(actual, typical);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
{iconType !== undefined &&
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon
|
||||
type={iconType}
|
||||
size="s"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
}
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="xs">
|
||||
<p>{message}</p>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
|
||||
DescriptionCell.propTypes = {
|
||||
actual: PropTypes.oneOfType([PropTypes.array, PropTypes.number]),
|
||||
typical: PropTypes.oneOfType([PropTypes.array, PropTypes.number])
|
||||
};
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
EuiButtonIcon,
|
||||
EuiToolTip
|
||||
} from '@elastic/eui';
|
||||
|
||||
/*
|
||||
* Component for rendering an entity cell in the anomalies table, displaying the value
|
||||
* of the 'partition', 'by' or 'over' field, and optionally links for adding or removing
|
||||
* a filter on this entity.
|
||||
*/
|
||||
export function EntityCell({ entityName, entityValue, filter }) {
|
||||
const valueText = (entityName !== 'mlcategory') ? entityValue : `mlcategory ${entityValue}`;
|
||||
return (
|
||||
<React.Fragment>
|
||||
{valueText}
|
||||
{filter !== undefined && entityName !== undefined && entityValue !== undefined &&
|
||||
<React.Fragment>
|
||||
<EuiToolTip content="Add filter">
|
||||
<EuiButtonIcon
|
||||
size="xs"
|
||||
className="filter-button"
|
||||
onClick={() => filter(entityName, entityValue, '+')}
|
||||
iconType="plusInCircle"
|
||||
aria-label="Add filter"
|
||||
/>
|
||||
</EuiToolTip>
|
||||
<EuiToolTip content="Remove filter">
|
||||
<EuiButtonIcon
|
||||
size="xs"
|
||||
className="filter-button"
|
||||
onClick={() => filter(entityName, entityValue, '-')}
|
||||
iconType="minusInCircle"
|
||||
aria-label="Remove filter"
|
||||
/>
|
||||
</EuiToolTip>
|
||||
</React.Fragment>
|
||||
}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
EntityCell.propTypes = {
|
||||
entityName: PropTypes.string,
|
||||
entityValue: PropTypes.any,
|
||||
filter: PropTypes.func
|
||||
};
|
|
@ -1,108 +0,0 @@
|
|||
<div class="ml-tablerow-expanded">
|
||||
<span class="ml-tablerow-expanded-heading">Description:</span>
|
||||
{{description}}
|
||||
<span ng-if="multiVariateDescription" class="ml-tablerow-expanded-mv-description">{{multiVariateDescription}}</span>
|
||||
|
||||
<span ng-if="isShowingAggregatedData === true" class="ml-tablerow-expanded-heading">Details on highest severity anomaly:</span>
|
||||
<span ng-if="isShowingAggregatedData === false" class="ml-tablerow-expanded-heading">Anomaly Details:</span>
|
||||
<div ng-if="isInterim === true" class="ml-anomaly-interim-result"><i class="fa fa-exclamation-triangle"></i> Interim result</div>
|
||||
<table>
|
||||
<tr ng-if="record.source.partition_field_value">
|
||||
<td>{{record.source.partition_field_name}}:</td>
|
||||
<td>{{record.source.partition_field_value}}
|
||||
<button ng-if="filteringEnabled" ng-click="filter(record.source.partition_field_name, record.source.partition_field_value, '+')"
|
||||
tooltip="Add filter" tooltip-append-to-body="1" aria-label="Filter for value">
|
||||
<i class="fa fa-search-plus" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button ng-if="filteringEnabled" ng-click="filter(record.source.partition_field_name, record.source.partition_field_value, '-')"
|
||||
tooltip="Remove filter" tooltip-append-to-body="1" aria-label="Remove filter">
|
||||
<i class="fa fa-search-minus" aria-hidden="true"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="record.source.by_field_value">
|
||||
<td>{{record.source.by_field_name}}:</td>
|
||||
<td>{{record.source.by_field_value}}
|
||||
<button ng-if="filteringEnabled" ng-click="filter(record.source.by_field_name, record.source.by_field_value, '+')"
|
||||
tooltip="Add filter" tooltip-append-to-body="1" aria-label="Filter for value">
|
||||
<i class="fa fa-search-plus" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button ng-if="filteringEnabled" ng-click="filter(record.source.by_field_name, record.source.by_field_value, '-')"
|
||||
tooltip="Remove filter" tooltip-append-to-body="1" aria-label="Remove filter">
|
||||
<i class="fa fa-search-minus" aria-hidden="true"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="singleCauseByFieldValue">
|
||||
<td>{{singleCauseByFieldName}}:</td>
|
||||
<td>{{singleCauseByFieldValue}}
|
||||
<button ng-if="filteringEnabled" ng-click="filter(singleCauseByFieldName, singleCauseByFieldValue, '+')"
|
||||
tooltip="Add filter" tooltip-append-to-body="1" aria-label="Filter for value">
|
||||
<i class="fa fa-search-plus" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button ng-if="filteringEnabled" ng-click="filter(singleCauseByFieldName, singleCauseByFieldValue, '-')"
|
||||
tooltip="Remove filter" tooltip-append-to-body="1" aria-label="Remove filter">
|
||||
<i class="fa fa-search-minus" aria-hidden="true"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="record.source.over_field_value">
|
||||
<td>{{record.source.over_field_name}}:</td>
|
||||
<td>{{record.source.over_field_value}}
|
||||
<button ng-if="filteringEnabled" ng-click="filter(record.source.over_field_name, record.source.over_field_value, '+')"
|
||||
tooltip="Add filter" tooltip-append-to-body="1" aria-label="Filter for value">
|
||||
<i class="fa fa-search-plus" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button ng-if="filteringEnabled" ng-click="filter(record.source.over_field_name, record.source.over_field_value, '-')"
|
||||
tooltip="Remove filter" tooltip-append-to-body="1" aria-label="Remove filter">
|
||||
<i class="fa fa-search-minus" aria-hidden="true"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="anomalyEndTime"><td>time:</td><td>{{anomalyTime}} to {{anomalyEndTime}}</td></tr>
|
||||
<tr ng-if="!anomalyEndTime"><td>time:</td><td>{{anomalyTime}}</td></tr>
|
||||
<tr ng-if="examples" ng-repeat="example in examples track by $index">
|
||||
<td ng-if="$index === 0">category examples:</td><td ng-if="$index > 0"> </td>
|
||||
<td>{{example}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>function:</td>
|
||||
<td ng-if="record.source.function !== 'metric'">{{record.source.function}}</td>
|
||||
<td ng-if="record.source.function === 'metric'">{{record.source.function_description}}</td>
|
||||
</tr>
|
||||
<tr ng-if="record.source.field_name"><td>fieldName:</td><td>{{record.source.field_name}}</td></tr>
|
||||
<tr ng-if="actual !== undefined"><td>actual:</td><td>{{actual | formatValue:record.source.function}}</td></tr>
|
||||
<tr ng-if="typical !== undefined"><td>typical:</td><td>{{typical | formatValue:record.source.function}}</td></tr>
|
||||
<tr>
|
||||
<td>job ID:</td>
|
||||
<td>{{record.jobId}}
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
</tr>
|
||||
<tr><td>probability:</td><td>{{record.source.probability}}</td></tr>
|
||||
<tr ng-if="causes" ng-repeat="cause in causes track by $index">
|
||||
<td ng-if="$index === 0">{{cause.entityName}} values:</td><td ng-if="$index > 0"> </td>
|
||||
<td>{{cause.entityValue}} (actual {{cause.actual | formatValue:record.source.function}}, typical {{cause.typical | formatValue:record.source.function}}, probability {{cause.probability}})</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<span ng-if="influencers" class="ml-tablerow-expanded-heading">Influenced by:</span>
|
||||
<table ng-if="influencers">
|
||||
<tr ng-repeat="influencer in influencers">
|
||||
<td>{{influencer.name}}</td>
|
||||
<td>{{influencer.value}}</td>
|
||||
</tr>
|
||||
<tr ng-if="otherInfluencersCount > 0">
|
||||
<td colspan="2">
|
||||
<button class="euiLink euiLink--primary" type="button" ng-click="toggleAllInfluencers()">and {{otherInfluencersCount}} more</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="influencersNumToDislay > (influencersLimit + 1)">
|
||||
<td colspan="2">
|
||||
<button class="euiLink euiLink--primary" type="button" ng-click="toggleAllInfluencers()">show less</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
|
@ -1,217 +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.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* Angular directive for rendering the expanded row content in the
|
||||
* Machine Learning anomalies table. It displays more details on the
|
||||
* anomaly summarized in the row, including field names,
|
||||
* actual and typical values for the analyzed metric,
|
||||
* plus causes and examples events according to the detector configuration.
|
||||
*/
|
||||
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
|
||||
import template from './expanded_row.html';
|
||||
import {
|
||||
getSeverity,
|
||||
showActualForFunction,
|
||||
showTypicalForFunction
|
||||
} from 'plugins/ml/../common/util/anomaly_utils';
|
||||
import 'plugins/ml/formatters/format_value';
|
||||
|
||||
import { uiModules } from 'ui/modules';
|
||||
const module = uiModules.get('apps/ml');
|
||||
|
||||
module.directive('mlAnomaliesTableExpandedRow', function () {
|
||||
|
||||
function link(scope) {
|
||||
scope.record = scope.$parent.record;
|
||||
scope.filter = scope.$parent.filter;
|
||||
scope.filteringEnabled = scope.$parent.filteringEnabled;
|
||||
scope.isShowingAggregatedData = scope.$parent.isShowingAggregatedData;
|
||||
scope.influencersLimit = scope.$parent.influencersLimit;
|
||||
scope.influencersNumToDislay = scope.influencersLimit;
|
||||
|
||||
const timeFieldName = 'timestamp';
|
||||
const momentTime = moment(scope.record.source[timeFieldName]);
|
||||
scope.anomalyTime = momentTime.format('MMMM Do YYYY, HH:mm:ss');
|
||||
if (_.has(scope.record.source, 'bucket_span')) {
|
||||
scope.anomalyEndTime = momentTime.add(scope.record.source.bucket_span, 's').format('MMMM Do YYYY, HH:mm:ss');
|
||||
}
|
||||
|
||||
scope.$on('initRow', () => {
|
||||
// Only build the description and details on metric values,
|
||||
// causes and influencers when the row is first expanded.
|
||||
buildContent();
|
||||
});
|
||||
|
||||
scope.toggleAllInfluencers = function () {
|
||||
if (_.has(scope.record, 'influencers')) {
|
||||
const recordInfluencers = scope.record.influencers;
|
||||
if (scope.influencers.length === recordInfluencers.length) {
|
||||
scope.influencersNumToDislay = scope.influencersLimit;
|
||||
} else {
|
||||
scope.influencersNumToDislay = recordInfluencers.length;
|
||||
}
|
||||
buildInfluencers();
|
||||
}
|
||||
};
|
||||
|
||||
if (scope.$parent.open === true) {
|
||||
// Build the content if the row was already open before re-render (e.g. when sorting),
|
||||
buildContent();
|
||||
}
|
||||
|
||||
if (_.has(scope.record, 'entityValue') && scope.record.entityName === 'mlcategory') {
|
||||
// For categorization results, controller will obtain the definition when the
|
||||
// row is first expanded and place the categoryDefinition in the row scope.
|
||||
const unbindWatch = scope.$parent.$watch('categoryDefinition', (categoryDefinition) => {
|
||||
if (categoryDefinition !== undefined) {
|
||||
scope.examples = categoryDefinition.examples;
|
||||
unbindWatch();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildContent() {
|
||||
buildDescription();
|
||||
buildMetrics();
|
||||
buildCauses();
|
||||
buildInfluencers();
|
||||
}
|
||||
|
||||
function buildDescription() {
|
||||
const record = scope.record;
|
||||
let rowDescription = getSeverity(record.source.record_score) + ' anomaly in ' + record.detector;
|
||||
|
||||
if (_.has(record, 'entityName')) {
|
||||
rowDescription += ' found for ' + record.entityName;
|
||||
rowDescription += ' ';
|
||||
rowDescription += record.entityValue;
|
||||
}
|
||||
|
||||
if (_.has(record.source, 'partition_field_name') && (record.source.partition_field_name !== record.entityName)) {
|
||||
rowDescription += ' detected in ' + record.source.partition_field_name;
|
||||
rowDescription += ' ';
|
||||
rowDescription += record.source.partition_field_value;
|
||||
}
|
||||
|
||||
scope.description = rowDescription;
|
||||
|
||||
// Check for a correlatedByFieldValue in the source which will be present for multivariate analyses
|
||||
// where the record is anomalous due to relationship with another 'by' field value.
|
||||
if (_.has(record.source, 'correlated_by_field_value')) {
|
||||
let mvDescription = 'multivariate correlations found in ';
|
||||
mvDescription += record.source.by_field_name;
|
||||
mvDescription += '; ';
|
||||
mvDescription += record.source.by_field_value;
|
||||
mvDescription += ' is considered anomalous given ';
|
||||
mvDescription += record.source.correlated_by_field_value;
|
||||
scope.multiVariateDescription = mvDescription;
|
||||
}
|
||||
|
||||
|
||||
// Display a warning below the description if the record is an interim result.
|
||||
scope.isInterim = _.get(record, 'source.is_interim', false);
|
||||
}
|
||||
|
||||
function buildMetrics() {
|
||||
const record = scope.record;
|
||||
const functionDescription = _.get(record, 'source.function_description', '');
|
||||
if (showActualForFunction(functionDescription) === true) {
|
||||
if (!_.has(scope.record.source, 'causes')) {
|
||||
scope.actual = record.source.actual;
|
||||
} else {
|
||||
const causes = scope.record.source.causes;
|
||||
if (causes.length === 1) {
|
||||
// If only one 'cause', move value to top level.
|
||||
const cause = _.first(causes);
|
||||
scope.actual = cause.actual;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showTypicalForFunction(functionDescription) === true) {
|
||||
if (!_.has(scope.record.source, 'causes')) {
|
||||
scope.typical = record.source.typical;
|
||||
} else {
|
||||
const causes = scope.record.source.causes;
|
||||
if (causes.length === 1) {
|
||||
// If only one 'cause', move value to top level.
|
||||
const cause = _.first(causes);
|
||||
scope.typical = cause.typical;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildCauses() {
|
||||
if (_.has(scope.record.source, 'causes')) {
|
||||
const causes = scope.record.source.causes;
|
||||
|
||||
// TODO - build different information depending on whether function is rare, freq_rare or another.
|
||||
|
||||
// TODO - look in each cause for a 'correlatedByFieldValue' field,
|
||||
// and if so, add to causes scope object for rendering in the template.
|
||||
if (causes.length === 1) {
|
||||
// Metrics and probability will already have been placed at the top level.
|
||||
// If cause has byFieldValue, move it to a top level fields for display.
|
||||
const cause = _.first(causes);
|
||||
if (_.has(cause, 'by_field_name')) {
|
||||
scope.singleCauseByFieldName = cause.by_field_name;
|
||||
scope.singleCauseByFieldValue = cause.by_field_value;
|
||||
}
|
||||
} else {
|
||||
scope.causes = _.map(causes, (cause) => {
|
||||
const simplified = _.pick(cause, 'typical', 'actual', 'probability');
|
||||
// Get the 'entity field name/value' to display in the cause -
|
||||
// For by and over, use by_field_name/Value (over_field_name/Value are in the toplevel fields)
|
||||
// For just an 'over' field - the over_field_name/Value appear in both top level and cause.
|
||||
simplified.entityName = _.has(cause, 'by_field_name') ? cause.by_field_name : cause.over_field_name;
|
||||
simplified.entityValue = _.has(cause, 'by_field_value') ? cause.by_field_value : cause.over_field_value;
|
||||
return simplified;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
function buildInfluencers() {
|
||||
if (_.has(scope.record, 'influencers')) {
|
||||
const recordInfluencers = scope.record.influencers;
|
||||
scope.influencersNumToDislay = Math.min(scope.influencersNumToDislay, recordInfluencers.length);
|
||||
let othersCount = Math.max(recordInfluencers.length - scope.influencersNumToDislay, 0);
|
||||
|
||||
if (othersCount === 1) {
|
||||
// Display the 1 extra influencer as displaying "and 1 more" would also take up a line.
|
||||
scope.influencersNumToDislay++;
|
||||
othersCount = 0;
|
||||
}
|
||||
|
||||
const influencers = [];
|
||||
for (let i = 0; i < scope.influencersNumToDislay; i++) {
|
||||
_.each(recordInfluencers[i], (influencerFieldValue, influencerFieldName) => {
|
||||
influencers.push({ 'name': influencerFieldName, 'value': influencerFieldValue });
|
||||
});
|
||||
}
|
||||
|
||||
scope.influencers = influencers;
|
||||
scope.otherInfluencersCount = othersCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
restrict: 'AE',
|
||||
replace: false,
|
||||
scope: {},
|
||||
template,
|
||||
link: link
|
||||
};
|
||||
});
|
|
@ -5,7 +5,6 @@
|
|||
*/
|
||||
|
||||
|
||||
|
||||
import './styles/main.less';
|
||||
import './anomalies_table_directive.js';
|
||||
import './anomalies_table_directive';
|
||||
import './anomalies_table_service.js';
|
||||
import './styles/main.less';
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
*/
|
||||
|
||||
|
||||
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
|
@ -21,23 +20,8 @@ export class InfluencersCell extends Component {
|
|||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.limit = props.limit;
|
||||
const recordInfluencers = props.influencers || [];
|
||||
this.influencers = [];
|
||||
_.each(recordInfluencers, (influencer) => {
|
||||
_.each(influencer, (influencerFieldValue, influencerFieldName) => {
|
||||
this.influencers.push({
|
||||
influencerFieldName,
|
||||
influencerFieldValue
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Allow one more influencer than the supplied limit as displaying
|
||||
// 'and 1 more' would take up an extra line.
|
||||
const showAll = this.influencers.length <= (this.limit + 1);
|
||||
this.state = {
|
||||
showAll
|
||||
showAll: false
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -45,36 +29,42 @@ export class InfluencersCell extends Component {
|
|||
this.setState({ showAll: !this.state.showAll });
|
||||
}
|
||||
|
||||
renderInfluencers() {
|
||||
const numberToDisplay = this.state.showAll === false ? this.limit : this.influencers.length;
|
||||
const displayInfluencers = this.influencers.slice(0, numberToDisplay);
|
||||
renderInfluencers(influencers) {
|
||||
const numberToDisplay = (this.state.showAll === false) ? this.props.limit : influencers.length;
|
||||
const displayInfluencers = influencers.slice(0, numberToDisplay);
|
||||
|
||||
this.othersCount = Math.max(this.influencers.length - numberToDisplay, 0);
|
||||
if (this.othersCount === 1) {
|
||||
let othersCount = Math.max(influencers.length - numberToDisplay, 0);
|
||||
if (othersCount === 1) {
|
||||
// Display the additional influencer.
|
||||
displayInfluencers.push(this.influencers[this.limit]);
|
||||
this.othersCount = 0;
|
||||
displayInfluencers.push(influencers[this.props.limit]);
|
||||
othersCount = 0;
|
||||
}
|
||||
|
||||
return displayInfluencers.map((influencer, index) => {
|
||||
return (
|
||||
<div key={index}>{influencer.influencerFieldName}: {influencer.influencerFieldValue}</div>
|
||||
);
|
||||
});
|
||||
const displayRows = displayInfluencers.map((influencer, index) => (
|
||||
<div key={index}>{influencer.influencerFieldName}: {influencer.influencerFieldValue}</div>
|
||||
));
|
||||
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{displayRows}
|
||||
{this.renderOthers(influencers.length, othersCount)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
renderOthers() {
|
||||
if (this.othersCount > 0) {
|
||||
renderOthers(totalCount, othersCount) {
|
||||
if (othersCount > 0) {
|
||||
return (
|
||||
<div>
|
||||
<EuiLink
|
||||
onClick={() => this.toggleAllInfluencers()}
|
||||
>
|
||||
and {this.othersCount} more
|
||||
and {othersCount} more
|
||||
</EuiLink>
|
||||
</div>
|
||||
);
|
||||
} else if (this.influencers.length > this.limit + 1) {
|
||||
} else if (totalCount > this.props.limit + 1) {
|
||||
return (
|
||||
<div>
|
||||
<EuiLink
|
||||
|
@ -88,10 +78,22 @@ export class InfluencersCell extends Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
const recordInfluencers = this.props.influencers || [];
|
||||
|
||||
const influencers = [];
|
||||
recordInfluencers.forEach((influencer) => {
|
||||
_.each(influencer, (influencerFieldValue, influencerFieldName) => {
|
||||
influencers.push({
|
||||
influencerFieldName,
|
||||
influencerFieldValue
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{this.renderInfluencers()}
|
||||
{this.renderOthers()}
|
||||
{this.renderInfluencers(influencers)}
|
||||
{this.renderOthers(influencers)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,18 +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 { InfluencersCell } from './influencers_cell';
|
||||
|
||||
import { uiModules } from 'ui/modules';
|
||||
const module = uiModules.get('apps/ml', ['react']);
|
||||
module.directive('mlInfluencersCell', function (reactDirective) {
|
||||
return reactDirective(InfluencersCell, undefined, { restrict: 'E' });
|
||||
});
|
|
@ -0,0 +1,414 @@
|
|||
/*
|
||||
* 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 _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
import rison from 'rison-node';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import {
|
||||
EuiButtonEmpty,
|
||||
EuiContextMenuPanel,
|
||||
EuiContextMenuItem,
|
||||
EuiPopover
|
||||
} from '@elastic/eui';
|
||||
|
||||
import 'ui/timefilter';
|
||||
import chrome from 'ui/chrome';
|
||||
import { toastNotifications } from 'ui/notify';
|
||||
|
||||
import { ES_FIELD_TYPES } from 'plugins/ml/../common/constants/field_types';
|
||||
import { parseInterval } from 'plugins/ml/../common/util/parse_interval';
|
||||
import { getFieldTypeFromMapping } from 'plugins/ml/services/mapping_service';
|
||||
import { ml } from 'plugins/ml/services/ml_api_service';
|
||||
import { mlJobService } from 'plugins/ml/services/job_service';
|
||||
import { getUrlForRecord } from 'plugins/ml/util/custom_url_utils';
|
||||
import { getIndexPatterns } from 'plugins/ml/util/index_utils';
|
||||
import { replaceStringTokens } from 'plugins/ml/util/string_utils';
|
||||
|
||||
|
||||
/*
|
||||
* Component for rendering the links menu inside a cell in the anomalies table.
|
||||
*/
|
||||
export class LinksMenu extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
isPopoverOpen: false,
|
||||
toasts: []
|
||||
};
|
||||
}
|
||||
|
||||
openCustomUrl = (customUrl) => {
|
||||
const { anomaly, interval, isAggregatedData } = this.props;
|
||||
|
||||
console.log('Anomalies Table - open customUrl for record:', anomaly);
|
||||
|
||||
// If url_value contains $earliest$ and $latest$ tokens, add in times to the source record.
|
||||
// Create a copy of the record as we are adding properties into it.
|
||||
const record = _.cloneDeep(anomaly.source);
|
||||
const timestamp = record.timestamp;
|
||||
const configuredUrlValue = customUrl.url_value;
|
||||
const timeRangeInterval = parseInterval(customUrl.time_range);
|
||||
if (configuredUrlValue.includes('$earliest$')) {
|
||||
let earliestMoment = moment(timestamp);
|
||||
if (timeRangeInterval !== null) {
|
||||
earliestMoment.subtract(timeRangeInterval);
|
||||
} else {
|
||||
earliestMoment = moment(timestamp).startOf(interval);
|
||||
if (interval === 'hour') {
|
||||
// Start from the previous hour.
|
||||
earliestMoment.subtract(1, 'h');
|
||||
}
|
||||
}
|
||||
record.earliest = earliestMoment.toISOString(); // e.g. 2016-02-08T16:00:00.000Z
|
||||
}
|
||||
|
||||
if (configuredUrlValue.includes('$latest$')) {
|
||||
let latestMoment = moment(timestamp).add(record.bucket_span, 's');
|
||||
if (timeRangeInterval !== null) {
|
||||
latestMoment.add(timeRangeInterval);
|
||||
} else {
|
||||
if (isAggregatedData === true) {
|
||||
latestMoment = moment(timestamp).endOf(interval);
|
||||
if (interval === 'hour') {
|
||||
// Show to the end of the next hour.
|
||||
latestMoment.add(1, 'h'); // e.g. 2016-02-08T18:59:59.999Z
|
||||
}
|
||||
}
|
||||
}
|
||||
record.latest = latestMoment.toISOString();
|
||||
}
|
||||
|
||||
// If url_value contains $mlcategoryterms$ or $mlcategoryregex$, add in the
|
||||
// terms and regex for the selected categoryId to the source record.
|
||||
if ((configuredUrlValue.includes('$mlcategoryterms$') || configuredUrlValue.includes('$mlcategoryregex$'))
|
||||
&& _.has(record, 'mlcategory')) {
|
||||
const jobId = record.job_id;
|
||||
|
||||
// mlcategory in the source record will be an array
|
||||
// - use first value (will only ever be more than one if influenced by category other than by/partition/over).
|
||||
const categoryId = record.mlcategory[0];
|
||||
|
||||
ml.results.getCategoryDefinition(jobId, categoryId)
|
||||
.then((resp) => {
|
||||
// Prefix each of the terms with '+' so that the Elasticsearch Query String query
|
||||
// run in a drilldown Kibana dashboard has to match on all terms.
|
||||
const termsArray = resp.terms.split(' ').map(term => `+${term}`);
|
||||
record.mlcategoryterms = termsArray.join(' ');
|
||||
record.mlcategoryregex = resp.regex;
|
||||
|
||||
// Replace any tokens in the configured url_value with values from the source record,
|
||||
// and then open link in a new tab/window.
|
||||
const urlPath = replaceStringTokens(customUrl.url_value, record, true);
|
||||
window.open(urlPath, '_blank');
|
||||
|
||||
}).catch((resp) => {
|
||||
console.log('openCustomUrl(): error loading categoryDefinition:', resp);
|
||||
toastNotifications.addDanger(
|
||||
`Unable to open link as an error occurred loading details on category ID ${categoryId}`);
|
||||
});
|
||||
|
||||
} else {
|
||||
// Replace any tokens in the configured url_value with values from the source record,
|
||||
// and then open link in a new tab/window.
|
||||
const urlPath = getUrlForRecord(customUrl, record);
|
||||
window.open(urlPath, '_blank');
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
viewSeries = () => {
|
||||
const record = this.props.anomaly.source;
|
||||
const bounds = this.props.timefilter.getActiveBounds();
|
||||
const from = bounds.min.toISOString(); // e.g. 2016-02-08T16:00:00.000Z
|
||||
const to = bounds.max.toISOString();
|
||||
|
||||
// Zoom to show 50 buckets either side of the record.
|
||||
const recordTime = moment(record.timestamp);
|
||||
const zoomFrom = recordTime.subtract(50 * record.bucket_span, 's').toISOString();
|
||||
const zoomTo = recordTime.add(100 * record.bucket_span, 's').toISOString();
|
||||
|
||||
// Extract the by, over and partition fields for the record.
|
||||
const entityCondition = {};
|
||||
|
||||
if (_.has(record, 'partition_field_value')) {
|
||||
entityCondition[record.partition_field_name] = record.partition_field_value;
|
||||
}
|
||||
|
||||
if (_.has(record, 'over_field_value')) {
|
||||
entityCondition[record.over_field_name] = record.over_field_value;
|
||||
}
|
||||
|
||||
if (_.has(record, 'by_field_value')) {
|
||||
// Note that analyses with by and over fields, will have a top-level by_field_name,
|
||||
// but the by_field_value(s) will be in the nested causes array.
|
||||
// TODO - drilldown from cause in expanded row only?
|
||||
entityCondition[record.by_field_name] = record.by_field_value;
|
||||
}
|
||||
|
||||
// Use rison to build the URL .
|
||||
const _g = rison.encode({
|
||||
ml: {
|
||||
jobIds: [record.job_id]
|
||||
},
|
||||
refreshInterval: {
|
||||
display: 'Off',
|
||||
pause: false,
|
||||
value: 0
|
||||
},
|
||||
time: {
|
||||
from: from,
|
||||
to: to,
|
||||
mode: 'absolute'
|
||||
}
|
||||
});
|
||||
|
||||
const _a = rison.encode({
|
||||
mlTimeSeriesExplorer: {
|
||||
zoom: {
|
||||
from: zoomFrom,
|
||||
to: zoomTo
|
||||
},
|
||||
detectorIndex: record.detector_index,
|
||||
entities: entityCondition,
|
||||
},
|
||||
filters: [],
|
||||
query: {
|
||||
query_string: {
|
||||
analyze_wildcard: true,
|
||||
query: '*'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Need to encode the _a parameter in case any entities contain unsafe characters such as '+'.
|
||||
let path = `${chrome.getBasePath()}/app/ml#/timeseriesexplorer`;
|
||||
path += `?_g=${_g}&_a=${encodeURIComponent(_a)}`;
|
||||
window.open(path, '_blank');
|
||||
}
|
||||
|
||||
viewExamples = () => {
|
||||
const categoryId = this.props.anomaly.entityValue;
|
||||
const record = this.props.anomaly.source;
|
||||
const indexPatterns = getIndexPatterns();
|
||||
|
||||
const job = mlJobService.getJob(this.props.anomaly.jobId);
|
||||
if (job === undefined) {
|
||||
console.log(`viewExamples(): no job found with ID: ${this.props.anomaly.jobId}`);
|
||||
toastNotifications.addDanger(
|
||||
`Unable to view examples as no details could be found for job ID ${this.props.anomaly.jobId}`);
|
||||
return;
|
||||
}
|
||||
const categorizationFieldName = job.analysis_config.categorization_field_name;
|
||||
const datafeedIndices = job.datafeed_config.indices;
|
||||
// Find the type of the categorization field i.e. text (preferred) or keyword.
|
||||
// Uses the first matching field found in the list of indices in the datafeed_config.
|
||||
// attempt to load the field type using each index. we have to do it this way as _field_caps
|
||||
// doesn't specify which index a field came from unless there is a clash.
|
||||
let i = 0;
|
||||
findFieldType(datafeedIndices[i]);
|
||||
|
||||
function findFieldType(index) {
|
||||
getFieldTypeFromMapping(index, categorizationFieldName)
|
||||
.then((resp) => {
|
||||
if (resp !== '') {
|
||||
createAndOpenUrl(index, resp);
|
||||
} else {
|
||||
i++;
|
||||
if (i < datafeedIndices.length) {
|
||||
findFieldType(datafeedIndices[i]);
|
||||
} else {
|
||||
error();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
error();
|
||||
});
|
||||
}
|
||||
|
||||
function createAndOpenUrl(index, categorizationFieldType) {
|
||||
// Find the ID of the index pattern with a title attribute which matches the
|
||||
// index configured in the datafeed. If a Kibana index pattern has not been created
|
||||
// for this index, then the user will see a warning message on the Discover tab advising
|
||||
// them that no matching index pattern has been configured.
|
||||
let indexPatternId = index;
|
||||
for (let j = 0; j < indexPatterns.length; j++) {
|
||||
if (indexPatterns[j].get('title') === index) {
|
||||
indexPatternId = indexPatterns[j].id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the definition of the category and use the terms or regex to view the
|
||||
// matching events in the Kibana Discover tab depending on whether the
|
||||
// categorization field is of mapping type text (preferred) or keyword.
|
||||
ml.results.getCategoryDefinition(record.job_id, categoryId)
|
||||
.then((resp) => {
|
||||
let query = null;
|
||||
// Build query using categorization regex (if keyword type) or terms (if text type).
|
||||
// Check for terms or regex in case categoryId represents an anomaly from the absence of the
|
||||
// categorization field in documents (usually indicated by a categoryId of -1).
|
||||
if (categorizationFieldType === ES_FIELD_TYPES.KEYWORD) {
|
||||
if (resp.regex) {
|
||||
query = `${categorizationFieldName}:/${resp.regex}/`;
|
||||
}
|
||||
} else {
|
||||
if (resp.terms) {
|
||||
query = `${categorizationFieldName}:` + resp.terms.split(' ').join(` AND ${categorizationFieldName}:`);
|
||||
}
|
||||
}
|
||||
|
||||
const recordTime = moment(record.timestamp);
|
||||
const from = recordTime.toISOString();
|
||||
const to = recordTime.add(record.bucket_span, 's').toISOString();
|
||||
|
||||
// Use rison to build the URL .
|
||||
const _g = rison.encode({
|
||||
refreshInterval: {
|
||||
display: 'Off',
|
||||
pause: false,
|
||||
value: 0
|
||||
},
|
||||
time: {
|
||||
from: from,
|
||||
to: to,
|
||||
mode: 'absolute'
|
||||
}
|
||||
});
|
||||
|
||||
const appStateProps = {
|
||||
index: indexPatternId,
|
||||
filters: []
|
||||
};
|
||||
if (query !== null) {
|
||||
appStateProps.query = {
|
||||
query_string: {
|
||||
analyze_wildcard: true,
|
||||
query: query
|
||||
}
|
||||
};
|
||||
}
|
||||
const _a = rison.encode(appStateProps);
|
||||
|
||||
// Need to encode the _a parameter as it will contain characters such as '+' if using the regex.
|
||||
let path = chrome.getBasePath();
|
||||
path += '/app/kibana#/discover';
|
||||
path += '?_g=' + _g;
|
||||
path += '&_a=' + encodeURIComponent(_a);
|
||||
window.open(path, '_blank');
|
||||
|
||||
}).catch((resp) => {
|
||||
console.log('viewExamples(): error loading categoryDefinition:', resp);
|
||||
toastNotifications.addDanger(
|
||||
`Unable to view examples as an error occurred loading details on category ID ${categoryId}`);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function error() {
|
||||
console.log(`viewExamples(): error finding type of field ${categorizationFieldName} in indices:`,
|
||||
datafeedIndices);
|
||||
toastNotifications.addDanger(
|
||||
`Unable to view examples of documents with mlcategory ${categoryId} ` +
|
||||
`as no mapping could be found for the categorization field ${categorizationFieldName}`);
|
||||
}
|
||||
};
|
||||
|
||||
onButtonClick = () => {
|
||||
this.setState(prevState => ({
|
||||
isPopoverOpen: !prevState.isPopoverOpen,
|
||||
}));
|
||||
};
|
||||
|
||||
closePopover = () => {
|
||||
this.setState({
|
||||
isPopoverOpen: false,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { anomaly, showViewSeriesLink } = this.props;
|
||||
|
||||
const button = (
|
||||
<EuiButtonEmpty
|
||||
size="s"
|
||||
type="text"
|
||||
iconType="arrowDown"
|
||||
iconSide="right"
|
||||
onClick={this.onButtonClick}
|
||||
>
|
||||
Open link
|
||||
</EuiButtonEmpty>
|
||||
);
|
||||
|
||||
const items = [];
|
||||
if (anomaly.customUrls !== undefined) {
|
||||
anomaly.customUrls.forEach((customUrl, index) => {
|
||||
items.push(
|
||||
<EuiContextMenuItem
|
||||
key={`custom_url_${index}`}
|
||||
icon="popout"
|
||||
onClick={() => { this.closePopover(); this.openCustomUrl(customUrl); }}
|
||||
>
|
||||
{customUrl.url_name}
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (showViewSeriesLink === true && anomaly.isTimeSeriesViewDetector === true) {
|
||||
items.push(
|
||||
<EuiContextMenuItem
|
||||
key="view_series"
|
||||
icon="popout"
|
||||
onClick={() => { this.closePopover(); this.viewSeries(); }}
|
||||
>
|
||||
View series
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (anomaly.entityName === 'mlcategory') {
|
||||
items.push(
|
||||
<EuiContextMenuItem
|
||||
key="view_examples"
|
||||
icon="popout"
|
||||
onClick={() => { this.closePopover(); this.viewExamples(); }}
|
||||
>
|
||||
View examples
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
id="singlePanel"
|
||||
button={button}
|
||||
isOpen={this.state.isPopoverOpen}
|
||||
closePopover={this.closePopover}
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downLeft"
|
||||
>
|
||||
<EuiContextMenuPanel
|
||||
items={items}
|
||||
/>
|
||||
</EuiPopover>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
LinksMenu.propTypes = {
|
||||
anomaly: PropTypes.object.isRequired,
|
||||
showViewSeriesLink: PropTypes.bool,
|
||||
isAggregatedData: PropTypes.bool,
|
||||
interval: PropTypes.string,
|
||||
timefilter: PropTypes.object.isRequired
|
||||
};
|
|
@ -1,126 +1,114 @@
|
|||
ml-anomalies-table {
|
||||
|
||||
.anomalies-table {
|
||||
|
||||
padding: 10px;
|
||||
|
||||
.no-results-item {
|
||||
text-align: center;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-bottom: 35px;
|
||||
|
||||
td, th {
|
||||
color: #2d2d2d;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
|
||||
.kuiButton.dropdown-toggle {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.fa.fa-arrow-up, .fa.fa-arrow-down {
|
||||
font-size: 11px;
|
||||
color: #686868;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
}
|
||||
button:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
button {
|
||||
font-family: inherit;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
button {
|
||||
background-color: #FFFFFF;
|
||||
padding: 0px 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.agg-table-paginated {
|
||||
overflow-x: auto;
|
||||
.dropdown-menu {
|
||||
left: -60px;
|
||||
}
|
||||
}
|
||||
|
||||
.ml-anomaly-interim-result {
|
||||
font-style:italic;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
.ml-tablerow-expanded {
|
||||
width: 100%;
|
||||
padding: 5px 20px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
|
||||
table {
|
||||
td {
|
||||
padding: 0px 0px 2px 0px;
|
||||
button {
|
||||
padding: 0px 0px;
|
||||
}
|
||||
}
|
||||
|
||||
tr>td:first-child {
|
||||
padding-left: 2px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
td:nth-child(2) {
|
||||
padding-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.ml-tablerow-expanded-heading {
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.ml-tablerow-expanded-heading:first-child {
|
||||
padding-top: 0px;
|
||||
}
|
||||
|
||||
.ml-tablerow-expanded-mv-description {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ml-anomalies-table {
|
||||
.ml-icon-severity-critical,
|
||||
.ml-icon-severity-major,
|
||||
.ml-icon-severity-minor,
|
||||
.ml-icon-severity-warning,
|
||||
.ml-icon-severity-unknown {
|
||||
color: inherit;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
|
||||
.ml-icon-severity-critical {
|
||||
.euiIcon {
|
||||
fill: #fe5050;
|
||||
}
|
||||
}
|
||||
|
||||
.ml-icon-severity-major {
|
||||
.euiIcon {
|
||||
fill: #fba740;
|
||||
}
|
||||
}
|
||||
|
||||
.ml-icon-severity-minor {
|
||||
.euiIcon {
|
||||
fill: #fdec25;
|
||||
}
|
||||
}
|
||||
|
||||
.ml-icon-severity-warning {
|
||||
.euiIcon {
|
||||
fill: #8bc8fb;
|
||||
}
|
||||
}
|
||||
|
||||
.ml-icon-severity-unknown {
|
||||
.euiIcon {
|
||||
fill: #c0c0c0;
|
||||
}
|
||||
}
|
||||
|
||||
tr th:first-child,
|
||||
tr td:first-child {
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.euiTableCellContent {
|
||||
.euiHealth {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
opacity: 0.3;
|
||||
width: 20px;
|
||||
padding-top: 2px;
|
||||
|
||||
.euiIcon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.euiContextMenuItem {
|
||||
min-width: 150px
|
||||
}
|
||||
|
||||
.category-example {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.interim-result {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ml-anomalies-table-details {
|
||||
padding: 4px 32px;
|
||||
max-height: 1000px;
|
||||
overflow-y: auto;
|
||||
|
||||
.anomaly-description-list {
|
||||
|
||||
.euiDescriptionList__title {
|
||||
margin-top: 0px;
|
||||
flex-basis: 15%;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.euiDescriptionList__description {
|
||||
margin-top: 0px;
|
||||
flex-basis: 85%;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
height: 20px;
|
||||
padding-top: 2px;
|
||||
|
||||
.euiButtonIcon__icon {
|
||||
-webkit-transform: translateY(-7px);
|
||||
transform: translateY(-7px);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.ml-anomalies-table.dropdown-menu {
|
||||
min-width: 120px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ml-anomalies-table.dropdown-menu > li > a {
|
||||
color: #444444;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ml-anomalies-table.dropdown-menu > li > a:hover,
|
||||
.ml-anomalies-table.dropdown-menu > li > a:active,
|
||||
.ml-anomalies-table.dropdown-menu > li > a:focus {
|
||||
color: #ffffff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
|
|
@ -141,13 +141,10 @@
|
|||
</ml-explorer-charts-container>
|
||||
</div>
|
||||
|
||||
<div class="euiText">
|
||||
<ml-anomalies-table
|
||||
anomaly-records="anomalyRecords"
|
||||
time-field-name="timeFieldName"
|
||||
show-view-series-link="true">
|
||||
</ml-anomalies-table>
|
||||
</div>
|
||||
|
||||
<ml-anomalies-table
|
||||
table-data="tableData"
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ import DragSelect from 'dragselect';
|
|||
import moment from 'moment';
|
||||
|
||||
import 'plugins/ml/components/anomalies_table';
|
||||
import 'plugins/ml/components/controls';
|
||||
import 'plugins/ml/components/influencers_list';
|
||||
import 'plugins/ml/components/job_select_list';
|
||||
|
||||
|
@ -32,10 +33,12 @@ import { checkGetJobsPrivilege } from 'plugins/ml/privilege/check_privilege';
|
|||
import { loadIndexPatterns, getIndexPatterns } from 'plugins/ml/util/index_utils';
|
||||
import { refreshIntervalWatcher } from 'plugins/ml/util/refresh_interval_watcher';
|
||||
import { IntervalHelperProvider, getBoundsRoundedToInterval } from 'plugins/ml/util/ml_time_buckets';
|
||||
import { ml } from 'plugins/ml/services/ml_api_service';
|
||||
import { mlResultsService } from 'plugins/ml/services/results_service';
|
||||
import { mlJobService } from 'plugins/ml/services/job_service';
|
||||
import { mlFieldFormatService } from 'plugins/ml/services/field_format_service';
|
||||
import { JobSelectServiceProvider } from 'plugins/ml/components/job_select_list/job_select_service';
|
||||
import { isTimeSeriesViewDetector } from 'plugins/ml/../common/util/job_utils';
|
||||
|
||||
uiRoutes
|
||||
.when('/explorer/?', {
|
||||
|
@ -60,6 +63,7 @@ module.controller('MlExplorerController', function (
|
|||
mlCheckboxShowChartsService,
|
||||
mlExplorerDashboardService,
|
||||
mlSelectLimitService,
|
||||
mlSelectIntervalService,
|
||||
mlSelectSeverityService) {
|
||||
|
||||
$scope.timeFieldName = 'timestamp';
|
||||
|
@ -75,12 +79,12 @@ module.controller('MlExplorerController', function (
|
|||
|
||||
const $mlExplorer = $('.ml-explorer');
|
||||
const MAX_INFLUENCER_FIELD_VALUES = 10;
|
||||
const MAX_CATEGORY_EXAMPLES = 10;
|
||||
const VIEW_BY_JOB_LABEL = 'job ID';
|
||||
|
||||
const ALLOW_CELL_RANGE_SELECTION = mlExplorerDashboardService.allowCellRangeSelection;
|
||||
let disableDragSelectOnMouseLeave = true;
|
||||
$scope.queryFilters = [];
|
||||
$scope.anomalyRecords = [];
|
||||
|
||||
const dragSelect = new DragSelect({
|
||||
selectables: document.querySelectorAll('.sl-cell'),
|
||||
|
@ -309,18 +313,36 @@ module.controller('MlExplorerController', function (
|
|||
// Returns the time range of the cell(s) currently selected in the swimlane.
|
||||
// If no cell(s) are currently selected, returns the dashboard time range.
|
||||
const bounds = timefilter.getActiveBounds();
|
||||
|
||||
// time property of the cell data is an array, with the elements being
|
||||
// the start times of the first and last cell selected.
|
||||
const earliestMs = cellData.time[0] !== undefined ? ((cellData.time[0]) * 1000) : bounds.min.valueOf();
|
||||
let earliestMs = bounds.min.valueOf();
|
||||
let latestMs = bounds.max.valueOf();
|
||||
if (cellData.time[1] !== undefined) {
|
||||
// Subtract 1 ms so search does not include start of next bucket.
|
||||
latestMs = ((cellData.time[1] + cellData.interval) * 1000) - 1;
|
||||
|
||||
if (cellData !== undefined && cellData.time !== undefined) {
|
||||
// time property of the cell data is an array, with the elements being
|
||||
// the start times of the first and last cell selected.
|
||||
earliestMs = (cellData.time[0] !== undefined) ? cellData.time[0] * 1000 : bounds.min.valueOf();
|
||||
latestMs = bounds.max.valueOf();
|
||||
if (cellData.time[1] !== undefined) {
|
||||
// Subtract 1 ms so search does not include start of next bucket.
|
||||
latestMs = ((cellData.time[1] + cellData.interval) * 1000) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { earliestMs, latestMs };
|
||||
}
|
||||
|
||||
function getSelectionInfluencers(cellData) {
|
||||
const influencers = [];
|
||||
|
||||
if (cellData !== undefined && cellData.fieldName !== undefined &&
|
||||
cellData.fieldName !== VIEW_BY_JOB_LABEL) {
|
||||
cellData.laneLabels.forEach((laneLabel) =>{
|
||||
influencers.push({ fieldName: $scope.swimlaneViewByFieldName, fieldValue: laneLabel });
|
||||
});
|
||||
}
|
||||
|
||||
return influencers;
|
||||
}
|
||||
|
||||
// Listener for click events in the swimlane and load corresponding anomaly data.
|
||||
// Empty cellData is passed on clicking outside a cell with score > 0.
|
||||
const swimlaneCellClickListener = function (cellData) {
|
||||
|
@ -332,8 +354,6 @@ module.controller('MlExplorerController', function (
|
|||
}
|
||||
clearSelectedAnomalies();
|
||||
} else {
|
||||
let jobIds = [];
|
||||
const influencers = [];
|
||||
const timerange = getSelectionTimeRange(cellData);
|
||||
|
||||
if (cellData.fieldName === undefined) {
|
||||
|
@ -343,22 +363,15 @@ module.controller('MlExplorerController', function (
|
|||
$scope.viewByLoadedForTimeFormatted = moment(timerange.earliestMs).format('MMMM Do YYYY, HH:mm');
|
||||
}
|
||||
|
||||
if (cellData.fieldName === VIEW_BY_JOB_LABEL) {
|
||||
jobIds = cellData.laneLabels;
|
||||
} else {
|
||||
jobIds = $scope.getSelectedJobIds();
|
||||
|
||||
if (cellData.fieldName !== undefined) {
|
||||
cellData.laneLabels.forEach((laneLabel) =>{
|
||||
influencers.push({ fieldName: $scope.swimlaneViewByFieldName, fieldValue: laneLabel });
|
||||
});
|
||||
}
|
||||
}
|
||||
const jobIds = (cellData.fieldName === VIEW_BY_JOB_LABEL) ?
|
||||
cellData.laneLabels : $scope.getSelectedJobIds();
|
||||
const influencers = getSelectionInfluencers(cellData);
|
||||
|
||||
$scope.cellData = cellData;
|
||||
loadAnomaliesTableData();
|
||||
|
||||
const args = [jobIds, influencers, timerange.earliestMs, timerange.latestMs];
|
||||
loadAnomalies(...args);
|
||||
$scope.loadAnomaliesForCharts(...args);
|
||||
loadDataForCharts(...args);
|
||||
}
|
||||
};
|
||||
mlExplorerDashboardService.swimlaneCellClick.watch(swimlaneCellClickListener);
|
||||
|
@ -387,6 +400,12 @@ module.controller('MlExplorerController', function (
|
|||
};
|
||||
mlSelectSeverityService.state.watch(anomalyChartsSeverityListener);
|
||||
|
||||
const tableControlsListener = function () {
|
||||
loadAnomaliesTableData();
|
||||
};
|
||||
mlSelectIntervalService.state.watch(tableControlsListener);
|
||||
mlSelectSeverityService.state.watch(tableControlsListener);
|
||||
|
||||
const swimlaneLimitListener = function () {
|
||||
loadViewBySwimlane([]);
|
||||
clearSelectedAnomalies();
|
||||
|
@ -405,42 +424,37 @@ module.controller('MlExplorerController', function (
|
|||
mlExplorerDashboardService.swimlaneCellClick.unwatch(swimlaneCellClickListener);
|
||||
mlExplorerDashboardService.swimlaneRenderDone.unwatch(swimlaneRenderDoneListener);
|
||||
mlSelectSeverityService.state.unwatch(anomalyChartsSeverityListener);
|
||||
mlSelectIntervalService.state.unwatch(tableControlsListener);
|
||||
mlSelectSeverityService.state.unwatch(tableControlsListener);
|
||||
mlSelectLimitService.state.unwatch(swimlaneLimitListener);
|
||||
$scope.cellData = undefined;
|
||||
delete $scope.cellData;
|
||||
refreshWatcher.cancel();
|
||||
// Cancel listening for updates to the global nav state.
|
||||
navListener();
|
||||
});
|
||||
|
||||
$scope.loadAnomaliesForCharts = function (jobIds, influencers, earliestMs, latestMs) {
|
||||
// Load the top anomalies (by record_score) which will be displayed in the charts.
|
||||
// TODO - combine this with loadAnomalies().
|
||||
mlResultsService.getRecordsForInfluencer(
|
||||
jobIds, influencers, 0, earliestMs, latestMs, 500
|
||||
).then((resp) => {
|
||||
$scope.anomalyChartRecords = resp.records;
|
||||
console.log('Explorer anomaly charts data set:', $scope.anomalyChartRecords);
|
||||
|
||||
if (mlCheckboxShowChartsService.state.get('showCharts')) {
|
||||
mlExplorerDashboardService.anomalyDataChange.changed(
|
||||
$scope.anomalyChartRecords, earliestMs, latestMs
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function loadAnomalies(jobIds, influencers, earliestMs, latestMs) {
|
||||
// Loads the anomalies for the table, plus the scores for
|
||||
// the Top Influencers List for the influencers in the anomaly records.
|
||||
|
||||
function loadDataForCharts(jobIds, influencers, earliestMs, latestMs) {
|
||||
// Loads the data used to populate the anomaly charts and the Top Influencers List.
|
||||
if (influencers.length === 0) {
|
||||
getTopInfluencers(jobIds, earliestMs, latestMs);
|
||||
}
|
||||
|
||||
// Load the top anomalies (by record_score) which will be displayed in the charts.
|
||||
mlResultsService.getRecordsForInfluencer(
|
||||
jobIds, influencers, 0, earliestMs, latestMs, 500
|
||||
)
|
||||
.then((resp) => {
|
||||
if ($scope.cellData !== undefined && _.keys($scope.cellData).length > 0) {
|
||||
$scope.anomalyChartRecords = resp.records;
|
||||
console.log('Explorer anomaly charts data set:', $scope.anomalyChartRecords);
|
||||
|
||||
if (mlCheckboxShowChartsService.state.get('showCharts')) {
|
||||
mlExplorerDashboardService.anomalyDataChange.changed(
|
||||
$scope.anomalyChartRecords, earliestMs, latestMs
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (influencers.length > 0) {
|
||||
// Filter the Top Influencers list to show just the influencers from
|
||||
// the records in the selected time range.
|
||||
|
@ -483,13 +497,6 @@ module.controller('MlExplorerController', function (
|
|||
|
||||
getTopInfluencers(jobIds, earliestMs, latestMs, filterInfluencers);
|
||||
}
|
||||
|
||||
// Use $evalAsync to ensure the update happens after the child scope is updated with the new data.
|
||||
$scope.$evalAsync(() => {
|
||||
// Sort in descending time order before storing in scope.
|
||||
$scope.anomalyRecords = _.chain(resp.records).sortBy(record => record[$scope.timeFieldName]).reverse().value();
|
||||
console.log('Explorer anomalies table data set:', $scope.anomalyRecords);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -767,10 +774,62 @@ module.controller('MlExplorerController', function (
|
|||
}
|
||||
}
|
||||
|
||||
function loadAnomaliesTableData() {
|
||||
const cellData = $scope.cellData;
|
||||
const jobIds = ($scope.cellData !== undefined && cellData.fieldName === VIEW_BY_JOB_LABEL) ?
|
||||
cellData.laneLabels : $scope.getSelectedJobIds();
|
||||
const influencers = getSelectionInfluencers(cellData);
|
||||
const timeRange = getSelectionTimeRange(cellData);
|
||||
|
||||
ml.results.getAnomaliesTableData(
|
||||
jobIds,
|
||||
[],
|
||||
influencers,
|
||||
mlSelectIntervalService.state.get('interval').val,
|
||||
mlSelectSeverityService.state.get('threshold').val,
|
||||
timeRange.earliestMs,
|
||||
timeRange.latestMs,
|
||||
500,
|
||||
MAX_CATEGORY_EXAMPLES
|
||||
).then((resp) => {
|
||||
const anomalies = resp.anomalies;
|
||||
const detectorsByJob = mlJobService.detectorsByJob;
|
||||
anomalies.forEach((anomaly) => {
|
||||
// Add a detector property to each anomaly.
|
||||
// Default to functionDescription if no description available.
|
||||
// TODO - when job_service is moved server_side, move this to server endpoint.
|
||||
const jobId = anomaly.jobId;
|
||||
anomaly.detector = _.get(detectorsByJob,
|
||||
[jobId, anomaly.detectorIndex, 'detector_description'],
|
||||
anomaly.source.function_description);
|
||||
|
||||
// Add properties used for building the links menu.
|
||||
// TODO - when job_service is moved server_side, move this to server endpoint.
|
||||
anomaly.isTimeSeriesViewDetector = isTimeSeriesViewDetector(
|
||||
mlJobService.getJob(jobId), anomaly.detectorIndex);
|
||||
if (_.has(mlJobService.customUrlsByJob, jobId)) {
|
||||
anomaly.customUrls = mlJobService.customUrlsByJob[jobId];
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$evalAsync(() => {
|
||||
$scope.tableData = {
|
||||
anomalies,
|
||||
interval: resp.interval,
|
||||
examplesByJobId: resp.examplesByJobId,
|
||||
showViewSeriesLink: true
|
||||
};
|
||||
});
|
||||
|
||||
}).catch((resp) => {
|
||||
console.log('Explorer - error loading data for anomalies table:', resp);
|
||||
});
|
||||
}
|
||||
|
||||
function clearSelectedAnomalies() {
|
||||
$scope.anomalyChartRecords = [];
|
||||
$scope.anomalyRecords = [];
|
||||
$scope.viewByLoadedForTimeFormatted = null;
|
||||
delete $scope.cellData;
|
||||
|
||||
// With no swimlane selection, display anomalies over all time in the table.
|
||||
const jobIds = $scope.getSelectedJobIds();
|
||||
|
@ -778,7 +837,8 @@ module.controller('MlExplorerController', function (
|
|||
const earliestMs = bounds.min.valueOf();
|
||||
const latestMs = bounds.max.valueOf();
|
||||
mlExplorerDashboardService.anomalyDataChange.changed($scope.anomalyChartRecords, earliestMs, latestMs);
|
||||
loadAnomalies(jobIds, [], earliestMs, latestMs);
|
||||
loadDataForCharts(jobIds, [], earliestMs, latestMs);
|
||||
loadAnomaliesTableData();
|
||||
}
|
||||
|
||||
function calculateSwimlaneBucketInterval() {
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
width: 100%;
|
||||
display: inline-block;
|
||||
color: #555;
|
||||
font-size: 10px;
|
||||
|
||||
.visualize-error {
|
||||
h4 {
|
||||
|
@ -333,3 +332,5 @@
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@ const basePath = chrome.addBasePath('/api/ml');
|
|||
export const results = {
|
||||
getAnomaliesTableData(
|
||||
jobIds,
|
||||
criteriaFields,
|
||||
influencers,
|
||||
aggregationInterval,
|
||||
threshold,
|
||||
|
@ -28,6 +29,7 @@ export const results = {
|
|||
method: 'POST',
|
||||
data: {
|
||||
jobIds,
|
||||
criteriaFields,
|
||||
influencers,
|
||||
aggregationInterval,
|
||||
threshold,
|
||||
|
|
|
@ -28,6 +28,7 @@ import {
|
|||
numTicksForDateFormat
|
||||
} from 'plugins/ml/util/chart_utils';
|
||||
import { TimeBuckets } from 'ui/time_buckets';
|
||||
import { mlAnomaliesTableService } from 'plugins/ml/components/anomalies_table/anomalies_table_service';
|
||||
import ContextChartMask from 'plugins/ml/timeseriesexplorer/context_chart_mask';
|
||||
import { findNearestChartPointToTime } from 'plugins/ml/timeseriesexplorer/timeseriesexplorer_utils';
|
||||
import { mlEscape } from 'plugins/ml/util/string_utils';
|
||||
|
@ -40,7 +41,6 @@ module.directive('mlTimeseriesChart', function (
|
|||
$compile,
|
||||
$timeout,
|
||||
timefilter,
|
||||
mlAnomaliesTableService,
|
||||
Private,
|
||||
mlChartTooltipService) {
|
||||
|
||||
|
|
|
@ -127,10 +127,9 @@
|
|||
</div>
|
||||
|
||||
<ml-anomalies-table
|
||||
anomaly-records="anomalyRecords"
|
||||
time-field-name="timeFieldName"
|
||||
filtering-enabled="true">
|
||||
</ml-anomalies-table>
|
||||
table-data="tableData"
|
||||
filter="filter"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -16,6 +16,7 @@ import _ from 'lodash';
|
|||
import moment from 'moment';
|
||||
|
||||
import 'plugins/ml/components/anomalies_table';
|
||||
import 'plugins/ml/components/controls';
|
||||
|
||||
import { notify } from 'ui/notify';
|
||||
import uiRoutes from 'ui/routes';
|
||||
|
@ -41,6 +42,7 @@ import { IntervalHelperProvider, getBoundsRoundedToInterval } from 'plugins/ml/u
|
|||
import { mlResultsService } from 'plugins/ml/services/results_service';
|
||||
import template from './timeseriesexplorer.html';
|
||||
import { getMlNodeCount } from 'plugins/ml/ml_nodes_check/check_ml_nodes';
|
||||
import { ml } from 'plugins/ml/services/ml_api_service';
|
||||
import { mlJobService } from 'plugins/ml/services/job_service';
|
||||
import { mlFieldFormatService } from 'plugins/ml/services/field_format_service';
|
||||
import { JobSelectServiceProvider } from 'plugins/ml/components/job_select_list/job_select_service';
|
||||
|
@ -70,7 +72,8 @@ module.controller('MlTimeSeriesExplorerController', function (
|
|||
Private,
|
||||
timefilter,
|
||||
AppState,
|
||||
mlAnomaliesTableService) {
|
||||
mlSelectIntervalService,
|
||||
mlSelectSeverityService) {
|
||||
|
||||
$scope.timeFieldName = 'timestamp';
|
||||
timefilter.enableTimeRangeSelector();
|
||||
|
@ -341,7 +344,7 @@ module.controller('MlTimeSeriesExplorerController', function (
|
|||
|
||||
$scope.refreshFocusData = function (fromDate, toDate) {
|
||||
|
||||
// Counter to keep track of what data sets have been loaded.
|
||||
// Counter to keep track of the queries to populate the chart.
|
||||
let awaitingCount = 3;
|
||||
|
||||
// This object is used to store the results of individual remote requests
|
||||
|
@ -356,7 +359,6 @@ module.controller('MlTimeSeriesExplorerController', function (
|
|||
awaitingCount--;
|
||||
if (awaitingCount === 0) {
|
||||
// Tell the results container directives to render the focus chart.
|
||||
// Need to use $timeout to ensure the broadcast happens after the child scope is updated with the new data.
|
||||
refreshFocusData.focusChartData = processDataForFocusAnomalies(
|
||||
refreshFocusData.focusChartData,
|
||||
refreshFocusData.anomalyRecords,
|
||||
|
@ -366,7 +368,8 @@ module.controller('MlTimeSeriesExplorerController', function (
|
|||
refreshFocusData.focusChartData,
|
||||
refreshFocusData.scheduledEvents);
|
||||
|
||||
// All the data is ready now for a scope update
|
||||
// All the data is ready now for a scope update.
|
||||
// Use $evalAsync to ensure the update happens after the child scope is updated with the new data.
|
||||
$scope.$evalAsync(() => {
|
||||
$scope = Object.assign($scope, refreshFocusData);
|
||||
console.log('Time series explorer focus chart data set:', $scope.focusChartData);
|
||||
|
@ -405,7 +408,7 @@ module.controller('MlTimeSeriesExplorerController', function (
|
|||
console.log('Time series explorer - error getting metric data from elasticsearch:', resp);
|
||||
});
|
||||
|
||||
// Query 2 - load records across selected time range.
|
||||
// Query 2 - load all the records across selected time range for the chart anomaly markers.
|
||||
mlResultsService.getRecordsForCriteria(
|
||||
[$scope.selectedJob.job_id],
|
||||
$scope.criteriaFields,
|
||||
|
@ -467,6 +470,9 @@ module.controller('MlTimeSeriesExplorerController', function (
|
|||
});
|
||||
}
|
||||
|
||||
// Load the data for the anomalies table.
|
||||
loadAnomaliesTableData(searchBounds.min.valueOf(), searchBounds.max.valueOf());
|
||||
|
||||
};
|
||||
|
||||
$scope.saveSeriesPropertiesAndRefresh = function () {
|
||||
|
@ -480,6 +486,19 @@ module.controller('MlTimeSeriesExplorerController', function (
|
|||
$scope.refresh();
|
||||
};
|
||||
|
||||
$scope.filter = function (field, value, operator) {
|
||||
const entity = _.find($scope.entities, { fieldName: field });
|
||||
if (entity !== undefined) {
|
||||
if (operator === '+' && entity.fieldValue !== value) {
|
||||
entity.fieldValue = value;
|
||||
$scope.saveSeriesPropertiesAndRefresh();
|
||||
} else if (operator === '-' && entity.fieldValue === value) {
|
||||
entity.fieldValue = '';
|
||||
$scope.saveSeriesPropertiesAndRefresh();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$scope.loadForForecastId = function (forecastId) {
|
||||
mlForecastService.getForecastDateRange(
|
||||
$scope.selectedJob,
|
||||
|
@ -548,28 +567,23 @@ module.controller('MlTimeSeriesExplorerController', function (
|
|||
$scope.refresh();
|
||||
});
|
||||
|
||||
// Add a listener for filter changes triggered from the anomalies table.
|
||||
const filterChangeListener = function (field, value, operator) {
|
||||
const entity = _.find($scope.entities, { fieldName: field });
|
||||
if (entity !== undefined) {
|
||||
if (operator === '+' && entity.fieldValue !== value) {
|
||||
entity.fieldValue = value;
|
||||
$scope.saveSeriesPropertiesAndRefresh();
|
||||
} else if (operator === '-' && entity.fieldValue === value) {
|
||||
entity.fieldValue = '';
|
||||
$scope.saveSeriesPropertiesAndRefresh();
|
||||
}
|
||||
// Reload the anomalies table if the Interval or Threshold controls are changed.
|
||||
const tableControlsListener = function () {
|
||||
if ($scope.zoomFrom !== undefined && $scope.zoomTo !== undefined) {
|
||||
loadAnomaliesTableData($scope.zoomFrom.getTime(), $scope.zoomTo.getTime());
|
||||
}
|
||||
};
|
||||
mlSelectIntervalService.state.watch(tableControlsListener);
|
||||
mlSelectSeverityService.state.watch(tableControlsListener);
|
||||
|
||||
mlAnomaliesTableService.filterChange.watch(filterChangeListener);
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
refreshWatcher.cancel();
|
||||
mlAnomaliesTableService.filterChange.unwatch(filterChangeListener);
|
||||
mlSelectIntervalService.state.unwatch(tableControlsListener);
|
||||
mlSelectSeverityService.state.unwatch(tableControlsListener);
|
||||
});
|
||||
|
||||
// When inside a dashboard in the ML plugin, listen for changes to job selection.
|
||||
// Listen for changes to job selection.
|
||||
mlJobSelectService.listenJobSelectionChange($scope, (event, selections) => {
|
||||
// Clear the detectorIndex, entities and forecast info.
|
||||
if (selections.length > 0) {
|
||||
|
@ -665,6 +679,49 @@ module.controller('MlTimeSeriesExplorerController', function (
|
|||
});
|
||||
}
|
||||
|
||||
function loadAnomaliesTableData(earliestMs, latestMs) {
|
||||
ml.results.getAnomaliesTableData(
|
||||
[$scope.selectedJob.job_id],
|
||||
$scope.criteriaFields,
|
||||
[],
|
||||
mlSelectIntervalService.state.get('interval').val,
|
||||
mlSelectSeverityService.state.get('threshold').val,
|
||||
earliestMs,
|
||||
latestMs,
|
||||
ANOMALIES_MAX_RESULTS
|
||||
).then((resp) => {
|
||||
const anomalies = resp.anomalies;
|
||||
const detectorsByJob = mlJobService.detectorsByJob;
|
||||
anomalies.forEach((anomaly) => {
|
||||
// Add a detector property to each anomaly.
|
||||
// Default to functionDescription if no description available.
|
||||
// TODO - when job_service is moved server_side, move this to server endpoint.
|
||||
const jobId = anomaly.jobId;
|
||||
anomaly.detector = _.get(detectorsByJob,
|
||||
[jobId, anomaly.detectorIndex, 'detector_description'],
|
||||
anomaly.source.function_description);
|
||||
|
||||
// Add properties used for building the links menu.
|
||||
// TODO - when job_service is moved server_side, move this to server endpoint.
|
||||
if (_.has(mlJobService.customUrlsByJob, jobId)) {
|
||||
anomaly.customUrls = mlJobService.customUrlsByJob[jobId];
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$evalAsync(() => {
|
||||
$scope.tableData = {
|
||||
anomalies,
|
||||
interval: resp.interval,
|
||||
examplesByJobId: resp.examplesByJobId,
|
||||
showViewSeriesLink: false
|
||||
};
|
||||
});
|
||||
|
||||
}).catch((resp) => {
|
||||
console.log('Time series explorer - error loading data for anomalies table:', resp);
|
||||
});
|
||||
}
|
||||
|
||||
function updateControlsForDetector() {
|
||||
// Update the entity dropdown control(s) according to the partitioning fields for the selected detector.
|
||||
const detectorIndex = +$scope.detectorId;
|
||||
|
|
|
@ -107,9 +107,9 @@ export function processDataForFocusAnomalies(
|
|||
const recordTime = record[timeFieldName];
|
||||
let chartPoint = findNearestChartPointToTime(chartData, recordTime);
|
||||
|
||||
// TODO - handle case where there is an anomaly due to the absense of data
|
||||
// TODO - handle case where there is an anomaly due to the absence of data
|
||||
// and there is no model plot.
|
||||
if (chartPoint === undefined && chartData.length) {
|
||||
if (chartPoint === undefined && chartData !== undefined && chartData.length) {
|
||||
// In case there is a record with a time after that of the last chart point, set the score
|
||||
// for the last chart point to that of the last record, if that record has a higher score.
|
||||
const lastChartPoint = chartData[chartData.length - 1];
|
||||
|
@ -167,6 +167,10 @@ export function processScheduledEventsForChart(chartData, scheduledEvents) {
|
|||
|
||||
export function findNearestChartPointToTime(chartData, time) {
|
||||
let chartPoint;
|
||||
if(chartData === undefined) {
|
||||
return chartPoint;
|
||||
}
|
||||
|
||||
for (let i = 0; i < chartData.length; i++) {
|
||||
if (chartData[i].date.getTime() === time) {
|
||||
chartPoint = chartData[i];
|
||||
|
|
|
@ -36,10 +36,14 @@ export function buildAnomalyTableItems(anomalyRecords, aggregationInterval) {
|
|||
|
||||
// Fill out the remaining properties in each display record
|
||||
// for the columns to be displayed in the table.
|
||||
return displayRecords.map((record) => {
|
||||
const time = (new Date()).getTime();
|
||||
return displayRecords.map((record, index) => {
|
||||
const source = record.source;
|
||||
const jobId = source.job_id;
|
||||
|
||||
// Identify each row with a unique ID which is used by the table for row expansion.
|
||||
record.rowId = `${time}_${index}`;
|
||||
|
||||
record.jobId = jobId;
|
||||
record.detectorIndex = source.detector_index;
|
||||
record.severity = source.record_score;
|
||||
|
@ -64,29 +68,48 @@ export function buildAnomalyTableItems(anomalyRecords, aggregationInterval) {
|
|||
record.influencers = influencers;
|
||||
}
|
||||
|
||||
// Add fields to the display records for the actual and typical values.
|
||||
// To ensure sorting in the EuiTable works correctly, add extra 'sort'
|
||||
// properties which are single numeric values rather than the underlying arrays.
|
||||
// These properties can be removed if EuiTable sorting logic can be customized
|
||||
// - see https://github.com/elastic/eui/issues/425
|
||||
const functionDescription = source.function_description || '';
|
||||
const causes = source.causes || [];
|
||||
if (showActualForFunction(functionDescription) === true) {
|
||||
if (source.actual !== undefined) {
|
||||
record.actual = source.actual;
|
||||
record.actualSort = getMetricSortValue(source.actual);
|
||||
} else {
|
||||
// If only a single cause, copy values to the top level.
|
||||
if (causes.length === 1) {
|
||||
record.actual = causes[0].actual;
|
||||
record.actualSort = getMetricSortValue(causes[0].actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showTypicalForFunction(functionDescription) === true) {
|
||||
if (source.typical !== undefined) {
|
||||
record.typical = source.typical;
|
||||
record.typicalSort = getMetricSortValue(source.typical);
|
||||
} else {
|
||||
// If only a single cause, copy values to the top level.
|
||||
if (causes.length === 1) {
|
||||
record.typical = causes[0].typical;
|
||||
record.typicalSort = getMetricSortValue(causes[0].typical);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add a sortable property for the magnitude of the factor by
|
||||
// which the actual value is different from the typical.
|
||||
if (Array.isArray(record.actual) && record.actual.length === 1 &&
|
||||
Array.isArray(record.typical) && record.typical.length === 1) {
|
||||
const actualVal = Number(record.actual[0]);
|
||||
const typicalVal = Number(record.typical[0]);
|
||||
record.metricDescriptionSort = (actualVal > typicalVal) ?
|
||||
actualVal / typicalVal : typicalVal / actualVal;
|
||||
}
|
||||
|
||||
return record;
|
||||
|
||||
});
|
||||
|
@ -160,3 +183,10 @@ function aggregateAnomalies(anomalyRecords, interval) {
|
|||
return summaryRecords;
|
||||
|
||||
}
|
||||
|
||||
function getMetricSortValue(value) {
|
||||
// Returns a sortable value for a metric field (actual and typical values)
|
||||
// from the supplied value, which for metric functions will be a single
|
||||
// valued array.
|
||||
return (Array.isArray(value) && value.length > 0) ? value[0] : value;
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ import { ML_RESULTS_INDEX_PATTERN } from '../../../common/constants/index_patter
|
|||
// ML Results dashboards.
|
||||
|
||||
const DEFAULT_QUERY_SIZE = 500;
|
||||
const DEFAULT_MAX_EXAMPLES = 500;
|
||||
|
||||
export function resultsServiceProvider(callWithRequest) {
|
||||
|
||||
|
@ -28,13 +29,14 @@ export function resultsServiceProvider(callWithRequest) {
|
|||
// anomalies are categorization anomalies in mlcategory.
|
||||
async function getAnomaliesTableData(
|
||||
jobIds,
|
||||
criteriaFields,
|
||||
influencers,
|
||||
aggregationInterval,
|
||||
threshold,
|
||||
earliestMs,
|
||||
latestMs,
|
||||
maxRecords,
|
||||
maxExamples) {
|
||||
maxRecords = DEFAULT_QUERY_SIZE,
|
||||
maxExamples = DEFAULT_MAX_EXAMPLES) {
|
||||
|
||||
// Build the query to return the matching anomaly record results.
|
||||
// Add criteria for the time range, record score, plus any specified job IDs.
|
||||
|
@ -74,6 +76,15 @@ export function resultsServiceProvider(callWithRequest) {
|
|||
});
|
||||
}
|
||||
|
||||
// Add in term queries for each of the specified criteria.
|
||||
criteriaFields.forEach((criteria) => {
|
||||
boolCriteria.push({
|
||||
term: {
|
||||
[criteria.fieldName]: criteria.fieldValue
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add a nested query to filter for each of the specified influencers.
|
||||
if (influencers.length > 0) {
|
||||
boolCriteria.push({
|
||||
|
@ -108,7 +119,7 @@ export function resultsServiceProvider(callWithRequest) {
|
|||
|
||||
const resp = await callWithRequest('search', {
|
||||
index: ML_RESULTS_INDEX_PATTERN,
|
||||
size: maxRecords !== undefined ? maxRecords : DEFAULT_QUERY_SIZE,
|
||||
size: maxRecords,
|
||||
body: {
|
||||
query: {
|
||||
bool: {
|
||||
|
|
|
@ -15,6 +15,7 @@ function getAnomaliesTableData(callWithRequest, payload) {
|
|||
const rs = resultsServiceProvider(callWithRequest);
|
||||
const {
|
||||
jobIds,
|
||||
criteriaFields,
|
||||
influencers,
|
||||
aggregationInterval,
|
||||
threshold,
|
||||
|
@ -24,6 +25,7 @@ function getAnomaliesTableData(callWithRequest, payload) {
|
|||
maxExamples } = payload;
|
||||
return rs.getAnomaliesTableData(
|
||||
jobIds,
|
||||
criteriaFields,
|
||||
influencers,
|
||||
aggregationInterval,
|
||||
threshold,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue