[ML] explorer controller refactor (#28750)

Refactores the application logic of Anomaly Explorer to reduce relying on angularjs.
This commit is contained in:
Walter Rafelsberger 2019-01-24 09:07:54 +01:00 committed by GitHub
parent 15658b7db3
commit 735cc82edd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 1600 additions and 1230 deletions

View file

@ -19,9 +19,8 @@ describe('ML - Explorer Controller', () => {
const scope = $rootScope.$new();
$controller('MlExplorerController', { $scope: scope });
expect(Array.isArray(scope.annotationsData)).to.be(true);
expect(Array.isArray(scope.anomalyChartRecords)).to.be(true);
expect(scope.loading).to.be(true);
expect(Array.isArray(scope.jobs)).to.be(true);
expect(Array.isArray(scope.queryFilters)).to.be(true);
});
});
});

View file

@ -8,9 +8,11 @@
* React component for rendering Explorer dashboard swimlanes.
*/
import _ from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import { FormattedMessage, injectI18n } from '@kbn/i18n/react';
import DragSelect from 'dragselect';
import {
EuiFlexGroup,
@ -22,18 +24,54 @@ import {
} from '@elastic/eui';
import { AnnotationsTable } from '../components/annotations_table';
import { CheckboxShowCharts } from '../components/controls/checkbox_showcharts/checkbox_showcharts';
import {
ExplorerNoInfluencersFound,
ExplorerNoJobsFound,
ExplorerNoResultsFound,
} from './components';
import { ExplorerSwimlane } from './explorer_swimlane';
import { formatHumanReadableDateTime } from '../util/date_utils';
import { getBoundsRoundedToInterval } from 'plugins/ml/util/ml_time_buckets';
import { InfluencersList } from '../components/influencers_list';
import { mlExplorerDashboardService } from './explorer_dashboard_service';
import { mlResultsService } from 'plugins/ml/services/results_service';
import { LoadingIndicator } from '../components/loading_indicator/loading_indicator';
import { SelectInterval } from '../components/controls/select_interval/select_interval';
import { SelectLimit } from './select_limit/select_limit';
import { SelectSeverity } from '../components/controls/select_severity/select_severity';
import { CheckboxShowCharts, mlCheckboxShowChartsService } from '../components/controls/checkbox_showcharts/checkbox_showcharts';
import { SelectInterval, mlSelectIntervalService } from '../components/controls/select_interval/select_interval';
import { SelectLimit, mlSelectLimitService } from './select_limit/select_limit';
import { SelectSeverity, mlSelectSeverityService } from '../components/controls/select_severity/select_severity';
import {
getClearedSelectedAnomaliesState,
getDefaultViewBySwimlaneData,
getFilteredTopInfluencers,
getSelectionInfluencers,
getSelectionTimeRange,
getViewBySwimlaneOptions,
loadAnnotationsTableData,
loadAnomaliesTableData,
loadDataForCharts,
loadTopInfluencers,
processOverallResults,
processViewByResults,
selectedJobsHaveInfluencers,
} from './explorer_utils';
import {
explorerChartsContainerServiceFactory,
getDefaultChartsData
} from './explorer_charts/explorer_charts_container_service';
import {
getSwimlaneContainerWidth
} from './legacy_utils';
import {
DRAG_SELECT_ACTION,
APP_STATE_ACTION,
EXPLORER_ACTION,
SWIMLANE_DEFAULT_LIMIT,
SWIMLANE_TYPE,
VIEW_BY_JOB_LABEL,
} from './explorer_constants';
// Explorer Charts
import { ExplorerChartsContainer } from './explorer_charts/explorer_charts_container';
@ -42,6 +80,28 @@ import { ExplorerChartsContainer } from './explorer_charts/explorer_charts_conta
import { AnomaliesTable } from '../components/anomalies_table/anomalies_table';
import { timefilter } from 'ui/timefilter';
function getExplorerDefaultState() {
return {
annotationsData: [],
anomalyChartRecords: [],
chartsData: getDefaultChartsData(),
hasResults: false,
influencers: {},
loading: true,
noInfluencersConfigured: true,
noJobsFound: true,
overallSwimlaneData: [],
selectedCells: null,
selectedJobs: null,
swimlaneViewByFieldName: undefined,
tableData: {},
viewByLoadedForTimeFormatted: null,
viewBySwimlaneData: getDefaultViewBySwimlaneData(),
viewBySwimlaneDataLoading: false,
viewBySwimlaneOptions: [],
};
}
function mapSwimlaneOptionsToEuiOptions(options) {
return options.map(option => ({
value: option,
@ -52,48 +112,775 @@ function mapSwimlaneOptionsToEuiOptions(options) {
export const Explorer = injectI18n(
class Explorer extends React.Component {
static propTypes = {
annotationsData: PropTypes.array,
anomalyChartRecords: PropTypes.array,
hasResults: PropTypes.bool,
influencers: PropTypes.object,
jobs: PropTypes.array,
loading: PropTypes.bool,
noInfluencersConfigured: PropTypes.bool,
setSwimlaneSelectActive: PropTypes.func,
setSwimlaneViewBy: PropTypes.func,
showViewBySwimlane: PropTypes.bool,
swimlaneOverall: PropTypes.object,
swimlaneViewByFieldName: PropTypes.string,
tableData: PropTypes.object,
viewByLoadedForTimeFormatted: PropTypes.any,
viewBySwimlaneOptions: PropTypes.array,
appStateHandler: PropTypes.func.isRequired,
dateFormatTz: PropTypes.string.isRequired,
mlJobSelectService: PropTypes.object.isRequired,
MlTimeBuckets: PropTypes.func.isRequired,
};
viewByChangeHandler = e => this.props.setSwimlaneViewBy(e.target.value);
state = getExplorerDefaultState();
onSwimlaneEnterHandler = () => this.props.setSwimlaneSelectActive(true);
onSwimlaneLeaveHandler = () => this.props.setSwimlaneSelectActive(false);
// helper to avoid calling `setState()` in the listener for chart updates.
_isMounted = false;
// make sure dragSelect is only available if the mouse pointer is actually over a swimlane
disableDragSelectOnMouseLeave = true;
// skip listening to clicks on swimlanes while they are loading to avoid race conditions
skipCellClicks = true;
updateCharts = explorerChartsContainerServiceFactory((data) => {
if (this._isMounted) {
this.setState({
chartsData: {
...getDefaultChartsData(),
chartsPerRow: data.chartsPerRow,
seriesToPlot: data.seriesToPlot,
// convert truthy/falsy value to Boolean
tooManyBuckets: !!data.tooManyBuckets,
}
});
}
});
ALLOW_CELL_RANGE_SELECTION = mlExplorerDashboardService.allowCellRangeSelection;
dragSelect = new DragSelect({
selectables: document.getElementsByClassName('sl-cell'),
callback(elements) {
if (elements.length > 1 && !this.ALLOW_CELL_RANGE_SELECTION) {
elements = [elements[0]];
}
if (elements.length > 0) {
mlExplorerDashboardService.dragSelect.changed({
action: DRAG_SELECT_ACTION.NEW_SELECTION,
elements
});
}
this.disableDragSelectOnMouseLeave = true;
},
onDragStart() {
if (this.ALLOW_CELL_RANGE_SELECTION) {
mlExplorerDashboardService.dragSelect.changed({
action: DRAG_SELECT_ACTION.DRAG_START
});
this.disableDragSelectOnMouseLeave = false;
}
},
onElementSelect() {
if (this.ALLOW_CELL_RANGE_SELECTION) {
mlExplorerDashboardService.dragSelect.changed({
action: DRAG_SELECT_ACTION.ELEMENT_SELECT
});
}
}
});
dashboardListener = ((action, payload = {}) => {
// Listen to the initial loading of jobs
if (action === EXPLORER_ACTION.INITIALIZE) {
const { noJobsFound, selectedCells, selectedJobs, swimlaneViewByFieldName } = payload;
let currentSelectedCells = this.state.selectedCells;
let currentSwimlaneViewByFieldName = this.state.swimlaneViewByFieldName;
if (selectedCells !== undefined && currentSelectedCells === null) {
currentSelectedCells = selectedCells;
currentSwimlaneViewByFieldName = swimlaneViewByFieldName;
}
const stateUpdate = {
noInfluencersConfigured: !selectedJobsHaveInfluencers(selectedJobs),
noJobsFound,
selectedCells: currentSelectedCells,
selectedJobs,
swimlaneViewByFieldName: currentSwimlaneViewByFieldName
};
this.updateExplorer(stateUpdate, true);
}
// Listen for changes to job selection.
if (action === EXPLORER_ACTION.JOB_SELECTION_CHANGE) {
const { selectedJobs } = payload;
const stateUpdate = {
noInfluencersConfigured: !selectedJobsHaveInfluencers(selectedJobs),
selectedJobs,
};
this.props.appStateHandler(APP_STATE_ACTION.CLEAR_SELECTION);
Object.assign(stateUpdate, getClearedSelectedAnomaliesState());
if (selectedJobs.length > 1) {
this.props.appStateHandler(
APP_STATE_ACTION.SAVE_SWIMLANE_VIEW_BY_FIELD_NAME,
{ swimlaneViewByFieldName: VIEW_BY_JOB_LABEL },
);
stateUpdate.swimlaneViewByFieldName = VIEW_BY_JOB_LABEL;
// enforce a state update for swimlaneViewByFieldName
this.setState({ swimlaneViewByFieldName: VIEW_BY_JOB_LABEL }, () => {
this.updateExplorer(stateUpdate, true);
});
return;
}
this.updateExplorer(stateUpdate, true);
}
// RELOAD reloads full Anomaly Explorer and clears the selection.
if (action === EXPLORER_ACTION.RELOAD) {
this.props.appStateHandler(APP_STATE_ACTION.CLEAR_SELECTION);
this.updateExplorer({ ...payload, ...getClearedSelectedAnomaliesState() }, true);
}
// REDRAW reloads Anomaly Explorer and tries to retain the selection.
if (action === EXPLORER_ACTION.REDRAW) {
this.updateExplorer({}, false);
}
});
checkboxShowChartsListener = () => {
const showCharts = mlCheckboxShowChartsService.state.get('showCharts');
const { selectedCells, selectedJobs } = this.state;
const timerange = getSelectionTimeRange(
selectedCells,
this.getSwimlaneBucketInterval(selectedJobs).asSeconds()
);
if (showCharts && selectedCells !== null) {
this.updateCharts(
this.state.anomalyChartRecords, timerange.earliestMs, timerange.latestMs
);
} else {
this.updateCharts(
[], timerange.earliestMs, timerange.latestMs
);
}
};
anomalyChartsSeverityListener = () => {
const showCharts = mlCheckboxShowChartsService.state.get('showCharts');
const { anomalyChartRecords, selectedCells, selectedJobs } = this.state;
if (showCharts && selectedCells !== null) {
const timerange = getSelectionTimeRange(
selectedCells,
this.getSwimlaneBucketInterval(selectedJobs).asSeconds()
);
this.updateCharts(
anomalyChartRecords, timerange.earliestMs, timerange.latestMs
);
}
};
tableControlsListener = async () => {
const { dateFormatTz } = this.props;
const { selectedCells, swimlaneViewByFieldName, selectedJobs } = this.state;
this.setState({
tableData: await loadAnomaliesTableData(
selectedCells,
selectedJobs,
dateFormatTz,
this.getSwimlaneBucketInterval(selectedJobs).asSeconds(),
swimlaneViewByFieldName
)
});
};
swimlaneLimitListener = () => {
this.props.appStateHandler(APP_STATE_ACTION.CLEAR_SELECTION);
this.updateExplorer(getClearedSelectedAnomaliesState(), false);
};
// Listens to render updates of the swimlanes to update dragSelect
swimlaneRenderDoneListener = () => {
this.dragSelect.clearSelection();
this.dragSelect.setSelectables(document.getElementsByClassName('sl-cell'));
};
componentDidMount() {
this._isMounted = true;
mlExplorerDashboardService.explorer.watch(this.dashboardListener);
mlCheckboxShowChartsService.state.watch(this.checkboxShowChartsListener);
mlSelectLimitService.state.watch(this.swimlaneLimitListener);
mlSelectSeverityService.state.watch(this.anomalyChartsSeverityListener);
mlSelectIntervalService.state.watch(this.tableControlsListener);
mlSelectSeverityService.state.watch(this.tableControlsListener);
}
componentWillUnmount() {
this._isMounted = false;
mlExplorerDashboardService.explorer.unwatch(this.dashboardListener);
mlCheckboxShowChartsService.state.unwatch(this.checkboxShowChartsListener);
mlSelectLimitService.state.unwatch(this.swimlaneLimitListener);
mlSelectSeverityService.state.unwatch(this.anomalyChartsSeverityListener);
mlSelectIntervalService.state.unwatch(this.tableControlsListener);
mlSelectSeverityService.state.unwatch(this.tableControlsListener);
}
getSwimlaneBucketInterval(selectedJobs) {
const { MlTimeBuckets } = this.props;
const swimlaneWidth = getSwimlaneContainerWidth(this.state.noInfluencersConfigured);
// Bucketing interval should be the maximum of the chart related interval (i.e. time range related)
// and the max bucket span for the jobs shown in the chart.
const bounds = timefilter.getActiveBounds();
const buckets = new MlTimeBuckets();
buckets.setInterval('auto');
buckets.setBounds(bounds);
const intervalSeconds = buckets.getInterval().asSeconds();
// if the swimlane cell widths are too small they will not be visible
// calculate how many buckets will be drawn before the swimlanes are actually rendered
// and increase the interval to widen the cells if they're going to be smaller than 8px
// this has to be done at this stage so all searches use the same interval
const timerangeSeconds = (bounds.max.valueOf() - bounds.min.valueOf()) / 1000;
const numBuckets = parseInt(timerangeSeconds / intervalSeconds);
const cellWidth = Math.floor(swimlaneWidth / numBuckets * 100) / 100;
// if the cell width is going to be less than 8px, double the interval
if (cellWidth < 8) {
buckets.setInterval((intervalSeconds * 2) + 's');
}
const maxBucketSpanSeconds = selectedJobs.reduce((memo, job) => Math.max(memo, job.bucketSpanSeconds), 0);
if (maxBucketSpanSeconds > intervalSeconds) {
buckets.setInterval(maxBucketSpanSeconds + 's');
buckets.setBounds(bounds);
}
return buckets.getInterval();
}
loadOverallDataPreviousArgs = null;
loadOverallDataPreviousData = null;
loadOverallData(selectedJobs, interval, showLoadingIndicator = true) {
return new Promise((resolve) => {
// Loads the overall data components i.e. the overall swimlane and influencers list.
if (selectedJobs === null) {
resolve({
loading: false,
hasResuts: false
});
return;
}
// check if we can just return existing cached data
const compareArgs = {
selectedJobs,
intervalAsSeconds: interval.asSeconds()
};
if (_.isEqual(compareArgs, this.loadOverallDataPreviousArgs)) {
const overallSwimlaneData = this.loadOverallDataPreviousData;
const hasResults = (overallSwimlaneData.points && overallSwimlaneData.points.length > 0);
resolve({
hasResults,
loading: false,
overallSwimlaneData,
});
return;
}
this.loadOverallDataPreviousArgs = compareArgs;
if (showLoadingIndicator) {
this.setState({ hasResults: false, loading: true });
}
// Ensure the search bounds align to the bucketing interval used in the swimlane so
// that the first and last buckets are complete.
const bounds = timefilter.getActiveBounds();
const searchBounds = getBoundsRoundedToInterval(
bounds,
interval,
false
);
const selectedJobIds = selectedJobs.map(d => d.id);
// Load the overall bucket scores by time.
// Pass the interval in seconds as the swimlane relies on a fixed number of seconds between buckets
// which wouldn't be the case if e.g. '1M' was used.
// Pass 'true' when obtaining bucket bounds due to the way the overall_buckets endpoint works
// to ensure the search is inclusive of end time.
const overallBucketsBounds = getBoundsRoundedToInterval(
bounds,
interval,
true
);
mlResultsService.getOverallBucketScores(
selectedJobIds,
// Note there is an optimization for when top_n == 1.
// If top_n > 1, we should test what happens when the request takes long
// and refactor the loading calls, if necessary, to avoid delays in loading other components.
1,
overallBucketsBounds.min.valueOf(),
overallBucketsBounds.max.valueOf(),
interval.asSeconds() + 's'
).then((resp) => {
this.skipCellClicks = false;
const overallSwimlaneData = processOverallResults(
resp.results,
searchBounds,
interval.asSeconds(),
);
this.loadOverallDataPreviousData = overallSwimlaneData;
console.log('Explorer overall swimlane data set:', overallSwimlaneData);
const hasResults = (overallSwimlaneData.points && overallSwimlaneData.points.length > 0);
resolve({
hasResults,
loading: false,
overallSwimlaneData,
});
});
});
}
loadViewBySwimlanePreviousArgs = null;
loadViewBySwimlanePreviousData = null;
loadViewBySwimlane(fieldValues, overallSwimlaneData, selectedJobs, swimlaneViewByFieldName) {
const limit = mlSelectLimitService.state.get('limit');
const swimlaneLimit = (limit === undefined) ? SWIMLANE_DEFAULT_LIMIT : limit.val;
const compareArgs = {
fieldValues,
overallSwimlaneData,
selectedJobs,
swimlaneLimit,
swimlaneViewByFieldName,
interval: this.getSwimlaneBucketInterval(selectedJobs).asSeconds()
};
return new Promise((resolve) => {
this.skipCellClicks = true;
// check if we can just return existing cached data
if (_.isEqual(compareArgs, this.loadViewBySwimlanePreviousArgs)) {
this.skipCellClicks = false;
resolve({
viewBySwimlaneData: this.loadViewBySwimlanePreviousData,
viewBySwimlaneDataLoading: false
});
return;
}
this.setState({
viewBySwimlaneData: getDefaultViewBySwimlaneData(),
viewBySwimlaneDataLoading: true
});
const finish = (resp) => {
this.skipCellClicks = false;
if (resp !== undefined) {
const viewBySwimlaneData = processViewByResults(
resp.results,
fieldValues,
overallSwimlaneData,
swimlaneViewByFieldName,
this.getSwimlaneBucketInterval(selectedJobs).asSeconds(),
);
this.loadViewBySwimlanePreviousArgs = compareArgs;
this.loadViewBySwimlanePreviousData = viewBySwimlaneData;
console.log('Explorer view by swimlane data set:', viewBySwimlaneData);
resolve({
viewBySwimlaneData,
viewBySwimlaneDataLoading: false
});
} else {
resolve({ viewBySwimlaneDataLoading: false });
}
};
if (
selectedJobs === undefined ||
swimlaneViewByFieldName === undefined
) {
finish();
return;
} else {
// Ensure the search bounds align to the bucketing interval used in the swimlane so
// that the first and last buckets are complete.
const bounds = timefilter.getActiveBounds();
const searchBounds = getBoundsRoundedToInterval(
bounds,
this.getSwimlaneBucketInterval(selectedJobs),
false,
);
const selectedJobIds = selectedJobs.map(d => d.id);
// load scores by influencer/jobId value and time.
// Pass the interval in seconds as the swimlane relies on a fixed number of seconds between buckets
// which wouldn't be the case if e.g. '1M' was used.
const interval = `${this.getSwimlaneBucketInterval(selectedJobs).asSeconds()}s`;
if (swimlaneViewByFieldName !== VIEW_BY_JOB_LABEL) {
mlResultsService.getInfluencerValueMaxScoreByTime(
selectedJobIds,
swimlaneViewByFieldName,
fieldValues,
searchBounds.min.valueOf(),
searchBounds.max.valueOf(),
interval,
swimlaneLimit
).then(finish);
} else {
const jobIds = (fieldValues !== undefined && fieldValues.length > 0) ? fieldValues : selectedJobIds;
mlResultsService.getScoresByBucket(
jobIds,
searchBounds.min.valueOf(),
searchBounds.max.valueOf(),
interval,
swimlaneLimit
).then(finish);
}
}
});
}
topFieldsPreviousArgs = null;
topFieldsPreviousData = null;
loadViewByTopFieldValuesForSelectedTime(earliestMs, latestMs, selectedJobs, swimlaneViewByFieldName) {
const selectedJobIds = selectedJobs.map(d => d.id);
const limit = mlSelectLimitService.state.get('limit');
const swimlaneLimit = (limit === undefined) ? SWIMLANE_DEFAULT_LIMIT : limit.val;
const compareArgs = {
earliestMs, latestMs, selectedJobIds, swimlaneLimit, swimlaneViewByFieldName,
interval: this.getSwimlaneBucketInterval(selectedJobs).asSeconds()
};
// Find the top field values for the selected time, and then load the 'view by'
// swimlane over the full time range for those specific field values.
return new Promise((resolve) => {
if (_.isEqual(compareArgs, this.topFieldsPreviousArgs)) {
resolve(this.topFieldsPreviousData);
return;
}
this.topFieldsPreviousArgs = compareArgs;
if (swimlaneViewByFieldName !== VIEW_BY_JOB_LABEL) {
mlResultsService.getTopInfluencers(
selectedJobIds,
earliestMs,
latestMs,
swimlaneLimit
).then((resp) => {
if (resp.influencers[swimlaneViewByFieldName] === undefined) {
this.topFieldsPreviousData = [];
resolve([]);
}
const topFieldValues = [];
const topInfluencers = resp.influencers[swimlaneViewByFieldName];
topInfluencers.forEach((influencerData) => {
if (influencerData.maxAnomalyScore > 0) {
topFieldValues.push(influencerData.influencerFieldValue);
}
});
this.topFieldsPreviousData = topFieldValues;
resolve(topFieldValues);
});
} else {
mlResultsService.getScoresByBucket(
selectedJobIds,
earliestMs,
latestMs,
this.getSwimlaneBucketInterval(selectedJobs).asSeconds() + 's',
swimlaneLimit
).then((resp) => {
const topFieldValues = Object.keys(resp.results);
this.topFieldsPreviousData = topFieldValues;
resolve(topFieldValues);
});
}
});
}
anomaliesTablePreviousArgs = null;
anomaliesTablePreviousData = null;
annotationsTablePreviousArgs = null;
annotationsTablePreviousData = null;
async updateExplorer(stateUpdate, showOverallLoadingIndicator = true) {
const {
noInfluencersConfigured,
noJobsFound,
selectedCells,
selectedJobs,
} = {
...this.state,
...stateUpdate
};
this.skipCellClicks = false;
if (noJobsFound) {
this.setState(stateUpdate);
return;
}
if (this.swimlaneCellClickQueue.length > 0) {
this.setState(stateUpdate);
const latestSelectedCells = this.swimlaneCellClickQueue.pop();
this.swimlaneCellClickQueue.length = 0;
this.swimlaneCellClick(latestSelectedCells);
return;
}
const { dateFormatTz } = this.props;
const jobIds = (selectedCells !== null && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL)
? selectedCells.lanes
: selectedJobs.map(d => d.id);
const timerange = getSelectionTimeRange(
selectedCells,
this.getSwimlaneBucketInterval(selectedJobs).asSeconds()
);
// Load the overall data - if the FieldFormats failed to populate
// the default formatting will be used for metric values.
Object.assign(
stateUpdate,
await this.loadOverallData(
selectedJobs,
this.getSwimlaneBucketInterval(selectedJobs),
showOverallLoadingIndicator,
)
);
const { overallSwimlaneData } = stateUpdate;
const annotationsTableCompareArgs = {
selectedCells,
selectedJobs,
interval: this.getSwimlaneBucketInterval(selectedJobs).asSeconds()
};
if (_.isEqual(annotationsTableCompareArgs, this.annotationsTablePreviousArgs)) {
stateUpdate.annotationsData = this.annotationsTablePreviousData;
} else {
this.annotationsTablePreviousArgs = annotationsTableCompareArgs;
stateUpdate.annotationsData = this.annotationsTablePreviousData = await loadAnnotationsTableData(
selectedCells,
selectedJobs,
this.getSwimlaneBucketInterval(selectedJobs).asSeconds()
);
}
const viewBySwimlaneOptions = getViewBySwimlaneOptions(selectedJobs, this.state.swimlaneViewByFieldName);
Object.assign(stateUpdate, viewBySwimlaneOptions);
if (selectedCells !== null && selectedCells.showTopFieldValues === true) {
// this.setState({ viewBySwimlaneData: getDefaultViewBySwimlaneData(), viewBySwimlaneDataLoading: true });
// Click is in one of the cells in the Overall swimlane - reload the 'view by' swimlane
// to show the top 'view by' values for the selected time.
const topFieldValues = await this.loadViewByTopFieldValuesForSelectedTime(
timerange.earliestMs,
timerange.latestMs,
selectedJobs,
viewBySwimlaneOptions.swimlaneViewByFieldName
);
Object.assign(
stateUpdate,
await this.loadViewBySwimlane(
topFieldValues,
overallSwimlaneData,
selectedJobs,
viewBySwimlaneOptions.swimlaneViewByFieldName
),
{ viewByLoadedForTimeFormatted: formatHumanReadableDateTime(timerange.earliestMs) }
);
} else {
Object.assign(
stateUpdate,
viewBySwimlaneOptions,
await this.loadViewBySwimlane(
[],
overallSwimlaneData,
selectedJobs,
viewBySwimlaneOptions.swimlaneViewByFieldName
),
);
}
const { viewBySwimlaneData } = stateUpdate;
// do a sanity check against selectedCells. It can happen that a previously
// selected lane loaded via URL/AppState is not available anymore.
let clearSelection = false;
if (
selectedCells !== null &&
selectedCells.type === SWIMLANE_TYPE.VIEW_BY
) {
clearSelection = !selectedCells.lanes.some((lane) => {
return viewBySwimlaneData.points.some((point) => {
return (
point.laneLabel === lane &&
point.time === selectedCells.times[0]
);
});
});
}
if (clearSelection === true) {
this.props.appStateHandler(APP_STATE_ACTION.CLEAR_SELECTION);
Object.assign(stateUpdate, getClearedSelectedAnomaliesState());
}
const selectionInfluencers = getSelectionInfluencers(selectedCells, viewBySwimlaneOptions.swimlaneViewByFieldName);
if (selectionInfluencers.length === 0) {
stateUpdate.influencers = await loadTopInfluencers(jobIds, timerange.earliestMs, timerange.latestMs, noInfluencersConfigured);
}
const updatedAnomalyChartRecords = await loadDataForCharts(
jobIds, timerange.earliestMs, timerange.latestMs, selectionInfluencers, selectedCells
);
if (selectionInfluencers.length > 0 && updatedAnomalyChartRecords !== undefined) {
stateUpdate.influencers = await getFilteredTopInfluencers(
jobIds,
timerange.earliestMs,
timerange.latestMs,
updatedAnomalyChartRecords,
selectionInfluencers,
noInfluencersConfigured,
);
}
stateUpdate.anomalyChartRecords = updatedAnomalyChartRecords || [];
this.setState(stateUpdate);
if (mlCheckboxShowChartsService.state.get('showCharts') && selectedCells !== null) {
this.updateCharts(
stateUpdate.anomalyChartRecords, timerange.earliestMs, timerange.latestMs
);
} else {
this.updateCharts(
[], timerange.earliestMs, timerange.latestMs
);
}
const anomaliesTableCompareArgs = {
selectedCells,
selectedJobs,
dateFormatTz,
interval: this.getSwimlaneBucketInterval(selectedJobs).asSeconds(),
swimlaneViewByFieldName: viewBySwimlaneOptions.swimlaneViewByFieldName,
};
if (_.isEqual(anomaliesTableCompareArgs, this.anomaliesTablePreviousArgs)) {
this.setState(this.anomaliesTablePreviousData);
} else {
this.anomaliesTablePreviousArgs = anomaliesTableCompareArgs;
const tableData = this.anomaliesTablePreviousData = await loadAnomaliesTableData(
selectedCells,
selectedJobs,
dateFormatTz,
this.getSwimlaneBucketInterval(selectedJobs).asSeconds(),
viewBySwimlaneOptions.swimlaneViewByFieldName
);
this.setState({ tableData });
}
}
viewByChangeHandler = e => this.setSwimlaneViewBy(e.target.value);
setSwimlaneViewBy = (swimlaneViewByFieldName) => {
this.props.appStateHandler(APP_STATE_ACTION.CLEAR_SELECTION);
this.props.appStateHandler(APP_STATE_ACTION.SAVE_SWIMLANE_VIEW_BY_FIELD_NAME, { swimlaneViewByFieldName });
this.setState({ swimlaneViewByFieldName }, () => {
this.updateExplorer({
swimlaneViewByFieldName,
...getClearedSelectedAnomaliesState(),
}, false);
});
};
onSwimlaneEnterHandler = () => this.setSwimlaneSelectActive(true);
onSwimlaneLeaveHandler = () => this.setSwimlaneSelectActive(false);
setSwimlaneSelectActive = (active) => {
if (!active && this.disableDragSelectOnMouseLeave) {
this.dragSelect.clearSelection();
this.dragSelect.stop();
return;
}
this.dragSelect.start();
};
// This queue tracks click events while the swimlanes are loading.
// To avoid race conditions we keep the click events selectedCells in this queue
// and trigger another event only after the current loading is done.
// The queue is necessary since a click in the overall swimlane triggers
// an update of the viewby swimlanes. If we'd just ignored click events
// during the loading, we could miss programmatically triggered events like
// those coming via AppState when a selection is part of the URL.
swimlaneCellClickQueue = [];
// Listener for click events in the swimlane to load corresponding anomaly data.
swimlaneCellClick = (swimlaneSelectedCells) => {
if (this.skipCellClicks === true) {
this.swimlaneCellClickQueue.push(swimlaneSelectedCells);
return;
}
// If selectedCells is an empty object we clear any existing selection,
// otherwise we save the new selection in AppState and update the Explorer.
if (Object.keys(swimlaneSelectedCells).length === 0) {
this.props.appStateHandler(APP_STATE_ACTION.CLEAR_SELECTION);
const stateUpdate = getClearedSelectedAnomaliesState();
this.updateExplorer(stateUpdate, false);
} else {
swimlaneSelectedCells.showTopFieldValues = false;
const currentSwimlaneType = _.get(this.state, 'selectedCells.type');
const currentShowTopFieldValues = _.get(this.state, 'selectedCells.showTopFieldValues', false);
const newSwimlaneType = _.get(swimlaneSelectedCells, 'type');
if (
(currentSwimlaneType === SWIMLANE_TYPE.OVERALL && newSwimlaneType === SWIMLANE_TYPE.VIEW_BY) ||
newSwimlaneType === SWIMLANE_TYPE.OVERALL ||
currentShowTopFieldValues === true
) {
swimlaneSelectedCells.showTopFieldValues = true;
}
this.props.appStateHandler(APP_STATE_ACTION.SAVE_SELECTION, { swimlaneSelectedCells });
this.updateExplorer({ selectedCells: swimlaneSelectedCells }, false);
}
}
render() {
const {
intl,
MlTimeBuckets,
} = this.props;
const {
annotationsData,
anomalyChartRecords,
chartsData,
influencers,
intl,
hasResults,
jobs,
loading,
noInfluencersConfigured,
showViewBySwimlane,
swimlaneOverall,
swimlaneViewBy,
noJobsFound,
overallSwimlaneData,
selectedCells,
swimlaneViewByFieldName,
tableData,
viewByLoadedForTimeFormatted,
viewBySwimlaneData,
viewBySwimlaneDataLoading,
viewBySwimlaneOptions,
} = this.props;
} = this.state;
const loading = this.props.loading || this.state.loading;
const swimlaneWidth = getSwimlaneContainerWidth(noInfluencersConfigured);
if (loading === true) {
return (
@ -106,17 +893,23 @@ export const Explorer = injectI18n(
);
}
if (jobs.length === 0) {
if (noJobsFound) {
return <ExplorerNoJobsFound />;
}
if (jobs.length > 0 && hasResults === false) {
if (noJobsFound && hasResults === false) {
return <ExplorerNoResultsFound />;
}
const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10';
const mainColumnClasses = `column ${mainColumnWidthClassName}`;
const showViewBySwimlane = (
viewBySwimlaneData !== null &&
viewBySwimlaneData.laneLabels &&
viewBySwimlaneData.laneLabels.length > 0
);
return (
<div className="results-container">
{noInfluencersConfigured && (
@ -158,7 +951,15 @@ export const Explorer = injectI18n(
onMouseEnter={this.onSwimlaneEnterHandler}
onMouseLeave={this.onSwimlaneLeaveHandler}
>
<ExplorerSwimlane {...swimlaneOverall} />
<ExplorerSwimlane
chartWidth={swimlaneWidth}
MlTimeBuckets={MlTimeBuckets}
swimlaneCellClick={this.swimlaneCellClick}
swimlaneData={overallSwimlaneData}
swimlaneType={SWIMLANE_TYPE.OVERALL}
selection={selectedCells}
swimlaneRenderDoneListener={this.swimlaneRenderDoneListener}
/>
</div>
{viewBySwimlaneOptions.length > 0 && (
@ -216,7 +1017,15 @@ export const Explorer = injectI18n(
onMouseEnter={this.onSwimlaneEnterHandler}
onMouseLeave={this.onSwimlaneLeaveHandler}
>
<ExplorerSwimlane {...swimlaneViewBy} />
<ExplorerSwimlane
chartWidth={swimlaneWidth}
MlTimeBuckets={MlTimeBuckets}
swimlaneCellClick={this.swimlaneCellClick}
swimlaneData={viewBySwimlaneData}
swimlaneType={SWIMLANE_TYPE.VIEW_BY}
selection={selectedCells}
swimlaneRenderDoneListener={this.swimlaneRenderDoneListener}
/>
</div>
)}
@ -224,7 +1033,7 @@ export const Explorer = injectI18n(
<LoadingIndicator/>
)}
{!showViewBySwimlane && !viewBySwimlaneDataLoading && (
{!showViewBySwimlane && !viewBySwimlaneDataLoading && swimlaneViewByFieldName !== null && (
<ExplorerNoInfluencersFound swimlaneViewByFieldName={swimlaneViewByFieldName} />
)}
</React.Fragment>

View file

@ -25,7 +25,7 @@ import {
mlSelectSeverityService,
SEVERITY_OPTIONS,
} from '../../components/controls/select_severity/select_severity';
import { getChartContainerWidth } from './legacy_utils';
import { getChartContainerWidth } from '../legacy_utils';
import { CHART_TYPE } from '../explorer_constants';

View file

@ -81,7 +81,7 @@ jest.mock('../../util/string_utils', () => ({
mlEscape(d) { return d; }
}));
jest.mock('./legacy_utils', () => ({
jest.mock('../legacy_utils', () => ({
getChartContainerWidth() { return 1140; }
}));

View file

@ -8,12 +8,28 @@
* Contains values for ML anomaly explorer.
*/
import { i18n } from '@kbn/i18n';
export const DRAG_SELECT_ACTION = {
NEW_SELECTION: 'newSelection',
ELEMENT_SELECT: 'elementSelect',
DRAG_START: 'dragStart'
};
export const EXPLORER_ACTION = {
INITIALIZE: 'initialize',
JOB_SELECTION_CHANGE: 'jobSelectionChange',
LOAD_JOBS: 'loadJobs',
REDRAW: 'redraw',
RELOAD: 'reload',
};
export const APP_STATE_ACTION = {
CLEAR_SELECTION: 'clearSelection',
SAVE_SELECTION: 'saveSelection',
SAVE_SWIMLANE_VIEW_BY_FIELD_NAME: 'saveSwimlaneViewByFieldName',
};
export const SWIMLANE_DEFAULT_LIMIT = 10;
export const SWIMLANE_TYPE = {
@ -26,3 +42,8 @@ export const CHART_TYPE = {
POPULATION_DISTRIBUTION: 'population_distribution',
SINGLE_METRIC: 'single_metric',
};
export const MAX_CATEGORY_EXAMPLES = 10;
export const MAX_INFLUENCER_FIELD_VALUES = 10;
export const VIEW_BY_JOB_LABEL = i18n.translate('xpack.ml.explorer.jobIdLabel', { defaultMessage: 'job ID' });

File diff suppressed because it is too large Load diff

View file

@ -20,12 +20,12 @@ function mlExplorerDashboardServiceFactory() {
const listenerFactory = listenerFactoryProvider();
const dragSelect = service.dragSelect = listenerFactory();
const swimlaneRenderDone = service.swimlaneRenderDone = listenerFactory();
const explorer = service.explorer = listenerFactory();
service.init = function () {
// Clear out any old listeners.
dragSelect.unwatchAll();
swimlaneRenderDone.unwatchAll();
explorer.unwatchAll();
};
return service;

View file

@ -8,79 +8,28 @@
* AngularJS directive wrapper for rendering Anomaly Explorer's React component.
*/
import { pick } from 'lodash';
import React from 'react';
import ReactDOM from 'react-dom';
import { Explorer } from './explorer';
import { IntervalHelperProvider } from 'plugins/ml/util/ml_time_buckets';
import { SWIMLANE_TYPE } from './explorer_constants';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
import { I18nProvider } from '@kbn/i18n/react';
import { mapScopeToProps } from './explorer_utils';
module.directive('mlExplorerReactWrapper', function (Private) {
const TimeBuckets = Private(IntervalHelperProvider);
import { EXPLORER_ACTION } from './explorer_constants';
import { mlExplorerDashboardService } from './explorer_dashboard_service';
module.directive('mlExplorerReactWrapper', function () {
function link(scope, element) {
function getSwimlaneData(swimlaneType) {
switch (swimlaneType) {
case SWIMLANE_TYPE.OVERALL:
return scope.overallSwimlaneData;
case SWIMLANE_TYPE.VIEW_BY:
return scope.viewBySwimlaneData;
}
}
ReactDOM.render(
<I18nProvider>{React.createElement(Explorer, mapScopeToProps(scope))}</I18nProvider>,
element[0]
);
function mapScopeToSwimlaneProps(swimlaneType) {
return {
chartWidth: scope.swimlaneWidth,
MlTimeBuckets: TimeBuckets,
swimlaneCellClick: scope.swimlaneCellClick,
swimlaneData: getSwimlaneData(swimlaneType),
swimlaneType,
selection: scope.appState.mlExplorerSwimlane,
};
}
function render() {
const props = pick(scope, [
'annotationsData',
'anomalyChartRecords',
'chartsData',
'hasResults',
'influencers',
'jobs',
'loading',
'noInfluencersConfigured',
'setSwimlaneSelectActive',
'setSwimlaneViewBy',
'showViewBySwimlane',
'swimlaneViewByFieldName',
'tableData',
'viewByLoadedForTimeFormatted',
'viewBySwimlaneDataLoading',
'viewBySwimlaneOptions',
]);
props.swimlaneOverall = mapScopeToSwimlaneProps(SWIMLANE_TYPE.OVERALL);
props.swimlaneViewBy = mapScopeToSwimlaneProps(SWIMLANE_TYPE.VIEW_BY);
ReactDOM.render(
<I18nProvider>{React.createElement(Explorer, props)}</I18nProvider>,
element[0]
);
}
render();
scope.$watch(() => {
render();
});
mlExplorerDashboardService.explorer.changed(EXPLORER_ACTION.LOAD_JOBS);
element.on('$destroy', () => {
ReactDOM.unmountComponentAtNode(element[0]);

View file

@ -37,7 +37,8 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
laneLabels: PropTypes.array.isRequired
}).isRequired,
swimlaneType: PropTypes.string.isRequired,
selection: PropTypes.object
selection: PropTypes.object,
swimlaneRenderDoneListener: PropTypes.func.isRequired,
}
// Since this component is mostly rendered using d3 and cellMouseoverActive is only
@ -85,14 +86,14 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
const { swimlaneType } = this.props;
if (action === DRAG_SELECT_ACTION.NEW_SELECTION && elements.length > 0) {
const firstCellData = d3.select(elements[0]).node().__clickData__;
const firstSelectedCell = d3.select(elements[0]).node().__clickData__;
if (typeof firstCellData !== 'undefined' && swimlaneType === firstCellData.swimlaneType) {
if (typeof firstSelectedCell !== 'undefined' && swimlaneType === firstSelectedCell.swimlaneType) {
const selectedData = elements.reduce((d, e) => {
const cellData = d3.select(e).node().__clickData__;
d.bucketScore = Math.max(d.bucketScore, cellData.bucketScore);
d.laneLabels.push(cellData.laneLabel);
d.times.push(cellData.time);
const cell = d3.select(e).node().__clickData__;
d.bucketScore = Math.max(d.bucketScore, cell.bucketScore);
d.laneLabels.push(cell.laneLabel);
d.times.push(cell.time);
return d;
}, {
bucketScore: 0,
@ -142,9 +143,9 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
// since it also includes the "viewBy" attribute which might differ depending
// on whether the overall or viewby swimlane was selected.
const oldSelection = {
selectedType: selection.selectedType,
selectedLanes: selection.selectedLanes,
selectedTimes: selection.selectedTimes
selectedType: selection && selection.type,
selectedLanes: selection && selection.lanes,
selectedTimes: selection && selection.times
};
const newSelection = {
@ -162,13 +163,13 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
return;
}
const cellData = {
fieldName: swimlaneData.fieldName,
const selectedCells = {
viewByFieldName: swimlaneData.fieldName,
lanes: laneLabels,
times: d3.extent(times),
type: swimlaneType
};
swimlaneCellClick(cellData);
swimlaneCellClick(selectedCells);
}
highlightSelection(cellsToSelect, laneLabels, times) {
@ -249,7 +250,7 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
const swimlanes = element.select('.ml-swimlanes');
swimlanes.html('');
const cellWidth = Math.floor(chartWidth / numBuckets);
const cellWidth = Math.floor(chartWidth / numBuckets * 100) / 100;
const xAxisWidth = cellWidth * numBuckets;
const xAxisScale = d3.time.scale()
@ -309,7 +310,7 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
.style('width', `${laneLabelWidth}px`)
.html(label => mlEscape(label))
.on('click', () => {
if (typeof selection.selectedLanes !== 'undefined') {
if (typeof selection.lanes !== 'undefined') {
swimlaneCellClick({});
}
})
@ -424,13 +425,11 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
}
});
mlExplorerDashboardService.swimlaneRenderDone.changed();
// Check for selection and reselect the corresponding swimlane cell
// if the time range and lane label are still in view.
const selectionState = selection;
const selectedType = _.get(selectionState, 'selectedType', undefined);
const viewBy = _.get(selectionState, 'viewBy', '');
const selectedType = _.get(selectionState, 'type', undefined);
const selectionViewByFieldName = _.get(selectionState, 'viewByFieldName', '');
// If a selection was done in the other swimlane, add the "masked" classes
// to de-emphasize the swimlane cells.
@ -439,15 +438,19 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
element.selectAll('.sl-cell-inner').classed('sl-cell-inner-masked', true);
}
if ((swimlaneType !== selectedType) ||
(swimlaneData.fieldName !== undefined && swimlaneData.fieldName !== viewBy)) {
this.props.swimlaneRenderDoneListener();
if (
(swimlaneType !== selectedType) ||
(swimlaneData.fieldName !== undefined && swimlaneData.fieldName !== selectionViewByFieldName)
) {
// Not this swimlane which was selected.
return;
}
const cellsToSelect = [];
const selectedLanes = _.get(selectionState, 'selectedLanes', []);
const selectedTimes = _.get(selectionState, 'selectedTimes', []);
const selectedLanes = _.get(selectionState, 'lanes', []);
const selectedTimes = _.get(selectionState, 'times', []);
const selectedTimeExtent = d3.extent(selectedTimes);
selectedLanes.forEach((selectedLane) => {

View file

@ -26,12 +26,6 @@ jest.mock('./explorer_dashboard_service', () => ({
dragSelect: {
watch: jest.fn(),
unwatch: jest.fn()
},
swimlaneCellClick: {
changed: jest.fn()
},
swimlaneRenderDone: {
changed: jest.fn()
}
}
}));
@ -68,12 +62,15 @@ describe('ExplorerSwimlane', () => {
test('Minimal initialization', () => {
const mocks = getExplorerSwimlaneMocks();
const swimlaneRenderDoneListener = jest.fn();
const wrapper = mountWithIntl(<ExplorerSwimlane.WrappedComponent
chartWidth={mockChartWidth}
MlTimeBuckets={mocks.MlTimeBuckets}
swimlaneCellClick={jest.fn()}
swimlaneData={mocks.swimlaneData}
swimlaneType="overall"
swimlaneRenderDoneListener={swimlaneRenderDoneListener}
/>);
expect(wrapper.html()).toBe(
@ -82,32 +79,33 @@ describe('ExplorerSwimlane', () => {
);
// test calls to mock functions
expect(mlExplorerDashboardService.swimlaneRenderDone.changed.mock.calls.length).toBeGreaterThanOrEqual(1);
expect(mlExplorerDashboardService.dragSelect.watch.mock.calls.length).toBeGreaterThanOrEqual(1);
expect(mlExplorerDashboardService.dragSelect.unwatch.mock.calls).toHaveLength(0);
expect(mlExplorerDashboardService.swimlaneCellClick.changed.mock.calls).toHaveLength(0);
expect(mocks.MlTimeBuckets.mockMethods.setInterval.mock.calls.length).toBeGreaterThanOrEqual(1);
expect(mocks.MlTimeBuckets.mockMethods.getScaledDateFormat.mock.calls.length).toBeGreaterThanOrEqual(1);
expect(swimlaneRenderDoneListener.mock.calls.length).toBeGreaterThanOrEqual(1);
});
test('Overall swimlane', () => {
const mocks = getExplorerSwimlaneMocks();
const swimlaneRenderDoneListener = jest.fn();
const wrapper = mountWithIntl(<ExplorerSwimlane.WrappedComponent
chartWidth={mockChartWidth}
MlTimeBuckets={mocks.MlTimeBuckets}
swimlaneCellClick={jest.fn()}
swimlaneData={mockOverallSwimlaneData}
swimlaneType="overall"
swimlaneRenderDoneListener={swimlaneRenderDoneListener}
/>);
expect(wrapper.html()).toMatchSnapshot();
// test calls to mock functions
expect(mlExplorerDashboardService.swimlaneRenderDone.changed.mock.calls.length).toBeGreaterThanOrEqual(1);
expect(mlExplorerDashboardService.dragSelect.watch.mock.calls.length).toBeGreaterThanOrEqual(1);
expect(mlExplorerDashboardService.dragSelect.unwatch.mock.calls).toHaveLength(0);
expect(mlExplorerDashboardService.swimlaneCellClick.changed.mock.calls).toHaveLength(0);
expect(mocks.MlTimeBuckets.mockMethods.setInterval.mock.calls.length).toBeGreaterThanOrEqual(1);
expect(mocks.MlTimeBuckets.mockMethods.getScaledDateFormat.mock.calls.length).toBeGreaterThanOrEqual(1);
expect(swimlaneRenderDoneListener.mock.calls.length).toBeGreaterThanOrEqual(1);
});
});

View file

@ -0,0 +1,532 @@
/*
* 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.
*/
/*
* utils for Anomaly Explorer.
*/
import { chain, each, get, union, uniq } from 'lodash';
import { timefilter } from 'ui/timefilter';
import { parseInterval } from 'ui/utils/parse_interval';
import { isTimeSeriesViewDetector } from '../../common/util/job_utils';
import { ml } from '../services/ml_api_service';
import { mlJobService } from '../services/job_service';
import { mlResultsService } from 'plugins/ml/services/results_service';
import { mlSelectIntervalService } from '../components/controls/select_interval/select_interval';
import { mlSelectSeverityService } from '../components/controls/select_severity/select_severity';
import {
MAX_CATEGORY_EXAMPLES,
MAX_INFLUENCER_FIELD_VALUES,
VIEW_BY_JOB_LABEL,
} from './explorer_constants';
import {
ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE,
ANOMALIES_TABLE_DEFAULT_QUERY_SIZE
} from '../../common/constants/search';
import { i18n } from '@kbn/i18n';
import chrome from 'ui/chrome';
const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false);
// create new job objects based on standard job config objects
// new job objects just contain job id, bucket span in seconds and a selected flag.
export function createJobs(jobs) {
return jobs.map(job => {
const bucketSpan = parseInterval(job.analysis_config.bucket_span);
return { id: job.job_id, selected: false, bucketSpanSeconds: bucketSpan.asSeconds() };
});
}
export function getClearedSelectedAnomaliesState() {
return {
anomalyChartRecords: [],
selectedCells: null,
viewByLoadedForTimeFormatted: null,
};
}
export function getDefaultViewBySwimlaneData() {
return {
fieldName: '',
laneLabels: [],
points: [],
interval: 3600
};
}
export function mapScopeToProps(scope) {
return {
appStateHandler: scope.appStateHandler,
dateFormatTz: scope.dateFormatTz,
mlJobSelectService: scope.mlJobSelectService,
MlTimeBuckets: scope.MlTimeBuckets,
};
}
export async function getFilteredTopInfluencers(jobIds, earliestMs, latestMs, records, influencers, noInfluencersConfigured) {
// Filter the Top Influencers list to show just the influencers from
// the records in the selected time range.
const recordInfluencersByName = {};
// Add the specified influencer(s) to ensure they are used in the filter
// even if their influencer score for the selected time range is zero.
influencers.forEach((influencer) => {
const fieldName = influencer.fieldName;
if (recordInfluencersByName[influencer.fieldName] === undefined) {
recordInfluencersByName[influencer.fieldName] = [];
}
recordInfluencersByName[fieldName].push(influencer.fieldValue);
});
// Add the influencers from the top scoring anomalies.
records.forEach((record) => {
const influencersByName = record.influencers || [];
influencersByName.forEach((influencer) => {
const fieldName = influencer.influencer_field_name;
const fieldValues = influencer.influencer_field_values;
if (recordInfluencersByName[fieldName] === undefined) {
recordInfluencersByName[fieldName] = [];
}
recordInfluencersByName[fieldName].push(...fieldValues);
});
});
const uniqValuesByName = {};
Object.keys(recordInfluencersByName).forEach((fieldName) => {
const fieldValues = recordInfluencersByName[fieldName];
uniqValuesByName[fieldName] = uniq(fieldValues);
});
const filterInfluencers = [];
Object.keys(uniqValuesByName).forEach((fieldName) => {
// Find record influencers with the same field name as the clicked on cell(s).
const matchingFieldName = influencers.find((influencer) => {
return influencer.fieldName === fieldName;
});
if (matchingFieldName !== undefined) {
// Filter for the value(s) of the clicked on cell(s).
filterInfluencers.push(...influencers);
} else {
// For other field names, add values from all records.
uniqValuesByName[fieldName].forEach((fieldValue) => {
filterInfluencers.push({ fieldName, fieldValue });
});
}
});
return await loadTopInfluencers(jobIds, earliestMs, latestMs, filterInfluencers, noInfluencersConfigured);
}
export function selectedJobsHaveInfluencers(selectedJobs = []) {
let hasInfluencers = false;
selectedJobs.forEach((selectedJob) => {
const job = mlJobService.getJob(selectedJob.id);
let influencers = [];
if (job !== undefined) {
influencers = job.analysis_config.influencers || [];
}
hasInfluencers = hasInfluencers || influencers.length > 0;
});
return hasInfluencers;
}
export function getFieldsByJob() {
return mlJobService.jobs.reduce((reducedFieldsByJob, job) => {
// Add the list of distinct by, over, partition and influencer fields for each job.
const analysisConfig = job.analysis_config;
const influencers = analysisConfig.influencers || [];
const fieldsForJob = (analysisConfig.detectors || [])
.reduce((reducedfieldsForJob, detector) => {
if (detector.partition_field_name !== undefined) {
reducedfieldsForJob.push(detector.partition_field_name);
}
if (detector.over_field_name !== undefined) {
reducedfieldsForJob.push(detector.over_field_name);
}
// For jobs with by and over fields, don't add the 'by' field as this
// field will only be added to the top-level fields for record type results
// if it also an influencer over the bucket.
if (detector.by_field_name !== undefined && detector.over_field_name === undefined) {
reducedfieldsForJob.push(detector.by_field_name);
}
return reducedfieldsForJob;
}, [])
.concat(influencers);
reducedFieldsByJob[job.job_id] = uniq(fieldsForJob);
reducedFieldsByJob['*'] = union(reducedFieldsByJob['*'], reducedFieldsByJob[job.job_id]);
return reducedFieldsByJob;
}, { '*': [] });
}
export function getSelectionTimeRange(selectedCells, interval) {
// 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();
let earliestMs = bounds.min.valueOf();
let latestMs = bounds.max.valueOf();
if (selectedCells !== null && selectedCells.times !== 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 = (selectedCells.times[0] !== undefined) ? selectedCells.times[0] * 1000 : bounds.min.valueOf();
latestMs = bounds.max.valueOf();
if (selectedCells.times[1] !== undefined) {
// Subtract 1 ms so search does not include start of next bucket.
latestMs = ((selectedCells.times[1] + interval) * 1000) - 1;
}
}
return { earliestMs, latestMs };
}
export function getSelectionInfluencers(selectedCells, fieldName) {
if (
selectedCells !== null &&
selectedCells.viewByFieldName !== undefined &&
selectedCells.viewByFieldName !== VIEW_BY_JOB_LABEL
) {
return selectedCells.lanes.map(laneLabel => ({ fieldName, fieldValue: laneLabel }));
}
return [];
}
// Obtain the list of 'View by' fields per job and swimlaneViewByFieldName
export function getViewBySwimlaneOptions(selectedJobs, currentSwimlaneViewByFieldName) {
const selectedJobIds = selectedJobs.map(d => d.id);
// Unique influencers for the selected job(s).
const viewByOptions = chain(
mlJobService.jobs.reduce((reducedViewByOptions, job) => {
if (selectedJobIds.some(jobId => jobId === job.job_id)) {
return reducedViewByOptions.concat(job.analysis_config.influencers || []);
}
return reducedViewByOptions;
}, []))
.uniq()
.sortBy(fieldName => fieldName.toLowerCase())
.value();
viewByOptions.push(VIEW_BY_JOB_LABEL);
const viewBySwimlaneOptions = viewByOptions;
let swimlaneViewByFieldName = undefined;
if (
viewBySwimlaneOptions.indexOf(currentSwimlaneViewByFieldName) !== -1
) {
// Set the swimlane viewBy to that stored in the state (URL) if set.
// This means we reset it to the current state because it was set by the listener
// on initialization.
swimlaneViewByFieldName = currentSwimlaneViewByFieldName;
} else {
if (selectedJobIds.length > 1) {
// If more than one job selected, default to job ID.
swimlaneViewByFieldName = VIEW_BY_JOB_LABEL;
} else {
// For a single job, default to the first partition, over,
// by or influencer field of the first selected job.
const firstSelectedJob = mlJobService.jobs.find((job) => {
return job.job_id === selectedJobIds[0];
});
const firstJobInfluencers = firstSelectedJob.analysis_config.influencers || [];
firstSelectedJob.analysis_config.detectors.forEach((detector) => {
if (
detector.partition_field_name !== undefined &&
firstJobInfluencers.indexOf(detector.partition_field_name) !== -1
) {
swimlaneViewByFieldName = detector.partition_field_name;
return false;
}
if (
detector.over_field_name !== undefined &&
firstJobInfluencers.indexOf(detector.over_field_name) !== -1
) {
swimlaneViewByFieldName = detector.over_field_name;
return false;
}
// For jobs with by and over fields, don't add the 'by' field as this
// field will only be added to the top-level fields for record type results
// if it also an influencer over the bucket.
if (
detector.by_field_name !== undefined &&
detector.over_field_name === undefined &&
firstJobInfluencers.indexOf(detector.by_field_name) !== -1
) {
swimlaneViewByFieldName = detector.by_field_name;
return false;
}
});
if (swimlaneViewByFieldName === undefined) {
if (firstJobInfluencers.length > 0) {
swimlaneViewByFieldName = firstJobInfluencers[0];
} else {
// No influencers for first selected job - set to first available option.
swimlaneViewByFieldName = viewBySwimlaneOptions.length > 0
? viewBySwimlaneOptions[0]
: undefined;
}
}
}
}
return {
swimlaneViewByFieldName,
viewBySwimlaneOptions,
};
}
export function processOverallResults(scoresByTime, searchBounds, interval) {
const overallLabel = i18n.translate('xpack.ml.explorer.overallLabel', { defaultMessage: 'Overall' });
const dataset = {
laneLabels: [overallLabel],
points: [],
interval,
earliest: searchBounds.min.valueOf() / 1000,
latest: searchBounds.max.valueOf() / 1000
};
if (Object.keys(scoresByTime).length > 0) {
// Store the earliest and latest times of the data returned by the ES aggregations,
// These will be used for calculating the earliest and latest times for the swimlane charts.
each(scoresByTime, (score, timeMs) => {
const time = timeMs / 1000;
dataset.points.push({
laneLabel: overallLabel,
time,
value: score
});
dataset.earliest = Math.min(time, dataset.earliest);
dataset.latest = Math.max((time + dataset.interval), dataset.latest);
});
}
return dataset;
}
export function processViewByResults(
scoresByInfluencerAndTime,
sortedLaneValues,
overallSwimlaneData,
swimlaneViewByFieldName,
interval,
) {
// Processes the scores for the 'view by' swimlane.
// Sorts the lanes according to the supplied array of lane
// values in the order in which they should be displayed,
// or pass an empty array to sort lanes according to max score over all time.
const dataset = {
fieldName: swimlaneViewByFieldName,
points: [],
interval
};
// Set the earliest and latest to be the same as the overall swimlane.
dataset.earliest = overallSwimlaneData.earliest;
dataset.latest = overallSwimlaneData.latest;
const laneLabels = [];
const maxScoreByLaneLabel = {};
each(scoresByInfluencerAndTime, (influencerData, influencerFieldValue) => {
laneLabels.push(influencerFieldValue);
maxScoreByLaneLabel[influencerFieldValue] = 0;
each(influencerData, (anomalyScore, timeMs) => {
const time = timeMs / 1000;
dataset.points.push({
laneLabel: influencerFieldValue,
time,
value: anomalyScore
});
maxScoreByLaneLabel[influencerFieldValue] =
Math.max(maxScoreByLaneLabel[influencerFieldValue], anomalyScore);
});
});
const sortValuesLength = sortedLaneValues.length;
if (sortValuesLength === 0) {
// Sort lanes in descending order of max score.
// Note the keys in scoresByInfluencerAndTime received from the ES request
// are not guaranteed to be sorted by score if they can be parsed as numbers
// (e.g. if viewing by HTTP response code).
dataset.laneLabels = laneLabels.sort((a, b) => {
return maxScoreByLaneLabel[b] - maxScoreByLaneLabel[a];
});
} else {
// Sort lanes according to supplied order
// e.g. when a cell in the overall swimlane has been selected.
// Find the index of each lane label from the actual data set,
// rather than using sortedLaneValues as-is, just in case they differ.
dataset.laneLabels = laneLabels.sort((a, b) => {
let aIndex = sortedLaneValues.indexOf(a);
let bIndex = sortedLaneValues.indexOf(b);
aIndex = (aIndex > -1) ? aIndex : sortValuesLength;
bIndex = (bIndex > -1) ? bIndex : sortValuesLength;
return aIndex - bIndex;
});
}
return dataset;
}
export async function loadAnnotationsTableData(selectedCells, selectedJobs, interval) {
const jobIds = (selectedCells !== null && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL) ?
selectedCells.lanes : selectedJobs.map(d => d.id);
const timeRange = getSelectionTimeRange(selectedCells, interval);
if (mlAnnotationsEnabled === false) {
return Promise.resolve([]);
}
const resp = await ml.annotations.getAnnotations({
jobIds,
earliestMs: timeRange.earliestMs,
latestMs: timeRange.latestMs,
maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE
});
const annotationsData = [];
jobIds.forEach((jobId) => {
const jobAnnotations = resp.annotations[jobId];
if (jobAnnotations !== undefined) {
annotationsData.push(...jobAnnotations);
}
});
return Promise.resolve(
annotationsData
.sort((a, b) => {
return a.timestamp - b.timestamp;
})
.map((d, i) => {
d.key = String.fromCharCode(65 + i);
return d;
})
);
}
export async function loadAnomaliesTableData(selectedCells, selectedJobs, dateFormatTz, interval, fieldName) {
const jobIds = (selectedCells !== null && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL) ?
selectedCells.lanes : selectedJobs.map(d => d.id);
const influencers = getSelectionInfluencers(selectedCells, fieldName);
const timeRange = getSelectionTimeRange(selectedCells, interval);
return new Promise((resolve, reject) => {
ml.results.getAnomaliesTableData(
jobIds,
[],
influencers,
mlSelectIntervalService.state.get('interval').val,
mlSelectSeverityService.state.get('threshold').val,
timeRange.earliestMs,
timeRange.latestMs,
dateFormatTz,
ANOMALIES_TABLE_DEFAULT_QUERY_SIZE,
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;
const detector = get(detectorsByJob, [jobId, anomaly.detectorIndex]);
anomaly.detector = get(detector,
['detector_description'],
anomaly.source.function_description);
// For detectors with rules, add a property with the rule count.
if (detector !== undefined && detector.custom_rules !== undefined) {
anomaly.rulesLength = detector.custom_rules.length;
}
// 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 (mlJobService.customUrlsByJob[jobId] !== undefined) {
anomaly.customUrls = mlJobService.customUrlsByJob[jobId];
}
});
resolve({
anomalies,
interval: resp.interval,
examplesByJobId: resp.examplesByJobId,
showViewSeriesLink: true,
jobIds
});
}).catch((resp) => {
console.log('Explorer - error loading data for anomalies table:', resp);
reject();
});
});
}
// track the request to be able to ignore out of date requests
// and avoid race conditions ending up with the wrong charts.
let requestCount = 0;
export async function loadDataForCharts(jobIds, earliestMs, latestMs, influencers = [], selectedCells) {
return new Promise((resolve) => {
// Just skip doing the request when this function
// is called without the minimum required data.
if (selectedCells === null && influencers.length === 0) {
resolve([]);
}
const newRequestCount = ++requestCount;
requestCount = newRequestCount;
// Load the top anomalies (by record_score) which will be displayed in the charts.
mlResultsService.getRecordsForInfluencer(
jobIds, influencers, 0, earliestMs, latestMs, 500
)
.then((resp) => {
// Ignore this response if it's returned by an out of date promise
if (newRequestCount < requestCount) {
resolve(undefined);
}
if (selectedCells !== null && Object.keys(selectedCells).length > 0) {
console.log('Explorer anomaly charts data set:', resp.records);
resolve(resp.records);
}
resolve(undefined);
});
});
}
export async function loadTopInfluencers(selectedJobIds, earliestMs, latestMs, influencers = [], noInfluencersConfigured) {
return new Promise((resolve) => {
if (noInfluencersConfigured !== true) {
mlResultsService.getTopInfluencers(
selectedJobIds,
earliestMs,
latestMs,
MAX_INFLUENCER_FIELD_VALUES,
influencers
).then((resp) => {
// TODO - sort the influencers keys so that the partition field(s) are first.
console.log('Explorer top influencers data set:', resp.influencers);
resolve(resp.influencers);
});
} else {
resolve({});
}
});
}

View file

@ -11,3 +11,17 @@ export function getChartContainerWidth() {
const chartContainer = document.querySelector('.explorer-charts');
return Math.floor(chartContainer && chartContainer.clientWidth || 0);
}
export function getSwimlaneContainerWidth(noInfluencersConfigured = true) {
const explorerContainer = document.querySelector('.ml-explorer');
const explorerContainerWidth = explorerContainer && explorerContainer.clientWidth || 0;
if (noInfluencersConfigured === true) {
// swimlane is full width, minus 30 for the 'no influencers' info icon,
// minus 170 for the lane labels, minus 50 padding
return explorerContainerWidth - 250;
} else {
// swimlane width is 5 sixths of the window,
// minus 170 for the lane labels, minus 50 padding
return ((explorerContainerWidth / 6) * 5) - 220;
}
}