mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[ML] explorer controller refactor (#28750)
Refactores the application logic of Anomaly Explorer to reduce relying on angularjs.
This commit is contained in:
parent
15658b7db3
commit
735cc82edd
12 changed files with 1600 additions and 1230 deletions
|
@ -19,9 +19,8 @@ describe('ML - Explorer Controller', () => {
|
|||
const scope = $rootScope.$new();
|
||||
$controller('MlExplorerController', { $scope: scope });
|
||||
|
||||
expect(Array.isArray(scope.annotationsData)).to.be(true);
|
||||
expect(Array.isArray(scope.anomalyChartRecords)).to.be(true);
|
||||
expect(scope.loading).to.be(true);
|
||||
expect(Array.isArray(scope.jobs)).to.be(true);
|
||||
expect(Array.isArray(scope.queryFilters)).to.be(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,9 +8,11 @@
|
|||
* React component for rendering Explorer dashboard swimlanes.
|
||||
*/
|
||||
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { FormattedMessage, injectI18n } from '@kbn/i18n/react';
|
||||
import DragSelect from 'dragselect';
|
||||
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
|
@ -22,18 +24,54 @@ import {
|
|||
} from '@elastic/eui';
|
||||
|
||||
import { AnnotationsTable } from '../components/annotations_table';
|
||||
import { CheckboxShowCharts } from '../components/controls/checkbox_showcharts/checkbox_showcharts';
|
||||
import {
|
||||
ExplorerNoInfluencersFound,
|
||||
ExplorerNoJobsFound,
|
||||
ExplorerNoResultsFound,
|
||||
} from './components';
|
||||
import { ExplorerSwimlane } from './explorer_swimlane';
|
||||
import { formatHumanReadableDateTime } from '../util/date_utils';
|
||||
import { getBoundsRoundedToInterval } from 'plugins/ml/util/ml_time_buckets';
|
||||
import { InfluencersList } from '../components/influencers_list';
|
||||
import { mlExplorerDashboardService } from './explorer_dashboard_service';
|
||||
import { mlResultsService } from 'plugins/ml/services/results_service';
|
||||
import { LoadingIndicator } from '../components/loading_indicator/loading_indicator';
|
||||
import { SelectInterval } from '../components/controls/select_interval/select_interval';
|
||||
import { SelectLimit } from './select_limit/select_limit';
|
||||
import { SelectSeverity } from '../components/controls/select_severity/select_severity';
|
||||
import { CheckboxShowCharts, mlCheckboxShowChartsService } from '../components/controls/checkbox_showcharts/checkbox_showcharts';
|
||||
import { SelectInterval, mlSelectIntervalService } from '../components/controls/select_interval/select_interval';
|
||||
import { SelectLimit, mlSelectLimitService } from './select_limit/select_limit';
|
||||
import { SelectSeverity, mlSelectSeverityService } from '../components/controls/select_severity/select_severity';
|
||||
|
||||
import {
|
||||
getClearedSelectedAnomaliesState,
|
||||
getDefaultViewBySwimlaneData,
|
||||
getFilteredTopInfluencers,
|
||||
getSelectionInfluencers,
|
||||
getSelectionTimeRange,
|
||||
getViewBySwimlaneOptions,
|
||||
loadAnnotationsTableData,
|
||||
loadAnomaliesTableData,
|
||||
loadDataForCharts,
|
||||
loadTopInfluencers,
|
||||
processOverallResults,
|
||||
processViewByResults,
|
||||
selectedJobsHaveInfluencers,
|
||||
} from './explorer_utils';
|
||||
import {
|
||||
explorerChartsContainerServiceFactory,
|
||||
getDefaultChartsData
|
||||
} from './explorer_charts/explorer_charts_container_service';
|
||||
import {
|
||||
getSwimlaneContainerWidth
|
||||
} from './legacy_utils';
|
||||
|
||||
import {
|
||||
DRAG_SELECT_ACTION,
|
||||
APP_STATE_ACTION,
|
||||
EXPLORER_ACTION,
|
||||
SWIMLANE_DEFAULT_LIMIT,
|
||||
SWIMLANE_TYPE,
|
||||
VIEW_BY_JOB_LABEL,
|
||||
} from './explorer_constants';
|
||||
|
||||
// Explorer Charts
|
||||
import { ExplorerChartsContainer } from './explorer_charts/explorer_charts_container';
|
||||
|
@ -42,6 +80,28 @@ import { ExplorerChartsContainer } from './explorer_charts/explorer_charts_conta
|
|||
import { AnomaliesTable } from '../components/anomalies_table/anomalies_table';
|
||||
import { timefilter } from 'ui/timefilter';
|
||||
|
||||
function getExplorerDefaultState() {
|
||||
return {
|
||||
annotationsData: [],
|
||||
anomalyChartRecords: [],
|
||||
chartsData: getDefaultChartsData(),
|
||||
hasResults: false,
|
||||
influencers: {},
|
||||
loading: true,
|
||||
noInfluencersConfigured: true,
|
||||
noJobsFound: true,
|
||||
overallSwimlaneData: [],
|
||||
selectedCells: null,
|
||||
selectedJobs: null,
|
||||
swimlaneViewByFieldName: undefined,
|
||||
tableData: {},
|
||||
viewByLoadedForTimeFormatted: null,
|
||||
viewBySwimlaneData: getDefaultViewBySwimlaneData(),
|
||||
viewBySwimlaneDataLoading: false,
|
||||
viewBySwimlaneOptions: [],
|
||||
};
|
||||
}
|
||||
|
||||
function mapSwimlaneOptionsToEuiOptions(options) {
|
||||
return options.map(option => ({
|
||||
value: option,
|
||||
|
@ -52,48 +112,775 @@ function mapSwimlaneOptionsToEuiOptions(options) {
|
|||
export const Explorer = injectI18n(
|
||||
class Explorer extends React.Component {
|
||||
static propTypes = {
|
||||
annotationsData: PropTypes.array,
|
||||
anomalyChartRecords: PropTypes.array,
|
||||
hasResults: PropTypes.bool,
|
||||
influencers: PropTypes.object,
|
||||
jobs: PropTypes.array,
|
||||
loading: PropTypes.bool,
|
||||
noInfluencersConfigured: PropTypes.bool,
|
||||
setSwimlaneSelectActive: PropTypes.func,
|
||||
setSwimlaneViewBy: PropTypes.func,
|
||||
showViewBySwimlane: PropTypes.bool,
|
||||
swimlaneOverall: PropTypes.object,
|
||||
swimlaneViewByFieldName: PropTypes.string,
|
||||
tableData: PropTypes.object,
|
||||
viewByLoadedForTimeFormatted: PropTypes.any,
|
||||
viewBySwimlaneOptions: PropTypes.array,
|
||||
appStateHandler: PropTypes.func.isRequired,
|
||||
dateFormatTz: PropTypes.string.isRequired,
|
||||
mlJobSelectService: PropTypes.object.isRequired,
|
||||
MlTimeBuckets: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
viewByChangeHandler = e => this.props.setSwimlaneViewBy(e.target.value);
|
||||
state = getExplorerDefaultState();
|
||||
|
||||
onSwimlaneEnterHandler = () => this.props.setSwimlaneSelectActive(true);
|
||||
onSwimlaneLeaveHandler = () => this.props.setSwimlaneSelectActive(false);
|
||||
// helper to avoid calling `setState()` in the listener for chart updates.
|
||||
_isMounted = false;
|
||||
|
||||
// make sure dragSelect is only available if the mouse pointer is actually over a swimlane
|
||||
disableDragSelectOnMouseLeave = true;
|
||||
// skip listening to clicks on swimlanes while they are loading to avoid race conditions
|
||||
skipCellClicks = true;
|
||||
|
||||
updateCharts = explorerChartsContainerServiceFactory((data) => {
|
||||
if (this._isMounted) {
|
||||
this.setState({
|
||||
chartsData: {
|
||||
...getDefaultChartsData(),
|
||||
chartsPerRow: data.chartsPerRow,
|
||||
seriesToPlot: data.seriesToPlot,
|
||||
// convert truthy/falsy value to Boolean
|
||||
tooManyBuckets: !!data.tooManyBuckets,
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ALLOW_CELL_RANGE_SELECTION = mlExplorerDashboardService.allowCellRangeSelection;
|
||||
|
||||
dragSelect = new DragSelect({
|
||||
selectables: document.getElementsByClassName('sl-cell'),
|
||||
callback(elements) {
|
||||
if (elements.length > 1 && !this.ALLOW_CELL_RANGE_SELECTION) {
|
||||
elements = [elements[0]];
|
||||
}
|
||||
|
||||
if (elements.length > 0) {
|
||||
mlExplorerDashboardService.dragSelect.changed({
|
||||
action: DRAG_SELECT_ACTION.NEW_SELECTION,
|
||||
elements
|
||||
});
|
||||
}
|
||||
|
||||
this.disableDragSelectOnMouseLeave = true;
|
||||
},
|
||||
onDragStart() {
|
||||
if (this.ALLOW_CELL_RANGE_SELECTION) {
|
||||
mlExplorerDashboardService.dragSelect.changed({
|
||||
action: DRAG_SELECT_ACTION.DRAG_START
|
||||
});
|
||||
this.disableDragSelectOnMouseLeave = false;
|
||||
}
|
||||
},
|
||||
onElementSelect() {
|
||||
if (this.ALLOW_CELL_RANGE_SELECTION) {
|
||||
mlExplorerDashboardService.dragSelect.changed({
|
||||
action: DRAG_SELECT_ACTION.ELEMENT_SELECT
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
dashboardListener = ((action, payload = {}) => {
|
||||
// Listen to the initial loading of jobs
|
||||
if (action === EXPLORER_ACTION.INITIALIZE) {
|
||||
const { noJobsFound, selectedCells, selectedJobs, swimlaneViewByFieldName } = payload;
|
||||
let currentSelectedCells = this.state.selectedCells;
|
||||
let currentSwimlaneViewByFieldName = this.state.swimlaneViewByFieldName;
|
||||
|
||||
if (selectedCells !== undefined && currentSelectedCells === null) {
|
||||
currentSelectedCells = selectedCells;
|
||||
currentSwimlaneViewByFieldName = swimlaneViewByFieldName;
|
||||
}
|
||||
|
||||
const stateUpdate = {
|
||||
noInfluencersConfigured: !selectedJobsHaveInfluencers(selectedJobs),
|
||||
noJobsFound,
|
||||
selectedCells: currentSelectedCells,
|
||||
selectedJobs,
|
||||
swimlaneViewByFieldName: currentSwimlaneViewByFieldName
|
||||
};
|
||||
|
||||
this.updateExplorer(stateUpdate, true);
|
||||
}
|
||||
|
||||
// Listen for changes to job selection.
|
||||
if (action === EXPLORER_ACTION.JOB_SELECTION_CHANGE) {
|
||||
const { selectedJobs } = payload;
|
||||
const stateUpdate = {
|
||||
noInfluencersConfigured: !selectedJobsHaveInfluencers(selectedJobs),
|
||||
selectedJobs,
|
||||
};
|
||||
|
||||
this.props.appStateHandler(APP_STATE_ACTION.CLEAR_SELECTION);
|
||||
Object.assign(stateUpdate, getClearedSelectedAnomaliesState());
|
||||
|
||||
if (selectedJobs.length > 1) {
|
||||
this.props.appStateHandler(
|
||||
APP_STATE_ACTION.SAVE_SWIMLANE_VIEW_BY_FIELD_NAME,
|
||||
{ swimlaneViewByFieldName: VIEW_BY_JOB_LABEL },
|
||||
);
|
||||
stateUpdate.swimlaneViewByFieldName = VIEW_BY_JOB_LABEL;
|
||||
// enforce a state update for swimlaneViewByFieldName
|
||||
this.setState({ swimlaneViewByFieldName: VIEW_BY_JOB_LABEL }, () => {
|
||||
this.updateExplorer(stateUpdate, true);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateExplorer(stateUpdate, true);
|
||||
}
|
||||
|
||||
// RELOAD reloads full Anomaly Explorer and clears the selection.
|
||||
if (action === EXPLORER_ACTION.RELOAD) {
|
||||
this.props.appStateHandler(APP_STATE_ACTION.CLEAR_SELECTION);
|
||||
this.updateExplorer({ ...payload, ...getClearedSelectedAnomaliesState() }, true);
|
||||
}
|
||||
|
||||
// REDRAW reloads Anomaly Explorer and tries to retain the selection.
|
||||
if (action === EXPLORER_ACTION.REDRAW) {
|
||||
this.updateExplorer({}, false);
|
||||
}
|
||||
});
|
||||
|
||||
checkboxShowChartsListener = () => {
|
||||
const showCharts = mlCheckboxShowChartsService.state.get('showCharts');
|
||||
const { selectedCells, selectedJobs } = this.state;
|
||||
|
||||
const timerange = getSelectionTimeRange(
|
||||
selectedCells,
|
||||
this.getSwimlaneBucketInterval(selectedJobs).asSeconds()
|
||||
);
|
||||
|
||||
if (showCharts && selectedCells !== null) {
|
||||
this.updateCharts(
|
||||
this.state.anomalyChartRecords, timerange.earliestMs, timerange.latestMs
|
||||
);
|
||||
} else {
|
||||
this.updateCharts(
|
||||
[], timerange.earliestMs, timerange.latestMs
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
anomalyChartsSeverityListener = () => {
|
||||
const showCharts = mlCheckboxShowChartsService.state.get('showCharts');
|
||||
const { anomalyChartRecords, selectedCells, selectedJobs } = this.state;
|
||||
if (showCharts && selectedCells !== null) {
|
||||
const timerange = getSelectionTimeRange(
|
||||
selectedCells,
|
||||
this.getSwimlaneBucketInterval(selectedJobs).asSeconds()
|
||||
);
|
||||
this.updateCharts(
|
||||
anomalyChartRecords, timerange.earliestMs, timerange.latestMs
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
tableControlsListener = async () => {
|
||||
const { dateFormatTz } = this.props;
|
||||
const { selectedCells, swimlaneViewByFieldName, selectedJobs } = this.state;
|
||||
this.setState({
|
||||
tableData: await loadAnomaliesTableData(
|
||||
selectedCells,
|
||||
selectedJobs,
|
||||
dateFormatTz,
|
||||
this.getSwimlaneBucketInterval(selectedJobs).asSeconds(),
|
||||
swimlaneViewByFieldName
|
||||
)
|
||||
});
|
||||
};
|
||||
|
||||
swimlaneLimitListener = () => {
|
||||
this.props.appStateHandler(APP_STATE_ACTION.CLEAR_SELECTION);
|
||||
this.updateExplorer(getClearedSelectedAnomaliesState(), false);
|
||||
};
|
||||
|
||||
// Listens to render updates of the swimlanes to update dragSelect
|
||||
swimlaneRenderDoneListener = () => {
|
||||
this.dragSelect.clearSelection();
|
||||
this.dragSelect.setSelectables(document.getElementsByClassName('sl-cell'));
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
mlExplorerDashboardService.explorer.watch(this.dashboardListener);
|
||||
mlCheckboxShowChartsService.state.watch(this.checkboxShowChartsListener);
|
||||
mlSelectLimitService.state.watch(this.swimlaneLimitListener);
|
||||
mlSelectSeverityService.state.watch(this.anomalyChartsSeverityListener);
|
||||
mlSelectIntervalService.state.watch(this.tableControlsListener);
|
||||
mlSelectSeverityService.state.watch(this.tableControlsListener);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
mlExplorerDashboardService.explorer.unwatch(this.dashboardListener);
|
||||
mlCheckboxShowChartsService.state.unwatch(this.checkboxShowChartsListener);
|
||||
mlSelectLimitService.state.unwatch(this.swimlaneLimitListener);
|
||||
mlSelectSeverityService.state.unwatch(this.anomalyChartsSeverityListener);
|
||||
mlSelectIntervalService.state.unwatch(this.tableControlsListener);
|
||||
mlSelectSeverityService.state.unwatch(this.tableControlsListener);
|
||||
}
|
||||
|
||||
getSwimlaneBucketInterval(selectedJobs) {
|
||||
const { MlTimeBuckets } = this.props;
|
||||
|
||||
const swimlaneWidth = getSwimlaneContainerWidth(this.state.noInfluencersConfigured);
|
||||
// Bucketing interval should be the maximum of the chart related interval (i.e. time range related)
|
||||
// and the max bucket span for the jobs shown in the chart.
|
||||
const bounds = timefilter.getActiveBounds();
|
||||
const buckets = new MlTimeBuckets();
|
||||
buckets.setInterval('auto');
|
||||
buckets.setBounds(bounds);
|
||||
|
||||
const intervalSeconds = buckets.getInterval().asSeconds();
|
||||
|
||||
// if the swimlane cell widths are too small they will not be visible
|
||||
// calculate how many buckets will be drawn before the swimlanes are actually rendered
|
||||
// and increase the interval to widen the cells if they're going to be smaller than 8px
|
||||
// this has to be done at this stage so all searches use the same interval
|
||||
const timerangeSeconds = (bounds.max.valueOf() - bounds.min.valueOf()) / 1000;
|
||||
const numBuckets = parseInt(timerangeSeconds / intervalSeconds);
|
||||
const cellWidth = Math.floor(swimlaneWidth / numBuckets * 100) / 100;
|
||||
|
||||
// if the cell width is going to be less than 8px, double the interval
|
||||
if (cellWidth < 8) {
|
||||
buckets.setInterval((intervalSeconds * 2) + 's');
|
||||
}
|
||||
|
||||
const maxBucketSpanSeconds = selectedJobs.reduce((memo, job) => Math.max(memo, job.bucketSpanSeconds), 0);
|
||||
if (maxBucketSpanSeconds > intervalSeconds) {
|
||||
buckets.setInterval(maxBucketSpanSeconds + 's');
|
||||
buckets.setBounds(bounds);
|
||||
}
|
||||
|
||||
return buckets.getInterval();
|
||||
}
|
||||
|
||||
loadOverallDataPreviousArgs = null;
|
||||
loadOverallDataPreviousData = null;
|
||||
loadOverallData(selectedJobs, interval, showLoadingIndicator = true) {
|
||||
return new Promise((resolve) => {
|
||||
// Loads the overall data components i.e. the overall swimlane and influencers list.
|
||||
if (selectedJobs === null) {
|
||||
resolve({
|
||||
loading: false,
|
||||
hasResuts: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// check if we can just return existing cached data
|
||||
const compareArgs = {
|
||||
selectedJobs,
|
||||
intervalAsSeconds: interval.asSeconds()
|
||||
};
|
||||
|
||||
if (_.isEqual(compareArgs, this.loadOverallDataPreviousArgs)) {
|
||||
const overallSwimlaneData = this.loadOverallDataPreviousData;
|
||||
const hasResults = (overallSwimlaneData.points && overallSwimlaneData.points.length > 0);
|
||||
resolve({
|
||||
hasResults,
|
||||
loading: false,
|
||||
overallSwimlaneData,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadOverallDataPreviousArgs = compareArgs;
|
||||
|
||||
if (showLoadingIndicator) {
|
||||
this.setState({ hasResults: false, loading: true });
|
||||
}
|
||||
|
||||
// Ensure the search bounds align to the bucketing interval used in the swimlane so
|
||||
// that the first and last buckets are complete.
|
||||
const bounds = timefilter.getActiveBounds();
|
||||
const searchBounds = getBoundsRoundedToInterval(
|
||||
bounds,
|
||||
interval,
|
||||
false
|
||||
);
|
||||
const selectedJobIds = selectedJobs.map(d => d.id);
|
||||
|
||||
// Load the overall bucket scores by time.
|
||||
// Pass the interval in seconds as the swimlane relies on a fixed number of seconds between buckets
|
||||
// which wouldn't be the case if e.g. '1M' was used.
|
||||
// Pass 'true' when obtaining bucket bounds due to the way the overall_buckets endpoint works
|
||||
// to ensure the search is inclusive of end time.
|
||||
const overallBucketsBounds = getBoundsRoundedToInterval(
|
||||
bounds,
|
||||
interval,
|
||||
true
|
||||
);
|
||||
mlResultsService.getOverallBucketScores(
|
||||
selectedJobIds,
|
||||
// Note there is an optimization for when top_n == 1.
|
||||
// If top_n > 1, we should test what happens when the request takes long
|
||||
// and refactor the loading calls, if necessary, to avoid delays in loading other components.
|
||||
1,
|
||||
overallBucketsBounds.min.valueOf(),
|
||||
overallBucketsBounds.max.valueOf(),
|
||||
interval.asSeconds() + 's'
|
||||
).then((resp) => {
|
||||
this.skipCellClicks = false;
|
||||
const overallSwimlaneData = processOverallResults(
|
||||
resp.results,
|
||||
searchBounds,
|
||||
interval.asSeconds(),
|
||||
);
|
||||
this.loadOverallDataPreviousData = overallSwimlaneData;
|
||||
|
||||
console.log('Explorer overall swimlane data set:', overallSwimlaneData);
|
||||
const hasResults = (overallSwimlaneData.points && overallSwimlaneData.points.length > 0);
|
||||
resolve({
|
||||
hasResults,
|
||||
loading: false,
|
||||
overallSwimlaneData,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
loadViewBySwimlanePreviousArgs = null;
|
||||
loadViewBySwimlanePreviousData = null;
|
||||
loadViewBySwimlane(fieldValues, overallSwimlaneData, selectedJobs, swimlaneViewByFieldName) {
|
||||
const limit = mlSelectLimitService.state.get('limit');
|
||||
const swimlaneLimit = (limit === undefined) ? SWIMLANE_DEFAULT_LIMIT : limit.val;
|
||||
|
||||
const compareArgs = {
|
||||
fieldValues,
|
||||
overallSwimlaneData,
|
||||
selectedJobs,
|
||||
swimlaneLimit,
|
||||
swimlaneViewByFieldName,
|
||||
interval: this.getSwimlaneBucketInterval(selectedJobs).asSeconds()
|
||||
};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.skipCellClicks = true;
|
||||
|
||||
// check if we can just return existing cached data
|
||||
if (_.isEqual(compareArgs, this.loadViewBySwimlanePreviousArgs)) {
|
||||
this.skipCellClicks = false;
|
||||
|
||||
resolve({
|
||||
viewBySwimlaneData: this.loadViewBySwimlanePreviousData,
|
||||
viewBySwimlaneDataLoading: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
viewBySwimlaneData: getDefaultViewBySwimlaneData(),
|
||||
viewBySwimlaneDataLoading: true
|
||||
});
|
||||
|
||||
const finish = (resp) => {
|
||||
this.skipCellClicks = false;
|
||||
if (resp !== undefined) {
|
||||
const viewBySwimlaneData = processViewByResults(
|
||||
resp.results,
|
||||
fieldValues,
|
||||
overallSwimlaneData,
|
||||
swimlaneViewByFieldName,
|
||||
this.getSwimlaneBucketInterval(selectedJobs).asSeconds(),
|
||||
);
|
||||
this.loadViewBySwimlanePreviousArgs = compareArgs;
|
||||
this.loadViewBySwimlanePreviousData = viewBySwimlaneData;
|
||||
console.log('Explorer view by swimlane data set:', viewBySwimlaneData);
|
||||
|
||||
resolve({
|
||||
viewBySwimlaneData,
|
||||
viewBySwimlaneDataLoading: false
|
||||
});
|
||||
} else {
|
||||
resolve({ viewBySwimlaneDataLoading: false });
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
selectedJobs === undefined ||
|
||||
swimlaneViewByFieldName === undefined
|
||||
) {
|
||||
finish();
|
||||
return;
|
||||
} else {
|
||||
// Ensure the search bounds align to the bucketing interval used in the swimlane so
|
||||
// that the first and last buckets are complete.
|
||||
const bounds = timefilter.getActiveBounds();
|
||||
const searchBounds = getBoundsRoundedToInterval(
|
||||
bounds,
|
||||
this.getSwimlaneBucketInterval(selectedJobs),
|
||||
false,
|
||||
);
|
||||
const selectedJobIds = selectedJobs.map(d => d.id);
|
||||
|
||||
// load scores by influencer/jobId value and time.
|
||||
// Pass the interval in seconds as the swimlane relies on a fixed number of seconds between buckets
|
||||
// which wouldn't be the case if e.g. '1M' was used.
|
||||
const interval = `${this.getSwimlaneBucketInterval(selectedJobs).asSeconds()}s`;
|
||||
if (swimlaneViewByFieldName !== VIEW_BY_JOB_LABEL) {
|
||||
mlResultsService.getInfluencerValueMaxScoreByTime(
|
||||
selectedJobIds,
|
||||
swimlaneViewByFieldName,
|
||||
fieldValues,
|
||||
searchBounds.min.valueOf(),
|
||||
searchBounds.max.valueOf(),
|
||||
interval,
|
||||
swimlaneLimit
|
||||
).then(finish);
|
||||
} else {
|
||||
const jobIds = (fieldValues !== undefined && fieldValues.length > 0) ? fieldValues : selectedJobIds;
|
||||
mlResultsService.getScoresByBucket(
|
||||
jobIds,
|
||||
searchBounds.min.valueOf(),
|
||||
searchBounds.max.valueOf(),
|
||||
interval,
|
||||
swimlaneLimit
|
||||
).then(finish);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
topFieldsPreviousArgs = null;
|
||||
topFieldsPreviousData = null;
|
||||
loadViewByTopFieldValuesForSelectedTime(earliestMs, latestMs, selectedJobs, swimlaneViewByFieldName) {
|
||||
const selectedJobIds = selectedJobs.map(d => d.id);
|
||||
const limit = mlSelectLimitService.state.get('limit');
|
||||
const swimlaneLimit = (limit === undefined) ? SWIMLANE_DEFAULT_LIMIT : limit.val;
|
||||
|
||||
const compareArgs = {
|
||||
earliestMs, latestMs, selectedJobIds, swimlaneLimit, swimlaneViewByFieldName,
|
||||
interval: this.getSwimlaneBucketInterval(selectedJobs).asSeconds()
|
||||
};
|
||||
|
||||
// Find the top field values for the selected time, and then load the 'view by'
|
||||
// swimlane over the full time range for those specific field values.
|
||||
return new Promise((resolve) => {
|
||||
if (_.isEqual(compareArgs, this.topFieldsPreviousArgs)) {
|
||||
resolve(this.topFieldsPreviousData);
|
||||
return;
|
||||
}
|
||||
this.topFieldsPreviousArgs = compareArgs;
|
||||
|
||||
if (swimlaneViewByFieldName !== VIEW_BY_JOB_LABEL) {
|
||||
mlResultsService.getTopInfluencers(
|
||||
selectedJobIds,
|
||||
earliestMs,
|
||||
latestMs,
|
||||
swimlaneLimit
|
||||
).then((resp) => {
|
||||
if (resp.influencers[swimlaneViewByFieldName] === undefined) {
|
||||
this.topFieldsPreviousData = [];
|
||||
resolve([]);
|
||||
}
|
||||
|
||||
const topFieldValues = [];
|
||||
const topInfluencers = resp.influencers[swimlaneViewByFieldName];
|
||||
topInfluencers.forEach((influencerData) => {
|
||||
if (influencerData.maxAnomalyScore > 0) {
|
||||
topFieldValues.push(influencerData.influencerFieldValue);
|
||||
}
|
||||
});
|
||||
this.topFieldsPreviousData = topFieldValues;
|
||||
resolve(topFieldValues);
|
||||
});
|
||||
} else {
|
||||
mlResultsService.getScoresByBucket(
|
||||
selectedJobIds,
|
||||
earliestMs,
|
||||
latestMs,
|
||||
this.getSwimlaneBucketInterval(selectedJobs).asSeconds() + 's',
|
||||
swimlaneLimit
|
||||
).then((resp) => {
|
||||
const topFieldValues = Object.keys(resp.results);
|
||||
this.topFieldsPreviousData = topFieldValues;
|
||||
resolve(topFieldValues);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
anomaliesTablePreviousArgs = null;
|
||||
anomaliesTablePreviousData = null;
|
||||
annotationsTablePreviousArgs = null;
|
||||
annotationsTablePreviousData = null;
|
||||
async updateExplorer(stateUpdate, showOverallLoadingIndicator = true) {
|
||||
const {
|
||||
noInfluencersConfigured,
|
||||
noJobsFound,
|
||||
selectedCells,
|
||||
selectedJobs,
|
||||
} = {
|
||||
...this.state,
|
||||
...stateUpdate
|
||||
};
|
||||
|
||||
this.skipCellClicks = false;
|
||||
|
||||
if (noJobsFound) {
|
||||
this.setState(stateUpdate);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.swimlaneCellClickQueue.length > 0) {
|
||||
this.setState(stateUpdate);
|
||||
|
||||
const latestSelectedCells = this.swimlaneCellClickQueue.pop();
|
||||
this.swimlaneCellClickQueue.length = 0;
|
||||
this.swimlaneCellClick(latestSelectedCells);
|
||||
return;
|
||||
}
|
||||
|
||||
const { dateFormatTz } = this.props;
|
||||
|
||||
const jobIds = (selectedCells !== null && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL)
|
||||
? selectedCells.lanes
|
||||
: selectedJobs.map(d => d.id);
|
||||
|
||||
const timerange = getSelectionTimeRange(
|
||||
selectedCells,
|
||||
this.getSwimlaneBucketInterval(selectedJobs).asSeconds()
|
||||
);
|
||||
|
||||
// Load the overall data - if the FieldFormats failed to populate
|
||||
// the default formatting will be used for metric values.
|
||||
Object.assign(
|
||||
stateUpdate,
|
||||
await this.loadOverallData(
|
||||
selectedJobs,
|
||||
this.getSwimlaneBucketInterval(selectedJobs),
|
||||
showOverallLoadingIndicator,
|
||||
)
|
||||
);
|
||||
|
||||
const { overallSwimlaneData } = stateUpdate;
|
||||
|
||||
const annotationsTableCompareArgs = {
|
||||
selectedCells,
|
||||
selectedJobs,
|
||||
interval: this.getSwimlaneBucketInterval(selectedJobs).asSeconds()
|
||||
};
|
||||
|
||||
if (_.isEqual(annotationsTableCompareArgs, this.annotationsTablePreviousArgs)) {
|
||||
stateUpdate.annotationsData = this.annotationsTablePreviousData;
|
||||
} else {
|
||||
this.annotationsTablePreviousArgs = annotationsTableCompareArgs;
|
||||
stateUpdate.annotationsData = this.annotationsTablePreviousData = await loadAnnotationsTableData(
|
||||
selectedCells,
|
||||
selectedJobs,
|
||||
this.getSwimlaneBucketInterval(selectedJobs).asSeconds()
|
||||
);
|
||||
}
|
||||
|
||||
const viewBySwimlaneOptions = getViewBySwimlaneOptions(selectedJobs, this.state.swimlaneViewByFieldName);
|
||||
Object.assign(stateUpdate, viewBySwimlaneOptions);
|
||||
if (selectedCells !== null && selectedCells.showTopFieldValues === true) {
|
||||
// this.setState({ viewBySwimlaneData: getDefaultViewBySwimlaneData(), viewBySwimlaneDataLoading: true });
|
||||
// Click is in one of the cells in the Overall swimlane - reload the 'view by' swimlane
|
||||
// to show the top 'view by' values for the selected time.
|
||||
const topFieldValues = await this.loadViewByTopFieldValuesForSelectedTime(
|
||||
timerange.earliestMs,
|
||||
timerange.latestMs,
|
||||
selectedJobs,
|
||||
viewBySwimlaneOptions.swimlaneViewByFieldName
|
||||
);
|
||||
Object.assign(
|
||||
stateUpdate,
|
||||
await this.loadViewBySwimlane(
|
||||
topFieldValues,
|
||||
overallSwimlaneData,
|
||||
selectedJobs,
|
||||
viewBySwimlaneOptions.swimlaneViewByFieldName
|
||||
),
|
||||
{ viewByLoadedForTimeFormatted: formatHumanReadableDateTime(timerange.earliestMs) }
|
||||
);
|
||||
} else {
|
||||
Object.assign(
|
||||
stateUpdate,
|
||||
viewBySwimlaneOptions,
|
||||
await this.loadViewBySwimlane(
|
||||
[],
|
||||
overallSwimlaneData,
|
||||
selectedJobs,
|
||||
viewBySwimlaneOptions.swimlaneViewByFieldName
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const { viewBySwimlaneData } = stateUpdate;
|
||||
|
||||
// do a sanity check against selectedCells. It can happen that a previously
|
||||
// selected lane loaded via URL/AppState is not available anymore.
|
||||
let clearSelection = false;
|
||||
if (
|
||||
selectedCells !== null &&
|
||||
selectedCells.type === SWIMLANE_TYPE.VIEW_BY
|
||||
) {
|
||||
clearSelection = !selectedCells.lanes.some((lane) => {
|
||||
return viewBySwimlaneData.points.some((point) => {
|
||||
return (
|
||||
point.laneLabel === lane &&
|
||||
point.time === selectedCells.times[0]
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (clearSelection === true) {
|
||||
this.props.appStateHandler(APP_STATE_ACTION.CLEAR_SELECTION);
|
||||
Object.assign(stateUpdate, getClearedSelectedAnomaliesState());
|
||||
}
|
||||
|
||||
const selectionInfluencers = getSelectionInfluencers(selectedCells, viewBySwimlaneOptions.swimlaneViewByFieldName);
|
||||
|
||||
if (selectionInfluencers.length === 0) {
|
||||
stateUpdate.influencers = await loadTopInfluencers(jobIds, timerange.earliestMs, timerange.latestMs, noInfluencersConfigured);
|
||||
}
|
||||
|
||||
const updatedAnomalyChartRecords = await loadDataForCharts(
|
||||
jobIds, timerange.earliestMs, timerange.latestMs, selectionInfluencers, selectedCells
|
||||
);
|
||||
|
||||
if (selectionInfluencers.length > 0 && updatedAnomalyChartRecords !== undefined) {
|
||||
stateUpdate.influencers = await getFilteredTopInfluencers(
|
||||
jobIds,
|
||||
timerange.earliestMs,
|
||||
timerange.latestMs,
|
||||
updatedAnomalyChartRecords,
|
||||
selectionInfluencers,
|
||||
noInfluencersConfigured,
|
||||
);
|
||||
}
|
||||
|
||||
stateUpdate.anomalyChartRecords = updatedAnomalyChartRecords || [];
|
||||
|
||||
this.setState(stateUpdate);
|
||||
|
||||
if (mlCheckboxShowChartsService.state.get('showCharts') && selectedCells !== null) {
|
||||
this.updateCharts(
|
||||
stateUpdate.anomalyChartRecords, timerange.earliestMs, timerange.latestMs
|
||||
);
|
||||
} else {
|
||||
this.updateCharts(
|
||||
[], timerange.earliestMs, timerange.latestMs
|
||||
);
|
||||
}
|
||||
|
||||
const anomaliesTableCompareArgs = {
|
||||
selectedCells,
|
||||
selectedJobs,
|
||||
dateFormatTz,
|
||||
interval: this.getSwimlaneBucketInterval(selectedJobs).asSeconds(),
|
||||
swimlaneViewByFieldName: viewBySwimlaneOptions.swimlaneViewByFieldName,
|
||||
};
|
||||
|
||||
if (_.isEqual(anomaliesTableCompareArgs, this.anomaliesTablePreviousArgs)) {
|
||||
this.setState(this.anomaliesTablePreviousData);
|
||||
} else {
|
||||
this.anomaliesTablePreviousArgs = anomaliesTableCompareArgs;
|
||||
const tableData = this.anomaliesTablePreviousData = await loadAnomaliesTableData(
|
||||
selectedCells,
|
||||
selectedJobs,
|
||||
dateFormatTz,
|
||||
this.getSwimlaneBucketInterval(selectedJobs).asSeconds(),
|
||||
viewBySwimlaneOptions.swimlaneViewByFieldName
|
||||
);
|
||||
this.setState({ tableData });
|
||||
}
|
||||
}
|
||||
|
||||
viewByChangeHandler = e => this.setSwimlaneViewBy(e.target.value);
|
||||
setSwimlaneViewBy = (swimlaneViewByFieldName) => {
|
||||
this.props.appStateHandler(APP_STATE_ACTION.CLEAR_SELECTION);
|
||||
this.props.appStateHandler(APP_STATE_ACTION.SAVE_SWIMLANE_VIEW_BY_FIELD_NAME, { swimlaneViewByFieldName });
|
||||
this.setState({ swimlaneViewByFieldName }, () => {
|
||||
this.updateExplorer({
|
||||
swimlaneViewByFieldName,
|
||||
...getClearedSelectedAnomaliesState(),
|
||||
}, false);
|
||||
});
|
||||
};
|
||||
|
||||
onSwimlaneEnterHandler = () => this.setSwimlaneSelectActive(true);
|
||||
onSwimlaneLeaveHandler = () => this.setSwimlaneSelectActive(false);
|
||||
setSwimlaneSelectActive = (active) => {
|
||||
if (!active && this.disableDragSelectOnMouseLeave) {
|
||||
this.dragSelect.clearSelection();
|
||||
this.dragSelect.stop();
|
||||
return;
|
||||
}
|
||||
this.dragSelect.start();
|
||||
};
|
||||
|
||||
// This queue tracks click events while the swimlanes are loading.
|
||||
// To avoid race conditions we keep the click events selectedCells in this queue
|
||||
// and trigger another event only after the current loading is done.
|
||||
// The queue is necessary since a click in the overall swimlane triggers
|
||||
// an update of the viewby swimlanes. If we'd just ignored click events
|
||||
// during the loading, we could miss programmatically triggered events like
|
||||
// those coming via AppState when a selection is part of the URL.
|
||||
swimlaneCellClickQueue = [];
|
||||
|
||||
// Listener for click events in the swimlane to load corresponding anomaly data.
|
||||
swimlaneCellClick = (swimlaneSelectedCells) => {
|
||||
if (this.skipCellClicks === true) {
|
||||
this.swimlaneCellClickQueue.push(swimlaneSelectedCells);
|
||||
return;
|
||||
}
|
||||
|
||||
// If selectedCells is an empty object we clear any existing selection,
|
||||
// otherwise we save the new selection in AppState and update the Explorer.
|
||||
if (Object.keys(swimlaneSelectedCells).length === 0) {
|
||||
this.props.appStateHandler(APP_STATE_ACTION.CLEAR_SELECTION);
|
||||
|
||||
const stateUpdate = getClearedSelectedAnomaliesState();
|
||||
this.updateExplorer(stateUpdate, false);
|
||||
} else {
|
||||
swimlaneSelectedCells.showTopFieldValues = false;
|
||||
|
||||
const currentSwimlaneType = _.get(this.state, 'selectedCells.type');
|
||||
const currentShowTopFieldValues = _.get(this.state, 'selectedCells.showTopFieldValues', false);
|
||||
const newSwimlaneType = _.get(swimlaneSelectedCells, 'type');
|
||||
|
||||
if (
|
||||
(currentSwimlaneType === SWIMLANE_TYPE.OVERALL && newSwimlaneType === SWIMLANE_TYPE.VIEW_BY) ||
|
||||
newSwimlaneType === SWIMLANE_TYPE.OVERALL ||
|
||||
currentShowTopFieldValues === true
|
||||
) {
|
||||
swimlaneSelectedCells.showTopFieldValues = true;
|
||||
}
|
||||
|
||||
this.props.appStateHandler(APP_STATE_ACTION.SAVE_SELECTION, { swimlaneSelectedCells });
|
||||
this.updateExplorer({ selectedCells: swimlaneSelectedCells }, false);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
intl,
|
||||
MlTimeBuckets,
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
annotationsData,
|
||||
anomalyChartRecords,
|
||||
chartsData,
|
||||
influencers,
|
||||
intl,
|
||||
hasResults,
|
||||
jobs,
|
||||
loading,
|
||||
noInfluencersConfigured,
|
||||
showViewBySwimlane,
|
||||
swimlaneOverall,
|
||||
swimlaneViewBy,
|
||||
noJobsFound,
|
||||
overallSwimlaneData,
|
||||
selectedCells,
|
||||
swimlaneViewByFieldName,
|
||||
tableData,
|
||||
viewByLoadedForTimeFormatted,
|
||||
viewBySwimlaneData,
|
||||
viewBySwimlaneDataLoading,
|
||||
viewBySwimlaneOptions,
|
||||
} = this.props;
|
||||
} = this.state;
|
||||
|
||||
const loading = this.props.loading || this.state.loading;
|
||||
|
||||
const swimlaneWidth = getSwimlaneContainerWidth(noInfluencersConfigured);
|
||||
|
||||
if (loading === true) {
|
||||
return (
|
||||
|
@ -106,17 +893,23 @@ export const Explorer = injectI18n(
|
|||
);
|
||||
}
|
||||
|
||||
if (jobs.length === 0) {
|
||||
if (noJobsFound) {
|
||||
return <ExplorerNoJobsFound />;
|
||||
}
|
||||
|
||||
if (jobs.length > 0 && hasResults === false) {
|
||||
if (noJobsFound && hasResults === false) {
|
||||
return <ExplorerNoResultsFound />;
|
||||
}
|
||||
|
||||
const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10';
|
||||
const mainColumnClasses = `column ${mainColumnWidthClassName}`;
|
||||
|
||||
const showViewBySwimlane = (
|
||||
viewBySwimlaneData !== null &&
|
||||
viewBySwimlaneData.laneLabels &&
|
||||
viewBySwimlaneData.laneLabels.length > 0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="results-container">
|
||||
{noInfluencersConfigured && (
|
||||
|
@ -158,7 +951,15 @@ export const Explorer = injectI18n(
|
|||
onMouseEnter={this.onSwimlaneEnterHandler}
|
||||
onMouseLeave={this.onSwimlaneLeaveHandler}
|
||||
>
|
||||
<ExplorerSwimlane {...swimlaneOverall} />
|
||||
<ExplorerSwimlane
|
||||
chartWidth={swimlaneWidth}
|
||||
MlTimeBuckets={MlTimeBuckets}
|
||||
swimlaneCellClick={this.swimlaneCellClick}
|
||||
swimlaneData={overallSwimlaneData}
|
||||
swimlaneType={SWIMLANE_TYPE.OVERALL}
|
||||
selection={selectedCells}
|
||||
swimlaneRenderDoneListener={this.swimlaneRenderDoneListener}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{viewBySwimlaneOptions.length > 0 && (
|
||||
|
@ -216,7 +1017,15 @@ export const Explorer = injectI18n(
|
|||
onMouseEnter={this.onSwimlaneEnterHandler}
|
||||
onMouseLeave={this.onSwimlaneLeaveHandler}
|
||||
>
|
||||
<ExplorerSwimlane {...swimlaneViewBy} />
|
||||
<ExplorerSwimlane
|
||||
chartWidth={swimlaneWidth}
|
||||
MlTimeBuckets={MlTimeBuckets}
|
||||
swimlaneCellClick={this.swimlaneCellClick}
|
||||
swimlaneData={viewBySwimlaneData}
|
||||
swimlaneType={SWIMLANE_TYPE.VIEW_BY}
|
||||
selection={selectedCells}
|
||||
swimlaneRenderDoneListener={this.swimlaneRenderDoneListener}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
@ -224,7 +1033,7 @@ export const Explorer = injectI18n(
|
|||
<LoadingIndicator/>
|
||||
)}
|
||||
|
||||
{!showViewBySwimlane && !viewBySwimlaneDataLoading && (
|
||||
{!showViewBySwimlane && !viewBySwimlaneDataLoading && swimlaneViewByFieldName !== null && (
|
||||
<ExplorerNoInfluencersFound swimlaneViewByFieldName={swimlaneViewByFieldName} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
|
|
|
@ -25,7 +25,7 @@ import {
|
|||
mlSelectSeverityService,
|
||||
SEVERITY_OPTIONS,
|
||||
} from '../../components/controls/select_severity/select_severity';
|
||||
import { getChartContainerWidth } from './legacy_utils';
|
||||
import { getChartContainerWidth } from '../legacy_utils';
|
||||
|
||||
import { CHART_TYPE } from '../explorer_constants';
|
||||
|
||||
|
|
|
@ -81,7 +81,7 @@ jest.mock('../../util/string_utils', () => ({
|
|||
mlEscape(d) { return d; }
|
||||
}));
|
||||
|
||||
jest.mock('./legacy_utils', () => ({
|
||||
jest.mock('../legacy_utils', () => ({
|
||||
getChartContainerWidth() { return 1140; }
|
||||
}));
|
||||
|
||||
|
|
|
@ -8,12 +8,28 @@
|
|||
* Contains values for ML anomaly explorer.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const DRAG_SELECT_ACTION = {
|
||||
NEW_SELECTION: 'newSelection',
|
||||
ELEMENT_SELECT: 'elementSelect',
|
||||
DRAG_START: 'dragStart'
|
||||
};
|
||||
|
||||
export const EXPLORER_ACTION = {
|
||||
INITIALIZE: 'initialize',
|
||||
JOB_SELECTION_CHANGE: 'jobSelectionChange',
|
||||
LOAD_JOBS: 'loadJobs',
|
||||
REDRAW: 'redraw',
|
||||
RELOAD: 'reload',
|
||||
};
|
||||
|
||||
export const APP_STATE_ACTION = {
|
||||
CLEAR_SELECTION: 'clearSelection',
|
||||
SAVE_SELECTION: 'saveSelection',
|
||||
SAVE_SWIMLANE_VIEW_BY_FIELD_NAME: 'saveSwimlaneViewByFieldName',
|
||||
};
|
||||
|
||||
export const SWIMLANE_DEFAULT_LIMIT = 10;
|
||||
|
||||
export const SWIMLANE_TYPE = {
|
||||
|
@ -26,3 +42,8 @@ export const CHART_TYPE = {
|
|||
POPULATION_DISTRIBUTION: 'population_distribution',
|
||||
SINGLE_METRIC: 'single_metric',
|
||||
};
|
||||
|
||||
export const MAX_CATEGORY_EXAMPLES = 10;
|
||||
export const MAX_INFLUENCER_FIELD_VALUES = 10;
|
||||
|
||||
export const VIEW_BY_JOB_LABEL = i18n.translate('xpack.ml.explorer.jobIdLabel', { defaultMessage: 'job ID' });
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -20,12 +20,12 @@ function mlExplorerDashboardServiceFactory() {
|
|||
|
||||
const listenerFactory = listenerFactoryProvider();
|
||||
const dragSelect = service.dragSelect = listenerFactory();
|
||||
const swimlaneRenderDone = service.swimlaneRenderDone = listenerFactory();
|
||||
const explorer = service.explorer = listenerFactory();
|
||||
|
||||
service.init = function () {
|
||||
// Clear out any old listeners.
|
||||
dragSelect.unwatchAll();
|
||||
swimlaneRenderDone.unwatchAll();
|
||||
explorer.unwatchAll();
|
||||
};
|
||||
|
||||
return service;
|
||||
|
|
|
@ -8,79 +8,28 @@
|
|||
* AngularJS directive wrapper for rendering Anomaly Explorer's React component.
|
||||
*/
|
||||
|
||||
import { pick } from 'lodash';
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { Explorer } from './explorer';
|
||||
|
||||
import { IntervalHelperProvider } from 'plugins/ml/util/ml_time_buckets';
|
||||
import { SWIMLANE_TYPE } from './explorer_constants';
|
||||
|
||||
import { uiModules } from 'ui/modules';
|
||||
const module = uiModules.get('apps/ml');
|
||||
|
||||
import { I18nProvider } from '@kbn/i18n/react';
|
||||
import { mapScopeToProps } from './explorer_utils';
|
||||
|
||||
module.directive('mlExplorerReactWrapper', function (Private) {
|
||||
const TimeBuckets = Private(IntervalHelperProvider);
|
||||
import { EXPLORER_ACTION } from './explorer_constants';
|
||||
import { mlExplorerDashboardService } from './explorer_dashboard_service';
|
||||
|
||||
module.directive('mlExplorerReactWrapper', function () {
|
||||
function link(scope, element) {
|
||||
function getSwimlaneData(swimlaneType) {
|
||||
switch (swimlaneType) {
|
||||
case SWIMLANE_TYPE.OVERALL:
|
||||
return scope.overallSwimlaneData;
|
||||
case SWIMLANE_TYPE.VIEW_BY:
|
||||
return scope.viewBySwimlaneData;
|
||||
}
|
||||
}
|
||||
ReactDOM.render(
|
||||
<I18nProvider>{React.createElement(Explorer, mapScopeToProps(scope))}</I18nProvider>,
|
||||
element[0]
|
||||
);
|
||||
|
||||
function mapScopeToSwimlaneProps(swimlaneType) {
|
||||
return {
|
||||
chartWidth: scope.swimlaneWidth,
|
||||
MlTimeBuckets: TimeBuckets,
|
||||
swimlaneCellClick: scope.swimlaneCellClick,
|
||||
swimlaneData: getSwimlaneData(swimlaneType),
|
||||
swimlaneType,
|
||||
selection: scope.appState.mlExplorerSwimlane,
|
||||
};
|
||||
}
|
||||
|
||||
function render() {
|
||||
const props = pick(scope, [
|
||||
'annotationsData',
|
||||
'anomalyChartRecords',
|
||||
'chartsData',
|
||||
'hasResults',
|
||||
'influencers',
|
||||
'jobs',
|
||||
'loading',
|
||||
'noInfluencersConfigured',
|
||||
'setSwimlaneSelectActive',
|
||||
'setSwimlaneViewBy',
|
||||
'showViewBySwimlane',
|
||||
'swimlaneViewByFieldName',
|
||||
'tableData',
|
||||
'viewByLoadedForTimeFormatted',
|
||||
'viewBySwimlaneDataLoading',
|
||||
'viewBySwimlaneOptions',
|
||||
]);
|
||||
|
||||
props.swimlaneOverall = mapScopeToSwimlaneProps(SWIMLANE_TYPE.OVERALL);
|
||||
props.swimlaneViewBy = mapScopeToSwimlaneProps(SWIMLANE_TYPE.VIEW_BY);
|
||||
|
||||
ReactDOM.render(
|
||||
<I18nProvider>{React.createElement(Explorer, props)}</I18nProvider>,
|
||||
element[0]
|
||||
);
|
||||
}
|
||||
|
||||
render();
|
||||
|
||||
scope.$watch(() => {
|
||||
render();
|
||||
});
|
||||
mlExplorerDashboardService.explorer.changed(EXPLORER_ACTION.LOAD_JOBS);
|
||||
|
||||
element.on('$destroy', () => {
|
||||
ReactDOM.unmountComponentAtNode(element[0]);
|
||||
|
|
|
@ -37,7 +37,8 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
|
|||
laneLabels: PropTypes.array.isRequired
|
||||
}).isRequired,
|
||||
swimlaneType: PropTypes.string.isRequired,
|
||||
selection: PropTypes.object
|
||||
selection: PropTypes.object,
|
||||
swimlaneRenderDoneListener: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
// Since this component is mostly rendered using d3 and cellMouseoverActive is only
|
||||
|
@ -85,14 +86,14 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
|
|||
const { swimlaneType } = this.props;
|
||||
|
||||
if (action === DRAG_SELECT_ACTION.NEW_SELECTION && elements.length > 0) {
|
||||
const firstCellData = d3.select(elements[0]).node().__clickData__;
|
||||
const firstSelectedCell = d3.select(elements[0]).node().__clickData__;
|
||||
|
||||
if (typeof firstCellData !== 'undefined' && swimlaneType === firstCellData.swimlaneType) {
|
||||
if (typeof firstSelectedCell !== 'undefined' && swimlaneType === firstSelectedCell.swimlaneType) {
|
||||
const selectedData = elements.reduce((d, e) => {
|
||||
const cellData = d3.select(e).node().__clickData__;
|
||||
d.bucketScore = Math.max(d.bucketScore, cellData.bucketScore);
|
||||
d.laneLabels.push(cellData.laneLabel);
|
||||
d.times.push(cellData.time);
|
||||
const cell = d3.select(e).node().__clickData__;
|
||||
d.bucketScore = Math.max(d.bucketScore, cell.bucketScore);
|
||||
d.laneLabels.push(cell.laneLabel);
|
||||
d.times.push(cell.time);
|
||||
return d;
|
||||
}, {
|
||||
bucketScore: 0,
|
||||
|
@ -142,9 +143,9 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
|
|||
// since it also includes the "viewBy" attribute which might differ depending
|
||||
// on whether the overall or viewby swimlane was selected.
|
||||
const oldSelection = {
|
||||
selectedType: selection.selectedType,
|
||||
selectedLanes: selection.selectedLanes,
|
||||
selectedTimes: selection.selectedTimes
|
||||
selectedType: selection && selection.type,
|
||||
selectedLanes: selection && selection.lanes,
|
||||
selectedTimes: selection && selection.times
|
||||
};
|
||||
|
||||
const newSelection = {
|
||||
|
@ -162,13 +163,13 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
|
|||
return;
|
||||
}
|
||||
|
||||
const cellData = {
|
||||
fieldName: swimlaneData.fieldName,
|
||||
const selectedCells = {
|
||||
viewByFieldName: swimlaneData.fieldName,
|
||||
lanes: laneLabels,
|
||||
times: d3.extent(times),
|
||||
type: swimlaneType
|
||||
};
|
||||
swimlaneCellClick(cellData);
|
||||
swimlaneCellClick(selectedCells);
|
||||
}
|
||||
|
||||
highlightSelection(cellsToSelect, laneLabels, times) {
|
||||
|
@ -249,7 +250,7 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
|
|||
const swimlanes = element.select('.ml-swimlanes');
|
||||
swimlanes.html('');
|
||||
|
||||
const cellWidth = Math.floor(chartWidth / numBuckets);
|
||||
const cellWidth = Math.floor(chartWidth / numBuckets * 100) / 100;
|
||||
|
||||
const xAxisWidth = cellWidth * numBuckets;
|
||||
const xAxisScale = d3.time.scale()
|
||||
|
@ -309,7 +310,7 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
|
|||
.style('width', `${laneLabelWidth}px`)
|
||||
.html(label => mlEscape(label))
|
||||
.on('click', () => {
|
||||
if (typeof selection.selectedLanes !== 'undefined') {
|
||||
if (typeof selection.lanes !== 'undefined') {
|
||||
swimlaneCellClick({});
|
||||
}
|
||||
})
|
||||
|
@ -424,13 +425,11 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
|
|||
}
|
||||
});
|
||||
|
||||
mlExplorerDashboardService.swimlaneRenderDone.changed();
|
||||
|
||||
// Check for selection and reselect the corresponding swimlane cell
|
||||
// if the time range and lane label are still in view.
|
||||
const selectionState = selection;
|
||||
const selectedType = _.get(selectionState, 'selectedType', undefined);
|
||||
const viewBy = _.get(selectionState, 'viewBy', '');
|
||||
const selectedType = _.get(selectionState, 'type', undefined);
|
||||
const selectionViewByFieldName = _.get(selectionState, 'viewByFieldName', '');
|
||||
|
||||
// If a selection was done in the other swimlane, add the "masked" classes
|
||||
// to de-emphasize the swimlane cells.
|
||||
|
@ -439,15 +438,19 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
|
|||
element.selectAll('.sl-cell-inner').classed('sl-cell-inner-masked', true);
|
||||
}
|
||||
|
||||
if ((swimlaneType !== selectedType) ||
|
||||
(swimlaneData.fieldName !== undefined && swimlaneData.fieldName !== viewBy)) {
|
||||
this.props.swimlaneRenderDoneListener();
|
||||
|
||||
if (
|
||||
(swimlaneType !== selectedType) ||
|
||||
(swimlaneData.fieldName !== undefined && swimlaneData.fieldName !== selectionViewByFieldName)
|
||||
) {
|
||||
// Not this swimlane which was selected.
|
||||
return;
|
||||
}
|
||||
|
||||
const cellsToSelect = [];
|
||||
const selectedLanes = _.get(selectionState, 'selectedLanes', []);
|
||||
const selectedTimes = _.get(selectionState, 'selectedTimes', []);
|
||||
const selectedLanes = _.get(selectionState, 'lanes', []);
|
||||
const selectedTimes = _.get(selectionState, 'times', []);
|
||||
const selectedTimeExtent = d3.extent(selectedTimes);
|
||||
|
||||
selectedLanes.forEach((selectedLane) => {
|
||||
|
|
|
@ -26,12 +26,6 @@ jest.mock('./explorer_dashboard_service', () => ({
|
|||
dragSelect: {
|
||||
watch: jest.fn(),
|
||||
unwatch: jest.fn()
|
||||
},
|
||||
swimlaneCellClick: {
|
||||
changed: jest.fn()
|
||||
},
|
||||
swimlaneRenderDone: {
|
||||
changed: jest.fn()
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
@ -68,12 +62,15 @@ describe('ExplorerSwimlane', () => {
|
|||
|
||||
test('Minimal initialization', () => {
|
||||
const mocks = getExplorerSwimlaneMocks();
|
||||
const swimlaneRenderDoneListener = jest.fn();
|
||||
|
||||
const wrapper = mountWithIntl(<ExplorerSwimlane.WrappedComponent
|
||||
chartWidth={mockChartWidth}
|
||||
MlTimeBuckets={mocks.MlTimeBuckets}
|
||||
swimlaneCellClick={jest.fn()}
|
||||
swimlaneData={mocks.swimlaneData}
|
||||
swimlaneType="overall"
|
||||
swimlaneRenderDoneListener={swimlaneRenderDoneListener}
|
||||
/>);
|
||||
|
||||
expect(wrapper.html()).toBe(
|
||||
|
@ -82,32 +79,33 @@ describe('ExplorerSwimlane', () => {
|
|||
);
|
||||
|
||||
// test calls to mock functions
|
||||
expect(mlExplorerDashboardService.swimlaneRenderDone.changed.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(mlExplorerDashboardService.dragSelect.watch.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(mlExplorerDashboardService.dragSelect.unwatch.mock.calls).toHaveLength(0);
|
||||
expect(mlExplorerDashboardService.swimlaneCellClick.changed.mock.calls).toHaveLength(0);
|
||||
expect(mocks.MlTimeBuckets.mockMethods.setInterval.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(mocks.MlTimeBuckets.mockMethods.getScaledDateFormat.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(swimlaneRenderDoneListener.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('Overall swimlane', () => {
|
||||
const mocks = getExplorerSwimlaneMocks();
|
||||
const swimlaneRenderDoneListener = jest.fn();
|
||||
|
||||
const wrapper = mountWithIntl(<ExplorerSwimlane.WrappedComponent
|
||||
chartWidth={mockChartWidth}
|
||||
MlTimeBuckets={mocks.MlTimeBuckets}
|
||||
swimlaneCellClick={jest.fn()}
|
||||
swimlaneData={mockOverallSwimlaneData}
|
||||
swimlaneType="overall"
|
||||
swimlaneRenderDoneListener={swimlaneRenderDoneListener}
|
||||
/>);
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
|
||||
// test calls to mock functions
|
||||
expect(mlExplorerDashboardService.swimlaneRenderDone.changed.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(mlExplorerDashboardService.dragSelect.watch.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(mlExplorerDashboardService.dragSelect.unwatch.mock.calls).toHaveLength(0);
|
||||
expect(mlExplorerDashboardService.swimlaneCellClick.changed.mock.calls).toHaveLength(0);
|
||||
expect(mocks.MlTimeBuckets.mockMethods.setInterval.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(mocks.MlTimeBuckets.mockMethods.getScaledDateFormat.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(swimlaneRenderDoneListener.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
|
532
x-pack/plugins/ml/public/explorer/explorer_utils.js
Normal file
532
x-pack/plugins/ml/public/explorer/explorer_utils.js
Normal file
|
@ -0,0 +1,532 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
/*
|
||||
* utils for Anomaly Explorer.
|
||||
*/
|
||||
|
||||
import { chain, each, get, union, uniq } from 'lodash';
|
||||
import { timefilter } from 'ui/timefilter';
|
||||
import { parseInterval } from 'ui/utils/parse_interval';
|
||||
|
||||
import { isTimeSeriesViewDetector } from '../../common/util/job_utils';
|
||||
import { ml } from '../services/ml_api_service';
|
||||
import { mlJobService } from '../services/job_service';
|
||||
import { mlResultsService } from 'plugins/ml/services/results_service';
|
||||
import { mlSelectIntervalService } from '../components/controls/select_interval/select_interval';
|
||||
import { mlSelectSeverityService } from '../components/controls/select_severity/select_severity';
|
||||
|
||||
import {
|
||||
MAX_CATEGORY_EXAMPLES,
|
||||
MAX_INFLUENCER_FIELD_VALUES,
|
||||
VIEW_BY_JOB_LABEL,
|
||||
} from './explorer_constants';
|
||||
import {
|
||||
ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE,
|
||||
ANOMALIES_TABLE_DEFAULT_QUERY_SIZE
|
||||
} from '../../common/constants/search';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import chrome from 'ui/chrome';
|
||||
const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false);
|
||||
|
||||
// create new job objects based on standard job config objects
|
||||
// new job objects just contain job id, bucket span in seconds and a selected flag.
|
||||
export function createJobs(jobs) {
|
||||
return jobs.map(job => {
|
||||
const bucketSpan = parseInterval(job.analysis_config.bucket_span);
|
||||
return { id: job.job_id, selected: false, bucketSpanSeconds: bucketSpan.asSeconds() };
|
||||
});
|
||||
}
|
||||
|
||||
export function getClearedSelectedAnomaliesState() {
|
||||
return {
|
||||
anomalyChartRecords: [],
|
||||
selectedCells: null,
|
||||
viewByLoadedForTimeFormatted: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultViewBySwimlaneData() {
|
||||
return {
|
||||
fieldName: '',
|
||||
laneLabels: [],
|
||||
points: [],
|
||||
interval: 3600
|
||||
};
|
||||
}
|
||||
|
||||
export function mapScopeToProps(scope) {
|
||||
return {
|
||||
appStateHandler: scope.appStateHandler,
|
||||
dateFormatTz: scope.dateFormatTz,
|
||||
mlJobSelectService: scope.mlJobSelectService,
|
||||
MlTimeBuckets: scope.MlTimeBuckets,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getFilteredTopInfluencers(jobIds, earliestMs, latestMs, records, influencers, noInfluencersConfigured) {
|
||||
// Filter the Top Influencers list to show just the influencers from
|
||||
// the records in the selected time range.
|
||||
const recordInfluencersByName = {};
|
||||
|
||||
// Add the specified influencer(s) to ensure they are used in the filter
|
||||
// even if their influencer score for the selected time range is zero.
|
||||
influencers.forEach((influencer) => {
|
||||
const fieldName = influencer.fieldName;
|
||||
if (recordInfluencersByName[influencer.fieldName] === undefined) {
|
||||
recordInfluencersByName[influencer.fieldName] = [];
|
||||
}
|
||||
recordInfluencersByName[fieldName].push(influencer.fieldValue);
|
||||
});
|
||||
|
||||
// Add the influencers from the top scoring anomalies.
|
||||
records.forEach((record) => {
|
||||
const influencersByName = record.influencers || [];
|
||||
influencersByName.forEach((influencer) => {
|
||||
const fieldName = influencer.influencer_field_name;
|
||||
const fieldValues = influencer.influencer_field_values;
|
||||
if (recordInfluencersByName[fieldName] === undefined) {
|
||||
recordInfluencersByName[fieldName] = [];
|
||||
}
|
||||
recordInfluencersByName[fieldName].push(...fieldValues);
|
||||
});
|
||||
});
|
||||
|
||||
const uniqValuesByName = {};
|
||||
Object.keys(recordInfluencersByName).forEach((fieldName) => {
|
||||
const fieldValues = recordInfluencersByName[fieldName];
|
||||
uniqValuesByName[fieldName] = uniq(fieldValues);
|
||||
});
|
||||
|
||||
const filterInfluencers = [];
|
||||
Object.keys(uniqValuesByName).forEach((fieldName) => {
|
||||
// Find record influencers with the same field name as the clicked on cell(s).
|
||||
const matchingFieldName = influencers.find((influencer) => {
|
||||
return influencer.fieldName === fieldName;
|
||||
});
|
||||
|
||||
if (matchingFieldName !== undefined) {
|
||||
// Filter for the value(s) of the clicked on cell(s).
|
||||
filterInfluencers.push(...influencers);
|
||||
} else {
|
||||
// For other field names, add values from all records.
|
||||
uniqValuesByName[fieldName].forEach((fieldValue) => {
|
||||
filterInfluencers.push({ fieldName, fieldValue });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return await loadTopInfluencers(jobIds, earliestMs, latestMs, filterInfluencers, noInfluencersConfigured);
|
||||
}
|
||||
|
||||
export function selectedJobsHaveInfluencers(selectedJobs = []) {
|
||||
let hasInfluencers = false;
|
||||
selectedJobs.forEach((selectedJob) => {
|
||||
const job = mlJobService.getJob(selectedJob.id);
|
||||
let influencers = [];
|
||||
if (job !== undefined) {
|
||||
influencers = job.analysis_config.influencers || [];
|
||||
}
|
||||
hasInfluencers = hasInfluencers || influencers.length > 0;
|
||||
});
|
||||
return hasInfluencers;
|
||||
}
|
||||
|
||||
export function getFieldsByJob() {
|
||||
return mlJobService.jobs.reduce((reducedFieldsByJob, job) => {
|
||||
// Add the list of distinct by, over, partition and influencer fields for each job.
|
||||
const analysisConfig = job.analysis_config;
|
||||
const influencers = analysisConfig.influencers || [];
|
||||
const fieldsForJob = (analysisConfig.detectors || [])
|
||||
.reduce((reducedfieldsForJob, detector) => {
|
||||
if (detector.partition_field_name !== undefined) {
|
||||
reducedfieldsForJob.push(detector.partition_field_name);
|
||||
}
|
||||
if (detector.over_field_name !== undefined) {
|
||||
reducedfieldsForJob.push(detector.over_field_name);
|
||||
}
|
||||
// For jobs with by and over fields, don't add the 'by' field as this
|
||||
// field will only be added to the top-level fields for record type results
|
||||
// if it also an influencer over the bucket.
|
||||
if (detector.by_field_name !== undefined && detector.over_field_name === undefined) {
|
||||
reducedfieldsForJob.push(detector.by_field_name);
|
||||
}
|
||||
return reducedfieldsForJob;
|
||||
}, [])
|
||||
.concat(influencers);
|
||||
|
||||
reducedFieldsByJob[job.job_id] = uniq(fieldsForJob);
|
||||
reducedFieldsByJob['*'] = union(reducedFieldsByJob['*'], reducedFieldsByJob[job.job_id]);
|
||||
return reducedFieldsByJob;
|
||||
}, { '*': [] });
|
||||
}
|
||||
|
||||
export function getSelectionTimeRange(selectedCells, interval) {
|
||||
// Returns the time range of the cell(s) currently selected in the swimlane.
|
||||
// If no cell(s) are currently selected, returns the dashboard time range.
|
||||
const bounds = timefilter.getActiveBounds();
|
||||
let earliestMs = bounds.min.valueOf();
|
||||
let latestMs = bounds.max.valueOf();
|
||||
|
||||
if (selectedCells !== null && selectedCells.times !== undefined) {
|
||||
// time property of the cell data is an array, with the elements being
|
||||
// the start times of the first and last cell selected.
|
||||
earliestMs = (selectedCells.times[0] !== undefined) ? selectedCells.times[0] * 1000 : bounds.min.valueOf();
|
||||
latestMs = bounds.max.valueOf();
|
||||
if (selectedCells.times[1] !== undefined) {
|
||||
// Subtract 1 ms so search does not include start of next bucket.
|
||||
latestMs = ((selectedCells.times[1] + interval) * 1000) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { earliestMs, latestMs };
|
||||
}
|
||||
|
||||
export function getSelectionInfluencers(selectedCells, fieldName) {
|
||||
if (
|
||||
selectedCells !== null &&
|
||||
selectedCells.viewByFieldName !== undefined &&
|
||||
selectedCells.viewByFieldName !== VIEW_BY_JOB_LABEL
|
||||
) {
|
||||
return selectedCells.lanes.map(laneLabel => ({ fieldName, fieldValue: laneLabel }));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// Obtain the list of 'View by' fields per job and swimlaneViewByFieldName
|
||||
export function getViewBySwimlaneOptions(selectedJobs, currentSwimlaneViewByFieldName) {
|
||||
const selectedJobIds = selectedJobs.map(d => d.id);
|
||||
|
||||
// Unique influencers for the selected job(s).
|
||||
const viewByOptions = chain(
|
||||
mlJobService.jobs.reduce((reducedViewByOptions, job) => {
|
||||
if (selectedJobIds.some(jobId => jobId === job.job_id)) {
|
||||
return reducedViewByOptions.concat(job.analysis_config.influencers || []);
|
||||
}
|
||||
return reducedViewByOptions;
|
||||
}, []))
|
||||
.uniq()
|
||||
.sortBy(fieldName => fieldName.toLowerCase())
|
||||
.value();
|
||||
|
||||
viewByOptions.push(VIEW_BY_JOB_LABEL);
|
||||
const viewBySwimlaneOptions = viewByOptions;
|
||||
|
||||
let swimlaneViewByFieldName = undefined;
|
||||
|
||||
if (
|
||||
viewBySwimlaneOptions.indexOf(currentSwimlaneViewByFieldName) !== -1
|
||||
) {
|
||||
// Set the swimlane viewBy to that stored in the state (URL) if set.
|
||||
// This means we reset it to the current state because it was set by the listener
|
||||
// on initialization.
|
||||
swimlaneViewByFieldName = currentSwimlaneViewByFieldName;
|
||||
} else {
|
||||
if (selectedJobIds.length > 1) {
|
||||
// If more than one job selected, default to job ID.
|
||||
swimlaneViewByFieldName = VIEW_BY_JOB_LABEL;
|
||||
} else {
|
||||
// For a single job, default to the first partition, over,
|
||||
// by or influencer field of the first selected job.
|
||||
const firstSelectedJob = mlJobService.jobs.find((job) => {
|
||||
return job.job_id === selectedJobIds[0];
|
||||
});
|
||||
|
||||
const firstJobInfluencers = firstSelectedJob.analysis_config.influencers || [];
|
||||
firstSelectedJob.analysis_config.detectors.forEach((detector) => {
|
||||
if (
|
||||
detector.partition_field_name !== undefined &&
|
||||
firstJobInfluencers.indexOf(detector.partition_field_name) !== -1
|
||||
) {
|
||||
swimlaneViewByFieldName = detector.partition_field_name;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
detector.over_field_name !== undefined &&
|
||||
firstJobInfluencers.indexOf(detector.over_field_name) !== -1
|
||||
) {
|
||||
swimlaneViewByFieldName = detector.over_field_name;
|
||||
return false;
|
||||
}
|
||||
|
||||
// For jobs with by and over fields, don't add the 'by' field as this
|
||||
// field will only be added to the top-level fields for record type results
|
||||
// if it also an influencer over the bucket.
|
||||
if (
|
||||
detector.by_field_name !== undefined &&
|
||||
detector.over_field_name === undefined &&
|
||||
firstJobInfluencers.indexOf(detector.by_field_name) !== -1
|
||||
) {
|
||||
swimlaneViewByFieldName = detector.by_field_name;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (swimlaneViewByFieldName === undefined) {
|
||||
if (firstJobInfluencers.length > 0) {
|
||||
swimlaneViewByFieldName = firstJobInfluencers[0];
|
||||
} else {
|
||||
// No influencers for first selected job - set to first available option.
|
||||
swimlaneViewByFieldName = viewBySwimlaneOptions.length > 0
|
||||
? viewBySwimlaneOptions[0]
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
swimlaneViewByFieldName,
|
||||
viewBySwimlaneOptions,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function processOverallResults(scoresByTime, searchBounds, interval) {
|
||||
const overallLabel = i18n.translate('xpack.ml.explorer.overallLabel', { defaultMessage: 'Overall' });
|
||||
const dataset = {
|
||||
laneLabels: [overallLabel],
|
||||
points: [],
|
||||
interval,
|
||||
earliest: searchBounds.min.valueOf() / 1000,
|
||||
latest: searchBounds.max.valueOf() / 1000
|
||||
};
|
||||
|
||||
if (Object.keys(scoresByTime).length > 0) {
|
||||
// Store the earliest and latest times of the data returned by the ES aggregations,
|
||||
// These will be used for calculating the earliest and latest times for the swimlane charts.
|
||||
each(scoresByTime, (score, timeMs) => {
|
||||
const time = timeMs / 1000;
|
||||
dataset.points.push({
|
||||
laneLabel: overallLabel,
|
||||
time,
|
||||
value: score
|
||||
});
|
||||
|
||||
dataset.earliest = Math.min(time, dataset.earliest);
|
||||
dataset.latest = Math.max((time + dataset.interval), dataset.latest);
|
||||
});
|
||||
}
|
||||
|
||||
return dataset;
|
||||
}
|
||||
|
||||
export function processViewByResults(
|
||||
scoresByInfluencerAndTime,
|
||||
sortedLaneValues,
|
||||
overallSwimlaneData,
|
||||
swimlaneViewByFieldName,
|
||||
interval,
|
||||
) {
|
||||
// Processes the scores for the 'view by' swimlane.
|
||||
// Sorts the lanes according to the supplied array of lane
|
||||
// values in the order in which they should be displayed,
|
||||
// or pass an empty array to sort lanes according to max score over all time.
|
||||
const dataset = {
|
||||
fieldName: swimlaneViewByFieldName,
|
||||
points: [],
|
||||
interval
|
||||
};
|
||||
|
||||
// Set the earliest and latest to be the same as the overall swimlane.
|
||||
dataset.earliest = overallSwimlaneData.earliest;
|
||||
dataset.latest = overallSwimlaneData.latest;
|
||||
|
||||
const laneLabels = [];
|
||||
const maxScoreByLaneLabel = {};
|
||||
|
||||
each(scoresByInfluencerAndTime, (influencerData, influencerFieldValue) => {
|
||||
laneLabels.push(influencerFieldValue);
|
||||
maxScoreByLaneLabel[influencerFieldValue] = 0;
|
||||
|
||||
each(influencerData, (anomalyScore, timeMs) => {
|
||||
const time = timeMs / 1000;
|
||||
dataset.points.push({
|
||||
laneLabel: influencerFieldValue,
|
||||
time,
|
||||
value: anomalyScore
|
||||
});
|
||||
maxScoreByLaneLabel[influencerFieldValue] =
|
||||
Math.max(maxScoreByLaneLabel[influencerFieldValue], anomalyScore);
|
||||
});
|
||||
});
|
||||
|
||||
const sortValuesLength = sortedLaneValues.length;
|
||||
if (sortValuesLength === 0) {
|
||||
// Sort lanes in descending order of max score.
|
||||
// Note the keys in scoresByInfluencerAndTime received from the ES request
|
||||
// are not guaranteed to be sorted by score if they can be parsed as numbers
|
||||
// (e.g. if viewing by HTTP response code).
|
||||
dataset.laneLabels = laneLabels.sort((a, b) => {
|
||||
return maxScoreByLaneLabel[b] - maxScoreByLaneLabel[a];
|
||||
});
|
||||
} else {
|
||||
// Sort lanes according to supplied order
|
||||
// e.g. when a cell in the overall swimlane has been selected.
|
||||
// Find the index of each lane label from the actual data set,
|
||||
// rather than using sortedLaneValues as-is, just in case they differ.
|
||||
dataset.laneLabels = laneLabels.sort((a, b) => {
|
||||
let aIndex = sortedLaneValues.indexOf(a);
|
||||
let bIndex = sortedLaneValues.indexOf(b);
|
||||
aIndex = (aIndex > -1) ? aIndex : sortValuesLength;
|
||||
bIndex = (bIndex > -1) ? bIndex : sortValuesLength;
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
}
|
||||
|
||||
return dataset;
|
||||
}
|
||||
|
||||
export async function loadAnnotationsTableData(selectedCells, selectedJobs, interval) {
|
||||
const jobIds = (selectedCells !== null && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL) ?
|
||||
selectedCells.lanes : selectedJobs.map(d => d.id);
|
||||
const timeRange = getSelectionTimeRange(selectedCells, interval);
|
||||
|
||||
if (mlAnnotationsEnabled === false) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
const resp = await ml.annotations.getAnnotations({
|
||||
jobIds,
|
||||
earliestMs: timeRange.earliestMs,
|
||||
latestMs: timeRange.latestMs,
|
||||
maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE
|
||||
});
|
||||
|
||||
const annotationsData = [];
|
||||
jobIds.forEach((jobId) => {
|
||||
const jobAnnotations = resp.annotations[jobId];
|
||||
if (jobAnnotations !== undefined) {
|
||||
annotationsData.push(...jobAnnotations);
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.resolve(
|
||||
annotationsData
|
||||
.sort((a, b) => {
|
||||
return a.timestamp - b.timestamp;
|
||||
})
|
||||
.map((d, i) => {
|
||||
d.key = String.fromCharCode(65 + i);
|
||||
return d;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadAnomaliesTableData(selectedCells, selectedJobs, dateFormatTz, interval, fieldName) {
|
||||
const jobIds = (selectedCells !== null && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL) ?
|
||||
selectedCells.lanes : selectedJobs.map(d => d.id);
|
||||
const influencers = getSelectionInfluencers(selectedCells, fieldName);
|
||||
const timeRange = getSelectionTimeRange(selectedCells, interval);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ml.results.getAnomaliesTableData(
|
||||
jobIds,
|
||||
[],
|
||||
influencers,
|
||||
mlSelectIntervalService.state.get('interval').val,
|
||||
mlSelectSeverityService.state.get('threshold').val,
|
||||
timeRange.earliestMs,
|
||||
timeRange.latestMs,
|
||||
dateFormatTz,
|
||||
ANOMALIES_TABLE_DEFAULT_QUERY_SIZE,
|
||||
MAX_CATEGORY_EXAMPLES
|
||||
).then((resp) => {
|
||||
const anomalies = resp.anomalies;
|
||||
const detectorsByJob = mlJobService.detectorsByJob;
|
||||
anomalies.forEach((anomaly) => {
|
||||
// Add a detector property to each anomaly.
|
||||
// Default to functionDescription if no description available.
|
||||
// TODO - when job_service is moved server_side, move this to server endpoint.
|
||||
const jobId = anomaly.jobId;
|
||||
const detector = get(detectorsByJob, [jobId, anomaly.detectorIndex]);
|
||||
anomaly.detector = get(detector,
|
||||
['detector_description'],
|
||||
anomaly.source.function_description);
|
||||
|
||||
// For detectors with rules, add a property with the rule count.
|
||||
if (detector !== undefined && detector.custom_rules !== undefined) {
|
||||
anomaly.rulesLength = detector.custom_rules.length;
|
||||
}
|
||||
|
||||
// Add properties used for building the links menu.
|
||||
// TODO - when job_service is moved server_side, move this to server endpoint.
|
||||
anomaly.isTimeSeriesViewDetector = isTimeSeriesViewDetector(mlJobService.getJob(jobId), anomaly.detectorIndex);
|
||||
if (mlJobService.customUrlsByJob[jobId] !== undefined) {
|
||||
anomaly.customUrls = mlJobService.customUrlsByJob[jobId];
|
||||
}
|
||||
});
|
||||
|
||||
resolve({
|
||||
anomalies,
|
||||
interval: resp.interval,
|
||||
examplesByJobId: resp.examplesByJobId,
|
||||
showViewSeriesLink: true,
|
||||
jobIds
|
||||
});
|
||||
}).catch((resp) => {
|
||||
console.log('Explorer - error loading data for anomalies table:', resp);
|
||||
reject();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// track the request to be able to ignore out of date requests
|
||||
// and avoid race conditions ending up with the wrong charts.
|
||||
let requestCount = 0;
|
||||
export async function loadDataForCharts(jobIds, earliestMs, latestMs, influencers = [], selectedCells) {
|
||||
return new Promise((resolve) => {
|
||||
// Just skip doing the request when this function
|
||||
// is called without the minimum required data.
|
||||
if (selectedCells === null && influencers.length === 0) {
|
||||
resolve([]);
|
||||
}
|
||||
|
||||
const newRequestCount = ++requestCount;
|
||||
requestCount = newRequestCount;
|
||||
|
||||
// Load the top anomalies (by record_score) which will be displayed in the charts.
|
||||
mlResultsService.getRecordsForInfluencer(
|
||||
jobIds, influencers, 0, earliestMs, latestMs, 500
|
||||
)
|
||||
.then((resp) => {
|
||||
// Ignore this response if it's returned by an out of date promise
|
||||
if (newRequestCount < requestCount) {
|
||||
resolve(undefined);
|
||||
}
|
||||
|
||||
if (selectedCells !== null && Object.keys(selectedCells).length > 0) {
|
||||
console.log('Explorer anomaly charts data set:', resp.records);
|
||||
resolve(resp.records);
|
||||
}
|
||||
|
||||
resolve(undefined);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadTopInfluencers(selectedJobIds, earliestMs, latestMs, influencers = [], noInfluencersConfigured) {
|
||||
return new Promise((resolve) => {
|
||||
if (noInfluencersConfigured !== true) {
|
||||
mlResultsService.getTopInfluencers(
|
||||
selectedJobIds,
|
||||
earliestMs,
|
||||
latestMs,
|
||||
MAX_INFLUENCER_FIELD_VALUES,
|
||||
influencers
|
||||
).then((resp) => {
|
||||
// TODO - sort the influencers keys so that the partition field(s) are first.
|
||||
console.log('Explorer top influencers data set:', resp.influencers);
|
||||
resolve(resp.influencers);
|
||||
});
|
||||
} else {
|
||||
resolve({});
|
||||
}
|
||||
});
|
||||
}
|
|
@ -11,3 +11,17 @@ export function getChartContainerWidth() {
|
|||
const chartContainer = document.querySelector('.explorer-charts');
|
||||
return Math.floor(chartContainer && chartContainer.clientWidth || 0);
|
||||
}
|
||||
|
||||
export function getSwimlaneContainerWidth(noInfluencersConfigured = true) {
|
||||
const explorerContainer = document.querySelector('.ml-explorer');
|
||||
const explorerContainerWidth = explorerContainer && explorerContainer.clientWidth || 0;
|
||||
if (noInfluencersConfigured === true) {
|
||||
// swimlane is full width, minus 30 for the 'no influencers' info icon,
|
||||
// minus 170 for the lane labels, minus 50 padding
|
||||
return explorerContainerWidth - 250;
|
||||
} else {
|
||||
// swimlane width is 5 sixths of the window,
|
||||
// minus 170 for the lane labels, minus 50 padding
|
||||
return ((explorerContainerWidth / 6) * 5) - 220;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue