Skip to content

Commit

Permalink
[ML] Anomaly Detection: Add 'Add to dashboard' action for Single Metr…
Browse files Browse the repository at this point in the history
…ic Viewer (elastic#182538)

## Summary

Adds an action to add the Single Metric Viewer to new or existing
dashboards.

Related meta issue: elastic#181272



https://github.com/elastic/kibana/assets/6446462/a95cc114-fcb4-4dd3-8f9c-68f50d7749dd


### 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)
- [ ] [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
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: Kibana Machine <[email protected]>
  • Loading branch information
alvarezmelissa87 and kibanamachine authored May 6, 2024
1 parent 7e49f2d commit e2807c3
Show file tree
Hide file tree
Showing 6 changed files with 262 additions and 52 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
* 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.
*/

export { TimeSeriesExplorerControls } from './timeseriesexplorer_controls';
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
/*
* 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 { FC } from 'react';
import React, { useCallback, useState } from 'react';
import type { EuiContextMenuPanelDescriptor } from '@elastic/eui';
import {
EuiButtonIcon,
EuiCheckbox,
EuiContextMenu,
EuiFlexGroup,
EuiFlexItem,
EuiPopover,
htmlIdGenerator,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { SaveModalDashboardProps } from '@kbn/presentation-util-plugin/public';
import {
LazySavedObjectSaveModalDashboard,
withSuspense,
} from '@kbn/presentation-util-plugin/public';
import type { JobId } from '../../../../../common/types/anomaly_detection_jobs/job';
import { useMlKibana } from '../../../contexts/kibana';
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';
import type { SingleMetricViewerEmbeddableState } from '../../../../embeddables/types';

interface Props {
selectedDetectorIndex: number;
selectedEntities?: MlEntity;
selectedJobId: JobId;
showAnnotationsCheckbox: boolean;
showAnnotations: boolean;
showForecastCheckbox: boolean;
showForecast: boolean;
showModelBoundsCheckbox: boolean;
showModelBounds: boolean;
onShowModelBoundsChange: () => void;
onShowAnnotationsChange: () => void;
onShowForecastChange: () => void;
}

const SavedObjectSaveModalDashboard = withSuspense(LazySavedObjectSaveModalDashboard);

function getDefaultEmbeddablePanelConfig(jobId: JobId, queryString?: string) {
return {
title: getDefaultSingleMetricViewerPanelTitle(jobId).concat(
queryString ? `- ${queryString}` : ''
),
id: htmlIdGenerator()(),
};
}

export const TimeSeriesExplorerControls: FC<Props> = ({
selectedDetectorIndex,
selectedEntities,
selectedJobId,
showAnnotations,
showAnnotationsCheckbox,
showForecast,
showForecastCheckbox,
showModelBounds,
showModelBoundsCheckbox,
onShowAnnotationsChange,
onShowModelBoundsChange,
onShowForecastChange,
}) => {
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
const [createInDashboard, setCreateInDashboard] = useState<boolean>(false);

const {
services: {
application: { capabilities },
embeddable,
},
} = useMlKibana();

const canEditDashboards = capabilities.dashboard?.createNew ?? false;

const closePopoverOnAction = useCallback(
(actionCallback: Function) => {
return () => {
setIsMenuOpen(false);
actionCallback();
};
},
[setIsMenuOpen]
);

const menuPanels: EuiContextMenuPanelDescriptor[] = [
{
id: 0,
items: [
{
name: (
<FormattedMessage
id="xpack.ml.timeseriesExplorer.addToDashboardLabel"
defaultMessage="Add to dashboard"
/>
),
onClick: closePopoverOnAction(() => {
setCreateInDashboard(true);
}),
},
],
},
];

const onSaveCallback: SaveModalDashboardProps['onSave'] = useCallback(
({ dashboardId, newTitle, newDescription }) => {
const stateTransfer = embeddable!.getStateTransfer();
const config = getDefaultEmbeddablePanelConfig(selectedJobId);

const embeddableInput: Partial<SingleMetricViewerEmbeddableState> = {
id: config.id,
title: newTitle,
description: newDescription,
jobIds: [selectedJobId],
selectedDetectorIndex,
selectedEntities,
};

const state = {
input: embeddableInput,
type: ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE,
};

const path = dashboardId === 'new' ? '#/create' : `#/view/${dashboardId}`;

stateTransfer.navigateToWithEmbeddablePackage('dashboards', {
state,
path,
});
},
[embeddable, selectedJobId, selectedDetectorIndex, selectedEntities]
);

return (
<>
<EuiFlexGroup style={{ float: 'right' }} alignItems="center">
{showModelBoundsCheckbox && (
<EuiFlexItem grow={false}>
<EuiCheckbox
id="toggleModelBoundsCheckbox"
label={i18n.translate('xpack.ml.timeSeriesExplorer.showModelBoundsLabel', {
defaultMessage: 'show model bounds',
})}
checked={showModelBounds}
onChange={onShowModelBoundsChange}
/>
</EuiFlexItem>
)}

{showAnnotationsCheckbox && (
<EuiFlexItem grow={false}>
<EuiCheckbox
id="toggleAnnotationsCheckbox"
label={i18n.translate('xpack.ml.timeSeriesExplorer.annotationsLabel', {
defaultMessage: 'annotations',
})}
checked={showAnnotations}
onChange={onShowAnnotationsChange}
/>
</EuiFlexItem>
)}

{showForecastCheckbox && (
<EuiFlexItem grow={false}>
<EuiCheckbox
id="toggleShowForecastCheckbox"
label={
<span data-test-subj={'mlForecastCheckbox'}>
{i18n.translate('xpack.ml.timeSeriesExplorer.showForecastLabel', {
defaultMessage: 'show forecast',
})}
</span>
}
checked={showForecast}
onChange={onShowForecastChange}
/>
</EuiFlexItem>
)}

{canEditDashboards ? (
<EuiFlexItem grow={false} css={{ marginLeft: 'auto !important', alignSelf: 'baseline' }}>
<EuiPopover
button={
<EuiButtonIcon
size="s"
aria-label={i18n.translate('xpack.ml.explorer.swimlaneActions', {
defaultMessage: 'Actions',
})}
color="text"
iconType="boxesHorizontal"
onClick={setIsMenuOpen.bind(null, !isMenuOpen)}
data-test-subj="mlAnomalyTimelinePanelMenu"
/>
}
isOpen={isMenuOpen}
closePopover={setIsMenuOpen.bind(null, false)}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenu initialPanelId={0} panels={menuPanels} />
</EuiPopover>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
{createInDashboard ? (
<SavedObjectSaveModalDashboard
canSaveByReference={false}
objectType={i18n.translate('xpack.ml.cases.singleMetricViewer.displayName', {
defaultMessage: 'Single Metric Viewer',
})}
documentInfo={{
title: getDefaultSingleMetricViewerPanelTitle(selectedJobId),
}}
onClose={() => setCreateInDashboard(false)}
onSave={onSaveCallback}
/>
) : null}
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import React, { createRef, Fragment } from 'react';

import {
EuiCallOut,
EuiCheckbox,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
Expand Down Expand Up @@ -88,6 +87,7 @@ import { TimeseriesexplorerChartDataError } from './components/timeseriesexplore
import { ExplorerNoJobsSelected } from '../explorer/components';
import { getDataViewsAndIndicesWithGeoFields } from '../explorer/explorer_utils';
import { indexServiceFactory } from '../util/index_service';
import { TimeSeriesExplorerControls } from './components/timeseriesexplorer_controls';

// Used to indicate the chart is being plotted across
// all partition field values, where the cardinality of the field cannot be
Expand Down Expand Up @@ -937,6 +937,7 @@ export class TimeSeriesExplorer extends React.Component {
dateFormatTz,
lastRefresh,
selectedDetectorIndex,
selectedEntities,
selectedJobId,
} = this.props;

Expand Down Expand Up @@ -1160,50 +1161,21 @@ export class TimeSeriesExplorer extends React.Component {
<TimeSeriesExplorerHelpPopover />
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup style={{ float: 'right' }}>
{showModelBoundsCheckbox && (
<EuiFlexItem grow={false}>
<EuiCheckbox
id="toggleModelBoundsCheckbox"
label={i18n.translate('xpack.ml.timeSeriesExplorer.showModelBoundsLabel', {
defaultMessage: 'show model bounds',
})}
checked={showModelBounds}
onChange={this.toggleShowModelBoundsHandler}
/>
</EuiFlexItem>
)}

{showAnnotationsCheckbox && (
<EuiFlexItem grow={false}>
<EuiCheckbox
id="toggleAnnotationsCheckbox"
label={i18n.translate('xpack.ml.timeSeriesExplorer.annotationsLabel', {
defaultMessage: 'annotations',
})}
checked={showAnnotations}
onChange={this.toggleShowAnnotationsHandler}
/>
</EuiFlexItem>
)}

{showForecastCheckbox && (
<EuiFlexItem grow={false}>
<EuiCheckbox
id="toggleShowForecastCheckbox"
label={
<span data-test-subj={'mlForecastCheckbox'}>
{i18n.translate('xpack.ml.timeSeriesExplorer.showForecastLabel', {
defaultMessage: 'show forecast',
})}
</span>
}
checked={showForecast}
onChange={this.toggleShowForecastHandler}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>

<TimeSeriesExplorerControls
selectedDetectorIndex={selectedDetectorIndex}
selectedEntities={selectedEntities}
selectedJobId={selectedJobId}
showAnnotationsCheckbox={showAnnotationsCheckbox}
showAnnotations={showAnnotations}
showForecastCheckbox={showForecastCheckbox}
showForecast={showForecast}
showModelBoundsCheckbox={showModelBoundsCheckbox}
showModelBounds={showModelBounds}
onShowModelBoundsChange={this.toggleShowModelBoundsHandler}
onShowAnnotationsChange={this.toggleShowAnnotationsHandler}
onShowForecastChange={this.toggleShowForecastHandler}
/>

<TimeSeriesChartWithTooltips
chartProps={chartProps}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ export const TimeSeriesExplorerPage: FC<PropsWithChildren<TimeSeriesExplorerPage
noSingleMetricJobsFound,
}) => {
const {
services: { docLinks },
services: { presentationUtil, docLinks },
} = useMlKibana();
const PresentationContextProvider = presentationUtil?.ContextProvider ?? React.Fragment;
const helpLink = docLinks.links.ml.anomalyDetection;
return (
<>
Expand All @@ -61,8 +62,7 @@ export const TimeSeriesExplorerPage: FC<PropsWithChildren<TimeSeriesExplorerPage
{noSingleMetricJobsFound ? null : (
<JobSelector dateFormatTz={dateFormatTz!} singleSelection={true} timeseriesOnly={true} />
)}

{children}
<PresentationContextProvider>{children}</PresentationContextProvider>
<HelpMenu docLink={helpLink} />
</div>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
import { i18n } from '@kbn/i18n';
import type { JobId } from '../../../common/types/anomaly_detection_jobs';

export const getDefaultSingleMetricViewerPanelTitle = (jobIds: JobId[]) =>
export const getDefaultSingleMetricViewerPanelTitle = (jobId: JobId) =>
i18n.translate('xpack.ml.singleMetricViewerEmbeddable.title', {
defaultMessage: 'ML single metric viewer chart for {jobIds}',
values: { jobIds: jobIds.join(', ') },
defaultMessage: 'ML single metric viewer chart for {jobId}',
values: { jobId },
});
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export async function resolveEmbeddableSingleMetricViewerUserInput(
input?.jobIds ? input.jobIds : undefined,
true
);
const title = input?.title ?? getDefaultSingleMetricViewerPanelTitle(jobIds);
const title = input?.title ?? getDefaultSingleMetricViewerPanelTitle(jobIds[0]);
const { jobs } = await mlApiServices.getJobs({ jobId: jobIds.join(',') });

const modalSession = overlays.openModal(
Expand Down

0 comments on commit e2807c3

Please sign in to comment.