[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 <arnautov.dima@gmail.com>
This commit is contained in:
Melissa Alvarez 2024-05-23 09:02:23 -06:00 committed by GitHub
parent 2c7efb4464
commit 2ec4ec362d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 563 additions and 222 deletions

View file

@ -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;

View file

@ -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<Props> = ({
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<Props> = ({
[setIsMenuOpen]
);
const openCasesModalCallback = useCasesModal(ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE);
const menuPanels: EuiContextMenuPanelDescriptor[] = [
{
id: 0,
@ -112,6 +119,27 @@ export const TimeSeriesExplorerControls: FC<Props> = ({
},
];
const casesPrivileges = cases?.helpers.canUseCases();
if (!!casesPrivileges?.create || !!casesPrivileges?.update) {
menuPanels[0].items!.push({
name: (
<FormattedMessage
id="xpack.ml.timeseriesExplorer.addToCaseLabel"
defaultMessage="Add to case"
/>
),
onClick: closePopoverOnAction(() => {
openCasesModalCallback({
jobIds: [selectedJobId],
selectedDetectorIndex,
selectedEntities,
timeRange: globalTimeRange,
});
}),
});
}
const onSaveCallback: SaveModalDashboardProps['onSave'] = useCallback(
({ dashboardId, newTitle, newDescription }) => {
const stateTransfer = embeddable!.getStateTransfer();

View file

@ -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();
}
}
};

View file

@ -33,9 +33,11 @@ export const TimeSeriesExplorerPage: FC<PropsWithChildren<TimeSeriesExplorerPage
noSingleMetricJobsFound,
}) => {
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<PropsWithChildren<TimeSeriesExplorerPage
{noSingleMetricJobsFound ? null : (
<JobSelector dateFormatTz={dateFormatTz!} singleSelection={true} timeseriesOnly={true} />
)}
<PresentationContextProvider>{children}</PresentationContextProvider>
<CasesContext owner={[]} permissions={casesPermissions!}>
<PresentationContextProvider>{children}</PresentationContextProvider>
</CasesContext>
<HelpMenu docLink={helpLink} />
</div>
</>

View file

@ -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
);
}

View file

@ -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: (
<FormattedMessage
id="xpack.ml.cases.singleMetricViewer.embeddableAddedEvent"
defaultMessage="added single metric viewer"
/>
),
timelineAvatar: PLUGIN_ICON,
children: React.lazy(async () => {
const { initComponent } = await import('./single_metric_viewer_attachment');
return {
default: initComponent(pluginStart.fieldFormats, SingleMetricViewerComponent),
};
}),
}),
});
}

View file

@ -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: (
<FormattedMessage
id="xpack.ml.cases.singleMetricViewer.description.jobIdLabel"
defaultMessage="Job ID"
/>
),
description: inputProps.jobIds.join(', '),
},
{
title: (
<FormattedMessage
id="xpack.ml.cases.singleMetricViewer.description.timeRangeLabel"
defaultMessage="Time range"
/>
),
description: `${dataFormatter.convert(
inputProps.timeRange!.from
)} - ${dataFormatter.convert(inputProps.timeRange!.to)}`,
},
];
if (typeof inputProps.query?.query === 'string' && inputProps.query?.query !== '') {
listItems.push({
title: (
<FormattedMessage
id="xpack.ml.cases.singleMetricViewer.description.queryLabel"
defaultMessage="Query"
/>
),
description: inputProps.query?.query,
});
}
const { jobIds, timeRange, ...rest } = inputProps;
const selectedJobId = jobIds[0];
return (
<>
<EuiDescriptionList compressed type={'inline'} listItems={listItems} />
<SingleMetricViewerComponent
bounds={{ min: moment(timeRange!.from), max: moment(timeRange!.to) }}
lastRefresh={Date.now()}
selectedJobId={selectedJobId}
uuid={caseData.id}
{...rest}
/>
</>
);
},
(prevProps, nextProps) =>
deepEqual(
prevProps.persistableStateAttachmentState,
nextProps.persistableStateAttachmentState
)
);
}
);

View file

@ -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;

View file

@ -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<MlStartDependencies, MlPluginStart>
): Promise<SingleMetricViewerEmbeddableServices> => {
export const getMlServices = async (
coreStart: CoreStart,
pluginsStart: MlStartDependencies
): Promise<SingleMetricViewerServices> => {
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 [
coreStart,
pluginsStart as MlDependencies,
{
anomalyDetectorService,
anomalyExplorerService,
mlApiServices,
mlCapabilities,
mlFieldFormatService,
mlJobService,
mlResultsService,
mlTimeSeriesSearchService,
mlTimeSeriesExplorerService,
toastNotificationService,
},
];
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<MlStartDependencies, MlPluginStart>
): Promise<SingleMetricViewerEmbeddableServices> => {
const [coreStart, pluginsStart] = await getStartServices();
const mlServices = await getMlServices(coreStart, pluginsStart);
return [coreStart, pluginsStart as MlDependencies, mlServices];
};

View file

@ -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 });

View file

@ -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<MlStartDependencies, MlPluginStart>
@ -147,31 +123,11 @@ export const getSingleMetricViewerEmbeddableFactory = (
services[1].data.query.timefilter.timefilter
);
const SingleMetricViewerComponent = getSingleMetricViewerComponent(...services);
return {
api,
Component: () => {
const [chartWidth, setChartWidth] = useState<number>(0);
const [zoom, setZoom] = useState<AppStateZoom | undefined>();
const [selectedForecastId, setSelectedForecastId] = useState<string | undefined>();
const [selectedJob, setSelectedJob] = useState<MlJob | undefined>();
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 (
<KibanaRenderContextProvider {...startServices}>
<KibanaContextProvider
services={{
mlServices: {
...services[2],
},
...services[0],
...services[1],
}}
>
<DatePickerContextProvider {...datePickerDeps}>
<EuiResizeObserver onResize={resizeHandler}>
{(resizeRef) => (
<div
id={`mlSingleMetricViewerEmbeddableWrapper-${api.uuid}`}
style={{
width: '100%',
overflowY: 'auto',
overflowX: 'hidden',
padding: '8px',
}}
data-test-subj={`mlSingleMetricViewer_${api.uuid}`}
ref={resizeRef}
className="ml-time-series-explorer"
>
{singleMetricViewerData !== undefined &&
autoZoomDuration !== undefined &&
jobsLoaded &&
selectedJobId === selectedJob?.job_id && (
<TimeSeriesExplorerEmbeddableChart
chartWidth={chartWidth - containerPadding}
dataViewsService={services[1].data.dataViews}
toastNotificationService={toastNotificationService}
appStateHandler={appStateHandler}
autoZoomDuration={autoZoomDuration}
bounds={bounds}
dateFormatTz={moment.tz.guess()}
lastRefresh={lastRefresh ?? 0}
previousRefresh={previousRefresh}
selectedJobId={selectedJobId}
selectedDetectorIndex={singleMetricViewerData.selectedDetectorIndex}
selectedEntities={singleMetricViewerData.selectedEntities}
selectedForecastId={selectedForecastId}
tableInterval="auto"
tableSeverity={0}
zoom={zoom}
functionDescription={functionDescription}
selectedJob={selectedJob}
/>
)}
</div>
)}
</EuiResizeObserver>
</DatePickerContextProvider>
</KibanaContextProvider>
</KibanaRenderContextProvider>
<SingleMetricViewerComponent
bounds={bounds}
functionDescription={functionDescription}
lastRefresh={lastRefresh}
onError={(error) => blockingError.next(error)}
selectedDetectorIndex={singleMetricViewerData?.selectedDetectorIndex}
selectedEntities={singleMetricViewerData?.selectedEntities}
selectedJobId={singleMetricViewerData?.jobIds[0]}
uuid={api.uuid}
onRenderComplete={() => {
dataLoading.next(false);
}}
/>
);
},
};

View file

@ -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();
},
}
);

View file

@ -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<MlPluginSetup, MlPluginStart> {
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) {

View file

@ -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 (
<SingleMetricViewerLazy
coreStart={coreStart}
pluginStart={pluginStart}
mlServices={mlServices}
{...props}
/>
);
};
};
export type { SingleMetricViewerSharedComponent } from './single_metric_viewer';

View file

@ -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<SingleMetricViewerProps>;
/**
* 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<SingleMetricViewerPropsWithDeps> = ({
// Component dependencies
coreStart,
pluginStart,
mlServices,
// Component props
bounds,
functionDescription,
lastRefresh,
onError,
onRenderComplete,
selectedDetectorIndex,
selectedEntities,
selectedJobId,
uuid,
}) => {
const [chartWidth, setChartWidth] = useState<number>(0);
const [zoom, setZoom] = useState<Zoom>();
const [selectedForecastId, setSelectedForecastId] = useState<ForecastId>();
const [selectedJob, setSelectedJob] = useState<MlJob | undefined>();
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 (
<EuiResizeObserver onResize={resizeHandler}>
{(resizeRef) => (
<div
id={`mlSingleMetricViewerEmbeddableWrapper-${uuid}`}
style={{
width: '100%',
overflowY: 'auto',
overflowX: 'hidden',
padding: '8px',
}}
data-test-subj={`mlSingleMetricViewer_${uuid}`}
ref={resizeRef}
className="ml-time-series-explorer"
data-shared-item="" // TODO: Remove data-shared-item as part of https://github.com/elastic/kibana/issues/179376
data-rendering-count={1}
>
<KibanaRenderContextProvider {...startServices}>
<KibanaContextProvider
services={{
mlServices: {
...mlServices,
},
...coreStart,
...pluginStart,
}}
>
<DatePickerContextProvider {...datePickerDeps}>
{selectedJobId !== undefined &&
autoZoomDuration !== undefined &&
jobsLoaded &&
selectedJobId === selectedJob?.job_id && (
<TimeSeriesExplorerEmbeddableChart
chartWidth={chartWidth - containerPadding}
dataViewsService={pluginStart.data.dataViews}
toastNotificationService={toastNotificationService}
appStateHandler={appStateHandler}
autoZoomDuration={autoZoomDuration}
bounds={bounds}
dateFormatTz={tz.guess()}
lastRefresh={lastRefresh ?? 0}
previousRefresh={previousRefresh}
selectedJobId={selectedJobId}
selectedDetectorIndex={selectedDetectorIndex}
selectedEntities={selectedEntities}
selectedForecastId={selectedForecastId}
tableInterval="auto"
tableSeverity={0}
zoom={zoom}
functionDescription={functionDescription}
selectedJob={selectedJob}
onRenderComplete={onRenderComplete}
/>
)}
</DatePickerContextProvider>
</KibanaContextProvider>
</KibanaRenderContextProvider>
</div>
)}
</EuiResizeObserver>
);
};
// eslint-disable-next-line import/no-default-export
export default SingleMetricViewerWrapper;

View file

@ -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}`
);
}
}
}

View file

@ -126,5 +126,6 @@
"@kbn/shared-ux-utility",
"@kbn/react-kibana-context-render",
"@kbn/esql-utils",
"@kbn/core-lifecycle-browser",
],
}

View file

@ -38,6 +38,7 @@ export default ({ getService }: FtrProviderContext): void => {
aiopsChangePointChart: 'a1212d71947ec34487b374cecc47ab9941b5d91c',
ml_anomaly_charts: '23e92e824af9db6e8b8bb1d63c222e04f57d2147',
ml_anomaly_swimlane: 'a3517f3e53fb041e9cbb150477fb6ef0f731bd5f',
ml_single_metric_viewer: '8b9532b0a40dfdfa282e262949b82cc1a643147c',
});
});
});