From 2ec4ec362d21fe03d04d53181fa0a8f7da888eb5 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Thu, 23 May 2024 09:02:23 -0600 Subject: [PATCH] [ML] Anomaly Detection: Single Metric Viewer - add cases action (#183423) ## Summary Related meta issue https://github.com/elastic/kibana/issues/181272 This PR adds the 'Add to case' action in the Single Metric Viewer ### Checklist Delete any items that are not applicable to this PR. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Dima Arnautov --- x-pack/plugins/ml/common/constants/cases.ts | 1 + .../timeseriesexplorer_controls.tsx | 28 ++ .../timeseriesexplorer_embeddable_chart.js | 4 + .../timeseriesexplorer_page.tsx | 8 +- .../cases/register_cases_attachments.ts | 11 +- ...gister_single_metric_viewer_attachment.tsx | 54 ++++ .../cases/single_metric_viewer_attachment.tsx | 94 +++++++ .../ml/public/embeddables/constants.ts | 5 +- .../single_metric_viewer/get_services.ts | 54 ++-- .../single_metric_viewer_data_fetcher.ts | 8 +- ...ingle_metric_viewer_embeddable_factory.tsx | 204 ++------------ .../single_metric_viewer_setup_flyout.tsx | 7 +- x-pack/plugins/ml/public/plugin.ts | 4 +- .../single_metric_viewer/_index.scss | 0 .../single_metric_viewer/index.tsx | 34 +++ .../single_metric_viewer.tsx | 254 ++++++++++++++++++ .../plugins/ml/server/lib/register_cases.ts | 11 + x-pack/plugins/ml/tsconfig.json | 1 + .../registered_persistable_state_trial.ts | 1 + 19 files changed, 562 insertions(+), 221 deletions(-) create mode 100644 x-pack/plugins/ml/public/cases/register_single_metric_viewer_attachment.tsx create mode 100644 x-pack/plugins/ml/public/cases/single_metric_viewer_attachment.tsx rename x-pack/plugins/ml/public/{embeddables => shared_components}/single_metric_viewer/_index.scss (100%) create mode 100644 x-pack/plugins/ml/public/shared_components/single_metric_viewer/index.tsx create mode 100644 x-pack/plugins/ml/public/shared_components/single_metric_viewer/single_metric_viewer.tsx diff --git a/x-pack/plugins/ml/common/constants/cases.ts b/x-pack/plugins/ml/common/constants/cases.ts index ba3b5b913d1b8..c8089ebaf5dbd 100644 --- a/x-pack/plugins/ml/common/constants/cases.ts +++ b/x-pack/plugins/ml/common/constants/cases.ts @@ -7,3 +7,4 @@ export const CASE_ATTACHMENT_TYPE_ID_ANOMALY_SWIMLANE = 'ml_anomaly_swimlane' as const; export const CASE_ATTACHMENT_TYPE_ID_ANOMALY_EXPLORER_CHARTS = 'ml_anomaly_charts' as const; +export const CASE_ATTACHMENT_TYPE_ID_SINGLE_METRIC_VIEWER = 'ml_single_metric_viewer' as const; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseriesexplorer_controls/timeseriesexplorer_controls.tsx b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseriesexplorer_controls/timeseriesexplorer_controls.tsx index 78c4add1026b7..618d348aa0398 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseriesexplorer_controls/timeseriesexplorer_controls.tsx +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseriesexplorer_controls/timeseriesexplorer_controls.tsx @@ -24,8 +24,10 @@ import { LazySavedObjectSaveModalDashboard, withSuspense, } from '@kbn/presentation-util-plugin/public'; +import { useTimeRangeUpdates } from '@kbn/ml-date-picker'; import type { JobId } from '../../../../../common/types/anomaly_detection_jobs/job'; import { useMlKibana } from '../../../contexts/kibana'; +import { useCasesModal } from '../../../contexts/kibana/use_cases_modal'; import { getDefaultSingleMetricViewerPanelTitle } from '../../../../embeddables/single_metric_viewer/get_default_panel_title'; import type { MlEntity } from '../../../../embeddables'; import { ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE } from '../../../../embeddables/constants'; @@ -77,10 +79,13 @@ export const TimeSeriesExplorerControls: FC = ({ const { services: { application: { capabilities }, + cases, embeddable, }, } = useMlKibana(); + const globalTimeRange = useTimeRangeUpdates(true); + const canEditDashboards = capabilities.dashboard?.createNew ?? false; const closePopoverOnAction = useCallback( @@ -93,6 +98,8 @@ export const TimeSeriesExplorerControls: FC = ({ [setIsMenuOpen] ); + const openCasesModalCallback = useCasesModal(ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE); + const menuPanels: EuiContextMenuPanelDescriptor[] = [ { id: 0, @@ -112,6 +119,27 @@ export const TimeSeriesExplorerControls: FC = ({ }, ]; + const casesPrivileges = cases?.helpers.canUseCases(); + + if (!!casesPrivileges?.create || !!casesPrivileges?.update) { + menuPanels[0].items!.push({ + name: ( + + ), + onClick: closePopoverOnAction(() => { + openCasesModalCallback({ + jobIds: [selectedJobId], + selectedDetectorIndex, + selectedEntities, + timeRange: globalTimeRange, + }); + }), + }); + } + const onSaveCallback: SaveModalDashboardProps['onSave'] = useCallback( ({ dashboardId, newTitle, newDescription }) => { const stateTransfer = embeddable!.getStateTransfer(); diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_embeddable_chart/timeseriesexplorer_embeddable_chart.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_embeddable_chart/timeseriesexplorer_embeddable_chart.js index 42e942ae907fc..4c09d4fe4adb8 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_embeddable_chart/timeseriesexplorer_embeddable_chart.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_embeddable_chart/timeseriesexplorer_embeddable_chart.js @@ -79,6 +79,7 @@ export class TimeSeriesExplorerEmbeddableChart extends React.Component { bounds: PropTypes.object.isRequired, chartWidth: PropTypes.number.isRequired, lastRefresh: PropTypes.number.isRequired, + onRenderComplete: PropTypes.func, previousRefresh: PropTypes.number.isRequired, selectedJobId: PropTypes.string.isRequired, selectedDetectorIndex: PropTypes.number, @@ -434,6 +435,9 @@ export class TimeSeriesExplorerEmbeddableChart extends React.Component { } this.setState(stateUpdate); + if (this.props.onRenderComplete !== undefined) { + this.props.onRenderComplete(); + } } }; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_page.tsx b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_page.tsx index d09bfa3edb533..982884911e582 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_page.tsx +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_page.tsx @@ -33,9 +33,11 @@ export const TimeSeriesExplorerPage: FC { const { - services: { presentationUtil, docLinks }, + services: { cases, docLinks, presentationUtil }, } = useMlKibana(); const PresentationContextProvider = presentationUtil?.ContextProvider ?? React.Fragment; + const CasesContext = cases?.ui.getCasesContext() ?? React.Fragment; + const casesPermissions = cases?.helpers.canUseCases(); const helpLink = docLinks.links.ml.anomalyDetection; return ( <> @@ -62,7 +64,9 @@ export const TimeSeriesExplorerPage: FC )} - {children} + + {children} + diff --git a/x-pack/plugins/ml/public/cases/register_cases_attachments.ts b/x-pack/plugins/ml/public/cases/register_cases_attachments.ts index 6b97fd84962a6..2625affdec331 100644 --- a/x-pack/plugins/ml/public/cases/register_cases_attachments.ts +++ b/x-pack/plugins/ml/public/cases/register_cases_attachments.ts @@ -8,14 +8,23 @@ import type { CasesPublicSetup } from '@kbn/cases-plugin/public'; import type { CoreStart } from '@kbn/core/public'; import { registerAnomalyChartsCasesAttachment } from './register_anomaly_charts_attachment'; +import { registerSingleMetricViewerCasesAttachment } from './register_single_metric_viewer_attachment'; import type { MlStartDependencies } from '../plugin'; +import type { SingleMetricViewerServices } from '../embeddables/types'; import { registerAnomalySwimLaneCasesAttachment } from './register_anomaly_swim_lane_attachment'; export function registerCasesAttachments( cases: CasesPublicSetup, coreStart: CoreStart, - pluginStart: MlStartDependencies + pluginStart: MlStartDependencies, + singleMetricViewerServices: SingleMetricViewerServices ) { registerAnomalySwimLaneCasesAttachment(cases, pluginStart); registerAnomalyChartsCasesAttachment(cases, coreStart, pluginStart); + registerSingleMetricViewerCasesAttachment( + cases, + coreStart, + pluginStart, + singleMetricViewerServices + ); } diff --git a/x-pack/plugins/ml/public/cases/register_single_metric_viewer_attachment.tsx b/x-pack/plugins/ml/public/cases/register_single_metric_viewer_attachment.tsx new file mode 100644 index 0000000000000..8ef4c0bc3813c --- /dev/null +++ b/x-pack/plugins/ml/public/cases/register_single_metric_viewer_attachment.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { CasesPublicSetup } from '@kbn/cases-plugin/public'; +import { i18n } from '@kbn/i18n'; +import type { CoreStart } from '@kbn/core/public'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React from 'react'; +import { PLUGIN_ICON } from '../../common/constants/app'; +import { CASE_ATTACHMENT_TYPE_ID_SINGLE_METRIC_VIEWER } from '../../common/constants/cases'; +import type { MlStartDependencies } from '../plugin'; +import { getSingleMetricViewerComponent } from '../shared_components/single_metric_viewer'; +import type { SingleMetricViewerServices } from '../embeddables/types'; +import type { MlDependencies } from '../application/app'; + +export function registerSingleMetricViewerCasesAttachment( + cases: CasesPublicSetup, + coreStart: CoreStart, + pluginStart: MlStartDependencies, + mlServices: SingleMetricViewerServices +) { + const SingleMetricViewerComponent = getSingleMetricViewerComponent( + coreStart, + pluginStart as MlDependencies, + mlServices + ); + + cases.attachmentFramework.registerPersistableState({ + id: CASE_ATTACHMENT_TYPE_ID_SINGLE_METRIC_VIEWER, + icon: PLUGIN_ICON, + displayName: i18n.translate('xpack.ml.cases.registerSingleMetricViewer.displayName', { + defaultMessage: 'Single metric viewer', + }), + getAttachmentViewObject: () => ({ + event: ( + + ), + timelineAvatar: PLUGIN_ICON, + children: React.lazy(async () => { + const { initComponent } = await import('./single_metric_viewer_attachment'); + return { + default: initComponent(pluginStart.fieldFormats, SingleMetricViewerComponent), + }; + }), + }), + }); +} diff --git a/x-pack/plugins/ml/public/cases/single_metric_viewer_attachment.tsx b/x-pack/plugins/ml/public/cases/single_metric_viewer_attachment.tsx new file mode 100644 index 0000000000000..d3b8e82616981 --- /dev/null +++ b/x-pack/plugins/ml/public/cases/single_metric_viewer_attachment.tsx @@ -0,0 +1,94 @@ +/* + * 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 { EuiDescriptionList } from '@elastic/eui'; +import type { PersistableStateAttachmentViewProps } from '@kbn/cases-plugin/public/client/attachment_framework/types'; +import moment from 'moment'; +import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; +import { FIELD_FORMAT_IDS } from '@kbn/field-formats-plugin/common'; +import { FormattedMessage } from '@kbn/i18n-react'; +import deepEqual from 'fast-deep-equal'; +import { memoize } from 'lodash'; +import React from 'react'; +import type { SingleMetricViewerEmbeddableState } from '../embeddables/types'; +import type { SingleMetricViewerSharedComponent } from '../shared_components/single_metric_viewer'; + +export const initComponent = memoize( + ( + fieldFormats: FieldFormatsStart, + SingleMetricViewerComponent: SingleMetricViewerSharedComponent + ) => { + return React.memo( + (props: PersistableStateAttachmentViewProps) => { + const { persistableStateAttachmentState, caseData } = props; + + const inputProps = + persistableStateAttachmentState as unknown as SingleMetricViewerEmbeddableState; + + const dataFormatter = fieldFormats.deserialize({ + id: FIELD_FORMAT_IDS.DATE, + }); + + const listItems = [ + { + title: ( + + ), + description: inputProps.jobIds.join(', '), + }, + { + title: ( + + ), + description: `${dataFormatter.convert( + inputProps.timeRange!.from + )} - ${dataFormatter.convert(inputProps.timeRange!.to)}`, + }, + ]; + + if (typeof inputProps.query?.query === 'string' && inputProps.query?.query !== '') { + listItems.push({ + title: ( + + ), + description: inputProps.query?.query, + }); + } + + const { jobIds, timeRange, ...rest } = inputProps; + const selectedJobId = jobIds[0]; + + return ( + <> + + + + ); + }, + (prevProps, nextProps) => + deepEqual( + prevProps.persistableStateAttachmentState, + nextProps.persistableStateAttachmentState + ) + ); + } +); diff --git a/x-pack/plugins/ml/public/embeddables/constants.ts b/x-pack/plugins/ml/public/embeddables/constants.ts index 1e42301676dfc..96abeb5c8d056 100644 --- a/x-pack/plugins/ml/public/embeddables/constants.ts +++ b/x-pack/plugins/ml/public/embeddables/constants.ts @@ -14,4 +14,7 @@ export type AnomalyExplorerChartsEmbeddableType = typeof ANOMALY_EXPLORER_CHARTS export type AnomalySingleMetricViewerEmbeddableType = typeof ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE; -export type MlEmbeddableTypes = AnomalySwimLaneEmbeddableType | AnomalyExplorerChartsEmbeddableType; +export type MlEmbeddableTypes = + | AnomalySwimLaneEmbeddableType + | AnomalyExplorerChartsEmbeddableType + | AnomalySingleMetricViewerEmbeddableType; diff --git a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/get_services.ts b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/get_services.ts index 5e8464f9f9ce5..8dab0f4b65ea0 100644 --- a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/get_services.ts +++ b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/get_services.ts @@ -6,20 +6,21 @@ */ import type { StartServicesAccessor } from '@kbn/core/public'; +import type { CoreStart } from '@kbn/core-lifecycle-browser'; import type { MlPluginStart, MlStartDependencies } from '../../plugin'; import type { MlDependencies } from '../../application/app'; import { HttpService } from '../../application/services/http_service'; import { AnomalyExplorerChartsService } from '../../application/services/anomaly_explorer_charts_service'; -import type { SingleMetricViewerEmbeddableServices } from '../types'; +import type { SingleMetricViewerEmbeddableServices, SingleMetricViewerServices } from '../types'; /** - * Provides the services required by the Anomaly Swimlane Embeddable. + * Provides the ML services required by the Single Metric Viewer Embeddable. */ -export const getServices = async ( - getStartServices: StartServicesAccessor -): Promise => { +export const getMlServices = async ( + coreStart: CoreStart, + pluginsStart: MlStartDependencies +): Promise => { const [ - [coreStart, pluginsStart], { AnomalyDetectorService }, { fieldFormatServiceFactory }, { indexServiceFactory }, @@ -31,7 +32,6 @@ export const getServices = async ( { timeSeriesSearchServiceFactory }, { toastNotificationServiceProvider }, ] = await Promise.all([ - await getStartServices(), await import('../../application/services/anomaly_detector_service'), await import('../../application/services/field_format_service_factory'), await import('../../application/util/index_service'), @@ -64,7 +64,6 @@ export const getServices = async ( mlApiServices, mlResultsService ); - // Note on the following services: // - `mlIndexUtils` is just instantiated here to be passed on to `mlFieldFormatService`, // but it's not being made available as part of global services. Since it's just @@ -76,21 +75,28 @@ export const getServices = async ( // way this manages its own state right now doesn't consider React component lifecycles. const mlIndexUtils = indexServiceFactory(pluginsStart.data.dataViews); const mlFieldFormatService = fieldFormatServiceFactory(mlApiServices, mlIndexUtils); + return { + anomalyDetectorService, + anomalyExplorerService, + mlApiServices, + mlCapabilities, + mlFieldFormatService, + mlJobService, + mlResultsService, + mlTimeSeriesSearchService, + mlTimeSeriesExplorerService, + toastNotificationService, + }; +}; + +/** + * Provides the services required by the Single Metric Viewer Embeddable. + */ +export const getServices = async ( + getStartServices: StartServicesAccessor +): Promise => { + const [coreStart, pluginsStart] = await getStartServices(); + const mlServices = await getMlServices(coreStart, pluginsStart); - return [ - coreStart, - pluginsStart as MlDependencies, - { - anomalyDetectorService, - anomalyExplorerService, - mlApiServices, - mlCapabilities, - mlFieldFormatService, - mlJobService, - mlResultsService, - mlTimeSeriesSearchService, - mlTimeSeriesExplorerService, - toastNotificationService, - }, - ]; + return [coreStart, pluginsStart as MlDependencies, mlServices]; }; diff --git a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_data_fetcher.ts b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_data_fetcher.ts index b3c46017e84e2..c3b43fd5dda86 100644 --- a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_data_fetcher.ts +++ b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_data_fetcher.ts @@ -52,8 +52,12 @@ export const initializeSingleMetricViewerDataFetcher = ( ([singleMetricViewerData, fetchContext]) => { let bounds; let lastRefresh; - if (timefilter !== undefined && fetchContext.timeRange !== undefined) { - bounds = timefilter.calculateBounds(fetchContext.timeRange); + if (timefilter !== undefined) { + bounds = timefilter.calculateBounds( + fetchContext?.timeRange + ? fetchContext?.timeRange + : api.timeRange$?.value ?? timefilter.getTime() + ); lastRefresh = Date.now(); } singleMetricViewerData$.next({ singleMetricViewerData, bounds, lastRefresh }); diff --git a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_embeddable_factory.tsx b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_embeddable_factory.tsx index 25ea1ea0f98d3..3fa630d7194e9 100644 --- a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_embeddable_factory.tsx +++ b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_embeddable_factory.tsx @@ -6,27 +6,17 @@ */ import type { StartServicesAccessor } from '@kbn/core/public'; -import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; -import { pick } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import type { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public'; -import { EuiResizeObserver } from '@elastic/eui'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React from 'react'; import useUnmount from 'react-use/lib/useUnmount'; -import moment from 'moment'; import { apiHasExecutionContext, initializeTimeRange, initializeTitles, useStateFromPublishingSubject, } from '@kbn/presentation-publishing'; -import { DatePickerContextProvider, type DatePickerDependencies } from '@kbn/ml-date-picker'; -import { UI_SETTINGS } from '@kbn/data-plugin/common'; -import type { MlJob } from '@elastic/elasticsearch/lib/api/types'; -import usePrevious from 'react-use/lib/usePrevious'; -import { throttle } from 'lodash'; import { BehaviorSubject, Subscription } from 'rxjs'; import { ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE } from '..'; import type { MlPluginStart, MlStartDependencies } from '../../plugin'; @@ -37,23 +27,9 @@ import type { } from '../types'; import { initializeSingleMetricViewerControls } from './single_metric_viewer_controls_initializer'; import { initializeSingleMetricViewerDataFetcher } from './single_metric_viewer_data_fetcher'; -import { TimeSeriesExplorerEmbeddableChart } from '../../application/timeseriesexplorer/timeseriesexplorer_embeddable_chart'; -import { APP_STATE_ACTION } from '../../application/timeseriesexplorer/timeseriesexplorer_constants'; import { getServices } from './get_services'; import { useReactEmbeddableExecutionContext } from '../common/use_embeddable_execution_context'; -import './_index.scss'; - -const RESIZE_THROTTLE_TIME_MS = 500; -const containerPadding = 10; -const minElemAndChartDiff = 20; -interface AppStateZoom { - from?: string; - to?: string; -} - -const errorMessage = i18n.translate('xpack.ml.singleMetricViewerEmbeddable.errorMessage"', { - defaultMessage: 'Unable to load the ML single metric viewer data', -}); +import { getSingleMetricViewerComponent } from '../../shared_components/single_metric_viewer'; export const getSingleMetricViewerEmbeddableFactory = ( getStartServices: StartServicesAccessor @@ -147,31 +123,11 @@ export const getSingleMetricViewerEmbeddableFactory = ( services[1].data.query.timefilter.timefilter ); + const SingleMetricViewerComponent = getSingleMetricViewerComponent(...services); + return { api, Component: () => { - const [chartWidth, setChartWidth] = useState(0); - const [zoom, setZoom] = useState(); - const [selectedForecastId, setSelectedForecastId] = useState(); - const [selectedJob, setSelectedJob] = useState(); - const [jobsLoaded, setJobsLoaded] = useState(false); - - const isMounted = useRef(true); - - const { - mlApiServices, - mlJobService, - mlTimeSeriesExplorerService, - toastNotificationService, - } = services[2]; - const startServices = pick(services[0], 'analytics', 'i18n', 'theme'); - const datePickerDeps: DatePickerDependencies = { - ...pick(services[0], ['http', 'notifications', 'theme', 'uiSettings', 'i18n']), - data: services[1].data, - uiSettingsKeys: UI_SETTINGS, - showFrozenDataTierChoice: false, - }; - if (!apiHasExecutionContext(parentApi)) { throw new Error('Parent API does not have execution context'); } @@ -192,153 +148,27 @@ export const getSingleMetricViewerEmbeddableFactory = ( subscriptions.unsubscribe(); }); - const selectedJobId = singleMetricViewerData?.jobIds[0]; // Need to make sure we fall back to `undefined` if `functionDescription` is an empty string, // otherwise anomaly table data will not be loaded. const functionDescription = (singleMetricViewerData?.functionDescription ?? '') === '' ? undefined : singleMetricViewerData?.functionDescription; - const previousRefresh = usePrevious(lastRefresh ?? 0); - - useEffect(function setUpJobsLoaded() { - async function loadJobs() { - try { - await mlJobService.loadJobsWrapper(); - setJobsLoaded(true); - } catch (e) { - blockingError.next(new Error(errorMessage)); - } - } - if (isMounted.current === false) { - return; - } - loadJobs(); - - return () => { - isMounted.current = false; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect( - function setUpSelectedJob() { - async function fetchSelectedJob() { - if (mlApiServices && selectedJobId !== undefined) { - try { - const { jobs } = await mlApiServices.getJobs({ jobId: selectedJobId }); - const job = jobs[0]; - setSelectedJob(job); - } catch (e) { - blockingError.next(new Error(errorMessage)); - } - } - } - if (isMounted.current === false) { - return; - } - fetchSelectedJob(); - }, - [selectedJobId, mlApiServices] - ); - - const autoZoomDuration = useMemo(() => { - if (!selectedJob) return; - return mlTimeSeriesExplorerService?.getAutoZoomDuration(selectedJob); - }, [mlTimeSeriesExplorerService, selectedJob]); - - // eslint-disable-next-line react-hooks/exhaustive-deps - const resizeHandler = useCallback( - throttle((e: { width: number; height: number }) => { - if (Math.abs(chartWidth - e.width) > minElemAndChartDiff) { - setChartWidth(e.width); - } - }, RESIZE_THROTTLE_TIME_MS), - [chartWidth] - ); - - const appStateHandler = useCallback( - (action: string, payload?: any) => { - /** - * Empty zoom indicates that chart hasn't been rendered yet, - * hence any updates prior that should replace the URL state. - */ - switch (action) { - case APP_STATE_ACTION.SET_FORECAST_ID: - setSelectedForecastId(payload); - setZoom(undefined); - break; - - case APP_STATE_ACTION.SET_ZOOM: - setZoom(payload); - break; - - case APP_STATE_ACTION.UNSET_ZOOM: - setZoom(undefined); - break; - } - }, - - [setZoom, setSelectedForecastId] - ); return ( - - - - - {(resizeRef) => ( -
- {singleMetricViewerData !== undefined && - autoZoomDuration !== undefined && - jobsLoaded && - selectedJobId === selectedJob?.job_id && ( - - )} -
- )} -
-
-
-
+ blockingError.next(error)} + selectedDetectorIndex={singleMetricViewerData?.selectedDetectorIndex} + selectedEntities={singleMetricViewerData?.selectedEntities} + selectedJobId={singleMetricViewerData?.jobIds[0]} + uuid={api.uuid} + onRenderComplete={() => { + dataLoading.next(false); + }} + /> ); }, }; diff --git a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_setup_flyout.tsx b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_setup_flyout.tsx index 1f54330a4829f..292ec390eb974 100644 --- a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_setup_flyout.tsx +++ b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_setup_flyout.tsx @@ -47,13 +47,13 @@ export async function resolveEmbeddableSingleMetricViewerUserInput( bounds={timefilter.getBounds()!} initialInput={input} onCreate={(explicitInput) => { - flyoutSession.close(); resolve(explicitInput); + flyoutSession.close(); overlayTracker?.clearOverlays(); }} onCancel={() => { - flyoutSession.close(); reject(); + flyoutSession.close(); overlayTracker?.clearOverlays(); }} /> @@ -65,8 +65,9 @@ export async function resolveEmbeddableSingleMetricViewerUserInput( ownFocus: true, size: 's', onClose: () => { - flyoutSession.close(); reject(); + flyoutSession.close(); + overlayTracker?.clearOverlays(); }, } ); diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index d762bb9557268..81c73c7cf9679 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -73,6 +73,7 @@ import type { ElasticModels } from './application/services/elastic_models_servic import type { MlApiServices } from './application/services/ml_api_service'; import type { MlCapabilities } from '../common/types/capabilities'; import { AnomalySwimLane } from './shared_components'; +import { getMlServices } from './embeddables/single_metric_viewer/get_services'; export interface MlStartDependencies { cases?: CasesPublicStart; @@ -272,7 +273,8 @@ export class MlPlugin implements Plugin { registerEmbeddables(pluginsSetup.embeddable, core); if (pluginsSetup.cases) { - registerCasesAttachments(pluginsSetup.cases, coreStart, pluginStart); + const mlServices = await getMlServices(coreStart, pluginStart); + registerCasesAttachments(pluginsSetup.cases, coreStart, pluginStart, mlServices); } if (pluginsSetup.maps) { diff --git a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/_index.scss b/x-pack/plugins/ml/public/shared_components/single_metric_viewer/_index.scss similarity index 100% rename from x-pack/plugins/ml/public/embeddables/single_metric_viewer/_index.scss rename to x-pack/plugins/ml/public/shared_components/single_metric_viewer/_index.scss diff --git a/x-pack/plugins/ml/public/shared_components/single_metric_viewer/index.tsx b/x-pack/plugins/ml/public/shared_components/single_metric_viewer/index.tsx new file mode 100644 index 0000000000000..76d55dfefb1cc --- /dev/null +++ b/x-pack/plugins/ml/public/shared_components/single_metric_viewer/index.tsx @@ -0,0 +1,34 @@ +/* + * 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 { dynamic } from '@kbn/shared-ux-utility'; +import type { CoreStart } from '@kbn/core-lifecycle-browser'; +import type { SingleMetricViewerServices } from '../../embeddables/types'; +import type { MlDependencies } from '../../application/app'; +import type { SingleMetricViewerSharedComponent } from './single_metric_viewer'; + +const SingleMetricViewerLazy = dynamic(async () => import('./single_metric_viewer')); + +export const getSingleMetricViewerComponent = ( + coreStart: CoreStart, + pluginStart: MlDependencies, + mlServices: SingleMetricViewerServices +): SingleMetricViewerSharedComponent => { + return (props) => { + return ( + + ); + }; +}; + +export type { SingleMetricViewerSharedComponent } from './single_metric_viewer'; diff --git a/x-pack/plugins/ml/public/shared_components/single_metric_viewer/single_metric_viewer.tsx b/x-pack/plugins/ml/public/shared_components/single_metric_viewer/single_metric_viewer.tsx new file mode 100644 index 0000000000000..a4c6681b45ef0 --- /dev/null +++ b/x-pack/plugins/ml/public/shared_components/single_metric_viewer/single_metric_viewer.tsx @@ -0,0 +1,254 @@ +/* + * 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 { useCallback, useEffect, useMemo, useState } from 'react'; +import useMountedState from 'react-use/lib/useMountedState'; +import type { FC } from 'react'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiResizeObserver } from '@elastic/eui'; +import type { CoreStart } from '@kbn/core-lifecycle-browser'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; +import { UI_SETTINGS } from '@kbn/data-plugin/common'; +import type { MlJob } from '@elastic/elasticsearch/lib/api/types'; +import { DatePickerContextProvider, type DatePickerDependencies } from '@kbn/ml-date-picker'; +import type { TimeRangeBounds } from '@kbn/ml-time-buckets'; +import usePrevious from 'react-use/lib/usePrevious'; +import { tz } from 'moment'; +import { pick, throttle } from 'lodash'; +import type { MlDependencies } from '../../application/app'; +import { TimeSeriesExplorerEmbeddableChart } from '../../application/timeseriesexplorer/timeseriesexplorer_embeddable_chart'; +import { APP_STATE_ACTION } from '../../application/timeseriesexplorer/timeseriesexplorer_constants'; +import type { SingleMetricViewerServices, MlEntity } from '../../embeddables/types'; +import './_index.scss'; + +const containerPadding = 10; +const minElemAndChartDiff = 20; +const RESIZE_THROTTLE_TIME_MS = 500; +interface AppStateZoom { + from?: string; + to?: string; +} + +const errorMessage = i18n.translate('xpack.ml.singleMetricViewerEmbeddable.errorMessage"', { + defaultMessage: 'Unable to load the ML single metric viewer data', +}); + +export type SingleMetricViewerSharedComponent = FC; + +/** + * Only used to initialize internally + */ +export type SingleMetricViewerPropsWithDeps = SingleMetricViewerProps & { + coreStart: CoreStart; + pluginStart: MlDependencies; + mlServices: SingleMetricViewerServices; +}; + +export interface SingleMetricViewerProps { + bounds?: TimeRangeBounds; + selectedEntities?: MlEntity; + selectedDetectorIndex?: number; + functionDescription?: string; + selectedJobId: string | undefined; + /** + * Last reload request time, can be used for manual reload + */ + lastRefresh?: number; + onRenderComplete?: () => void; + onError?: (error: Error) => void; + uuid: string; +} + +type Zoom = AppStateZoom | undefined; +type ForecastId = string | undefined; + +const SingleMetricViewerWrapper: FC = ({ + // Component dependencies + coreStart, + pluginStart, + mlServices, + // Component props + bounds, + functionDescription, + lastRefresh, + onError, + onRenderComplete, + selectedDetectorIndex, + selectedEntities, + selectedJobId, + uuid, +}) => { + const [chartWidth, setChartWidth] = useState(0); + const [zoom, setZoom] = useState(); + const [selectedForecastId, setSelectedForecastId] = useState(); + const [selectedJob, setSelectedJob] = useState(); + const [jobsLoaded, setJobsLoaded] = useState(false); + + const isMounted = useMountedState(); + + const { mlApiServices, mlJobService, mlTimeSeriesExplorerService, toastNotificationService } = + mlServices; + const startServices = pick(coreStart, 'analytics', 'i18n', 'theme'); + const datePickerDeps: DatePickerDependencies = { + ...pick(coreStart, ['http', 'notifications', 'theme', 'uiSettings', 'i18n']), + data: pluginStart.data, + uiSettingsKeys: UI_SETTINGS, + showFrozenDataTierChoice: false, + }; + + const previousRefresh = usePrevious(lastRefresh ?? 0); + + useEffect( + function setUpJobsLoaded() { + async function loadJobs() { + try { + await mlJobService.loadJobsWrapper(); + setJobsLoaded(true); + } catch (e) { + if (onError) { + onError(new Error(errorMessage)); + } + } + } + if (isMounted() === false) { + return; + } + loadJobs(); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [isMounted] + ); + + useEffect( + function setUpSelectedJob() { + async function fetchSelectedJob() { + if (mlApiServices && selectedJobId !== undefined) { + try { + const { jobs } = await mlApiServices.getJobs({ jobId: selectedJobId }); + const job = jobs[0]; + setSelectedJob(job); + } catch (e) { + if (onError) { + onError(new Error(errorMessage)); + } + } + } + } + if (isMounted() === false) { + return; + } + fetchSelectedJob(); + }, + [selectedJobId, mlApiServices, isMounted, onError] + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + const resizeHandler = useCallback( + throttle((e: { width: number; height: number }) => { + if (Math.abs(chartWidth - e.width) > minElemAndChartDiff) { + setChartWidth(e.width); + } + }, RESIZE_THROTTLE_TIME_MS), + [chartWidth] + ); + + const autoZoomDuration = useMemo(() => { + if (!selectedJob) return; + return mlTimeSeriesExplorerService?.getAutoZoomDuration(selectedJob); + }, [mlTimeSeriesExplorerService, selectedJob]); + + const appStateHandler = useCallback( + (action: string, payload?: Zoom | ForecastId) => { + /** + * Empty zoom indicates that chart hasn't been rendered yet, + * hence any updates prior that should replace the URL state. + */ + switch (action) { + case APP_STATE_ACTION.SET_FORECAST_ID: + setSelectedForecastId(payload as ForecastId); + setZoom(undefined); + break; + + case APP_STATE_ACTION.SET_ZOOM: + setZoom(payload as Zoom); + break; + + case APP_STATE_ACTION.UNSET_ZOOM: + setZoom(undefined); + break; + } + }, + + [setZoom, setSelectedForecastId] + ); + + return ( + + {(resizeRef) => ( +
+ + + + {selectedJobId !== undefined && + autoZoomDuration !== undefined && + jobsLoaded && + selectedJobId === selectedJob?.job_id && ( + + )} + + + +
+ )} +
+ ); +}; + +// eslint-disable-next-line import/no-default-export +export default SingleMetricViewerWrapper; diff --git a/x-pack/plugins/ml/server/lib/register_cases.ts b/x-pack/plugins/ml/server/lib/register_cases.ts index 4cd629c7b1ac2..7a4925089b398 100644 --- a/x-pack/plugins/ml/server/lib/register_cases.ts +++ b/x-pack/plugins/ml/server/lib/register_cases.ts @@ -11,6 +11,7 @@ import type { MlFeatures } from '../../common/constants/app'; import { CASE_ATTACHMENT_TYPE_ID_ANOMALY_EXPLORER_CHARTS, CASE_ATTACHMENT_TYPE_ID_ANOMALY_SWIMLANE, + CASE_ATTACHMENT_TYPE_ID_SINGLE_METRIC_VIEWER, } from '../../common/constants/cases'; export function registerCasesPersistableState( @@ -37,5 +38,15 @@ export function registerCasesPersistableState( `ML failed to register cases persistable state for ${CASE_ATTACHMENT_TYPE_ID_ANOMALY_EXPLORER_CHARTS}` ); } + + try { + cases.attachmentFramework.registerPersistableState({ + id: CASE_ATTACHMENT_TYPE_ID_SINGLE_METRIC_VIEWER, + }); + } catch (error) { + logger.warn( + `ML failed to register cases persistable state for ${CASE_ATTACHMENT_TYPE_ID_SINGLE_METRIC_VIEWER}` + ); + } } } diff --git a/x-pack/plugins/ml/tsconfig.json b/x-pack/plugins/ml/tsconfig.json index 4b3ae3a3f0beb..8e7ddc53038ff 100644 --- a/x-pack/plugins/ml/tsconfig.json +++ b/x-pack/plugins/ml/tsconfig.json @@ -126,5 +126,6 @@ "@kbn/shared-ux-utility", "@kbn/react-kibana-context-render", "@kbn/esql-utils", + "@kbn/core-lifecycle-browser", ], } diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/attachments_framework/registered_persistable_state_trial.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/attachments_framework/registered_persistable_state_trial.ts index 3b2b536b3c88d..c5e5a23032f66 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/attachments_framework/registered_persistable_state_trial.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/attachments_framework/registered_persistable_state_trial.ts @@ -38,6 +38,7 @@ export default ({ getService }: FtrProviderContext): void => { aiopsChangePointChart: 'a1212d71947ec34487b374cecc47ab9941b5d91c', ml_anomaly_charts: '23e92e824af9db6e8b8bb1d63c222e04f57d2147', ml_anomaly_swimlane: 'a3517f3e53fb041e9cbb150477fb6ef0f731bd5f', + ml_single_metric_viewer: '8b9532b0a40dfdfa282e262949b82cc1a643147c', }); }); });