[ML] Add Anomaly Swimlane Embeddable to the dashboard from the Anomaly Explorer page (#68784)

* [ML] WIP attach swimlane embeddable to dashboard from the explorer page

* [ML] fix deps

* [ML] getDefaultPanelTitle

* [ML] fix TS issue

* [ML] DashboardService

* [ML] unit tests

* [ML] redirect to the dashboard

* [ML] swimlane_panel

* [ML] Anomaly Timeline panel

* [ML] swimlane container

* [ML] fix ts

* [ML] Add multiple swimlanes

* [ML] fix SwimlaneType usage

* [ML] disable edit button on update

* [ML] fix i18n translation key

* [ML] use ViewMode enum

* [ML] use navigateToUrl

* [ML] TODO for edit dashboard

* [ML] check kibana dashboard capabilities

* [ML] mlApiServicesProvider

* [ML] mlResultsServiceProvider

* [ML] fix alignment

* [ML] labels and tooltips

* [ML] fix ts issue for proxyHttpStart

* [ML] canEditDashboards check

* [ML] fix TS

* [ML] update add_to_dashboard_control.tsx

* [ML] add form label, disable control on empty swimlanes selection

* [ML] resolve PR review comments

* [ML] e2e test

* [ML] increase panel padding

* [ML] position in row

* [ML] update e2e

* [ML] add data-test-subj for search box

* [ML] PR remarks
This commit is contained in:
Dima Arnautov 2020-06-17 21:25:37 +02:00 committed by GitHub
parent 052dfe9f9a
commit 1dd5db2cf0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 3628 additions and 2650 deletions

View file

@ -32,8 +32,10 @@ export {
export { DashboardConstants, createDashboardEditUrl } from './dashboard_constants';
export { DashboardStart, DashboardUrlGenerator } from './plugin';
export { DASHBOARD_APP_URL_GENERATOR } from './url_generator';
export { DASHBOARD_APP_URL_GENERATOR, createDashboardUrlGenerator } from './url_generator';
export { addEmbeddableToDashboardUrl } from './url_utils/url_helper';
export { SavedObjectDashboard } from './saved_dashboards';
export { SavedDashboardPanel } from './types';
export function plugin(initializerContext: PluginInitializerContext) {
return new DashboardPlugin(initializerContext);

View file

@ -28,6 +28,7 @@ import {
import { setStateToKbnUrl } from '../../kibana_utils/public';
import { UrlGeneratorsDefinition, UrlGeneratorState } from '../../share/public';
import { SavedObjectLoader } from '../../saved_objects/public';
import { ViewMode } from '../../embeddable/public';
export const STATE_STORAGE_KEY = '_a';
export const GLOBAL_STATE_STORAGE_KEY = '_g';
@ -73,6 +74,11 @@ export type DashboardAppLinkGeneratorState = UrlGeneratorState<{
* true is default
*/
preserveSavedFilters?: boolean;
/**
* View mode of the dashboard.
*/
viewMode?: ViewMode;
}>;
export const createDashboardUrlGenerator = (
@ -123,6 +129,7 @@ export const createDashboardUrlGenerator = (
cleanEmptyKeys({
query: state.query,
filters: filters?.filter((f) => !esFilters.isFilterPinned(f)),
viewMode: state.viewMode,
}),
{ useHash },
`${appBasePath}#/${hash}`

View file

@ -61,6 +61,7 @@
"@types/d3-shape": "^1.3.1",
"@types/d3-time": "^1.0.10",
"@types/d3-time-format": "^2.1.1",
"@types/dragselect": "^1.13.1",
"@types/elasticsearch": "^5.0.33",
"@types/fancy-log": "^1.3.1",
"@types/file-saver": "^2.0.0",

View file

@ -35,6 +35,8 @@ const App: FC<AppProps> = ({ coreStart, deps }) => {
};
const services = {
appName: 'ML',
kibanaVersion: deps.kibanaVersion,
share: deps.share,
data: deps.data,
security: deps.security,
licenseManagement: deps.licenseManagement,

View file

@ -4,12 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import PropTypes from 'prop-types';
import React from 'react';
import React, { FC } from 'react';
import { EuiLoadingChart, EuiSpacer } from '@elastic/eui';
export function LoadingIndicator({ height, label }) {
export const LoadingIndicator: FC<{ height?: number; label?: string }> = ({ height, label }) => {
height = height ? +height : 100;
return (
<div
@ -21,13 +20,9 @@ export function LoadingIndicator({ height, label }) {
{label && (
<>
<EuiSpacer size="s" />
<div ml-loading-indicator-label="true">{label}</div>
<div>{label}</div>
</>
)}
</div>
);
}
LoadingIndicator.propTypes = {
height: PropTypes.number,
label: PropTypes.string,
};

View file

@ -12,13 +12,15 @@ import {
} from '../../../../../../../src/plugins/kibana_react/public';
import { SecurityPluginSetup } from '../../../../../security/public';
import { LicenseManagementUIPluginSetup } from '../../../../../license_management/public';
import { SharePluginStart } from '../../../../../../../src/plugins/share/public';
interface StartPlugins {
data: DataPublicPluginStart;
security?: SecurityPluginSetup;
licenseManagement?: LicenseManagementUIPluginSetup;
share: SharePluginStart;
}
export type StartServices = CoreStart & StartPlugins;
export type StartServices = CoreStart & StartPlugins & { kibanaVersion: string };
// eslint-disable-next-line react-hooks/rules-of-hooks
export const useMlKibana = () => useKibana<StartServices>();
export type MlKibanaReactContextValue = KibanaReactContextValue<StartServices>;

View file

@ -14,6 +14,7 @@ export interface MlContextValue {
currentSavedSearch: SavedSearchSavedObject | null;
indexPatterns: IndexPatternsContract;
kibanaConfig: any; // IUiSettingsClient;
kibanaVersion: string;
}
export type SavedSearchQuery = object;

View file

@ -0,0 +1,321 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, useCallback, useMemo, useState, useEffect } from 'react';
import { debounce } from 'lodash';
import {
EuiFormRow,
EuiCheckboxGroup,
EuiInMemoryTableProps,
EuiModal,
EuiModalHeader,
EuiModalHeaderTitle,
EuiOverlayMask,
EuiSpacer,
EuiButtonEmpty,
EuiButton,
EuiModalFooter,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiModalBody } from '@elastic/eui';
import { EuiInMemoryTable } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useMlKibana } from '../contexts/kibana';
import { SavedObjectDashboard } from '../../../../../../src/plugins/dashboard/public';
import {
ANOMALY_SWIMLANE_EMBEDDABLE_TYPE,
getDefaultPanelTitle,
} from '../../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable';
import { useDashboardService } from '../services/dashboard_service';
import { SWIMLANE_TYPE, SwimlaneType } from './explorer_constants';
import { JobId } from '../../../common/types/anomaly_detection_jobs';
export interface DashboardItem {
id: string;
title: string;
description: string | undefined;
attributes: SavedObjectDashboard;
}
export type EuiTableProps = EuiInMemoryTableProps<DashboardItem>;
function getDefaultEmbeddablepaPanelConfig(jobIds: JobId[]) {
return {
type: ANOMALY_SWIMLANE_EMBEDDABLE_TYPE,
title: getDefaultPanelTitle(jobIds),
};
}
interface AddToDashboardControlProps {
jobIds: JobId[];
viewBy: string;
limit: number;
onClose: (callback?: () => Promise<any>) => void;
}
/**
* Component for attaching anomaly swimlane embeddable to dashboards.
*/
export const AddToDashboardControl: FC<AddToDashboardControlProps> = ({
onClose,
jobIds,
viewBy,
limit,
}) => {
const {
notifications: { toasts },
services: {
application: { navigateToUrl },
},
} = useMlKibana();
useEffect(() => {
fetchDashboards();
return () => {
fetchDashboards.cancel();
};
}, []);
const dashboardService = useDashboardService();
const [isLoading, setIsLoading] = useState(false);
const [selectedSwimlanes, setSelectedSwimlanes] = useState<{ [key in SwimlaneType]: boolean }>({
[SWIMLANE_TYPE.OVERALL]: true,
[SWIMLANE_TYPE.VIEW_BY]: false,
});
const [dashboardItems, setDashboardItems] = useState<DashboardItem[]>([]);
const [selectedItems, setSelectedItems] = useState<DashboardItem[]>([]);
const fetchDashboards = useCallback(
debounce(async (query?: string) => {
try {
const response = await dashboardService.fetchDashboards(query);
const items: DashboardItem[] = response.savedObjects.map((savedObject) => {
return {
id: savedObject.id,
title: savedObject.attributes.title,
description: savedObject.attributes.description,
attributes: savedObject.attributes,
};
});
setDashboardItems(items);
} catch (e) {
toasts.danger({
body: e,
});
}
setIsLoading(false);
}, 500),
[]
);
const search: EuiTableProps['search'] = useMemo(() => {
return {
onChange: ({ queryText }) => {
setIsLoading(true);
fetchDashboards(queryText);
},
box: {
incremental: true,
'data-test-subj': 'mlDashboardsSearchBox',
},
};
}, []);
const addSwimlaneToDashboardCallback = useCallback(async () => {
const swimlanes = Object.entries(selectedSwimlanes)
.filter(([, isSelected]) => isSelected)
.map(([swimlaneType]) => swimlaneType);
for (const selectedDashboard of selectedItems) {
const panelsData = swimlanes.map((swimlaneType) => {
const config = getDefaultEmbeddablepaPanelConfig(jobIds);
if (swimlaneType === SWIMLANE_TYPE.VIEW_BY) {
return {
...config,
embeddableConfig: {
jobIds,
swimlaneType,
viewBy,
limit,
},
};
}
return {
...config,
embeddableConfig: {
jobIds,
swimlaneType,
},
};
});
try {
await dashboardService.attachPanels(
selectedDashboard.id,
selectedDashboard.attributes,
panelsData
);
toasts.success({
title: (
<FormattedMessage
id="xpack.ml.explorer.dashboardsTable.savedSuccessfullyTitle"
defaultMessage='Dashboard "{dashboardTitle}" updated successfully'
values={{ dashboardTitle: selectedDashboard.title }}
/>
),
toastLifeTimeMs: 3000,
});
} catch (e) {
toasts.danger({
body: e,
});
}
}
}, [selectedSwimlanes, selectedItems]);
const columns: EuiTableProps['columns'] = [
{
field: 'title',
name: i18n.translate('xpack.ml.explorer.dashboardsTable.titleColumnHeader', {
defaultMessage: 'Title',
}),
sortable: true,
truncateText: true,
},
{
field: 'description',
name: i18n.translate('xpack.ml.explorer.dashboardsTable.descriptionColumnHeader', {
defaultMessage: 'Description',
}),
truncateText: true,
},
];
const swimlaneTypeOptions = [
{
id: SWIMLANE_TYPE.OVERALL,
label: i18n.translate('xpack.ml.explorer.overallLabel', {
defaultMessage: 'Overall',
}),
},
{
id: SWIMLANE_TYPE.VIEW_BY,
label: i18n.translate('xpack.ml.explorer.viewByFieldLabel', {
defaultMessage: 'View by {viewByField}, up to {limit} rows',
values: { viewByField: viewBy, limit },
}),
},
];
const selection: EuiTableProps['selection'] = {
onSelectionChange: setSelectedItems,
};
const noSwimlaneSelected = Object.values(selectedSwimlanes).every((isSelected) => !isSelected);
return (
<EuiOverlayMask data-test-subj="mlAddToDashboardModal">
<EuiModal onClose={onClose.bind(null, undefined)}>
<EuiModalHeader>
<EuiModalHeaderTitle>
<FormattedMessage
id="xpack.ml.explorer.dashboardsTitle"
defaultMessage="Add swimlanes to dashboards"
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiFormRow
label={
<FormattedMessage
id="xpack.ml.explorer.addToDashboard.selectSwimlanesLabel"
defaultMessage="Select swimlane view:"
/>
}
>
<EuiCheckboxGroup
options={swimlaneTypeOptions}
idToSelectedMap={selectedSwimlanes}
onChange={(optionId) => {
const newSelection = {
...selectedSwimlanes,
[optionId]: !selectedSwimlanes[optionId as SwimlaneType],
};
setSelectedSwimlanes(newSelection);
}}
data-test-subj="mlAddToDashboardSwimlaneTypeSelector"
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFormRow
fullWidth
label={
<FormattedMessage
id="xpack.ml.explorer.addToDashboard.selectDashboardsLabel"
defaultMessage="Select dashboards:"
/>
}
data-test-subj="mlDashboardSelectionContainer"
>
<EuiInMemoryTable
itemId="id"
isSelectable={true}
selection={selection}
items={dashboardItems}
loading={isLoading}
columns={columns}
search={search}
pagination={true}
sorting={true}
data-test-subj="mlDashboardSelectionTable"
/>
</EuiFormRow>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={onClose.bind(null, undefined)}>
<FormattedMessage
id="xpack.ml.explorer.addToDashboard.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
<EuiButton
disabled={noSwimlaneSelected || selectedItems.length !== 1}
onClick={async () => {
onClose(async () => {
const selectedDashboardId = selectedItems[0].id;
await addSwimlaneToDashboardCallback();
await navigateToUrl(
await dashboardService.getDashboardEditUrl(selectedDashboardId)
);
});
}}
data-test-subj="mlAddAndEditDashboardButton"
>
<FormattedMessage
id="xpack.ml.explorer.dashboardsTable.addAndEditDashboardLabel"
defaultMessage="Add and edit dashboard"
/>
</EuiButton>
<EuiButton
fill
onClick={onClose.bind(null, addSwimlaneToDashboardCallback)}
disabled={noSwimlaneSelected || selectedItems.length === 0}
data-test-subj="mlAddToDashboardsButton"
>
<FormattedMessage
id="xpack.ml.explorer.dashboardsTable.addToDashboardLabel"
defaultMessage="Add to dashboards"
/>
</EuiButton>
</EuiModalFooter>
</EuiModal>
</EuiOverlayMask>
);
};

View file

@ -0,0 +1,392 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, useCallback, useMemo, useRef, useState } from 'react';
import { isEqual } from 'lodash';
import DragSelect from 'dragselect';
import {
EuiPanel,
EuiPopover,
EuiContextMenuPanel,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiSelect,
EuiTitle,
EuiSpacer,
EuiContextMenuItem,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { DRAG_SELECT_ACTION, VIEW_BY_JOB_LABEL } from './explorer_constants';
import { AddToDashboardControl } from './add_to_dashboard_control';
import { useMlKibana } from '../contexts/kibana';
import { TimeBuckets } from '../util/time_buckets';
import { UI_SETTINGS } from '../../../../../../src/plugins/data/common';
import { SelectLimit } from './select_limit';
import {
ALLOW_CELL_RANGE_SELECTION,
dragSelect$,
explorerService,
} from './explorer_dashboard_service';
import { ExplorerState } from './reducers/explorer_reducer';
import { hasMatchingPoints } from './has_matching_points';
import { ExplorerNoInfluencersFound } from './components/explorer_no_influencers_found/explorer_no_influencers_found';
import { LoadingIndicator } from '../components/loading_indicator';
import { SwimlaneContainer } from './swimlane_container';
import { OverallSwimlaneData } from './explorer_utils';
function mapSwimlaneOptionsToEuiOptions(options: string[]) {
return options.map((option) => ({
value: option,
text: option,
}));
}
interface AnomalyTimelineProps {
explorerState: ExplorerState;
setSelectedCells: (cells?: any) => void;
}
export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
({ explorerState, setSelectedCells }) => {
const {
services: {
uiSettings,
application: { capabilities },
},
} = useMlKibana();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isAddDashboardsActive, setIsAddDashboardActive] = useState(false);
const isSwimlaneSelectActive = useRef(false);
// make sure dragSelect is only available if the mouse pointer is actually over a swimlane
const disableDragSelectOnMouseLeave = useRef(true);
const canEditDashboards = capabilities.dashboard?.createNew ?? false;
const timeBuckets = useMemo(() => {
return new TimeBuckets({
'histogram:maxBars': uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS),
'histogram:barTarget': uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET),
dateFormat: uiSettings.get('dateFormat'),
'dateFormat:scaled': uiSettings.get('dateFormat:scaled'),
});
}, [uiSettings]);
const dragSelect = useMemo(
() =>
new DragSelect({
selectorClass: 'ml-swimlane-selector',
selectables: document.querySelectorAll('.sl-cell'),
callback(elements) {
if (elements.length > 1 && !ALLOW_CELL_RANGE_SELECTION) {
elements = [elements[0]];
}
if (elements.length > 0) {
dragSelect$.next({
action: DRAG_SELECT_ACTION.NEW_SELECTION,
elements,
});
}
disableDragSelectOnMouseLeave.current = true;
},
onDragStart(e) {
let target = e.target as HTMLElement;
while (target && target !== document.body && !target.classList.contains('sl-cell')) {
target = target.parentNode as HTMLElement;
}
if (ALLOW_CELL_RANGE_SELECTION && target !== document.body) {
dragSelect$.next({
action: DRAG_SELECT_ACTION.DRAG_START,
});
disableDragSelectOnMouseLeave.current = false;
}
},
onElementSelect() {
if (ALLOW_CELL_RANGE_SELECTION) {
dragSelect$.next({
action: DRAG_SELECT_ACTION.ELEMENT_SELECT,
});
}
},
}),
[]
);
const {
filterActive,
filteredFields,
maskAll,
overallSwimlaneData,
selectedCells,
viewByLoadedForTimeFormatted,
viewBySwimlaneData,
viewBySwimlaneDataLoading,
viewBySwimlaneFieldName,
viewBySwimlaneOptions,
swimlaneLimit,
selectedJobs,
} = explorerState;
const setSwimlaneSelectActive = useCallback((active: boolean) => {
if (isSwimlaneSelectActive.current && !active && disableDragSelectOnMouseLeave.current) {
dragSelect.stop();
isSwimlaneSelectActive.current = active;
return;
}
if (!isSwimlaneSelectActive.current && active) {
dragSelect.start();
dragSelect.clearSelection();
dragSelect.setSelectables(document.querySelectorAll('.sl-cell'));
isSwimlaneSelectActive.current = active;
}
}, []);
const onSwimlaneEnterHandler = () => setSwimlaneSelectActive(true);
const onSwimlaneLeaveHandler = () => setSwimlaneSelectActive(false);
// Listens to render updates of the swimlanes to update dragSelect
const swimlaneRenderDoneListener = useCallback(() => {
dragSelect.clearSelection();
dragSelect.setSelectables(document.querySelectorAll('.sl-cell'));
}, []);
// Listener for click events in the swimlane to load corresponding anomaly data.
const swimlaneCellClick = useCallback((selectedCellsUpdate: any) => {
// If selectedCells is an empty object we clear any existing selection,
// otherwise we save the new selection in AppState and update the Explorer.
if (Object.keys(selectedCellsUpdate).length === 0) {
setSelectedCells();
} else {
setSelectedCells(selectedCellsUpdate);
}
}, []);
const showOverallSwimlane =
overallSwimlaneData !== null &&
overallSwimlaneData.laneLabels &&
overallSwimlaneData.laneLabels.length > 0;
const showViewBySwimlane =
viewBySwimlaneData !== null &&
viewBySwimlaneData.laneLabels &&
viewBySwimlaneData.laneLabels.length > 0;
const menuItems = useMemo(() => {
const items = [];
if (canEditDashboards) {
items.push(
<EuiContextMenuItem
key="addToDashboard"
onClick={setIsAddDashboardActive.bind(null, true)}
data-test-subj="mlAnomalyTimelinePanelAddToDashboardButton"
>
<FormattedMessage
id="xpack.ml.explorer.addToDashboardLabel"
defaultMessage="Add to dashboard"
/>
</EuiContextMenuItem>
);
}
return items;
}, [canEditDashboards]);
return (
<>
<EuiPanel paddingSize="m">
<EuiFlexGroup direction="row" gutterSize="m" responsive={false} alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle className="panel-title">
<h2>
<FormattedMessage
id="xpack.ml.explorer.anomalyTimelineTitle"
defaultMessage="Anomaly timeline"
/>
</h2>
</EuiTitle>
</EuiFlexItem>
{viewBySwimlaneOptions.length > 0 && (
<>
<EuiFlexItem grow={false}>
<EuiFormRow
label={
<span className="eui-textNoWrap">
<FormattedMessage
id="xpack.ml.explorer.viewByLabel"
defaultMessage="View by"
/>
</span>
}
display={'columnCompressed'}
>
<EuiSelect
compressed
id="selectViewBy"
options={mapSwimlaneOptionsToEuiOptions(viewBySwimlaneOptions)}
value={viewBySwimlaneFieldName}
onChange={(e) => explorerService.setViewBySwimlaneFieldName(e.target.value)}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormRow
label={
<span className="eui-textNoWrap">
<FormattedMessage
id="xpack.ml.explorer.limitLabel"
defaultMessage="Limit"
/>
</span>
}
display={'columnCompressed'}
>
<SelectLimit />
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ alignSelf: 'center' }}>
<div className="panel-sub-title">
{viewByLoadedForTimeFormatted && (
<FormattedMessage
id="xpack.ml.explorer.sortedByMaxAnomalyScoreForTimeFormattedLabel"
defaultMessage="(Sorted by max anomaly score for {viewByLoadedForTimeFormatted})"
values={{ viewByLoadedForTimeFormatted }}
/>
)}
{viewByLoadedForTimeFormatted === undefined && (
<FormattedMessage
id="xpack.ml.explorer.sortedByMaxAnomalyScoreLabel"
defaultMessage="(Sorted by max anomaly score)"
/>
)}
{filterActive === true && viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL && (
<FormattedMessage
id="xpack.ml.explorer.jobScoreAcrossAllInfluencersLabel"
defaultMessage="(Job score across all influencers)"
/>
)}
</div>
</EuiFlexItem>
</>
)}
{menuItems.length > 0 && (
<EuiFlexItem grow={false} style={{ marginLeft: 'auto', alignSelf: 'baseline' }}>
<EuiPopover
button={
<EuiButtonIcon
size="s"
aria-label={i18n.translate('xpack.ml.explorer.swimlaneActions', {
defaultMessage: 'Actions',
})}
color="subdued"
iconType="boxesHorizontal"
onClick={setIsMenuOpen.bind(null, !isMenuOpen)}
data-test-subj="mlAnomalyTimelinePanelMenu"
/>
}
isOpen={isMenuOpen}
closePopover={setIsMenuOpen.bind(null, false)}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel items={menuItems} />
</EuiPopover>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer size="m" />
<div
className="ml-explorer-swimlane euiText"
onMouseEnter={onSwimlaneEnterHandler}
onMouseLeave={onSwimlaneLeaveHandler}
data-test-subj="mlAnomalyExplorerSwimlaneOverall"
>
{showOverallSwimlane && (
<SwimlaneContainer
filterActive={filterActive}
maskAll={maskAll}
timeBuckets={timeBuckets}
swimlaneCellClick={swimlaneCellClick}
swimlaneData={overallSwimlaneData as OverallSwimlaneData}
swimlaneType={'overall'}
selection={selectedCells}
swimlaneRenderDoneListener={swimlaneRenderDoneListener}
onResize={(width) => explorerService.setSwimlaneContainerWidth(width)}
/>
)}
</div>
{viewBySwimlaneOptions.length > 0 && (
<>
{showViewBySwimlane && (
<>
<EuiSpacer size="m" />
<div
className="ml-explorer-swimlane euiText"
onMouseEnter={onSwimlaneEnterHandler}
onMouseLeave={onSwimlaneLeaveHandler}
data-test-subj="mlAnomalyExplorerSwimlaneViewBy"
>
<SwimlaneContainer
filterActive={filterActive}
maskAll={
maskAll &&
!hasMatchingPoints({
filteredFields,
swimlaneData: viewBySwimlaneData,
})
}
timeBuckets={timeBuckets}
swimlaneCellClick={swimlaneCellClick}
swimlaneData={viewBySwimlaneData as OverallSwimlaneData}
swimlaneType={'viewBy'}
selection={selectedCells}
swimlaneRenderDoneListener={swimlaneRenderDoneListener}
onResize={(width) => explorerService.setSwimlaneContainerWidth(width)}
/>
</div>
</>
)}
{viewBySwimlaneDataLoading && <LoadingIndicator />}
{!showViewBySwimlane &&
!viewBySwimlaneDataLoading &&
typeof viewBySwimlaneFieldName === 'string' && (
<ExplorerNoInfluencersFound
viewBySwimlaneFieldName={viewBySwimlaneFieldName}
showFilterMessage={filterActive === true}
/>
)}
</>
)}
</EuiPanel>
{isAddDashboardsActive && selectedJobs && (
<AddToDashboardControl
onClose={async (callback) => {
setIsAddDashboardActive(false);
if (callback) {
await callback();
}
}}
jobIds={selectedJobs.map(({ id }) => id)}
viewBy={viewBySwimlaneFieldName!}
limit={swimlaneLimit}
/>
)}
</>
);
},
(prevProps, nextProps) => {
return isEqual(prevProps.explorerState, nextProps.explorerState);
}
);

View file

@ -4,20 +4,18 @@
* you may not use this file except in compliance with the Elastic License.
*/
/*
* React component for rendering EuiEmptyPrompt when no influencers were found.
*/
import PropTypes from 'prop-types';
import React from 'react';
import React, { FC } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiEmptyPrompt } from '@elastic/eui';
export const ExplorerNoInfluencersFound = ({
viewBySwimlaneFieldName,
showFilterMessage = false,
}) => (
/*
* React component for rendering EuiEmptyPrompt when no influencers were found.
*/
export const ExplorerNoInfluencersFound: FC<{
viewBySwimlaneFieldName: string;
showFilterMessage?: boolean;
}> = ({ viewBySwimlaneFieldName, showFilterMessage = false }) => (
<EuiEmptyPrompt
titleSize="xs"
title={
@ -40,8 +38,3 @@ export const ExplorerNoInfluencersFound = ({
}
/>
);
ExplorerNoInfluencersFound.propTypes = {
viewBySwimlaneFieldName: PropTypes.string.isRequired,
showFilterMessage: PropTypes.bool,
};

View file

@ -9,10 +9,9 @@
*/
import PropTypes from 'prop-types';
import React, { createRef } from 'react';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import DragSelect from 'dragselect/dist/ds.min.js';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -26,34 +25,23 @@ import {
EuiPageBody,
EuiPageHeader,
EuiPageHeaderSection,
EuiSelect,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { AnnotationFlyout } from '../components/annotations/annotation_flyout';
import { AnnotationsTable } from '../components/annotations/annotations_table';
import {
ExplorerNoInfluencersFound,
ExplorerNoJobsFound,
ExplorerNoResultsFound,
} from './components';
import { ExplorerSwimlane } from './explorer_swimlane';
import { getTimeBucketsFromCache } from '../util/time_buckets';
import { ExplorerNoJobsFound, ExplorerNoResultsFound } from './components';
import { DatePickerWrapper } from '../components/navigation_menu/date_picker_wrapper';
import { InfluencersList } from '../components/influencers_list';
import {
ALLOW_CELL_RANGE_SELECTION,
dragSelect$,
explorerService,
} from './explorer_dashboard_service';
import { explorerService } from './explorer_dashboard_service';
import { AnomalyResultsViewSelector } from '../components/anomaly_results_view_selector';
import { LoadingIndicator } from '../components/loading_indicator/loading_indicator';
import { NavigationMenu } from '../components/navigation_menu';
import { CheckboxShowCharts } from '../components/controls/checkbox_showcharts';
import { JobSelector } from '../components/job_selector';
import { SelectInterval } from '../components/controls/select_interval/select_interval';
import { SelectLimit, limit$ } from './select_limit/select_limit';
import { limit$ } from './select_limit/select_limit';
import { SelectSeverity } from '../components/controls/select_severity/select_severity';
import {
ExplorerQueryBar,
@ -67,14 +55,9 @@ import {
escapeParens,
escapeDoubleQuotes,
} from './explorer_utils';
import { getSwimlaneContainerWidth } from './legacy_utils';
import { AnomalyTimeline } from './anomaly_timeline';
import {
DRAG_SELECT_ACTION,
FILTER_ACTION,
SWIMLANE_TYPE,
VIEW_BY_JOB_LABEL,
} from './explorer_constants';
import { FILTER_ACTION } from './explorer_constants';
// Explorer Charts
import { ExplorerChartsContainer } from './explorer_charts/explorer_charts_container';
@ -82,17 +65,7 @@ import { ExplorerChartsContainer } from './explorer_charts/explorer_charts_conta
// Anomalies Table
import { AnomaliesTable } from '../components/anomalies_table/anomalies_table';
import { ResizeChecker } from '../../../../../../src/plugins/kibana_utils/public';
import { getTimefilter, getToastNotifications } from '../util/dependency_cache';
import { MlTooltipComponent } from '../components/chart_tooltip';
import { hasMatchingPoints } from './has_matching_points';
function mapSwimlaneOptionsToEuiOptions(options) {
return options.map((option) => ({
value: option,
text: option,
}));
}
const ExplorerPage = ({
children,
@ -105,9 +78,8 @@ const ExplorerPage = ({
queryString,
filterIconTriggeredQuery,
updateLanguage,
resizeRef,
}) => (
<div ref={resizeRef} data-test-subj="mlPageAnomalyExplorer">
<div data-test-subj="mlPageAnomalyExplorer">
<NavigationMenu tabId="anomaly_detection" />
<EuiPage style={{ background: 'none' }}>
<EuiPageBody>
@ -171,108 +143,18 @@ export class Explorer extends React.Component {
state = { filterIconTriggeredQuery: undefined, language: DEFAULT_QUERY_LANG };
_unsubscribeAll = new Subject();
// make sure dragSelect is only available if the mouse pointer is actually over a swimlane
disableDragSelectOnMouseLeave = true;
dragSelect = new DragSelect({
selectorClass: 'ml-swimlane-selector',
selectables: document.getElementsByClassName('sl-cell'),
callback(elements) {
if (elements.length > 1 && !ALLOW_CELL_RANGE_SELECTION) {
elements = [elements[0]];
}
if (elements.length > 0) {
dragSelect$.next({
action: DRAG_SELECT_ACTION.NEW_SELECTION,
elements,
});
}
this.disableDragSelectOnMouseLeave = true;
},
onDragStart(e) {
let target = e.target;
while (target && target !== document.body && !target.classList.contains('sl-cell')) {
target = target.parentNode;
}
if (ALLOW_CELL_RANGE_SELECTION && target !== document.body) {
dragSelect$.next({
action: DRAG_SELECT_ACTION.DRAG_START,
});
this.disableDragSelectOnMouseLeave = false;
}
},
onElementSelect() {
if (ALLOW_CELL_RANGE_SELECTION) {
dragSelect$.next({
action: DRAG_SELECT_ACTION.ELEMENT_SELECT,
});
}
},
});
// Listens to render updates of the swimlanes to update dragSelect
swimlaneRenderDoneListener = () => {
this.dragSelect.clearSelection();
this.dragSelect.setSelectables(document.getElementsByClassName('sl-cell'));
};
resizeRef = createRef();
resizeChecker = undefined;
resizeHandler = () => {
explorerService.setSwimlaneContainerWidth(getSwimlaneContainerWidth());
};
componentDidMount() {
limit$.pipe(takeUntil(this._unsubscribeAll)).subscribe(explorerService.setSwimlaneLimit);
// Required to redraw the time series chart when the container is resized.
this.resizeChecker = new ResizeChecker(this.resizeRef.current);
this.resizeChecker.on('resize', this.resizeHandler);
this.timeBuckets = getTimeBucketsFromCache();
}
componentWillUnmount() {
this._unsubscribeAll.next();
this._unsubscribeAll.complete();
this.resizeChecker.destroy();
}
resetCache() {
this.anomaliesTablePreviousArgs = null;
}
viewByChangeHandler = (e) => explorerService.setViewBySwimlaneFieldName(e.target.value);
isSwimlaneSelectActive = false;
onSwimlaneEnterHandler = () => this.setSwimlaneSelectActive(true);
onSwimlaneLeaveHandler = () => this.setSwimlaneSelectActive(false);
setSwimlaneSelectActive = (active) => {
if (this.isSwimlaneSelectActive && !active && this.disableDragSelectOnMouseLeave) {
this.dragSelect.stop();
this.isSwimlaneSelectActive = active;
return;
}
if (!this.isSwimlaneSelectActive && active) {
this.dragSelect.start();
this.dragSelect.clearSelection();
this.dragSelect.setSelectables(document.getElementsByClassName('sl-cell'));
this.isSwimlaneSelectActive = active;
}
};
// Listener for click events in the swimlane to load corresponding anomaly data.
swimlaneCellClick = (selectedCells) => {
// If selectedCells is an empty object we clear any existing selection,
// otherwise we save the new selection in AppState and update the Explorer.
if (Object.keys(selectedCells).length === 0) {
this.props.setSelectedCells();
} else {
this.props.setSelectedCells(selectedCells);
}
};
// Escape regular parens from fieldName as that portion of the query is not wrapped in double quotes
// and will cause a syntax error when called with getKqlQueryValues
applyFilter = (fieldName, fieldValue, action) => {
@ -339,24 +221,16 @@ export class Explorer extends React.Component {
annotationsData,
chartsData,
filterActive,
filteredFields,
filterPlaceHolder,
indexPattern,
influencers,
loading,
maskAll,
noInfluencersConfigured,
overallSwimlaneData,
queryString,
selectedCells,
selectedJobs,
swimlaneContainerWidth,
tableData,
viewByLoadedForTimeFormatted,
viewBySwimlaneData,
viewBySwimlaneDataLoading,
viewBySwimlaneFieldName,
viewBySwimlaneOptions,
} = this.props.explorerState;
const jobSelectorProps = {
@ -378,7 +252,6 @@ export class Explorer extends React.Component {
indexPattern={indexPattern}
queryString={queryString}
updateLanguage={this.updateLanguage}
resizeRef={this.resizeRef}
>
<LoadingIndicator
label={i18n.translate('xpack.ml.explorer.loadingLabel', {
@ -391,7 +264,7 @@ export class Explorer extends React.Component {
if (noJobsFound) {
return (
<ExplorerPage jobSelectorProps={jobSelectorProps} resizeRef={this.resizeRef}>
<ExplorerPage jobSelectorProps={jobSelectorProps}>
<ExplorerNoJobsFound />
</ExplorerPage>
);
@ -399,7 +272,7 @@ export class Explorer extends React.Component {
if (noJobsFound && hasResults === false) {
return (
<ExplorerPage jobSelectorProps={jobSelectorProps} resizeRef={this.resizeRef}>
<ExplorerPage jobSelectorProps={jobSelectorProps}>
<ExplorerNoResultsFound />
</ExplorerPage>
);
@ -408,15 +281,6 @@ export class Explorer extends React.Component {
const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10';
const mainColumnClasses = `column ${mainColumnWidthClassName}`;
const showOverallSwimlane =
overallSwimlaneData !== null &&
overallSwimlaneData.laneLabels &&
overallSwimlaneData.laneLabels.length > 0;
const showViewBySwimlane =
viewBySwimlaneData !== null &&
viewBySwimlaneData.laneLabels &&
viewBySwimlaneData.laneLabels.length > 0;
const timefilter = getTimefilter();
const bounds = timefilter.getActiveBounds();
@ -431,7 +295,6 @@ export class Explorer extends React.Component {
indexPattern={indexPattern}
queryString={queryString}
updateLanguage={this.updateLanguage}
resizeRef={this.resizeRef}
>
<div className="results-container">
{noInfluencersConfigured && (
@ -462,142 +325,12 @@ export class Explorer extends React.Component {
)}
<div className={mainColumnClasses}>
<EuiTitle className="panel-title">
<h2>
<FormattedMessage
id="xpack.ml.explorer.anomalyTimelineTitle"
defaultMessage="Anomaly timeline"
/>
</h2>
</EuiTitle>
<div
className="ml-explorer-swimlane euiText"
onMouseEnter={this.onSwimlaneEnterHandler}
onMouseLeave={this.onSwimlaneLeaveHandler}
data-test-subj="mlAnomalyExplorerSwimlaneOverall"
>
{showOverallSwimlane && (
<MlTooltipComponent>
{(tooltipService) => (
<ExplorerSwimlane
chartWidth={swimlaneContainerWidth}
filterActive={filterActive}
filteredFields={filteredFields}
maskAll={maskAll}
timeBuckets={this.timeBuckets}
swimlaneCellClick={this.swimlaneCellClick}
swimlaneData={overallSwimlaneData}
swimlaneType={SWIMLANE_TYPE.OVERALL}
selection={selectedCells}
swimlaneRenderDoneListener={this.swimlaneRenderDoneListener}
tooltipService={tooltipService}
/>
)}
</MlTooltipComponent>
)}
</div>
{viewBySwimlaneOptions.length > 0 && (
<>
<EuiFlexGroup direction="row" gutterSize="l" responsive={true}>
<EuiFlexItem grow={false}>
<EuiFormRow
label={i18n.translate('xpack.ml.explorer.viewByLabel', {
defaultMessage: 'View by',
})}
>
<EuiSelect
id="selectViewBy"
options={mapSwimlaneOptionsToEuiOptions(viewBySwimlaneOptions)}
value={viewBySwimlaneFieldName}
onChange={this.viewByChangeHandler}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormRow
label={i18n.translate('xpack.ml.explorer.limitLabel', {
defaultMessage: 'Limit',
})}
>
<SelectLimit />
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ alignSelf: 'center' }}>
<EuiFormRow label="&#8203;">
<div className="panel-sub-title">
{viewByLoadedForTimeFormatted && (
<FormattedMessage
id="xpack.ml.explorer.sortedByMaxAnomalyScoreForTimeFormattedLabel"
defaultMessage="(Sorted by max anomaly score for {viewByLoadedForTimeFormatted})"
values={{ viewByLoadedForTimeFormatted }}
/>
)}
{viewByLoadedForTimeFormatted === undefined && (
<FormattedMessage
id="xpack.ml.explorer.sortedByMaxAnomalyScoreLabel"
defaultMessage="(Sorted by max anomaly score)"
/>
)}
{filterActive === true && viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL && (
<FormattedMessage
id="xpack.ml.explorer.jobScoreAcrossAllInfluencersLabel"
defaultMessage="(Job score across all influencers)"
/>
)}
</div>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
{showViewBySwimlane && (
<>
<EuiSpacer size="m" />
<div
className="ml-explorer-swimlane euiText"
onMouseEnter={this.onSwimlaneEnterHandler}
onMouseLeave={this.onSwimlaneLeaveHandler}
data-test-subj="mlAnomalyExplorerSwimlaneViewBy"
>
<MlTooltipComponent>
{(tooltipService) => (
<ExplorerSwimlane
chartWidth={swimlaneContainerWidth}
filterActive={filterActive}
maskAll={
maskAll &&
!hasMatchingPoints({
filteredFields,
swimlaneData: viewBySwimlaneData,
})
}
timeBuckets={this.timeBuckets}
swimlaneCellClick={this.swimlaneCellClick}
swimlaneData={viewBySwimlaneData}
swimlaneType={SWIMLANE_TYPE.VIEW_BY}
selection={selectedCells}
swimlaneRenderDoneListener={this.swimlaneRenderDoneListener}
tooltipService={tooltipService}
/>
)}
</MlTooltipComponent>
</div>
</>
)}
{viewBySwimlaneDataLoading && <LoadingIndicator />}
{!showViewBySwimlane &&
!viewBySwimlaneDataLoading &&
viewBySwimlaneFieldName !== null && (
<ExplorerNoInfluencersFound
viewBySwimlaneFieldName={viewBySwimlaneFieldName}
showFilterMessage={filterActive === true}
/>
)}
</>
)}
<EuiSpacer size="m" />
<AnomalyTimeline
explorerState={this.props.explorerState}
setSelectedCells={this.props.setSelectedCells}
/>
<EuiSpacer size="m" />
{annotationsData.length > 0 && (
<>

View file

@ -37,10 +37,12 @@ export const FILTER_ACTION = {
REMOVE: '-',
};
export enum SWIMLANE_TYPE {
OVERALL = 'overall',
VIEW_BY = 'viewBy',
}
export const SWIMLANE_TYPE = {
OVERALL: 'overall',
VIEW_BY: 'viewBy',
} as const;
export type SwimlaneType = typeof SWIMLANE_TYPE[keyof typeof SWIMLANE_TYPE];
export const CHART_TYPE = {
EVENT_DISTRIBUTION: 'event_distribution',

View file

@ -22,7 +22,7 @@ import { numTicksForDateFormat } from '../util/chart_utils';
import { getSeverityColor } from '../../../common/util/anomaly_utils';
import { mlEscape } from '../util/string_utils';
import { ALLOW_CELL_RANGE_SELECTION, dragSelect$ } from './explorer_dashboard_service';
import { DRAG_SELECT_ACTION } from './explorer_constants';
import { DRAG_SELECT_ACTION, SwimlaneType } from './explorer_constants';
import { EMPTY_FIELD_VALUE_LABEL } from '../timeseriesexplorer/components/entity_control/entity_control';
import { TimeBuckets as TimeBucketsClass } from '../util/time_buckets';
import {
@ -58,7 +58,7 @@ export interface ExplorerSwimlaneProps {
timeBuckets: InstanceType<typeof TimeBucketsClass>;
swimlaneCellClick?: Function;
swimlaneData: OverallSwimlaneData;
swimlaneType: string;
swimlaneType: SwimlaneType;
selection?: {
lanes: any[];
type: string;

View file

@ -16,8 +16,9 @@ import {
AnomaliesTableData,
ExplorerJob,
AppStateSelectedCells,
SwimlaneData,
TimeRangeBounds,
OverallSwimlaneData,
SwimlaneData,
} from '../../explorer_utils';
export interface ExplorerState {
@ -35,7 +36,7 @@ export interface ExplorerState {
loading: boolean;
maskAll: boolean;
noInfluencersConfigured: boolean;
overallSwimlaneData: SwimlaneData;
overallSwimlaneData: SwimlaneData | OverallSwimlaneData;
queryString: string;
selectedCells: AppStateSelectedCells | undefined;
selectedJobs: ExplorerJob[] | null;
@ -45,7 +46,7 @@ export interface ExplorerState {
tableData: AnomaliesTableData;
tableQueryString: string;
viewByLoadedForTimeFormatted: string | null;
viewBySwimlaneData: SwimlaneData;
viewBySwimlaneData: SwimlaneData | OverallSwimlaneData;
viewBySwimlaneDataLoading: boolean;
viewBySwimlaneFieldName?: string;
viewBySwimlaneOptions: string[];

View file

@ -36,5 +36,5 @@ export const SelectLimit = () => {
setLimit(parseInt(e.target.value, 10));
}
return <EuiSelect options={euiOptions} onChange={onChange} value={limit} />;
return <EuiSelect compressed options={euiOptions} onChange={onChange} value={limit} />;
};

View file

@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, useCallback, useState } from 'react';
import { EuiResizeObserver, EuiText } from '@elastic/eui';
import { throttle } from 'lodash';
import {
ExplorerSwimlane,
ExplorerSwimlaneProps,
} from '../../application/explorer/explorer_swimlane';
import { MlTooltipComponent } from '../../application/components/chart_tooltip';
const RESIZE_THROTTLE_TIME_MS = 500;
export const SwimlaneContainer: FC<
Omit<ExplorerSwimlaneProps, 'chartWidth' | 'tooltipService'> & {
onResize: (width: number) => void;
}
> = ({ children, onResize, ...props }) => {
const [chartWidth, setChartWidth] = useState<number>(0);
const resizeHandler = useCallback(
throttle((e: { width: number; height: number }) => {
const labelWidth = 200;
setChartWidth(e.width - labelWidth);
onResize(e.width);
}, RESIZE_THROTTLE_TIME_MS),
[]
);
return (
<EuiResizeObserver onResize={resizeHandler}>
{(resizeRef) => (
<div
ref={(el) => {
resizeRef(el);
}}
>
<div style={{ width: '100%' }}>
<EuiText color="subdued" size="s">
<MlTooltipComponent>
{(tooltipService) => (
<ExplorerSwimlane
chartWidth={chartWidth}
tooltipService={tooltipService}
{...props}
/>
)}
</MlTooltipComponent>
</EuiText>
</div>
</div>
)}
</EuiResizeObserver>
);
};

View file

@ -0,0 +1,174 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { dashboardServiceProvider } from './dashboard_service';
import { savedObjectsServiceMock } from '../../../../../../src/core/public/mocks';
import { SavedObjectDashboard } from '../../../../../../src/plugins/dashboard/public/saved_dashboards';
import {
DashboardUrlGenerator,
SavedDashboardPanel,
} from '../../../../../../src/plugins/dashboard/public';
jest.mock('@elastic/eui', () => {
return {
htmlIdGenerator: jest.fn(() => {
return jest.fn(() => 'test-panel-id');
}),
};
});
describe('DashboardService', () => {
const mockSavedObjectClient = savedObjectsServiceMock.createStartContract().client;
const dashboardUrlGenerator = ({
createUrl: jest.fn(),
} as unknown) as DashboardUrlGenerator;
const dashboardService = dashboardServiceProvider(
mockSavedObjectClient,
'8.0.0',
dashboardUrlGenerator
);
test('should fetch dashboard', () => {
// act
dashboardService.fetchDashboards('test');
// assert
expect(mockSavedObjectClient.find).toHaveBeenCalledWith({
type: 'dashboard',
perPage: 10,
search: `test*`,
searchFields: ['title^3', 'description'],
});
});
test('should attach panel to the dashboard', () => {
// act
dashboardService.attachPanels(
'test-dashboard',
({
title: 'ML Test',
hits: 0,
description: '',
panelsJSON: JSON.stringify([
{
version: '8.0.0',
type: 'ml_anomaly_swimlane',
gridData: { x: 0, y: 0, w: 24, h: 15, i: 'i63c960b1-ab1b-11ea-809d-f5c60c43347f' },
panelIndex: 'i63c960b1-ab1b-11ea-809d-f5c60c43347f',
embeddableConfig: {
title: 'Panel test!',
jobIds: ['cw_multi_1'],
swimlaneType: 'overall',
},
title: 'Panel test!',
},
{
version: '8.0.0',
type: 'ml_anomaly_swimlane',
gridData: { x: 24, y: 0, w: 24, h: 15, i: '0aa334bd-8308-4ded-9462-80dbd37680ee' },
panelIndex: '0aa334bd-8308-4ded-9462-80dbd37680ee',
embeddableConfig: {
title: 'ML anomaly swimlane for fb_population_1',
jobIds: ['fb_population_1'],
limit: 5,
swimlaneType: 'overall',
},
title: 'ML anomaly swimlane for fb_population_1',
},
{
version: '8.0.0',
gridData: { x: 0, y: 15, w: 24, h: 15, i: 'abd36eb7-4774-4216-891e-12100752b46d' },
panelIndex: 'abd36eb7-4774-4216-891e-12100752b46d',
embeddableConfig: {},
panelRefName: 'panel_2',
},
]),
optionsJSON: '{"hidePanelTitles":false,"useMargins":true}',
version: 1,
timeRestore: false,
kibanaSavedObjectMeta: {
searchSourceJSON: '{"query":{"language":"kuery","query":""},"filter":[]}',
},
} as unknown) as SavedObjectDashboard,
[{ title: 'Test title', type: 'test-panel', embeddableConfig: { testConfig: '' } }]
);
// assert
expect(mockSavedObjectClient.update).toHaveBeenCalledWith('dashboard', 'test-dashboard', {
title: 'ML Test',
hits: 0,
description: '',
panelsJSON: JSON.stringify([
{
version: '8.0.0',
type: 'ml_anomaly_swimlane',
gridData: { x: 0, y: 0, w: 24, h: 15, i: 'i63c960b1-ab1b-11ea-809d-f5c60c43347f' },
panelIndex: 'i63c960b1-ab1b-11ea-809d-f5c60c43347f',
embeddableConfig: {
title: 'Panel test!',
jobIds: ['cw_multi_1'],
swimlaneType: 'overall',
},
title: 'Panel test!',
},
{
version: '8.0.0',
type: 'ml_anomaly_swimlane',
gridData: { x: 24, y: 0, w: 24, h: 15, i: '0aa334bd-8308-4ded-9462-80dbd37680ee' },
panelIndex: '0aa334bd-8308-4ded-9462-80dbd37680ee',
embeddableConfig: {
title: 'ML anomaly swimlane for fb_population_1',
jobIds: ['fb_population_1'],
limit: 5,
swimlaneType: 'overall',
},
title: 'ML anomaly swimlane for fb_population_1',
},
{
version: '8.0.0',
gridData: { x: 0, y: 15, w: 24, h: 15, i: 'abd36eb7-4774-4216-891e-12100752b46d' },
panelIndex: 'abd36eb7-4774-4216-891e-12100752b46d',
embeddableConfig: {},
panelRefName: 'panel_2',
},
{
panelIndex: 'test-panel-id',
embeddableConfig: { testConfig: '' },
title: 'Test title',
type: 'test-panel',
version: '8.0.0',
gridData: { h: 15, i: 'test-panel-id', w: 24, x: 24, y: 15 },
},
]),
optionsJSON: '{"hidePanelTitles":false,"useMargins":true}',
version: 1,
timeRestore: false,
kibanaSavedObjectMeta: {
searchSourceJSON: '{"query":{"language":"kuery","query":""},"filter":[]}',
},
});
});
test('should generate edit url to the dashboard', () => {
dashboardService.getDashboardEditUrl('test-id');
expect(dashboardUrlGenerator.createUrl).toHaveBeenCalledWith({
dashboardId: 'test-id',
useHash: false,
viewMode: 'edit',
});
});
test('should find the panel positioned at the end', () => {
expect(
dashboardService.getLastPanel([
{ gridData: { y: 15, x: 7 } },
{ gridData: { y: 17, x: 9 } },
{ gridData: { y: 15, x: 1 } },
{ gridData: { y: 17, x: 10 } },
{ gridData: { y: 15, x: 22 } },
{ gridData: { y: 17, x: 9 } },
] as SavedDashboardPanel[])
).toEqual({ gridData: { y: 17, x: 10 } });
});
});

View file

@ -0,0 +1,136 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { SavedObjectsClientContract } from 'kibana/public';
import { htmlIdGenerator } from '@elastic/eui';
import { useMemo } from 'react';
import {
DASHBOARD_APP_URL_GENERATOR,
DashboardUrlGenerator,
SavedDashboardPanel,
SavedObjectDashboard,
} from '../../../../../../src/plugins/dashboard/public';
import { useMlKibana } from '../contexts/kibana';
import { ViewMode } from '../../../../../../src/plugins/embeddable/public';
export type DashboardService = ReturnType<typeof dashboardServiceProvider>;
export function dashboardServiceProvider(
savedObjectClient: SavedObjectsClientContract,
kibanaVersion: string,
dashboardUrlGenerator: DashboardUrlGenerator
) {
const generateId = htmlIdGenerator();
const DEFAULT_PANEL_WIDTH = 24;
const DEFAULT_PANEL_HEIGHT = 15;
return {
/**
* Fetches dashboards
*/
async fetchDashboards(query?: string) {
return await savedObjectClient.find<SavedObjectDashboard>({
type: 'dashboard',
perPage: 10,
search: query ? `${query}*` : '',
searchFields: ['title^3', 'description'],
});
},
/**
* Resolves the last positioned panel from the collection.
*/
getLastPanel(panels: SavedDashboardPanel[]): SavedDashboardPanel | null {
return panels.length > 0
? panels.reduce((prev, current) =>
prev.gridData.y >= current.gridData.y
? prev.gridData.y === current.gridData.y
? prev.gridData.x > current.gridData.x
? prev
: current
: prev
: current
)
: null;
},
/**
* Attaches embeddable panels to the dashboard
*/
async attachPanels(
dashboardId: string,
dashboardAttributes: SavedObjectDashboard,
panelsData: Array<Pick<SavedDashboardPanel, 'title' | 'type' | 'embeddableConfig'>>
) {
const panels = JSON.parse(dashboardAttributes.panelsJSON) as SavedDashboardPanel[];
const version = kibanaVersion;
const rowWidth = DEFAULT_PANEL_WIDTH * 2;
for (const panelData of panelsData) {
const panelIndex = generateId();
const lastPanel = this.getLastPanel(panels);
const xOffset = lastPanel ? lastPanel.gridData.w + lastPanel.gridData.x : 0;
const availableRowSpace = rowWidth - xOffset;
const xPosition = availableRowSpace - DEFAULT_PANEL_WIDTH >= 0 ? xOffset : 0;
panels.push({
panelIndex,
embeddableConfig: panelData.embeddableConfig as { [key: string]: any },
title: panelData.title,
type: panelData.type,
version,
gridData: {
h: DEFAULT_PANEL_HEIGHT,
i: panelIndex,
w: DEFAULT_PANEL_WIDTH,
x: xPosition,
y: lastPanel
? xPosition > 0
? lastPanel.gridData.y
: lastPanel.gridData.y + lastPanel.gridData.h
: 0,
},
});
}
await savedObjectClient.update('dashboard', dashboardId, {
...dashboardAttributes,
panelsJSON: JSON.stringify(panels),
});
},
/**
* Generates dashboard url with edit mode
*/
async getDashboardEditUrl(dashboardId: string) {
return await dashboardUrlGenerator.createUrl({
dashboardId,
useHash: false,
viewMode: ViewMode.EDIT,
});
},
};
}
/**
* Hook to use {@link DashboardService} in react components
*/
export function useDashboardService(): DashboardService {
const {
services: {
savedObjects: { client: savedObjectClient },
kibanaVersion,
share: { urlGenerators },
},
} = useMlKibana();
return useMemo(
() =>
dashboardServiceProvider(
savedObjectClient,
kibanaVersion,
urlGenerators.getUrlGenerator(DASHBOARD_APP_URL_GENERATOR)
),
[savedObjectClient, kibanaVersion]
);
}

View file

@ -37,6 +37,8 @@ function getFetchOptions(
/**
* Function for making HTTP requests to Kibana's backend.
* Wrapper for Kibana's HttpHandler.
*
* @deprecated use {@link HttpService} instead
*/
export async function http<T>(options: HttpFetchOptionsWithPath): Promise<T> {
const { path, fetchOptions } = getFetchOptions(options);
@ -46,6 +48,8 @@ export async function http<T>(options: HttpFetchOptionsWithPath): Promise<T> {
/**
* Function for making HTTP requests to Kibana's backend which returns an Observable
* with request cancellation support.
*
* @deprecated use {@link HttpService} instead
*/
export function http$<T>(options: HttpFetchOptionsWithPath): Observable<T> {
const { path, fetchOptions } = getFetchOptions(options);
@ -55,7 +59,7 @@ export function http$<T>(options: HttpFetchOptionsWithPath): Observable<T> {
/**
* Creates an Observable from Kibana's HttpHandler.
*/
export function fromHttpHandler<T>(input: string, init?: RequestInit): Observable<T> {
function fromHttpHandler<T>(input: string, init?: RequestInit): Observable<T> {
return new Observable<T>((subscriber) => {
const controller = new AbortController();
const signal = controller.signal;

View file

@ -5,14 +5,14 @@
*/
// Service for obtaining data for the ML Results dashboards.
import { http, http$ } from '../http_service';
import { HttpService } from '../http_service';
import { basePath } from './index';
import { JobId } from '../../../../common/types/anomaly_detection_jobs';
import { PartitionFieldsDefinition } from '../results_service/result_service_rx';
export const results = {
export const resultsApiProvider = (httpService: HttpService) => ({
getAnomaliesTableData(
jobIds: string[],
criteriaFields: string[],
@ -40,7 +40,7 @@ export const results = {
influencersFilterQuery,
});
return http$<any>({
return httpService.http$<any>({
path: `${basePath()}/results/anomalies_table_data`,
method: 'POST',
body,
@ -53,7 +53,7 @@ export const results = {
earliestMs,
latestMs,
});
return http<any>({
return httpService.http<any>({
path: `${basePath()}/results/max_anomaly_score`,
method: 'POST',
body,
@ -62,7 +62,7 @@ export const results = {
getCategoryDefinition(jobId: string, categoryId: string) {
const body = JSON.stringify({ jobId, categoryId });
return http<any>({
return httpService.http<any>({
path: `${basePath()}/results/category_definition`,
method: 'POST',
body,
@ -75,7 +75,7 @@ export const results = {
categoryIds,
maxExamples,
});
return http<any>({
return httpService.http<any>({
path: `${basePath()}/results/category_examples`,
method: 'POST',
body,
@ -90,10 +90,10 @@ export const results = {
latestMs: number
) {
const body = JSON.stringify({ jobId, searchTerm, criteriaFields, earliestMs, latestMs });
return http$<PartitionFieldsDefinition>({
return httpService.http$<PartitionFieldsDefinition>({
path: `${basePath()}/results/partition_fields_values`,
method: 'POST',
body,
});
},
};
});

View file

@ -4,47 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
getMetricData,
getModelPlotOutput,
getRecordsForCriteria,
getScheduledEventsByBucket,
fetchPartitionFieldsValues,
} from './result_service_rx';
import {
getEventDistributionData,
getEventRateData,
getInfluencerValueMaxScoreByTime,
getOverallBucketScores,
getRecordInfluencers,
getRecordMaxScoreByTime,
getRecords,
getRecordsForDetector,
getRecordsForInfluencer,
getScoresByBucket,
getTopInfluencers,
getTopInfluencerValues,
} from './results_service';
export const mlResultsService = {
getScoresByBucket,
getScheduledEventsByBucket,
getTopInfluencers,
getTopInfluencerValues,
getOverallBucketScores,
getInfluencerValueMaxScoreByTime,
getRecordInfluencers,
getRecordsForInfluencer,
getRecordsForDetector,
getRecords,
getRecordsForCriteria,
getMetricData,
getEventRateData,
getEventDistributionData,
getModelPlotOutput,
getRecordMaxScoreByTime,
fetchPartitionFieldsValues,
};
import { resultsServiceRxProvider } from './result_service_rx';
import { resultsServiceProvider } from './results_service';
import { ml, MlApiServices } from '../ml_api_service';
export type MlResultsService = typeof mlResultsService;
@ -57,3 +19,12 @@ export interface CriteriaField {
fieldName: string;
fieldValue: any;
}
export const mlResultsService = mlResultsServiceProvider(ml);
export function mlResultsServiceProvider(mlApiServices: MlApiServices) {
return {
...resultsServiceProvider(mlApiServices),
...resultsServiceRxProvider(mlApiServices),
};
}

View file

@ -4,43 +4,49 @@
* you may not use this file except in compliance with the Elastic License.
*/
export function getScoresByBucket(
jobIds: string[],
earliestMs: number,
latestMs: number,
interval: string | number,
maxResults: number
): Promise<any>;
export function getTopInfluencers(): Promise<any>;
export function getTopInfluencerValues(): Promise<any>;
export function getOverallBucketScores(
jobIds: any,
topN: any,
earliestMs: any,
latestMs: any,
interval?: any
): Promise<any>;
export function getInfluencerValueMaxScoreByTime(
jobIds: string[],
influencerFieldName: string,
influencerFieldValues: string[],
earliestMs: number,
latestMs: number,
interval: string,
maxResults: number,
influencersFilterQuery: any
): Promise<any>;
export function getRecordInfluencers(): Promise<any>;
export function getRecordsForInfluencer(): Promise<any>;
export function getRecordsForDetector(): Promise<any>;
export function getRecords(): Promise<any>;
export function getEventRateData(
index: string,
query: any,
timeFieldName: string,
earliestMs: number,
latestMs: number,
interval: string | number
): Promise<any>;
export function getEventDistributionData(): Promise<any>;
export function getRecordMaxScoreByTime(): Promise<any>;
import { MlApiServices } from '../ml_api_service';
export function resultsServiceProvider(
mlApiServices: MlApiServices
): {
getScoresByBucket(
jobIds: string[],
earliestMs: number,
latestMs: number,
interval: string | number,
maxResults: number
): Promise<any>;
getTopInfluencers(): Promise<any>;
getTopInfluencerValues(): Promise<any>;
getOverallBucketScores(
jobIds: any,
topN: any,
earliestMs: any,
latestMs: any,
interval?: any
): Promise<any>;
getInfluencerValueMaxScoreByTime(
jobIds: string[],
influencerFieldName: string,
influencerFieldValues: string[],
earliestMs: number,
latestMs: number,
interval: string,
maxResults: number,
influencersFilterQuery: any
): Promise<any>;
getRecordInfluencers(): Promise<any>;
getRecordsForInfluencer(): Promise<any>;
getRecordsForDetector(): Promise<any>;
getRecords(): Promise<any>;
getEventRateData(
index: string,
query: any,
timeFieldName: string,
earliestMs: number,
latestMs: number,
interval: string | number
): Promise<any>;
getEventDistributionData(): Promise<any>;
getRecordMaxScoreByTime(): Promise<any>;
};

View file

@ -7,6 +7,7 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { CoreStart } from 'kibana/public';
import { i18n } from '@kbn/i18n';
import { Subject } from 'rxjs';
import {
Embeddable,
@ -25,12 +26,19 @@ import {
RefreshInterval,
TimeRange,
} from '../../../../../../src/plugins/data/common';
import { SwimlaneType } from '../../application/explorer/explorer_constants';
export const ANOMALY_SWIMLANE_EMBEDDABLE_TYPE = 'ml_anomaly_swimlane';
export const getDefaultPanelTitle = (jobIds: JobId[]) =>
i18n.translate('xpack.ml.swimlaneEmbeddable.title', {
defaultMessage: 'ML anomaly swimlane for {jobIds}',
values: { jobIds: jobIds.join(', ') },
});
export interface AnomalySwimlaneEmbeddableCustomInput {
jobIds: JobId[];
swimlaneType: string;
swimlaneType: SwimlaneType;
viewBy?: string;
limit?: number;
@ -43,9 +51,12 @@ export interface AnomalySwimlaneEmbeddableCustomInput {
export type AnomalySwimlaneEmbeddableInput = EmbeddableInput & AnomalySwimlaneEmbeddableCustomInput;
export interface AnomalySwimlaneEmbeddableOutput extends EmbeddableOutput {
export type AnomalySwimlaneEmbeddableOutput = EmbeddableOutput &
AnomalySwimlaneEmbeddableCustomOutput;
export interface AnomalySwimlaneEmbeddableCustomOutput {
jobIds: JobId[];
swimlaneType: string;
swimlaneType: SwimlaneType;
viewBy?: string;
limit?: number;
}

View file

@ -23,8 +23,9 @@ import { MlStartDependencies } from '../../plugin';
import { HttpService } from '../../application/services/http_service';
import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service';
import { ExplorerService } from '../../application/services/explorer_service';
import { mlResultsService } from '../../application/services/results_service';
import { mlResultsServiceProvider } from '../../application/services/results_service';
import { resolveAnomalySwimlaneUserInput } from './anomaly_swimlane_setup_flyout';
import { mlApiServicesProvider } from '../../application/services/ml_api_service';
export class AnomalySwimlaneEmbeddableFactory
implements EmbeddableFactoryDefinition<AnomalySwimlaneEmbeddableInput> {
@ -64,8 +65,7 @@ export class AnomalySwimlaneEmbeddableFactory
const explorerService = new ExplorerService(
pluginsStart.data.query.timefilter.timefilter,
coreStart.uiSettings,
// TODO mlResultsService to use DI
mlResultsService
mlResultsServiceProvider(mlApiServicesProvider(httpService))
);
return [coreStart, pluginsStart, { anomalyDetectorService, explorerService }];

View file

@ -20,7 +20,7 @@ import {
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { SWIMLANE_TYPE } from '../../application/explorer/explorer_constants';
import { SWIMLANE_TYPE, SwimlaneType } from '../../application/explorer/explorer_constants';
import { AnomalySwimlaneEmbeddableInput } from './anomaly_swimlane_embeddable';
export interface AnomalySwimlaneInitializerProps {
@ -31,7 +31,7 @@ export interface AnomalySwimlaneInitializerProps {
>;
onCreate: (swimlaneProps: {
panelTitle: string;
swimlaneType: string;
swimlaneType: SwimlaneType;
viewBy?: string;
limit?: number;
}) => void;
@ -51,8 +51,8 @@ export const AnomalySwimlaneInitializer: FC<AnomalySwimlaneInitializerProps> = (
initialInput,
}) => {
const [panelTitle, setPanelTitle] = useState(defaultTitle);
const [swimlaneType, setSwimlaneType] = useState<SWIMLANE_TYPE>(
(initialInput?.swimlaneType ?? SWIMLANE_TYPE.OVERALL) as SWIMLANE_TYPE
const [swimlaneType, setSwimlaneType] = useState(
initialInput?.swimlaneType ?? SWIMLANE_TYPE.OVERALL
);
const [viewBySwimlaneFieldName, setViewBySwimlaneFieldName] = useState(initialInput?.viewBy);
const [limit, setLimit] = useState(initialInput?.limit ?? 5);
@ -135,7 +135,7 @@ export const AnomalySwimlaneInitializer: FC<AnomalySwimlaneInitializerProps> = (
})}
options={swimlaneTypeOptions}
idSelected={swimlaneType}
onChange={(id) => setSwimlaneType(id as SWIMLANE_TYPE)}
onChange={(id) => setSwimlaneType(id as SwimlaneType)}
/>
</EuiFormRow>

View file

@ -6,7 +6,6 @@
import React from 'react';
import { IUiSettingsClient, OverlayStart } from 'kibana/public';
import { i18n } from '@kbn/i18n';
import moment from 'moment';
import { VIEW_BY_JOB_LABEL } from '../../application/explorer/explorer_constants';
import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public';
@ -14,7 +13,10 @@ import { AnomalySwimlaneInitializer } from './anomaly_swimlane_initializer';
import { JobSelectorFlyout } from '../../application/components/job_selector/job_selector_flyout';
import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service';
import { getInitialGroupsMap } from '../../application/components/job_selector/job_selector';
import { AnomalySwimlaneEmbeddableInput } from './anomaly_swimlane_embeddable';
import {
AnomalySwimlaneEmbeddableInput,
getDefaultPanelTitle,
} from './anomaly_swimlane_embeddable';
export async function resolveAnomalySwimlaneUserInput(
{
@ -52,12 +54,7 @@ export async function resolveAnomalySwimlaneUserInput(
reject();
}}
onSelectionConfirmed={async ({ jobIds, groups }) => {
const title =
input?.title ??
i18n.translate('xpack.ml.swimlaneEmbeddable.title', {
defaultMessage: 'ML anomaly swimlane for {jobIds}',
values: { jobIds: jobIds.join(', ') },
});
const title = input?.title ?? getDefaultPanelTitle(jobIds);
const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise();

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, useState } from 'react';
import React, { FC, useCallback, useState } from 'react';
import {
EuiCallOut,
EuiFlexGroup,
@ -28,6 +28,7 @@ import {
} from './anomaly_swimlane_embeddable';
import { MlTooltipComponent } from '../../application/components/chart_tooltip';
import { useSwimlaneInputResolver } from './swimlane_input_resolver';
import { SwimlaneType } from '../../application/explorer/explorer_constants';
const RESIZE_THROTTLE_TIME_MS = 500;
@ -54,10 +55,13 @@ export const ExplorerSwimlaneContainer: FC<ExplorerSwimlaneContainerProps> = ({
chartWidth
);
const onResize = throttle((e: { width: number; height: number }) => {
const labelWidth = 200;
setChartWidth(e.width - labelWidth);
}, RESIZE_THROTTLE_TIME_MS);
const onResize = useCallback(
throttle((e: { width: number; height: number }) => {
const labelWidth = 200;
setChartWidth(e.width - labelWidth);
}, RESIZE_THROTTLE_TIME_MS),
[]
);
if (error) {
return (
@ -91,14 +95,14 @@ export const ExplorerSwimlaneContainer: FC<ExplorerSwimlaneContainerProps> = ({
<EuiSpacer size="m" />
{chartWidth > 0 && swimlaneData && swimlaneType ? (
<EuiText color="subdued" size="s">
<EuiText color="subdued" size="s" data-test-subj="mlAnomalySwimlaneEmbeddableWrapper">
<MlTooltipComponent>
{(tooltipService) => (
<ExplorerSwimlane
chartWidth={chartWidth}
timeBuckets={timeBuckets}
swimlaneData={swimlaneData}
swimlaneType={swimlaneType}
swimlaneType={swimlaneType as SwimlaneType}
tooltipService={tooltipService}
/>
)}

View file

@ -24,7 +24,7 @@ import {
AnomalySwimlaneServices,
} from './anomaly_swimlane_embeddable';
import { MlStartDependencies } from '../../plugin';
import { SWIMLANE_TYPE } from '../../application/explorer/explorer_constants';
import { SWIMLANE_TYPE, SwimlaneType } from '../../application/explorer/explorer_constants';
import { Filter } from '../../../../../../src/plugins/data/common/es_query/filters';
import { Query } from '../../../../../../src/plugins/data/common/query';
import { esKuery, UI_SETTINGS } from '../../../../../../src/plugins/data/public';
@ -55,7 +55,7 @@ export function useSwimlaneInputResolver(
const [{ uiSettings }, , { explorerService, anomalyDetectorService }] = services;
const [swimlaneData, setSwimlaneData] = useState<OverallSwimlaneData>();
const [swimlaneType, setSwimlaneType] = useState<string>();
const [swimlaneType, setSwimlaneType] = useState<SwimlaneType>();
const [error, setError] = useState<Error | null>();
const chartWidth$ = useMemo(() => new Subject<number>(), []);

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { PluginInitializer } from 'kibana/public';
import { PluginInitializer, PluginInitializerContext } from 'kibana/public';
import './index.scss';
import {
MlPlugin,
@ -19,7 +19,7 @@ export const plugin: PluginInitializer<
MlPluginStart,
MlSetupDependencies,
MlStartDependencies
> = () => new MlPlugin();
> = (initializerContext: PluginInitializerContext) => new MlPlugin(initializerContext);
export { MlPluginSetup, MlPluginStart };
export * from './shared';

View file

@ -5,7 +5,13 @@
*/
import { i18n } from '@kbn/i18n';
import { Plugin, CoreStart, CoreSetup, AppMountParameters } from 'kibana/public';
import {
Plugin,
CoreStart,
CoreSetup,
AppMountParameters,
PluginInitializerContext,
} from 'kibana/public';
import { ManagementSetup } from 'src/plugins/management/public';
import { SharePluginStart } from 'src/plugins/share/public';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
@ -38,9 +44,13 @@ export interface MlSetupDependencies {
home: HomePublicPluginSetup;
embeddable: EmbeddableSetup;
uiActions: UiActionsSetup;
kibanaVersion: string;
share: SharePluginStart;
}
export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
constructor(private initializerContext: PluginInitializerContext) {}
setup(core: CoreSetup<MlStartDependencies, MlPluginStart>, pluginsSetup: MlSetupDependencies) {
core.application.register({
id: PLUGIN_ID,
@ -53,6 +63,7 @@ export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
category: DEFAULT_APP_CATEGORIES.kibana,
mount: async (params: AppMountParameters) => {
const [coreStart, pluginsStart] = await core.getStartServices();
const kibanaVersion = this.initializerContext.env.packageInfo.version;
const { renderApp } = await import('./application/app');
return renderApp(
coreStart,
@ -67,6 +78,7 @@ export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
home: pluginsSetup.home,
embeddable: pluginsSetup.embeddable,
uiActions: pluginsSetup.uiActions,
kibanaVersion,
},
{
element: params.element,

View file

@ -61,6 +61,7 @@ export default function ({ getService }: FtrProviderContext) {
before(async () => {
await esArchiver.loadIfNeeded('ml/farequote');
await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp');
await ml.testResources.createMLTestDashboardIfNeeded();
await ml.testResources.setKibanaTimeZoneToUTC();
await ml.securityUI.loginAsMlPowerUser();
@ -125,6 +126,12 @@ export default function ({ getService }: FtrProviderContext) {
it('anomalies table is not empty', async () => {
await ml.anomaliesTable.assertTableNotEmpty();
});
// should be the last step because it navigates away from the Anomaly Explorer page
it('should allow to attach anomaly swimlane embeddable to the dashboard', async () => {
await ml.anomalyExplorer.openAddToDashboardControl();
await ml.anomalyExplorer.addAndEditSwimlaneInDashboard('ML Test');
});
});
}
});

View file

@ -22,6 +22,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
await ml.securityCommon.cleanMlRoles();
await ml.testResources.deleteSavedSearches();
await ml.testResources.deleteDashboards();
await ml.testResources.deleteIndexPatternByTitle('ft_farequote');
await ml.testResources.deleteIndexPatternByTitle('ft_ecommerce');

View file

@ -66,5 +66,38 @@ export function MachineLearningAnomalyExplorerProvider({ getService }: FtrProvid
async assertSwimlaneViewByExists() {
await testSubjects.existOrFail('mlAnomalyExplorerSwimlaneViewBy');
},
async openAddToDashboardControl() {
await testSubjects.click('mlAnomalyTimelinePanelMenu');
await testSubjects.click('mlAnomalyTimelinePanelAddToDashboardButton');
await testSubjects.existOrFail('mlAddToDashboardModal');
},
async addAndEditSwimlaneInDashboard(dashboardTitle: string) {
await this.filterWithSearchString(dashboardTitle);
await testSubjects.isDisplayed('mlDashboardSelectionTable > checkboxSelectAll');
await testSubjects.click('mlDashboardSelectionTable > checkboxSelectAll');
expect(await testSubjects.isChecked('mlDashboardSelectionTable > checkboxSelectAll')).to.be(
true
);
await testSubjects.clickWhenNotDisabled('mlAddAndEditDashboardButton');
const embeddable = await testSubjects.find('mlAnomalySwimlaneEmbeddableWrapper');
const swimlane = await embeddable.findByClassName('ml-swimlanes');
expect(await swimlane.isDisplayed()).to.eql(
true,
'Anomaly swimlane should be displayed in dashboard'
);
},
async waitForDashboardsToLoad() {
await testSubjects.existOrFail('~mlDashboardSelectionTable', { timeout: 60 * 1000 });
},
async filterWithSearchString(filter: string) {
await this.waitForDashboardsToLoad();
const searchBarInput = await testSubjects.find('mlDashboardsSearchBox');
await searchBarInput.clearValueWithKeyboard();
await searchBarInput.type(filter);
},
};
}

View file

@ -5,7 +5,7 @@
*/
import { ProvidedType } from '@kbn/test/types/ftr';
import { savedSearches } from './test_resources_data';
import { savedSearches, dashboards } from './test_resources_data';
import { COMMON_REQUEST_HEADERS } from './common';
import { FtrProviderContext } from '../../ftr_provider_context';
@ -137,6 +137,20 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider
return createResponse.id;
},
async createDashboard(title: string, body: object): Promise<string> {
log.debug(`Creating dashboard with title '${title}'`);
const createResponse = await supertest
.post(`/api/saved_objects/${SavedObjectType.DASHBOARD}`)
.set(COMMON_REQUEST_HEADERS)
.send(body)
.expect(200)
.then((res: any) => res.body);
log.debug(` > Created with id '${createResponse.id}'`);
return createResponse.id;
},
async createSavedSearchIfNeeded(savedSearch: any): Promise<string> {
const title = savedSearch.requestBody.attributes.title;
const savedSearchId = await this.getSavedSearchId(title);
@ -181,6 +195,21 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider
await this.createSavedSearchIfNeeded(savedSearches.farequoteFilter);
},
async createMLTestDashboardIfNeeded() {
await this.createDashboardIfNeeded(dashboards.mlTestDashboard);
},
async createDashboardIfNeeded(dashboard: any) {
const title = dashboard.requestBody.attributes.title;
const dashboardId = await this.getDashboardId(title);
if (dashboardId !== undefined) {
log.debug(`Dashboard with title '${title}' already exists. Nothing to create.`);
return dashboardId;
} else {
return await this.createDashboard(title, dashboard.requestBody);
}
},
async createSavedSearchFarequoteLuceneIfNeeded() {
await this.createSavedSearchIfNeeded(savedSearches.farequoteLucene);
},
@ -285,6 +314,12 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider
}
},
async deleteDashboards() {
for (const dashboard of Object.values(dashboards)) {
await this.deleteDashboardByTitle(dashboard.requestBody.attributes.title);
}
},
async assertSavedObjectExistsByTitle(title: string, objectType: SavedObjectType) {
await retry.waitForWithTimeout(
`${objectType} with title '${title}' to exist`,

View file

@ -247,3 +247,22 @@ export const savedSearches = {
},
},
};
export const dashboards = {
mlTestDashboard: {
requestBody: {
attributes: {
title: 'ML Test',
hits: 0,
description: '',
panelsJSON: '[]',
optionsJSON: '{"hidePanelTitles":false,"useMargins":true}',
version: 1,
timeRestore: false,
kibanaSavedObjectMeta: {
searchSourceJSON: '{"query":{"language":"kuery","query":""},"filter":[]}',
},
},
},
},
};

View file

@ -4803,6 +4803,11 @@
resolved "https://registry.yarnpkg.com/@types/delete-empty/-/delete-empty-2.0.0.tgz#1647ae9e68f708a6ba778531af667ec55bc61964"
integrity sha512-sq+kwx8zA9BSugT9N+Jr8/uWjbHMZ+N/meJEzRyT3gmLq/WMtx/iSIpvdpmBUi/cvXl6Kzpvve8G2ESkabFwmg==
"@types/dragselect@^1.13.1":
version "1.13.1"
resolved "https://registry.yarnpkg.com/@types/dragselect/-/dragselect-1.13.1.tgz#f19b7b41063a7c9d5963194c83c3c364e84d46ee"
integrity sha512-3m0fvSM0cSs0DXvprytV/ZY92hNX3jJuEb/vkdqU+4QMzV2jxYKgBFTuaT2fflqbmfzUqHHIkGP55WIuigElQw==
"@types/elasticsearch@^5.0.33":
version "5.0.33"
resolved "https://registry.yarnpkg.com/@types/elasticsearch/-/elasticsearch-5.0.33.tgz#b0fd37dc674f498223b6d68c313bdfd71f4d812b"