diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/forecasts_list.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/forecasts_list.js
index 1854982c8db0..7f9fcc7bc551 100644
--- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/forecasts_list.js
+++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/forecasts_list.js
@@ -78,6 +78,12 @@ function getColumns(viewForecast) {
// TODO - add in ml-info-icon to the h3 element,
// then remove tooltip and inline style.
export function ForecastsList({ forecasts, viewForecast }) {
+ const getRowProps = (item) => {
+ return {
+ 'data-test-subj': `mlForecastsListRow row-${item.rowId}`,
+ };
+ };
+
return (
);
diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js
index 87131583e44e..cad5bb68fb62 100644
--- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js
+++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js
@@ -547,9 +547,18 @@ class TimeseriesChartIntl extends Component {
// Create the path elements for the forecast value line and bounds area.
if (contextForecastData) {
- fcsGroup.append('path').attr('class', 'area forecast');
- fcsGroup.append('path').attr('class', 'values-line forecast');
- fcsGroup.append('g').attr('class', 'focus-chart-markers forecast');
+ fcsGroup
+ .append('path')
+ .attr('class', 'area forecast')
+ .attr('data-test-subj', 'mlForecastArea');
+ fcsGroup
+ .append('path')
+ .attr('class', 'values-line forecast')
+ .attr('data-test-subj', 'mlForecastValuesline');
+ fcsGroup
+ .append('g')
+ .attr('class', 'focus-chart-markers forecast')
+ .attr('data-test-subj', 'mlForecastMarkers');
}
fcsGroup
diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js
index 9b8770350909..e4d7fc457de0 100644
--- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js
+++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js
@@ -1170,9 +1170,13 @@ export class TimeSeriesExplorer extends React.Component {
+ {i18n.translate('xpack.ml.timeSeriesExplorer.showForecastLabel', {
+ defaultMessage: 'show forecast',
+ })}
+
+ }
checked={showForecast}
onChange={this.toggleShowForecastHandler}
/>
diff --git a/x-pack/test/functional/apps/ml/anomaly_detection/forecasts.ts b/x-pack/test/functional/apps/ml/anomaly_detection/forecasts.ts
new file mode 100644
index 000000000000..f65653e2c03c
--- /dev/null
+++ b/x-pack/test/functional/apps/ml/anomaly_detection/forecasts.ts
@@ -0,0 +1,116 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { FtrProviderContext } from '../../../ftr_provider_context';
+import { Job, Datafeed } from '../../../../../plugins/ml/common/types/anomaly_detection_jobs';
+
+// @ts-expect-error not full interface
+const JOB_CONFIG: Job = {
+ job_id: `fq_single_1_smv`,
+ description: 'count() on farequote dataset with 15m bucket span',
+ groups: ['farequote', 'automated', 'single-metric'],
+ analysis_config: {
+ bucket_span: '15m',
+ influencers: [],
+ detectors: [
+ {
+ function: 'count',
+ },
+ ],
+ },
+ data_description: { time_field: '@timestamp' },
+ analysis_limits: { model_memory_limit: '10mb' },
+ model_plot_config: { enabled: true },
+};
+
+// @ts-expect-error not full interface
+const DATAFEED_CONFIG: Datafeed = {
+ datafeed_id: 'datafeed-fq_single_1_smv',
+ indices: ['ft_farequote'],
+ job_id: 'fq_single_1_smv',
+ query: { bool: { must: [{ match_all: {} }] } },
+};
+
+export default function ({ getService }: FtrProviderContext) {
+ const esArchiver = getService('esArchiver');
+ const ml = getService('ml');
+
+ describe('forecasts', function () {
+ this.tags(['mlqa']);
+
+ describe('with single metric job', function () {
+ before(async () => {
+ await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote');
+ await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp');
+ await ml.testResources.setKibanaTimeZoneToUTC();
+
+ await ml.api.createAndRunAnomalyDetectionLookbackJob(JOB_CONFIG, DATAFEED_CONFIG);
+ await ml.securityUI.loginAsMlPowerUser();
+ });
+
+ after(async () => {
+ await ml.api.cleanMlIndices();
+ });
+
+ it('opens a job from job list link', async () => {
+ await ml.testExecution.logTestStep('navigate to job list');
+ await ml.navigation.navigateToMl();
+ await ml.navigation.navigateToJobManagement();
+
+ await ml.testExecution.logTestStep('open job in single metric viewer');
+ await ml.jobTable.waitForJobsToLoad();
+ await ml.jobTable.filterWithSearchString(JOB_CONFIG.job_id, 1);
+
+ await ml.jobTable.clickOpenJobInSingleMetricViewerButton(JOB_CONFIG.job_id);
+ await ml.commonUI.waitForMlLoadingIndicatorToDisappear();
+ });
+
+ it('displays job results', async () => {
+ await ml.testExecution.logTestStep('pre-fills the job selection');
+ await ml.jobSelection.assertJobSelection([JOB_CONFIG.job_id]);
+
+ await ml.testExecution.logTestStep('pre-fills the detector input');
+ await ml.singleMetricViewer.assertDetectorInputExist();
+ await ml.singleMetricViewer.assertDetectorInputValue('0');
+
+ await ml.testExecution.logTestStep('displays the chart');
+ await ml.singleMetricViewer.assertChartExist();
+
+ await ml.testExecution.logTestStep('should not display the forecasts toggle checkbox');
+ await ml.forecast.assertForecastCheckboxMissing();
+
+ await ml.testExecution.logTestStep('should open the forecasts modal');
+ await ml.forecast.assertForecastButtonExists();
+ await ml.forecast.assertForecastButtonEnabled(true);
+ await ml.forecast.openForecastModal();
+ await ml.forecast.assertForecastModalRunButtonEnabled(true);
+
+ await ml.testExecution.logTestStep('should run the forecast and close the modal');
+ await ml.forecast.clickForecastModalRunButton();
+
+ await ml.testExecution.logTestStep('should display the forecasts toggle checkbox');
+ await ml.forecast.assertForecastCheckboxExists();
+
+ await ml.testExecution.logTestStep(
+ 'should display the forecast in the single metric chart'
+ );
+ await ml.forecast.assertForecastChartElementsExists();
+
+ await ml.testExecution.logTestStep('should hide the forecast in the single metric chart');
+ await ml.forecast.clickForecastCheckbox();
+ await ml.forecast.assertForecastChartElementsHidden();
+
+ await ml.testExecution.logTestStep('should open the forecasts modal and list the forecast');
+ await ml.forecast.assertForecastButtonExists();
+ await ml.forecast.assertForecastButtonEnabled(true);
+ await ml.forecast.openForecastModal();
+ await ml.forecast.assertForecastTableExists();
+ await ml.forecast.assertForecastTableNotEmpty();
+ });
+ });
+ });
+}
diff --git a/x-pack/test/functional/apps/ml/anomaly_detection/index.ts b/x-pack/test/functional/apps/ml/anomaly_detection/index.ts
index d87da8469db1..ed5f618f8664 100644
--- a/x-pack/test/functional/apps/ml/anomaly_detection/index.ts
+++ b/x-pack/test/functional/apps/ml/anomaly_detection/index.ts
@@ -24,5 +24,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./annotations'));
loadTestFile(require.resolve('./aggregated_scripted_job'));
loadTestFile(require.resolve('./custom_urls'));
+ loadTestFile(require.resolve('./forecasts'));
});
}
diff --git a/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts
index 448774f1a0c7..356e38221796 100644
--- a/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts
+++ b/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts
@@ -237,11 +237,11 @@ export default function ({ getService }: FtrProviderContext) {
await ml.testExecution.logTestStep(
'should display the forecast modal with enabled run button'
);
- await ml.singleMetricViewer.assertForecastButtonExists();
- await ml.singleMetricViewer.assertForecastButtonEnabled(true);
- await ml.singleMetricViewer.openForecastModal();
- await ml.singleMetricViewer.assertForecastModalRunButtonEnabled(true);
- await ml.singleMetricViewer.closeForecastModal();
+ await ml.forecast.assertForecastButtonExists();
+ await ml.forecast.assertForecastButtonEnabled(true);
+ await ml.forecast.openForecastModal();
+ await ml.forecast.assertForecastModalRunButtonEnabled(true);
+ await ml.forecast.closeForecastModal();
});
it('should display elements on Anomaly Explorer page correctly', async () => {
diff --git a/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts
index b96da7485078..be57904b9445 100644
--- a/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts
+++ b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts
@@ -230,11 +230,11 @@ export default function ({ getService }: FtrProviderContext) {
await ml.testExecution.logTestStep(
'should display the forecast modal with disabled run button'
);
- await ml.singleMetricViewer.assertForecastButtonExists();
- await ml.singleMetricViewer.assertForecastButtonEnabled(true);
- await ml.singleMetricViewer.openForecastModal();
- await ml.singleMetricViewer.assertForecastModalRunButtonEnabled(false);
- await ml.singleMetricViewer.closeForecastModal();
+ await ml.forecast.assertForecastButtonExists();
+ await ml.forecast.assertForecastButtonEnabled(true);
+ await ml.forecast.openForecastModal();
+ await ml.forecast.assertForecastModalRunButtonEnabled(false);
+ await ml.forecast.closeForecastModal();
});
it('should display elements on Anomaly Explorer page correctly', async () => {
diff --git a/x-pack/test/functional/services/ml/forecast.ts b/x-pack/test/functional/services/ml/forecast.ts
new file mode 100644
index 000000000000..c26216c97adf
--- /dev/null
+++ b/x-pack/test/functional/services/ml/forecast.ts
@@ -0,0 +1,126 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import expect from '@kbn/expect';
+
+import { FtrProviderContext } from '../../ftr_provider_context';
+
+export function MachineLearningForecastProvider({ getService }: FtrProviderContext) {
+ const testSubjects = getService('testSubjects');
+
+ return {
+ async assertForecastButtonExists() {
+ await testSubjects.existOrFail(
+ 'mlSingleMetricViewerSeriesControls > mlSingleMetricViewerButtonForecast'
+ );
+ },
+
+ async assertForecastButtonEnabled(expectedValue: boolean) {
+ const isEnabled = await testSubjects.isEnabled(
+ 'mlSingleMetricViewerSeriesControls > mlSingleMetricViewerButtonForecast'
+ );
+ expect(isEnabled).to.eql(
+ expectedValue,
+ `Expected "forecast" button to be '${expectedValue ? 'enabled' : 'disabled'}' (got '${
+ isEnabled ? 'enabled' : 'disabled'
+ }')`
+ );
+ },
+
+ async assertForecastChartElementsExists() {
+ await testSubjects.existOrFail(`mlForecastArea`, {
+ timeout: 30 * 1000,
+ });
+ await testSubjects.existOrFail(`mlForecastValuesline`, {
+ timeout: 30 * 1000,
+ });
+ await testSubjects.existOrFail(`mlForecastMarkers`, {
+ timeout: 30 * 1000,
+ });
+ },
+
+ async assertForecastChartElementsHidden() {
+ await testSubjects.missingOrFail(`mlForecastArea`, {
+ allowHidden: true,
+ timeout: 30 * 1000,
+ });
+ await testSubjects.missingOrFail(`mlForecastValuesline`, {
+ allowHidden: true,
+ timeout: 30 * 1000,
+ });
+ await testSubjects.missingOrFail(`mlForecastMarkers`, {
+ allowHidden: true,
+ timeout: 30 * 1000,
+ });
+ },
+
+ async assertForecastCheckboxExists() {
+ await testSubjects.existOrFail(`mlForecastCheckbox`, {
+ timeout: 30 * 1000,
+ });
+ },
+
+ async assertForecastCheckboxMissing() {
+ await testSubjects.missingOrFail(`mlForecastCheckbox`, {
+ timeout: 30 * 1000,
+ });
+ },
+
+ async clickForecastCheckbox() {
+ await testSubjects.click('mlForecastCheckbox');
+ },
+
+ async openForecastModal() {
+ await testSubjects.click(
+ 'mlSingleMetricViewerSeriesControls > mlSingleMetricViewerButtonForecast'
+ );
+ await testSubjects.existOrFail('mlModalForecast');
+ },
+
+ async closeForecastModal() {
+ await testSubjects.click('mlModalForecast > mlModalForecastButtonClose');
+ await this.assertForecastModalMissing();
+ },
+
+ async assertForecastModalMissing() {
+ await testSubjects.missingOrFail(`mlModalForecast`, {
+ timeout: 30 * 1000,
+ });
+ },
+
+ async assertForecastModalRunButtonEnabled(expectedValue: boolean) {
+ const isEnabled = await testSubjects.isEnabled('mlModalForecast > mlModalForecastButtonRun');
+ expect(isEnabled).to.eql(
+ expectedValue,
+ `Expected forecast "run" button to be '${expectedValue ? 'enabled' : 'disabled'}' (got '${
+ isEnabled ? 'enabled' : 'disabled'
+ }')`
+ );
+ },
+
+ async assertForecastTableExists() {
+ await testSubjects.existOrFail('mlModalForecast > mlModalForecastTable');
+ },
+
+ async clickForecastModalRunButton() {
+ await testSubjects.click('mlModalForecast > mlModalForecastButtonRun');
+ await this.assertForecastModalMissing();
+ },
+
+ async getForecastTableRows() {
+ return await testSubjects.findAll('mlModalForecastTable > ~mlForecastsListRow');
+ },
+
+ async assertForecastTableNotEmpty() {
+ const tableRows = await this.getForecastTableRows();
+ expect(tableRows.length).to.be.greaterThan(
+ 0,
+ `Forecast table should have at least one row (got '${tableRows.length}')`
+ );
+ },
+ };
+}
diff --git a/x-pack/test/functional/services/ml/index.ts b/x-pack/test/functional/services/ml/index.ts
index 17302b278222..4b48e4c0269e 100644
--- a/x-pack/test/functional/services/ml/index.ts
+++ b/x-pack/test/functional/services/ml/index.ts
@@ -24,6 +24,7 @@ import { MachineLearningDataVisualizerProvider } from './data_visualizer';
import { MachineLearningDataVisualizerFileBasedProvider } from './data_visualizer_file_based';
import { MachineLearningDataVisualizerIndexBasedProvider } from './data_visualizer_index_based';
import { MachineLearningDataVisualizerIndexPatternManagementProvider } from './data_visualizer_index_pattern_management';
+import { MachineLearningForecastProvider } from './forecast';
import { MachineLearningJobManagementProvider } from './job_management';
import { MachineLearningJobSelectionProvider } from './job_selection';
import { MachineLearningJobSourceSelectionProvider } from './job_source_selection';
@@ -92,6 +93,7 @@ export function MachineLearningProvider(context: FtrProviderContext) {
const dataVisualizerIndexPatternManagement =
MachineLearningDataVisualizerIndexPatternManagementProvider(context, dataVisualizerTable);
+ const forecast = MachineLearningForecastProvider(context);
const jobAnnotations = MachineLearningJobAnnotationsProvider(context);
const jobManagement = MachineLearningJobManagementProvider(context, api);
const jobSelection = MachineLearningJobSelectionProvider(context);
@@ -145,6 +147,7 @@ export function MachineLearningProvider(context: FtrProviderContext) {
dataVisualizerIndexBased,
dataVisualizerIndexPatternManagement,
dataVisualizerTable,
+ forecast,
jobAnnotations,
jobManagement,
jobSelection,
diff --git a/x-pack/test/functional/services/ml/single_metric_viewer.ts b/x-pack/test/functional/services/ml/single_metric_viewer.ts
index ac3fd67e3f94..29f1ded74deb 100644
--- a/x-pack/test/functional/services/ml/single_metric_viewer.ts
+++ b/x-pack/test/functional/services/ml/single_metric_viewer.ts
@@ -22,24 +22,6 @@ export function MachineLearningSingleMetricViewerProvider(
await testSubjects.existOrFail('mlNoSingleMetricJobsFound');
},
- async assertForecastButtonExists() {
- await testSubjects.existOrFail(
- 'mlSingleMetricViewerSeriesControls > mlSingleMetricViewerButtonForecast'
- );
- },
-
- async assertForecastButtonEnabled(expectedValue: boolean) {
- const isEnabled = await testSubjects.isEnabled(
- 'mlSingleMetricViewerSeriesControls > mlSingleMetricViewerButtonForecast'
- );
- expect(isEnabled).to.eql(
- expectedValue,
- `Expected "forecast" button to be '${expectedValue ? 'enabled' : 'disabled'}' (got '${
- isEnabled ? 'enabled' : 'disabled'
- }')`
- );
- },
-
async assertDetectorInputExist() {
await testSubjects.existOrFail(
'mlSingleMetricViewerSeriesControls > mlSingleMetricViewerDetectorSelect'
@@ -97,28 +79,6 @@ export function MachineLearningSingleMetricViewerProvider(
});
},
- async openForecastModal() {
- await testSubjects.click(
- 'mlSingleMetricViewerSeriesControls > mlSingleMetricViewerButtonForecast'
- );
- await testSubjects.existOrFail('mlModalForecast');
- },
-
- async closeForecastModal() {
- await testSubjects.click('mlModalForecast > mlModalForecastButtonClose');
- await testSubjects.missingOrFail('mlModalForecast');
- },
-
- async assertForecastModalRunButtonEnabled(expectedValue: boolean) {
- const isEnabled = await testSubjects.isEnabled('mlModalForecast > mlModalForecastButtonRun');
- expect(isEnabled).to.eql(
- expectedValue,
- `Expected forecast "run" button to be '${expectedValue ? 'enabled' : 'disabled'}' (got '${
- isEnabled ? 'enabled' : 'disabled'
- }')`
- );
- },
-
async openAnomalyExplorer() {
await testSubjects.click('mlAnomalyResultsViewSelectorExplorer');
await testSubjects.existOrFail('mlPageAnomalyExplorer');