[ML] Attach the anomaly charts embeddable to Case (#139628)

* reusable get_embeddable_component.tsx

* register attachment type

* useCasesModal

* fix anomaly charts input resolver and fetching

* fix jest test

* functional tests

* add anomaly charts to ml functional test services

* check for undefined cases modal

* remove todo

* filter and query condition

* update assertion

* stabilize set debug state

* memoize component

* move embeddable components init

* refactor react lazy

* refactor react lazy for swim lane

* memo callbacks

* update deps for attachment
This commit is contained in:
Dima Arnautov 2022-08-31 16:37:57 +02:00 committed by GitHub
parent 2a3cf660ab
commit cb7b03106f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 560 additions and 188 deletions

View file

@ -0,0 +1,53 @@
/*
* 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 } from 'react';
import { stringHash } from '@kbn/ml-string-hash';
import { CommentType } from '@kbn/cases-plugin/common';
import { useMlKibana } from './kibana_context';
import type { MappedEmbeddableTypeOf, MlEmbeddableTypes } from '../../../embeddables';
/**
* Returns a callback for opening the cases modal with provided attachment state.
*/
export const useCasesModal = <EmbeddableType extends MlEmbeddableTypes>(
embeddableType: EmbeddableType
) => {
const {
services: { cases },
} = useMlKibana();
const selectCaseModal = cases?.hooks.getUseCasesAddToExistingCaseModal();
return useCallback(
(persistableState: Partial<Omit<MappedEmbeddableTypeOf<EmbeddableType>, 'id'>>) => {
const persistableStateAttachmentState = {
...persistableState,
// Creates unique id based on the input
id: stringHash(JSON.stringify(persistableState)).toString(),
};
if (!selectCaseModal) {
throw new Error('Cases modal is not available');
}
selectCaseModal.open({
attachments: [
{
type: CommentType.persistableState,
persistableStateAttachmentTypeId: embeddableType,
// TODO Cases: improve type for persistableStateAttachmentState with io-ts
persistableStateAttachmentState: JSON.parse(
JSON.stringify(persistableStateAttachmentState)
),
},
],
});
},
[embeddableType]
);
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useMemo, useState, FC } from 'react';
import React, { FC, useCallback, useMemo, useState } from 'react';
import {
EuiButtonIcon,
EuiContextMenuItem,
@ -15,6 +15,10 @@ import {
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { useCasesModal } from '../contexts/kibana/use_cases_modal';
import { DEFAULT_MAX_SERIES_TO_PLOT } from '../services/anomaly_explorer_charts_service';
import { ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE } from '../../embeddables';
import { useTimeRangeUpdates } from '../contexts/kibana/use_timefilter';
import { useMlKibana } from '../contexts/kibana';
import type { AppStateSelectedCells, ExplorerJob } from './explorer_utils';
import { TimeRangeBounds } from '../util/time_buckets';
@ -37,13 +41,25 @@ export const AnomalyContextMenu: FC<AnomalyContextMenuProps> = ({
const {
services: {
application: { capabilities },
cases,
},
} = useMlKibana();
const globalTimeRange = useTimeRangeUpdates(true);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isAddDashboardsActive, setIsAddDashboardActive] = useState(false);
const closePopoverOnAction = useCallback(
(actionCallback: Function) => {
setIsMenuOpen(false);
actionCallback();
},
[setIsMenuOpen]
);
const openCasesModal = useCasesModal(ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE);
const canEditDashboards = capabilities.dashboard?.createNew ?? false;
const casesPrivileges = cases?.helpers.canUseCases();
const menuItems = useMemo(() => {
const items = [];
@ -51,7 +67,7 @@ export const AnomalyContextMenu: FC<AnomalyContextMenuProps> = ({
items.push(
<EuiContextMenuItem
key="addToDashboard"
onClick={setIsAddDashboardActive.bind(null, true)}
onClick={closePopoverOnAction.bind(null, setIsAddDashboardActive.bind(null, true))}
data-test-subj="mlAnomalyAddChartsToDashboardButton"
>
<FormattedMessage
@ -61,15 +77,37 @@ export const AnomalyContextMenu: FC<AnomalyContextMenuProps> = ({
</EuiContextMenuItem>
);
}
if (!!casesPrivileges?.create || !!casesPrivileges?.update) {
items.push(
<EuiContextMenuItem
key="attachToCase"
onClick={closePopoverOnAction.bind(
null,
openCasesModal.bind(null, {
jobIds: selectedJobs?.map((v) => v.id),
timeRange: globalTimeRange,
maxSeriesToPlot: DEFAULT_MAX_SERIES_TO_PLOT,
})
)}
data-test-subj="mlAnomalyAttachChartsToCasesButton"
>
<FormattedMessage
id="xpack.ml.explorer.attachToCaseLabel"
defaultMessage="Attach to case"
/>
</EuiContextMenuItem>
);
}
return items;
}, [canEditDashboards]);
}, [canEditDashboards, globalTimeRange, closePopoverOnAction, selectedJobs]);
const jobIds = selectedJobs.map(({ id }) => id);
return (
<>
{menuItems.length > 0 && chartsCount > 0 && (
<EuiFlexItem grow={false} style={{ marginLeft: 'auto', alignSelf: 'baseline' }}>
{menuItems.length > 0 && chartsCount > 0 ? (
<EuiFlexItem grow={false} css={{ marginLeft: 'auto', alignSelf: 'baseline' }}>
<EuiPopover
button={
<EuiButtonIcon
@ -92,7 +130,7 @@ export const AnomalyContextMenu: FC<AnomalyContextMenuProps> = ({
<EuiContextMenuPanel items={menuItems} />
</EuiPopover>
</EuiFlexItem>
)}
) : null}
{isAddDashboardsActive && selectedJobs ? (
<AddAnomalyChartsToDashboardControl
onClose={async () => {

View file

@ -26,10 +26,8 @@ import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import useDebounce from 'react-use/lib/useDebounce';
import useObservable from 'react-use/lib/useObservable';
import { CommentType } from '@kbn/cases-plugin/common';
import { stringHash } from '@kbn/ml-string-hash';
import { useCasesModal } from '../contexts/kibana/use_cases_modal';
import { useTimeRangeUpdates } from '../contexts/kibana/use_timefilter';
import type { AnomalySwimlaneEmbeddableInput } from '../../embeddables';
import { ANOMALY_SWIMLANE_EMBEDDABLE_TYPE } from '../..';
import {
OVERALL_LABEL,
@ -164,34 +162,18 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
[severityUpdate, swimLaneSeverity]
);
const openCasesModalCallback = useCasesModal(ANOMALY_SWIMLANE_EMBEDDABLE_TYPE);
const openCasesModal = useCallback(
(swimLaneType: SwimlaneType) => {
const persistableStateAttachmentState = {
openCasesModalCallback({
swimlaneType: swimLaneType,
...(swimLaneType === SWIMLANE_TYPE.VIEW_BY ? { viewBy: viewBySwimlaneFieldName } : {}),
jobIds: selectedJobs?.map((v) => v.id),
timeRange: globalTimeRange,
} as AnomalySwimlaneEmbeddableInput;
// Creates unique id based on the input
persistableStateAttachmentState.id = stringHash(
JSON.stringify(persistableStateAttachmentState)
).toString();
selectCaseModal!.open({
attachments: [
{
type: CommentType.persistableState,
persistableStateAttachmentTypeId: ANOMALY_SWIMLANE_EMBEDDABLE_TYPE,
// TODO Cases: improve type for persistableStateAttachmentState with io-ts
persistableStateAttachmentState: JSON.parse(
JSON.stringify(persistableStateAttachmentState)
),
},
],
});
},
[selectCaseModal, selectedJobs, globalTimeRange, viewBySwimlaneFieldName]
[openCasesModalCallback, selectedJobs, globalTimeRange, viewBySwimlaneFieldName]
);
const annotations = useMemo(() => overallAnnotations.annotationsData, [overallAnnotations]);

View file

@ -49,7 +49,7 @@ describe('AnomalyExplorerChartsService', () => {
);
timefilterMock = createTimefilterMock();
timefilterMock.getActiveBounds.mockReturnValue({
timefilterMock.getBounds.mockReturnValue({
min: moment(1486656000000),
max: moment(1486670399999),
});

View file

@ -110,7 +110,7 @@ export class AnomalyExplorerChartsService {
severity = 0,
maxSeries?: number
): Observable<ExplorerChartsData> {
const bounds = this.timeFilter.getActiveBounds();
const bounds = this.getTimeBounds();
const boundsMin = bounds?.min ? bounds.min.valueOf() : undefined;
const boundsMax = bounds?.max ? bounds.max.valueOf() : undefined;

View file

@ -0,0 +1,71 @@
/*
* 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, { FC } from 'react';
import { memoize } from 'lodash';
import deepEqual from 'fast-deep-equal';
import { FormattedMessage } from '@kbn/i18n-react';
import type { PersistableStateAttachmentViewProps } from '@kbn/cases-plugin/public/client/attachment_framework/types';
import { FIELD_FORMAT_IDS } from '@kbn/field-formats-plugin/common';
import { EuiDescriptionList } from '@elastic/eui';
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import type { AnomalyChartsEmbeddableInput } from '../embeddables';
export const initComponent = memoize(
(fieldFormats: FieldFormatsStart, EmbeddableComponent: FC<AnomalyChartsEmbeddableInput>) => {
return React.memo(
(props: PersistableStateAttachmentViewProps) => {
const { persistableStateAttachmentState } = props;
const dataFormatter = fieldFormats.deserialize({
id: FIELD_FORMAT_IDS.DATE,
});
const inputProps =
persistableStateAttachmentState as unknown as AnomalyChartsEmbeddableInput;
return (
<>
<EuiDescriptionList
compressed
type={'inline'}
listItems={[
{
title: (
<FormattedMessage
id="xpack.ml.cases.anomalyCharts.description.jobIdsLabel"
defaultMessage="Job IDs"
/>
),
description: inputProps.jobIds.join(', '),
},
{
title: (
<FormattedMessage
id="xpack.ml.cases.anomalyCharts.description.timeRangeLabel"
defaultMessage="Time range"
/>
),
description: `${dataFormatter.convert(
inputProps.timeRange.from
)} - ${dataFormatter.convert(inputProps.timeRange.to)}`,
},
]}
/>
<EmbeddableComponent {...inputProps} />
</>
);
},
(prevProps, nextProps) =>
deepEqual(
prevProps.persistableStateAttachmentState,
nextProps.persistableStateAttachmentState
)
);
}
);

View file

@ -0,0 +1,83 @@
/*
* 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, { FC } from 'react';
import { memoize } from 'lodash';
import { FormattedMessage } from '@kbn/i18n-react';
import { PersistableStateAttachmentViewProps } from '@kbn/cases-plugin/public/client/attachment_framework/types';
import { FIELD_FORMAT_IDS } from '@kbn/field-formats-plugin/common';
import { EuiDescriptionList } from '@elastic/eui';
import { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import deepEqual from 'fast-deep-equal';
import { AnomalySwimlaneEmbeddableInput } from '..';
export const initComponent = memoize(
(fieldFormats: FieldFormatsStart, EmbeddableComponent: FC<AnomalySwimlaneEmbeddableInput>) => {
return React.memo(
(props: PersistableStateAttachmentViewProps) => {
const { persistableStateAttachmentState } = props;
const dataFormatter = fieldFormats.deserialize({
id: FIELD_FORMAT_IDS.DATE,
});
const inputProps =
persistableStateAttachmentState as unknown as AnomalySwimlaneEmbeddableInput;
return (
<>
<EuiDescriptionList
compressed
type={'inline'}
listItems={[
{
title: (
<FormattedMessage
id="xpack.ml.cases.anomalySwimLane.description.jobIdsLabel"
defaultMessage="Job IDs"
/>
),
description: inputProps.jobIds.join(', '),
},
...(inputProps.viewBy
? [
{
title: (
<FormattedMessage
id="xpack.ml.cases.anomalySwimLane.description.viewByLabel"
defaultMessage="View by"
/>
),
description: inputProps.viewBy,
},
]
: []),
{
title: (
<FormattedMessage
id="xpack.ml.cases.anomalySwimLane.description.timeRangeLabel"
defaultMessage="Time range"
/>
),
description: `${dataFormatter.convert(
inputProps.timeRange.from
)} - ${dataFormatter.convert(inputProps.timeRange.to)}`,
},
]}
/>
<EmbeddableComponent {...inputProps} />
</>
);
},
(prevProps, nextProps) =>
deepEqual(
prevProps.persistableStateAttachmentState,
nextProps.persistableStateAttachmentState
)
);
}
);

View file

@ -0,0 +1,50 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { CasesUiSetup } from '@kbn/cases-plugin/public';
import type { CoreStart } from '@kbn/core/public';
import { ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE, getEmbeddableComponent } from '../embeddables';
import type { MlStartDependencies } from '../plugin';
import { PLUGIN_ICON } from '../../common/constants/app';
export function registerAnomalyChartsCasesAttachment(
cases: CasesUiSetup,
coreStart: CoreStart,
pluginStart: MlStartDependencies
) {
const EmbeddableComponent = getEmbeddableComponent(
ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE,
coreStart,
pluginStart
);
cases.attachmentFramework.registerPersistableState({
id: ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE,
icon: PLUGIN_ICON,
displayName: i18n.translate('xpack.ml.cases.anomalyCharts.displayName', {
defaultMessage: 'Anomaly charts',
}),
getAttachmentViewObject: () => ({
event: (
<FormattedMessage
id="xpack.ml.cases.anomalyCharts.embeddableAddedEvent"
defaultMessage="added the Anomaly Charts embeddable"
/>
),
timelineAvatar: PLUGIN_ICON,
children: React.lazy(async () => {
const { initComponent } = await import('./anomaly_charts_attachments');
return {
default: initComponent(pluginStart.fieldFormats, EmbeddableComponent),
};
}),
}),
});
}

View file

@ -8,36 +8,27 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiButtonIcon, EuiDescriptionList } from '@elastic/eui';
import { CasesUiSetup } from '@kbn/cases-plugin/public';
import { CoreStart } from '@kbn/core/public';
import { PersistableStateAttachmentViewProps } from '@kbn/cases-plugin/public/client/attachment_framework/types';
import { FIELD_FORMAT_IDS } from '@kbn/field-formats-plugin/common';
import { MlStartDependencies } from '../plugin';
import { ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, AnomalySwimlaneEmbeddableInput } from '..';
import type { CasesUiSetup } from '@kbn/cases-plugin/public';
import type { CoreStart } from '@kbn/core/public';
import { getEmbeddableComponent } from '../embeddables';
import type { MlStartDependencies } from '../plugin';
import { ANOMALY_SWIMLANE_EMBEDDABLE_TYPE } from '..';
import { PLUGIN_ICON } from '../../common/constants/app';
import { getAnomalySwimLaneEmbeddableComponent } from '../embeddables/anomaly_swimlane';
const AttachmentActions: React.FC = () => {
return (
<EuiButtonIcon
data-test-subj="test-attachment-action"
onClick={() => {}}
iconType="boxesHorizontal"
aria-label="See attachment"
/>
);
};
export function registerAnomalySwimLaneCasesAttachment(
cases: CasesUiSetup,
coreStart: CoreStart,
pluginStart: MlStartDependencies
) {
const EmbeddableComponent = getEmbeddableComponent(
ANOMALY_SWIMLANE_EMBEDDABLE_TYPE,
coreStart,
pluginStart
);
cases.attachmentFramework.registerPersistableState({
id: ANOMALY_SWIMLANE_EMBEDDABLE_TYPE,
icon: PLUGIN_ICON,
// TODO check where this name is presented
displayName: i18n.translate('xpack.ml.cases.anomalySwimLane.displayName', {
defaultMessage: 'Anomaly swim lane',
}),
@ -49,69 +40,11 @@ export function registerAnomalySwimLaneCasesAttachment(
/>
),
timelineAvatar: PLUGIN_ICON,
actions: <AttachmentActions />,
children: React.lazy(() => {
return Promise.resolve().then(() => {
const EmbeddableComponent = getAnomalySwimLaneEmbeddableComponent(coreStart, pluginStart);
return {
default: React.memo((props: PersistableStateAttachmentViewProps) => {
const { persistableStateAttachmentState } = props;
const dataFormatter = pluginStart.fieldFormats.deserialize({
id: FIELD_FORMAT_IDS.DATE,
});
const inputProps =
persistableStateAttachmentState as unknown as AnomalySwimlaneEmbeddableInput;
return (
<>
<EuiDescriptionList
compressed
type={'inline'}
listItems={[
{
title: (
<FormattedMessage
id="xpack.ml.cases.anomalySwimLane.description.jobIdsLabel"
defaultMessage="Job IDs"
/>
),
description: inputProps.jobIds.join(', '),
},
...(inputProps.viewBy
? [
{
title: (
<FormattedMessage
id="xpack.ml.cases.anomalySwimLane.description.viewByLabel"
defaultMessage="View by"
/>
),
description: inputProps.viewBy,
},
]
: []),
{
title: (
<FormattedMessage
id="xpack.ml.cases.anomalySwimLane.description.timeRangeLabel"
defaultMessage="Time range"
/>
),
description: `${dataFormatter.convert(
inputProps.timeRange.from
)} - ${dataFormatter.convert(inputProps.timeRange.to)}`,
},
]}
/>
<EmbeddableComponent {...inputProps} />
</>
);
}),
};
});
children: React.lazy(async () => {
const { initComponent } = await import('./anomaly_swim_lane_attachment');
return {
default: initComponent(pluginStart.fieldFormats, EmbeddableComponent),
};
}),
}),
});

View file

@ -7,6 +7,7 @@
import { CasesUiSetup } from '@kbn/cases-plugin/public';
import { CoreStart } from '@kbn/core/public';
import { registerAnomalyChartsCasesAttachment } from './register_anomaly_charts_attachment';
import { MlStartDependencies } from '../plugin';
import { registerAnomalySwimLaneCasesAttachment } from './register_anomaly_swim_lane_attachment';
@ -16,4 +17,5 @@ export function registerCasesAttachments(
pluginStart: MlStartDependencies
) {
registerAnomalySwimLaneCasesAttachment(cases, coreStart, pluginStart);
registerAnomalyChartsCasesAttachment(cases, coreStart, pluginStart);
}

View file

@ -81,9 +81,11 @@ export function useAnomalyChartsInputResolver(
anomalyExplorerService.setTimeRange(timeRangeInput);
let influencersFilterQuery: InfluencersFilterQuery;
let influencersFilterQuery: InfluencersFilterQuery | undefined;
try {
influencersFilterQuery = processFilters(filters, query);
if (filters || query) {
influencersFilterQuery = processFilters(filters, query);
}
} catch (e) {
// handle query syntax errors
setError(e);

View file

@ -1,38 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC } from 'react';
import type { CoreStart } from '@kbn/core/public';
import {
EmbeddableFactory,
EmbeddableInput,
EmbeddableOutput,
EmbeddableRoot,
useEmbeddableFactory,
} from '@kbn/embeddable-plugin/public';
import { EuiLoadingChart } from '@elastic/eui';
import { ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, AnomalySwimlaneEmbeddableInput } from '../..';
import type { MlStartDependencies } from '../../plugin';
export function getEmbeddableComponent(core: CoreStart, plugins: MlStartDependencies) {
const { embeddable: embeddableStart } = plugins;
const factory = embeddableStart.getEmbeddableFactory(ANOMALY_SWIMLANE_EMBEDDABLE_TYPE)!;
return (props: AnomalySwimlaneEmbeddableInput) => {
return <EmbeddableRootWrapper factory={factory} input={props} />;
};
}
const EmbeddableRootWrapper: FC<{
factory: EmbeddableFactory<EmbeddableInput, EmbeddableOutput>;
input: AnomalySwimlaneEmbeddableInput;
}> = ({ factory, input }) => {
const [embeddable, loading, error] = useEmbeddableFactory({ factory, input });
if (loading) {
return <EuiLoadingChart />;
}
return <EmbeddableRoot embeddable={embeddable} loading={loading} error={error} input={input} />;
};

View file

@ -6,4 +6,3 @@
*/
export { AnomalySwimlaneEmbeddableFactory } from './anomaly_swimlane_embeddable_factory';
export { getEmbeddableComponent as getAnomalySwimLaneEmbeddableComponent } from './embeddable_component';

View file

@ -151,7 +151,7 @@ export function useSwimlaneInputResolver(
let appliedFilters: any;
try {
if (filters && query) {
if (filters || query) {
appliedFilters = processFilters(filters, query, CONTROLLED_BY_SWIM_LANE_FILTER);
}
} catch (e) {

View file

@ -7,3 +7,8 @@
export const ANOMALY_SWIMLANE_EMBEDDABLE_TYPE = 'ml_anomaly_swimlane' as const;
export const ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE = 'ml_anomaly_charts' as const;
export type AnomalySwimLaneEmbeddableType = typeof ANOMALY_SWIMLANE_EMBEDDABLE_TYPE;
export type AnomalyExplorerChartsEmbeddableType = typeof ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE;
export type MlEmbeddableTypes = AnomalySwimLaneEmbeddableType | AnomalyExplorerChartsEmbeddableType;

View file

@ -0,0 +1,59 @@
/*
* 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 type { CoreStart } from '@kbn/core/public';
import {
EmbeddableFactory,
EmbeddableInput,
EmbeddableRoot,
useEmbeddableFactory,
} from '@kbn/embeddable-plugin/public';
import { EuiLoadingChart } from '@elastic/eui';
import type { MappedEmbeddableTypeOf } from './types';
import type { MlStartDependencies } from '../plugin';
import type { MlEmbeddableTypes } from './constants';
/**
* Gets an instance of an embeddable component of requested type.
* @param embeddableType
* @param core
* @param plugins
*/
export function getEmbeddableComponent<EmbeddableType extends MlEmbeddableTypes>(
embeddableType: EmbeddableType,
core: CoreStart,
plugins: MlStartDependencies
) {
const { embeddable: embeddableStart } = plugins;
const factory =
embeddableStart.getEmbeddableFactory<MappedEmbeddableTypeOf<EmbeddableType>>(embeddableType);
if (!factory) {
throw new Error(`Embeddable type "${embeddableType}" has not been registered.`);
}
return React.memo((props: MappedEmbeddableTypeOf<EmbeddableType>) => {
return <EmbeddableRootWrapper factory={factory} input={props} />;
});
}
interface EmbeddableRootWrapperProps<TMlEmbeddableInput extends EmbeddableInput> {
factory: EmbeddableFactory<TMlEmbeddableInput>;
input: TMlEmbeddableInput;
}
const EmbeddableRootWrapper = <TMlEmbeddableInput extends EmbeddableInput>({
factory,
input,
}: EmbeddableRootWrapperProps<TMlEmbeddableInput>) => {
const [embeddable, loading, error] = useEmbeddableFactory({ factory, input });
if (loading) {
return <EuiLoadingChart />;
}
return <EmbeddableRoot embeddable={embeddable} loading={loading} error={error} input={input} />;
};

View file

@ -13,6 +13,8 @@ import { AnomalyChartsEmbeddableFactory } from './anomaly_charts';
export * from './constants';
export * from './types';
export { getEmbeddableComponent } from './get_embeddable_component';
export function registerEmbeddables(embeddable: EmbeddableSetup, core: MlCoreSetup) {
const anomalySwimlaneEmbeddableFactory = new AnomalySwimlaneEmbeddableFactory(
core.getStartServices

View file

@ -22,6 +22,9 @@ import { EntityField } from '../../common/util/anomaly_utils';
import {
ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE,
ANOMALY_SWIMLANE_EMBEDDABLE_TYPE,
AnomalyExplorerChartsEmbeddableType,
AnomalySwimLaneEmbeddableType,
MlEmbeddableTypes,
} from './constants';
import { MlResultsService } from '../application/services/results_service';
@ -127,3 +130,10 @@ export function isAnomalyExplorerEmbeddable(
arg.embeddable.type === ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE
);
}
export type MappedEmbeddableTypeOf<TEmbeddableType extends MlEmbeddableTypes> =
TEmbeddableType extends AnomalySwimLaneEmbeddableType
? AnomalySwimlaneEmbeddableInput
: TEmbeddableType extends AnomalyExplorerChartsEmbeddableType
? AnomalyChartsEmbeddableInput
: unknown;

View file

@ -6,9 +6,10 @@
*/
import { stringHash } from '@kbn/ml-string-hash';
import { Job, Datafeed } from '@kbn/ml-plugin/common/types/anomaly_detection_jobs';
import { AnomalySwimlaneEmbeddableInput } from '@kbn/ml-plugin/public';
import { FtrProviderContext } from '../../../ftr_provider_context';
import type { Job, Datafeed } from '@kbn/ml-plugin/common/types/anomaly_detection_jobs';
import type { AnomalySwimlaneEmbeddableInput } from '@kbn/ml-plugin/public';
import type { AnomalyChartsEmbeddableInput } from '@kbn/ml-plugin/public/embeddables';
import type { FtrProviderContext } from '../../../ftr_provider_context';
import { USER } from '../../../services/ml/security_common';
// @ts-expect-error not full interface
@ -209,7 +210,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await ml.swimLane.assertAxisLabels(viewBySwimLaneTestSubj, 'y', ['EGF', 'DAL']);
await ml.testExecution.logTestStep('renders anomaly explorer charts');
// TODO check why count changed from 4 to 5
await ml.anomalyExplorer.assertAnomalyExplorerChartsCount(5);
await ml.testExecution.logTestStep('updates top influencers list');
@ -410,7 +410,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await ml.anomalyExplorer.attachSwimLaneToCase('viewBy', {
title: 'ML Test case',
description: 'Case with an anomaly swim lane',
tag: 'ml_case',
tag: 'ml_swim_lane_case',
});
const expectedAttachment = {
@ -429,7 +429,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
{
title: 'ML Test case',
description: 'Case with an anomaly swim lane',
tag: 'ml_case',
tag: 'ml_swim_lane_case',
reporter: USER.ML_POWERUSER,
},
expectedAttachment,
@ -447,6 +447,60 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await ml.anomalyExplorer.addAndEditSwimlaneInDashboard('ML Test');
});
});
describe('Anomaly Charts as embeddable', function () {
beforeEach(async () => {
await ml.navigation.navigateToAnomalyExplorer(
testData.jobConfig.job_id,
{
from: '2016-02-07T00%3A00%3A00.000Z',
to: '2016-02-11T23%3A59%3A54.000Z',
},
() => elasticChart.setNewChartUiDebugFlag(true)
);
await ml.commonUI.waitForMlLoadingIndicatorToDisappear();
await ml.commonUI.waitForDatePickerIndicatorLoaded();
await ml.testExecution.logTestStep('clicks on the Overall swim lane cell');
const sampleCell = (await ml.swimLane.getCells(overallSwimLaneTestSubj))[0];
await ml.swimLane.selectSingleCell(overallSwimLaneTestSubj, {
x: sampleCell.x + cellSize,
y: sampleCell.y + cellSize,
});
await ml.swimLane.waitForSwimLanesToLoad();
});
it('attaches an embeddable to a case', async () => {
await ml.anomalyExplorer.attachAnomalyChartsToCase({
title: 'ML Charts Test case',
description: 'Case with an anomaly charts attachment',
tag: 'ml_anomaly_charts',
});
const expectedAttachment = {
jobIds: [testData.jobConfig.job_id],
timeRange: {
from: '2016-02-07T00:00:00.000Z',
to: '2016-02-11T23:59:54.000Z',
},
maxSeriesToPlot: 6,
} as AnomalyChartsEmbeddableInput;
expectedAttachment.id = stringHash(JSON.stringify(expectedAttachment)).toString();
await ml.cases.assertCaseWithAnomalyChartsAttachment(
{
title: 'ML Charts Test case',
description: 'Case with an anomaly charts attachment',
tag: 'ml_anomaly_charts',
reporter: USER.ML_POWERUSER,
},
expectedAttachment,
6
);
});
});
});
}
});

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { ProvidedType } from '@kbn/test';
import { FtrProviderContext } from '../../ftr_provider_context';
export type MlAnomalyCharts = ProvidedType<typeof AnomalyChartsProvider>;
export function AnomalyChartsProvider({ getService }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
return {
async assertAnomalyExplorerChartsCount(
chartsContainerSubj: string,
expectedChartsCount: number
) {
const chartsContainer = await testSubjects.find(chartsContainerSubj);
const actualChartsCount = (
await chartsContainer.findAllByClassName('ml-explorer-chart-container', 3000)
).length;
expect(actualChartsCount).to.eql(
expectedChartsCount,
`Expect ${expectedChartsCount} charts to appear, got ${actualChartsCount}`
);
},
};
}

View file

@ -10,11 +10,12 @@ import expect from '@kbn/expect';
import type { SwimlaneType } from '@kbn/ml-plugin/public/application/explorer/explorer_constants';
import { FtrProviderContext } from '../../ftr_provider_context';
import type { CreateCaseParams } from '../cases/create';
import { MlAnomalyCharts } from './anomaly_charts';
export function MachineLearningAnomalyExplorerProvider({
getPageObject,
getService,
}: FtrProviderContext) {
export function MachineLearningAnomalyExplorerProvider(
{ getPageObject, getService }: FtrProviderContext,
anomalyCharts: MlAnomalyCharts
) {
const dashboardPage = getPageObject('dashboard');
const retry = getService('retry');
const testSubjects = getService('testSubjects');
@ -174,13 +175,9 @@ export function MachineLearningAnomalyExplorerProvider({
},
async assertAnomalyExplorerChartsCount(expectedChartsCount: number) {
const chartsContainer = await testSubjects.find('mlExplorerChartsContainer');
const actualChartsCount = (
await chartsContainer.findAllByClassName('ml-explorer-chart-container', 3000)
).length;
expect(actualChartsCount).to.eql(
expectedChartsCount,
`Expect ${expectedChartsCount} charts to appear, got ${actualChartsCount}`
await anomalyCharts.assertAnomalyExplorerChartsCount(
'mlExplorerChartsContainer',
expectedChartsCount
);
},
@ -203,5 +200,12 @@ export function MachineLearningAnomalyExplorerProvider({
}' (got '${isEnabled ? 'enabled' : 'disabled'}')`
);
},
async attachAnomalyChartsToCase(params: CreateCaseParams) {
await testSubjects.click('mlExplorerAnomalyPanelMenu');
await testSubjects.click('mlAnomalyAttachChartsToCasesButton');
await cases.create.createCaseFromModal(params);
},
};
}

View file

@ -5,10 +5,12 @@
* 2.0.
*/
import { SwimlaneType } from '@kbn/ml-plugin/public/application/explorer/explorer_constants';
import { AnomalySwimlaneEmbeddableInput } from '@kbn/ml-plugin/public';
import { FtrProviderContext } from '../../ftr_provider_context';
import { MlAnomalySwimLane } from './swim_lane';
import type { SwimlaneType } from '@kbn/ml-plugin/public/application/explorer/explorer_constants';
import type { AnomalySwimlaneEmbeddableInput } from '@kbn/ml-plugin/public';
import type { AnomalyChartsEmbeddableInput } from '@kbn/ml-plugin/public/embeddables';
import type { FtrProviderContext } from '../../ftr_provider_context';
import type { MlAnomalySwimLane } from './swim_lane';
import type { MlAnomalyCharts } from './anomaly_charts';
export interface CaseParams {
title: string;
@ -23,7 +25,8 @@ export interface Attachment {
export function MachineLearningCasesProvider(
{ getPageObject, getService }: FtrProviderContext,
mlAnomalySwimLane: MlAnomalySwimLane
mlAnomalySwimLane: MlAnomalySwimLane,
mlAnomalyCharts: MlAnomalyCharts
) {
const testSubjects = getService('testSubjects');
const cases = getService('cases');
@ -38,6 +41,15 @@ export function MachineLearningCasesProvider(
await cases.casesTable.goToFirstListedCase();
},
async assertBasicCaseProps(params: CaseParams) {
await this.openCaseInCasesApp(params.tag);
await elasticChart.setNewChartUiDebugFlag(true);
// Assert case details
await cases.singleCase.assertCaseTitle(params.title);
await cases.singleCase.assertCaseDescription(params.description);
},
async assertCaseWithAnomalySwimLaneAttachment(
params: CaseParams,
attachment: AnomalySwimlaneEmbeddableInput,
@ -45,12 +57,7 @@ export function MachineLearningCasesProvider(
yAxisLabelCount: number;
}
) {
await this.openCaseInCasesApp(params.tag);
await elasticChart.setNewChartUiDebugFlag(true);
// Assert case details
await cases.singleCase.assertCaseTitle(params.title);
await cases.singleCase.assertCaseDescription(params.description);
await this.assertBasicCaseProps(params);
await testSubjects.existOrFail('comment-persistableState-ml_anomaly_swimlane');
@ -61,5 +68,19 @@ export function MachineLearningCasesProvider(
expectedSwimLaneState.yAxisLabelCount
);
},
async assertCaseWithAnomalyChartsAttachment(
params: CaseParams,
attachment: AnomalyChartsEmbeddableInput,
expectedChartsCount: number
) {
await this.assertBasicCaseProps(params);
await testSubjects.existOrFail('comment-persistableState-ml_anomaly_charts');
await mlAnomalyCharts.assertAnomalyExplorerChartsCount(
`mlExplorerEmbeddable_${attachment.id}`,
expectedChartsCount
);
},
};
}

View file

@ -58,6 +58,7 @@ import { TrainedModelsTableProvider } from './trained_models_table';
import { MachineLearningJobAnnotationsProvider } from './job_annotations_table';
import { MlNodesPanelProvider } from './ml_nodes_list';
import { MachineLearningCasesProvider } from './cases';
import { AnomalyChartsProvider } from './anomaly_charts';
export function MachineLearningProvider(context: FtrProviderContext) {
const commonAPI = MachineLearningCommonAPIProvider(context);
@ -65,7 +66,8 @@ export function MachineLearningProvider(context: FtrProviderContext) {
const commonDataGrid = MachineLearningCommonDataGridProvider(context);
const anomaliesTable = MachineLearningAnomaliesTableProvider(context);
const anomalyExplorer = MachineLearningAnomalyExplorerProvider(context);
const anomalyCharts = AnomalyChartsProvider(context);
const anomalyExplorer = MachineLearningAnomalyExplorerProvider(context, anomalyCharts);
const api = MachineLearningAPIProvider(context);
const commonConfig = MachineLearningCommonConfigsProvider(context);
const customUrls = MachineLearningCustomUrlsProvider(context);
@ -129,10 +131,11 @@ export function MachineLearningProvider(context: FtrProviderContext) {
const trainedModelsTable = TrainedModelsTableProvider(context, commonUI);
const mlNodesPanel = MlNodesPanelProvider(context);
const cases = MachineLearningCasesProvider(context, swimLane);
const cases = MachineLearningCasesProvider(context, swimLane, anomalyCharts);
return {
anomaliesTable,
anomalyCharts,
anomalyExplorer,
alerting,
api,

View file

@ -150,12 +150,19 @@ export function MachineLearningNavigationProvider({
await this.navigateToArea('~mlMainTab & ~anomalyDetection', 'mlPageJobManagement');
},
async navigateToAnomalyExplorer(jobId: string, timeRange: { from: string; to: string }) {
async navigateToAnomalyExplorer(
jobId: string,
timeRange: { from: string; to: string },
callback?: () => Promise<void>
) {
await PageObjects.common.navigateToUrlWithBrowserHistory(
'ml',
`/explorer`,
`?_g=(ml%3A(jobIds%3A!(${jobId}))%2CrefreshInterval%3A(display%3AOff%2Cpause%3A!t%2Cvalue%3A0)%2Ctime%3A(from%3A'${timeRange.from}'%2Cto%3A'${timeRange.to}'))`
);
if (callback) {
await callback();
}
await PageObjects.header.waitUntilLoadingHasFinished();
},