[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 { DashboardConstants, createDashboardEditUrl } from './dashboard_constants';
|
||||||
|
|
||||||
export { DashboardStart, DashboardUrlGenerator } from './plugin';
|
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 { addEmbeddableToDashboardUrl } from './url_utils/url_helper';
|
||||||
|
export { SavedObjectDashboard } from './saved_dashboards';
|
||||||
|
export { SavedDashboardPanel } from './types';
|
||||||
|
|
||||||
export function plugin(initializerContext: PluginInitializerContext) {
|
export function plugin(initializerContext: PluginInitializerContext) {
|
||||||
return new DashboardPlugin(initializerContext);
|
return new DashboardPlugin(initializerContext);
|
||||||
|
|
|
@ -28,6 +28,7 @@ import {
|
||||||
import { setStateToKbnUrl } from '../../kibana_utils/public';
|
import { setStateToKbnUrl } from '../../kibana_utils/public';
|
||||||
import { UrlGeneratorsDefinition, UrlGeneratorState } from '../../share/public';
|
import { UrlGeneratorsDefinition, UrlGeneratorState } from '../../share/public';
|
||||||
import { SavedObjectLoader } from '../../saved_objects/public';
|
import { SavedObjectLoader } from '../../saved_objects/public';
|
||||||
|
import { ViewMode } from '../../embeddable/public';
|
||||||
|
|
||||||
export const STATE_STORAGE_KEY = '_a';
|
export const STATE_STORAGE_KEY = '_a';
|
||||||
export const GLOBAL_STATE_STORAGE_KEY = '_g';
|
export const GLOBAL_STATE_STORAGE_KEY = '_g';
|
||||||
|
@ -73,6 +74,11 @@ export type DashboardAppLinkGeneratorState = UrlGeneratorState<{
|
||||||
* true is default
|
* true is default
|
||||||
*/
|
*/
|
||||||
preserveSavedFilters?: boolean;
|
preserveSavedFilters?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View mode of the dashboard.
|
||||||
|
*/
|
||||||
|
viewMode?: ViewMode;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const createDashboardUrlGenerator = (
|
export const createDashboardUrlGenerator = (
|
||||||
|
@ -123,6 +129,7 @@ export const createDashboardUrlGenerator = (
|
||||||
cleanEmptyKeys({
|
cleanEmptyKeys({
|
||||||
query: state.query,
|
query: state.query,
|
||||||
filters: filters?.filter((f) => !esFilters.isFilterPinned(f)),
|
filters: filters?.filter((f) => !esFilters.isFilterPinned(f)),
|
||||||
|
viewMode: state.viewMode,
|
||||||
}),
|
}),
|
||||||
{ useHash },
|
{ useHash },
|
||||||
`${appBasePath}#/${hash}`
|
`${appBasePath}#/${hash}`
|
||||||
|
|
|
@ -61,6 +61,7 @@
|
||||||
"@types/d3-shape": "^1.3.1",
|
"@types/d3-shape": "^1.3.1",
|
||||||
"@types/d3-time": "^1.0.10",
|
"@types/d3-time": "^1.0.10",
|
||||||
"@types/d3-time-format": "^2.1.1",
|
"@types/d3-time-format": "^2.1.1",
|
||||||
|
"@types/dragselect": "^1.13.1",
|
||||||
"@types/elasticsearch": "^5.0.33",
|
"@types/elasticsearch": "^5.0.33",
|
||||||
"@types/fancy-log": "^1.3.1",
|
"@types/fancy-log": "^1.3.1",
|
||||||
"@types/file-saver": "^2.0.0",
|
"@types/file-saver": "^2.0.0",
|
||||||
|
|
|
@ -35,6 +35,8 @@ const App: FC<AppProps> = ({ coreStart, deps }) => {
|
||||||
};
|
};
|
||||||
const services = {
|
const services = {
|
||||||
appName: 'ML',
|
appName: 'ML',
|
||||||
|
kibanaVersion: deps.kibanaVersion,
|
||||||
|
share: deps.share,
|
||||||
data: deps.data,
|
data: deps.data,
|
||||||
security: deps.security,
|
security: deps.security,
|
||||||
licenseManagement: deps.licenseManagement,
|
licenseManagement: deps.licenseManagement,
|
||||||
|
|
|
@ -4,12 +4,11 @@
|
||||||
* you may not use this file except in compliance with the Elastic License.
|
* you may not use this file except in compliance with the Elastic License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import PropTypes from 'prop-types';
|
import React, { FC } from 'react';
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { EuiLoadingChart, EuiSpacer } from '@elastic/eui';
|
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;
|
height = height ? +height : 100;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -21,13 +20,9 @@ export function LoadingIndicator({ height, label }) {
|
||||||
{label && (
|
{label && (
|
||||||
<>
|
<>
|
||||||
<EuiSpacer size="s" />
|
<EuiSpacer size="s" />
|
||||||
<div ml-loading-indicator-label="true">{label}</div>
|
<div>{label}</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
LoadingIndicator.propTypes = {
|
|
||||||
height: PropTypes.number,
|
|
||||||
label: PropTypes.string,
|
|
||||||
};
|
};
|
|
@ -12,13 +12,15 @@ import {
|
||||||
} from '../../../../../../../src/plugins/kibana_react/public';
|
} from '../../../../../../../src/plugins/kibana_react/public';
|
||||||
import { SecurityPluginSetup } from '../../../../../security/public';
|
import { SecurityPluginSetup } from '../../../../../security/public';
|
||||||
import { LicenseManagementUIPluginSetup } from '../../../../../license_management/public';
|
import { LicenseManagementUIPluginSetup } from '../../../../../license_management/public';
|
||||||
|
import { SharePluginStart } from '../../../../../../../src/plugins/share/public';
|
||||||
|
|
||||||
interface StartPlugins {
|
interface StartPlugins {
|
||||||
data: DataPublicPluginStart;
|
data: DataPublicPluginStart;
|
||||||
security?: SecurityPluginSetup;
|
security?: SecurityPluginSetup;
|
||||||
licenseManagement?: LicenseManagementUIPluginSetup;
|
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
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
export const useMlKibana = () => useKibana<StartServices>();
|
export const useMlKibana = () => useKibana<StartServices>();
|
||||||
export type MlKibanaReactContextValue = KibanaReactContextValue<StartServices>;
|
export type MlKibanaReactContextValue = KibanaReactContextValue<StartServices>;
|
||||||
|
|
|
@ -14,6 +14,7 @@ export interface MlContextValue {
|
||||||
currentSavedSearch: SavedSearchSavedObject | null;
|
currentSavedSearch: SavedSearchSavedObject | null;
|
||||||
indexPatterns: IndexPatternsContract;
|
indexPatterns: IndexPatternsContract;
|
||||||
kibanaConfig: any; // IUiSettingsClient;
|
kibanaConfig: any; // IUiSettingsClient;
|
||||||
|
kibanaVersion: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SavedSearchQuery = object;
|
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.
|
* you may not use this file except in compliance with the Elastic License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/*
|
import React, { FC } from 'react';
|
||||||
* React component for rendering EuiEmptyPrompt when no influencers were found.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { FormattedMessage } from '@kbn/i18n/react';
|
import { FormattedMessage } from '@kbn/i18n/react';
|
||||||
import { EuiEmptyPrompt } from '@elastic/eui';
|
import { EuiEmptyPrompt } from '@elastic/eui';
|
||||||
|
|
||||||
export const ExplorerNoInfluencersFound = ({
|
/*
|
||||||
viewBySwimlaneFieldName,
|
* React component for rendering EuiEmptyPrompt when no influencers were found.
|
||||||
showFilterMessage = false,
|
*/
|
||||||
}) => (
|
export const ExplorerNoInfluencersFound: FC<{
|
||||||
|
viewBySwimlaneFieldName: string;
|
||||||
|
showFilterMessage?: boolean;
|
||||||
|
}> = ({ viewBySwimlaneFieldName, showFilterMessage = false }) => (
|
||||||
<EuiEmptyPrompt
|
<EuiEmptyPrompt
|
||||||
titleSize="xs"
|
titleSize="xs"
|
||||||
title={
|
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 PropTypes from 'prop-types';
|
||||||
import React, { createRef } from 'react';
|
import React from 'react';
|
||||||
import { i18n } from '@kbn/i18n';
|
import { i18n } from '@kbn/i18n';
|
||||||
import { FormattedMessage } from '@kbn/i18n/react';
|
import { FormattedMessage } from '@kbn/i18n/react';
|
||||||
import DragSelect from 'dragselect/dist/ds.min.js';
|
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@ -26,34 +25,23 @@ import {
|
||||||
EuiPageBody,
|
EuiPageBody,
|
||||||
EuiPageHeader,
|
EuiPageHeader,
|
||||||
EuiPageHeaderSection,
|
EuiPageHeaderSection,
|
||||||
EuiSelect,
|
|
||||||
EuiSpacer,
|
EuiSpacer,
|
||||||
EuiTitle,
|
EuiTitle,
|
||||||
} from '@elastic/eui';
|
} from '@elastic/eui';
|
||||||
|
|
||||||
import { AnnotationFlyout } from '../components/annotations/annotation_flyout';
|
import { AnnotationFlyout } from '../components/annotations/annotation_flyout';
|
||||||
import { AnnotationsTable } from '../components/annotations/annotations_table';
|
import { AnnotationsTable } from '../components/annotations/annotations_table';
|
||||||
import {
|
import { ExplorerNoJobsFound, ExplorerNoResultsFound } from './components';
|
||||||
ExplorerNoInfluencersFound,
|
|
||||||
ExplorerNoJobsFound,
|
|
||||||
ExplorerNoResultsFound,
|
|
||||||
} from './components';
|
|
||||||
import { ExplorerSwimlane } from './explorer_swimlane';
|
|
||||||
import { getTimeBucketsFromCache } from '../util/time_buckets';
|
|
||||||
import { DatePickerWrapper } from '../components/navigation_menu/date_picker_wrapper';
|
import { DatePickerWrapper } from '../components/navigation_menu/date_picker_wrapper';
|
||||||
import { InfluencersList } from '../components/influencers_list';
|
import { InfluencersList } from '../components/influencers_list';
|
||||||
import {
|
import { explorerService } from './explorer_dashboard_service';
|
||||||
ALLOW_CELL_RANGE_SELECTION,
|
|
||||||
dragSelect$,
|
|
||||||
explorerService,
|
|
||||||
} from './explorer_dashboard_service';
|
|
||||||
import { AnomalyResultsViewSelector } from '../components/anomaly_results_view_selector';
|
import { AnomalyResultsViewSelector } from '../components/anomaly_results_view_selector';
|
||||||
import { LoadingIndicator } from '../components/loading_indicator/loading_indicator';
|
import { LoadingIndicator } from '../components/loading_indicator/loading_indicator';
|
||||||
import { NavigationMenu } from '../components/navigation_menu';
|
import { NavigationMenu } from '../components/navigation_menu';
|
||||||
import { CheckboxShowCharts } from '../components/controls/checkbox_showcharts';
|
import { CheckboxShowCharts } from '../components/controls/checkbox_showcharts';
|
||||||
import { JobSelector } from '../components/job_selector';
|
import { JobSelector } from '../components/job_selector';
|
||||||
import { SelectInterval } from '../components/controls/select_interval/select_interval';
|
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 { SelectSeverity } from '../components/controls/select_severity/select_severity';
|
||||||
import {
|
import {
|
||||||
ExplorerQueryBar,
|
ExplorerQueryBar,
|
||||||
|
@ -67,14 +55,9 @@ import {
|
||||||
escapeParens,
|
escapeParens,
|
||||||
escapeDoubleQuotes,
|
escapeDoubleQuotes,
|
||||||
} from './explorer_utils';
|
} from './explorer_utils';
|
||||||
import { getSwimlaneContainerWidth } from './legacy_utils';
|
import { AnomalyTimeline } from './anomaly_timeline';
|
||||||
|
|
||||||
import {
|
import { FILTER_ACTION } from './explorer_constants';
|
||||||
DRAG_SELECT_ACTION,
|
|
||||||
FILTER_ACTION,
|
|
||||||
SWIMLANE_TYPE,
|
|
||||||
VIEW_BY_JOB_LABEL,
|
|
||||||
} from './explorer_constants';
|
|
||||||
|
|
||||||
// Explorer Charts
|
// Explorer Charts
|
||||||
import { ExplorerChartsContainer } from './explorer_charts/explorer_charts_container';
|
import { ExplorerChartsContainer } from './explorer_charts/explorer_charts_container';
|
||||||
|
@ -82,17 +65,7 @@ import { ExplorerChartsContainer } from './explorer_charts/explorer_charts_conta
|
||||||
// Anomalies Table
|
// Anomalies Table
|
||||||
import { AnomaliesTable } from '../components/anomalies_table/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 { 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 = ({
|
const ExplorerPage = ({
|
||||||
children,
|
children,
|
||||||
|
@ -105,9 +78,8 @@ const ExplorerPage = ({
|
||||||
queryString,
|
queryString,
|
||||||
filterIconTriggeredQuery,
|
filterIconTriggeredQuery,
|
||||||
updateLanguage,
|
updateLanguage,
|
||||||
resizeRef,
|
|
||||||
}) => (
|
}) => (
|
||||||
<div ref={resizeRef} data-test-subj="mlPageAnomalyExplorer">
|
<div data-test-subj="mlPageAnomalyExplorer">
|
||||||
<NavigationMenu tabId="anomaly_detection" />
|
<NavigationMenu tabId="anomaly_detection" />
|
||||||
<EuiPage style={{ background: 'none' }}>
|
<EuiPage style={{ background: 'none' }}>
|
||||||
<EuiPageBody>
|
<EuiPageBody>
|
||||||
|
@ -171,108 +143,18 @@ export class Explorer extends React.Component {
|
||||||
state = { filterIconTriggeredQuery: undefined, language: DEFAULT_QUERY_LANG };
|
state = { filterIconTriggeredQuery: undefined, language: DEFAULT_QUERY_LANG };
|
||||||
|
|
||||||
_unsubscribeAll = new Subject();
|
_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() {
|
componentDidMount() {
|
||||||
limit$.pipe(takeUntil(this._unsubscribeAll)).subscribe(explorerService.setSwimlaneLimit);
|
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() {
|
componentWillUnmount() {
|
||||||
this._unsubscribeAll.next();
|
this._unsubscribeAll.next();
|
||||||
this._unsubscribeAll.complete();
|
this._unsubscribeAll.complete();
|
||||||
this.resizeChecker.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
resetCache() {
|
|
||||||
this.anomaliesTablePreviousArgs = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
viewByChangeHandler = (e) => explorerService.setViewBySwimlaneFieldName(e.target.value);
|
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
|
// 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
|
// and will cause a syntax error when called with getKqlQueryValues
|
||||||
applyFilter = (fieldName, fieldValue, action) => {
|
applyFilter = (fieldName, fieldValue, action) => {
|
||||||
|
@ -339,24 +221,16 @@ export class Explorer extends React.Component {
|
||||||
annotationsData,
|
annotationsData,
|
||||||
chartsData,
|
chartsData,
|
||||||
filterActive,
|
filterActive,
|
||||||
filteredFields,
|
|
||||||
filterPlaceHolder,
|
filterPlaceHolder,
|
||||||
indexPattern,
|
indexPattern,
|
||||||
influencers,
|
influencers,
|
||||||
loading,
|
loading,
|
||||||
maskAll,
|
|
||||||
noInfluencersConfigured,
|
noInfluencersConfigured,
|
||||||
overallSwimlaneData,
|
overallSwimlaneData,
|
||||||
queryString,
|
queryString,
|
||||||
selectedCells,
|
selectedCells,
|
||||||
selectedJobs,
|
selectedJobs,
|
||||||
swimlaneContainerWidth,
|
|
||||||
tableData,
|
tableData,
|
||||||
viewByLoadedForTimeFormatted,
|
|
||||||
viewBySwimlaneData,
|
|
||||||
viewBySwimlaneDataLoading,
|
|
||||||
viewBySwimlaneFieldName,
|
|
||||||
viewBySwimlaneOptions,
|
|
||||||
} = this.props.explorerState;
|
} = this.props.explorerState;
|
||||||
|
|
||||||
const jobSelectorProps = {
|
const jobSelectorProps = {
|
||||||
|
@ -378,7 +252,6 @@ export class Explorer extends React.Component {
|
||||||
indexPattern={indexPattern}
|
indexPattern={indexPattern}
|
||||||
queryString={queryString}
|
queryString={queryString}
|
||||||
updateLanguage={this.updateLanguage}
|
updateLanguage={this.updateLanguage}
|
||||||
resizeRef={this.resizeRef}
|
|
||||||
>
|
>
|
||||||
<LoadingIndicator
|
<LoadingIndicator
|
||||||
label={i18n.translate('xpack.ml.explorer.loadingLabel', {
|
label={i18n.translate('xpack.ml.explorer.loadingLabel', {
|
||||||
|
@ -391,7 +264,7 @@ export class Explorer extends React.Component {
|
||||||
|
|
||||||
if (noJobsFound) {
|
if (noJobsFound) {
|
||||||
return (
|
return (
|
||||||
<ExplorerPage jobSelectorProps={jobSelectorProps} resizeRef={this.resizeRef}>
|
<ExplorerPage jobSelectorProps={jobSelectorProps}>
|
||||||
<ExplorerNoJobsFound />
|
<ExplorerNoJobsFound />
|
||||||
</ExplorerPage>
|
</ExplorerPage>
|
||||||
);
|
);
|
||||||
|
@ -399,7 +272,7 @@ export class Explorer extends React.Component {
|
||||||
|
|
||||||
if (noJobsFound && hasResults === false) {
|
if (noJobsFound && hasResults === false) {
|
||||||
return (
|
return (
|
||||||
<ExplorerPage jobSelectorProps={jobSelectorProps} resizeRef={this.resizeRef}>
|
<ExplorerPage jobSelectorProps={jobSelectorProps}>
|
||||||
<ExplorerNoResultsFound />
|
<ExplorerNoResultsFound />
|
||||||
</ExplorerPage>
|
</ExplorerPage>
|
||||||
);
|
);
|
||||||
|
@ -408,15 +281,6 @@ export class Explorer extends React.Component {
|
||||||
const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10';
|
const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10';
|
||||||
const mainColumnClasses = `column ${mainColumnWidthClassName}`;
|
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 timefilter = getTimefilter();
|
||||||
const bounds = timefilter.getActiveBounds();
|
const bounds = timefilter.getActiveBounds();
|
||||||
|
|
||||||
|
@ -431,7 +295,6 @@ export class Explorer extends React.Component {
|
||||||
indexPattern={indexPattern}
|
indexPattern={indexPattern}
|
||||||
queryString={queryString}
|
queryString={queryString}
|
||||||
updateLanguage={this.updateLanguage}
|
updateLanguage={this.updateLanguage}
|
||||||
resizeRef={this.resizeRef}
|
|
||||||
>
|
>
|
||||||
<div className="results-container">
|
<div className="results-container">
|
||||||
{noInfluencersConfigured && (
|
{noInfluencersConfigured && (
|
||||||
|
@ -462,142 +325,12 @@ export class Explorer extends React.Component {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={mainColumnClasses}>
|
<div className={mainColumnClasses}>
|
||||||
<EuiTitle className="panel-title">
|
<EuiSpacer size="m" />
|
||||||
<h2>
|
<AnomalyTimeline
|
||||||
<FormattedMessage
|
explorerState={this.props.explorerState}
|
||||||
id="xpack.ml.explorer.anomalyTimelineTitle"
|
setSelectedCells={this.props.setSelectedCells}
|
||||||
defaultMessage="Anomaly timeline"
|
/>
|
||||||
/>
|
<EuiSpacer size="m" />
|
||||||
</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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{annotationsData.length > 0 && (
|
{annotationsData.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -37,10 +37,12 @@ export const FILTER_ACTION = {
|
||||||
REMOVE: '-',
|
REMOVE: '-',
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum SWIMLANE_TYPE {
|
export const SWIMLANE_TYPE = {
|
||||||
OVERALL = 'overall',
|
OVERALL: 'overall',
|
||||||
VIEW_BY = 'viewBy',
|
VIEW_BY: 'viewBy',
|
||||||
}
|
} as const;
|
||||||
|
|
||||||
|
export type SwimlaneType = typeof SWIMLANE_TYPE[keyof typeof SWIMLANE_TYPE];
|
||||||
|
|
||||||
export const CHART_TYPE = {
|
export const CHART_TYPE = {
|
||||||
EVENT_DISTRIBUTION: 'event_distribution',
|
EVENT_DISTRIBUTION: 'event_distribution',
|
||||||
|
|
|
@ -22,7 +22,7 @@ import { numTicksForDateFormat } from '../util/chart_utils';
|
||||||
import { getSeverityColor } from '../../../common/util/anomaly_utils';
|
import { getSeverityColor } from '../../../common/util/anomaly_utils';
|
||||||
import { mlEscape } from '../util/string_utils';
|
import { mlEscape } from '../util/string_utils';
|
||||||
import { ALLOW_CELL_RANGE_SELECTION, dragSelect$ } from './explorer_dashboard_service';
|
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 { EMPTY_FIELD_VALUE_LABEL } from '../timeseriesexplorer/components/entity_control/entity_control';
|
||||||
import { TimeBuckets as TimeBucketsClass } from '../util/time_buckets';
|
import { TimeBuckets as TimeBucketsClass } from '../util/time_buckets';
|
||||||
import {
|
import {
|
||||||
|
@ -58,7 +58,7 @@ export interface ExplorerSwimlaneProps {
|
||||||
timeBuckets: InstanceType<typeof TimeBucketsClass>;
|
timeBuckets: InstanceType<typeof TimeBucketsClass>;
|
||||||
swimlaneCellClick?: Function;
|
swimlaneCellClick?: Function;
|
||||||
swimlaneData: OverallSwimlaneData;
|
swimlaneData: OverallSwimlaneData;
|
||||||
swimlaneType: string;
|
swimlaneType: SwimlaneType;
|
||||||
selection?: {
|
selection?: {
|
||||||
lanes: any[];
|
lanes: any[];
|
||||||
type: string;
|
type: string;
|
||||||
|
|
|
@ -16,8 +16,9 @@ import {
|
||||||
AnomaliesTableData,
|
AnomaliesTableData,
|
||||||
ExplorerJob,
|
ExplorerJob,
|
||||||
AppStateSelectedCells,
|
AppStateSelectedCells,
|
||||||
SwimlaneData,
|
|
||||||
TimeRangeBounds,
|
TimeRangeBounds,
|
||||||
|
OverallSwimlaneData,
|
||||||
|
SwimlaneData,
|
||||||
} from '../../explorer_utils';
|
} from '../../explorer_utils';
|
||||||
|
|
||||||
export interface ExplorerState {
|
export interface ExplorerState {
|
||||||
|
@ -35,7 +36,7 @@ export interface ExplorerState {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
maskAll: boolean;
|
maskAll: boolean;
|
||||||
noInfluencersConfigured: boolean;
|
noInfluencersConfigured: boolean;
|
||||||
overallSwimlaneData: SwimlaneData;
|
overallSwimlaneData: SwimlaneData | OverallSwimlaneData;
|
||||||
queryString: string;
|
queryString: string;
|
||||||
selectedCells: AppStateSelectedCells | undefined;
|
selectedCells: AppStateSelectedCells | undefined;
|
||||||
selectedJobs: ExplorerJob[] | null;
|
selectedJobs: ExplorerJob[] | null;
|
||||||
|
@ -45,7 +46,7 @@ export interface ExplorerState {
|
||||||
tableData: AnomaliesTableData;
|
tableData: AnomaliesTableData;
|
||||||
tableQueryString: string;
|
tableQueryString: string;
|
||||||
viewByLoadedForTimeFormatted: string | null;
|
viewByLoadedForTimeFormatted: string | null;
|
||||||
viewBySwimlaneData: SwimlaneData;
|
viewBySwimlaneData: SwimlaneData | OverallSwimlaneData;
|
||||||
viewBySwimlaneDataLoading: boolean;
|
viewBySwimlaneDataLoading: boolean;
|
||||||
viewBySwimlaneFieldName?: string;
|
viewBySwimlaneFieldName?: string;
|
||||||
viewBySwimlaneOptions: string[];
|
viewBySwimlaneOptions: string[];
|
||||||
|
|
|
@ -36,5 +36,5 @@ export const SelectLimit = () => {
|
||||||
setLimit(parseInt(e.target.value, 10));
|
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.
|
* Function for making HTTP requests to Kibana's backend.
|
||||||
* Wrapper for Kibana's HttpHandler.
|
* Wrapper for Kibana's HttpHandler.
|
||||||
|
*
|
||||||
|
* @deprecated use {@link HttpService} instead
|
||||||
*/
|
*/
|
||||||
export async function http<T>(options: HttpFetchOptionsWithPath): Promise<T> {
|
export async function http<T>(options: HttpFetchOptionsWithPath): Promise<T> {
|
||||||
const { path, fetchOptions } = getFetchOptions(options);
|
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
|
* Function for making HTTP requests to Kibana's backend which returns an Observable
|
||||||
* with request cancellation support.
|
* with request cancellation support.
|
||||||
|
*
|
||||||
|
* @deprecated use {@link HttpService} instead
|
||||||
*/
|
*/
|
||||||
export function http$<T>(options: HttpFetchOptionsWithPath): Observable<T> {
|
export function http$<T>(options: HttpFetchOptionsWithPath): Observable<T> {
|
||||||
const { path, fetchOptions } = getFetchOptions(options);
|
const { path, fetchOptions } = getFetchOptions(options);
|
||||||
|
@ -55,7 +59,7 @@ export function http$<T>(options: HttpFetchOptionsWithPath): Observable<T> {
|
||||||
/**
|
/**
|
||||||
* Creates an Observable from Kibana's HttpHandler.
|
* 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) => {
|
return new Observable<T>((subscriber) => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const signal = controller.signal;
|
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.
|
// 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 { basePath } from './index';
|
||||||
|
|
||||||
import { JobId } from '../../../../common/types/anomaly_detection_jobs';
|
import { JobId } from '../../../../common/types/anomaly_detection_jobs';
|
||||||
import { PartitionFieldsDefinition } from '../results_service/result_service_rx';
|
import { PartitionFieldsDefinition } from '../results_service/result_service_rx';
|
||||||
|
|
||||||
export const results = {
|
export const resultsApiProvider = (httpService: HttpService) => ({
|
||||||
getAnomaliesTableData(
|
getAnomaliesTableData(
|
||||||
jobIds: string[],
|
jobIds: string[],
|
||||||
criteriaFields: string[],
|
criteriaFields: string[],
|
||||||
|
@ -40,7 +40,7 @@ export const results = {
|
||||||
influencersFilterQuery,
|
influencersFilterQuery,
|
||||||
});
|
});
|
||||||
|
|
||||||
return http$<any>({
|
return httpService.http$<any>({
|
||||||
path: `${basePath()}/results/anomalies_table_data`,
|
path: `${basePath()}/results/anomalies_table_data`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body,
|
body,
|
||||||
|
@ -53,7 +53,7 @@ export const results = {
|
||||||
earliestMs,
|
earliestMs,
|
||||||
latestMs,
|
latestMs,
|
||||||
});
|
});
|
||||||
return http<any>({
|
return httpService.http<any>({
|
||||||
path: `${basePath()}/results/max_anomaly_score`,
|
path: `${basePath()}/results/max_anomaly_score`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body,
|
body,
|
||||||
|
@ -62,7 +62,7 @@ export const results = {
|
||||||
|
|
||||||
getCategoryDefinition(jobId: string, categoryId: string) {
|
getCategoryDefinition(jobId: string, categoryId: string) {
|
||||||
const body = JSON.stringify({ jobId, categoryId });
|
const body = JSON.stringify({ jobId, categoryId });
|
||||||
return http<any>({
|
return httpService.http<any>({
|
||||||
path: `${basePath()}/results/category_definition`,
|
path: `${basePath()}/results/category_definition`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body,
|
body,
|
||||||
|
@ -75,7 +75,7 @@ export const results = {
|
||||||
categoryIds,
|
categoryIds,
|
||||||
maxExamples,
|
maxExamples,
|
||||||
});
|
});
|
||||||
return http<any>({
|
return httpService.http<any>({
|
||||||
path: `${basePath()}/results/category_examples`,
|
path: `${basePath()}/results/category_examples`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body,
|
body,
|
||||||
|
@ -90,10 +90,10 @@ export const results = {
|
||||||
latestMs: number
|
latestMs: number
|
||||||
) {
|
) {
|
||||||
const body = JSON.stringify({ jobId, searchTerm, criteriaFields, earliestMs, latestMs });
|
const body = JSON.stringify({ jobId, searchTerm, criteriaFields, earliestMs, latestMs });
|
||||||
return http$<PartitionFieldsDefinition>({
|
return httpService.http$<PartitionFieldsDefinition>({
|
||||||
path: `${basePath()}/results/partition_fields_values`,
|
path: `${basePath()}/results/partition_fields_values`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
|
@ -4,47 +4,9 @@
|
||||||
* you may not use this file except in compliance with the Elastic License.
|
* you may not use this file except in compliance with the Elastic License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { resultsServiceRxProvider } from './result_service_rx';
|
||||||
getMetricData,
|
import { resultsServiceProvider } from './results_service';
|
||||||
getModelPlotOutput,
|
import { ml, MlApiServices } from '../ml_api_service';
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MlResultsService = typeof mlResultsService;
|
export type MlResultsService = typeof mlResultsService;
|
||||||
|
|
||||||
|
@ -57,3 +19,12 @@ export interface CriteriaField {
|
||||||
fieldName: string;
|
fieldName: string;
|
||||||
fieldValue: any;
|
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.
|
* you may not use this file except in compliance with the Elastic License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function getScoresByBucket(
|
import { MlApiServices } from '../ml_api_service';
|
||||||
jobIds: string[],
|
|
||||||
earliestMs: number,
|
export function resultsServiceProvider(
|
||||||
latestMs: number,
|
mlApiServices: MlApiServices
|
||||||
interval: string | number,
|
): {
|
||||||
maxResults: number
|
getScoresByBucket(
|
||||||
): Promise<any>;
|
jobIds: string[],
|
||||||
export function getTopInfluencers(): Promise<any>;
|
earliestMs: number,
|
||||||
export function getTopInfluencerValues(): Promise<any>;
|
latestMs: number,
|
||||||
export function getOverallBucketScores(
|
interval: string | number,
|
||||||
jobIds: any,
|
maxResults: number
|
||||||
topN: any,
|
): Promise<any>;
|
||||||
earliestMs: any,
|
getTopInfluencers(): Promise<any>;
|
||||||
latestMs: any,
|
getTopInfluencerValues(): Promise<any>;
|
||||||
interval?: any
|
getOverallBucketScores(
|
||||||
): Promise<any>;
|
jobIds: any,
|
||||||
export function getInfluencerValueMaxScoreByTime(
|
topN: any,
|
||||||
jobIds: string[],
|
earliestMs: any,
|
||||||
influencerFieldName: string,
|
latestMs: any,
|
||||||
influencerFieldValues: string[],
|
interval?: any
|
||||||
earliestMs: number,
|
): Promise<any>;
|
||||||
latestMs: number,
|
getInfluencerValueMaxScoreByTime(
|
||||||
interval: string,
|
jobIds: string[],
|
||||||
maxResults: number,
|
influencerFieldName: string,
|
||||||
influencersFilterQuery: any
|
influencerFieldValues: string[],
|
||||||
): Promise<any>;
|
earliestMs: number,
|
||||||
export function getRecordInfluencers(): Promise<any>;
|
latestMs: number,
|
||||||
export function getRecordsForInfluencer(): Promise<any>;
|
interval: string,
|
||||||
export function getRecordsForDetector(): Promise<any>;
|
maxResults: number,
|
||||||
export function getRecords(): Promise<any>;
|
influencersFilterQuery: any
|
||||||
export function getEventRateData(
|
): Promise<any>;
|
||||||
index: string,
|
getRecordInfluencers(): Promise<any>;
|
||||||
query: any,
|
getRecordsForInfluencer(): Promise<any>;
|
||||||
timeFieldName: string,
|
getRecordsForDetector(): Promise<any>;
|
||||||
earliestMs: number,
|
getRecords(): Promise<any>;
|
||||||
latestMs: number,
|
getEventRateData(
|
||||||
interval: string | number
|
index: string,
|
||||||
): Promise<any>;
|
query: any,
|
||||||
export function getEventDistributionData(): Promise<any>;
|
timeFieldName: string,
|
||||||
export function getRecordMaxScoreByTime(): Promise<any>;
|
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 React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import { CoreStart } from 'kibana/public';
|
import { CoreStart } from 'kibana/public';
|
||||||
|
import { i18n } from '@kbn/i18n';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
Embeddable,
|
Embeddable,
|
||||||
|
@ -25,12 +26,19 @@ import {
|
||||||
RefreshInterval,
|
RefreshInterval,
|
||||||
TimeRange,
|
TimeRange,
|
||||||
} from '../../../../../../src/plugins/data/common';
|
} from '../../../../../../src/plugins/data/common';
|
||||||
|
import { SwimlaneType } from '../../application/explorer/explorer_constants';
|
||||||
|
|
||||||
export const ANOMALY_SWIMLANE_EMBEDDABLE_TYPE = 'ml_anomaly_swimlane';
|
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 {
|
export interface AnomalySwimlaneEmbeddableCustomInput {
|
||||||
jobIds: JobId[];
|
jobIds: JobId[];
|
||||||
swimlaneType: string;
|
swimlaneType: SwimlaneType;
|
||||||
viewBy?: string;
|
viewBy?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|
||||||
|
@ -43,9 +51,12 @@ export interface AnomalySwimlaneEmbeddableCustomInput {
|
||||||
|
|
||||||
export type AnomalySwimlaneEmbeddableInput = EmbeddableInput & AnomalySwimlaneEmbeddableCustomInput;
|
export type AnomalySwimlaneEmbeddableInput = EmbeddableInput & AnomalySwimlaneEmbeddableCustomInput;
|
||||||
|
|
||||||
export interface AnomalySwimlaneEmbeddableOutput extends EmbeddableOutput {
|
export type AnomalySwimlaneEmbeddableOutput = EmbeddableOutput &
|
||||||
|
AnomalySwimlaneEmbeddableCustomOutput;
|
||||||
|
|
||||||
|
export interface AnomalySwimlaneEmbeddableCustomOutput {
|
||||||
jobIds: JobId[];
|
jobIds: JobId[];
|
||||||
swimlaneType: string;
|
swimlaneType: SwimlaneType;
|
||||||
viewBy?: string;
|
viewBy?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,8 +23,9 @@ import { MlStartDependencies } from '../../plugin';
|
||||||
import { HttpService } from '../../application/services/http_service';
|
import { HttpService } from '../../application/services/http_service';
|
||||||
import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service';
|
import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service';
|
||||||
import { ExplorerService } from '../../application/services/explorer_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 { resolveAnomalySwimlaneUserInput } from './anomaly_swimlane_setup_flyout';
|
||||||
|
import { mlApiServicesProvider } from '../../application/services/ml_api_service';
|
||||||
|
|
||||||
export class AnomalySwimlaneEmbeddableFactory
|
export class AnomalySwimlaneEmbeddableFactory
|
||||||
implements EmbeddableFactoryDefinition<AnomalySwimlaneEmbeddableInput> {
|
implements EmbeddableFactoryDefinition<AnomalySwimlaneEmbeddableInput> {
|
||||||
|
@ -64,8 +65,7 @@ export class AnomalySwimlaneEmbeddableFactory
|
||||||
const explorerService = new ExplorerService(
|
const explorerService = new ExplorerService(
|
||||||
pluginsStart.data.query.timefilter.timefilter,
|
pluginsStart.data.query.timefilter.timefilter,
|
||||||
coreStart.uiSettings,
|
coreStart.uiSettings,
|
||||||
// TODO mlResultsService to use DI
|
mlResultsServiceProvider(mlApiServicesProvider(httpService))
|
||||||
mlResultsService
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return [coreStart, pluginsStart, { anomalyDetectorService, explorerService }];
|
return [coreStart, pluginsStart, { anomalyDetectorService, explorerService }];
|
||||||
|
|
|
@ -20,7 +20,7 @@ import {
|
||||||
} from '@elastic/eui';
|
} from '@elastic/eui';
|
||||||
import { FormattedMessage } from '@kbn/i18n/react';
|
import { FormattedMessage } from '@kbn/i18n/react';
|
||||||
import { i18n } from '@kbn/i18n';
|
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';
|
import { AnomalySwimlaneEmbeddableInput } from './anomaly_swimlane_embeddable';
|
||||||
|
|
||||||
export interface AnomalySwimlaneInitializerProps {
|
export interface AnomalySwimlaneInitializerProps {
|
||||||
|
@ -31,7 +31,7 @@ export interface AnomalySwimlaneInitializerProps {
|
||||||
>;
|
>;
|
||||||
onCreate: (swimlaneProps: {
|
onCreate: (swimlaneProps: {
|
||||||
panelTitle: string;
|
panelTitle: string;
|
||||||
swimlaneType: string;
|
swimlaneType: SwimlaneType;
|
||||||
viewBy?: string;
|
viewBy?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
@ -51,8 +51,8 @@ export const AnomalySwimlaneInitializer: FC<AnomalySwimlaneInitializerProps> = (
|
||||||
initialInput,
|
initialInput,
|
||||||
}) => {
|
}) => {
|
||||||
const [panelTitle, setPanelTitle] = useState(defaultTitle);
|
const [panelTitle, setPanelTitle] = useState(defaultTitle);
|
||||||
const [swimlaneType, setSwimlaneType] = useState<SWIMLANE_TYPE>(
|
const [swimlaneType, setSwimlaneType] = useState(
|
||||||
(initialInput?.swimlaneType ?? SWIMLANE_TYPE.OVERALL) as SWIMLANE_TYPE
|
initialInput?.swimlaneType ?? SWIMLANE_TYPE.OVERALL
|
||||||
);
|
);
|
||||||
const [viewBySwimlaneFieldName, setViewBySwimlaneFieldName] = useState(initialInput?.viewBy);
|
const [viewBySwimlaneFieldName, setViewBySwimlaneFieldName] = useState(initialInput?.viewBy);
|
||||||
const [limit, setLimit] = useState(initialInput?.limit ?? 5);
|
const [limit, setLimit] = useState(initialInput?.limit ?? 5);
|
||||||
|
@ -135,7 +135,7 @@ export const AnomalySwimlaneInitializer: FC<AnomalySwimlaneInitializerProps> = (
|
||||||
})}
|
})}
|
||||||
options={swimlaneTypeOptions}
|
options={swimlaneTypeOptions}
|
||||||
idSelected={swimlaneType}
|
idSelected={swimlaneType}
|
||||||
onChange={(id) => setSwimlaneType(id as SWIMLANE_TYPE)}
|
onChange={(id) => setSwimlaneType(id as SwimlaneType)}
|
||||||
/>
|
/>
|
||||||
</EuiFormRow>
|
</EuiFormRow>
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,6 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { IUiSettingsClient, OverlayStart } from 'kibana/public';
|
import { IUiSettingsClient, OverlayStart } from 'kibana/public';
|
||||||
import { i18n } from '@kbn/i18n';
|
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { VIEW_BY_JOB_LABEL } from '../../application/explorer/explorer_constants';
|
import { VIEW_BY_JOB_LABEL } from '../../application/explorer/explorer_constants';
|
||||||
import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public';
|
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 { JobSelectorFlyout } from '../../application/components/job_selector/job_selector_flyout';
|
||||||
import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service';
|
import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service';
|
||||||
import { getInitialGroupsMap } from '../../application/components/job_selector/job_selector';
|
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(
|
export async function resolveAnomalySwimlaneUserInput(
|
||||||
{
|
{
|
||||||
|
@ -52,12 +54,7 @@ export async function resolveAnomalySwimlaneUserInput(
|
||||||
reject();
|
reject();
|
||||||
}}
|
}}
|
||||||
onSelectionConfirmed={async ({ jobIds, groups }) => {
|
onSelectionConfirmed={async ({ jobIds, groups }) => {
|
||||||
const title =
|
const title = input?.title ?? getDefaultPanelTitle(jobIds);
|
||||||
input?.title ??
|
|
||||||
i18n.translate('xpack.ml.swimlaneEmbeddable.title', {
|
|
||||||
defaultMessage: 'ML anomaly swimlane for {jobIds}',
|
|
||||||
values: { jobIds: jobIds.join(', ') },
|
|
||||||
});
|
|
||||||
|
|
||||||
const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise();
|
const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise();
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
* you may not use this file except in compliance with the Elastic License.
|
* 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 {
|
import {
|
||||||
EuiCallOut,
|
EuiCallOut,
|
||||||
EuiFlexGroup,
|
EuiFlexGroup,
|
||||||
|
@ -28,6 +28,7 @@ import {
|
||||||
} from './anomaly_swimlane_embeddable';
|
} from './anomaly_swimlane_embeddable';
|
||||||
import { MlTooltipComponent } from '../../application/components/chart_tooltip';
|
import { MlTooltipComponent } from '../../application/components/chart_tooltip';
|
||||||
import { useSwimlaneInputResolver } from './swimlane_input_resolver';
|
import { useSwimlaneInputResolver } from './swimlane_input_resolver';
|
||||||
|
import { SwimlaneType } from '../../application/explorer/explorer_constants';
|
||||||
|
|
||||||
const RESIZE_THROTTLE_TIME_MS = 500;
|
const RESIZE_THROTTLE_TIME_MS = 500;
|
||||||
|
|
||||||
|
@ -54,10 +55,13 @@ export const ExplorerSwimlaneContainer: FC<ExplorerSwimlaneContainerProps> = ({
|
||||||
chartWidth
|
chartWidth
|
||||||
);
|
);
|
||||||
|
|
||||||
const onResize = throttle((e: { width: number; height: number }) => {
|
const onResize = useCallback(
|
||||||
const labelWidth = 200;
|
throttle((e: { width: number; height: number }) => {
|
||||||
setChartWidth(e.width - labelWidth);
|
const labelWidth = 200;
|
||||||
}, RESIZE_THROTTLE_TIME_MS);
|
setChartWidth(e.width - labelWidth);
|
||||||
|
}, RESIZE_THROTTLE_TIME_MS),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
@ -91,14 +95,14 @@ export const ExplorerSwimlaneContainer: FC<ExplorerSwimlaneContainerProps> = ({
|
||||||
<EuiSpacer size="m" />
|
<EuiSpacer size="m" />
|
||||||
|
|
||||||
{chartWidth > 0 && swimlaneData && swimlaneType ? (
|
{chartWidth > 0 && swimlaneData && swimlaneType ? (
|
||||||
<EuiText color="subdued" size="s">
|
<EuiText color="subdued" size="s" data-test-subj="mlAnomalySwimlaneEmbeddableWrapper">
|
||||||
<MlTooltipComponent>
|
<MlTooltipComponent>
|
||||||
{(tooltipService) => (
|
{(tooltipService) => (
|
||||||
<ExplorerSwimlane
|
<ExplorerSwimlane
|
||||||
chartWidth={chartWidth}
|
chartWidth={chartWidth}
|
||||||
timeBuckets={timeBuckets}
|
timeBuckets={timeBuckets}
|
||||||
swimlaneData={swimlaneData}
|
swimlaneData={swimlaneData}
|
||||||
swimlaneType={swimlaneType}
|
swimlaneType={swimlaneType as SwimlaneType}
|
||||||
tooltipService={tooltipService}
|
tooltipService={tooltipService}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -24,7 +24,7 @@ import {
|
||||||
AnomalySwimlaneServices,
|
AnomalySwimlaneServices,
|
||||||
} from './anomaly_swimlane_embeddable';
|
} from './anomaly_swimlane_embeddable';
|
||||||
import { MlStartDependencies } from '../../plugin';
|
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 { Filter } from '../../../../../../src/plugins/data/common/es_query/filters';
|
||||||
import { Query } from '../../../../../../src/plugins/data/common/query';
|
import { Query } from '../../../../../../src/plugins/data/common/query';
|
||||||
import { esKuery, UI_SETTINGS } from '../../../../../../src/plugins/data/public';
|
import { esKuery, UI_SETTINGS } from '../../../../../../src/plugins/data/public';
|
||||||
|
@ -55,7 +55,7 @@ export function useSwimlaneInputResolver(
|
||||||
const [{ uiSettings }, , { explorerService, anomalyDetectorService }] = services;
|
const [{ uiSettings }, , { explorerService, anomalyDetectorService }] = services;
|
||||||
|
|
||||||
const [swimlaneData, setSwimlaneData] = useState<OverallSwimlaneData>();
|
const [swimlaneData, setSwimlaneData] = useState<OverallSwimlaneData>();
|
||||||
const [swimlaneType, setSwimlaneType] = useState<string>();
|
const [swimlaneType, setSwimlaneType] = useState<SwimlaneType>();
|
||||||
const [error, setError] = useState<Error | null>();
|
const [error, setError] = useState<Error | null>();
|
||||||
|
|
||||||
const chartWidth$ = useMemo(() => new Subject<number>(), []);
|
const chartWidth$ = useMemo(() => new Subject<number>(), []);
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
* you may not use this file except in compliance with the Elastic License.
|
* 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 './index.scss';
|
||||||
import {
|
import {
|
||||||
MlPlugin,
|
MlPlugin,
|
||||||
|
@ -19,7 +19,7 @@ export const plugin: PluginInitializer<
|
||||||
MlPluginStart,
|
MlPluginStart,
|
||||||
MlSetupDependencies,
|
MlSetupDependencies,
|
||||||
MlStartDependencies
|
MlStartDependencies
|
||||||
> = () => new MlPlugin();
|
> = (initializerContext: PluginInitializerContext) => new MlPlugin(initializerContext);
|
||||||
|
|
||||||
export { MlPluginSetup, MlPluginStart };
|
export { MlPluginSetup, MlPluginStart };
|
||||||
export * from './shared';
|
export * from './shared';
|
||||||
|
|
|
@ -5,7 +5,13 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { i18n } from '@kbn/i18n';
|
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 { ManagementSetup } from 'src/plugins/management/public';
|
||||||
import { SharePluginStart } from 'src/plugins/share/public';
|
import { SharePluginStart } from 'src/plugins/share/public';
|
||||||
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
|
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
|
||||||
|
@ -38,9 +44,13 @@ export interface MlSetupDependencies {
|
||||||
home: HomePublicPluginSetup;
|
home: HomePublicPluginSetup;
|
||||||
embeddable: EmbeddableSetup;
|
embeddable: EmbeddableSetup;
|
||||||
uiActions: UiActionsSetup;
|
uiActions: UiActionsSetup;
|
||||||
|
kibanaVersion: string;
|
||||||
|
share: SharePluginStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
|
export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
|
||||||
|
constructor(private initializerContext: PluginInitializerContext) {}
|
||||||
|
|
||||||
setup(core: CoreSetup<MlStartDependencies, MlPluginStart>, pluginsSetup: MlSetupDependencies) {
|
setup(core: CoreSetup<MlStartDependencies, MlPluginStart>, pluginsSetup: MlSetupDependencies) {
|
||||||
core.application.register({
|
core.application.register({
|
||||||
id: PLUGIN_ID,
|
id: PLUGIN_ID,
|
||||||
|
@ -53,6 +63,7 @@ export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
|
||||||
category: DEFAULT_APP_CATEGORIES.kibana,
|
category: DEFAULT_APP_CATEGORIES.kibana,
|
||||||
mount: async (params: AppMountParameters) => {
|
mount: async (params: AppMountParameters) => {
|
||||||
const [coreStart, pluginsStart] = await core.getStartServices();
|
const [coreStart, pluginsStart] = await core.getStartServices();
|
||||||
|
const kibanaVersion = this.initializerContext.env.packageInfo.version;
|
||||||
const { renderApp } = await import('./application/app');
|
const { renderApp } = await import('./application/app');
|
||||||
return renderApp(
|
return renderApp(
|
||||||
coreStart,
|
coreStart,
|
||||||
|
@ -67,6 +78,7 @@ export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
|
||||||
home: pluginsSetup.home,
|
home: pluginsSetup.home,
|
||||||
embeddable: pluginsSetup.embeddable,
|
embeddable: pluginsSetup.embeddable,
|
||||||
uiActions: pluginsSetup.uiActions,
|
uiActions: pluginsSetup.uiActions,
|
||||||
|
kibanaVersion,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
element: params.element,
|
element: params.element,
|
||||||
|
|
|
@ -61,6 +61,7 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
before(async () => {
|
before(async () => {
|
||||||
await esArchiver.loadIfNeeded('ml/farequote');
|
await esArchiver.loadIfNeeded('ml/farequote');
|
||||||
await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp');
|
await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp');
|
||||||
|
await ml.testResources.createMLTestDashboardIfNeeded();
|
||||||
await ml.testResources.setKibanaTimeZoneToUTC();
|
await ml.testResources.setKibanaTimeZoneToUTC();
|
||||||
|
|
||||||
await ml.securityUI.loginAsMlPowerUser();
|
await ml.securityUI.loginAsMlPowerUser();
|
||||||
|
@ -125,6 +126,12 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
it('anomalies table is not empty', async () => {
|
it('anomalies table is not empty', async () => {
|
||||||
await ml.anomaliesTable.assertTableNotEmpty();
|
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.securityCommon.cleanMlRoles();
|
||||||
|
|
||||||
await ml.testResources.deleteSavedSearches();
|
await ml.testResources.deleteSavedSearches();
|
||||||
|
await ml.testResources.deleteDashboards();
|
||||||
|
|
||||||
await ml.testResources.deleteIndexPatternByTitle('ft_farequote');
|
await ml.testResources.deleteIndexPatternByTitle('ft_farequote');
|
||||||
await ml.testResources.deleteIndexPatternByTitle('ft_ecommerce');
|
await ml.testResources.deleteIndexPatternByTitle('ft_ecommerce');
|
||||||
|
|
|
@ -66,5 +66,38 @@ export function MachineLearningAnomalyExplorerProvider({ getService }: FtrProvid
|
||||||
async assertSwimlaneViewByExists() {
|
async assertSwimlaneViewByExists() {
|
||||||
await testSubjects.existOrFail('mlAnomalyExplorerSwimlaneViewBy');
|
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 { 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 { COMMON_REQUEST_HEADERS } from './common';
|
||||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||||
|
|
||||||
|
@ -137,6 +137,20 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider
|
||||||
return createResponse.id;
|
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> {
|
async createSavedSearchIfNeeded(savedSearch: any): Promise<string> {
|
||||||
const title = savedSearch.requestBody.attributes.title;
|
const title = savedSearch.requestBody.attributes.title;
|
||||||
const savedSearchId = await this.getSavedSearchId(title);
|
const savedSearchId = await this.getSavedSearchId(title);
|
||||||
|
@ -181,6 +195,21 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider
|
||||||
await this.createSavedSearchIfNeeded(savedSearches.farequoteFilter);
|
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() {
|
async createSavedSearchFarequoteLuceneIfNeeded() {
|
||||||
await this.createSavedSearchIfNeeded(savedSearches.farequoteLucene);
|
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) {
|
async assertSavedObjectExistsByTitle(title: string, objectType: SavedObjectType) {
|
||||||
await retry.waitForWithTimeout(
|
await retry.waitForWithTimeout(
|
||||||
`${objectType} with title '${title}' to exist`,
|
`${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"
|
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==
|
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":
|
"@types/elasticsearch@^5.0.33":
|
||||||
version "5.0.33"
|
version "5.0.33"
|
||||||
resolved "https://registry.yarnpkg.com/@types/elasticsearch/-/elasticsearch-5.0.33.tgz#b0fd37dc674f498223b6d68c313bdfd71f4d812b"
|
resolved "https://registry.yarnpkg.com/@types/elasticsearch/-/elasticsearch-5.0.33.tgz#b0fd37dc674f498223b6d68c313bdfd71f4d812b"
|
||||||
|
|
Loading…
Reference in a new issue