diff --git a/x-pack/plugins/ml/public/explorer/explorer.html b/x-pack/plugins/ml/public/explorer/explorer.html
index 81f2b2e76985f1..857a3755a77717 100644
--- a/x-pack/plugins/ml/public/explorer/explorer.html
+++ b/x-pack/plugins/ml/public/explorer/explorer.html
@@ -55,14 +55,7 @@
ng-mouseenter="setSwimlaneSelectActive(true)"
ng-mouseleave="setSwimlaneSelectActive(false)"
>
-
-
+
@@ -95,14 +88,7 @@
ng-mouseenter="setSwimlaneSelectActive(true)"
ng-mouseleave="setSwimlaneSelectActive(false)"
>
-
-
+
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container_directive.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container_directive.js
index 58b9994b6a650b..45f2ae31cac2e8 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container_directive.js
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container_directive.js
@@ -65,6 +65,8 @@ module.directive('mlExplorerChartsContainer', function (
element[0]
);
}
+
+ mlExplorerDashboardService.chartsInitDone.changed();
}
return {
diff --git a/x-pack/plugins/ml/public/explorer/explorer_constants.js b/x-pack/plugins/ml/public/explorer/explorer_constants.js
index cd5dee537a0f61..7d69bc563b3f4f 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_constants.js
+++ b/x-pack/plugins/ml/public/explorer/explorer_constants.js
@@ -13,3 +13,10 @@ export const DRAG_SELECT_ACTION = {
ELEMENT_SELECT: 'elementSelect',
DRAG_START: 'dragStart'
};
+
+export const SWIMLANE_DEFAULT_LIMIT = 10;
+
+export const SWIMLANE_TYPE = {
+ OVERALL: 'overall',
+ VIEW_BY: 'viewBy'
+};
diff --git a/x-pack/plugins/ml/public/explorer/explorer_controller.js b/x-pack/plugins/ml/public/explorer/explorer_controller.js
index f7b93edf7b40a6..bbce82047bebf0 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_controller.js
+++ b/x-pack/plugins/ml/public/explorer/explorer_controller.js
@@ -40,7 +40,11 @@ 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';
import { timefilter } from 'ui/timefilter';
-import { DRAG_SELECT_ACTION } from './explorer_constants';
+import {
+ DRAG_SELECT_ACTION,
+ SWIMLANE_DEFAULT_LIMIT,
+ SWIMLANE_TYPE
+} from './explorer_constants';
uiRoutes
.when('/explorer/?', {
@@ -65,6 +69,7 @@ function getDefaultViewBySwimlaneData() {
};
}
+
module.controller('MlExplorerController', function (
$scope,
$timeout,
@@ -93,7 +98,7 @@ module.controller('MlExplorerController', function (
const VIEW_BY_JOB_LABEL = 'job ID';
const ALLOW_CELL_RANGE_SELECTION = mlExplorerDashboardService.allowCellRangeSelection;
- // make sure dragSelect is only available if the mouse point is actually over a swimlane
+ // make sure dragSelect is only available if the mouse pointer is actually over a swimlane
let disableDragSelectOnMouseLeave = true;
// skip listening to clicks on swimlanes while they are loading to avoid race conditions
let skipCellClicks = true;
@@ -142,6 +147,16 @@ module.controller('MlExplorerController', function (
$scope.viewBySwimlaneOptions = [];
$scope.viewBySwimlaneData = getDefaultViewBySwimlaneData();
+
+ let isChartsContainerInitialized = false;
+ let chartsCallback = () => {};
+ function initializeAfterChartsContainerDone() {
+ if (isChartsContainerInitialized === false) {
+ chartsCallback();
+ }
+ isChartsContainerInitialized = true;
+ }
+
$scope.initializeVis = function () {
// Initialize the AppState in which to store filters.
const stateDefaults = {
@@ -159,7 +174,7 @@ module.controller('MlExplorerController', function (
// Select any jobs set in the global state (i.e. passed in the URL).
const selectedJobIds = mlJobSelectService.getSelectedJobIds(true);
- $scope.setSelectedJobs(selectedJobIds);
+ $scope.setSelectedJobs(selectedJobIds, true);
} else {
$scope.loading = false;
}
@@ -169,6 +184,7 @@ module.controller('MlExplorerController', function (
});
mlExplorerDashboardService.init();
+ mlExplorerDashboardService.chartsInitDone.watch(initializeAfterChartsContainerDone);
};
// create new job objects based on standard job config objects
@@ -180,7 +196,25 @@ module.controller('MlExplorerController', function (
});
}
- $scope.setSelectedJobs = function (selectedIds) {
+ function restoreCellDataFromAppState() {
+ // restore cellData from AppState
+ if (
+ $scope.cellData === undefined &&
+ $scope.appState.mlExplorerSwimlane.selectedType !== undefined
+ ) {
+ $scope.cellData = {
+ type: $scope.appState.mlExplorerSwimlane.selectedType,
+ lanes: $scope.appState.mlExplorerSwimlane.selectedLanes,
+ times: $scope.appState.mlExplorerSwimlane.selectedTimes
+ };
+ if ($scope.cellData.type === SWIMLANE_TYPE.VIEW_BY) {
+ $scope.cellData.fieldName = $scope.appState.mlExplorerSwimlane.viewBy;
+ }
+ $scope.swimlaneViewByFieldName = $scope.appState.mlExplorerSwimlane.viewBy;
+ }
+ }
+
+ $scope.setSelectedJobs = function (selectedIds, keepSwimlaneSelection = false) {
let previousSelected = 0;
if ($scope.selectedJobs !== null) {
previousSelected = $scope.selectedJobs.length;
@@ -220,6 +254,7 @@ module.controller('MlExplorerController', function (
// Clear viewBy from the state if we are moving from single
// to multi selection, or vice-versa.
+ $scope.appState.fetch();
if ((previousSelected <= 1 && $scope.selectedJobs.length > 1) ||
($scope.selectedJobs.length === 1 && previousSelected > 1)) {
delete $scope.appState.mlExplorerSwimlane.viewBy;
@@ -231,8 +266,13 @@ module.controller('MlExplorerController', function (
.finally(() => {
// Load the data - if the FieldFormats failed to populate
// the default formatting will be used for metric values.
- clearSelectedAnomalies();
loadOverallData();
+ if (keepSwimlaneSelection === false) {
+ clearSelectedAnomalies();
+ } else {
+ restoreCellDataFromAppState();
+ updateExplorer();
+ }
});
};
@@ -250,6 +290,7 @@ module.controller('MlExplorerController', function (
$scope.swimlaneViewByFieldName = viewByFieldName;
// Save the 'view by' field name to the AppState so that it can restored from the URL.
+ $scope.appState.fetch();
$scope.appState.mlExplorerSwimlane.viewBy = viewByFieldName;
$scope.appState.save();
@@ -274,12 +315,7 @@ module.controller('MlExplorerController', function (
// Listen for changes to job selection.
mlJobSelectService.listenJobSelectionChange($scope, (event, selections) => {
- // Clear swimlane selection from state.
- delete $scope.appState.mlExplorerSwimlane.selectedType;
- delete $scope.appState.mlExplorerSwimlane.selectedLane;
- delete $scope.appState.mlExplorerSwimlane.selectedTime;
- delete $scope.appState.mlExplorerSwimlane.selectedInterval;
-
+ clearSwimlaneSelectionFromAppState();
$scope.setSelectedJobs(selections);
});
@@ -299,12 +335,40 @@ module.controller('MlExplorerController', function (
}, 300);
});
+ function clearSwimlaneSelectionFromAppState() {
+ $scope.appState.fetch();
+ delete $scope.appState.mlExplorerSwimlane.selectedType;
+ delete $scope.appState.mlExplorerSwimlane.selectedLanes;
+ delete $scope.appState.mlExplorerSwimlane.selectedTimes;
+ $scope.appState.save();
+ }
+
+ function getSwimlaneData(swimlaneType) {
+ switch (swimlaneType) {
+ case SWIMLANE_TYPE.OVERALL:
+ return $scope.overallSwimlaneData;
+ case SWIMLANE_TYPE.VIEW_BY:
+ return $scope.viewBySwimlaneData;
+ }
+ }
+
+ function mapScopeToSwimlaneProps(swimlaneType) {
+ return {
+ chartWidth: $scope.swimlaneWidth,
+ MlTimeBuckets: TimeBuckets,
+ swimlaneData: getSwimlaneData(swimlaneType),
+ swimlaneType,
+ mlExplorerDashboardService,
+ selection: $scope.appState.mlExplorerSwimlane
+ };
+ }
+
function redrawOnResize() {
$scope.swimlaneWidth = getSwimlaneContainerWidth();
$scope.$apply();
- mlExplorerDashboardService.swimlaneDataChange.changed('overall');
- mlExplorerDashboardService.swimlaneDataChange.changed('viewBy');
+ mlExplorerDashboardService.swimlaneDataChange.changed(mapScopeToSwimlaneProps(SWIMLANE_TYPE.OVERALL));
+ mlExplorerDashboardService.swimlaneDataChange.changed(mapScopeToSwimlaneProps(SWIMLANE_TYPE.VIEW_BY));
if (
mlCheckboxShowChartsService.state.get('showCharts') &&
@@ -337,14 +401,15 @@ module.controller('MlExplorerController', function (
let earliestMs = bounds.min.valueOf();
let latestMs = bounds.max.valueOf();
- if (cellData !== undefined && cellData.time !== undefined) {
+ if (cellData !== undefined && cellData.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 = (cellData.time[0] !== undefined) ? cellData.time[0] * 1000 : bounds.min.valueOf();
+ earliestMs = (cellData.times[0] !== undefined) ? cellData.times[0] * 1000 : bounds.min.valueOf();
latestMs = bounds.max.valueOf();
- if (cellData.time[1] !== undefined) {
+ if (cellData.times[1] !== undefined) {
// Subtract 1 ms so search does not include start of next bucket.
- latestMs = ((cellData.time[1] + cellData.interval) * 1000) - 1;
+ const interval = $scope.swimlaneBucketInterval.asSeconds();
+ latestMs = ((cellData.times[1] + interval) * 1000) - 1;
}
}
@@ -354,9 +419,12 @@ module.controller('MlExplorerController', function (
function getSelectionInfluencers(cellData) {
const influencers = [];
- if (cellData !== undefined && cellData.fieldName !== undefined &&
- cellData.fieldName !== VIEW_BY_JOB_LABEL) {
- cellData.laneLabels.forEach((laneLabel) =>{
+ if (
+ cellData !== undefined &&
+ cellData.fieldName !== undefined &&
+ cellData.fieldName !== VIEW_BY_JOB_LABEL
+ ) {
+ cellData.lanes.forEach((laneLabel) =>{
influencers.push({ fieldName: $scope.swimlaneViewByFieldName, fieldValue: laneLabel });
});
}
@@ -373,83 +441,29 @@ module.controller('MlExplorerController', function (
// those coming via AppState when a selection is part of the URL.
const swimlaneCellClickListenerQueue = [];
- // swimlaneCellClickListener could trigger multiple times with the same data.
- // we track the previous click data here to be able to compare it and filter
- // consecutive calls with the same data.
- let previousListenerData = null;
-
- // Listener for click events in the swimlane and load corresponding anomaly data.
- // Empty cellData is passed on clicking outside a cell with score > 0.
- // The reset argument is useful when we intentionally want to reset state comparison
- // of click events and want to pass through.
- // For example, toggling showCharts isn't considered in the comparison
- // and would therefor fail to update properly.
- const swimlaneCellClickListener = function (cellData, skipComparison = false) {
+ // Listener for click events in the swimlane to load corresponding anomaly data.
+ const swimlaneCellClickListener = function (cellData) {
if (skipCellClicks === true) {
swimlaneCellClickListenerQueue.push(cellData);
return;
}
+ // If cellData is an empty object we clear any existing selection,
+ // otherwise we save the new selection in AppState and update the Explorer.
if (_.keys(cellData).length === 0) {
- // Swimlane deselection - clear anomalies section.
if ($scope.viewByLoadedForTimeFormatted) {
// Reload 'view by' swimlane over full time range.
loadViewBySwimlane([]);
}
clearSelectedAnomalies();
- previousListenerData = null;
} else {
- const timerange = getSelectionTimeRange(cellData);
+ $scope.appState.fetch();
+ $scope.appState.mlExplorerSwimlane.selectedType = cellData.type;
+ $scope.appState.mlExplorerSwimlane.selectedLanes = cellData.lanes;
+ $scope.appState.mlExplorerSwimlane.selectedTimes = cellData.times;
+ $scope.appState.save();
$scope.cellData = cellData;
-
- if (cellData.score > 0) {
- const jobIds = (cellData.fieldName === VIEW_BY_JOB_LABEL) ?
- cellData.laneLabels : $scope.getSelectedJobIds();
- const influencers = getSelectionInfluencers(cellData);
-
- const listenerData = {
- jobIds,
- influencers,
- start: timerange.earliestMs,
- end: timerange.latestMs,
- cellData
- };
- if (_.isEqual(listenerData, previousListenerData) && skipComparison === false) {
- return;
- }
- previousListenerData = listenerData;
-
- if (cellData.fieldName === undefined) {
- // 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.
- loadViewBySwimlaneForSelectedTime(timerange.earliestMs, timerange.latestMs);
- $scope.viewByLoadedForTimeFormatted = moment(timerange.earliestMs).format('MMMM Do YYYY, HH:mm');
- }
-
- // pass influencers on to loadDataForCharts(),
- // it will take care of calling loadTopInfluencers() in this case.
- loadDataForCharts(jobIds, timerange.earliestMs, timerange.latestMs, influencers);
- loadAnomaliesTableData();
- } else {
- // Multiple cells are selected, all with a score of 0 - clear all anomalies.
- $scope.$evalAsync(() => {
- $scope.influencers = {};
- $scope.anomalyChartRecords = [];
-
- $scope.tableData = {
- anomalies: [],
- interval: mlSelectIntervalService.state.get('interval').val,
- examplesByJobId: {},
- showViewSeriesLink: true
- };
- });
-
- mlExplorerDashboardService.anomalyDataChange.changed(
- [],
- timerange.earliestMs,
- timerange.latestMs
- );
- }
+ updateExplorer();
}
};
mlExplorerDashboardService.swimlaneCellClick.watch(swimlaneCellClickListener);
@@ -457,8 +471,7 @@ module.controller('MlExplorerController', function (
const checkboxShowChartsListener = function () {
const showCharts = mlCheckboxShowChartsService.state.get('showCharts');
if (showCharts && $scope.cellData !== undefined) {
- // passing true as the second argument skips click event filtering
- swimlaneCellClickListener($scope.cellData, true);
+ updateExplorer();
} else {
const timerange = getSelectionTimeRange($scope.cellData);
mlExplorerDashboardService.anomalyDataChange.changed(
@@ -506,6 +519,7 @@ module.controller('MlExplorerController', function (
mlSelectIntervalService.state.unwatch(tableControlsListener);
mlSelectSeverityService.state.unwatch(tableControlsListener);
mlSelectLimitService.state.unwatch(swimlaneLimitListener);
+ mlExplorerDashboardService.chartsInitDone.unwatch(initializeAfterChartsContainerDone);
delete $scope.cellData;
refreshWatcher.cancel();
// Cancel listening for updates to the global nav state.
@@ -516,8 +530,8 @@ module.controller('MlExplorerController', function (
// and avoid race conditions ending up with the wrong charts.
let requestCount = 0;
function loadDataForCharts(jobIds, earliestMs, latestMs, influencers = []) {
- // Just skip doing the request when this function is called without
- // the minimum required data.
+ // Just skip doing the request when this function
+ // is called without the minimum required data.
if ($scope.cellData === undefined && influencers.length === 0) {
return;
}
@@ -546,6 +560,14 @@ module.controller('MlExplorerController', function (
}
}
+ // While the charts were loaded, other events could reset cellData,
+ // so check if it's still present. This can happen if a cell selection
+ // gets restored from URL/AppState and we find out it's not applicable
+ // to the view by swimlanes currently on display.
+ if ($scope.cellData === undefined) {
+ return;
+ }
+
if (influencers.length > 0) {
// Filter the Top Influencers list to show just the influencers from
// the records in the selected time range.
@@ -697,6 +719,7 @@ module.controller('MlExplorerController', function (
}
+ $scope.appState.fetch();
$scope.appState.mlExplorerSwimlane.viewBy = $scope.swimlaneViewByFieldName;
$scope.appState.save();
}
@@ -740,7 +763,8 @@ module.controller('MlExplorerController', function (
overallBucketsBounds.max.valueOf(),
$scope.swimlaneBucketInterval.asSeconds() + 's'
).then((resp) => {
- processOverallResults(resp.results, searchBounds);
+ skipCellClicks = false;
+ $scope.overallSwimlaneData = processOverallResults(resp.results, searchBounds);
console.log('Explorer overall swimlane data set:', $scope.overallSwimlaneData);
if ($scope.overallSwimlaneData.points && $scope.overallSwimlaneData.points.length > 0) {
@@ -758,8 +782,7 @@ module.controller('MlExplorerController', function (
// Need to use $timeout to ensure the broadcast happens after the child scope is updated with the new data.
$timeout(() => {
$scope.$broadcast('render');
- mlExplorerDashboardService.swimlaneDataChange.changed('overall');
- skipCellClicks = false;
+ mlExplorerDashboardService.swimlaneDataChange.changed(mapScopeToSwimlaneProps(SWIMLANE_TYPE.OVERALL));
}, 0);
});
@@ -790,7 +813,26 @@ module.controller('MlExplorerController', function (
skipCellClicks = true;
// finish() function, called after each data set has been loaded and processed.
// The last one to call it will trigger the page render.
- function finish() {
+ function finish(resp) {
+ if (resp !== undefined) {
+ $scope.viewBySwimlaneData = processViewByResults(resp.results, fieldValues);
+
+ // do a sanity check against cellData. It can happen that a previously
+ // selected lane loaded via URL/AppState is not available anymore.
+ if (
+ $scope.cellData !== undefined &&
+ $scope.cellData.type === SWIMLANE_TYPE.VIEW_BY
+ ) {
+ const selectionExists = $scope.cellData.lanes.some((lane) => {
+ return ($scope.viewBySwimlaneData.laneLabels.includes(lane));
+ });
+ if (selectionExists === false) {
+ clearSelectedAnomalies();
+ }
+ }
+ }
+
+ skipCellClicks = false;
console.log('Explorer view by swimlane data set:', $scope.viewBySwimlaneData);
if (swimlaneCellClickListenerQueue.length > 0) {
const cellData = swimlaneCellClickListenerQueue.pop();
@@ -801,8 +843,7 @@ module.controller('MlExplorerController', function (
// Fire event to indicate swimlane data has changed.
// Need to use $timeout to ensure this happens after the child scope is updated with the new data.
$timeout(() => {
- skipCellClicks = false;
- mlExplorerDashboardService.swimlaneDataChange.changed('viewBy');
+ mlExplorerDashboardService.swimlaneDataChange.changed(mapScopeToSwimlaneProps(SWIMLANE_TYPE.VIEW_BY));
}, 0);
}
@@ -812,6 +853,7 @@ module.controller('MlExplorerController', function (
$scope.swimlaneViewByFieldName === null
) {
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.
@@ -819,7 +861,7 @@ module.controller('MlExplorerController', function (
const searchBounds = getBoundsRoundedToInterval(bounds, $scope.swimlaneBucketInterval, false);
const selectedJobIds = $scope.getSelectedJobIds();
const limit = mlSelectLimitService.state.get('limit');
- const swimlaneLimit = (limit === undefined) ? 10 : limit.val;
+ const swimlaneLimit = (limit === undefined) ? SWIMLANE_DEFAULT_LIMIT : limit.val;
// 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
@@ -834,10 +876,7 @@ module.controller('MlExplorerController', function (
searchBounds.max.valueOf(),
interval,
swimlaneLimit
- ).then((resp) => {
- processViewByResults(resp.results, fieldValues);
- finish();
- });
+ ).then(finish);
} else {
const jobIds = (fieldValues !== undefined && fieldValues.length > 0) ? fieldValues : selectedJobIds;
mlResultsService.getScoresByBucket(
@@ -846,11 +885,7 @@ module.controller('MlExplorerController', function (
searchBounds.max.valueOf(),
interval,
swimlaneLimit
- ).then((resp) => {
- processViewByResults(resp.results, fieldValues);
- finish();
- });
-
+ ).then(finish);
}
}
}
@@ -858,7 +893,7 @@ module.controller('MlExplorerController', function (
function loadViewBySwimlaneForSelectedTime(earliestMs, latestMs) {
const selectedJobIds = $scope.getSelectedJobIds();
const limit = mlSelectLimitService.state.get('limit');
- const swimlaneLimit = (limit === undefined) ? 10 : limit.val;
+ const swimlaneLimit = (limit === undefined) ? SWIMLANE_DEFAULT_LIMIT : limit.val;
// 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.
@@ -894,7 +929,7 @@ 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();
+ cellData.lanes : $scope.getSelectedJobIds();
const influencers = getSelectionInfluencers(cellData);
const timeRange = getSelectionTimeRange(cellData);
@@ -949,22 +984,54 @@ module.controller('MlExplorerController', function (
});
}
+ function updateExplorer() {
+ const cellData = $scope.cellData;
+
+ const jobIds = (cellData !== undefined && cellData.fieldName === VIEW_BY_JOB_LABEL) ? cellData.lanes : $scope.getSelectedJobIds();
+ const timerange = getSelectionTimeRange(cellData);
+ const influencers = getSelectionInfluencers(cellData);
+
+ // The following is to avoid running into a race condition where loading a swimlane selection from URL/AppState
+ // would fail because the Explorer Charts Container's directive wasn't linked yet and not being subscribed
+ // to the anomalyDataChange listener used in loadDataForCharts().
+ function finish() {
+ if ($scope.overallSwimlaneData !== undefined) {
+ mlExplorerDashboardService.swimlaneDataChange.changed(mapScopeToSwimlaneProps(SWIMLANE_TYPE.OVERALL));
+ }
+ if ($scope.viewBySwimlaneData !== undefined) {
+ mlExplorerDashboardService.swimlaneDataChange.changed(mapScopeToSwimlaneProps(SWIMLANE_TYPE.VIEW_BY));
+ }
+ mlExplorerDashboardService.anomalyDataChange.changed($scope.anomalyChartRecords || [], timerange.earliestMs, timerange.latestMs);
+
+ if (cellData !== undefined && cellData.fieldName === undefined) {
+ // 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.
+ loadViewBySwimlaneForSelectedTime(timerange.earliestMs, timerange.latestMs);
+ $scope.viewByLoadedForTimeFormatted = moment(timerange.earliestMs).format('MMMM Do YYYY, HH:mm');
+ }
+
+ if (influencers.length === 0) {
+ loadTopInfluencers(jobIds, timerange.earliestMs, timerange.latestMs);
+ loadDataForCharts(jobIds, timerange.earliestMs, timerange.latestMs);
+ } else {
+ loadDataForCharts(jobIds, timerange.earliestMs, timerange.latestMs, influencers);
+ }
+ loadAnomaliesTableData();
+ }
+
+ if (isChartsContainerInitialized) {
+ finish();
+ } else {
+ chartsCallback = finish;
+ }
+ }
+
function clearSelectedAnomalies() {
$scope.anomalyChartRecords = [];
$scope.viewByLoadedForTimeFormatted = null;
delete $scope.cellData;
-
- // With no swimlane selection, display anomalies over all time in the table.
- const jobIds = $scope.getSelectedJobIds();
- const bounds = timefilter.getActiveBounds();
- const earliestMs = bounds.min.valueOf();
- const latestMs = bounds.max.valueOf();
- mlExplorerDashboardService.anomalyDataChange.changed($scope.anomalyChartRecords, earliestMs, latestMs);
- // Load all top influencers right away because the filtering
- // done in loadDataForCharts() isn't neccessary here.
- loadTopInfluencers(jobIds, earliestMs, latestMs);
- loadDataForCharts(jobIds, earliestMs, latestMs);
- loadAnomaliesTableData();
+ clearSwimlaneSelectionFromAppState();
+ updateExplorer();
}
function calculateSwimlaneBucketInterval() {
@@ -1038,7 +1105,7 @@ module.controller('MlExplorerController', function (
});
}
- $scope.overallSwimlaneData = dataset;
+ return dataset;
}
function processViewByResults(scoresByInfluencerAndTime, sortedLaneValues) {
@@ -1049,7 +1116,8 @@ module.controller('MlExplorerController', function (
const dataset = {
fieldName: $scope.swimlaneViewByFieldName,
points: [],
- interval: $scope.swimlaneBucketInterval.asSeconds() };
+ interval: $scope.swimlaneBucketInterval.asSeconds()
+ };
// Set the earliest and latest to be the same as the overall swimlane.
dataset.earliest = $scope.overallSwimlaneData.earliest;
@@ -1097,7 +1165,7 @@ module.controller('MlExplorerController', function (
});
}
- $scope.viewBySwimlaneData = dataset;
+ return dataset;
}
});
diff --git a/x-pack/plugins/ml/public/explorer/explorer_dashboard_service.js b/x-pack/plugins/ml/public/explorer/explorer_dashboard_service.js
index d18fb706f0d324..ea6dcb0faab2b5 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_dashboard_service.js
+++ b/x-pack/plugins/ml/public/explorer/explorer_dashboard_service.js
@@ -24,6 +24,7 @@ module.service('mlExplorerDashboardService', function () {
const swimlaneCellClick = this.swimlaneCellClick = listenerFactory();
const swimlaneDataChange = this.swimlaneDataChange = listenerFactory();
const swimlaneRenderDone = this.swimlaneRenderDone = listenerFactory();
+ const chartsInitDone = this.chartsInitDone = listenerFactory();
this.anomalyDataChange = listenerFactory();
this.init = function () {
@@ -32,6 +33,7 @@ module.service('mlExplorerDashboardService', function () {
swimlaneCellClick.unwatchAll();
swimlaneDataChange.unwatchAll();
swimlaneRenderDone.unwatchAll();
+ chartsInitDone.unwatchAll();
};
});
diff --git a/x-pack/plugins/ml/public/explorer/explorer_swimlane.js b/x-pack/plugins/ml/public/explorer/explorer_swimlane.js
index 2c0000a264fbe2..baaa1cf94f7870 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_swimlane.js
+++ b/x-pack/plugins/ml/public/explorer/explorer_swimlane.js
@@ -27,17 +27,20 @@ import { DRAG_SELECT_ACTION } from './explorer_constants';
export class ExplorerSwimlane extends React.Component {
static propTypes = {
- appState: PropTypes.object.isRequired,
- lanes: PropTypes.array.isRequired,
- mlExplorerDashboardService: PropTypes.object.isRequired
+ chartWidth: PropTypes.number.isRequired,
+ MlTimeBuckets: PropTypes.func.isRequired,
+ swimlaneData: PropTypes.shape({
+ laneLabels: PropTypes.array.isRequired
+ }).isRequired,
+ swimlaneType: PropTypes.string.isRequired,
+ mlExplorerDashboardService: PropTypes.object.isRequired,
+ selection: PropTypes.object
}
- constructor(props) {
- super(props);
- this.state = {
- cellMouseoverActive: true
- };
- }
+ // Since this component is mostly rendered using d3 and cellMouseoverActive is only
+ // relevant for d3 based interaction, we don't manage this using React's state
+ // and intentionally circumvent the component lifecycle when updating it.
+ cellMouseoverActive = true;
componentWillUnmount() {
const { mlExplorerDashboardService } = this.props;
@@ -45,6 +48,7 @@ export class ExplorerSwimlane extends React.Component {
const element = d3.select(this.rootNode);
element.html('');
}
+
componentDidMount() {
const element = d3.select(this.rootNode.parentNode);
const { mlExplorerDashboardService } = this.props;
@@ -63,7 +67,6 @@ export class ExplorerSwimlane extends React.Component {
this.renderSwimlane();
}
-
componentDidUpdate() {
this.renderSwimlane();
}
@@ -71,7 +74,7 @@ export class ExplorerSwimlane extends React.Component {
// property to remember the bound dragSelectListener
boundDragSelectListener = null;
- // property for cellClick data comparison to be able to filter
+ // property for data comparison to be able to filter
// consecutive click events with the same data.
previousSelectedData = null;
@@ -99,17 +102,17 @@ export class ExplorerSwimlane extends React.Component {
selectedData.laneLabels = _.uniq(selectedData.laneLabels);
selectedData.times = _.uniq(selectedData.times);
if (_.isEqual(selectedData, this.previousSelectedData) === false) {
- this.cellClick(elements, selectedData);
+ this.selectCell(elements, selectedData);
this.previousSelectedData = selectedData;
}
}
- this.setState({ cellMouseoverActive: true });
+ this.cellMouseoverActive = true;
} else if (action === DRAG_SELECT_ACTION.ELEMENT_SELECT) {
element.classed('ml-dragselect-dragging', true);
return;
} else if (action === DRAG_SELECT_ACTION.DRAG_START) {
- this.setState({ cellMouseoverActive: false });
+ this.cellMouseoverActive = false;
return;
}
@@ -118,81 +121,57 @@ export class ExplorerSwimlane extends React.Component {
elements.map(e => d3.select(e).classed('ds-selected', false));
}
- cellClick(cellsToSelect, { laneLabels, bucketScore, times }) {
- if (cellsToSelect.length > 1 || bucketScore > 0) {
- this.selectCell(cellsToSelect, laneLabels, times, bucketScore, true);
- } else {
- this.clearSelection();
- }
- }
-
- checkForSelection() {
- const element = d3.select(this.rootNode.parentNode);
-
+ selectCell(cellsToSelect, { laneLabels, bucketScore, times }) {
const {
- appState,
+ selection,
+ mlExplorerDashboardService,
swimlaneData,
swimlaneType
} = this.props;
- // Check for selection in the AppState and reselect the corresponding swimlane cell
- // if the time range and lane label are still in view.
- const selectionState = appState.mlExplorerSwimlane;
- const selectedType = _.get(selectionState, 'selectedType', undefined);
- const viewBy = _.get(selectionState, 'viewBy', '');
- if (swimlaneType !== selectedType && selectedType !== undefined) {
- element.selectAll('.lane-label').classed('lane-label-masked', true);
- element.selectAll('.sl-cell-inner').classed('sl-cell-inner-masked', true);
- }
+ let triggerNewSelection = false;
- if ((swimlaneType !== selectedType) ||
- (swimlaneData.fieldName !== undefined && swimlaneData.fieldName !== viewBy)) {
- // Not this swimlane which was selected.
- return;
+ if (cellsToSelect.length > 1 || bucketScore > 0) {
+ triggerNewSelection = true;
}
- const cellsToSelect = [];
- const selectedLanes = _.get(selectionState, 'selectedLanes', []);
- const selectedTimes = _.get(selectionState, 'selectedTimes', []);
- const selectedTimeExtent = d3.extent(selectedTimes);
+ // Check if the same cells were selected again, if so clear the selection,
+ // otherwise activate the new selection. The two objects are built for
+ // comparison because we cannot simply compare to "appState.mlExplorerSwimlane"
+ // 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
+ };
- const lanes = swimlaneData.laneLabels;
- const startTime = swimlaneData.earliest;
- const endTime = swimlaneData.latest;
+ const newSelection = {
+ selectedType: swimlaneType,
+ selectedLanes: laneLabels,
+ selectedTimes: d3.extent(times)
+ };
- selectedLanes.forEach((selectedLane) => {
- if (lanes.indexOf(selectedLane) > -1 && selectedTimeExtent[0] >= startTime && selectedTimeExtent[1] <= endTime) {
- // Locate matching cell - look for exact time, otherwise closest before.
- const swimlanes = element.select('.ml-swimlanes');
- const laneCells = swimlanes.selectAll(`div[data-lane-label="${mlEscape(selectedLane)}"]`);
+ if (_.isEqual(oldSelection, newSelection)) {
+ triggerNewSelection = false;
+ }
- laneCells.each(function () {
- const cell = d3.select(this);
- const cellTime = cell.attr('data-time');
- if (cellTime >= selectedTimeExtent[0] && cellTime <= selectedTimeExtent[1]) {
- cellsToSelect.push(cell.node());
- }
- });
- }
- });
- const selectedMaxBucketScore = cellsToSelect.reduce((maxBucketScore, cell) => {
- return Math.max(maxBucketScore, +d3.select(cell).attr('data-bucket-score') || 0);
- }, 0);
- if (cellsToSelect.length > 1 || selectedMaxBucketScore > 0) {
- this.selectCell(cellsToSelect, selectedLanes, selectedTimes, selectedMaxBucketScore);
- } else {
- // Clear selection from state as previous selection is no longer applicable.
- this.clearSelection();
+ if (triggerNewSelection === false) {
+ mlExplorerDashboardService.swimlaneCellClick.changed({});
+ return;
}
+
+ const cellData = {
+ fieldName: swimlaneData.fieldName,
+ lanes: laneLabels,
+ times: d3.extent(times),
+ type: swimlaneType
+ };
+ mlExplorerDashboardService.swimlaneCellClick.changed(cellData);
}
- selectCell(cellsToSelect, laneLabels, times, bucketScore, checkEqualSelection = false) {
- const {
- appState,
- mlExplorerDashboardService,
- swimlaneData,
- swimlaneType
- } = this.props;
+ highlightSelection(cellsToSelect, laneLabels, times) {
+ const { swimlaneType } = this.props;
// This selects both overall and viewby swimlane
const wrapper = d3.selectAll('.ml-explorer-swimlane');
@@ -209,7 +188,7 @@ export class ExplorerSwimlane extends React.Component {
const rootParent = d3.select(this.rootNode.parentNode);
rootParent.selectAll('.lane-label')
.classed('lane-label-masked', function () {
- return (laneLabels.indexOf(d3.select(this).text()) > -1);
+ return (laneLabels.indexOf(d3.select(this).text()) === -1);
});
if (swimlaneType === 'viewBy') {
@@ -220,44 +199,10 @@ export class ExplorerSwimlane extends React.Component {
overallCell.classed('sl-cell-inner-selected', true);
});
}
-
- // Check if the same cells were selected again, if so clear the selection,
- // otherwise activate the new selection. The two objects are built for
- // comparison because we cannot simply compare to "appState.mlExplorerSwimlane"
- // since it also includes the "viewBy" attribute which might differ depending
- // on whether the overall or viewby swimlane was selected.
- if (checkEqualSelection && _.isEqual(
- {
- selectedType: appState.mlExplorerSwimlane.selectedType,
- selectedLanes: appState.mlExplorerSwimlane.selectedLanes,
- selectedTimes: appState.mlExplorerSwimlane.selectedTimes
- },
- {
- selectedType: swimlaneType,
- selectedLanes: laneLabels,
- selectedTimes: times
- }
- )) {
- this.clearSelection();
- } else {
- appState.mlExplorerSwimlane.selectedType = swimlaneType;
- appState.mlExplorerSwimlane.selectedLanes = laneLabels;
- appState.mlExplorerSwimlane.selectedTimes = times;
- appState.save();
-
- mlExplorerDashboardService.swimlaneCellClick.changed({
- fieldName: swimlaneData.fieldName,
- laneLabels,
- time: d3.extent(times),
- interval: swimlaneData.interval,
- score: bucketScore
- });
- }
}
-
clearSelection() {
- const { appState, mlExplorerDashboardService } = this.props;
+ const { mlExplorerDashboardService } = this.props;
// This selects both overall and viewby swimlane
const wrapper = d3.selectAll('.ml-explorer-swimlane');
@@ -268,35 +213,31 @@ export class ExplorerSwimlane extends React.Component {
wrapper.selectAll('.sl-cell-inner-dragselect.sl-cell-inner-selected').classed('sl-cell-inner-selected', false);
wrapper.selectAll('.ds-selected').classed('sl-cell-inner-selected', false);
- delete appState.mlExplorerSwimlane.selectedType;
- delete appState.mlExplorerSwimlane.selectedLanes;
- delete appState.mlExplorerSwimlane.selectedTimes;
- appState.save();
-
mlExplorerDashboardService.swimlaneCellClick.changed({});
}
renderSwimlane() {
const element = d3.select(this.rootNode.parentNode);
- const {
- cellMouseoverActive
- } = this.state;
+ const cellMouseoverActive = this.cellMouseoverActive;
const {
- lanes,
- startTime,
- endTime,
- stepSecs,
- points,
chartWidth,
MlTimeBuckets,
swimlaneData,
swimlaneType,
mlExplorerDashboardService,
- appState
+ selection
} = this.props;
+ const {
+ laneLabels: lanes,
+ earliest: startTime,
+ latest: endTime,
+ interval: stepSecs,
+ points
+ } = swimlaneData;
+
function colorScore(value) {
return getSeverityColor(value);
}
@@ -322,6 +263,17 @@ export class ExplorerSwimlane extends React.Component {
timeBuckets.setInterval(`${stepSecs}s`);
const xAxisTickFormat = timeBuckets.getScaledDateFormat();
+ function cellMouseOverFactory(time, i) {
+ // Don't use an arrow function here because we need access to `this`,
+ // which is where d3 supplies a reference to the corresponding DOM element.
+ return function (lane) {
+ const bucketScore = getBucketScore(lane, time);
+ if (bucketScore !== 0) {
+ cellMouseover(this, lane, bucketScore, i, time);
+ }
+ };
+ }
+
function cellMouseover(target, laneLabel, bucketScore, index, time) {
if (bucketScore === undefined || cellMouseoverActive === false) {
return;
@@ -348,8 +300,6 @@ export class ExplorerSwimlane extends React.Component {
mlChartTooltipService.hide();
}
- const that = this;
-
const d3Lanes = swimlanes.selectAll('.lane').data(lanes);
const d3LanesEnter = d3Lanes.enter().append('div').classed('lane', true);
@@ -358,8 +308,8 @@ export class ExplorerSwimlane extends React.Component {
.style('width', `${laneLabelWidth}px`)
.html(label => mlEscape(label))
.on('click', () => {
- if (typeof appState.mlExplorerSwimlane.selectedLanes !== 'undefined') {
- that.clearSelection();
+ if (typeof selection.selectedLanes !== 'undefined') {
+ mlExplorerDashboardService.swimlaneCellClick.changed({});
}
})
.each(function () {
@@ -371,16 +321,6 @@ export class ExplorerSwimlane extends React.Component {
}
});
- function cellMouseOverFactory(time, i) {
- // Don't use an arrow function here because we need access to `this`,
- // which is where d3 supplies a reference to the corresponding DOM element.
- return function (lane) {
- const bucketScore = getBucketScore(lane, time);
- if (bucketScore === 0) { return; }
- cellMouseover(this, lane, bucketScore, i, time);
- };
- }
-
const cellsContainer = d3LanesEnter.append('div').classed('cells-container', true);
function getBucketScore(lane, time) {
@@ -485,7 +425,55 @@ export class ExplorerSwimlane extends React.Component {
mlExplorerDashboardService.swimlaneRenderDone.changed();
- this.checkForSelection();
+ // 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', '');
+
+ // If a selection was done in the other swimlane, add the "masked" classes
+ // to de-emphasize the swimlane cells.
+ if (swimlaneType !== selectedType && selectedType !== undefined) {
+ element.selectAll('.lane-label').classed('lane-label-masked', true);
+ element.selectAll('.sl-cell-inner').classed('sl-cell-inner-masked', true);
+ }
+
+ if ((swimlaneType !== selectedType) ||
+ (swimlaneData.fieldName !== undefined && swimlaneData.fieldName !== viewBy)) {
+ // Not this swimlane which was selected.
+ return;
+ }
+
+ const cellsToSelect = [];
+ const selectedLanes = _.get(selectionState, 'selectedLanes', []);
+ const selectedTimes = _.get(selectionState, 'selectedTimes', []);
+ const selectedTimeExtent = d3.extent(selectedTimes);
+
+ selectedLanes.forEach((selectedLane) => {
+ if (lanes.indexOf(selectedLane) > -1 && selectedTimeExtent[0] >= startTime && selectedTimeExtent[1] <= endTime) {
+ // Locate matching cell - look for exact time, otherwise closest before.
+ const swimlaneElements = element.select('.ml-swimlanes');
+ const laneCells = swimlaneElements.selectAll(`div[data-lane-label="${mlEscape(selectedLane)}"]`);
+
+ laneCells.each(function () {
+ const cell = d3.select(this);
+ const cellTime = cell.attr('data-time');
+ if (cellTime >= selectedTimeExtent[0] && cellTime <= selectedTimeExtent[1]) {
+ cellsToSelect.push(cell.node());
+ }
+ });
+ }
+ });
+
+ const selectedMaxBucketScore = cellsToSelect.reduce((maxBucketScore, cell) => {
+ return Math.max(maxBucketScore, +d3.select(cell).attr('data-bucket-score') || 0);
+ }, 0);
+
+ if (cellsToSelect.length > 1 || selectedMaxBucketScore > 0) {
+ this.highlightSelection(cellsToSelect, selectedLanes, selectedTimes);
+ } else {
+ this.clearSelection();
+ }
}
shouldComponentUpdate() {
diff --git a/x-pack/plugins/ml/public/explorer/explorer_swimlane.test.js b/x-pack/plugins/ml/public/explorer/explorer_swimlane.test.js
index 46ecaf020badb8..278ac487bfc2e5 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_swimlane.test.js
+++ b/x-pack/plugins/ml/public/explorer/explorer_swimlane.test.js
@@ -13,11 +13,6 @@ import React from 'react';
import { ExplorerSwimlane } from './explorer_swimlane';
function getExplorerSwimlaneMocks() {
- const appState = {
- mlExplorerSwimlane: {},
- save: jest.fn()
- };
-
const mlExplorerDashboardService = {
allowCellRangeSelection: false,
dragSelect: {
@@ -39,16 +34,17 @@ function getExplorerSwimlaneMocks() {
const MlTimeBuckets = jest.fn(() => MlTimeBucketsMethods);
MlTimeBuckets.mockMethods = MlTimeBucketsMethods;
- const swimlaneData = {};
+ const swimlaneData = { laneLabels: [] };
return {
- appState,
mlExplorerDashboardService,
MlTimeBuckets,
swimlaneData
};
}
+const mockChartWidth = 800;
+
describe('ExplorerSwimlane', () => {
const mockedGetBBox = { x: 0, y: -11.5, width: 12.1875, height: 14.5 };
const originalGetBBox = SVGElement.prototype.getBBox;
@@ -65,24 +61,23 @@ describe('ExplorerSwimlane', () => {
const mocks = getExplorerSwimlaneMocks();
const wrapper = mount(
);
expect(wrapper.html()).toBe(
- `