[ML] DF Analytics: add ability to edit job for fields supported by API (#70489)

* wip: add edit action to dfanalytics table

* add update endpoint and edit flyout

* show success and error toasts. close flyout and refresh on success

* show permission message in edit action

* update types

* disable update button if mml not valid

* show error in toast, init values are config values

* fix undefined check for allow lazy start

* prevent update if mml is empty
This commit is contained in:
Melissa Alvarez 2020-07-06 15:10:01 -04:00 committed by GitHub
parent 321fb871cc
commit a4340f0ece
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 424 additions and 2 deletions

View file

@ -67,6 +67,8 @@ export function requiredValidator() {
export type ValidationResult = object | null;
export type MemoryInputValidatorResult = { invalidUnits: { allowedUnits: string } } | null;
export function memoryInputValidator(allowedUnits = ALLOWED_DATA_UNITS) {
return (value: any) => {
if (typeof value !== 'string' || value === '') {

View file

@ -327,9 +327,14 @@ export const isClassificationEvaluateResponse = (
);
};
export interface UpdateDataFrameAnalyticsConfig {
allow_lazy_start?: string;
description?: string;
model_memory_limit?: string;
}
export interface DataFrameAnalyticsConfig {
id: DataFrameAnalyticsId;
// Description attribute is not supported yet
description?: string;
dest: {
index: IndexName;

View file

@ -13,6 +13,7 @@ export {
useRefreshAnalyticsList,
DataFrameAnalyticsId,
DataFrameAnalyticsConfig,
UpdateDataFrameAnalyticsConfig,
IndexName,
IndexPattern,
REFRESH_ANALYTICS_LIST_STATE,

View file

@ -0,0 +1,66 @@
/*
* 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.
*/
import React, { useState, FC } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui';
import { checkPermission } from '../../../../../capabilities/check_capabilities';
import { DataFrameAnalyticsListRow } from './common';
import { EditAnalyticsFlyout } from './edit_analytics_flyout';
interface EditActionProps {
item: DataFrameAnalyticsListRow;
}
export const EditAction: FC<EditActionProps> = ({ item }) => {
const canCreateDataFrameAnalytics: boolean = checkPermission('canCreateDataFrameAnalytics');
const [isFlyoutVisible, setIsFlyoutVisible] = useState(false);
const closeFlyout = () => setIsFlyoutVisible(false);
const showFlyout = () => setIsFlyoutVisible(true);
const buttonEditText = i18n.translate('xpack.ml.dataframe.analyticsList.editActionName', {
defaultMessage: 'Edit',
});
const editButton = (
<EuiButtonEmpty
data-test-subj="mlAnalyticsJobEditButton"
size="xs"
color="text"
disabled={!canCreateDataFrameAnalytics}
iconType="copy"
onClick={showFlyout}
aria-label={buttonEditText}
>
{buttonEditText}
</EuiButtonEmpty>
);
if (!canCreateDataFrameAnalytics) {
return (
<EuiToolTip
position="top"
content={i18n.translate('xpack.ml.dataframe.analyticsList.editActionPermissionTooltip', {
defaultMessage: 'You do not have permission to edit analytics jobs.',
})}
>
{editButton}
</EuiToolTip>
);
}
return (
<>
{editButton}
{isFlyoutVisible && <EditAnalyticsFlyout closeFlyout={closeFlyout} item={item} />}
</>
);
};

View file

@ -27,6 +27,7 @@ import { getResultsUrl, isDataFrameAnalyticsRunning, DataFrameAnalyticsListRow }
import { stopAnalytics } from '../../services/analytics_service';
import { StartAction } from './action_start';
import { EditAction } from './action_edit';
import { DeleteAction } from './action_delete';
interface Props {
@ -133,6 +134,11 @@ export const getActions = (
return stopButton;
},
},
{
render: (item: DataFrameAnalyticsListRow) => {
return <EditAction item={item} />;
},
},
{
render: (item: DataFrameAnalyticsListRow) => {
return <DeleteAction item={item} />;

View file

@ -0,0 +1,270 @@
/*
* 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.
*/
import React, { FC, useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButton,
EuiButtonEmpty,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiForm,
EuiFormRow,
EuiOverlayMask,
EuiSelect,
EuiTitle,
} from '@elastic/eui';
import { useMlKibana } from '../../../../../contexts/kibana';
import { ml } from '../../../../../services/ml_api_service';
import {
memoryInputValidator,
MemoryInputValidatorResult,
} from '../../../../../../../common/util/validators';
import { extractErrorMessage } from '../../../../../../../common/util/errors';
import { DataFrameAnalyticsListRow, DATA_FRAME_TASK_STATE } from './common';
import {
useRefreshAnalyticsList,
UpdateDataFrameAnalyticsConfig,
} from '../../../../common/analytics';
interface EditAnalyticsJobFlyoutProps {
closeFlyout: () => void;
item: DataFrameAnalyticsListRow;
}
let mmLValidator: (value: any) => MemoryInputValidatorResult;
export const EditAnalyticsFlyout: FC<EditAnalyticsJobFlyoutProps> = ({ closeFlyout, item }) => {
const { id: jobId, config } = item;
const { state } = item.stats;
const initialAllowLazyStart =
config.allow_lazy_start !== undefined ? String(config.allow_lazy_start) : '';
const [allowLazyStart, setAllowLazyStart] = useState<string>(initialAllowLazyStart);
const [description, setDescription] = useState<string>(config.description || '');
const [modelMemoryLimit, setModelMemoryLimit] = useState<string>(config.model_memory_limit);
const [mmlValidationError, setMmlValidationError] = useState<string | undefined>();
const {
services: { notifications },
} = useMlKibana();
const { refresh } = useRefreshAnalyticsList();
// Disable if mml is not valid
const updateButtonDisabled = mmlValidationError !== undefined;
useEffect(() => {
if (mmLValidator === undefined) {
mmLValidator = memoryInputValidator();
}
// validate mml and create validation message
if (modelMemoryLimit !== '') {
const validationResult = mmLValidator(modelMemoryLimit);
if (validationResult !== null && validationResult.invalidUnits) {
setMmlValidationError(
i18n.translate('xpack.ml.dataframe.analytics.create.modelMemoryUnitsInvalidError', {
defaultMessage: 'Model memory limit data unit unrecognized. It must be {str}',
values: { str: validationResult.invalidUnits.allowedUnits },
})
);
} else {
setMmlValidationError(undefined);
}
} else {
setMmlValidationError(
i18n.translate('xpack.ml.dataframe.analytics.create.modelMemoryEmptyError', {
defaultMessage: 'Model memory limit must not be empty',
})
);
}
}, [modelMemoryLimit]);
const onSubmit = async () => {
const updateConfig: UpdateDataFrameAnalyticsConfig = Object.assign(
{
allow_lazy_start: allowLazyStart,
description,
},
modelMemoryLimit && { model_memory_limit: modelMemoryLimit }
);
try {
await ml.dataFrameAnalytics.updateDataFrameAnalytics(jobId, updateConfig);
notifications.toasts.addSuccess(
i18n.translate('xpack.ml.dataframe.analyticsList.editFlyoutSuccessMessage', {
defaultMessage: 'Analytics job {jobId} has been updated.',
values: { jobId },
})
);
refresh();
closeFlyout();
} catch (e) {
// eslint-disable-next-line
console.error(e);
notifications.toasts.addDanger({
title: i18n.translate('xpack.ml.dataframe.analyticsList.editFlyoutErrorMessage', {
defaultMessage: 'Could not save changes to analytics job {jobId}',
values: {
jobId,
},
}),
text: extractErrorMessage(e),
});
}
};
return (
<EuiOverlayMask>
<EuiFlyout
onClose={closeFlyout}
hideCloseButton
aria-labelledby="analyticsEditFlyoutTitle"
data-test-subj="analyticsEditFlyout"
>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2 id="analyticsEditFlyoutTitle">
{i18n.translate('xpack.ml.dataframe.analyticsList.editFlyoutTitle', {
defaultMessage: 'Edit {jobId}',
values: {
jobId,
},
})}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiForm>
<EuiFormRow
label={i18n.translate(
'xpack.ml.dataframe.analyticsList.editFlyout.allowLazyStartLabel',
{
defaultMessage: 'Allow lazy start',
}
)}
>
<EuiSelect
aria-label={i18n.translate(
'xpack.ml.dataframe.analyticsList.editFlyout.allowLazyStartAriaLabel',
{
defaultMessage: 'Update allow lazy start.',
}
)}
data-test-subj="mlAnalyticsEditFlyoutAllowLazyStartInput"
options={[
{
value: 'true',
text: i18n.translate(
'xpack.ml.dataframe.analyticsList.editFlyout.allowLazyStartTrueValue',
{
defaultMessage: 'True',
}
),
},
{
value: 'false',
text: i18n.translate(
'xpack.ml.dataframe.analyticsList.editFlyout.allowLazyStartFalseValue',
{
defaultMessage: 'False',
}
),
},
]}
value={allowLazyStart}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setAllowLazyStart(e.target.value)
}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate(
'xpack.ml.dataframe.analyticsList.editFlyout.descriptionLabel',
{
defaultMessage: 'Description',
}
)}
>
<EuiFieldText
data-test-subj="mlAnalyticsEditFlyoutDescriptionInput"
value={description}
onChange={(e) => setDescription(e.target.value)}
aria-label={i18n.translate(
'xpack.ml.dataframe.analyticsList.editFlyout.descriptionAriaLabel',
{
defaultMessage: 'Update the job description.',
}
)}
/>
</EuiFormRow>
<EuiFormRow
helpText={
state !== DATA_FRAME_TASK_STATE.STOPPED &&
i18n.translate('xpack.ml.dataframe.analyticsList.editFlyout.modelMemoryHelpText', {
defaultMessage: 'Model memory limit cannot be edited while the job is running.',
})
}
label={i18n.translate(
'xpack.ml.dataframe.analyticsList.editFlyout.modelMemoryLimitLabel',
{
defaultMessage: 'Model memory limit',
}
)}
isInvalid={mmlValidationError !== undefined}
error={mmlValidationError}
>
<EuiFieldText
data-test-subj="mlAnalyticsEditFlyoutmodelMemoryLimitInput"
isInvalid={mmlValidationError !== undefined}
readOnly={state !== DATA_FRAME_TASK_STATE.STOPPED}
value={modelMemoryLimit}
onChange={(e) => setModelMemoryLimit(e.target.value)}
aria-label={i18n.translate(
'xpack.ml.dataframe.analyticsList.editFlyout.modelMemoryLimitAriaLabel',
{
defaultMessage: 'Update the model memory limit.',
}
)}
/>
</EuiFormRow>
</EuiForm>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="cross" onClick={closeFlyout} flush="left">
{i18n.translate('xpack.ml.dataframe.analyticsList.editFlyoutCancelButtonText', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="analyticsEditFlyoutUpdateButton"
onClick={onSubmit}
fill
isDisabled={updateButtonDisabled}
>
{i18n.translate('xpack.ml.dataframe.analyticsList.editFlyoutUpdateButtonText', {
defaultMessage: 'Update',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
</EuiOverlayMask>
);
};

View file

@ -8,7 +8,10 @@ import { http } from '../http_service';
import { basePath } from './index';
import { DataFrameAnalyticsStats } from '../../data_frame_analytics/pages/analytics_management/components/analytics_list/common';
import { DataFrameAnalyticsConfig } from '../../data_frame_analytics/common';
import {
DataFrameAnalyticsConfig,
UpdateDataFrameAnalyticsConfig,
} from '../../data_frame_analytics/common';
import { DeepPartial } from '../../../../common/types/common';
import { DeleteDataFrameAnalyticsWithIndexStatus } from '../../../../common/types/data_frame_analytics';
@ -72,6 +75,14 @@ export const dataFrameAnalytics = {
body,
});
},
updateDataFrameAnalytics(analyticsId: string, updateConfig: UpdateDataFrameAnalyticsConfig) {
const body = JSON.stringify(updateConfig);
return http<any>({
path: `${basePath()}/data_frame/analytics/${analyticsId}/_update`,
method: 'POST',
body,
});
},
evaluateDataFrameAnalytics(evaluateConfig: any) {
const body = JSON.stringify(evaluateConfig);
return http<any>({

View file

@ -223,6 +223,21 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any)
method: 'POST',
});
ml.updateDataFrameAnalytics = ca({
urls: [
{
fmt: '/_ml/data_frame/analytics/<%=analyticsId%>/_update',
req: {
analyticsId: {
type: 'string',
},
},
},
],
needBody: true,
method: 'POST',
});
ml.deleteJob = ca({
urls: [
{

View file

@ -10,6 +10,7 @@ import { analyticsAuditMessagesProvider } from '../models/data_frame_analytics/a
import { RouteInitialization } from '../types';
import {
dataAnalyticsJobConfigSchema,
dataAnalyticsJobUpdateSchema,
dataAnalyticsEvaluateSchema,
dataAnalyticsExplainSchema,
analyticsIdSchema,
@ -483,6 +484,45 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat
})
);
/**
* @apiGroup DataFrameAnalytics
*
* @api {post} /api/ml/data_frame/analytics/:analyticsId/_update Update specified analytics job
* @apiName UpdateDataFrameAnalyticsJob
* @apiDescription Updates a data frame analytics job.
*
* @apiSchema (params) analyticsIdSchema
*/
router.post(
{
path: '/api/ml/data_frame/analytics/{analyticsId}/_update',
validate: {
params: analyticsIdSchema,
body: dataAnalyticsJobUpdateSchema,
},
options: {
tags: ['access:ml:canCreateDataFrameAnalytics'],
},
},
mlLicense.fullLicenseAPIGuard(async (context, request, response) => {
try {
const { analyticsId } = request.params;
const results = await context.ml!.mlClient.callAsCurrentUser(
'ml.updateDataFrameAnalytics',
{
body: request.body,
analyticsId,
}
);
return response.ok({
body: results,
});
} catch (e) {
return response.customError(wrapError(e));
}
})
);
/**
* @apiGroup DataFrameAnalytics
*

View file

@ -69,6 +69,12 @@ export const deleteDataFrameAnalyticsJobSchema = schema.object({
deleteDestIndexPattern: schema.maybe(schema.boolean()),
});
export const dataAnalyticsJobUpdateSchema = schema.object({
description: schema.maybe(schema.string()),
model_memory_limit: schema.maybe(schema.string()),
allow_lazy_start: schema.maybe(schema.boolean()),
});
export const stopsDataFrameAnalyticsJobQuerySchema = schema.object({
force: schema.maybe(schema.boolean()),
});