From a25fc75f38038aa0a8071aab56c5b65d60cd0aea Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Mon, 24 Sep 2018 16:43:47 +0200 Subject: [PATCH] [ML] Fix Limit Dropdown, simplify state management of Anomaly Explorer. (#23388) - This fixes the limit dropdown behavior. The fix for that is actually just the $scope.appState.fetch(); statements in explorer_controller.js, they avoid to run the information stored in appState across modules out of sync. - Additionally, the aim of this PR is to simplify the state management of Anomaly Explorer in the context of selecting cells in the swimlanes and updating the influencers list, charts and table accordingly. --- .../plugins/ml/public/explorer/explorer.html | 18 +- .../explorer_charts_container_directive.js | 2 + .../ml/public/explorer/explorer_constants.js | 7 + .../ml/public/explorer/explorer_controller.js | 310 +++++++++++------- .../explorer/explorer_dashboard_service.js | 2 + .../ml/public/explorer/explorer_swimlane.js | 268 ++++++++------- .../public/explorer/explorer_swimlane.test.js | 30 +- .../explorer/explorer_swimlane_directive.js | 68 +--- .../ml/public/explorer/styles/main.less | 3 + 9 files changed, 358 insertions(+), 350 deletions(-) diff --git a/x-pack/plugins/ml/public/explorer/explorer.html b/x-pack/plugins/ml/public/explorer/explorer.html index 81f2b2e76985f1d..857a3755a777178 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 58b9994b6a650b3..45f2ae31cac2e84 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 cd5dee537a0f613..7d69bc563b3f4f1 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 6b7e7d29adfd62a..94604244dd43c79 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); }); @@ -301,12 +337,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') && @@ -339,14 +403,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; } } @@ -356,9 +421,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 }); }); } @@ -375,83 +443,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); @@ -459,8 +473,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( @@ -508,6 +521,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(); $(window).off('resize', jqueryRedrawOnResize); @@ -519,8 +533,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; } @@ -549,6 +563,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. @@ -700,6 +722,7 @@ module.controller('MlExplorerController', function ( } + $scope.appState.fetch(); $scope.appState.mlExplorerSwimlane.viewBy = $scope.swimlaneViewByFieldName; $scope.appState.save(); } @@ -743,7 +766,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) { @@ -761,8 +785,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); }); @@ -793,7 +816,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(); @@ -804,8 +846,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); } @@ -815,6 +856,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. @@ -822,7 +864,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 @@ -837,10 +879,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( @@ -849,11 +888,7 @@ module.controller('MlExplorerController', function ( searchBounds.max.valueOf(), interval, swimlaneLimit - ).then((resp) => { - processViewByResults(resp.results, fieldValues); - finish(); - }); - + ).then(finish); } } } @@ -861,7 +896,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. @@ -897,7 +932,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); @@ -952,22 +987,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() { @@ -1041,7 +1108,7 @@ module.controller('MlExplorerController', function ( }); } - $scope.overallSwimlaneData = dataset; + return dataset; } function processViewByResults(scoresByInfluencerAndTime, sortedLaneValues) { @@ -1052,7 +1119,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; @@ -1100,7 +1168,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 d18fb706f0d3246..ea6dcb0faab2b5b 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 2c0000a264fbe2d..baaa1cf94f78707 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 46ecaf020badb8a..278ac487bfc2e54 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( - `
` + + `
` + `
` ); // test calls to mock functions - expect(mocks.appState.save.mock.calls).toHaveLength(1); expect(mocks.mlExplorerDashboardService.swimlaneRenderDone.changed.mock.calls).toHaveLength(1); expect(mocks.mlExplorerDashboardService.dragSelect.watch.mock.calls).toHaveLength(1); expect(mocks.mlExplorerDashboardService.dragSelect.unwatch.mock.calls).toHaveLength(0); - expect(mocks.mlExplorerDashboardService.swimlaneCellClick.changed.mock.calls).toHaveLength(1); + expect(mocks.mlExplorerDashboardService.swimlaneCellClick.changed.mock.calls).toHaveLength(0); expect(mocks.MlTimeBuckets.mockMethods.setInterval.mock.calls).toHaveLength(1); expect(mocks.MlTimeBuckets.mockMethods.getScaledDateFormat.mock.calls).toHaveLength(1); }); @@ -91,23 +86,16 @@ describe('ExplorerSwimlane', () => { const mocks = getExplorerSwimlaneMocks(); const wrapper = mount(); expect(wrapper.html()).toMatchSnapshot(); // test calls to mock functions - expect(mocks.appState.save.mock.calls).toHaveLength(0); expect(mocks.mlExplorerDashboardService.swimlaneRenderDone.changed.mock.calls).toHaveLength(1); expect(mocks.mlExplorerDashboardService.dragSelect.watch.mock.calls).toHaveLength(1); expect(mocks.mlExplorerDashboardService.dragSelect.unwatch.mock.calls).toHaveLength(0); diff --git a/x-pack/plugins/ml/public/explorer/explorer_swimlane_directive.js b/x-pack/plugins/ml/public/explorer/explorer_swimlane_directive.js index fc7adcce51b6205..d57e829c3bdd482 100644 --- a/x-pack/plugins/ml/public/explorer/explorer_swimlane_directive.js +++ b/x-pack/plugins/ml/public/explorer/explorer_swimlane_directive.js @@ -7,29 +7,35 @@ /* - * AngularJS directive for rendering Explorer dashboard swimlanes. + * AngularJS directive wrapper for rendering Anomaly Explorer's ExplorerSwimlane React component. */ -import _ from 'lodash'; import React from 'react'; import ReactDOM from 'react-dom'; -import { IntervalHelperProvider } from 'plugins/ml/util/ml_time_buckets'; import { ExplorerSwimlane } from './explorer_swimlane'; import { uiModules } from 'ui/modules'; const module = uiModules.get('apps/ml'); -module.directive('mlExplorerSwimlane', function ($compile, Private, mlExplorerDashboardService) { +module.directive('mlExplorerSwimlane', function (mlExplorerDashboardService) { function link(scope, element) { - // Re-render the swimlane whenever the underlying data changes. - function swimlaneDataChangeListener(swimlaneType) { - if (swimlaneType === scope.swimlaneType) { - render(); + function swimlaneDataChangeListener(props) { + if ( + props.swimlaneType !== scope.swimlaneType || + props.swimlaneData === undefined || + props.swimlaneData.earliest === undefined || + props.swimlaneData.latest === undefined + ) { + return; } - } + ReactDOM.render( + React.createElement(ExplorerSwimlane, props), + element[0] + ); + } mlExplorerDashboardService.swimlaneDataChange.watch(swimlaneDataChangeListener); element.on('$destroy', () => { @@ -39,53 +45,11 @@ module.directive('mlExplorerSwimlane', function ($compile, Private, mlExplorerDa ReactDOM.unmountComponentAtNode(element[0]); scope.$destroy(); }); - - const MlTimeBuckets = Private(IntervalHelperProvider); - - // This triggers the render function quite aggressively, but we want to make sure we don't miss - // any updates to related scopes of directives and/or controllers. However, we do a deep comparison - // of current and future props to filter redundant render triggers. - scope.$watch(function () { - render(); - }); - let previousProps = null; - function render() { - if (scope.swimlaneData === undefined) { - return; - } - - const props = { - lanes: scope.swimlaneData.laneLabels, - startTime: scope.swimlaneData.earliest, - endTime: scope.swimlaneData.latest, - stepSecs: scope.swimlaneData.interval, - points: scope.swimlaneData.points, - chartWidth: scope.chartWidth, - MlTimeBuckets, - swimlaneData: scope.swimlaneData, - swimlaneType: scope.swimlaneType, - mlExplorerDashboardService, - appState: scope.appState - }; - - if (_.isEqual(props, previousProps) === false) { - ReactDOM.render( - React.createElement(ExplorerSwimlane, props), - element[0] - ); - previousProps = props; - } - - } } return { scope: { - swimlaneType: '@', - swimlaneData: '=', - selectedJobIds: '=', - chartWidth: '=', - appState: '=' + swimlaneType: '@' }, link }; diff --git a/x-pack/plugins/ml/public/explorer/styles/main.less b/x-pack/plugins/ml/public/explorer/styles/main.less index b3c297821bcbf25..462343bfcde3459 100644 --- a/x-pack/plugins/ml/public/explorer/styles/main.less +++ b/x-pack/plugins/ml/public/explorer/styles/main.less @@ -143,6 +143,9 @@ border-radius: 2px; margin-bottom: 5px; } + + width: 100%; + height: 250px; } ml-explorer-swimlane.ml-dragselect-dragging {