mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
* [ML] Adding ability to change data view in advanced job wizard * updating translation ids * type and text changes * code clean up * route id change * text changes * text change * changing data view to index pattern * adding api tests * text updates * removing first step * renaming temp variable * adding permission checks Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: James Gowdy <jgowdy@elastic.co>
This commit is contained in:
parent
a50fb5ad87
commit
6e64940117
22 changed files with 711 additions and 19 deletions
14
x-pack/plugins/ml/common/types/job_validation.ts
Normal file
14
x-pack/plugins/ml/common/types/job_validation.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* 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 type { ErrorType } from '../util/errors';
|
||||
|
||||
export interface DatafeedValidationResponse {
|
||||
valid: boolean;
|
||||
documentsFound: boolean;
|
||||
error?: ErrorType;
|
||||
}
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { ErrorType } from '../util/errors';
|
||||
import type { ErrorType } from '../util/errors';
|
||||
export type JobType = 'anomaly-detector' | 'data-frame-analytics';
|
||||
export const ML_SAVED_OBJECT_TYPE = 'ml-job';
|
||||
export const ML_MODULE_SAVED_OBJECT_TYPE = 'ml-module';
|
||||
|
|
|
@ -23,7 +23,7 @@ export const useNavigateToPath = () => {
|
|||
const location = useLocation();
|
||||
|
||||
return useCallback(
|
||||
async (path: string | undefined, preserveSearch = false) => {
|
||||
async (path: string | undefined, preserveSearch: boolean = false) => {
|
||||
if (path === undefined) return;
|
||||
const modifiedPath = `${path}${preserveSearch === true ? location.search : ''}`;
|
||||
/**
|
||||
|
|
|
@ -502,6 +502,10 @@ export class JobCreator {
|
|||
return this._datafeed_config.indices;
|
||||
}
|
||||
|
||||
public set indices(indics: string[]) {
|
||||
this._datafeed_config.indices = indics;
|
||||
}
|
||||
|
||||
public get scriptFields(): Field[] {
|
||||
return this._scriptFields;
|
||||
}
|
||||
|
|
|
@ -258,17 +258,21 @@ export function convertToMultiMetricJob(
|
|||
jobCreator.createdBy = CREATED_BY_LABEL.MULTI_METRIC;
|
||||
jobCreator.modelPlot = false;
|
||||
stashJobForCloning(jobCreator, true, true);
|
||||
|
||||
navigateToPath(`jobs/new_job/${JOB_TYPE.MULTI_METRIC}`, true);
|
||||
}
|
||||
|
||||
export function convertToAdvancedJob(jobCreator: JobCreatorType, navigateToPath: NavigateToPath) {
|
||||
jobCreator.createdBy = null;
|
||||
stashJobForCloning(jobCreator, true, true);
|
||||
|
||||
navigateToPath(`jobs/new_job/${JOB_TYPE.ADVANCED}`, true);
|
||||
}
|
||||
|
||||
export function resetAdvancedJob(jobCreator: JobCreatorType, navigateToPath: NavigateToPath) {
|
||||
jobCreator.createdBy = null;
|
||||
stashJobForCloning(jobCreator, true, false);
|
||||
navigateToPath('/jobs/new_job');
|
||||
}
|
||||
|
||||
export function resetJob(jobCreator: JobCreatorType, navigateToPath: NavigateToPath) {
|
||||
jobCreator.jobId = '';
|
||||
stashJobForCloning(jobCreator, true, true);
|
||||
|
|
|
@ -204,7 +204,7 @@ export const JsonEditorFlyout: FC<Props> = ({ isDisabled, jobEditorMode, datafee
|
|||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.newJob.wizard.jsonFlyout.indicesChange.calloutText"
|
||||
defaultMessage="It is not possible to alter the indices being used by the datafeed here. If you wish to select a different index pattern or saved search, please start the job creation again, selecting a different index pattern."
|
||||
defaultMessage="You cannot alter the indices being used by the datafeed here. To select a different index pattern or saved search, go to step 1 of the wizard and select the Change index pattern option."
|
||||
/>
|
||||
</EuiCallOut>
|
||||
</>
|
||||
|
|
|
@ -0,0 +1,326 @@
|
|||
/*
|
||||
* 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 React, { FC, useState, useEffect, useCallback, useContext } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiButtonEmpty,
|
||||
EuiModal,
|
||||
EuiButton,
|
||||
EuiCallOut,
|
||||
EuiSpacer,
|
||||
EuiModalHeader,
|
||||
EuiLoadingSpinner,
|
||||
EuiModalHeaderTitle,
|
||||
EuiModalBody,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { JobCreatorContext } from '../../../job_creator_context';
|
||||
import { AdvancedJobCreator } from '../../../../../common/job_creator';
|
||||
import { resetAdvancedJob } from '../../../../../common/job_creator/util/general';
|
||||
import {
|
||||
CombinedJob,
|
||||
Datafeed,
|
||||
} from '../../../../../../../../../common/types/anomaly_detection_jobs';
|
||||
import { extractErrorMessage } from '../../../../../../../../../common/util/errors';
|
||||
import type { DatafeedValidationResponse } from '../../../../../../../../../common/types/job_validation';
|
||||
|
||||
import { SavedObjectFinderUi } from '../../../../../../../../../../../../src/plugins/saved_objects/public';
|
||||
import {
|
||||
useMlKibana,
|
||||
useMlApiContext,
|
||||
useNavigateToPath,
|
||||
} from '../../../../../../../contexts/kibana';
|
||||
|
||||
const fixedPageSize: number = 8;
|
||||
|
||||
enum STEP {
|
||||
PICK_DATA_VIEW,
|
||||
VALIDATE,
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ChangeDataViewModal: FC<Props> = ({ onClose }) => {
|
||||
const {
|
||||
services: {
|
||||
savedObjects,
|
||||
uiSettings,
|
||||
data: { dataViews },
|
||||
},
|
||||
} = useMlKibana();
|
||||
const navigateToPath = useNavigateToPath();
|
||||
const { validateDatafeedPreview } = useMlApiContext();
|
||||
|
||||
const { jobCreator: jc } = useContext(JobCreatorContext);
|
||||
const jobCreator = jc as AdvancedJobCreator;
|
||||
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [step, setStep] = useState(STEP.PICK_DATA_VIEW);
|
||||
|
||||
const [currentDataViewTitle, setCurrentDataViewTitle] = useState<string>('');
|
||||
const [newDataViewTitle, setNewDataViewTitle] = useState<string>('');
|
||||
const [validationResponse, setValidationResponse] = useState<DatafeedValidationResponse | null>(
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(function initialPageLoad() {
|
||||
setCurrentDataViewTitle(jobCreator.indexPatternTitle);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
function stepChange() {
|
||||
if (step === STEP.PICK_DATA_VIEW) {
|
||||
setValidationResponse(null);
|
||||
}
|
||||
},
|
||||
[step]
|
||||
);
|
||||
|
||||
function onDataViewSelected(dataViewId: string) {
|
||||
if (validating === false) {
|
||||
setStep(STEP.VALIDATE);
|
||||
validate(dataViewId);
|
||||
}
|
||||
}
|
||||
|
||||
const validate = useCallback(
|
||||
async (dataViewId: string) => {
|
||||
setValidating(true);
|
||||
|
||||
const { title } = await dataViews.get(dataViewId);
|
||||
setNewDataViewTitle(title);
|
||||
|
||||
const indices = title.split(',');
|
||||
if (jobCreator.detectors.length) {
|
||||
const datafeed: Datafeed = { ...jobCreator.datafeedConfig, indices };
|
||||
const resp = await validateDatafeedPreview({
|
||||
job: {
|
||||
...jobCreator.jobConfig,
|
||||
datafeed_config: datafeed,
|
||||
} as CombinedJob,
|
||||
});
|
||||
setValidationResponse(resp);
|
||||
}
|
||||
setValidating(false);
|
||||
},
|
||||
[dataViews, validateDatafeedPreview, jobCreator]
|
||||
);
|
||||
|
||||
const applyDataView = useCallback(() => {
|
||||
const newIndices = newDataViewTitle.split(',');
|
||||
jobCreator.indices = newIndices;
|
||||
resetAdvancedJob(jobCreator, navigateToPath);
|
||||
}, [jobCreator, newDataViewTitle, navigateToPath]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiModal onClose={onClose} data-test-subj="mlJobMgmtImportJobsFlyout">
|
||||
<EuiModalHeader>
|
||||
<EuiModalHeaderTitle>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.newJob.wizard.datafeedStep.dataView.step0.title"
|
||||
defaultMessage="Change index pattern"
|
||||
/>
|
||||
</EuiModalHeaderTitle>
|
||||
</EuiModalHeader>
|
||||
|
||||
<EuiModalBody>
|
||||
{step === STEP.PICK_DATA_VIEW && (
|
||||
<>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.newJob.wizard.datafeedStep.dataView.step1.title"
|
||||
defaultMessage="Select new index pattern for the job"
|
||||
/>
|
||||
|
||||
<EuiSpacer size="s" />
|
||||
|
||||
<SavedObjectFinderUi
|
||||
key="searchSavedObjectFinder"
|
||||
onChoose={onDataViewSelected}
|
||||
showFilter
|
||||
noItemsMessage={i18n.translate(
|
||||
'xpack.ml.newJob.wizard.datafeedStep.dataView.step1.noMatchingError',
|
||||
{
|
||||
defaultMessage: 'No matching indices or saved searches found.',
|
||||
}
|
||||
)}
|
||||
savedObjectMetaData={[
|
||||
{
|
||||
type: 'index-pattern',
|
||||
getIconForSavedObject: () => 'indexPatternApp',
|
||||
name: i18n.translate(
|
||||
'xpack.ml.newJob.wizard.datafeedStep.dataView.step1.dataView',
|
||||
{
|
||||
defaultMessage: 'Index pattern',
|
||||
}
|
||||
),
|
||||
},
|
||||
]}
|
||||
fixedPageSize={fixedPageSize}
|
||||
uiSettings={uiSettings}
|
||||
savedObjects={savedObjects}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{step === STEP.VALIDATE && (
|
||||
<>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.newJob.wizard.datafeedStep.dataView.step2.title"
|
||||
defaultMessage="Changing {dv1} for {dv2}"
|
||||
values={{ dv1: currentDataViewTitle, dv2: newDataViewTitle }}
|
||||
/>
|
||||
|
||||
<EuiSpacer size="s" />
|
||||
|
||||
{validating === true ? (
|
||||
<>
|
||||
<EuiLoadingSpinner />
|
||||
<FormattedMessage
|
||||
id="xpack.ml.newJob.wizard.datafeedStep.dataView.step2.validatingText"
|
||||
defaultMessage="Checking index pattern and job compatibility"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<ValidationMessage
|
||||
validationResponse={validationResponse}
|
||||
dataViewTitle={newDataViewTitle}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EuiSpacer size="s" />
|
||||
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
onClick={setStep.bind(null, STEP.PICK_DATA_VIEW)}
|
||||
isDisabled={validating}
|
||||
flush="left"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.newJob.wizard.datafeedStep.dataView.step2.backButton"
|
||||
defaultMessage="Back"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
onClick={() => applyDataView()}
|
||||
isDisabled={validating}
|
||||
data-test-subj="mlJobsImportButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.newJob.wizard.datafeedStep.dataView.step2.ApplyButton"
|
||||
defaultMessage="Apply"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</>
|
||||
)}
|
||||
</EuiModalBody>
|
||||
</EuiModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ValidationMessage: FC<{
|
||||
validationResponse: DatafeedValidationResponse | null;
|
||||
dataViewTitle: string;
|
||||
}> = ({ validationResponse, dataViewTitle }) => {
|
||||
if (validationResponse === null) {
|
||||
return (
|
||||
<EuiCallOut
|
||||
title={i18n.translate(
|
||||
'xpack.ml.newJob.wizard.datafeedStep.dataView.validation.noDetectors.title',
|
||||
{
|
||||
defaultMessage: 'Index pattern valid',
|
||||
}
|
||||
)}
|
||||
color="primary"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.newJob.wizard.datafeedStep.dataView.validation.noDetectors.message"
|
||||
defaultMessage="No detectors have been configured; this index pattern can be applied to the job."
|
||||
/>
|
||||
</EuiCallOut>
|
||||
);
|
||||
}
|
||||
if (validationResponse.valid === true) {
|
||||
if (validationResponse.documentsFound === true) {
|
||||
return (
|
||||
<EuiCallOut
|
||||
title={i18n.translate(
|
||||
'xpack.ml.newJob.wizard.datafeedStep.dataView.validation.valid.title',
|
||||
{
|
||||
defaultMessage: 'Index pattern valid',
|
||||
}
|
||||
)}
|
||||
color="primary"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.newJob.wizard.datafeedStep.dataView.validation.valid.message"
|
||||
defaultMessage="This index pattern can be applied to this job."
|
||||
/>
|
||||
</EuiCallOut>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<EuiCallOut
|
||||
title={i18n.translate(
|
||||
'xpack.ml.newJob.wizard.datafeedStep.dataView.validation.possiblyInvalid.title',
|
||||
{
|
||||
defaultMessage: 'Index pattern possibly invalid',
|
||||
}
|
||||
)}
|
||||
color="warning"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.newJob.wizard.datafeedStep.dataView.validation.possiblyInvalid.message"
|
||||
defaultMessage="This index pattern produced no results when previewing the datafeed. There may be no documents in {dataViewTitle}."
|
||||
values={{ dataViewTitle }}
|
||||
/>
|
||||
</EuiCallOut>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
<EuiCallOut
|
||||
title={i18n.translate(
|
||||
'xpack.ml.newJob.wizard.datafeedStep.dataView.validation.invalid.title',
|
||||
{
|
||||
defaultMessage: 'Index pattern invalid',
|
||||
}
|
||||
)}
|
||||
color="danger"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.newJob.wizard.datafeedStep.dataView.validation.invalid.message"
|
||||
defaultMessage="This index pattern produced an error when attempting to preview the datafeed. The fields selected for this job might not exist in {dataViewTitle}."
|
||||
values={{ dataViewTitle }}
|
||||
/>
|
||||
|
||||
<EuiSpacer size="s" />
|
||||
|
||||
<FormattedMessage
|
||||
id="xpack.ml.newJob.wizard.datafeedStep.dataView.validation.invalid.reason"
|
||||
defaultMessage="Reason:"
|
||||
/>
|
||||
|
||||
<EuiSpacer size="s" />
|
||||
|
||||
{validationResponse.error ? extractErrorMessage(validationResponse.error) : null}
|
||||
</EuiCallOut>
|
||||
);
|
||||
}
|
||||
};
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* 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 React, { FC, useState } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
import { EuiButtonEmpty } from '@elastic/eui';
|
||||
import { Description } from './description';
|
||||
import { ChangeDataViewModal } from './change_data_view';
|
||||
|
||||
export const ChangeDataView: FC<{ isDisabled: boolean }> = ({ isDisabled }) => {
|
||||
const [showFlyout, setShowFlyout] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showFlyout && <ChangeDataViewModal onClose={setShowFlyout.bind(null, false)} />}
|
||||
|
||||
<Description>
|
||||
<EuiButtonEmpty
|
||||
onClick={setShowFlyout.bind(null, true)}
|
||||
isDisabled={isDisabled}
|
||||
data-test-subj="mlJobsImportButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.newJob.wizard.datafeedStep.dataView.changeDataView.button"
|
||||
defaultMessage="Change index pattern"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</Description>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* 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 React, { memo, FC } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui';
|
||||
|
||||
export const Description: FC = memo(({ children }) => {
|
||||
const title = i18n.translate('xpack.ml.newJob.wizard.datafeedStep.dataView.title', {
|
||||
defaultMessage: 'Index pattern',
|
||||
});
|
||||
return (
|
||||
<EuiDescribedFormGroup
|
||||
title={<h3>{title}</h3>}
|
||||
description={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.newJob.wizard.datafeedStep.dataView.description"
|
||||
defaultMessage="The index pattern that is currently used for this job."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFormRow>
|
||||
<>{children}</>
|
||||
</EuiFormRow>
|
||||
</EuiDescribedFormGroup>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { ChangeDataView } from './change_data_view_button';
|
|
@ -14,6 +14,7 @@ import { FrequencyInput } from './components/frequency';
|
|||
import { ScrollSizeInput } from './components/scroll_size';
|
||||
import { ResetQueryButton } from './components/reset_query';
|
||||
import { TimeField } from './components/time_field';
|
||||
import { ChangeDataView } from './components/data_view';
|
||||
import { WIZARD_STEPS, StepProps } from '../step_types';
|
||||
import { JobCreatorContext } from '../job_creator_context';
|
||||
import { JsonEditorFlyout, EDITOR_MODE } from '../common/json_editor_flyout';
|
||||
|
@ -46,6 +47,7 @@ export const DatafeedStep: FC<StepProps> = ({ setCurrentStep, isCurrentStep }) =
|
|||
<FrequencyInput />
|
||||
<ScrollSizeInput />
|
||||
<TimeField />
|
||||
<ChangeDataView isDisabled={false} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<ResetQueryButton />
|
||||
|
|
|
@ -43,6 +43,7 @@ import type { FieldHistogramRequestConfig } from '../../datavisualizer/index_bas
|
|||
import type { DataRecognizerConfigResponse, Module } from '../../../../common/types/modules';
|
||||
import { getHttp } from '../../util/dependency_cache';
|
||||
import type { RuntimeMappings } from '../../../../common/types/fields';
|
||||
import type { DatafeedValidationResponse } from '../../../../common/types/job_validation';
|
||||
|
||||
export interface MlInfoResponse {
|
||||
defaults: MlServerDefaults;
|
||||
|
@ -194,7 +195,7 @@ export function mlApiServicesProvider(httpService: HttpService) {
|
|||
},
|
||||
|
||||
validateJob(payload: {
|
||||
job: Job;
|
||||
job: CombinedJob;
|
||||
duration: {
|
||||
start?: number;
|
||||
end?: number;
|
||||
|
@ -209,6 +210,15 @@ export function mlApiServicesProvider(httpService: HttpService) {
|
|||
});
|
||||
},
|
||||
|
||||
validateDatafeedPreview(payload: { job: CombinedJob }) {
|
||||
const body = JSON.stringify(payload);
|
||||
return httpService.http<DatafeedValidationResponse>({
|
||||
path: `${basePath()}/validate/datafeed_preview`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
validateCardinality$(job: CombinedJob): Observable<CardinalityValidationResults> {
|
||||
const body = JSON.stringify(job);
|
||||
return httpService.http$({
|
||||
|
|
|
@ -7,3 +7,7 @@
|
|||
|
||||
export { validateJob } from './job_validation';
|
||||
export { validateCardinality } from './validate_cardinality';
|
||||
export {
|
||||
validateDatafeedPreviewWithMessages,
|
||||
validateDatafeedPreview,
|
||||
} from './validate_datafeed_preview';
|
||||
|
|
|
@ -17,7 +17,7 @@ import { basicJobValidation, uniqWithIsEqual } from '../../../common/util/job_ut
|
|||
import { validateBucketSpan } from './validate_bucket_span';
|
||||
import { validateCardinality } from './validate_cardinality';
|
||||
import { validateInfluencers } from './validate_influencers';
|
||||
import { validateDatafeedPreview } from './validate_datafeed_preview';
|
||||
import { validateDatafeedPreviewWithMessages } from './validate_datafeed_preview';
|
||||
import { validateModelMemoryLimit } from './validate_model_memory_limit';
|
||||
import { validateTimeRange, isValidTimeField } from './validate_time_range';
|
||||
import { validateJobSchema } from '../../routes/schemas/job_validation_schema';
|
||||
|
@ -111,7 +111,9 @@ export async function validateJob(
|
|||
validationMessages.push({ id: 'missing_summary_count_field_name' });
|
||||
}
|
||||
|
||||
validationMessages.push(...(await validateDatafeedPreview(mlClient, authHeader, job)));
|
||||
validationMessages.push(
|
||||
...(await validateDatafeedPreviewWithMessages(mlClient, authHeader, job))
|
||||
);
|
||||
} else {
|
||||
validationMessages = basicValidation.messages;
|
||||
validationMessages.push({ id: 'skipped_extended_tests' });
|
||||
|
|
|
@ -9,12 +9,25 @@ import type { MlClient } from '../../lib/ml_client';
|
|||
import type { AuthorizationHeader } from '../../lib/request_authorization';
|
||||
import type { CombinedJob } from '../../../common/types/anomaly_detection_jobs';
|
||||
import type { JobValidationMessage } from '../../../common/constants/messages';
|
||||
import type { DatafeedValidationResponse } from '../../../common/types/job_validation';
|
||||
|
||||
export async function validateDatafeedPreviewWithMessages(
|
||||
mlClient: MlClient,
|
||||
authHeader: AuthorizationHeader,
|
||||
job: CombinedJob
|
||||
): Promise<JobValidationMessage[]> {
|
||||
const { valid, documentsFound } = await validateDatafeedPreview(mlClient, authHeader, job);
|
||||
if (valid) {
|
||||
return documentsFound ? [] : [{ id: 'datafeed_preview_no_documents' }];
|
||||
}
|
||||
return [{ id: 'datafeed_preview_failed' }];
|
||||
}
|
||||
|
||||
export async function validateDatafeedPreview(
|
||||
mlClient: MlClient,
|
||||
authHeader: AuthorizationHeader,
|
||||
job: CombinedJob
|
||||
): Promise<JobValidationMessage[]> {
|
||||
): Promise<DatafeedValidationResponse> {
|
||||
const { datafeed_config: datafeed, ...tempJob } = job;
|
||||
try {
|
||||
const { body } = (await mlClient.previewDatafeed(
|
||||
|
@ -28,11 +41,15 @@ export async function validateDatafeedPreview(
|
|||
// previewDatafeed response type is incorrect
|
||||
)) as unknown as { body: unknown[] };
|
||||
|
||||
if (Array.isArray(body) === false || body.length === 0) {
|
||||
return [{ id: 'datafeed_preview_no_documents' }];
|
||||
}
|
||||
return [];
|
||||
return {
|
||||
valid: true,
|
||||
documentsFound: Array.isArray(body) && body.length > 0,
|
||||
};
|
||||
} catch (error) {
|
||||
return [{ id: 'datafeed_preview_failed' }];
|
||||
return {
|
||||
valid: false,
|
||||
documentsFound: false,
|
||||
error: error.body ?? error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -123,11 +123,13 @@
|
|||
"GetJobAuditMessages",
|
||||
"GetAllJobAuditMessages",
|
||||
"ClearJobAuditMessages",
|
||||
|
||||
"JobValidation",
|
||||
"EstimateBucketSpan",
|
||||
"CalculateModelMemoryLimit",
|
||||
"ValidateCardinality",
|
||||
"ValidateJob",
|
||||
"ValidateDataFeedPreview",
|
||||
|
||||
"DatafeedService",
|
||||
"CreateDatafeed",
|
||||
|
|
|
@ -16,12 +16,18 @@ import {
|
|||
modelMemoryLimitSchema,
|
||||
validateCardinalitySchema,
|
||||
validateJobSchema,
|
||||
validateDatafeedPreviewSchema,
|
||||
} from './schemas/job_validation_schema';
|
||||
import { estimateBucketSpanFactory } from '../models/bucket_span_estimator';
|
||||
import { calculateModelMemoryLimitProvider } from '../models/calculate_model_memory_limit';
|
||||
import { validateJob, validateCardinality } from '../models/job_validation';
|
||||
import {
|
||||
validateJob,
|
||||
validateCardinality,
|
||||
validateDatafeedPreview,
|
||||
} from '../models/job_validation';
|
||||
import { getAuthorizationHeader } from '../lib/request_authorization';
|
||||
import type { MlClient } from '../lib/ml_client';
|
||||
import { CombinedJob } from '../../common/types/anomaly_detection_jobs';
|
||||
|
||||
type CalculateModelMemoryLimitPayload = TypeOf<typeof modelMemoryLimitSchema>;
|
||||
|
||||
|
@ -205,4 +211,40 @@ export function jobValidationRoutes({ router, mlLicense, routeGuard }: RouteInit
|
|||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* @apiGroup DataFeedPreviewValidation
|
||||
*
|
||||
* @api {post} /api/ml/validate/datafeed_preview Validates datafeed preview
|
||||
* @apiName ValidateDataFeedPreview
|
||||
* @apiDescription Validates that the datafeed preview runs successfully and produces results
|
||||
*
|
||||
* @apiSchema (body) validateDatafeedPreviewSchema
|
||||
*/
|
||||
router.post(
|
||||
{
|
||||
path: '/api/ml/validate/datafeed_preview',
|
||||
validate: {
|
||||
body: validateDatafeedPreviewSchema,
|
||||
},
|
||||
options: {
|
||||
tags: ['access:ml:canCreateJob'],
|
||||
},
|
||||
},
|
||||
routeGuard.fullLicenseAPIGuard(async ({ client, mlClient, request, response }) => {
|
||||
try {
|
||||
const resp = await validateDatafeedPreview(
|
||||
mlClient,
|
||||
getAuthorizationHeader(request),
|
||||
request.body.job as CombinedJob
|
||||
);
|
||||
|
||||
return response.ok({
|
||||
body: resp,
|
||||
});
|
||||
} catch (e) {
|
||||
return response.customError(wrapError(e));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
@ -60,6 +60,13 @@ export const validateJobSchema = schema.object({
|
|||
}),
|
||||
});
|
||||
|
||||
export const validateDatafeedPreviewSchema = schema.object({
|
||||
job: schema.object({
|
||||
...anomalyDetectionJobSchema,
|
||||
datafeed_config: datafeedConfigSchema,
|
||||
}),
|
||||
});
|
||||
|
||||
export const validateCardinalitySchema = schema.object({
|
||||
...anomalyDetectionJobSchema,
|
||||
datafeed_config: datafeedConfigSchema,
|
||||
|
|
|
@ -130,7 +130,7 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
const destinationIndex = generateDestinationIndex(analyticsId);
|
||||
|
||||
before(async () => {
|
||||
await ml.api.createIndices(destinationIndex);
|
||||
await ml.api.createIndex(destinationIndex);
|
||||
await ml.api.assertIndicesExist(destinationIndex);
|
||||
});
|
||||
|
||||
|
@ -189,7 +189,7 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
|
||||
before(async () => {
|
||||
// Mimic real job by creating target index & index pattern after DFA job is created
|
||||
await ml.api.createIndices(destinationIndex);
|
||||
await ml.api.createIndex(destinationIndex);
|
||||
await ml.api.assertIndicesExist(destinationIndex);
|
||||
await ml.testResources.createIndexPatternIfNeeded(destinationIndex);
|
||||
});
|
||||
|
|
|
@ -0,0 +1,175 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
/*
|
||||
* 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 { estypes } from '@elastic/elasticsearch';
|
||||
import { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
import { USER } from '../../../../functional/services/ml/security_common';
|
||||
import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common_api';
|
||||
|
||||
const farequoteMappings: estypes.MappingTypeMapping = {
|
||||
properties: {
|
||||
'@timestamp': {
|
||||
type: 'date',
|
||||
},
|
||||
airline: {
|
||||
type: 'keyword',
|
||||
},
|
||||
responsetime: {
|
||||
type: 'float',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function getBaseJobConfig() {
|
||||
return {
|
||||
job_id: 'test',
|
||||
description: '',
|
||||
analysis_config: {
|
||||
bucket_span: '15m',
|
||||
detectors: [
|
||||
{
|
||||
function: 'mean',
|
||||
field_name: 'responsetime',
|
||||
},
|
||||
],
|
||||
influencers: [],
|
||||
},
|
||||
analysis_limits: {
|
||||
model_memory_limit: '11MB',
|
||||
},
|
||||
data_description: {
|
||||
time_field: '@timestamp',
|
||||
time_format: 'epoch_ms',
|
||||
},
|
||||
model_plot_config: {
|
||||
enabled: false,
|
||||
annotations_enabled: false,
|
||||
},
|
||||
model_snapshot_retention_days: 10,
|
||||
daily_model_snapshot_retention_after_days: 1,
|
||||
allow_lazy_open: false,
|
||||
datafeed_config: {
|
||||
query: {
|
||||
bool: {
|
||||
must: [
|
||||
{
|
||||
match_all: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
indices: ['ft_farequote'],
|
||||
scroll_size: 1000,
|
||||
delayed_data_check_config: {
|
||||
enabled: true,
|
||||
},
|
||||
job_id: 'test',
|
||||
datafeed_id: 'datafeed-test',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default ({ getService }: FtrProviderContext) => {
|
||||
const esArchiver = getService('esArchiver');
|
||||
const supertest = getService('supertestWithoutAuth');
|
||||
const ml = getService('ml');
|
||||
|
||||
describe('Validate datafeed preview', function () {
|
||||
before(async () => {
|
||||
await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote');
|
||||
await ml.testResources.setKibanaTimeZoneToUTC();
|
||||
await ml.api.createIndex('farequote_empty', farequoteMappings);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await ml.api.cleanMlIndices();
|
||||
await ml.api.deleteIndices('farequote_empty');
|
||||
});
|
||||
|
||||
it(`should validate a job with documents`, async () => {
|
||||
const job = getBaseJobConfig();
|
||||
|
||||
const { body } = await supertest
|
||||
.post('/api/ml/validate/datafeed_preview')
|
||||
.auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
|
||||
.set(COMMON_REQUEST_HEADERS)
|
||||
.send({ job })
|
||||
.expect(200);
|
||||
|
||||
expect(body.valid).to.eql(true, `valid should be true, but got ${body.valid}`);
|
||||
expect(body.documentsFound).to.eql(
|
||||
true,
|
||||
`documentsFound should be true, but got ${body.documentsFound}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`should fail to validate a job with documents and non-existent field`, async () => {
|
||||
const job = getBaseJobConfig();
|
||||
job.analysis_config.detectors[0].field_name = 'no_such_field';
|
||||
|
||||
const { body } = await supertest
|
||||
.post('/api/ml/validate/datafeed_preview')
|
||||
.auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
|
||||
.set(COMMON_REQUEST_HEADERS)
|
||||
.send({ job })
|
||||
.expect(200);
|
||||
|
||||
expect(body.valid).to.eql(false, `valid should be false, but got ${body.valid}`);
|
||||
expect(body.documentsFound).to.eql(
|
||||
false,
|
||||
`documentsFound should be false, but got ${body.documentsFound}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`should validate a job with no documents`, async () => {
|
||||
const job = getBaseJobConfig();
|
||||
job.datafeed_config.indices = ['farequote_empty'];
|
||||
|
||||
const { body } = await supertest
|
||||
.post('/api/ml/validate/datafeed_preview')
|
||||
.auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
|
||||
.set(COMMON_REQUEST_HEADERS)
|
||||
.send({ job })
|
||||
.expect(200);
|
||||
|
||||
expect(body.valid).to.eql(true, `valid should be true, but got ${body.valid}`);
|
||||
expect(body.documentsFound).to.eql(
|
||||
false,
|
||||
`documentsFound should be false, but got ${body.documentsFound}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`should fail for viewer user`, async () => {
|
||||
const job = getBaseJobConfig();
|
||||
|
||||
await supertest
|
||||
.post('/api/ml/validate/datafeed_preview')
|
||||
.auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER))
|
||||
.set(COMMON_REQUEST_HEADERS)
|
||||
.send({ job })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it(`should fail for unauthorized user`, async () => {
|
||||
const job = getBaseJobConfig();
|
||||
|
||||
await supertest
|
||||
.post('/api/ml/validate/datafeed_preview')
|
||||
.auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED))
|
||||
.set(COMMON_REQUEST_HEADERS)
|
||||
.send({ job })
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
};
|
|
@ -13,5 +13,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
|
|||
loadTestFile(require.resolve('./calculate_model_memory_limit'));
|
||||
loadTestFile(require.resolve('./cardinality'));
|
||||
loadTestFile(require.resolve('./validate'));
|
||||
loadTestFile(require.resolve('./datafeed_preview_validation'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -126,14 +126,20 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) {
|
|||
);
|
||||
},
|
||||
|
||||
async createIndices(indices: string) {
|
||||
async createIndex(
|
||||
indices: string,
|
||||
mappings?: Record<string, estypes.MappingTypeMapping> | estypes.MappingTypeMapping
|
||||
) {
|
||||
log.debug(`Creating indices: '${indices}'...`);
|
||||
if ((await es.indices.exists({ index: indices, allow_no_indices: false })).body === true) {
|
||||
log.debug(`Indices '${indices}' already exist. Nothing to create.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { body } = await es.indices.create({ index: indices });
|
||||
const { body } = await es.indices.create({
|
||||
index: indices,
|
||||
...(mappings ? { body: { mappings } } : {}),
|
||||
});
|
||||
expect(body)
|
||||
.to.have.property('acknowledged')
|
||||
.eql(true, 'Response for create request indices should be acknowledged.');
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue