From 9b212241e9cc4c315d09062bfaa1d5dbbf6bc2f8 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 7 Dec 2021 09:44:56 -0500 Subject: [PATCH] [APM] Offer users upgrade to multi-metric job (#119980) (#120605) Co-authored-by: Dario Gieselaar --- .../common/anomaly_detection/apm_ml_job.ts | 17 ++ .../get_anomaly_detection_setup_state.ts | 78 +++++++ x-pack/plugins/apm/common/environment_rt.ts | 12 +- .../app/Settings/anomaly_detection/index.tsx | 43 ++-- .../Settings/anomaly_detection/jobs_list.tsx | 179 ++++++++++++++-- .../anomaly_detection/jobs_list_status.tsx | 102 +++++++++ .../anomaly_detection/legacy_jobs_callout.tsx | 51 ----- .../app/service_inventory/index.tsx | 25 +-- .../service_inventory.stories.tsx | 2 + .../service_list/MLCallout.tsx | 60 ------ .../MachineLearningLinks/MLManageJobsLink.tsx | 40 +--- .../anomaly_detection_setup_link.test.tsx | 110 +++++++++- .../anomaly_detection_setup_link.tsx | 129 ++++++------ .../components/shared/managed_table/index.tsx | 3 + .../components/shared/ml_callout/index.tsx | 196 ++++++++++++++++++ .../anomaly_detection_jobs_context.tsx | 46 +++- .../public/hooks/use_ml_manage_jobs_href.ts | 48 +++++ .../anomaly_detection/apm_ml_jobs_query.ts | 10 +- .../create_anomaly_detection_jobs.ts | 20 +- .../get_anomaly_detection_jobs.ts | 28 +-- .../get_anomaly_timeseries.ts | 10 +- .../get_ml_jobs_with_apm_group.ts | 54 ++++- .../lib/anomaly_detection/has_legacy_jobs.ts | 38 ---- ...action_duration_anomaly_alert_type.test.ts | 23 +- ...transaction_duration_anomaly_alert_type.ts | 6 +- .../service_map/get_service_anomalies.ts | 12 +- .../settings/anomaly_detection/route.ts | 53 +++-- .../anomaly_detection/update_to_v3.ts | 60 ++++++ x-pack/plugins/apm/server/routes/typings.ts | 1 + x-pack/plugins/ml/common/index.ts | 1 + .../translations/translations/ja-JP.json | 6 - .../translations/translations/zh-CN.json | 6 - .../anomaly_detection/update_to_v3.spec.ts | 119 +++++++++++ 33 files changed, 1157 insertions(+), 431 deletions(-) create mode 100644 x-pack/plugins/apm/common/anomaly_detection/apm_ml_job.ts create mode 100644 x-pack/plugins/apm/common/anomaly_detection/get_anomaly_detection_setup_state.ts create mode 100644 x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list_status.tsx delete mode 100644 x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/legacy_jobs_callout.tsx delete mode 100644 x-pack/plugins/apm/public/components/app/service_inventory/service_list/MLCallout.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/ml_callout/index.tsx create mode 100644 x-pack/plugins/apm/public/hooks/use_ml_manage_jobs_href.ts delete mode 100644 x-pack/plugins/apm/server/lib/anomaly_detection/has_legacy_jobs.ts create mode 100644 x-pack/plugins/apm/server/routes/settings/anomaly_detection/update_to_v3.ts create mode 100644 x-pack/test/apm_api_integration/tests/settings/anomaly_detection/update_to_v3.spec.ts diff --git a/x-pack/plugins/apm/common/anomaly_detection/apm_ml_job.ts b/x-pack/plugins/apm/common/anomaly_detection/apm_ml_job.ts new file mode 100644 index 0000000000000..ab630decb70c8 --- /dev/null +++ b/x-pack/plugins/apm/common/anomaly_detection/apm_ml_job.ts @@ -0,0 +1,17 @@ +/* + * 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 { DATAFEED_STATE, JOB_STATE } from '../../../ml/common'; +import { Environment } from '../environment_rt'; + +export interface ApmMlJob { + environment: Environment; + version: number; + jobId: string; + jobState?: JOB_STATE; + datafeedId?: string; + datafeedState?: DATAFEED_STATE; +} diff --git a/x-pack/plugins/apm/common/anomaly_detection/get_anomaly_detection_setup_state.ts b/x-pack/plugins/apm/common/anomaly_detection/get_anomaly_detection_setup_state.ts new file mode 100644 index 0000000000000..9ca8ddbe437fe --- /dev/null +++ b/x-pack/plugins/apm/common/anomaly_detection/get_anomaly_detection_setup_state.ts @@ -0,0 +1,78 @@ +/* + * 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. + */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { FETCH_STATUS } from '../../public/hooks/use_fetcher'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import type { APIReturnType } from '../../public/services/rest/createCallApmApi'; +import { ENVIRONMENT_ALL } from '../environment_filter_values'; + +export enum AnomalyDetectionSetupState { + Loading = 'pending', + Failure = 'failure', + Unknown = 'unknown', + NoJobs = 'noJobs', + NoJobsForEnvironment = 'noJobsForEnvironment', + LegacyJobs = 'legacyJobs', + UpgradeableJobs = 'upgradeableJobs', + UpToDate = 'upToDate', +} + +export function getAnomalyDetectionSetupState({ + environment, + jobs, + fetchStatus, + isAuthorized, +}: { + environment: string; + jobs: APIReturnType<'GET /internal/apm/settings/anomaly-detection/jobs'>['jobs']; + fetchStatus: FETCH_STATUS; + isAuthorized: boolean; +}): AnomalyDetectionSetupState { + if (!isAuthorized) { + return AnomalyDetectionSetupState.Unknown; + } + + if (fetchStatus === FETCH_STATUS.LOADING) { + return AnomalyDetectionSetupState.Loading; + } + + if (fetchStatus === FETCH_STATUS.FAILURE) { + return AnomalyDetectionSetupState.Failure; + } + + if (fetchStatus !== FETCH_STATUS.SUCCESS) { + return AnomalyDetectionSetupState.Unknown; + } + + const jobsForEnvironment = + environment === ENVIRONMENT_ALL.value + ? jobs + : jobs.filter((job) => job.environment === environment); + + const hasV1Jobs = jobs.some((job) => job.version === 1); + const hasV2Jobs = jobsForEnvironment.some((job) => job.version === 2); + const hasV3Jobs = jobsForEnvironment.some((job) => job.version === 3); + const hasAnyJobs = jobs.length > 0; + + if (hasV3Jobs) { + return AnomalyDetectionSetupState.UpToDate; + } + + if (hasV2Jobs) { + return AnomalyDetectionSetupState.UpgradeableJobs; + } + + if (hasV1Jobs) { + return AnomalyDetectionSetupState.LegacyJobs; + } + + if (hasAnyJobs) { + return AnomalyDetectionSetupState.NoJobsForEnvironment; + } + + return AnomalyDetectionSetupState.NoJobs; +} diff --git a/x-pack/plugins/apm/common/environment_rt.ts b/x-pack/plugins/apm/common/environment_rt.ts index 4598ffa6f6681..67d1a6ce6fa64 100644 --- a/x-pack/plugins/apm/common/environment_rt.ts +++ b/x-pack/plugins/apm/common/environment_rt.ts @@ -11,12 +11,14 @@ import { ENVIRONMENT_NOT_DEFINED, } from './environment_filter_values'; +export const environmentStringRt = t.union([ + t.literal(ENVIRONMENT_NOT_DEFINED.value), + t.literal(ENVIRONMENT_ALL.value), + nonEmptyStringRt, +]); + export const environmentRt = t.type({ - environment: t.union([ - t.literal(ENVIRONMENT_NOT_DEFINED.value), - t.literal(ENVIRONMENT_ALL.value), - nonEmptyStringRt, - ]), + environment: environmentStringRt, }); export type Environment = t.TypeOf['environment']; diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx index 8e1064a71647f..7fd40cc4a1663 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx @@ -11,19 +11,14 @@ import { ML_ERRORS } from '../../../../../common/anomaly_detection'; import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; import { JobsList } from './jobs_list'; import { AddEnvironments } from './add_environments'; -import { useFetcher } from '../../../../hooks/use_fetcher'; import { LicensePrompt } from '../../../shared/license_prompt'; import { useLicenseContext } from '../../../../context/license/use_license_context'; import { APIReturnType } from '../../../../services/rest/createCallApmApi'; +import { useAnomalyDetectionJobsContext } from '../../../../context/anomaly_detection_jobs/use_anomaly_detection_jobs_context'; export type AnomalyDetectionApiResponse = APIReturnType<'GET /internal/apm/settings/anomaly-detection/jobs'>; -const DEFAULT_VALUE: AnomalyDetectionApiResponse = { - jobs: [], - hasLegacyJobs: false, -}; - export function AnomalyDetection() { const plugin = useApmPluginContext(); const canGetJobs = !!plugin.core.application.capabilities.ml?.canGetJobs; @@ -33,20 +28,14 @@ export function AnomalyDetection() { const [viewAddEnvironments, setViewAddEnvironments] = useState(false); const { - refetch, - data = DEFAULT_VALUE, - status, - } = useFetcher( - (callApmApi) => { - if (canGetJobs) { - return callApmApi({ - endpoint: `GET /internal/apm/settings/anomaly-detection/jobs`, - }); - } - }, - [canGetJobs], - { preservePreviousData: false, showToastOnError: false } - ); + anomalyDetectionJobsStatus, + anomalyDetectionJobsRefetch, + anomalyDetectionJobsData = { + jobs: [], + hasLegacyJobs: false, + } as AnomalyDetectionApiResponse, + anomalyDetectionSetupState, + } = useAnomalyDetectionJobsContext(); if (!hasValidLicense) { return ( @@ -71,9 +60,11 @@ export function AnomalyDetection() { <> {viewAddEnvironments ? ( environment)} + currentEnvironments={anomalyDetectionJobsData.jobs.map( + ({ environment }) => environment + )} onCreateJobSuccess={() => { - refetch(); + anomalyDetectionJobsRefetch(); setViewAddEnvironments(false); }} onCancel={() => { @@ -82,11 +73,15 @@ export function AnomalyDetection() { /> ) : ( { setViewAddEnvironments(true); }} + onUpdateComplete={() => { + anomalyDetectionJobsRefetch(); + }} /> )} diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx index 2e199d1d726fb..1faab4092361d 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx @@ -5,27 +5,35 @@ * 2.0. */ +import { EuiSwitch } from '@elastic/eui'; import { EuiButton, EuiFlexGroup, EuiFlexItem, + EuiIcon, EuiSpacer, EuiText, EuiTitle, + EuiToolTip, RIGHT_ALIGNMENT, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import React from 'react'; +import React, { useState } from 'react'; +import { MLJobsAwaitingNodeWarning } from '../../../../../../ml/public'; +import { AnomalyDetectionSetupState } from '../../../../../common/anomaly_detection/get_anomaly_detection_setup_state'; import { getEnvironmentLabel } from '../../../../../common/environment_filter_values'; +import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; +import { useMlManageJobsHref } from '../../../../hooks/use_ml_manage_jobs_href'; +import { callApmApi } from '../../../../services/rest/createCallApmApi'; import { MLExplorerLink } from '../../../shared/Links/MachineLearningLinks/MLExplorerLink'; import { MLManageJobsLink } from '../../../shared/Links/MachineLearningLinks/MLManageJobsLink'; import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; import { ITableColumn, ManagedTable } from '../../../shared/managed_table'; +import { MLCallout, shouldDisplayMlCallout } from '../../../shared/ml_callout'; import { AnomalyDetectionApiResponse } from './index'; -import { LegacyJobsCallout } from './legacy_jobs_callout'; -import { MLJobsAwaitingNodeWarning } from '../../../../../../ml/public'; +import { JobsListStatus } from './jobs_list_status'; type Jobs = AnomalyDetectionApiResponse['jobs']; @@ -36,7 +44,24 @@ const columns: Array> = [ 'xpack.apm.settings.anomalyDetection.jobList.environmentColumnLabel', { defaultMessage: 'Environment' } ), - render: getEnvironmentLabel, + width: '100%', + render: (_, { environment, jobId, jobState, datafeedState, version }) => { + return ( + + + {getEnvironmentLabel(environment)} + + + + + + ); + }, }, { field: 'job_id', @@ -45,30 +70,79 @@ const columns: Array> = [ 'xpack.apm.settings.anomalyDetection.jobList.actionColumnLabel', { defaultMessage: 'Action' } ), - render: (_, { job_id: jobId }) => ( - - {i18n.translate( - 'xpack.apm.settings.anomalyDetection.jobList.mlJobLinkText', - { - defaultMessage: 'View job in ML', - } - )} - - ), + render: (_, { jobId }) => { + return ( + + + + {/* setting the key to remount the element as a workaround for https://github.com/elastic/kibana/issues/119951*/} + + + + + + + + + + + + + + ); + }, }, ]; interface Props { data: AnomalyDetectionApiResponse; + setupState: AnomalyDetectionSetupState; status: FETCH_STATUS; onAddEnvironments: () => void; + onUpdateComplete: () => void; } -export function JobsList({ data, status, onAddEnvironments }: Props) { - const { jobs, hasLegacyJobs } = data; + +export function JobsList({ + data, + status, + onAddEnvironments, + setupState, + onUpdateComplete, +}: Props) { + const { core } = useApmPluginContext(); + + const { jobs } = data; + + // default to showing legacy jobs if not up to date + const [showLegacyJobs, setShowLegacyJobs] = useState( + setupState !== AnomalyDetectionSetupState.UpToDate + ); + + const mlManageJobsHref = useMlManageJobsHref(); + + const displayMlCallout = shouldDisplayMlCallout(setupState); + + const filteredJobs = showLegacyJobs + ? jobs + : jobs.filter((job) => job.version >= 3); return ( <> - j.job_id)} /> + j.jobId)} /> + {displayMlCallout && ( + <> + { + onAddEnvironments(); + }} + onUpgradeClick={() => { + if (setupState === AnomalyDetectionSetupState.UpgradeableJobs) { + return callApmApi({ + endpoint: + 'POST /internal/apm/settings/anomaly-detection/update_to_v3', + signal: null, + }).then(() => { + core.notifications.toasts.addSuccess({ + title: i18n.translate( + 'xpack.apm.jobsList.updateCompletedToastTitle', + { + defaultMessage: 'Anomaly detection jobs created!', + } + ), + text: i18n.translate( + 'xpack.apm.jobsList.updateCompletedToastText', + { + defaultMessage: + 'Your new anomaly detection jobs have been created successfully. You will start to see anomaly detection results in the app within minutes. The old jobs have been closed but the results are still available within Machine Learning.', + } + ), + }); + onUpdateComplete(); + }); + } + }} + anomalyDetectionSetupState={setupState} + /> + + + )} - +

@@ -103,12 +215,36 @@ export function JobsList({ data, status, onAddEnvironments }: Props) {

+ + { + setShowLegacyJobs(e.target.checked); + }} + label={i18n.translate( + 'xpack.apm.settings.anomalyDetection.jobList.showLegacyJobsCheckboxText', + { + defaultMessage: 'Show legacy jobs', + } + )} + /> + + + + {i18n.translate( + 'xpack.apm.settings.anomalyDetection.jobList.manageMlJobsButtonText', + { + defaultMessage: 'Manage jobs', + } + )} + + {i18n.translate( 'xpack.apm.settings.anomalyDetection.jobList.addEnvironments', { - defaultMessage: 'Create ML Job', + defaultMessage: 'Create job', } )} @@ -120,11 +256,10 @@ export function JobsList({ data, status, onAddEnvironments }: Props) { - - {hasLegacyJobs && } ); } diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list_status.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list_status.tsx new file mode 100644 index 0000000000000..6145e9f9ca7da --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list_status.tsx @@ -0,0 +1,102 @@ +/* + * 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 { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { DATAFEED_STATE, JOB_STATE } from '../../../../../../ml/common'; +import { MLManageJobsLink } from '../../../shared/Links/MachineLearningLinks/MLManageJobsLink'; + +export function JobsListStatus({ + jobId, + jobState, + datafeedState, + version, +}: { + jobId: string; + jobState?: JOB_STATE; + datafeedState?: DATAFEED_STATE; + version: number; +}) { + const jobIsOk = + jobState === JOB_STATE.OPENED || jobState === JOB_STATE.OPENING; + + const datafeedIsOk = + datafeedState === DATAFEED_STATE.STARTED || + datafeedState === DATAFEED_STATE.STARTING; + + const isClosed = + jobState === JOB_STATE.CLOSED || jobState === JOB_STATE.CLOSING; + + const isLegacy = version < 3; + + const statuses: React.ReactElement[] = []; + + if (jobIsOk && datafeedIsOk) { + statuses.push( + + {i18n.translate( + 'xpack.apm.settings.anomalyDetection.jobList.okStatusLabel', + { defaultMessage: 'OK' } + )} + + ); + } else if (!isClosed) { + statuses.push( + + + + {i18n.translate( + 'xpack.apm.settings.anomalyDetection.jobList.warningStatusBadgeLabel', + { defaultMessage: 'Warning' } + )} + + + + ); + } + + if (isClosed) { + statuses.push( + + {i18n.translate( + 'xpack.apm.settings.anomalyDetection.jobList.closedStatusLabel', + { defaultMessage: 'Closed' } + )} + + ); + } + + if (isLegacy) { + statuses.push( + + {' '} + {i18n.translate( + 'xpack.apm.settings.anomalyDetection.jobList.legacyStatusLabel', + { defaultMessage: 'Legacy' } + )} + + ); + } + + return ( + + {statuses.map((status, idx) => ( + + {status} + + ))} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/legacy_jobs_callout.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/legacy_jobs_callout.tsx deleted file mode 100644 index 0d3da5c9f97ad..0000000000000 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/legacy_jobs_callout.tsx +++ /dev/null @@ -1,51 +0,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 { EuiCallOut, EuiButton } from '@elastic/eui'; -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; -import { useMlHref } from '../../../../../../ml/public'; - -export function LegacyJobsCallout() { - const { - core, - plugins: { ml }, - } = useApmPluginContext(); - const mlADLink = useMlHref(ml, core.http.basePath.get(), { - page: 'jobs', - pageState: { - jobId: 'high_mean_response_time', - }, - }); - - return ( - -

- {i18n.translate( - 'xpack.apm.settings.anomaly_detection.legacy_jobs.body', - { - defaultMessage: - 'We have discovered legacy Machine Learning jobs from our previous integration which are no longer being used in the APM app', - } - )} -

- - {i18n.translate( - 'xpack.apm.settings.anomaly_detection.legacy_jobs.button', - { defaultMessage: 'Review jobs' } - )} - -
- ); -} diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx index 155de8fbdd947..146c1f5b4a2c8 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx @@ -26,7 +26,7 @@ import { useUpgradeAssistantHref } from '../../shared/Links/kibana'; import { SearchBar } from '../../shared/search_bar'; import { getTimeRangeComparison } from '../../shared/time_comparison/get_time_range_comparison'; import { ServiceList } from './service_list'; -import { MLCallout } from './service_list/MLCallout'; +import { MLCallout, shouldDisplayMlCallout } from '../../shared/ml_callout'; const initialData = { requestId: '', @@ -157,26 +157,19 @@ function useServicesFetcher() { } export function ServiceInventory() { - const { core } = useApmPluginContext(); - const { mainStatisticsData, mainStatisticsStatus, comparisonData } = useServicesFetcher(); - const { anomalyDetectionJobsData, anomalyDetectionJobsStatus } = - useAnomalyDetectionJobsContext(); + const { anomalyDetectionSetupState } = useAnomalyDetectionJobsContext(); const [userHasDismissedCallout, setUserHasDismissedCallout] = useLocalStorage( - 'apm.userHasDismissedServiceInventoryMlCallout', + `apm.userHasDismissedServiceInventoryMlCallout.${anomalyDetectionSetupState}`, false ); - const canCreateJob = !!core.application.capabilities.ml?.canCreateJob; - const displayMlCallout = - anomalyDetectionJobsStatus === FETCH_STATUS.SUCCESS && - !anomalyDetectionJobsData?.jobs.length && - canCreateJob && - !userHasDismissedCallout; + !userHasDismissedCallout && + shouldDisplayMlCallout(anomalyDetectionSetupState); const isLoading = mainStatisticsStatus === FETCH_STATUS.LOADING; const isFailure = mainStatisticsStatus === FETCH_STATUS.FAILURE; @@ -196,10 +189,14 @@ export function ServiceInventory() { return ( <> - + {displayMlCallout && ( - setUserHasDismissedCallout(true)} /> + setUserHasDismissedCallout(true)} + /> )} diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.stories.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.stories.tsx index 0a4adc07e1a98..bececfb545ba9 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.stories.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { CoreStart } from '../../../../../../../src/core/public'; import { createKibanaReactContext } from '../../../../../../../src/plugins/kibana_react/public'; +import { AnomalyDetectionSetupState } from '../../../../common/anomaly_detection/get_anomaly_detection_setup_state'; import { TimeRangeComparisonEnum } from '../../../../common/runtime_types/comparison_type_rt'; import { AnomalyDetectionJobsContext } from '../../../context/anomaly_detection_jobs/anomaly_detection_jobs_context'; import { ApmPluginContextValue } from '../../../context/apm_plugin/apm_plugin_context'; @@ -45,6 +46,7 @@ const stories: Meta<{}> = { anomalyDetectionJobsData: { jobs: [], hasLegacyJobs: false }, anomalyDetectionJobsStatus: FETCH_STATUS.SUCCESS, anomalyDetectionJobsRefetch: () => {}, + anomalyDetectionSetupState: AnomalyDetectionSetupState.NoJobs, }; return ( diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/MLCallout.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/MLCallout.tsx deleted file mode 100644 index 91625af7062cc..0000000000000 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/MLCallout.tsx +++ /dev/null @@ -1,60 +0,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 React from 'react'; -import { EuiCallOut } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { EuiButton } from '@elastic/eui'; -import { EuiFlexItem } from '@elastic/eui'; -import { EuiFlexGrid } from '@elastic/eui'; -import { EuiButtonEmpty } from '@elastic/eui'; -import { APMLink } from '../../../shared/Links/apm/APMLink'; - -export function MLCallout({ onDismiss }: { onDismiss: () => void }) { - return ( - -

- {i18n.translate('xpack.apm.serviceOverview.mlNudgeMessage.content', { - defaultMessage: `Pinpoint anomalous transactions and see the health of upstream and downstream services with APM's anomaly detection integration. Get started in just a few minutes.`, - })} -

- - - - - {i18n.translate( - 'xpack.apm.serviceOverview.mlNudgeMessage.learnMoreButton', - { - defaultMessage: `Get started`, - } - )} - - - - - onDismiss()}> - {i18n.translate( - 'xpack.apm.serviceOverview.mlNudgeMessage.dismissButton', - { - defaultMessage: `Dismiss`, - } - )} - - - -
- ); -} diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLManageJobsLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLManageJobsLink.tsx index eb7b531121753..4e2a7f477b666 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLManageJobsLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLManageJobsLink.tsx @@ -7,47 +7,17 @@ import { EuiLink } from '@elastic/eui'; import React from 'react'; -import { UI_SETTINGS } from '../../../../../../../../src/plugins/data/common'; -import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; -import { useMlHref, ML_PAGES } from '../../../../../../ml/public'; -import { useLegacyUrlParams } from '../../../../context/url_params_context/use_url_params'; -import { TimePickerRefreshInterval } from '../../DatePicker/typings'; +import { useMlManageJobsHref } from '../../../../hooks/use_ml_manage_jobs_href'; interface Props { children?: React.ReactNode; external?: boolean; + jobId?: string; } -export function MLManageJobsLink({ children, external }: Props) { - const { - core, - plugins: { ml }, - } = useApmPluginContext(); - - const { urlParams } = useLegacyUrlParams(); - - const timePickerRefreshIntervalDefaults = - core.uiSettings.get( - UI_SETTINGS.TIMEPICKER_REFRESH_INTERVAL_DEFAULTS - ); - - const { - // hardcoding a custom default of 1 hour since the default kibana timerange of 15 minutes is shorter than the ML interval - rangeFrom = 'now-1h', - rangeTo = 'now', - refreshInterval = timePickerRefreshIntervalDefaults.value, - refreshPaused = timePickerRefreshIntervalDefaults.pause, - } = urlParams; - - const mlADLink = useMlHref(ml, core.http.basePath.get(), { - page: ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE, - pageState: { - groupIds: ['apm'], - globalState: { - time: { from: rangeFrom, to: rangeTo }, - refreshInterval: { pause: refreshPaused, value: refreshInterval }, - }, - }, +export function MLManageJobsLink({ children, external, jobId }: Props) { + const mlADLink = useMlManageJobsHref({ + jobId, }); return ( diff --git a/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.test.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.test.tsx index 0520cfa39a743..e47c4853827de 100644 --- a/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.test.tsx @@ -5,28 +5,55 @@ * 2.0. */ +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { createMemoryHistory } from 'history'; import React from 'react'; -import { render, fireEvent, waitFor } from '@testing-library/react'; -import { MissingJobsAlert } from './anomaly_detection_setup_link'; +import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; +import { ApmMlJob } from '../../../../common/anomaly_detection/apm_ml_job'; +import { getAnomalyDetectionSetupState } from '../../../../common/anomaly_detection/get_anomaly_detection_setup_state'; +import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import * as hooks from '../../../context/anomaly_detection_jobs/use_anomaly_detection_jobs_context'; +import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; +import { AnomalyDetectionSetupLink } from './anomaly_detection_setup_link'; async function renderTooltipAnchor({ jobs, environment, }: { - jobs: Array<{ job_id: string; environment: string }>; + jobs: ApmMlJob[]; environment?: string; }) { // mock api response jest.spyOn(hooks, 'useAnomalyDetectionJobsContext').mockReturnValue({ - anomalyDetectionJobsData: { jobs, hasLegacyJobs: false }, + anomalyDetectionJobsData: { + jobs, + hasLegacyJobs: jobs.some((job) => job.version <= 2), + }, anomalyDetectionJobsStatus: FETCH_STATUS.SUCCESS, anomalyDetectionJobsRefetch: () => {}, + anomalyDetectionSetupState: getAnomalyDetectionSetupState({ + environment: environment ?? ENVIRONMENT_ALL.value, + fetchStatus: FETCH_STATUS.SUCCESS, + isAuthorized: true, + jobs, + }), + }); + + const history = createMemoryHistory({ + initialEntries: [ + `/services?environment=${ + environment || ENVIRONMENT_ALL.value + }&rangeFrom=now-15m&rangeTo=now`, + ], }); const { baseElement, container } = render( - + + + + + ); // hover tooltip anchor if it exists @@ -65,7 +92,13 @@ describe('MissingJobsAlert', () => { describe('when no jobs exists for the selected environment', () => { it('shows a warning', async () => { const { toolTipAnchor, toolTipText } = await renderTooltipAnchor({ - jobs: [{ environment: 'production', job_id: 'my_job_id' }], + jobs: [ + { + environment: 'production', + jobId: 'my_job_id', + version: 3, + } as ApmMlJob, + ], environment: 'staging', }); @@ -79,7 +112,13 @@ describe('MissingJobsAlert', () => { describe('when a job exists for the selected environment', () => { it('does not show a warning', async () => { const { toolTipAnchor, toolTipText } = await renderTooltipAnchor({ - jobs: [{ environment: 'production', job_id: 'my_job_id' }], + jobs: [ + { + environment: 'production', + jobId: 'my_job_id', + version: 3, + } as ApmMlJob, + ], environment: 'production', }); @@ -91,7 +130,13 @@ describe('MissingJobsAlert', () => { describe('when at least one job exists and no environment is selected', () => { it('does not show a warning', async () => { const { toolTipAnchor, toolTipText } = await renderTooltipAnchor({ - jobs: [{ environment: 'production', job_id: 'my_job_id' }], + jobs: [ + { + environment: 'production', + jobId: 'my_job_id', + version: 3, + } as ApmMlJob, + ], }); expect(toolTipAnchor).not.toBeInTheDocument(); @@ -102,7 +147,54 @@ describe('MissingJobsAlert', () => { describe('when at least one job exists and all environments are selected', () => { it('does not show a warning', async () => { const { toolTipAnchor, toolTipText } = await renderTooltipAnchor({ - jobs: [{ environment: 'ENVIRONMENT_ALL', job_id: 'my_job_id' }], + jobs: [ + { + environment: 'ENVIRONMENT_ALL', + jobId: 'my_job_id', + version: 3, + } as ApmMlJob, + ], + }); + + expect(toolTipAnchor).not.toBeInTheDocument(); + expect(toolTipText).toBe(undefined); + }); + }); + + describe('when at least one legacy job exists', () => { + it('displays a nudge to upgrade', async () => { + const { toolTipAnchor, toolTipText } = await renderTooltipAnchor({ + jobs: [ + { + environment: 'ENVIRONMENT_ALL', + jobId: 'my_job_id', + version: 2, + } as ApmMlJob, + ], + }); + + expect(toolTipAnchor).toBeInTheDocument(); + expect(toolTipText).toBe( + 'Updates available for existing anomaly detection jobs.' + ); + }); + }); + + describe('when both legacy and modern jobs exist', () => { + it('does not show a tooltip', async () => { + const { toolTipAnchor, toolTipText } = await renderTooltipAnchor({ + jobs: [ + { + environment: 'ENVIRONMENT_ALL', + jobId: 'my_job_id', + version: 2, + } as ApmMlJob, + { + environment: 'ENVIRONMENT_ALL', + jobId: 'my_job_id_2', + version: 3, + } as ApmMlJob, + ], }); expect(toolTipAnchor).not.toBeInTheDocument(); diff --git a/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.tsx index 4891ca896076a..e1bda5475acc4 100644 --- a/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.tsx +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.tsx @@ -5,32 +5,22 @@ * 2.0. */ -import { - EuiHeaderLink, - EuiIcon, - EuiLoadingSpinner, - EuiToolTip, -} from '@elastic/eui'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { IconType } from '@elastic/eui'; +import { EuiHeaderLink, EuiIcon, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; +import { AnomalyDetectionSetupState } from '../../../../common/anomaly_detection/get_anomaly_detection_setup_state'; import { ENVIRONMENT_ALL, getEnvironmentLabel, } from '../../../../common/environment_filter_values'; import { useAnomalyDetectionJobsContext } from '../../../context/anomaly_detection_jobs/use_anomaly_detection_jobs_context'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; -import { useLicenseContext } from '../../../context/license/use_license_context'; import { useApmParams } from '../../../hooks/use_apm_params'; -import { FETCH_STATUS } from '../../../hooks/use_fetcher'; import { useTheme } from '../../../hooks/use_theme'; -import { APIReturnType } from '../../../services/rest/createCallApmApi'; import { getLegacyApmHref } from '../Links/apm/APMLink'; -export type AnomalyDetectionApiResponse = - APIReturnType<'GET /internal/apm/settings/anomaly-detection/jobs'>; - -const DEFAULT_DATA = { jobs: [], hasLegacyJobs: false }; - export function AnomalyDetectionSetupLink() { const { query } = useApmParams('/*'); @@ -38,71 +28,86 @@ export function AnomalyDetectionSetupLink() { ('environment' in query && query.environment) || ENVIRONMENT_ALL.value; const { core } = useApmPluginContext(); - const canGetJobs = !!core.application.capabilities.ml?.canGetJobs; - const license = useLicenseContext(); - const hasValidLicense = license?.isActive && license?.hasAtLeast('platinum'); + const { basePath } = core.http; const theme = useTheme(); - return ( + const { anomalyDetectionSetupState } = useAnomalyDetectionJobsContext(); + + let tooltipText: string = ''; + let color: 'warning' | 'text' | 'success' | 'danger' = 'text'; + let icon: IconType | undefined; + + if (anomalyDetectionSetupState === AnomalyDetectionSetupState.Failure) { + color = 'warning'; + tooltipText = i18n.translate( + 'xpack.apm.anomalyDetectionSetup.jobFetchFailureText', + { + defaultMessage: 'Could not determine state of anomaly detection setup.', + } + ); + icon = 'alert'; + } else if ( + anomalyDetectionSetupState === AnomalyDetectionSetupState.NoJobs || + anomalyDetectionSetupState === + AnomalyDetectionSetupState.NoJobsForEnvironment + ) { + color = 'warning'; + tooltipText = getNoJobsMessage(anomalyDetectionSetupState, environment); + icon = 'alert'; + } else if ( + anomalyDetectionSetupState === AnomalyDetectionSetupState.UpgradeableJobs + ) { + color = 'success'; + tooltipText = i18n.translate( + 'xpack.apm.anomalyDetectionSetup.upgradeableJobsText', + { + defaultMessage: + 'Updates available for existing anomaly detection jobs.', + } + ); + icon = 'wrench'; + } + + let pre: React.ReactElement | null = null; + + if (anomalyDetectionSetupState === AnomalyDetectionSetupState.Loading) { + pre = ; + } else if (icon) { + pre = ; + } + + const element = ( - {canGetJobs && hasValidLicense ? ( - - ) : ( - - )} + {pre} {ANOMALY_DETECTION_LINK_LABEL} ); -} - -export function MissingJobsAlert({ environment }: { environment?: string }) { - const { - anomalyDetectionJobsData = DEFAULT_DATA, - anomalyDetectionJobsStatus, - } = useAnomalyDetectionJobsContext(); - const defaultIcon = ; - - if (anomalyDetectionJobsStatus === FETCH_STATUS.LOADING) { - return ; - } - - if (anomalyDetectionJobsStatus !== FETCH_STATUS.SUCCESS) { - return defaultIcon; - } - - const isEnvironmentSelected = - environment && environment !== ENVIRONMENT_ALL.value; - - // there are jobs for at least one environment - if (!isEnvironmentSelected && anomalyDetectionJobsData.jobs.length > 0) { - return defaultIcon; - } - - // there are jobs for the selected environment - if ( - isEnvironmentSelected && - anomalyDetectionJobsData.jobs.some((job) => environment === job.environment) - ) { - return defaultIcon; - } - - return ( - - + const wrappedElement = tooltipText ? ( + + {element} + ) : ( + element ); + + return wrappedElement; } -function getTooltipText(environment?: string) { - if (!environment || environment === ENVIRONMENT_ALL.value) { +function getNoJobsMessage( + state: + | AnomalyDetectionSetupState.NoJobs + | AnomalyDetectionSetupState.NoJobsForEnvironment, + environment: string +) { + if (state === AnomalyDetectionSetupState.NoJobs) { return i18n.translate('xpack.apm.anomalyDetectionSetup.notEnabledText', { defaultMessage: `Anomaly detection is not yet enabled. Click to continue setup.`, }); diff --git a/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx b/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx index 5d49f1d9a8f18..9fafafdcb9478 100644 --- a/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx @@ -44,6 +44,7 @@ interface Props { pagination?: boolean; isLoading?: boolean; error?: boolean; + tableLayout?: 'auto' | 'fixed'; } function defaultSortFn( @@ -70,6 +71,7 @@ function UnoptimizedManagedTable(props: Props) { pagination = true, isLoading = false, error = false, + tableLayout, } = props; const { @@ -140,6 +142,7 @@ function UnoptimizedManagedTable(props: Props) { return ( void; + onUpgradeClick?: () => any; + onCreateJobClick?: () => void; + isOnSettingsPage: boolean; + append?: React.ReactElement; +}) { + const [loading, setLoading] = useState(false); + + const mlManageJobsHref = useMlManageJobsHref(); + + let properties: + | { + primaryAction: React.ReactNode | undefined; + color: 'primary' | 'success' | 'danger' | 'warning'; + title: string; + icon: string; + text: string; + } + | undefined; + + const getLearnMoreLink = (color: 'primary' | 'success') => ( + + + {i18n.translate('xpack.apm.mlCallout.learnMoreButton', { + defaultMessage: `Learn more`, + })} + + + ); + + switch (anomalyDetectionSetupState) { + case AnomalyDetectionSetupState.NoJobs: + properties = { + title: i18n.translate('xpack.apm.mlCallout.noJobsCalloutTitle', { + defaultMessage: + 'Enable anomaly detection to add health status indicators to your services', + }), + text: i18n.translate('xpack.apm.mlCallout.noJobsCalloutText', { + defaultMessage: `Pinpoint anomalous transactions and see the health of upstream and downstream services with APM's anomaly detection integration. Get started in just a few minutes.`, + }), + icon: 'iInCircle', + color: 'primary', + primaryAction: isOnSettingsPage ? ( + { + onCreateJobClick?.(); + }} + > + {i18n.translate('xpack.apm.mlCallout.noJobsCalloutButtonText', { + defaultMessage: 'Create ML Job', + })} + + ) : ( + getLearnMoreLink('primary') + ), + }; + break; + + case AnomalyDetectionSetupState.UpgradeableJobs: + properties = { + title: i18n.translate( + 'xpack.apm.mlCallout.updateAvailableCalloutTitle', + { defaultMessage: 'Updates available' } + ), + text: i18n.translate('xpack.apm.mlCallout.updateAvailableCalloutText', { + defaultMessage: + 'We have updated the anomaly detection jobs that provide insights into degraded performance and added detectors for throughput and failed transaction rate. If you choose to upgrade, we will create the new jobs and close the existing legacy jobs. The data shown in the APM app will automatically switch to the new.', + }), + color: 'success', + icon: 'wrench', + primaryAction: isOnSettingsPage ? ( + { + setLoading(true); + Promise.resolve(onUpgradeClick?.()).finally(() => { + setLoading(false); + }); + }} + > + {i18n.translate( + 'xpack.apm.mlCallout.updateAvailableCalloutButtonText', + { + defaultMessage: 'Update jobs', + } + )} + + ) : ( + getLearnMoreLink('success') + ), + }; + break; + + case AnomalyDetectionSetupState.LegacyJobs: + properties = { + title: i18n.translate('xpack.apm.mlCallout.legacyJobsCalloutTitle', { + defaultMessage: 'Legacy ML jobs are no longer used in APM app', + }), + text: i18n.translate('xpack.apm.mlCallout.legacyJobsCalloutText', { + defaultMessage: + 'We have discovered legacy Machine Learning jobs from our previous integration which are no longer being used in the APM app', + }), + icon: 'iInCircle', + color: 'primary', + primaryAction: ( + + {i18n.translate( + 'xpack.apm.settings.anomaly_detection.legacy_jobs.button', + { defaultMessage: 'Review jobs' } + )} + + ), + }; + break; + } + + if (!properties) { + return null; + } + + const dismissable = !isOnSettingsPage; + + const hasAnyActions = properties.primaryAction || dismissable; + + const actions = hasAnyActions ? ( + + {properties.primaryAction && ( + {properties.primaryAction} + )} + {dismissable && ( + + + {i18n.translate('xpack.apm.mlCallout.dismissButton', { + defaultMessage: `Dismiss`, + })} + + + )} + + ) : null; + + return ( + +

{properties.text}

+ {actions} +
+ ); +} diff --git a/x-pack/plugins/apm/public/context/anomaly_detection_jobs/anomaly_detection_jobs_context.tsx b/x-pack/plugins/apm/public/context/anomaly_detection_jobs/anomaly_detection_jobs_context.tsx index bf9f2941fa2fb..3b9cea7b88998 100644 --- a/x-pack/plugins/apm/public/context/anomaly_detection_jobs/anomaly_detection_jobs_context.tsx +++ b/x-pack/plugins/apm/public/context/anomaly_detection_jobs/anomaly_detection_jobs_context.tsx @@ -5,14 +5,23 @@ * 2.0. */ -import React, { createContext, ReactChild, useState } from 'react'; +import React, { createContext, ReactChild } from 'react'; +import { + AnomalyDetectionSetupState, + getAnomalyDetectionSetupState, +} from '../../../common/anomaly_detection/get_anomaly_detection_setup_state'; +import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values'; +import { useApmParams } from '../../hooks/use_apm_params'; import { FETCH_STATUS, useFetcher } from '../../hooks/use_fetcher'; import { APIReturnType } from '../../services/rest/createCallApmApi'; +import { useApmPluginContext } from '../apm_plugin/use_apm_plugin_context'; +import { useLicenseContext } from '../license/use_license_context'; export interface AnomalyDetectionJobsContextValue { anomalyDetectionJobsData?: APIReturnType<'GET /internal/apm/settings/anomaly-detection/jobs'>; anomalyDetectionJobsStatus: FETCH_STATUS; anomalyDetectionJobsRefetch: () => void; + anomalyDetectionSetupState: AnomalyDetectionSetupState; } export const AnomalyDetectionJobsContext = createContext( @@ -24,24 +33,45 @@ export function AnomalyDetectionJobsContextProvider({ }: { children: ReactChild; }) { - const [fetchId, setFetchId] = useState(0); - const refetch = () => setFetchId((id) => id + 1); + const { core } = useApmPluginContext(); + const canGetJobs = !!core.application.capabilities.ml?.canGetJobs; + const license = useLicenseContext(); + const hasValidLicense = license?.isActive && license?.hasAtLeast('platinum'); - const { data, status } = useFetcher( - (callApmApi) => - callApmApi({ + const isAuthorized = !!(canGetJobs && hasValidLicense); + + const { data, status, refetch } = useFetcher( + (callApmApi) => { + if (!isAuthorized) { + return; + } + return callApmApi({ endpoint: `GET /internal/apm/settings/anomaly-detection/jobs`, - }), - [fetchId], // eslint-disable-line react-hooks/exhaustive-deps + }); + }, + [isAuthorized], { showToastOnError: false } ); + const { query } = useApmParams('/*'); + + const environment = + ('environment' in query && query.environment) || ENVIRONMENT_ALL.value; + + const anomalyDetectionSetupState = getAnomalyDetectionSetupState({ + environment, + fetchStatus: status, + jobs: data?.jobs ?? [], + isAuthorized, + }); + return ( {children} diff --git a/x-pack/plugins/apm/public/hooks/use_ml_manage_jobs_href.ts b/x-pack/plugins/apm/public/hooks/use_ml_manage_jobs_href.ts new file mode 100644 index 0000000000000..cc187c6cf619a --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_ml_manage_jobs_href.ts @@ -0,0 +1,48 @@ +/* + * 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 { UI_SETTINGS } from '../../../../../src/plugins/data/public'; +import { ML_PAGES, useMlHref } from '../../../ml/public'; +import { TimePickerRefreshInterval } from '../components/shared/DatePicker/typings'; +import { useApmPluginContext } from '../context/apm_plugin/use_apm_plugin_context'; +import { useLegacyUrlParams } from '../context/url_params_context/use_url_params'; + +export function useMlManageJobsHref({ jobId }: { jobId?: string } = {}) { + const { + core, + plugins: { ml }, + } = useApmPluginContext(); + + const { urlParams } = useLegacyUrlParams(); + + const timePickerRefreshIntervalDefaults = + core.uiSettings.get( + UI_SETTINGS.TIMEPICKER_REFRESH_INTERVAL_DEFAULTS + ); + + const { + // hardcoding a custom default of 1 hour since the default kibana timerange of 15 minutes is shorter than the ML interval + rangeFrom = 'now-1h', + rangeTo = 'now', + refreshInterval = timePickerRefreshIntervalDefaults.value, + refreshPaused = timePickerRefreshIntervalDefaults.pause, + } = urlParams; + + const mlADLink = useMlHref(ml, core.http.basePath.get(), { + page: ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE, + pageState: { + groupIds: ['apm'], + jobId, + globalState: { + time: { from: rangeFrom, to: rangeTo }, + refreshInterval: { pause: refreshPaused, value: refreshInterval }, + }, + }, + }); + + return mlADLink; +} diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/apm_ml_jobs_query.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/apm_ml_jobs_query.ts index 4eec3b39f3739..2720dbdecfe1c 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/apm_ml_jobs_query.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/apm_ml_jobs_query.ts @@ -4,12 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { - MlJob, - QueryDslQueryContainer, -} from '@elastic/elasticsearch/lib/api/types'; +import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import { ApmMlJob } from '../../../common/anomaly_detection/apm_ml_job'; -export function apmMlJobsQuery(jobs: MlJob[]) { +export function apmMlJobsQuery(jobs: ApmMlJob[]) { if (!jobs.length) { throw new Error('At least one ML job should be given'); } @@ -17,7 +15,7 @@ export function apmMlJobsQuery(jobs: MlJob[]) { return [ { terms: { - job_id: jobs.map((job) => job.job_id), + job_id: jobs.map((job) => job.jobId), }, }, ] as QueryDslQueryContainer[]; diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts index 7277a12c2bf14..d855adee4a9ba 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts @@ -15,6 +15,7 @@ import { METRICSET_NAME, PROCESSOR_EVENT, } from '../../../common/elasticsearch_fieldnames'; +import { Environment } from '../../../common/environment_rt'; import { ProcessorEvent } from '../../../common/processor_event'; import { environmentQuery } from '../../../common/utils/environment_query'; import { withApmSpan } from '../../utils/with_apm_span'; @@ -24,7 +25,7 @@ import { getAnomalyDetectionJobs } from './get_anomaly_detection_jobs'; export async function createAnomalyDetectionJobs( setup: Setup, - environments: string[], + environments: Environment[], logger: Logger ) { const { ml, indices } = setup; @@ -33,13 +34,6 @@ export async function createAnomalyDetectionJobs( throw Boom.notImplemented(ML_ERRORS.ML_NOT_AVAILABLE); } - const mlCapabilities = await withApmSpan('get_ml_capabilities', () => - ml.mlSystem.mlCapabilities() - ); - if (!mlCapabilities.mlFeatureEnabledInSpace) { - throw Boom.forbidden(ML_ERRORS.ML_NOT_AVAILABLE_IN_SPACE); - } - const uniqueMlJobEnvs = await getUniqueMlJobEnvs(setup, environments, logger); if (uniqueMlJobEnvs.length === 0) { return []; @@ -56,6 +50,7 @@ export async function createAnomalyDetectionJobs( createAnomalyDetectionJob({ ml, environment, dataViewName }) ) ); + const jobResponses = responses.flatMap((response) => response.jobs); const failedJobs = jobResponses.filter(({ success }) => !success); @@ -116,12 +111,15 @@ async function createAnomalyDetectionJob({ async function getUniqueMlJobEnvs( setup: Setup, - environments: string[], + environments: Environment[], logger: Logger ) { // skip creation of duplicate ML jobs - const jobs = await getAnomalyDetectionJobs(setup, logger); - const existingMlJobEnvs = jobs.map(({ environment }) => environment); + const jobs = await getAnomalyDetectionJobs(setup); + const existingMlJobEnvs = jobs + .filter((job) => job.version === 3) + .map(({ environment }) => environment); + const requestedExistingMlJobEnvs = environments.filter((env) => existingMlJobEnvs.includes(env) ); diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts index 75b2e8289c7a8..9047ae9ed90d0 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts @@ -4,41 +4,17 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { Logger } from 'kibana/server'; import Boom from '@hapi/boom'; import { ML_ERRORS } from '../../../common/anomaly_detection'; import { Setup } from '../helpers/setup_request'; import { getMlJobsWithAPMGroup } from './get_ml_jobs_with_apm_group'; -import { withApmSpan } from '../../utils/with_apm_span'; -export function getAnomalyDetectionJobs(setup: Setup, logger: Logger) { +export function getAnomalyDetectionJobs(setup: Setup) { const { ml } = setup; if (!ml) { throw Boom.notImplemented(ML_ERRORS.ML_NOT_AVAILABLE); } - return withApmSpan('get_anomaly_detection_jobs', async () => { - const mlCapabilities = await withApmSpan('get_ml_capabilities', () => - ml.mlSystem.mlCapabilities() - ); - - if (!mlCapabilities.mlFeatureEnabledInSpace) { - throw Boom.forbidden(ML_ERRORS.ML_NOT_AVAILABLE_IN_SPACE); - } - - const response = await getMlJobsWithAPMGroup(ml.anomalyDetectors); - return response.jobs - .filter( - (job) => (job.custom_settings?.job_tags?.apm_ml_version ?? 0) >= 2 - ) - .map((job) => { - const environment = job.custom_settings?.job_tags?.environment ?? ''; - return { - job_id: job.job_id, - environment, - }; - }); - }); + return getMlJobsWithAPMGroup(ml.anomalyDetectors); } diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_timeseries.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_timeseries.ts index 77ffef9801a86..37279d3320585 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_timeseries.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_timeseries.ts @@ -46,9 +46,7 @@ export async function getAnomalyTimeseries({ end, }); - const { jobs: mlJobs } = await getMlJobsWithAPMGroup( - mlSetup.anomalyDetectors - ); + const mlJobs = await getMlJobsWithAPMGroup(mlSetup.anomalyDetectors); if (!mlJobs.length) { return []; @@ -148,7 +146,7 @@ export async function getAnomalyTimeseries({ } ); - const jobsById = keyBy(mlJobs, (job) => job.job_id); + const jobsById = keyBy(mlJobs, (job) => job.jobId); function divide(value: number | null, divider: number) { if (value === null) { @@ -176,9 +174,9 @@ export async function getAnomalyTimeseries({ jobId, type, serviceName: bucket.key.serviceName as string, - environment: job.custom_settings!.job_tags!.environment as string, + environment: job.environment, transactionType: bucket.key.transactionType as string, - version: Number(job.custom_settings!.job_tags!.apm_ml_version), + version: job.version, anomalies: bucket.timeseries.buckets.map((dateBucket) => ({ x: dateBucket.key as number, y: diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/get_ml_jobs_with_apm_group.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/get_ml_jobs_with_apm_group.ts index bcea8f1ed6b26..1f989ba17fe7c 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/get_ml_jobs_with_apm_group.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/get_ml_jobs_with_apm_group.ts @@ -6,23 +6,63 @@ */ import { MlPluginSetup } from '../../../../ml/server'; +import { ApmMlJob } from '../../../common/anomaly_detection/apm_ml_job'; +import { Environment } from '../../../common/environment_rt'; import { withApmSpan } from '../../utils/with_apm_span'; import { APM_ML_JOB_GROUP } from './constants'; // returns ml jobs containing "apm" group // workaround: the ML api returns 404 when no jobs are found. This is handled so instead of throwing an empty response is returned + +function catch404(e: any) { + if (e.statusCode === 404) { + return []; + } + + throw e; +} + export function getMlJobsWithAPMGroup( anomalyDetectors: ReturnType -) { +): Promise { return withApmSpan('get_ml_jobs_with_apm_group', async () => { try { - return await anomalyDetectors.jobs(APM_ML_JOB_GROUP); - } catch (e) { - if (e.statusCode === 404) { - return { count: 0, jobs: [] }; - } + const [jobs, allJobStats, allDatafeedStats] = await Promise.all([ + anomalyDetectors + .jobs(APM_ML_JOB_GROUP) + .then((response) => response.jobs), + anomalyDetectors + .jobStats(APM_ML_JOB_GROUP) + .then((response) => response.jobs) + .catch(catch404), + anomalyDetectors + .datafeedStats(`datafeed-${APM_ML_JOB_GROUP}*`) + .then((response) => response.datafeeds) + .catch(catch404), + ]); + + return jobs.map((job): ApmMlJob => { + const jobStats = allJobStats.find( + (stats) => stats.job_id === job.job_id + ); - throw e; + const datafeedStats = allDatafeedStats.find( + (stats) => stats.datafeed_id === job.datafeed_config?.datafeed_id + ); + + return { + environment: String( + job.custom_settings?.job_tags?.environment + ) as Environment, + jobId: job.job_id, + jobState: jobStats?.state as ApmMlJob['jobState'], + version: Number(job.custom_settings?.job_tags?.apm_ml_version ?? 1), + datafeedId: datafeedStats?.datafeed_id, + datafeedState: datafeedStats?.state as ApmMlJob['datafeedState'], + }; + }); + } catch (e) { + return catch404(e) as ApmMlJob[]; } }); } diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/has_legacy_jobs.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/has_legacy_jobs.ts deleted file mode 100644 index c189d24efc23a..0000000000000 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/has_legacy_jobs.ts +++ /dev/null @@ -1,38 +0,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 Boom from '@hapi/boom'; -import { ML_ERRORS } from '../../../common/anomaly_detection'; -import { withApmSpan } from '../../utils/with_apm_span'; -import { Setup } from '../helpers/setup_request'; -import { getMlJobsWithAPMGroup } from './get_ml_jobs_with_apm_group'; - -// Determine whether there are any legacy ml jobs. -// A legacy ML job has a job id that ends with "high_mean_response_time" and created_by=ml-module-apm-transaction -export function hasLegacyJobs(setup: Setup) { - const { ml } = setup; - - if (!ml) { - throw Boom.notImplemented(ML_ERRORS.ML_NOT_AVAILABLE); - } - - return withApmSpan('has_legacy_jobs', async () => { - const mlCapabilities = await withApmSpan('get_ml_capabilities', () => - ml.mlSystem.mlCapabilities() - ); - if (!mlCapabilities.mlFeatureEnabledInSpace) { - throw Boom.forbidden(ML_ERRORS.ML_NOT_AVAILABLE_IN_SPACE); - } - - const response = await getMlJobsWithAPMGroup(ml.anomalyDetectors); - return response.jobs.some( - (job) => - job.job_id.endsWith('high_mean_response_time') && - job.custom_settings?.created_by === 'ml-module-apm-transaction' - ); - }); -} diff --git a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.test.ts index fa4125c54126d..889fe3c16596e 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.test.ts @@ -6,9 +6,10 @@ */ import { registerTransactionDurationAnomalyAlertType } from './register_transaction_duration_anomaly_alert_type'; import { ANOMALY_SEVERITY } from '../../../common/ml_constants'; -import { Job, MlPluginSetup } from '../../../../ml/server'; +import { MlPluginSetup } from '../../../../ml/server'; import * as GetServiceAnomalies from '../service_map/get_service_anomalies'; import { createRuleTypeMocks } from './test_utils'; +import { ApmMlJob } from '../../../common/anomaly_detection/apm_ml_job'; describe('Transaction duration anomaly alert', () => { afterEach(() => { @@ -65,14 +66,14 @@ describe('Transaction duration anomaly alert', () => { jest.spyOn(GetServiceAnomalies, 'getMLJobs').mockReturnValue( Promise.resolve([ { - job_id: '1', - custom_settings: { job_tags: { environment: 'development' } }, + jobId: '1', + environment: 'development', }, { - job_id: '2', - custom_settings: { job_tags: { environment: 'production' } }, + jobId: '2', + environment: 'production', }, - ] as unknown as Job[]) + ] as unknown as ApmMlJob[]) ); const { services, dependencies, executor } = createRuleTypeMocks(); @@ -118,14 +119,14 @@ describe('Transaction duration anomaly alert', () => { jest.spyOn(GetServiceAnomalies, 'getMLJobs').mockReturnValue( Promise.resolve([ { - job_id: '1', - custom_settings: { job_tags: { environment: 'development' } }, + jobId: '1', + environment: 'development', }, { - job_id: '2', - custom_settings: { job_tags: { environment: 'production' } }, + jobId: '2', + environment: 'production', }, - ] as unknown as Job[]) + ] as unknown as ApmMlJob[]) ); const { services, dependencies, executor, scheduleActions } = diff --git a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.ts b/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.ts index dead149cd7761..5216d485bc31e 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.ts @@ -126,7 +126,7 @@ export function registerTransactionDurationAnomalyAlertType({ return {}; } - const jobIds = mlJobs.map((job) => job.job_id); + const jobIds = mlJobs.map((job) => job.jobId); const anomalySearchParams = { body: { size: 0, @@ -190,7 +190,7 @@ export function registerTransactionDurationAnomalyAlertType({ .map((bucket) => { const latest = bucket.latest_score.top[0].metrics; - const job = mlJobs.find((j) => j.job_id === latest.job_id); + const job = mlJobs.find((j) => j.jobId === latest.job_id); if (!job) { logger.warn( @@ -202,7 +202,7 @@ export function registerTransactionDurationAnomalyAlertType({ return { serviceName: latest.partition_field_value as string, transactionType: latest.by_field_value as string, - environment: job.custom_settings!.job_tags!.environment, + environment: job.environment, score: latest.record_score as number, }; }) diff --git a/x-pack/plugins/apm/server/routes/service_map/get_service_anomalies.ts b/x-pack/plugins/apm/server/routes/service_map/get_service_anomalies.ts index 66b12dab59775..a5fcececad1cc 100644 --- a/x-pack/plugins/apm/server/routes/service_map/get_service_anomalies.ts +++ b/x-pack/plugins/apm/server/routes/service_map/get_service_anomalies.ts @@ -161,17 +161,13 @@ export async function getMLJobs( anomalyDetectors: ReturnType, environment: string ) { - const response = await getMlJobsWithAPMGroup(anomalyDetectors); + const jobs = await getMlJobsWithAPMGroup(anomalyDetectors); // to filter out legacy jobs we are filtering by the existence of `apm_ml_version` in `custom_settings` // and checking that it is compatable. - const mlJobs = response.jobs.filter( - (job) => (job.custom_settings?.job_tags?.apm_ml_version ?? 0) >= 2 - ); + const mlJobs = jobs.filter((job) => job.version >= 2); if (environment !== ENVIRONMENT_ALL.value) { - const matchingMLJob = mlJobs.find( - (job) => job.custom_settings?.job_tags?.environment === environment - ); + const matchingMLJob = mlJobs.find((job) => job.environment === environment); if (!matchingMLJob) { return []; } @@ -185,5 +181,5 @@ export async function getMLJobIds( environment: string ) { const mlJobs = await getMLJobs(anomalyDetectors, environment); - return mlJobs.map((job) => job.job_id); + return mlJobs.map((job) => job.jobId); } diff --git a/x-pack/plugins/apm/server/routes/settings/anomaly_detection/route.ts b/x-pack/plugins/apm/server/routes/settings/anomaly_detection/route.ts index a924a9214977d..35089acf38688 100644 --- a/x-pack/plugins/apm/server/routes/settings/anomaly_detection/route.ts +++ b/x-pack/plugins/apm/server/routes/settings/anomaly_detection/route.ts @@ -11,15 +11,15 @@ import { maxSuggestions } from '../../../../../observability/common'; import { isActivePlatinumLicense } from '../../../../common/license_check'; import { ML_ERRORS } from '../../../../common/anomaly_detection'; import { createApmServerRoute } from '../../apm_routes/create_apm_server_route'; -import { getAnomalyDetectionJobs } from '../../../lib/anomaly_detection/get_anomaly_detection_jobs'; import { createAnomalyDetectionJobs } from '../../../lib/anomaly_detection/create_anomaly_detection_jobs'; import { setupRequest } from '../../../lib/helpers/setup_request'; import { getAllEnvironments } from '../../environments/get_all_environments'; -import { hasLegacyJobs } from '../../../lib/anomaly_detection/has_legacy_jobs'; import { getSearchAggregatedTransactions } from '../../../lib/helpers/transactions'; import { notifyFeatureUsage } from '../../../feature'; -import { withApmSpan } from '../../../utils/with_apm_span'; import { createApmServerRouteRepository } from '../../apm_routes/create_apm_server_route_repository'; +import { updateToV3 } from './update_to_v3'; +import { environmentStringRt } from '../../../../common/environment_rt'; +import { getMlJobsWithAPMGroup } from '../../../lib/anomaly_detection/get_ml_jobs_with_apm_group'; // get ML anomaly detection jobs for each environment const anomalyDetectionJobsRoute = createApmServerRoute({ @@ -29,22 +29,21 @@ const anomalyDetectionJobsRoute = createApmServerRoute({ }, handler: async (resources) => { const setup = await setupRequest(resources); - const { context, logger } = resources; + const { context } = resources; if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(ML_ERRORS.INVALID_LICENSE); } - const [jobs, legacyJobs] = await withApmSpan('get_available_ml_jobs', () => - Promise.all([ - getAnomalyDetectionJobs(setup, logger), - hasLegacyJobs(setup), - ]) - ); + if (!setup.ml) { + throw Boom.forbidden(ML_ERRORS.ML_NOT_AVAILABLE); + } + + const jobs = await getMlJobsWithAPMGroup(setup.ml?.anomalyDetectors); return { jobs, - hasLegacyJobs: legacyJobs, + hasLegacyJobs: jobs.some((job) => job.version === 1), }; }, }); @@ -57,7 +56,7 @@ const createAnomalyDetectionJobsRoute = createApmServerRoute({ }, params: t.type({ body: t.type({ - environments: t.array(t.string), + environments: t.array(environmentStringRt), }), }), handler: async (resources) => { @@ -107,7 +106,35 @@ const anomalyDetectionEnvironmentsRoute = createApmServerRoute({ }, }); +const anomalyDetectionUpdateToV3Route = createApmServerRoute({ + endpoint: 'POST /internal/apm/settings/anomaly-detection/update_to_v3', + options: { + tags: [ + 'access:apm', + 'access:apm_write', + 'access:ml:canCreateJob', + 'access:ml:canGetJobs', + 'access:ml:canCloseJob', + ], + }, + handler: async (resources) => { + const [setup, esClient] = await Promise.all([ + setupRequest(resources), + resources.core + .start() + .then((start) => start.elasticsearch.client.asInternalUser), + ]); + + const { logger } = resources; + + return { + update: await updateToV3({ setup, logger, esClient }), + }; + }, +}); + export const anomalyDetectionRouteRepository = createApmServerRouteRepository() .add(anomalyDetectionJobsRoute) .add(createAnomalyDetectionJobsRoute) - .add(anomalyDetectionEnvironmentsRoute); + .add(anomalyDetectionEnvironmentsRoute) + .add(anomalyDetectionUpdateToV3Route); diff --git a/x-pack/plugins/apm/server/routes/settings/anomaly_detection/update_to_v3.ts b/x-pack/plugins/apm/server/routes/settings/anomaly_detection/update_to_v3.ts new file mode 100644 index 0000000000000..b23a28648482e --- /dev/null +++ b/x-pack/plugins/apm/server/routes/settings/anomaly_detection/update_to_v3.ts @@ -0,0 +1,60 @@ +/* + * 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 { Logger } from '@kbn/logging'; +import { uniq } from 'lodash'; +import pLimit from 'p-limit'; +import { ElasticsearchClient } from '../../../../../../../src/core/server'; +import { JOB_STATE } from '../../../../../ml/common'; +import { createAnomalyDetectionJobs } from '../../../lib/anomaly_detection/create_anomaly_detection_jobs'; +import { getAnomalyDetectionJobs } from '../../../lib/anomaly_detection/get_anomaly_detection_jobs'; +import { Setup } from '../../../lib/helpers/setup_request'; +import { withApmSpan } from '../../../utils/with_apm_span'; + +export async function updateToV3({ + logger, + setup, + esClient, +}: { + logger: Logger; + setup: Setup; + esClient: ElasticsearchClient; +}) { + const allJobs = await getAnomalyDetectionJobs(setup); + + const v2Jobs = allJobs.filter((job) => job.version === 2); + + const activeV2Jobs = v2Jobs.filter( + (job) => + job.jobState === JOB_STATE.OPENED || job.jobState === JOB_STATE.OPENING + ); + + const environments = uniq(v2Jobs.map((job) => job.environment)); + + const limiter = pLimit(3); + + if (!v2Jobs.length) { + return true; + } + + if (activeV2Jobs.length) { + await withApmSpan('anomaly_detection_stop_v2_jobs', () => + Promise.all( + activeV2Jobs.map((job) => + limiter(() => { + return esClient.ml.closeJob({ + job_id: job.jobId, + }); + }) + ) + ) + ); + } + + await createAnomalyDetectionJobs(setup, environments, logger); + + return true; +} diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index 5b6d44ab9295d..e75c033f55ba9 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -34,6 +34,7 @@ export interface APMRouteCreateOptions { | 'access:apm_write' | 'access:ml:canGetJobs' | 'access:ml:canCreateJob' + | 'access:ml:canCloseJob' >; body?: { accepts: Array<'application/json' | 'multipart/form-data'> }; disableTelemetry?: boolean; diff --git a/x-pack/plugins/ml/common/index.ts b/x-pack/plugins/ml/common/index.ts index ea8ad43d6bb3b..c76b662df7a5a 100644 --- a/x-pack/plugins/ml/common/index.ts +++ b/x-pack/plugins/ml/common/index.ts @@ -15,3 +15,4 @@ export { isRuntimeMappings, isRuntimeField } from './util/runtime_field_utils'; export { extractErrorMessage } from './util/errors'; export type { RuntimeMappings } from './types/fields'; export { getDefaultCapabilities as getDefaultMlCapabilities } from './types/capabilities'; +export { DATAFEED_STATE, JOB_STATE } from './constants/states'; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index bfedff819e6f3..0df0153b7197a 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5959,7 +5959,6 @@ "xpack.apm.serviceIcons.serviceDetails.service.frameworkLabel": "フレームワーク名", "xpack.apm.serviceIcons.serviceDetails.service.runtimeLabel": "ランタイム名・バージョン", "xpack.apm.serviceIcons.serviceDetails.service.versionLabel": "サービスバージョン", - "xpack.apm.serviceInventory.mlNudgeMessageTitle": "異常検知を有効にして、正常性ステータスインジケーターをサービスに追加します", "xpack.apm.serviceInventory.toastText": "現在 Elastic Stack 7.0+ を実行中で、以前のバージョン 6.x からの互換性のないデータを検知しました。このデータを APM で表示するには、移行が必要です。詳細 ", "xpack.apm.serviceInventory.toastTitle": "選択された時間範囲内にレガシーデータが検知されました。", "xpack.apm.serviceInventory.upgradeAssistantLinkText": "アップグレードアシスタント", @@ -6044,9 +6043,6 @@ "xpack.apm.serviceOverview.latencyColumnP95Label": "レイテンシ(95 番目)", "xpack.apm.serviceOverview.latencyColumnP99Label": "レイテンシ(99 番目)", "xpack.apm.serviceOverview.loadingText": "読み込み中…", - "xpack.apm.serviceOverview.mlNudgeMessage.content": "APM の異常検知統合で、異常なトランザクションを特定し、アップストリームおよびダウンストリームサービスの正常性を確認します。わずか数分で開始できます。", - "xpack.apm.serviceOverview.mlNudgeMessage.dismissButton": "閉じる", - "xpack.apm.serviceOverview.mlNudgeMessage.learnMoreButton": "使ってみる", "xpack.apm.serviceOverview.noResultsText": "インスタンスが見つかりません", "xpack.apm.serviceOverview.throughtputChart.previousPeriodLabel": "前の期間", "xpack.apm.serviceOverview.throughtputChartTitle": "スループット", @@ -6076,9 +6072,7 @@ "xpack.apm.settings.agentConfig": "エージェントの編集", "xpack.apm.settings.agentConfig.createConfigButton.tooltip": "エージェント構成を作成する権限がありません", "xpack.apm.settings.agentConfig.descriptionText": "APMアプリ内からエージェント構成を微調整してください。変更はAPMエージェントに自動的に伝達されるので、再デプロイする必要はありません。", - "xpack.apm.settings.anomaly_detection.legacy_jobs.body": "以前の統合のレガシー機械学習ジョブが見つかりました。これは、APMアプリでは使用されていません。", "xpack.apm.settings.anomaly_detection.legacy_jobs.button": "ジョブの確認", - "xpack.apm.settings.anomaly_detection.legacy_jobs.title": "レガシーMLジョブはAPMアプリで使用されていません。", "xpack.apm.settings.anomalyDetection": "異常検知", "xpack.apm.settings.anomalyDetection.addEnvironments.cancelButtonText": "キャンセル", "xpack.apm.settings.anomalyDetection.addEnvironments.createJobsButtonText": "ジョブの作成", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 2b71c0a8e52e0..301c264b34736 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6000,7 +6000,6 @@ "xpack.apm.serviceIcons.serviceDetails.service.frameworkLabel": "框架名称", "xpack.apm.serviceIcons.serviceDetails.service.runtimeLabel": "运行时名称和版本", "xpack.apm.serviceIcons.serviceDetails.service.versionLabel": "服务版本", - "xpack.apm.serviceInventory.mlNudgeMessageTitle": "启用异常检测以将运行状态指示器添加到您的服务中", "xpack.apm.serviceInventory.toastText": "您正在运行 Elastic Stack 7.0+,我们检测到来自以前 6.x 版本的数据不兼容。如果想在 APM 中查看此数据,您应迁移数据。在以下位置查看更多内容: ", "xpack.apm.serviceInventory.toastTitle": "在选定时间范围中检测到旧数据", "xpack.apm.serviceInventory.upgradeAssistantLinkText": "升级助手", @@ -6084,9 +6083,6 @@ "xpack.apm.serviceOverview.latencyColumnP95Label": "延迟(第 95 个)", "xpack.apm.serviceOverview.latencyColumnP99Label": "延迟(第 99 个)", "xpack.apm.serviceOverview.loadingText": "正在加载……", - "xpack.apm.serviceOverview.mlNudgeMessage.content": "通过 APM 的异常检测集成来查明异常事务,并了解上下游服务的运行状况。只需几分钟即可开始使用。", - "xpack.apm.serviceOverview.mlNudgeMessage.dismissButton": "关闭", - "xpack.apm.serviceOverview.mlNudgeMessage.learnMoreButton": "开始使用", "xpack.apm.serviceOverview.noResultsText": "未找到实例", "xpack.apm.serviceOverview.throughtputChart.previousPeriodLabel": "上一时段", "xpack.apm.serviceOverview.throughtputChartTitle": "吞吐量", @@ -6117,9 +6113,7 @@ "xpack.apm.settings.agentConfig": "代理配置", "xpack.apm.settings.agentConfig.createConfigButton.tooltip": "您无权创建代理配置", "xpack.apm.settings.agentConfig.descriptionText": "从 APM 应用中微调您的代理配置。更改将自动传播到 APM 代理,因此无需重新部署。", - "xpack.apm.settings.anomaly_detection.legacy_jobs.body": "我们在以前的集成中发现 APM 应用中不再使用的旧版 Machine Learning 作业", "xpack.apm.settings.anomaly_detection.legacy_jobs.button": "复查作业", - "xpack.apm.settings.anomaly_detection.legacy_jobs.title": "旧版 ML 作业不再用于 APM 应用", "xpack.apm.settings.anomalyDetection": "异常检测", "xpack.apm.settings.anomalyDetection.addEnvironments.cancelButtonText": "取消", "xpack.apm.settings.anomalyDetection.addEnvironments.createJobsButtonText": "创建作业", diff --git a/x-pack/test/apm_api_integration/tests/settings/anomaly_detection/update_to_v3.spec.ts b/x-pack/test/apm_api_integration/tests/settings/anomaly_detection/update_to_v3.spec.ts new file mode 100644 index 0000000000000..0a2ea7fd6efe0 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/settings/anomaly_detection/update_to_v3.spec.ts @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +export default function apiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + const apmApiClient = getService('apmApiClient'); + const ml = getService('ml'); + + const es = getService('es'); + + function getJobs() { + return apmApiClient.writeUser({ + endpoint: `GET /internal/apm/settings/anomaly-detection/jobs`, + }); + } + + function createJobs(environments: string[]) { + return apmApiClient.writeUser({ + endpoint: `POST /internal/apm/settings/anomaly-detection/jobs`, + params: { + body: { environments }, + }, + }); + } + + async function createV2Jobs(environments: string[]) { + await createJobs(environments); + + const { body } = await getJobs(); + + for (const mlJob of body.jobs) { + await es.ml.updateJob({ + job_id: mlJob.jobId, + custom_settings: { + job_tags: { + apm_ml_version: '2', + environment: mlJob.environment, + }, + }, + }); + } + } + + async function createV3Jobs(environments: string[]) { + await createJobs(environments); + } + + function callUpdateEndpoint() { + return apmApiClient.writeUser({ + endpoint: 'POST /internal/apm/settings/anomaly-detection/update_to_v3', + }); + } + + registry.when('Updating ML jobs to v3', { config: 'trial', archives: [] }, () => { + describe('when there are no v2 jobs', () => { + it('returns a 200/true', async () => { + const { status, body } = await callUpdateEndpoint(); + expect(status).to.eql(200); + expect(body.update).to.eql(true); + }); + }); + + describe('when there are only v2 jobs', () => { + before(async () => { + await createV2Jobs(['production', 'development']); + }); + it('creates a new job for each environment that has a v2 job', async () => { + await callUpdateEndpoint(); + + const { + body: { jobs }, + } = await getJobs(); + + expect( + jobs + .filter((job) => job.version === 3) + .map((job) => job.environment) + .sort() + ).to.eql(['development', 'production']); + }); + + after(() => ml.cleanMlIndices()); + }); + + describe('when there are both v2 and v3 jobs', () => { + before(async () => { + await createV2Jobs(['production', 'development']); + }); + + before(async () => { + await createV3Jobs(['production']); + }); + + after(() => ml.cleanMlIndices()); + + it('only creates new jobs for environments that did not have a v3 job', async () => { + await callUpdateEndpoint(); + + const { + body: { jobs }, + } = await getJobs(); + + expect( + jobs + .filter((job) => job.version === 3) + .map((job) => job.environment) + .sort() + ).to.eql(['development', 'production']); + }); + }); + }); +}