Skip to content

Commit

Permalink
[ML] Anomaly Detection: Single Metric Viewer - add cases action (elas…
Browse files Browse the repository at this point in the history
…tic#183423)

## Summary

Related meta issue elastic#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 <[email protected]>
Co-authored-by: Dima Arnautov <[email protected]>
  • Loading branch information
3 people authored May 23, 2024
1 parent 2c7efb4 commit 2ec4ec3
Show file tree
Hide file tree
Showing 19 changed files with 562 additions and 221 deletions.
1 change: 1 addition & 0 deletions x-pack/plugins/ml/common/constants/cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand All @@ -93,6 +98,8 @@ export const TimeSeriesExplorerControls: FC<Props> = ({
[setIsMenuOpen]
);

const openCasesModalCallback = useCasesModal(ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE);

const menuPanels: EuiContextMenuPanelDescriptor[] = [
{
id: 0,
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -434,6 +435,9 @@ export class TimeSeriesExplorerEmbeddableChart extends React.Component {
}

this.setState(stateUpdate);
if (this.props.onRenderComplete !== undefined) {
this.props.onRenderComplete();
}
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
Expand All @@ -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>
</>
Expand Down
11 changes: 10 additions & 1 deletion x-pack/plugins/ml/public/cases/register_cases_attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
Original file line number Diff line number Diff line change
@@ -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),
};
}),
}),
});
}
94 changes: 94 additions & 0 deletions x-pack/plugins/ml/public/cases/single_metric_viewer_attachment.tsx
Original file line number Diff line number Diff line change
@@ -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
)
);
}
);
5 changes: 4 additions & 1 deletion x-pack/plugins/ml/public/embeddables/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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'),
Expand Down Expand Up @@ -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
Expand All @@ -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<MlStartDependencies, MlPluginStart>
): Promise<SingleMetricViewerEmbeddableServices> => {
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];
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading

0 comments on commit 2ec4ec3

Please sign in to comment.