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( - `
` + + `
` + `
` ); // 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 fc7adcce51b620..d57e829c3bdd48 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 b3c297821bcbf2..462343bfcde345 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 {