From 78d7bfdf9765dd8256657c8c6c9da44d8963fea9 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Thu, 19 Nov 2020 15:26:01 +0000 Subject: [PATCH] [ML] Space management UI (#83320) * [ML] Space management UI * fixing types * small react refactor * adding repair toasts * text and style changes * handling spaces being disabled * correcting initalizing endpoint response * text updates * text updates * fixing spaces manager use when spaces is disabled * more text updates * switching to delete saved object first rather than overwrite * filtering non ml spaces * renaming file * fixing types * updating list style Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/ml/common/types/saved_objects.ts | 22 +- x-pack/plugins/ml/kibana.json | 3 +- .../components/job_spaces_list/index.ts | 2 +- .../job_spaces_list/job_spaces_list.tsx | 68 +++++- .../components/job_spaces_repair/index.ts | 7 + .../job_spaces_repair_flyout.tsx | 161 +++++++++++++ .../job_spaces_repair/repair_list.tsx | 182 ++++++++++++++ .../cannot_edit_callout.tsx | 29 +++ .../components/job_spaces_selector/index.ts | 7 + .../jobs_spaces_flyout.tsx | 131 +++++++++++ .../job_spaces_selector/spaces_selector.scss | 3 + .../job_spaces_selector/spaces_selectors.tsx | 222 ++++++++++++++++++ .../application/contexts/kibana/index.ts | 1 + .../contexts/kibana/use_ml_api_context.ts | 11 + .../application/contexts/spaces/index.ts | 12 + .../contexts/spaces/spaces_context.ts | 35 +++ .../analytics_list/analytics_list.tsx | 8 +- .../components/analytics_list/common.ts | 2 +- .../components/analytics_list/use_columns.tsx | 32 ++- .../analytics_service/get_analytics.ts | 2 +- .../components/jobs_list/jobs_list.js | 25 +- .../jobs_list_view/jobs_list_view.js | 12 +- .../jobs_list_page/jobs_list_page.tsx | 141 ++++++----- .../application/management/jobs_list/index.ts | 18 +- .../services/ml_api_service/saved_objects.ts | 21 +- x-pack/plugins/ml/public/plugin.ts | 2 + .../plugins/ml/server/saved_objects/repair.ts | 33 ++- .../ml/server/saved_objects/service.ts | 19 +- .../plugins/ml/server/saved_objects/util.ts | 4 + x-pack/plugins/spaces/public/index.ts | 2 + 30 files changed, 1096 insertions(+), 121 deletions(-) create mode 100644 x-pack/plugins/ml/public/application/components/job_spaces_repair/index.ts create mode 100644 x-pack/plugins/ml/public/application/components/job_spaces_repair/job_spaces_repair_flyout.tsx create mode 100644 x-pack/plugins/ml/public/application/components/job_spaces_repair/repair_list.tsx create mode 100644 x-pack/plugins/ml/public/application/components/job_spaces_selector/cannot_edit_callout.tsx create mode 100644 x-pack/plugins/ml/public/application/components/job_spaces_selector/index.ts create mode 100644 x-pack/plugins/ml/public/application/components/job_spaces_selector/jobs_spaces_flyout.tsx create mode 100644 x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selector.scss create mode 100644 x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selectors.tsx create mode 100644 x-pack/plugins/ml/public/application/contexts/kibana/use_ml_api_context.ts create mode 100644 x-pack/plugins/ml/public/application/contexts/spaces/index.ts create mode 100644 x-pack/plugins/ml/public/application/contexts/spaces/spaces_context.ts diff --git a/x-pack/plugins/ml/common/types/saved_objects.ts b/x-pack/plugins/ml/common/types/saved_objects.ts index dde235476f1f9..9f4d402ec1759 100644 --- a/x-pack/plugins/ml/common/types/saved_objects.ts +++ b/x-pack/plugins/ml/common/types/saved_objects.ts @@ -7,11 +7,23 @@ export type JobType = 'anomaly-detector' | 'data-frame-analytics'; export const ML_SAVED_OBJECT_TYPE = 'ml-job'; -type Result = Record; +export interface SavedObjectResult { + [jobId: string]: { success: boolean; error?: any }; +} export interface RepairSavedObjectResponse { - savedObjectsCreated: Result; - savedObjectsDeleted: Result; - datafeedsAdded: Result; - datafeedsRemoved: Result; + savedObjectsCreated: SavedObjectResult; + savedObjectsDeleted: SavedObjectResult; + datafeedsAdded: SavedObjectResult; + datafeedsRemoved: SavedObjectResult; +} + +export type JobsSpacesResponse = { + [jobType in JobType]: { [jobId: string]: string[] }; +}; + +export interface InitializeSavedObjectResponse { + jobs: Array<{ id: string; type: string }>; + success: boolean; + error?: any; } diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index 1cd52079b4e39..8ec9b8ee976d4 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -34,7 +34,8 @@ "kibanaReact", "dashboard", "savedObjects", - "home" + "home", + "spaces" ], "extraPublicDirs": [ "common" diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts b/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts index d154d82a8ee7f..f8b851e4fee35 100644 --- a/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts +++ b/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { JobSpacesList } from './job_spaces_list'; +export { JobSpacesList, ALL_SPACES_ID } from './job_spaces_list'; diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx index b362c87a12210..fa8d65d3e79fd 100644 --- a/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx +++ b/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx @@ -4,20 +4,64 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC } from 'react'; +import React, { FC, useState, useEffect } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiBadge } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; +import { JobSpacesFlyout } from '../job_spaces_selector'; +import { JobType } from '../../../../common/types/saved_objects'; +import { useSpacesContext } from '../../contexts/spaces'; +import { Space, SpaceAvatar } from '../../../../../spaces/public'; + +export const ALL_SPACES_ID = '*'; interface Props { - spaces: string[]; + spaceIds: string[]; + jobId: string; + jobType: JobType; + refresh(): void; +} + +function filterUnknownSpaces(ids: string[]) { + return ids.filter((id) => id !== '?'); } -export const JobSpacesList: FC = ({ spaces }) => ( - - {spaces.map((space) => ( - - {space} - - ))} - -); +export const JobSpacesList: FC = ({ spaceIds, jobId, jobType, refresh }) => { + const { allSpaces } = useSpacesContext(); + + const [showFlyout, setShowFlyout] = useState(false); + const [spaces, setSpaces] = useState([]); + + useEffect(() => { + const tempSpaces = spaceIds.includes(ALL_SPACES_ID) + ? [{ id: ALL_SPACES_ID, name: ALL_SPACES_ID, disabledFeatures: [], color: '#DDD' }] + : allSpaces.filter((s) => spaceIds.includes(s.id)); + setSpaces(tempSpaces); + }, [spaceIds, allSpaces]); + + function onClose() { + setShowFlyout(false); + refresh(); + } + + return ( + <> + setShowFlyout(true)} style={{ height: 'auto' }}> + + {spaces.map((space) => ( + + + + ))} + + + {showFlyout && ( + + )} + + ); +}; diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_repair/index.ts b/x-pack/plugins/ml/public/application/components/job_spaces_repair/index.ts new file mode 100644 index 0000000000000..3a9c22c1f3688 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/job_spaces_repair/index.ts @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { JobSpacesRepairFlyout } from './job_spaces_repair_flyout'; diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_repair/job_spaces_repair_flyout.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_repair/job_spaces_repair_flyout.tsx new file mode 100644 index 0000000000000..47d3fe065dd66 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/job_spaces_repair/job_spaces_repair_flyout.tsx @@ -0,0 +1,161 @@ +/* + * 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, useState, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiButtonEmpty, + EuiTitle, + EuiFlyoutBody, + EuiText, + EuiCallOut, + EuiSpacer, +} from '@elastic/eui'; + +import { ml } from '../../services/ml_api_service'; +import { + RepairSavedObjectResponse, + SavedObjectResult, +} from '../../../../common/types/saved_objects'; +import { RepairList } from './repair_list'; +import { useToastNotificationService } from '../../services/toast_notification_service'; + +interface Props { + onClose: () => void; +} +export const JobSpacesRepairFlyout: FC = ({ onClose }) => { + const { displayErrorToast, displaySuccessToast } = useToastNotificationService(); + const [loading, setLoading] = useState(false); + const [repairable, setRepairable] = useState(false); + const [repairResp, setRepairResp] = useState(null); + + async function loadRepairList(simulate: boolean = true) { + setLoading(true); + try { + const resp = await ml.savedObjects.repairSavedObjects(simulate); + setRepairResp(resp); + + const count = Object.values(resp).reduce((acc, cur) => acc + Object.keys(cur).length, 0); + setRepairable(count > 0); + setLoading(false); + return resp; + } catch (error) { + // this shouldn't be hit as errors are returned per-repair task + // as part of the response + displayErrorToast(error); + setLoading(false); + } + return null; + } + + useEffect(() => { + loadRepairList(); + }, []); + + async function repair() { + if (repairable) { + // perform the repair + const resp = await loadRepairList(false); + // check simulate the repair again to check that all + // items have been repaired. + await loadRepairList(true); + + if (resp === null) { + return; + } + const { successCount, errorCount } = getResponseCounts(resp); + if (errorCount > 0) { + const title = i18n.translate('xpack.ml.management.repairSavedObjectsFlyout.repair.error', { + defaultMessage: 'Some jobs cannot be repaired.', + }); + displayErrorToast(resp as any, title); + return; + } + + displaySuccessToast( + i18n.translate('xpack.ml.management.repairSavedObjectsFlyout.repair.success', { + defaultMessage: '{successCount} {successCount, plural, one {job} other {jobs}} repaired', + values: { successCount }, + }) + ); + } + } + + return ( + <> + + + +

+ +

+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + ); +}; + +function getResponseCounts(resp: RepairSavedObjectResponse) { + let successCount = 0; + let errorCount = 0; + Object.values(resp).forEach((result: SavedObjectResult) => { + Object.values(result).forEach(({ success, error }) => { + if (success === true) { + successCount++; + } else if (error !== undefined) { + errorCount++; + } + }); + }); + return { successCount, errorCount }; +} diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_repair/repair_list.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_repair/repair_list.tsx new file mode 100644 index 0000000000000..3eab255ba34e6 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/job_spaces_repair/repair_list.tsx @@ -0,0 +1,182 @@ +/* + * 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 } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiText, EuiTitle, EuiAccordion, EuiTextColor, EuiHorizontalRule } from '@elastic/eui'; + +import { RepairSavedObjectResponse } from '../../../../common/types/saved_objects'; + +export const RepairList: FC<{ repairItems: RepairSavedObjectResponse | null }> = ({ + repairItems, +}) => { + if (repairItems === null) { + return null; + } + + return ( + <> + + + + + + + + + + + + + + + + + ); +}; + +const SavedObjectsCreated: FC<{ repairItems: RepairSavedObjectResponse }> = ({ repairItems }) => { + const items = Object.keys(repairItems.savedObjectsCreated); + + const title = ( + <> + +

+ + + +

+
+ +

+ + + +

+
+ + ); + return ; +}; + +const SavedObjectsDeleted: FC<{ repairItems: RepairSavedObjectResponse }> = ({ repairItems }) => { + const items = Object.keys(repairItems.savedObjectsDeleted); + + const title = ( + <> + +

+ + + +

+
+ +

+ + + +

+
+ + ); + return ; +}; + +const DatafeedsAdded: FC<{ repairItems: RepairSavedObjectResponse }> = ({ repairItems }) => { + const items = Object.keys(repairItems.datafeedsAdded); + + const title = ( + <> + +

+ + + +

+
+ +

+ + + +

+
+ + ); + return ; +}; + +const DatafeedsRemoved: FC<{ repairItems: RepairSavedObjectResponse }> = ({ repairItems }) => { + const items = Object.keys(repairItems.datafeedsRemoved); + + const title = ( + <> + +

+ + + +

+
+ +

+ + + +

+
+ + ); + return ; +}; + +const RepairItem: FC<{ id: string; title: JSX.Element; items: string[] }> = ({ + id, + title, + items, +}) => ( + + + {items.length && ( +
    + {items.map((item) => ( +
  • {item}
  • + ))} +
+ )} +
+
+); diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/cannot_edit_callout.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_selector/cannot_edit_callout.tsx new file mode 100644 index 0000000000000..98473cf6a7f59 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/job_spaces_selector/cannot_edit_callout.tsx @@ -0,0 +1,29 @@ +/* + * 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 } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiSpacer, EuiCallOut } from '@elastic/eui'; + +export const CannotEditCallout: FC<{ jobId: string }> = ({ jobId }) => ( + <> + + + + + +); diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/index.ts b/x-pack/plugins/ml/public/application/components/job_spaces_selector/index.ts new file mode 100644 index 0000000000000..fe1537f58531f --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/job_spaces_selector/index.ts @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { JobSpacesFlyout } from './jobs_spaces_flyout'; diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/jobs_spaces_flyout.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_selector/jobs_spaces_flyout.tsx new file mode 100644 index 0000000000000..9aa8942bce795 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/job_spaces_selector/jobs_spaces_flyout.tsx @@ -0,0 +1,131 @@ +/* + * 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, useState, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { difference, xor } from 'lodash'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiButtonEmpty, + EuiTitle, + EuiFlyoutBody, +} from '@elastic/eui'; + +import { JobType, SavedObjectResult } from '../../../../common/types/saved_objects'; +import { ml } from '../../services/ml_api_service'; +import { useToastNotificationService } from '../../services/toast_notification_service'; + +import { SpacesSelector } from './spaces_selectors'; + +interface Props { + jobId: string; + jobType: JobType; + spaceIds: string[]; + onClose: () => void; +} +export const JobSpacesFlyout: FC = ({ jobId, jobType, spaceIds, onClose }) => { + const { displayErrorToast } = useToastNotificationService(); + + const [selectedSpaceIds, setSelectedSpaceIds] = useState(spaceIds); + const [saving, setSaving] = useState(false); + const [savable, setSavable] = useState(false); + const [canEditSpaces, setCanEditSpaces] = useState(false); + + useEffect(() => { + const different = xor(selectedSpaceIds, spaceIds).length !== 0; + setSavable(different === true && selectedSpaceIds.length > 0); + }, [selectedSpaceIds.length]); + + async function applySpaces() { + if (savable) { + setSaving(true); + const addedSpaces = difference(selectedSpaceIds, spaceIds); + const removedSpaces = difference(spaceIds, selectedSpaceIds); + if (addedSpaces.length) { + const resp = await ml.savedObjects.assignJobToSpace(jobType, [jobId], addedSpaces); + handleApplySpaces(resp); + } + if (removedSpaces.length) { + const resp = await ml.savedObjects.removeJobFromSpace(jobType, [jobId], removedSpaces); + handleApplySpaces(resp); + } + onClose(); + } + } + + function handleApplySpaces(resp: SavedObjectResult) { + Object.entries(resp).forEach(([id, { success, error }]) => { + if (success === false) { + const title = i18n.translate( + 'xpack.ml.management.spacesSelectorFlyout.updateSpaces.error', + { + defaultMessage: 'Error updating {id}', + values: { id }, + } + ); + displayErrorToast(error, title); + } + }); + } + + return ( + <> + + + +

+ +

+
+
+ + + + + + + + + + + + + + + + + +
+ + ); +}; diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selector.scss b/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selector.scss new file mode 100644 index 0000000000000..75cdbd972455b --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selector.scss @@ -0,0 +1,3 @@ +.mlCopyToSpace__spacesList { + margin-top: $euiSizeXS; +} diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selectors.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selectors.tsx new file mode 100644 index 0000000000000..233b64dc1432e --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selectors.tsx @@ -0,0 +1,222 @@ +/* + * 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 './spaces_selector.scss'; +import React, { FC, useState, useEffect, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiFormRow, + EuiSelectable, + EuiSelectableOption, + EuiIconTip, + EuiText, + EuiCheckableCard, + EuiFormFieldset, +} from '@elastic/eui'; + +import { SpaceAvatar } from '../../../../../spaces/public'; +import { useSpacesContext } from '../../contexts/spaces'; +import { ML_SAVED_OBJECT_TYPE } from '../../../../common/types/saved_objects'; +import { ALL_SPACES_ID } from '../job_spaces_list'; +import { CannotEditCallout } from './cannot_edit_callout'; + +type SpaceOption = EuiSelectableOption & { ['data-space-id']: string }; + +interface Props { + jobId: string; + spaceIds: string[]; + setSelectedSpaceIds: (ids: string[]) => void; + selectedSpaceIds: string[]; + canEditSpaces: boolean; + setCanEditSpaces: (canEditSpaces: boolean) => void; +} + +export const SpacesSelector: FC = ({ + jobId, + spaceIds, + setSelectedSpaceIds, + selectedSpaceIds, + canEditSpaces, + setCanEditSpaces, +}) => { + const { spacesManager, allSpaces } = useSpacesContext(); + + const [canShareToAllSpaces, setCanShareToAllSpaces] = useState(false); + + useEffect(() => { + if (spacesManager !== null) { + const getPermissions = spacesManager.getShareSavedObjectPermissions(ML_SAVED_OBJECT_TYPE); + Promise.all([getPermissions]).then(([{ shareToAllSpaces }]) => { + setCanShareToAllSpaces(shareToAllSpaces); + setCanEditSpaces(shareToAllSpaces || spaceIds.includes(ALL_SPACES_ID) === false); + }); + } + }, []); + + function toggleShareOption(isAllSpaces: boolean) { + const updatedSpaceIds = isAllSpaces + ? [ALL_SPACES_ID, ...selectedSpaceIds] + : selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID); + setSelectedSpaceIds(updatedSpaceIds); + } + + function updateSelectedSpaces(selectedOptions: SpaceOption[]) { + const ids = selectedOptions.filter((opt) => opt.checked).map((opt) => opt['data-space-id']); + setSelectedSpaceIds(ids); + } + + const isGlobalControlChecked = useMemo(() => selectedSpaceIds.includes(ALL_SPACES_ID), [ + selectedSpaceIds, + ]); + + const options = useMemo( + () => + allSpaces.map((space) => { + return { + label: space.name, + prepend: , + checked: selectedSpaceIds.includes(space.id) ? 'on' : undefined, + disabled: canEditSpaces === false, + ['data-space-id']: space.id, + ['data-test-subj']: `mlSpaceSelectorRow_${space.id}`, + }; + }), + [allSpaces, selectedSpaceIds, canEditSpaces] + ); + + const shareToAllSpaces = useMemo( + () => ({ + id: 'shareToAllSpaces', + title: i18n.translate('xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.title', { + defaultMessage: 'All spaces', + }), + text: i18n.translate('xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.text', { + defaultMessage: 'Make job available in all current and future spaces.', + }), + ...(!canShareToAllSpaces && { + tooltip: isGlobalControlChecked + ? i18n.translate( + 'xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotUncheckTooltip', + { defaultMessage: 'You need additional privileges to change this option.' } + ) + : i18n.translate( + 'xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotCheckTooltip', + { defaultMessage: 'You need additional privileges to use this option.' } + ), + }), + disabled: !canShareToAllSpaces, + }), + [isGlobalControlChecked, canShareToAllSpaces] + ); + + const shareToExplicitSpaces = useMemo( + () => ({ + id: 'shareToExplicitSpaces', + title: i18n.translate( + 'xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.title', + { + defaultMessage: 'Select spaces', + } + ), + text: i18n.translate('xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.text', { + defaultMessage: 'Make job available in selected spaces only.', + }), + disabled: !canShareToAllSpaces && isGlobalControlChecked, + }), + [canShareToAllSpaces, isGlobalControlChecked] + ); + + return ( + <> + {canEditSpaces === false && } + + toggleShareOption(false)} + disabled={shareToExplicitSpaces.disabled} + > + + } + fullWidth + > + updateSelectedSpaces(newOptions as SpaceOption[])} + listProps={{ + bordered: true, + rowHeight: 40, + className: 'mlCopyToSpace__spacesList', + 'data-test-subj': 'mlFormSpaceSelector', + }} + searchable + > + {(list, search) => { + return ( + <> + {search} + {list} + + ); + }} + + + + + + + toggleShareOption(true)} + disabled={shareToAllSpaces.disabled} + /> + + + ); +}; + +function createLabel({ + title, + text, + disabled, + tooltip, +}: { + title: string; + text: string; + disabled: boolean; + tooltip?: string; +}) { + return ( + <> + + + {title} + + {tooltip && ( + + + + )} + + + + {text} + + + ); +} diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/index.ts b/x-pack/plugins/ml/public/application/contexts/kibana/index.ts index f08ca3c153961..0f96c8f8282ef 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/index.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/index.ts @@ -10,3 +10,4 @@ export { useUiSettings } from './use_ui_settings_context'; export { useTimefilter } from './use_timefilter'; export { useNotifications } from './use_notifications_context'; export { useMlUrlGenerator, useMlLink } from './use_create_url'; +export { useMlApiContext } from './use_ml_api_context'; diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/use_ml_api_context.ts b/x-pack/plugins/ml/public/application/contexts/kibana/use_ml_api_context.ts new file mode 100644 index 0000000000000..4f0d4f9cacf19 --- /dev/null +++ b/x-pack/plugins/ml/public/application/contexts/kibana/use_ml_api_context.ts @@ -0,0 +1,11 @@ +/* + * 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 { useMlKibana } from './kibana_context'; + +export const useMlApiContext = () => { + return useMlKibana().services.mlServices.mlApiServices; +}; diff --git a/x-pack/plugins/ml/public/application/contexts/spaces/index.ts b/x-pack/plugins/ml/public/application/contexts/spaces/index.ts new file mode 100644 index 0000000000000..dc68767052176 --- /dev/null +++ b/x-pack/plugins/ml/public/application/contexts/spaces/index.ts @@ -0,0 +1,12 @@ +/* + * 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. + */ + +export { + SpacesContext, + SpacesContextValue, + createSpacesContext, + useSpacesContext, +} from './spaces_context'; diff --git a/x-pack/plugins/ml/public/application/contexts/spaces/spaces_context.ts b/x-pack/plugins/ml/public/application/contexts/spaces/spaces_context.ts new file mode 100644 index 0000000000000..d83273c6a9c89 --- /dev/null +++ b/x-pack/plugins/ml/public/application/contexts/spaces/spaces_context.ts @@ -0,0 +1,35 @@ +/* + * 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 { createContext, useContext } from 'react'; +import { HttpSetup } from 'src/core/public'; +import { SpacesManager, Space } from '../../../../../spaces/public'; + +export interface SpacesContextValue { + spacesManager: SpacesManager | null; + allSpaces: Space[]; + spacesEnabled: boolean; +} + +export const SpacesContext = createContext>({}); + +export function createSpacesContext(http: HttpSetup, spacesEnabled: boolean) { + return { + spacesManager: spacesEnabled ? new SpacesManager(http) : null, + allSpaces: [], + spacesEnabled, + } as SpacesContextValue; +} + +export function useSpacesContext() { + const context = useContext(SpacesContext); + + if (context.spacesManager === undefined) { + throw new Error('required attribute is undefined'); + } + + return context as SpacesContextValue; +} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx index 63b7074ec3aaa..f4cd64aa8c497 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx @@ -82,6 +82,7 @@ function getItemIdToExpandedRowMap( interface Props { isManagementTable?: boolean; isMlEnabledInSpace?: boolean; + spacesEnabled?: boolean; blockRefresh?: boolean; pageState: ListingPageUrlState; updatePageState: (update: Partial) => void; @@ -89,6 +90,7 @@ interface Props { export const DataFrameAnalyticsList: FC = ({ isManagementTable = false, isMlEnabledInSpace = true, + spacesEnabled = false, blockRefresh = false, pageState, updatePageState, @@ -159,7 +161,7 @@ export const DataFrameAnalyticsList: FC = ({ const getAnalyticsCallback = useCallback(() => getAnalytics(true), []); // Subscribe to the refresh observable to trigger reloading the analytics list. - useRefreshAnalyticsList( + const { refresh } = useRefreshAnalyticsList( { isLoading: setIsLoading, onRefresh: getAnalyticsCallback, @@ -171,7 +173,9 @@ export const DataFrameAnalyticsList: FC = ({ expandedRowItemIds, setExpandedRowItemIds, isManagementTable, - isMlEnabledInSpace + isMlEnabledInSpace, + spacesEnabled, + refresh ); const { onTableChange, pagination, sorting } = useTableSettings( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts index 84c37ac8b816b..bf13471c0d18b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts @@ -112,7 +112,7 @@ export interface DataFrameAnalyticsListRow { mode: string; state: DataFrameAnalyticsStats['state']; stats: DataFrameAnalyticsStats; - spaces?: string[]; + spaceIds?: string[]; } // Used to pass on attribute names to table columns diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx index 93868ce0c17e6..69335b55f4c78 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx @@ -148,7 +148,9 @@ export const useColumns = ( expandedRowItemIds: DataFrameAnalyticsId[], setExpandedRowItemIds: React.Dispatch>, isManagementTable: boolean = false, - isMlEnabledInSpace: boolean = true + isMlEnabledInSpace: boolean = true, + spacesEnabled: boolean = true, + refresh: () => void = () => {} ) => { const { actions, modals } = useActions(isManagementTable); function toggleDetails(item: DataFrameAnalyticsListRow) { @@ -278,16 +280,24 @@ export const useColumns = ( ]; if (isManagementTable === true) { - // insert before last column - columns.splice(columns.length - 1, 0, { - name: i18n.translate('xpack.ml.jobsList.analyticsSpacesLabel', { - defaultMessage: 'Spaces', - }), - render: (item: DataFrameAnalyticsListRow) => - Array.isArray(item.spaces) ? : null, - width: '75px', - }); - + if (spacesEnabled === true) { + // insert before last column + columns.splice(columns.length - 1, 0, { + name: i18n.translate('xpack.ml.jobsList.analyticsSpacesLabel', { + defaultMessage: 'Spaces', + }), + render: (item: DataFrameAnalyticsListRow) => + Array.isArray(item.spaceIds) ? ( + + ) : null, + width: '90px', + }); + } // Remove actions if Ml not enabled in current space if (isMlEnabledInSpace === false) { columns.pop(); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts index beb490d025785..2d251d94e9ca7 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts @@ -155,7 +155,7 @@ export const getAnalyticsFactory = ( mode: DATA_FRAME_MODE.BATCH, state: stats.state, stats, - spaces: spaces[config.id] ?? [], + spaceIds: spaces[config.id] ?? [], }); return reducedtableRows; }, diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js index 8a05cd51e4d65..9c58dc556e535 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js @@ -95,7 +95,7 @@ export class JobsList extends Component { } render() { - const { loading, isManagementTable } = this.props; + const { loading, isManagementTable, spacesEnabled } = this.props; const selectionControls = { selectable: (job) => job.deleting !== true, selectableMessage: (selectable, rowItem) => @@ -242,13 +242,22 @@ export class JobsList extends Component { ]; if (isManagementTable === true) { - // insert before last column - columns.splice(columns.length - 1, 0, { - name: i18n.translate('xpack.ml.jobsList.spacesLabel', { - defaultMessage: 'Spaces', - }), - render: (item) => , - }); + if (spacesEnabled === true) { + // insert before last column + columns.splice(columns.length - 1, 0, { + name: i18n.translate('xpack.ml.jobsList.spacesLabel', { + defaultMessage: 'Spaces', + }), + render: (item) => ( + + ), + }); + } // Remove actions if Ml not enabled in current space if (this.props.isMlEnabledInSpace === false) { columns.pop(); diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js index 570172abb28c1..6e3b9031de653 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js @@ -57,6 +57,7 @@ export class JobsListView extends Component { deletingJobIds: [], }; + this.spacesEnabled = props.spacesEnabled ?? false; this.updateFunctions = {}; this.showEditJobFlyout = () => {}; @@ -253,7 +254,7 @@ export class JobsListView extends Component { const expandedJobsIds = Object.keys(this.state.itemIdToExpandedRowMap); try { let spaces = {}; - if (this.props.isManagementTable) { + if (this.props.spacesEnabled && this.props.isManagementTable) { const allSpaces = await ml.savedObjects.jobsSpaces(); spaces = allSpaces['anomaly-detector']; } @@ -266,8 +267,11 @@ export class JobsListView extends Component { delete job.fullJob; } job.latestTimestampSortValue = job.latestTimestampMs || 0; - job.spaces = - this.props.isManagementTable && spaces && spaces[job.id] !== undefined + job.spaceIds = + this.props.spacesEnabled && + this.props.isManagementTable && + spaces && + spaces[job.id] !== undefined ? spaces[job.id] : []; return job; @@ -379,8 +383,10 @@ export class JobsListView extends Component { loading={loading} isManagementTable={true} isMlEnabledInSpace={this.props.isMlEnabledInSpace} + spacesEnabled={this.props.spacesEnabled} jobsViewState={this.props.jobsViewState} onJobsViewStateUpdate={this.props.onJobsViewStateUpdate} + refreshJobs={() => this.refreshJobSummaryList(true)} /> diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx index 1089484449bab..8ad18e2b821b6 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx +++ b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx @@ -19,8 +19,11 @@ import { EuiTabbedContent, EuiText, EuiTitle, + EuiTabbedContentTab, } from '@elastic/eui'; +import { PLUGIN_ID } from '../../../../../../common/constants/app'; +import { createSpacesContext, SpacesContext } from '../../../../contexts/spaces'; import { ManagementAppMountParams } from '../../../../../../../../../src/plugins/management/public/'; import { checkGetManagementMlJobsResolver } from '../../../../capabilities/check_capabilities'; @@ -35,16 +38,15 @@ import { JobsListView } from '../../../../jobs/jobs_list/components/jobs_list_vi import { DataFrameAnalyticsList } from '../../../../data_frame_analytics/pages/analytics_management/components/analytics_list'; import { AccessDeniedPage } from '../access_denied_page'; import { SharePluginStart } from '../../../../../../../../../src/plugins/share/public'; +import { SpacesPluginStart } from '../../../../../../../spaces/public'; +import { JobSpacesRepairFlyout } from '../../../../components/job_spaces_repair'; import { getDefaultAnomalyDetectionJobsListState } from '../../../../jobs/jobs_list/jobs'; import { getMlGlobalServices } from '../../../../app'; import { ListingPageUrlState } from '../../../../../../common/types/common'; import { getDefaultDFAListState } from '../../../../data_frame_analytics/pages/analytics_management/page'; -interface Tab { +interface Tab extends EuiTabbedContentTab { 'data-test-subj': string; - id: string; - name: string; - content: any; } function usePageState( @@ -65,7 +67,7 @@ function usePageState( return [pageState, updateState]; } -function useTabs(isMlEnabledInSpace: boolean): Tab[] { +function useTabs(isMlEnabledInSpace: boolean, spacesEnabled: boolean): Tab[] { const [adPageState, updateAdPageState] = usePageState(getDefaultAnomalyDetectionJobsListState()); const [dfaPageState, updateDfaPageState] = usePageState(getDefaultDFAListState()); @@ -85,6 +87,7 @@ function useTabs(isMlEnabledInSpace: boolean): Tab[] { onJobsViewStateUpdate={updateAdPageState} isManagementTable={true} isMlEnabledInSpace={isMlEnabledInSpace} + spacesEnabled={spacesEnabled} /> ), @@ -101,6 +104,7 @@ function useTabs(isMlEnabledInSpace: boolean): Tab[] { @@ -116,18 +120,28 @@ export const JobsListPage: FC<{ coreStart: CoreStart; share: SharePluginStart; history: ManagementAppMountParams['history']; -}> = ({ coreStart, share, history }) => { + spaces?: SpacesPluginStart; +}> = ({ coreStart, share, history, spaces }) => { + const spacesEnabled = spaces !== undefined; const [initialized, setInitialized] = useState(false); const [accessDenied, setAccessDenied] = useState(false); + const [showRepairFlyout, setShowRepairFlyout] = useState(false); const [isMlEnabledInSpace, setIsMlEnabledInSpace] = useState(false); - const tabs = useTabs(isMlEnabledInSpace); + const tabs = useTabs(isMlEnabledInSpace, spacesEnabled); const [currentTabId, setCurrentTabId] = useState(tabs[0].id); const I18nContext = coreStart.i18n.Context; + const spacesContext = useMemo(() => createSpacesContext(coreStart.http, spacesEnabled), []); const check = async () => { try { - const checkPrivilege = await checkGetManagementMlJobsResolver(); - setIsMlEnabledInSpace(checkPrivilege.mlFeatureEnabledInSpace); + const { mlFeatureEnabledInSpace } = await checkGetManagementMlJobsResolver(); + setIsMlEnabledInSpace(mlFeatureEnabledInSpace); + spacesContext.spacesEnabled = spacesEnabled; + if (spacesEnabled && spacesContext.spacesManager !== null) { + spacesContext.allSpaces = (await spacesContext.spacesManager.getSpaces()).filter( + (space) => space.disabledFeatures.includes(PLUGIN_ID) === false + ); + } } catch (e) { setAccessDenied(true); } @@ -170,6 +184,10 @@ export const JobsListPage: FC<{ ); } + function onCloseRepairFlyout() { + setShowRepairFlyout(false); + } + if (accessDenied) { return ; } @@ -180,51 +198,66 @@ export const JobsListPage: FC<{ - - - - - -

- {i18n.translate('xpack.ml.management.jobsList.jobsListTitle', { - defaultMessage: 'Machine Learning Jobs', - })} -

-
- - - {currentTabId === 'anomaly_detection_jobs' - ? anomalyDetectionDocsLabel - : analyticsDocsLabel} - - -
-
- - - - {i18n.translate('xpack.ml.management.jobsList.jobsListTagline', { - defaultMessage: 'View machine learning analytics and anomaly detection jobs.', - })} - - - - {renderTabs()} -
-
+ + + + + + +

+ {i18n.translate('xpack.ml.management.jobsList.jobsListTitle', { + defaultMessage: 'Machine Learning Jobs', + })} +

+
+ + + {currentTabId === 'anomaly_detection_jobs' + ? anomalyDetectionDocsLabel + : analyticsDocsLabel} + + +
+
+ + + + {i18n.translate('xpack.ml.management.jobsList.jobsListTagline', { + defaultMessage: 'View machine learning analytics and anomaly detection jobs.', + })} + + + + + {spacesEnabled && ( + <> + setShowRepairFlyout(true)}> + {i18n.translate('xpack.ml.management.jobsList.repairFlyoutButton', { + defaultMessage: 'Repair saved objects', + })} + + {showRepairFlyout && } + + + )} + {renderTabs()} + +
+
+
diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/index.ts b/x-pack/plugins/ml/public/application/management/jobs_list/index.ts index 422121e1845b2..284220e4e3caf 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/index.ts +++ b/x-pack/plugins/ml/public/application/management/jobs_list/index.ts @@ -14,14 +14,19 @@ import { getJobsListBreadcrumbs } from '../breadcrumbs'; import { setDependencyCache, clearCache } from '../../util/dependency_cache'; import './_index.scss'; import { SharePluginStart } from '../../../../../../../src/plugins/share/public'; +import { SpacesPluginStart } from '../../../../../spaces/public'; const renderApp = ( element: HTMLElement, history: ManagementAppMountParams['history'], coreStart: CoreStart, - share: SharePluginStart + share: SharePluginStart, + spaces?: SpacesPluginStart ) => { - ReactDOM.render(React.createElement(JobsListPage, { coreStart, history, share }), element); + ReactDOM.render( + React.createElement(JobsListPage, { coreStart, history, share, spaces }), + element + ); return () => { unmountComponentAtNode(element); clearCache(); @@ -42,6 +47,11 @@ export async function mountApp( }); params.setBreadcrumbs(getJobsListBreadcrumbs()); - - return renderApp(params.element, params.history, coreStart, pluginsStart.share); + return renderApp( + params.element, + params.history, + coreStart, + pluginsStart.share, + pluginsStart.spaces + ); } diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts index a1323b39b3bcc..b47cf3f62871c 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts @@ -9,18 +9,23 @@ import { HttpService } from '../http_service'; import { basePath } from './index'; -import { JobType } from '../../../../common/types/saved_objects'; +import { + JobType, + RepairSavedObjectResponse, + SavedObjectResult, + JobsSpacesResponse, +} from '../../../../common/types/saved_objects'; export const savedObjectsApiProvider = (httpService: HttpService) => ({ jobsSpaces() { - return httpService.http({ + return httpService.http({ path: `${basePath()}/saved_objects/jobs_spaces`, method: 'GET', }); }, assignJobToSpace(jobType: JobType, jobIds: string[], spaces: string[]) { const body = JSON.stringify({ jobType, jobIds, spaces }); - return httpService.http({ + return httpService.http({ path: `${basePath()}/saved_objects/assign_job_to_space`, method: 'POST', body, @@ -28,10 +33,18 @@ export const savedObjectsApiProvider = (httpService: HttpService) => ({ }, removeJobFromSpace(jobType: JobType, jobIds: string[], spaces: string[]) { const body = JSON.stringify({ jobType, jobIds, spaces }); - return httpService.http({ + return httpService.http({ path: `${basePath()}/saved_objects/remove_job_from_space`, method: 'POST', body, }); }, + + repairSavedObjects(simulate: boolean = false) { + return httpService.http({ + path: `${basePath()}/saved_objects/repair`, + method: 'GET', + query: { simulate }, + }); + }, }); diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 8a25c1c49e255..1cc69ac2239ab 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -26,6 +26,7 @@ import type { DataPublicPluginStart } from 'src/plugins/data/public'; import type { HomePublicPluginSetup } from 'src/plugins/home/public'; import type { IndexPatternManagementSetup } from 'src/plugins/index_pattern_management/public'; import type { EmbeddableSetup } from 'src/plugins/embeddable/public'; +import type { SpacesPluginStart } from '../../spaces/public'; import { AppStatus, AppUpdater, DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import type { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; @@ -50,6 +51,7 @@ export interface MlStartDependencies { share: SharePluginStart; kibanaLegacy: KibanaLegacyStart; uiActions: UiActionsStart; + spaces?: SpacesPluginStart; } export interface MlSetupDependencies { security?: SecurityPluginSetup; diff --git a/x-pack/plugins/ml/server/saved_objects/repair.ts b/x-pack/plugins/ml/server/saved_objects/repair.ts index 1b0b4b2609a91..692217e5fac36 100644 --- a/x-pack/plugins/ml/server/saved_objects/repair.ts +++ b/x-pack/plugins/ml/server/saved_objects/repair.ts @@ -7,8 +7,13 @@ import Boom from '@hapi/boom'; import { IScopedClusterClient } from 'kibana/server'; import type { JobObject, JobSavedObjectService } from './service'; -import { JobType, RepairSavedObjectResponse } from '../../common/types/saved_objects'; +import { + JobType, + RepairSavedObjectResponse, + InitializeSavedObjectResponse, +} from '../../common/types/saved_objects'; import { checksFactory } from './checks'; +import { getSavedObjectClientError } from './util'; import { Datafeed } from '../../common/types/anomaly_detection_jobs'; @@ -54,7 +59,7 @@ export function repairFactory( } catch (error) { results.savedObjectsCreated[job.jobId] = { success: false, - error: error.body ?? error, + error: getSavedObjectClientError(error), }; } }); @@ -75,7 +80,7 @@ export function repairFactory( } catch (error) { results.savedObjectsCreated[job.jobId] = { success: false, - error: error.body ?? error, + error: getSavedObjectClientError(error), }; } }); @@ -97,7 +102,7 @@ export function repairFactory( } catch (error) { results.savedObjectsDeleted[job.jobId] = { success: false, - error: error.body ?? error, + error: getSavedObjectClientError(error), }; } }); @@ -118,7 +123,7 @@ export function repairFactory( } catch (error) { results.savedObjectsDeleted[job.jobId] = { success: false, - error: error.body ?? error, + error: getSavedObjectClientError(error), }; } }); @@ -143,7 +148,10 @@ export function repairFactory( } results.datafeedsAdded[job.jobId] = { success: true }; } catch (error) { - results.datafeedsAdded[job.jobId] = { success: false, error }; + results.datafeedsAdded[job.jobId] = { + success: false, + error: getSavedObjectClientError(error), + }; } }); } @@ -163,7 +171,10 @@ export function repairFactory( await jobSavedObjectService.deleteDatafeed(datafeedId); results.datafeedsRemoved[job.jobId] = { success: true }; } catch (error) { - results.datafeedsRemoved[job.jobId] = { success: false, error: error.body ?? error }; + results.datafeedsRemoved[job.jobId] = { + success: false, + error: getSavedObjectClientError(error), + }; } }); } @@ -173,8 +184,11 @@ export function repairFactory( return results; } - async function initSavedObjects(simulate: boolean = false, spaceOverrides?: JobSpaceOverrides) { - const results: { jobs: Array<{ id: string; type: string }>; success: boolean; error?: any } = { + async function initSavedObjects( + simulate: boolean = false, + spaceOverrides?: JobSpaceOverrides + ): Promise { + const results: InitializeSavedObjectResponse = { jobs: [], success: true, }; @@ -211,7 +225,6 @@ export function repairFactory( type: attributes.type, }); }); - return { jobs: jobs.map((j) => j.job.job_id) }; } catch (error) { results.success = false; results.error = Boom.boomify(error).output; diff --git a/x-pack/plugins/ml/server/saved_objects/service.ts b/x-pack/plugins/ml/server/saved_objects/service.ts index 1193dfde85f1c..ecaf0869d196c 100644 --- a/x-pack/plugins/ml/server/saved_objects/service.ts +++ b/x-pack/plugins/ml/server/saved_objects/service.ts @@ -9,6 +9,7 @@ import { KibanaRequest, SavedObjectsClientContract, SavedObjectsFindOptions } fr import type { SecurityPluginSetup } from '../../../security/server'; import { JobType, ML_SAVED_OBJECT_TYPE } from '../../common/types/saved_objects'; import { MLJobNotFound } from '../lib/ml_client'; +import { getSavedObjectClientError } from './util'; import { authorizationProvider } from './authorization'; export interface JobObject { @@ -61,14 +62,24 @@ export function jobSavedObjectServiceFactory( async function _createJob(jobType: JobType, jobId: string, datafeedId?: string) { await isMlReady(); + const job: JobObject = { job_id: jobId, datafeed_id: datafeedId ?? null, type: jobType, }; + + const id = savedObjectId(job); + + try { + await savedObjectsClient.delete(ML_SAVED_OBJECT_TYPE, id, { force: true }); + } catch (error) { + // the saved object may exist if a previous job with the same ID has been deleted. + // if not, this error will be throw which we ignore. + } + await savedObjectsClient.create(ML_SAVED_OBJECT_TYPE, job, { - id: savedObjectId(job), - overwrite: true, + id, }); } @@ -257,7 +268,7 @@ export function jobSavedObjectServiceFactory( } catch (error) { results[id] = { success: false, - error, + error: getSavedObjectClientError(error), }; } } @@ -278,7 +289,7 @@ export function jobSavedObjectServiceFactory( } catch (error) { results[job.attributes.job_id] = { success: false, - error, + error: getSavedObjectClientError(error), }; } } diff --git a/x-pack/plugins/ml/server/saved_objects/util.ts b/x-pack/plugins/ml/server/saved_objects/util.ts index 72eca6ff5977a..4349c216abffa 100644 --- a/x-pack/plugins/ml/server/saved_objects/util.ts +++ b/x-pack/plugins/ml/server/saved_objects/util.ts @@ -35,3 +35,7 @@ export function savedObjectClientsFactory( }, }; } + +export function getSavedObjectClientError(error: any) { + return error.isBoom && error.output?.payload ? error.output.payload : error.body ?? error; +} diff --git a/x-pack/plugins/spaces/public/index.ts b/x-pack/plugins/spaces/public/index.ts index ecbf1d8b36b7d..5fc56dfb7a295 100644 --- a/x-pack/plugins/spaces/public/index.ts +++ b/x-pack/plugins/spaces/public/index.ts @@ -14,6 +14,8 @@ export { SpaceAvatar, getSpaceColor, getSpaceImageUrl, getSpaceInitials } from ' export { SpacesPluginSetup, SpacesPluginStart } from './plugin'; +export { SpacesManager } from './spaces_manager'; + export const plugin = () => { return new SpacesPlugin(); };