[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:
parent
052dfe9f9a
commit
1dd5db2cf0
|
@ -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);
|
||||
|
|
|
@ -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}`
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -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>;
|
||||
|
|
|
@ -14,6 +14,7 @@ export interface MlContextValue {
|
|||
currentSavedSearch: SavedSearchSavedObject | null;
|
||||
indexPatterns: IndexPatternsContract;
|
||||
kibanaConfig: any; // IUiSettingsClient;
|
||||
kibanaVersion: string;
|
||||
}
|
||||
|
||||
export type SavedSearchQuery = object;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
);
|
|
@ -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,
|
||||
};
|
|
@ -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="​">
|
||||
<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 && (
|
||||
<>
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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[];
|
||||
|
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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 } });
|
||||
});
|
||||
});
|
|
@ -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]
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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>;
|
||||
};
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 }];
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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>(), []);
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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`,
|
||||
|
|
|
@ -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":[]}',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue