mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[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:
parent
321fb871cc
commit
a4340f0ece
10 changed files with 424 additions and 2 deletions
|
@ -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 === '') {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -13,6 +13,7 @@ export {
|
|||
useRefreshAnalyticsList,
|
||||
DataFrameAnalyticsId,
|
||||
DataFrameAnalyticsConfig,
|
||||
UpdateDataFrameAnalyticsConfig,
|
||||
IndexName,
|
||||
IndexPattern,
|
||||
REFRESH_ANALYTICS_LIST_STATE,
|
||||
|
|
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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} />;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>({
|
||||
|
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -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()),
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue