diff --git a/src/plugins/dashboard/public/index.ts b/src/plugins/dashboard/public/index.ts index 28606b7dd978..17968dd0281e 100644 --- a/src/plugins/dashboard/public/index.ts +++ b/src/plugins/dashboard/public/index.ts @@ -32,8 +32,10 @@ export { export { DashboardConstants, createDashboardEditUrl } from './dashboard_constants'; export { DashboardStart, DashboardUrlGenerator } from './plugin'; -export { DASHBOARD_APP_URL_GENERATOR } from './url_generator'; +export { DASHBOARD_APP_URL_GENERATOR, createDashboardUrlGenerator } from './url_generator'; export { addEmbeddableToDashboardUrl } from './url_utils/url_helper'; +export { SavedObjectDashboard } from './saved_dashboards'; +export { SavedDashboardPanel } from './types'; export function plugin(initializerContext: PluginInitializerContext) { return new DashboardPlugin(initializerContext); diff --git a/src/plugins/dashboard/public/url_generator.ts b/src/plugins/dashboard/public/url_generator.ts index d6805b2d9411..188de7fd857b 100644 --- a/src/plugins/dashboard/public/url_generator.ts +++ b/src/plugins/dashboard/public/url_generator.ts @@ -28,6 +28,7 @@ import { import { setStateToKbnUrl } from '../../kibana_utils/public'; import { UrlGeneratorsDefinition, UrlGeneratorState } from '../../share/public'; import { SavedObjectLoader } from '../../saved_objects/public'; +import { ViewMode } from '../../embeddable/public'; export const STATE_STORAGE_KEY = '_a'; export const GLOBAL_STATE_STORAGE_KEY = '_g'; @@ -73,6 +74,11 @@ export type DashboardAppLinkGeneratorState = UrlGeneratorState<{ * true is default */ preserveSavedFilters?: boolean; + + /** + * View mode of the dashboard. + */ + viewMode?: ViewMode; }>; export const createDashboardUrlGenerator = ( @@ -123,6 +129,7 @@ export const createDashboardUrlGenerator = ( cleanEmptyKeys({ query: state.query, filters: filters?.filter((f) => !esFilters.isFilterPinned(f)), + viewMode: state.viewMode, }), { useHash }, `${appBasePath}#/${hash}` diff --git a/x-pack/package.json b/x-pack/package.json index e24d75cc0d96..b40a1e436425 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -61,6 +61,7 @@ "@types/d3-shape": "^1.3.1", "@types/d3-time": "^1.0.10", "@types/d3-time-format": "^2.1.1", + "@types/dragselect": "^1.13.1", "@types/elasticsearch": "^5.0.33", "@types/fancy-log": "^1.3.1", "@types/file-saver": "^2.0.0", diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 4b6ff8c64822..b871d857f7fd 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -35,6 +35,8 @@ const App: FC = ({ coreStart, deps }) => { }; const services = { appName: 'ML', + kibanaVersion: deps.kibanaVersion, + share: deps.share, data: deps.data, security: deps.security, licenseManagement: deps.licenseManagement, diff --git a/x-pack/plugins/ml/public/application/components/loading_indicator/index.js b/x-pack/plugins/ml/public/application/components/loading_indicator/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/components/loading_indicator/index.js rename to x-pack/plugins/ml/public/application/components/loading_indicator/index.ts diff --git a/x-pack/plugins/ml/public/application/components/loading_indicator/loading_indicator.js b/x-pack/plugins/ml/public/application/components/loading_indicator/loading_indicator.tsx similarity index 70% rename from x-pack/plugins/ml/public/application/components/loading_indicator/loading_indicator.js rename to x-pack/plugins/ml/public/application/components/loading_indicator/loading_indicator.tsx index 20f4fb86b537..364b23a27eaf 100644 --- a/x-pack/plugins/ml/public/application/components/loading_indicator/loading_indicator.js +++ b/x-pack/plugins/ml/public/application/components/loading_indicator/loading_indicator.tsx @@ -4,12 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import PropTypes from 'prop-types'; -import React from 'react'; +import React, { FC } from 'react'; import { EuiLoadingChart, EuiSpacer } from '@elastic/eui'; -export function LoadingIndicator({ height, label }) { +export const LoadingIndicator: FC<{ height?: number; label?: string }> = ({ height, label }) => { height = height ? +height : 100; return (
-
{label}
+
{label}
)}
); -} -LoadingIndicator.propTypes = { - height: PropTypes.number, - label: PropTypes.string, }; diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts index c65d872212ad..2a156b5716ad 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts @@ -12,13 +12,15 @@ import { } from '../../../../../../../src/plugins/kibana_react/public'; import { SecurityPluginSetup } from '../../../../../security/public'; import { LicenseManagementUIPluginSetup } from '../../../../../license_management/public'; +import { SharePluginStart } from '../../../../../../../src/plugins/share/public'; interface StartPlugins { data: DataPublicPluginStart; security?: SecurityPluginSetup; licenseManagement?: LicenseManagementUIPluginSetup; + share: SharePluginStart; } -export type StartServices = CoreStart & StartPlugins; +export type StartServices = CoreStart & StartPlugins & { kibanaVersion: string }; // eslint-disable-next-line react-hooks/rules-of-hooks export const useMlKibana = () => useKibana(); export type MlKibanaReactContextValue = KibanaReactContextValue; diff --git a/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts b/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts index f8abd48ce856..07d5a153664b 100644 --- a/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts @@ -14,6 +14,7 @@ export interface MlContextValue { currentSavedSearch: SavedSearchSavedObject | null; indexPatterns: IndexPatternsContract; kibanaConfig: any; // IUiSettingsClient; + kibanaVersion: string; } export type SavedSearchQuery = object; diff --git a/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx b/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx new file mode 100644 index 000000000000..cb11a33ccfd7 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx @@ -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; + +function getDefaultEmbeddablepaPanelConfig(jobIds: JobId[]) { + return { + type: ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, + title: getDefaultPanelTitle(jobIds), + }; +} + +interface AddToDashboardControlProps { + jobIds: JobId[]; + viewBy: string; + limit: number; + onClose: (callback?: () => Promise) => void; +} + +/** + * Component for attaching anomaly swimlane embeddable to dashboards. + */ +export const AddToDashboardControl: FC = ({ + 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([]); + const [selectedItems, setSelectedItems] = useState([]); + + 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: ( + + ), + 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 ( + + + + + + + + + + } + > + { + const newSelection = { + ...selectedSwimlanes, + [optionId]: !selectedSwimlanes[optionId as SwimlaneType], + }; + setSelectedSwimlanes(newSelection); + }} + data-test-subj="mlAddToDashboardSwimlaneTypeSelector" + /> + + + + + + } + data-test-subj="mlDashboardSelectionContainer" + > + + + + + + + + { + onClose(async () => { + const selectedDashboardId = selectedItems[0].id; + await addSwimlaneToDashboardCallback(); + await navigateToUrl( + await dashboardService.getDashboardEditUrl(selectedDashboardId) + ); + }); + }} + data-test-subj="mlAddAndEditDashboardButton" + > + + + + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx new file mode 100644 index 000000000000..b4d32e2af64b --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx @@ -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 = 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( + + + + ); + } + return items; + }, [canEditDashboards]); + + return ( + <> + + + + +

+ +

+
+
+ {viewBySwimlaneOptions.length > 0 && ( + <> + + + + + } + display={'columnCompressed'} + > + explorerService.setViewBySwimlaneFieldName(e.target.value)} + /> + + + + + + + } + display={'columnCompressed'} + > + + + + +
+ {viewByLoadedForTimeFormatted && ( + + )} + {viewByLoadedForTimeFormatted === undefined && ( + + )} + {filterActive === true && viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL && ( + + )} +
+
+ + )} + + {menuItems.length > 0 && ( + + + } + isOpen={isMenuOpen} + closePopover={setIsMenuOpen.bind(null, false)} + panelPaddingSize="none" + anchorPosition="downLeft" + > + + + + )} +
+ + + +
+ {showOverallSwimlane && ( + explorerService.setSwimlaneContainerWidth(width)} + /> + )} +
+ + {viewBySwimlaneOptions.length > 0 && ( + <> + {showViewBySwimlane && ( + <> + +
+ explorerService.setSwimlaneContainerWidth(width)} + /> +
+ + )} + + {viewBySwimlaneDataLoading && } + + {!showViewBySwimlane && + !viewBySwimlaneDataLoading && + typeof viewBySwimlaneFieldName === 'string' && ( + + )} + + )} +
+ {isAddDashboardsActive && selectedJobs && ( + { + setIsAddDashboardActive(false); + if (callback) { + await callback(); + } + }} + jobIds={selectedJobs.map(({ id }) => id)} + viewBy={viewBySwimlaneFieldName!} + limit={swimlaneLimit} + /> + )} + + ); + }, + (prevProps, nextProps) => { + return isEqual(prevProps.explorerState, nextProps.explorerState); + } +); diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/explorer_no_influencers_found.js b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/explorer_no_influencers_found.tsx similarity index 78% rename from x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/explorer_no_influencers_found.js rename to x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/explorer_no_influencers_found.tsx index 5f54c383e76a..639c0f7b7850 100644 --- a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/explorer_no_influencers_found.js +++ b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/explorer_no_influencers_found.tsx @@ -4,20 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -/* - * React component for rendering EuiEmptyPrompt when no influencers were found. - */ - -import PropTypes from 'prop-types'; -import React from 'react'; +import React, { FC } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiEmptyPrompt } from '@elastic/eui'; -export const ExplorerNoInfluencersFound = ({ - viewBySwimlaneFieldName, - showFilterMessage = false, -}) => ( +/* + * React component for rendering EuiEmptyPrompt when no influencers were found. + */ +export const ExplorerNoInfluencersFound: FC<{ + viewBySwimlaneFieldName: string; + showFilterMessage?: boolean; +}> = ({ viewBySwimlaneFieldName, showFilterMessage = false }) => ( ); - -ExplorerNoInfluencersFound.propTypes = { - viewBySwimlaneFieldName: PropTypes.string.isRequired, - showFilterMessage: PropTypes.bool, -}; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.js b/x-pack/plugins/ml/public/application/explorer/explorer.js index 1a5a9a9d8286..71c96840d1b5 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer.js @@ -9,10 +9,9 @@ */ import PropTypes from 'prop-types'; -import React, { createRef } from 'react'; +import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import DragSelect from 'dragselect/dist/ds.min.js'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @@ -26,34 +25,23 @@ import { EuiPageBody, EuiPageHeader, EuiPageHeaderSection, - EuiSelect, EuiSpacer, EuiTitle, } from '@elastic/eui'; import { AnnotationFlyout } from '../components/annotations/annotation_flyout'; import { AnnotationsTable } from '../components/annotations/annotations_table'; -import { - ExplorerNoInfluencersFound, - ExplorerNoJobsFound, - ExplorerNoResultsFound, -} from './components'; -import { ExplorerSwimlane } from './explorer_swimlane'; -import { getTimeBucketsFromCache } from '../util/time_buckets'; +import { ExplorerNoJobsFound, ExplorerNoResultsFound } from './components'; import { DatePickerWrapper } from '../components/navigation_menu/date_picker_wrapper'; import { InfluencersList } from '../components/influencers_list'; -import { - ALLOW_CELL_RANGE_SELECTION, - dragSelect$, - explorerService, -} from './explorer_dashboard_service'; +import { explorerService } from './explorer_dashboard_service'; import { AnomalyResultsViewSelector } from '../components/anomaly_results_view_selector'; import { LoadingIndicator } from '../components/loading_indicator/loading_indicator'; import { NavigationMenu } from '../components/navigation_menu'; import { CheckboxShowCharts } from '../components/controls/checkbox_showcharts'; import { JobSelector } from '../components/job_selector'; import { SelectInterval } from '../components/controls/select_interval/select_interval'; -import { SelectLimit, limit$ } from './select_limit/select_limit'; +import { limit$ } from './select_limit/select_limit'; import { SelectSeverity } from '../components/controls/select_severity/select_severity'; import { ExplorerQueryBar, @@ -67,14 +55,9 @@ import { escapeParens, escapeDoubleQuotes, } from './explorer_utils'; -import { getSwimlaneContainerWidth } from './legacy_utils'; +import { AnomalyTimeline } from './anomaly_timeline'; -import { - DRAG_SELECT_ACTION, - FILTER_ACTION, - SWIMLANE_TYPE, - VIEW_BY_JOB_LABEL, -} from './explorer_constants'; +import { FILTER_ACTION } from './explorer_constants'; // Explorer Charts import { ExplorerChartsContainer } from './explorer_charts/explorer_charts_container'; @@ -82,17 +65,7 @@ import { ExplorerChartsContainer } from './explorer_charts/explorer_charts_conta // Anomalies Table import { AnomaliesTable } from '../components/anomalies_table/anomalies_table'; -import { ResizeChecker } from '../../../../../../src/plugins/kibana_utils/public'; import { getTimefilter, getToastNotifications } from '../util/dependency_cache'; -import { MlTooltipComponent } from '../components/chart_tooltip'; -import { hasMatchingPoints } from './has_matching_points'; - -function mapSwimlaneOptionsToEuiOptions(options) { - return options.map((option) => ({ - value: option, - text: option, - })); -} const ExplorerPage = ({ children, @@ -105,9 +78,8 @@ const ExplorerPage = ({ queryString, filterIconTriggeredQuery, updateLanguage, - resizeRef, }) => ( -
+
@@ -171,108 +143,18 @@ export class Explorer extends React.Component { state = { filterIconTriggeredQuery: undefined, language: DEFAULT_QUERY_LANG }; _unsubscribeAll = new Subject(); - // make sure dragSelect is only available if the mouse pointer is actually over a swimlane - disableDragSelectOnMouseLeave = true; - - dragSelect = new DragSelect({ - selectorClass: 'ml-swimlane-selector', - selectables: document.getElementsByClassName('sl-cell'), - callback(elements) { - if (elements.length > 1 && !ALLOW_CELL_RANGE_SELECTION) { - elements = [elements[0]]; - } - - if (elements.length > 0) { - dragSelect$.next({ - action: DRAG_SELECT_ACTION.NEW_SELECTION, - elements, - }); - } - - this.disableDragSelectOnMouseLeave = true; - }, - onDragStart(e) { - let target = e.target; - while (target && target !== document.body && !target.classList.contains('sl-cell')) { - target = target.parentNode; - } - if (ALLOW_CELL_RANGE_SELECTION && target !== document.body) { - dragSelect$.next({ - action: DRAG_SELECT_ACTION.DRAG_START, - }); - this.disableDragSelectOnMouseLeave = false; - } - }, - onElementSelect() { - if (ALLOW_CELL_RANGE_SELECTION) { - dragSelect$.next({ - action: DRAG_SELECT_ACTION.ELEMENT_SELECT, - }); - } - }, - }); - - // Listens to render updates of the swimlanes to update dragSelect - swimlaneRenderDoneListener = () => { - this.dragSelect.clearSelection(); - this.dragSelect.setSelectables(document.getElementsByClassName('sl-cell')); - }; - - resizeRef = createRef(); - resizeChecker = undefined; - resizeHandler = () => { - explorerService.setSwimlaneContainerWidth(getSwimlaneContainerWidth()); - }; componentDidMount() { limit$.pipe(takeUntil(this._unsubscribeAll)).subscribe(explorerService.setSwimlaneLimit); - - // Required to redraw the time series chart when the container is resized. - this.resizeChecker = new ResizeChecker(this.resizeRef.current); - this.resizeChecker.on('resize', this.resizeHandler); - - this.timeBuckets = getTimeBucketsFromCache(); } componentWillUnmount() { this._unsubscribeAll.next(); this._unsubscribeAll.complete(); - this.resizeChecker.destroy(); - } - - resetCache() { - this.anomaliesTablePreviousArgs = null; } viewByChangeHandler = (e) => explorerService.setViewBySwimlaneFieldName(e.target.value); - isSwimlaneSelectActive = false; - onSwimlaneEnterHandler = () => this.setSwimlaneSelectActive(true); - onSwimlaneLeaveHandler = () => this.setSwimlaneSelectActive(false); - setSwimlaneSelectActive = (active) => { - if (this.isSwimlaneSelectActive && !active && this.disableDragSelectOnMouseLeave) { - this.dragSelect.stop(); - this.isSwimlaneSelectActive = active; - return; - } - if (!this.isSwimlaneSelectActive && active) { - this.dragSelect.start(); - this.dragSelect.clearSelection(); - this.dragSelect.setSelectables(document.getElementsByClassName('sl-cell')); - this.isSwimlaneSelectActive = active; - } - }; - - // Listener for click events in the swimlane to load corresponding anomaly data. - swimlaneCellClick = (selectedCells) => { - // If selectedCells is an empty object we clear any existing selection, - // otherwise we save the new selection in AppState and update the Explorer. - if (Object.keys(selectedCells).length === 0) { - this.props.setSelectedCells(); - } else { - this.props.setSelectedCells(selectedCells); - } - }; // Escape regular parens from fieldName as that portion of the query is not wrapped in double quotes // and will cause a syntax error when called with getKqlQueryValues applyFilter = (fieldName, fieldValue, action) => { @@ -339,24 +221,16 @@ export class Explorer extends React.Component { annotationsData, chartsData, filterActive, - filteredFields, filterPlaceHolder, indexPattern, influencers, loading, - maskAll, noInfluencersConfigured, overallSwimlaneData, queryString, selectedCells, selectedJobs, - swimlaneContainerWidth, tableData, - viewByLoadedForTimeFormatted, - viewBySwimlaneData, - viewBySwimlaneDataLoading, - viewBySwimlaneFieldName, - viewBySwimlaneOptions, } = this.props.explorerState; const jobSelectorProps = { @@ -378,7 +252,6 @@ export class Explorer extends React.Component { indexPattern={indexPattern} queryString={queryString} updateLanguage={this.updateLanguage} - resizeRef={this.resizeRef} > + ); @@ -399,7 +272,7 @@ export class Explorer extends React.Component { if (noJobsFound && hasResults === false) { return ( - + ); @@ -408,15 +281,6 @@ export class Explorer extends React.Component { const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10'; const mainColumnClasses = `column ${mainColumnWidthClassName}`; - const showOverallSwimlane = - overallSwimlaneData !== null && - overallSwimlaneData.laneLabels && - overallSwimlaneData.laneLabels.length > 0; - const showViewBySwimlane = - viewBySwimlaneData !== null && - viewBySwimlaneData.laneLabels && - viewBySwimlaneData.laneLabels.length > 0; - const timefilter = getTimefilter(); const bounds = timefilter.getActiveBounds(); @@ -431,7 +295,6 @@ export class Explorer extends React.Component { indexPattern={indexPattern} queryString={queryString} updateLanguage={this.updateLanguage} - resizeRef={this.resizeRef} >
{noInfluencersConfigured && ( @@ -462,142 +325,12 @@ export class Explorer extends React.Component { )}
- -

- -

-
- -
- {showOverallSwimlane && ( - - {(tooltipService) => ( - - )} - - )} -
- - {viewBySwimlaneOptions.length > 0 && ( - <> - - - - - - - - - - - - - -
- {viewByLoadedForTimeFormatted && ( - - )} - {viewByLoadedForTimeFormatted === undefined && ( - - )} - {filterActive === true && viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL && ( - - )} -
-
-
-
- - {showViewBySwimlane && ( - <> - -
- - {(tooltipService) => ( - - )} - -
- - )} - - {viewBySwimlaneDataLoading && } - - {!showViewBySwimlane && - !viewBySwimlaneDataLoading && - viewBySwimlaneFieldName !== null && ( - - )} - - )} + + + {annotationsData.length > 0 && ( <> diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts index 1cfd29e2f60d..d1adf8c7ad74 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts @@ -37,10 +37,12 @@ export const FILTER_ACTION = { REMOVE: '-', }; -export enum SWIMLANE_TYPE { - OVERALL = 'overall', - VIEW_BY = 'viewBy', -} +export const SWIMLANE_TYPE = { + OVERALL: 'overall', + VIEW_BY: 'viewBy', +} as const; + +export type SwimlaneType = typeof SWIMLANE_TYPE[keyof typeof SWIMLANE_TYPE]; export const CHART_TYPE = { EVENT_DISTRIBUTION: 'event_distribution', diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx index 18b5de1d51f9..4e6dcdcc5129 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx @@ -22,7 +22,7 @@ import { numTicksForDateFormat } from '../util/chart_utils'; import { getSeverityColor } from '../../../common/util/anomaly_utils'; import { mlEscape } from '../util/string_utils'; import { ALLOW_CELL_RANGE_SELECTION, dragSelect$ } from './explorer_dashboard_service'; -import { DRAG_SELECT_ACTION } from './explorer_constants'; +import { DRAG_SELECT_ACTION, SwimlaneType } from './explorer_constants'; import { EMPTY_FIELD_VALUE_LABEL } from '../timeseriesexplorer/components/entity_control/entity_control'; import { TimeBuckets as TimeBucketsClass } from '../util/time_buckets'; import { @@ -58,7 +58,7 @@ export interface ExplorerSwimlaneProps { timeBuckets: InstanceType; swimlaneCellClick?: Function; swimlaneData: OverallSwimlaneData; - swimlaneType: string; + swimlaneType: SwimlaneType; selection?: { lanes: any[]; type: string; diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts index 0a2dbf5bcff3..4e1a2af9b13a 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts @@ -16,8 +16,9 @@ import { AnomaliesTableData, ExplorerJob, AppStateSelectedCells, - SwimlaneData, TimeRangeBounds, + OverallSwimlaneData, + SwimlaneData, } from '../../explorer_utils'; export interface ExplorerState { @@ -35,7 +36,7 @@ export interface ExplorerState { loading: boolean; maskAll: boolean; noInfluencersConfigured: boolean; - overallSwimlaneData: SwimlaneData; + overallSwimlaneData: SwimlaneData | OverallSwimlaneData; queryString: string; selectedCells: AppStateSelectedCells | undefined; selectedJobs: ExplorerJob[] | null; @@ -45,7 +46,7 @@ export interface ExplorerState { tableData: AnomaliesTableData; tableQueryString: string; viewByLoadedForTimeFormatted: string | null; - viewBySwimlaneData: SwimlaneData; + viewBySwimlaneData: SwimlaneData | OverallSwimlaneData; viewBySwimlaneDataLoading: boolean; viewBySwimlaneFieldName?: string; viewBySwimlaneOptions: string[]; diff --git a/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.tsx b/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.tsx index 7f7a8fc5a70b..7a2df1a0f053 100644 --- a/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.tsx +++ b/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.tsx @@ -36,5 +36,5 @@ export const SelectLimit = () => { setLimit(parseInt(e.target.value, 10)); } - return ; + return ; }; diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx new file mode 100644 index 000000000000..57d1fd81000b --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -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 & { + onResize: (width: number) => void; + } +> = ({ children, onResize, ...props }) => { + const [chartWidth, setChartWidth] = useState(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 ( + + {(resizeRef) => ( +
{ + resizeRef(el); + }} + > +
+ + + {(tooltipService) => ( + + )} + + +
+
+ )} +
+ ); +}; diff --git a/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts b/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts new file mode 100644 index 000000000000..6cab23eb187c --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts @@ -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 } }); + }); +}); diff --git a/x-pack/plugins/ml/public/application/services/dashboard_service.ts b/x-pack/plugins/ml/public/application/services/dashboard_service.ts new file mode 100644 index 000000000000..7f2bb71d18eb --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/dashboard_service.ts @@ -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; + +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({ + 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> + ) { + 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] + ); +} diff --git a/x-pack/plugins/ml/public/application/services/http_service.ts b/x-pack/plugins/ml/public/application/services/http_service.ts index 7144411c2885..bd927dc0e301 100644 --- a/x-pack/plugins/ml/public/application/services/http_service.ts +++ b/x-pack/plugins/ml/public/application/services/http_service.ts @@ -37,6 +37,8 @@ function getFetchOptions( /** * Function for making HTTP requests to Kibana's backend. * Wrapper for Kibana's HttpHandler. + * + * @deprecated use {@link HttpService} instead */ export async function http(options: HttpFetchOptionsWithPath): Promise { const { path, fetchOptions } = getFetchOptions(options); @@ -46,6 +48,8 @@ export async function http(options: HttpFetchOptionsWithPath): Promise { /** * Function for making HTTP requests to Kibana's backend which returns an Observable * with request cancellation support. + * + * @deprecated use {@link HttpService} instead */ export function http$(options: HttpFetchOptionsWithPath): Observable { const { path, fetchOptions } = getFetchOptions(options); @@ -55,7 +59,7 @@ export function http$(options: HttpFetchOptionsWithPath): Observable { /** * Creates an Observable from Kibana's HttpHandler. */ -export function fromHttpHandler(input: string, init?: RequestInit): Observable { +function fromHttpHandler(input: string, init?: RequestInit): Observable { return new Observable((subscriber) => { const controller = new AbortController(); const signal = controller.signal; diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index fdaa3c2ffe79..6d32fca6a645 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -5,12 +5,13 @@ */ import { Observable } from 'rxjs'; -import { http, http$ } from '../http_service'; +import { HttpStart } from 'kibana/public'; +import { HttpService } from '../http_service'; import { annotations } from './annotations'; import { dataFrameAnalytics } from './data_frame_analytics'; import { filters } from './filters'; -import { results } from './results'; +import { resultsApiProvider } from './results'; import { jobs } from './jobs'; import { fileDatavisualizer } from './datavisualizer'; import { MlServerDefaults, MlServerLimits } from '../../../../common/types/ml_server_info'; @@ -28,6 +29,7 @@ import { import { ES_AGGREGATION } from '../../../../common/constants/aggregation_types'; import { FieldRequestConfig } from '../../datavisualizer/index_based/common'; import { DataRecognizerConfigResponse, Module } from '../../../../common/types/modules'; +import { getHttp } from '../../util/dependency_cache'; export interface MlInfoResponse { defaults: MlServerDefaults; @@ -87,327 +89,330 @@ export function basePath() { return '/api/ml'; } -export const ml = { - getJobs(obj?: { jobId?: string }) { - const jobId = obj && obj.jobId ? `/${obj.jobId}` : ''; - return http({ - path: `${basePath()}/anomaly_detectors${jobId}`, - }); +/** + * Temp solution to allow {@link ml} service to use http from + * the dependency_cache. + */ +const proxyHttpStart = new Proxy(({} as unknown) as HttpStart, { + get(obj, prop: keyof HttpStart) { + try { + return getHttp()[prop]; + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } }, +}); - getJobStats(obj: { jobId?: string }) { - const jobId = obj && obj.jobId ? `/${obj.jobId}` : ''; - return http({ - path: `${basePath()}/anomaly_detectors${jobId}/_stats`, - }); - }, +export type MlApiServices = ReturnType; - addJob({ jobId, job }: { jobId: string; job: Job }) { - const body = JSON.stringify(job); - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}`, - method: 'PUT', - body, - }); - }, +export const ml = mlApiServicesProvider(new HttpService(proxyHttpStart)); - openJob({ jobId }: { jobId: string }) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/_open`, - method: 'POST', - }); - }, +export function mlApiServicesProvider(httpService: HttpService) { + const { http } = httpService; + return { + getJobs(obj?: { jobId?: string }) { + const jobId = obj && obj.jobId ? `/${obj.jobId}` : ''; + return httpService.http({ + path: `${basePath()}/anomaly_detectors${jobId}`, + }); + }, - closeJob({ jobId }: { jobId: string }) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/_close`, - method: 'POST', - }); - }, + getJobStats(obj: { jobId?: string }) { + const jobId = obj && obj.jobId ? `/${obj.jobId}` : ''; + return httpService.http({ + path: `${basePath()}/anomaly_detectors${jobId}/_stats`, + }); + }, - forceCloseJob({ jobId }: { jobId: string }) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/_close?force=true`, - method: 'POST', - }); - }, + addJob({ jobId, job }: { jobId: string; job: Job }) { + const body = JSON.stringify(job); + return httpService.http({ + path: `${basePath()}/anomaly_detectors/${jobId}`, + method: 'PUT', + body, + }); + }, - deleteJob({ jobId }: { jobId: string }) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}`, - method: 'DELETE', - }); - }, + openJob({ jobId }: { jobId: string }) { + return httpService.http({ + path: `${basePath()}/anomaly_detectors/${jobId}/_open`, + method: 'POST', + }); + }, - forceDeleteJob({ jobId }: { jobId: string }) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}?force=true`, - method: 'DELETE', - }); - }, + closeJob({ jobId }: { jobId: string }) { + return http({ + path: `${basePath()}/anomaly_detectors/${jobId}/_close`, + method: 'POST', + }); + }, - updateJob({ jobId, job }: { jobId: string; job: Job }) { - const body = JSON.stringify(job); - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/_update`, - method: 'POST', - body, - }); - }, + forceCloseJob({ jobId }: { jobId: string }) { + return http({ + path: `${basePath()}/anomaly_detectors/${jobId}/_close?force=true`, + method: 'POST', + }); + }, - estimateBucketSpan(obj: BucketSpanEstimatorData) { - const body = JSON.stringify(obj); - return http({ - path: `${basePath()}/validate/estimate_bucket_span`, - method: 'POST', - body, - }); - }, + deleteJob({ jobId }: { jobId: string }) { + return httpService.http({ + path: `${basePath()}/anomaly_detectors/${jobId}`, + method: 'DELETE', + }); + }, - validateJob(payload: { - job: Job; - duration: { - start?: number; - end?: number; - }; - fields?: any[]; - }) { - const body = JSON.stringify(payload); - return http({ - path: `${basePath()}/validate/job`, - method: 'POST', - body, - }); - }, + forceDeleteJob({ jobId }: { jobId: string }) { + return httpService.http({ + path: `${basePath()}/anomaly_detectors/${jobId}?force=true`, + method: 'DELETE', + }); + }, - validateCardinality$(job: CombinedJob): Observable { - const body = JSON.stringify(job); - return http$({ - path: `${basePath()}/validate/cardinality`, - method: 'POST', - body, - }); - }, + updateJob({ jobId, job }: { jobId: string; job: Job }) { + const body = JSON.stringify(job); + return httpService.http({ + path: `${basePath()}/anomaly_detectors/${jobId}/_update`, + method: 'POST', + body, + }); + }, - getDatafeeds(obj: { datafeedId: string }) { - const datafeedId = obj && obj.datafeedId ? `/${obj.datafeedId}` : ''; - return http({ - path: `${basePath()}/datafeeds${datafeedId}`, - }); - }, + estimateBucketSpan(obj: BucketSpanEstimatorData) { + const body = JSON.stringify(obj); + return httpService.http({ + path: `${basePath()}/validate/estimate_bucket_span`, + method: 'POST', + body, + }); + }, - getDatafeedStats(obj: { datafeedId: string }) { - const datafeedId = obj && obj.datafeedId ? `/${obj.datafeedId}` : ''; - return http({ - path: `${basePath()}/datafeeds${datafeedId}/_stats`, - }); - }, + validateJob(payload: { + job: Job; + duration: { + start?: number; + end?: number; + }; + fields?: any[]; + }) { + const body = JSON.stringify(payload); + return httpService.http({ + path: `${basePath()}/validate/job`, + method: 'POST', + body, + }); + }, - addDatafeed({ datafeedId, datafeedConfig }: { datafeedId: string; datafeedConfig: Datafeed }) { - const body = JSON.stringify(datafeedConfig); - return http({ - path: `${basePath()}/datafeeds/${datafeedId}`, - method: 'PUT', - body, - }); - }, + validateCardinality$(job: CombinedJob): Observable { + const body = JSON.stringify(job); + return httpService.http$({ + path: `${basePath()}/validate/cardinality`, + method: 'POST', + body, + }); + }, - updateDatafeed({ datafeedId, datafeedConfig }: { datafeedId: string; datafeedConfig: Datafeed }) { - const body = JSON.stringify(datafeedConfig); - return http({ - path: `${basePath()}/datafeeds/${datafeedId}/_update`, - method: 'POST', - body, - }); - }, + getDatafeeds(obj: { datafeedId: string }) { + const datafeedId = obj && obj.datafeedId ? `/${obj.datafeedId}` : ''; + return httpService.http({ + path: `${basePath()}/datafeeds${datafeedId}`, + }); + }, - deleteDatafeed({ datafeedId }: { datafeedId: string }) { - return http({ - path: `${basePath()}/datafeeds/${datafeedId}`, - method: 'DELETE', - }); - }, + getDatafeedStats(obj: { datafeedId: string }) { + const datafeedId = obj && obj.datafeedId ? `/${obj.datafeedId}` : ''; + return httpService.http({ + path: `${basePath()}/datafeeds${datafeedId}/_stats`, + }); + }, - forceDeleteDatafeed({ datafeedId }: { datafeedId: string }) { - return http({ - path: `${basePath()}/datafeeds/${datafeedId}?force=true`, - method: 'DELETE', - }); - }, + addDatafeed({ datafeedId, datafeedConfig }: { datafeedId: string; datafeedConfig: Datafeed }) { + const body = JSON.stringify(datafeedConfig); + return httpService.http({ + path: `${basePath()}/datafeeds/${datafeedId}`, + method: 'PUT', + body, + }); + }, - startDatafeed({ datafeedId, start, end }: { datafeedId: string; start: number; end: number }) { - const body = JSON.stringify({ - ...(start !== undefined ? { start } : {}), - ...(end !== undefined ? { end } : {}), - }); + updateDatafeed({ + datafeedId, + datafeedConfig, + }: { + datafeedId: string; + datafeedConfig: Datafeed; + }) { + const body = JSON.stringify(datafeedConfig); + return httpService.http({ + path: `${basePath()}/datafeeds/${datafeedId}/_update`, + method: 'POST', + body, + }); + }, - return http({ - path: `${basePath()}/datafeeds/${datafeedId}/_start`, - method: 'POST', - body, - }); - }, + deleteDatafeed({ datafeedId }: { datafeedId: string }) { + return httpService.http({ + path: `${basePath()}/datafeeds/${datafeedId}`, + method: 'DELETE', + }); + }, - stopDatafeed({ datafeedId }: { datafeedId: string }) { - return http({ - path: `${basePath()}/datafeeds/${datafeedId}/_stop`, - method: 'POST', - }); - }, + forceDeleteDatafeed({ datafeedId }: { datafeedId: string }) { + return httpService.http({ + path: `${basePath()}/datafeeds/${datafeedId}?force=true`, + method: 'DELETE', + }); + }, - forceStopDatafeed({ datafeedId }: { datafeedId: string }) { - return http({ - path: `${basePath()}/datafeeds/${datafeedId}/_stop?force=true`, - method: 'POST', - }); - }, + startDatafeed({ datafeedId, start, end }: { datafeedId: string; start: number; end: number }) { + const body = JSON.stringify({ + ...(start !== undefined ? { start } : {}), + ...(end !== undefined ? { end } : {}), + }); - datafeedPreview({ datafeedId }: { datafeedId: string }) { - return http({ - path: `${basePath()}/datafeeds/${datafeedId}/_preview`, - method: 'GET', - }); - }, + return httpService.http({ + path: `${basePath()}/datafeeds/${datafeedId}/_start`, + method: 'POST', + body, + }); + }, - validateDetector({ detector }: { detector: Detector }) { - const body = JSON.stringify(detector); - return http({ - path: `${basePath()}/anomaly_detectors/_validate/detector`, - method: 'POST', - body, - }); - }, + stopDatafeed({ datafeedId }: { datafeedId: string }) { + return http({ + path: `${basePath()}/datafeeds/${datafeedId}/_stop`, + method: 'POST', + }); + }, - forecast({ jobId, duration }: { jobId: string; duration?: string }) { - const body = JSON.stringify({ - ...(duration !== undefined ? { duration } : {}), - }); + forceStopDatafeed({ datafeedId }: { datafeedId: string }) { + return http({ + path: `${basePath()}/datafeeds/${datafeedId}/_stop?force=true`, + method: 'POST', + }); + }, - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/_forecast`, - method: 'POST', - body, - }); - }, + datafeedPreview({ datafeedId }: { datafeedId: string }) { + return httpService.http({ + path: `${basePath()}/datafeeds/${datafeedId}/_preview`, + method: 'GET', + }); + }, - overallBuckets({ - jobId, - topN, - bucketSpan, - start, - end, - }: { - jobId: string; - topN: string; - bucketSpan: string; - start: number; - end: number; - }) { - const body = JSON.stringify({ topN, bucketSpan, start, end }); - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/results/overall_buckets`, - method: 'POST', - body, - }); - }, + validateDetector({ detector }: { detector: Detector }) { + const body = JSON.stringify(detector); + return httpService.http({ + path: `${basePath()}/anomaly_detectors/_validate/detector`, + method: 'POST', + body, + }); + }, - hasPrivileges(obj: any) { - const body = JSON.stringify(obj); - return http({ - path: `${basePath()}/_has_privileges`, - method: 'POST', - body, - }); - }, + forecast({ jobId, duration }: { jobId: string; duration?: string }) { + const body = JSON.stringify({ + ...(duration !== undefined ? { duration } : {}), + }); - checkMlCapabilities() { - return http({ - path: `${basePath()}/ml_capabilities`, - method: 'GET', - }); - }, + return httpService.http({ + path: `${basePath()}/anomaly_detectors/${jobId}/_forecast`, + method: 'POST', + body, + }); + }, - checkManageMLCapabilities() { - return http({ - path: `${basePath()}/ml_capabilities`, - method: 'GET', - }); - }, + overallBuckets({ + jobId, + topN, + bucketSpan, + start, + end, + }: { + jobId: string; + topN: string; + bucketSpan: string; + start: number; + end: number; + }) { + const body = JSON.stringify({ topN, bucketSpan, start, end }); + return httpService.http({ + path: `${basePath()}/anomaly_detectors/${jobId}/results/overall_buckets`, + method: 'POST', + body, + }); + }, - getNotificationSettings() { - return http({ - path: `${basePath()}/notification_settings`, - method: 'GET', - }); - }, + hasPrivileges(obj: any) { + const body = JSON.stringify(obj); + return httpService.http({ + path: `${basePath()}/_has_privileges`, + method: 'POST', + body, + }); + }, - getFieldCaps({ index, fields }: { index: string; fields: string[] }) { - const body = JSON.stringify({ - ...(index !== undefined ? { index } : {}), - ...(fields !== undefined ? { fields } : {}), - }); + checkMlCapabilities() { + return httpService.http({ + path: `${basePath()}/ml_capabilities`, + method: 'GET', + }); + }, - return http({ - path: `${basePath()}/indices/field_caps`, - method: 'POST', - body, - }); - }, + checkManageMLCapabilities() { + return httpService.http({ + path: `${basePath()}/ml_capabilities`, + method: 'GET', + }); + }, - recognizeIndex({ indexPatternTitle }: { indexPatternTitle: string }) { - return http({ - path: `${basePath()}/modules/recognize/${indexPatternTitle}`, - method: 'GET', - }); - }, + getNotificationSettings() { + return httpService.http({ + path: `${basePath()}/notification_settings`, + method: 'GET', + }); + }, - listDataRecognizerModules() { - return http({ - path: `${basePath()}/modules/get_module`, - method: 'GET', - }); - }, + getFieldCaps({ index, fields }: { index: string; fields: string[] }) { + const body = JSON.stringify({ + ...(index !== undefined ? { index } : {}), + ...(fields !== undefined ? { fields } : {}), + }); - getDataRecognizerModule({ moduleId }: { moduleId: string }) { - return http({ - path: `${basePath()}/modules/get_module/${moduleId}`, - method: 'GET', - }); - }, + return httpService.http({ + path: `${basePath()}/indices/field_caps`, + method: 'POST', + body, + }); + }, - dataRecognizerModuleJobsExist({ moduleId }: { moduleId: string }) { - return http({ - path: `${basePath()}/modules/jobs_exist/${moduleId}`, - method: 'GET', - }); - }, + recognizeIndex({ indexPatternTitle }: { indexPatternTitle: string }) { + return httpService.http({ + path: `${basePath()}/modules/recognize/${indexPatternTitle}`, + method: 'GET', + }); + }, - setupDataRecognizerConfig({ - moduleId, - prefix, - groups, - indexPatternName, - query, - useDedicatedIndex, - startDatafeed, - start, - end, - jobOverrides, - estimateModelMemory, - }: { - moduleId: string; - prefix?: string; - groups?: string[]; - indexPatternName?: string; - query?: any; - useDedicatedIndex?: boolean; - startDatafeed?: boolean; - start?: number; - end?: number; - jobOverrides?: Array>; - estimateModelMemory?: boolean; - }) { - const body = JSON.stringify({ + listDataRecognizerModules() { + return httpService.http({ + path: `${basePath()}/modules/get_module`, + method: 'GET', + }); + }, + + getDataRecognizerModule({ moduleId }: { moduleId: string }) { + return httpService.http({ + path: `${basePath()}/modules/get_module/${moduleId}`, + method: 'GET', + }); + }, + + dataRecognizerModuleJobsExist({ moduleId }: { moduleId: string }) { + return httpService.http({ + path: `${basePath()}/modules/jobs_exist/${moduleId}`, + method: 'GET', + }); + }, + + setupDataRecognizerConfig({ + moduleId, prefix, groups, indexPatternName, @@ -418,37 +423,41 @@ export const ml = { end, jobOverrides, estimateModelMemory, - }); + }: { + moduleId: string; + prefix?: string; + groups?: string[]; + indexPatternName?: string; + query?: any; + useDedicatedIndex?: boolean; + startDatafeed?: boolean; + start?: number; + end?: number; + jobOverrides?: Array>; + estimateModelMemory?: boolean; + }) { + const body = JSON.stringify({ + prefix, + groups, + indexPatternName, + query, + useDedicatedIndex, + startDatafeed, + start, + end, + jobOverrides, + estimateModelMemory, + }); - return http({ - path: `${basePath()}/modules/setup/${moduleId}`, - method: 'POST', - body, - }); - }, + return httpService.http({ + path: `${basePath()}/modules/setup/${moduleId}`, + method: 'POST', + body, + }); + }, - getVisualizerFieldStats({ - indexPatternTitle, - query, - timeFieldName, - earliest, - latest, - samplerShardSize, - interval, - fields, - maxExamples, - }: { - indexPatternTitle: string; - query: any; - timeFieldName?: string; - earliest?: number; - latest?: number; - samplerShardSize?: number; - interval?: string; - fields?: FieldRequestConfig[]; - maxExamples?: number; - }) { - const body = JSON.stringify({ + getVisualizerFieldStats({ + indexPatternTitle, query, timeFieldName, earliest, @@ -457,35 +466,37 @@ export const ml = { interval, fields, maxExamples, - }); + }: { + indexPatternTitle: string; + query: any; + timeFieldName?: string; + earliest?: number; + latest?: number; + samplerShardSize?: number; + interval?: string; + fields?: FieldRequestConfig[]; + maxExamples?: number; + }) { + const body = JSON.stringify({ + query, + timeFieldName, + earliest, + latest, + samplerShardSize, + interval, + fields, + maxExamples, + }); - return http({ - path: `${basePath()}/data_visualizer/get_field_stats/${indexPatternTitle}`, - method: 'POST', - body, - }); - }, + return httpService.http({ + path: `${basePath()}/data_visualizer/get_field_stats/${indexPatternTitle}`, + method: 'POST', + body, + }); + }, - getVisualizerOverallStats({ - indexPatternTitle, - query, - timeFieldName, - earliest, - latest, - samplerShardSize, - aggregatableFields, - nonAggregatableFields, - }: { - indexPatternTitle: string; - query: any; - timeFieldName?: string; - earliest?: number; - latest?: number; - samplerShardSize?: number; - aggregatableFields: string[]; - nonAggregatableFields: string[]; - }) { - const body = JSON.stringify({ + getVisualizerOverallStats({ + indexPatternTitle, query, timeFieldName, earliest, @@ -493,204 +504,230 @@ export const ml = { samplerShardSize, aggregatableFields, nonAggregatableFields, - }); + }: { + indexPatternTitle: string; + query: any; + timeFieldName?: string; + earliest?: number; + latest?: number; + samplerShardSize?: number; + aggregatableFields: string[]; + nonAggregatableFields: string[]; + }) { + const body = JSON.stringify({ + query, + timeFieldName, + earliest, + latest, + samplerShardSize, + aggregatableFields, + nonAggregatableFields, + }); - return http({ - path: `${basePath()}/data_visualizer/get_overall_stats/${indexPatternTitle}`, - method: 'POST', - body, - }); - }, + return httpService.http({ + path: `${basePath()}/data_visualizer/get_overall_stats/${indexPatternTitle}`, + method: 'POST', + body, + }); + }, - /** - * Gets a list of calendars - * @param obj - * @returns {Promise} - */ - calendars(obj?: { calendarId?: CalendarId; calendarIds?: CalendarId[] }) { - const { calendarId, calendarIds } = obj || {}; - let calendarIdsPathComponent = ''; - if (calendarId) { - calendarIdsPathComponent = `/${calendarId}`; - } else if (calendarIds) { - calendarIdsPathComponent = `/${calendarIds.join(',')}`; - } - return http({ - path: `${basePath()}/calendars${calendarIdsPathComponent}`, - method: 'GET', - }); - }, + /** + * Gets a list of calendars + * @param obj + * @returns {Promise} + */ + calendars(obj?: { calendarId?: CalendarId; calendarIds?: CalendarId[] }) { + const { calendarId, calendarIds } = obj || {}; + let calendarIdsPathComponent = ''; + if (calendarId) { + calendarIdsPathComponent = `/${calendarId}`; + } else if (calendarIds) { + calendarIdsPathComponent = `/${calendarIds.join(',')}`; + } + return httpService.http({ + path: `${basePath()}/calendars${calendarIdsPathComponent}`, + method: 'GET', + }); + }, - addCalendar(obj: Calendar) { - const body = JSON.stringify(obj); - return http({ - path: `${basePath()}/calendars`, - method: 'PUT', - body, - }); - }, + addCalendar(obj: Calendar) { + const body = JSON.stringify(obj); + return httpService.http({ + path: `${basePath()}/calendars`, + method: 'PUT', + body, + }); + }, - updateCalendar(obj: UpdateCalendar) { - const calendarId = obj && obj.calendarId ? `/${obj.calendarId}` : ''; - const body = JSON.stringify(obj); - return http({ - path: `${basePath()}/calendars${calendarId}`, - method: 'PUT', - body, - }); - }, + updateCalendar(obj: UpdateCalendar) { + const calendarId = obj && obj.calendarId ? `/${obj.calendarId}` : ''; + const body = JSON.stringify(obj); + return httpService.http({ + path: `${basePath()}/calendars${calendarId}`, + method: 'PUT', + body, + }); + }, - deleteCalendar({ calendarId }: { calendarId?: string }) { - return http({ - path: `${basePath()}/calendars/${calendarId}`, - method: 'DELETE', - }); - }, + deleteCalendar({ calendarId }: { calendarId?: string }) { + return httpService.http({ + path: `${basePath()}/calendars/${calendarId}`, + method: 'DELETE', + }); + }, - mlNodeCount() { - return http<{ count: number }>({ - path: `${basePath()}/ml_node_count`, - method: 'GET', - }); - }, + mlNodeCount() { + return httpService.http<{ count: number }>({ + path: `${basePath()}/ml_node_count`, + method: 'GET', + }); + }, - mlInfo() { - return http({ - path: `${basePath()}/info`, - method: 'GET', - }); - }, + mlInfo() { + return httpService.http({ + path: `${basePath()}/info`, + method: 'GET', + }); + }, - calculateModelMemoryLimit$({ - analysisConfig, - indexPattern, - query, - timeFieldName, - earliestMs, - latestMs, - }: { - analysisConfig: AnalysisConfig; - indexPattern: string; - query: any; - timeFieldName: string; - earliestMs: number; - latestMs: number; - }) { - const body = JSON.stringify({ + calculateModelMemoryLimit$({ analysisConfig, indexPattern, query, timeFieldName, earliestMs, latestMs, - }); + }: { + analysisConfig: AnalysisConfig; + indexPattern: string; + query: any; + timeFieldName: string; + earliestMs: number; + latestMs: number; + }) { + const body = JSON.stringify({ + analysisConfig, + indexPattern, + query, + timeFieldName, + earliestMs, + latestMs, + }); - return http$<{ modelMemoryLimit: string }>({ - path: `${basePath()}/validate/calculate_model_memory_limit`, - method: 'POST', - body, - }); - }, + return httpService.http$<{ modelMemoryLimit: string }>({ + path: `${basePath()}/validate/calculate_model_memory_limit`, + method: 'POST', + body, + }); + }, - getCardinalityOfFields({ - index, - fieldNames, - query, - timeFieldName, - earliestMs, - latestMs, - }: { - index: string; - fieldNames: string[]; - query: any; - timeFieldName: string; - earliestMs: number; - latestMs: number; - }) { - const body = JSON.stringify({ index, fieldNames, query, timeFieldName, earliestMs, latestMs }); + getCardinalityOfFields({ + index, + fieldNames, + query, + timeFieldName, + earliestMs, + latestMs, + }: { + index: string; + fieldNames: string[]; + query: any; + timeFieldName: string; + earliestMs: number; + latestMs: number; + }) { + const body = JSON.stringify({ + index, + fieldNames, + query, + timeFieldName, + earliestMs, + latestMs, + }); - return http({ - path: `${basePath()}/fields_service/field_cardinality`, - method: 'POST', - body, - }); - }, + return httpService.http({ + path: `${basePath()}/fields_service/field_cardinality`, + method: 'POST', + body, + }); + }, - getTimeFieldRange({ - index, - timeFieldName, - query, - }: { - index: string; - timeFieldName?: string; - query: any; - }) { - const body = JSON.stringify({ index, timeFieldName, query }); + getTimeFieldRange({ + index, + timeFieldName, + query, + }: { + index: string; + timeFieldName?: string; + query: any; + }) { + const body = JSON.stringify({ index, timeFieldName, query }); - return http({ - path: `${basePath()}/fields_service/time_field_range`, - method: 'POST', - body, - }); - }, + return httpService.http({ + path: `${basePath()}/fields_service/time_field_range`, + method: 'POST', + body, + }); + }, - esSearch(obj: any) { - const body = JSON.stringify(obj); - return http({ - path: `${basePath()}/es_search`, - method: 'POST', - body, - }); - }, + esSearch(obj: any) { + const body = JSON.stringify(obj); + return httpService.http({ + path: `${basePath()}/es_search`, + method: 'POST', + body, + }); + }, - esSearch$(obj: any) { - const body = JSON.stringify(obj); - return http$({ - path: `${basePath()}/es_search`, - method: 'POST', - body, - }); - }, + esSearch$(obj: any) { + const body = JSON.stringify(obj); + return httpService.http$({ + path: `${basePath()}/es_search`, + method: 'POST', + body, + }); + }, - getIndices() { - const tempBasePath = '/api'; - return http>({ - path: `${tempBasePath}/index_management/indices`, - method: 'GET', - }); - }, + getIndices() { + const tempBasePath = '/api'; + return httpService.http>({ + path: `${tempBasePath}/index_management/indices`, + method: 'GET', + }); + }, - getModelSnapshots(jobId: string, snapshotId?: string) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots${ - snapshotId !== undefined ? `/${snapshotId}` : '' - }`, - }); - }, + getModelSnapshots(jobId: string, snapshotId?: string) { + return http({ + path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots${ + snapshotId !== undefined ? `/${snapshotId}` : '' + }`, + }); + }, - updateModelSnapshot( - jobId: string, - snapshotId: string, - body: { description?: string; retain?: boolean } - ) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots/${snapshotId}/_update`, - method: 'POST', - body: JSON.stringify(body), - }); - }, + updateModelSnapshot( + jobId: string, + snapshotId: string, + body: { description?: string; retain?: boolean } + ) { + return http({ + path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots/${snapshotId}/_update`, + method: 'POST', + body: JSON.stringify(body), + }); + }, - deleteModelSnapshot(jobId: string, snapshotId: string) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots/${snapshotId}`, - method: 'DELETE', - }); - }, + deleteModelSnapshot(jobId: string, snapshotId: string) { + return http({ + path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots/${snapshotId}`, + method: 'DELETE', + }); + }, - annotations, - dataFrameAnalytics, - filters, - results, - jobs, - fileDatavisualizer, -}; + annotations, + dataFrameAnalytics, + filters, + results: resultsApiProvider(httpService), + jobs, + fileDatavisualizer, + }; +} diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts index 830e6fab4163..521fd306847e 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts @@ -5,14 +5,14 @@ */ // Service for obtaining data for the ML Results dashboards. -import { http, http$ } from '../http_service'; +import { HttpService } from '../http_service'; import { basePath } from './index'; import { JobId } from '../../../../common/types/anomaly_detection_jobs'; import { PartitionFieldsDefinition } from '../results_service/result_service_rx'; -export const results = { +export const resultsApiProvider = (httpService: HttpService) => ({ getAnomaliesTableData( jobIds: string[], criteriaFields: string[], @@ -40,7 +40,7 @@ export const results = { influencersFilterQuery, }); - return http$({ + return httpService.http$({ path: `${basePath()}/results/anomalies_table_data`, method: 'POST', body, @@ -53,7 +53,7 @@ export const results = { earliestMs, latestMs, }); - return http({ + return httpService.http({ path: `${basePath()}/results/max_anomaly_score`, method: 'POST', body, @@ -62,7 +62,7 @@ export const results = { getCategoryDefinition(jobId: string, categoryId: string) { const body = JSON.stringify({ jobId, categoryId }); - return http({ + return httpService.http({ path: `${basePath()}/results/category_definition`, method: 'POST', body, @@ -75,7 +75,7 @@ export const results = { categoryIds, maxExamples, }); - return http({ + return httpService.http({ path: `${basePath()}/results/category_examples`, method: 'POST', body, @@ -90,10 +90,10 @@ export const results = { latestMs: number ) { const body = JSON.stringify({ jobId, searchTerm, criteriaFields, earliestMs, latestMs }); - return http$({ + return httpService.http$({ path: `${basePath()}/results/partition_fields_values`, method: 'POST', body, }); }, -}; +}); diff --git a/x-pack/plugins/ml/public/application/services/results_service/index.ts b/x-pack/plugins/ml/public/application/services/results_service/index.ts index cc02248f4d5a..6c508422e706 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/index.ts @@ -4,47 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - getMetricData, - getModelPlotOutput, - getRecordsForCriteria, - getScheduledEventsByBucket, - fetchPartitionFieldsValues, -} from './result_service_rx'; -import { - getEventDistributionData, - getEventRateData, - getInfluencerValueMaxScoreByTime, - getOverallBucketScores, - getRecordInfluencers, - getRecordMaxScoreByTime, - getRecords, - getRecordsForDetector, - getRecordsForInfluencer, - getScoresByBucket, - getTopInfluencers, - getTopInfluencerValues, -} from './results_service'; - -export const mlResultsService = { - getScoresByBucket, - getScheduledEventsByBucket, - getTopInfluencers, - getTopInfluencerValues, - getOverallBucketScores, - getInfluencerValueMaxScoreByTime, - getRecordInfluencers, - getRecordsForInfluencer, - getRecordsForDetector, - getRecords, - getRecordsForCriteria, - getMetricData, - getEventRateData, - getEventDistributionData, - getModelPlotOutput, - getRecordMaxScoreByTime, - fetchPartitionFieldsValues, -}; +import { resultsServiceRxProvider } from './result_service_rx'; +import { resultsServiceProvider } from './results_service'; +import { ml, MlApiServices } from '../ml_api_service'; export type MlResultsService = typeof mlResultsService; @@ -57,3 +19,12 @@ export interface CriteriaField { fieldName: string; fieldValue: any; } + +export const mlResultsService = mlResultsServiceProvider(ml); + +export function mlResultsServiceProvider(mlApiServices: MlApiServices) { + return { + ...resultsServiceProvider(mlApiServices), + ...resultsServiceRxProvider(mlApiServices), + }; +} diff --git a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts index a21d0caaedd3..1bcbd8dbcdd6 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts @@ -17,7 +17,7 @@ import _ from 'lodash'; import { Dictionary } from '../../../../common/types/common'; import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; import { JobId } from '../../../../common/types/anomaly_detection_jobs'; -import { ml } from '../ml_api_service'; +import { MlApiServices } from '../ml_api_service'; import { ML_RESULTS_INDEX_PATTERN } from '../../../../common/constants/index_patterns'; import { CriteriaField } from './index'; @@ -46,524 +46,528 @@ export type PartitionFieldsDefinition = { [field in FieldTypes]: FieldDefinition; }; -export function getMetricData( - index: string, - entityFields: any[], - query: object | undefined, - metricFunction: string, // ES aggregation name - metricFieldName: string, - timeFieldName: string, - earliestMs: number, - latestMs: number, - interval: string -): Observable { - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the time range, entity fields, - // plus any additional supplied query. - const shouldCriteria: object[] = []; - const mustCriteria: object[] = [ - { - range: { - [timeFieldName]: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - ...(query ? [query] : []), - ]; - - entityFields.forEach((entity) => { - if (entity.fieldValue.length !== 0) { - mustCriteria.push({ - term: { - [entity.fieldName]: entity.fieldValue, - }, - }); - } else { - // Add special handling for blank entity field values, checking for either - // an empty string or the field not existing. - shouldCriteria.push({ - bool: { - must: [ - { - term: { - [entity.fieldName]: '', - }, - }, - ], - }, - }); - shouldCriteria.push({ - bool: { - must_not: [ - { - exists: { field: entity.fieldName }, - }, - ], - }, - }); - } - }); - - const body: any = { - query: { - bool: { - must: mustCriteria, - }, - }, - size: 0, - _source: { - excludes: [], - }, - aggs: { - byTime: { - date_histogram: { - field: timeFieldName, - interval, - min_doc_count: 0, - }, - }, - }, - }; - - if (shouldCriteria.length > 0) { - body.query.bool.should = shouldCriteria; - body.query.bool.minimum_should_match = shouldCriteria.length / 2; - } - - if (metricFieldName !== undefined && metricFieldName !== '') { - body.aggs.byTime.aggs = {}; - - const metricAgg: any = { - [metricFunction]: { - field: metricFieldName, - }, - }; - - if (metricFunction === 'percentiles') { - metricAgg[metricFunction].percents = [ML_MEDIAN_PERCENTS]; - } - body.aggs.byTime.aggs.metric = metricAgg; - } - - return ml.esSearch$({ index, body }).pipe( - map((resp: any) => { - const obj: MetricData = { success: true, results: {} }; - const dataByTime = resp?.aggregations?.byTime?.buckets ?? []; - dataByTime.forEach((dataForTime: any) => { - if (metricFunction === 'count') { - obj.results[dataForTime.key] = dataForTime.doc_count; - } else { - const value = dataForTime?.metric?.value; - const values = dataForTime?.metric?.values; - if (dataForTime.doc_count === 0) { - obj.results[dataForTime.key] = null; - } else if (value !== undefined) { - obj.results[dataForTime.key] = value; - } else if (values !== undefined) { - // Percentiles agg currently returns NaN rather than null when none of the docs in the - // bucket contain the field used in the aggregation - // (see elasticsearch issue https://github.com/elastic/elasticsearch/issues/29066). - // Store as null, so values can be handled in the same manner downstream as other aggs - // (min, mean, max) which return null. - const medianValues = values[ML_MEDIAN_PERCENTS]; - obj.results[dataForTime.key] = !isNaN(medianValues) ? medianValues : null; - } else { - obj.results[dataForTime.key] = null; - } - } - }); - - return obj; - }) - ); -} - export interface ModelPlotOutput extends ResultResponse { results: Record; } -export function getModelPlotOutput( - jobId: string, - detectorIndex: number, - criteriaFields: any[], - earliestMs: number, - latestMs: number, - interval: string, - aggType?: { min: any; max: any } -): Observable { - const obj: ModelPlotOutput = { - success: true, - results: {}, - }; - - // if an aggType object has been passed in, use it. - // otherwise default to min and max aggs for the upper and lower bounds - const modelAggs = - aggType === undefined - ? { max: 'max', min: 'min' } - : { - max: aggType.max, - min: aggType.min, - }; - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the job ID and time range. - const mustCriteria: object[] = [ - { - term: { job_id: jobId }, - }, - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - ]; - - // Add in term queries for each of the specified criteria. - _.each(criteriaFields, (criteria) => { - mustCriteria.push({ - term: { - [criteria.fieldName]: criteria.fieldValue, - }, - }); - }); - - // Add criteria for the detector index. Results from jobs created before 6.1 will not - // contain a detector_index field, so use a should criteria with a 'not exists' check. - const shouldCriteria = [ - { - term: { detector_index: detectorIndex }, - }, - { - bool: { - must_not: [ - { - exists: { field: 'detector_index' }, - }, - ], - }, - }, - ]; - - return ml - .esSearch$({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:model_plot', - analyze_wildcard: true, - }, - }, - { - bool: { - must: mustCriteria, - should: shouldCriteria, - minimum_should_match: 1, - }, - }, - ], - }, - }, - aggs: { - times: { - date_histogram: { - field: 'timestamp', - interval, - min_doc_count: 0, - }, - aggs: { - actual: { - avg: { - field: 'actual', - }, - }, - modelUpper: { - [modelAggs.max]: { - field: 'model_upper', - }, - }, - modelLower: { - [modelAggs.min]: { - field: 'model_lower', - }, - }, - }, - }, - }, - }, - }) - .pipe( - map((resp) => { - const aggregationsByTime = _.get(resp, ['aggregations', 'times', 'buckets'], []); - _.each(aggregationsByTime, (dataForTime: any) => { - const time = dataForTime.key; - const modelUpper: number | undefined = _.get(dataForTime, ['modelUpper', 'value']); - const modelLower: number | undefined = _.get(dataForTime, ['modelLower', 'value']); - const actual = _.get(dataForTime, ['actual', 'value']); - - obj.results[time] = { - actual, - modelUpper: - modelUpper === undefined || isFinite(modelUpper) === false ? null : modelUpper, - modelLower: - modelLower === undefined || isFinite(modelLower) === false ? null : modelLower, - }; - }); - - return obj; - }) - ); -} - export interface RecordsForCriteria extends ResultResponse { records: any[]; } -// Queries Elasticsearch to obtain the record level results matching the given criteria, -// for the specified job(s), time range, and record score threshold. -// criteriaFields parameter must be an array, with each object in the array having 'fieldName' -// 'fieldValue' properties. -// Pass an empty array or ['*'] to search over all job IDs. -export function getRecordsForCriteria( - jobIds: string[] | undefined, - criteriaFields: CriteriaField[], - threshold: any, - earliestMs: number, - latestMs: number, - maxResults: number | undefined -): Observable { - const obj: RecordsForCriteria = { success: true, records: [] }; - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the time range, record score, plus any specified job IDs. - const boolCriteria: any[] = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - { - range: { - record_score: { - gte: threshold, - }, - }, - }, - ]; - - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - if (i > 0) { - jobIdFilterStr += ' OR '; - } - jobIdFilterStr += 'job_id:'; - jobIdFilterStr += jobId; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } - - // Add in term queries for each of the specified criteria. - _.each(criteriaFields, (criteria) => { - boolCriteria.push({ - term: { - [criteria.fieldName]: criteria.fieldValue, - }, - }); - }); - - return ml - .esSearch$({ - index: ML_RESULTS_INDEX_PATTERN, - rest_total_hits_as_int: true, - size: maxResults !== undefined ? maxResults : 100, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:record', - analyze_wildcard: false, - }, - }, - { - bool: { - must: boolCriteria, - }, - }, - ], - }, - }, - sort: [{ record_score: { order: 'desc' } }], - }, - }) - .pipe( - map((resp) => { - if (resp.hits.total !== 0) { - _.each(resp.hits.hits, (hit: any) => { - obj.records.push(hit._source); - }); - } - return obj; - }) - ); -} - export interface ScheduledEventsByBucket extends ResultResponse { events: Record; } -// Obtains a list of scheduled events by job ID and time. -// Pass an empty array or ['*'] to search over all job IDs. -// Returned response contains a events property, which will only -// contains keys for jobs which have scheduled events for the specified time range. -export function getScheduledEventsByBucket( - jobIds: string[] | undefined, - earliestMs: number, - latestMs: number, - interval: string, - maxJobs: number, - maxEvents: number -): Observable { - const obj: ScheduledEventsByBucket = { - success: true, - events: {}, - }; - - // Build the criteria to use in the bool filter part of the request. - // Adds criteria for the time range plus any specified job IDs. - const boolCriteria: any[] = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', +export function resultsServiceRxProvider(mlApiServices: MlApiServices) { + return { + getMetricData( + index: string, + entityFields: any[], + query: object | undefined, + metricFunction: string, // ES aggregation name + metricFieldName: string, + timeFieldName: string, + earliestMs: number, + latestMs: number, + interval: string + ): Observable { + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the time range, entity fields, + // plus any additional supplied query. + const shouldCriteria: object[] = []; + const mustCriteria: object[] = [ + { + range: { + [timeFieldName]: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, }, - }, - }, - { - exists: { field: 'scheduled_events' }, - }, - ]; + ...(query ? [query] : []), + ]; - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - jobIdFilterStr += `${i > 0 ? ' OR ' : ''}job_id:${jobId}`; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } + entityFields.forEach((entity) => { + if (entity.fieldValue.length !== 0) { + mustCriteria.push({ + term: { + [entity.fieldName]: entity.fieldValue, + }, + }); + } else { + // Add special handling for blank entity field values, checking for either + // an empty string or the field not existing. + shouldCriteria.push({ + bool: { + must: [ + { + term: { + [entity.fieldName]: '', + }, + }, + ], + }, + }); + shouldCriteria.push({ + bool: { + must_not: [ + { + exists: { field: entity.fieldName }, + }, + ], + }, + }); + } + }); - return ml - .esSearch$({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { + const body: any = { query: { bool: { - filter: [ + must: mustCriteria, + }, + }, + size: 0, + _source: { + excludes: [], + }, + aggs: { + byTime: { + date_histogram: { + field: timeFieldName, + interval, + min_doc_count: 0, + }, + }, + }, + }; + + if (shouldCriteria.length > 0) { + body.query.bool.should = shouldCriteria; + body.query.bool.minimum_should_match = shouldCriteria.length / 2; + } + + if (metricFieldName !== undefined && metricFieldName !== '') { + body.aggs.byTime.aggs = {}; + + const metricAgg: any = { + [metricFunction]: { + field: metricFieldName, + }, + }; + + if (metricFunction === 'percentiles') { + metricAgg[metricFunction].percents = [ML_MEDIAN_PERCENTS]; + } + body.aggs.byTime.aggs.metric = metricAgg; + } + + return mlApiServices.esSearch$({ index, body }).pipe( + map((resp: any) => { + const obj: MetricData = { success: true, results: {} }; + const dataByTime = resp?.aggregations?.byTime?.buckets ?? []; + dataByTime.forEach((dataForTime: any) => { + if (metricFunction === 'count') { + obj.results[dataForTime.key] = dataForTime.doc_count; + } else { + const value = dataForTime?.metric?.value; + const values = dataForTime?.metric?.values; + if (dataForTime.doc_count === 0) { + obj.results[dataForTime.key] = null; + } else if (value !== undefined) { + obj.results[dataForTime.key] = value; + } else if (values !== undefined) { + // Percentiles agg currently returns NaN rather than null when none of the docs in the + // bucket contain the field used in the aggregation + // (see elasticsearch issue https://github.com/elastic/elasticsearch/issues/29066). + // Store as null, so values can be handled in the same manner downstream as other aggs + // (min, mean, max) which return null. + const medianValues = values[ML_MEDIAN_PERCENTS]; + obj.results[dataForTime.key] = !isNaN(medianValues) ? medianValues : null; + } else { + obj.results[dataForTime.key] = null; + } + } + }); + + return obj; + }) + ); + }, + + getModelPlotOutput( + jobId: string, + detectorIndex: number, + criteriaFields: any[], + earliestMs: number, + latestMs: number, + interval: string, + aggType?: { min: any; max: any } + ): Observable { + const obj: ModelPlotOutput = { + success: true, + results: {}, + }; + + // if an aggType object has been passed in, use it. + // otherwise default to min and max aggs for the upper and lower bounds + const modelAggs = + aggType === undefined + ? { max: 'max', min: 'min' } + : { + max: aggType.max, + min: aggType.min, + }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the job ID and time range. + const mustCriteria: object[] = [ + { + term: { job_id: jobId }, + }, + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }, + ]; + + // Add in term queries for each of the specified criteria. + _.each(criteriaFields, (criteria) => { + mustCriteria.push({ + term: { + [criteria.fieldName]: criteria.fieldValue, + }, + }); + }); + + // Add criteria for the detector index. Results from jobs created before 6.1 will not + // contain a detector_index field, so use a should criteria with a 'not exists' check. + const shouldCriteria = [ + { + term: { detector_index: detectorIndex }, + }, + { + bool: { + must_not: [ { - query_string: { - query: 'result_type:bucket', - analyze_wildcard: false, - }, - }, - { - bool: { - must: boolCriteria, - }, + exists: { field: 'detector_index' }, }, ], }, }, - aggs: { - jobs: { - terms: { - field: 'job_id', - min_doc_count: 1, - size: maxJobs, + ]; + + return mlApiServices + .esSearch$({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:model_plot', + analyze_wildcard: true, + }, + }, + { + bool: { + must: mustCriteria, + should: shouldCriteria, + minimum_should_match: 1, + }, + }, + ], + }, }, aggs: { times: { date_histogram: { field: 'timestamp', interval, - min_doc_count: 1, + min_doc_count: 0, }, aggs: { - events: { - terms: { - field: 'scheduled_events', - size: maxEvents, + actual: { + avg: { + field: 'actual', + }, + }, + modelUpper: { + [modelAggs.max]: { + field: 'model_upper', + }, + }, + modelLower: { + [modelAggs.min]: { + field: 'model_lower', }, }, }, }, }, }, + }) + .pipe( + map((resp) => { + const aggregationsByTime = _.get(resp, ['aggregations', 'times', 'buckets'], []); + _.each(aggregationsByTime, (dataForTime: any) => { + const time = dataForTime.key; + const modelUpper: number | undefined = _.get(dataForTime, ['modelUpper', 'value']); + const modelLower: number | undefined = _.get(dataForTime, ['modelLower', 'value']); + const actual = _.get(dataForTime, ['actual', 'value']); + + obj.results[time] = { + actual, + modelUpper: + modelUpper === undefined || isFinite(modelUpper) === false ? null : modelUpper, + modelLower: + modelLower === undefined || isFinite(modelLower) === false ? null : modelLower, + }; + }); + + return obj; + }) + ); + }, + + // Queries Elasticsearch to obtain the record level results matching the given criteria, + // for the specified job(s), time range, and record score threshold. + // criteriaFields parameter must be an array, with each object in the array having 'fieldName' + // 'fieldValue' properties. + // Pass an empty array or ['*'] to search over all job IDs. + getRecordsForCriteria( + jobIds: string[] | undefined, + criteriaFields: CriteriaField[], + threshold: any, + earliestMs: number, + latestMs: number, + maxResults: number | undefined + ): Observable { + const obj: RecordsForCriteria = { success: true, records: [] }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the time range, record score, plus any specified job IDs. + const boolCriteria: any[] = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, }, - }, - }) - .pipe( - map((resp) => { - const dataByJobId = _.get(resp, ['aggregations', 'jobs', 'buckets'], []); - _.each(dataByJobId, (dataForJob: any) => { - const jobId: string = dataForJob.key; - const resultsForTime: Record = {}; - const dataByTime = _.get(dataForJob, ['times', 'buckets'], []); - _.each(dataByTime, (dataForTime: any) => { - const time: string = dataForTime.key; - const events: object[] = _.get(dataForTime, ['events', 'buckets']); - resultsForTime[time] = _.map(events, 'key'); - }); - obj.events[jobId] = resultsForTime; + { + range: { + record_score: { + gte: threshold, + }, + }, + }, + ]; + + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + if (i > 0) { + jobIdFilterStr += ' OR '; + } + jobIdFilterStr += 'job_id:'; + jobIdFilterStr += jobId; }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, + }, + }); + } - return obj; - }) - ); -} + // Add in term queries for each of the specified criteria. + _.each(criteriaFields, (criteria) => { + boolCriteria.push({ + term: { + [criteria.fieldName]: criteria.fieldValue, + }, + }); + }); -export function fetchPartitionFieldsValues( - jobId: JobId, - searchTerm: Dictionary, - criteriaFields: Array<{ fieldName: string; fieldValue: any }>, - earliestMs: number, - latestMs: number -) { - return ml.results.fetchPartitionFieldsValues( - jobId, - searchTerm, - criteriaFields, - earliestMs, - latestMs - ); + return mlApiServices + .esSearch$({ + index: ML_RESULTS_INDEX_PATTERN, + rest_total_hits_as_int: true, + size: maxResults !== undefined ? maxResults : 100, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:record', + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], + }, + }, + sort: [{ record_score: { order: 'desc' } }], + }, + }) + .pipe( + map((resp) => { + if (resp.hits.total !== 0) { + _.each(resp.hits.hits, (hit: any) => { + obj.records.push(hit._source); + }); + } + return obj; + }) + ); + }, + + // Obtains a list of scheduled events by job ID and time. + // Pass an empty array or ['*'] to search over all job IDs. + // Returned response contains a events property, which will only + // contains keys for jobs which have scheduled events for the specified time range. + getScheduledEventsByBucket( + jobIds: string[] | undefined, + earliestMs: number, + latestMs: number, + interval: string, + maxJobs: number, + maxEvents: number + ): Observable { + const obj: ScheduledEventsByBucket = { + success: true, + events: {}, + }; + + // Build the criteria to use in the bool filter part of the request. + // Adds criteria for the time range plus any specified job IDs. + const boolCriteria: any[] = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }, + { + exists: { field: 'scheduled_events' }, + }, + ]; + + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + jobIdFilterStr += `${i > 0 ? ' OR ' : ''}job_id:${jobId}`; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, + }, + }); + } + + return mlApiServices + .esSearch$({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:bucket', + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], + }, + }, + aggs: { + jobs: { + terms: { + field: 'job_id', + min_doc_count: 1, + size: maxJobs, + }, + aggs: { + times: { + date_histogram: { + field: 'timestamp', + interval, + min_doc_count: 1, + }, + aggs: { + events: { + terms: { + field: 'scheduled_events', + size: maxEvents, + }, + }, + }, + }, + }, + }, + }, + }, + }) + .pipe( + map((resp) => { + const dataByJobId = _.get(resp, ['aggregations', 'jobs', 'buckets'], []); + _.each(dataByJobId, (dataForJob: any) => { + const jobId: string = dataForJob.key; + const resultsForTime: Record = {}; + const dataByTime = _.get(dataForJob, ['times', 'buckets'], []); + _.each(dataByTime, (dataForTime: any) => { + const time: string = dataForTime.key; + const events: object[] = _.get(dataForTime, ['events', 'buckets']); + resultsForTime[time] = _.map(events, 'key'); + }); + obj.events[jobId] = resultsForTime; + }); + + return obj; + }) + ); + }, + + fetchPartitionFieldsValues( + jobId: JobId, + searchTerm: Dictionary, + criteriaFields: Array<{ fieldName: string; fieldValue: any }>, + earliestMs: number, + latestMs: number + ) { + return mlApiServices.results.fetchPartitionFieldsValues( + jobId, + searchTerm, + criteriaFields, + earliestMs, + latestMs + ); + }, + }; } diff --git a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts index 4af08994432b..1b2c01ab73fc 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts @@ -4,43 +4,49 @@ * you may not use this file except in compliance with the Elastic License. */ -export function getScoresByBucket( - jobIds: string[], - earliestMs: number, - latestMs: number, - interval: string | number, - maxResults: number -): Promise; -export function getTopInfluencers(): Promise; -export function getTopInfluencerValues(): Promise; -export function getOverallBucketScores( - jobIds: any, - topN: any, - earliestMs: any, - latestMs: any, - interval?: any -): Promise; -export function getInfluencerValueMaxScoreByTime( - jobIds: string[], - influencerFieldName: string, - influencerFieldValues: string[], - earliestMs: number, - latestMs: number, - interval: string, - maxResults: number, - influencersFilterQuery: any -): Promise; -export function getRecordInfluencers(): Promise; -export function getRecordsForInfluencer(): Promise; -export function getRecordsForDetector(): Promise; -export function getRecords(): Promise; -export function getEventRateData( - index: string, - query: any, - timeFieldName: string, - earliestMs: number, - latestMs: number, - interval: string | number -): Promise; -export function getEventDistributionData(): Promise; -export function getRecordMaxScoreByTime(): Promise; +import { MlApiServices } from '../ml_api_service'; + +export function resultsServiceProvider( + mlApiServices: MlApiServices +): { + getScoresByBucket( + jobIds: string[], + earliestMs: number, + latestMs: number, + interval: string | number, + maxResults: number + ): Promise; + getTopInfluencers(): Promise; + getTopInfluencerValues(): Promise; + getOverallBucketScores( + jobIds: any, + topN: any, + earliestMs: any, + latestMs: any, + interval?: any + ): Promise; + getInfluencerValueMaxScoreByTime( + jobIds: string[], + influencerFieldName: string, + influencerFieldValues: string[], + earliestMs: number, + latestMs: number, + interval: string, + maxResults: number, + influencersFilterQuery: any + ): Promise; + getRecordInfluencers(): Promise; + getRecordsForInfluencer(): Promise; + getRecordsForDetector(): Promise; + getRecords(): Promise; + getEventRateData( + index: string, + query: any, + timeFieldName: string, + earliestMs: number, + latestMs: number, + interval: string | number + ): Promise; + getEventDistributionData(): Promise; + getRecordMaxScoreByTime(): Promise; +}; diff --git a/x-pack/plugins/ml/public/application/services/results_service/results_service.js b/x-pack/plugins/ml/public/application/services/results_service/results_service.js index 4fccc4d78937..9e3fed189b6f 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/results_service.js +++ b/x-pack/plugins/ml/public/application/services/results_service/results_service.js @@ -4,1322 +4,1331 @@ * you may not use this file except in compliance with the Elastic License. */ -// Service for carrying out Elasticsearch queries to obtain data for the -// Ml Results dashboards. import _ from 'lodash'; -// import d3 from 'd3'; import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; import { escapeForElasticsearchQuery } from '../../util/string_utils'; import { ML_RESULTS_INDEX_PATTERN } from '../../../../common/constants/index_patterns'; -import { ml } from '../ml_api_service'; +/** + * Service for carrying out Elasticsearch queries to obtain data for the Ml Results dashboards. + */ +export function resultsServiceProvider(mlApiServices) { + const SAMPLER_TOP_TERMS_SHARD_SIZE = 20000; + const ENTITY_AGGREGATION_SIZE = 10; + const AGGREGATION_MIN_DOC_COUNT = 1; + const CARDINALITY_PRECISION_THRESHOLD = 100; -// Obtains the maximum bucket anomaly scores by job ID and time. -// Pass an empty array or ['*'] to search over all job IDs. -// Returned response contains a results property, with a key for job -// which has results for the specified time range. -export function getScoresByBucket(jobIds, earliestMs, latestMs, interval, maxResults) { - return new Promise((resolve, reject) => { - const obj = { - success: true, - results: {}, - }; + return { + // Obtains the maximum bucket anomaly scores by job ID and time. + // Pass an empty array or ['*'] to search over all job IDs. + // Returned response contains a results property, with a key for job + // which has results for the specified time range. + getScoresByBucket(jobIds, earliestMs, latestMs, interval, maxResults) { + return new Promise((resolve, reject) => { + const obj = { + success: true, + results: {}, + }; - // Build the criteria to use in the bool filter part of the request. - // Adds criteria for the time range plus any specified job IDs. - const boolCriteria = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - ]; - - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - if (i > 0) { - jobIdFilterStr += ' OR '; - } - jobIdFilterStr += 'job_id:'; - jobIdFilterStr += jobId; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } - - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:bucket', - analyze_wildcard: false, - }, - }, - { - bool: { - must: boolCriteria, - }, - }, - ], - }, - }, - aggs: { - jobId: { - terms: { - field: 'job_id', - size: maxResults !== undefined ? maxResults : 5, - order: { - anomalyScore: 'desc', + // Build the criteria to use in the bool filter part of the request. + // Adds criteria for the time range plus any specified job IDs. + const boolCriteria = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', }, }, - aggs: { - anomalyScore: { - max: { - field: 'anomaly_score', + }, + ]; + + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + if (i > 0) { + jobIdFilterStr += ' OR '; + } + jobIdFilterStr += 'job_id:'; + jobIdFilterStr += jobId; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, + }, + }); + } + + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:bucket', + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], }, }, - byTime: { - date_histogram: { - field: 'timestamp', - interval: interval, - min_doc_count: 1, - extended_bounds: { - min: earliestMs, - max: latestMs, + aggs: { + jobId: { + terms: { + field: 'job_id', + size: maxResults !== undefined ? maxResults : 5, + order: { + anomalyScore: 'desc', + }, }, - }, - aggs: { - anomalyScore: { - max: { - field: 'anomaly_score', + aggs: { + anomalyScore: { + max: { + field: 'anomaly_score', + }, + }, + byTime: { + date_histogram: { + field: 'timestamp', + interval: interval, + min_doc_count: 1, + extended_bounds: { + min: earliestMs, + max: latestMs, + }, + }, + aggs: { + anomalyScore: { + max: { + field: 'anomaly_score', + }, + }, + }, }, }, }, }, }, + }) + .then((resp) => { + const dataByJobId = _.get(resp, ['aggregations', 'jobId', 'buckets'], []); + _.each(dataByJobId, (dataForJob) => { + const jobId = dataForJob.key; + + const resultsForTime = {}; + + const dataByTime = _.get(dataForJob, ['byTime', 'buckets'], []); + _.each(dataByTime, (dataForTime) => { + const value = _.get(dataForTime, ['anomalyScore', 'value']); + if (value !== undefined) { + const time = dataForTime.key; + resultsForTime[time] = _.get(dataForTime, ['anomalyScore', 'value']); + } + }); + obj.results[jobId] = resultsForTime; + }); + + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); + }); + }, + + // Obtains the top influencers, by maximum influencer score, for the specified index, time range and job ID(s). + // Pass an empty array or ['*'] to search over all job IDs. + // An optional array of influencers may be supplied, with each object in the array having 'fieldName' + // and 'fieldValue' properties, to limit data to the supplied list of influencers. + // Returned response contains an influencers property, with a key for each of the influencer field names, + // whose value is an array of objects containing influencerFieldValue, maxAnomalyScore and sumAnomalyScore keys. + getTopInfluencers( + jobIds, + earliestMs, + latestMs, + maxFieldValues = 10, + influencers = [], + influencersFilterQuery + ) { + return new Promise((resolve, reject) => { + const obj = { success: true, influencers: {} }; + + // Build the criteria to use in the bool filter part of the request. + // Adds criteria for the time range plus any specified job IDs. + const boolCriteria = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, }, - }, - }, - }) - .then((resp) => { - const dataByJobId = _.get(resp, ['aggregations', 'jobId', 'buckets'], []); - _.each(dataByJobId, (dataForJob) => { - const jobId = dataForJob.key; + { + range: { + influencer_score: { + gt: 0, + }, + }, + }, + ]; - const resultsForTime = {}; + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + if (i > 0) { + jobIdFilterStr += ' OR '; + } + jobIdFilterStr += 'job_id:'; + jobIdFilterStr += jobId; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, + }, + }); + } - const dataByTime = _.get(dataForJob, ['byTime', 'buckets'], []); - _.each(dataByTime, (dataForTime) => { - const value = _.get(dataForTime, ['anomalyScore', 'value']); - if (value !== undefined) { - const time = dataForTime.key; - resultsForTime[time] = _.get(dataForTime, ['anomalyScore', 'value']); + if (influencersFilterQuery !== undefined) { + boolCriteria.push(influencersFilterQuery); + } + + // Add a should query to filter for each of the specified influencers. + if (influencers.length > 0) { + boolCriteria.push({ + bool: { + should: influencers.map((influencer) => { + return { + bool: { + must: [ + { term: { influencer_field_name: influencer.fieldName } }, + { term: { influencer_field_value: influencer.fieldValue } }, + ], + }, + }; + }), + minimum_should_match: 1, + }, + }); + } + + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:influencer', + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], + }, + }, + aggs: { + influencerFieldNames: { + terms: { + field: 'influencer_field_name', + size: 5, + order: { + maxAnomalyScore: 'desc', + }, + }, + aggs: { + maxAnomalyScore: { + max: { + field: 'influencer_score', + }, + }, + influencerFieldValues: { + terms: { + field: 'influencer_field_value', + size: maxFieldValues, + order: { + maxAnomalyScore: 'desc', + }, + }, + aggs: { + maxAnomalyScore: { + max: { + field: 'influencer_score', + }, + }, + sumAnomalyScore: { + sum: { + field: 'influencer_score', + }, + }, + }, + }, + }, + }, + }, + }, + }) + .then((resp) => { + const fieldNameBuckets = _.get( + resp, + ['aggregations', 'influencerFieldNames', 'buckets'], + [] + ); + _.each(fieldNameBuckets, (nameBucket) => { + const fieldName = nameBucket.key; + const fieldValues = []; + + const fieldValueBuckets = _.get(nameBucket, ['influencerFieldValues', 'buckets'], []); + _.each(fieldValueBuckets, (valueBucket) => { + const fieldValueResult = { + influencerFieldValue: valueBucket.key, + maxAnomalyScore: valueBucket.maxAnomalyScore.value, + sumAnomalyScore: valueBucket.sumAnomalyScore.value, + }; + fieldValues.push(fieldValueResult); + }); + + obj.influencers[fieldName] = fieldValues; + }); + + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); + }); + }, + + // Obtains the top influencer field values, by maximum anomaly score, for a + // particular index, field name and job ID(s). + // Pass an empty array or ['*'] to search over all job IDs. + // Returned response contains a results property, which is an array of objects + // containing influencerFieldValue, maxAnomalyScore and sumAnomalyScore keys. + getTopInfluencerValues(jobIds, influencerFieldName, earliestMs, latestMs, maxResults) { + return new Promise((resolve, reject) => { + const obj = { success: true, results: [] }; + + // Build the criteria to use in the bool filter part of the request. + // Adds criteria for the time range plus any specified job IDs. + const boolCriteria = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }, + ]; + + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + if (i > 0) { + jobIdFilterStr += ' OR '; + } + jobIdFilterStr += 'job_id:'; + jobIdFilterStr += jobId; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, + }, + }); + } + + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: `result_type:influencer AND influencer_field_name: ${escapeForElasticsearchQuery( + influencerFieldName + )}`, + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], + }, + }, + aggs: { + influencerFieldValues: { + terms: { + field: 'influencer_field_value', + size: maxResults !== undefined ? maxResults : 2, + order: { + maxAnomalyScore: 'desc', + }, + }, + aggs: { + maxAnomalyScore: { + max: { + field: 'influencer_score', + }, + }, + sumAnomalyScore: { + sum: { + field: 'influencer_score', + }, + }, + }, + }, + }, + }, + }) + .then((resp) => { + const buckets = _.get(resp, ['aggregations', 'influencerFieldValues', 'buckets'], []); + _.each(buckets, (bucket) => { + const result = { + influencerFieldValue: bucket.key, + maxAnomalyScore: bucket.maxAnomalyScore.value, + sumAnomalyScore: bucket.sumAnomalyScore.value, + }; + obj.results.push(result); + }); + + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); + }); + }, + + // Obtains the overall bucket scores for the specified job ID(s). + // Pass ['*'] to search over all job IDs. + // Returned response contains a results property as an object of max score by time. + getOverallBucketScores(jobIds, topN, earliestMs, latestMs, interval) { + return new Promise((resolve, reject) => { + const obj = { success: true, results: {} }; + + mlApiServices + .overallBuckets({ + jobId: jobIds, + topN: topN, + bucketSpan: interval, + start: earliestMs, + end: latestMs, + }) + .then((resp) => { + const dataByTime = _.get(resp, ['overall_buckets'], []); + _.each(dataByTime, (dataForTime) => { + const value = _.get(dataForTime, ['overall_score']); + if (value !== undefined) { + obj.results[dataForTime.timestamp] = value; + } + }); + + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); + }); + }, + + // Obtains the maximum score by influencer_field_value and by time for the specified job ID(s) + // (pass an empty array or ['*'] to search over all job IDs), and specified influencer field + // values (pass an empty array to search over all field values). + // Returned response contains a results property with influencer field values keyed + // against max score by time. + getInfluencerValueMaxScoreByTime( + jobIds, + influencerFieldName, + influencerFieldValues, + earliestMs, + latestMs, + interval, + maxResults, + influencersFilterQuery + ) { + return new Promise((resolve, reject) => { + const obj = { success: true, results: {} }; + + // Build the criteria to use in the bool filter part of the request. + // Adds criteria for the time range plus any specified job IDs. + const boolCriteria = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }, + { + range: { + influencer_score: { + gt: 0, + }, + }, + }, + ]; + + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + if (i > 0) { + jobIdFilterStr += ' OR '; + } + jobIdFilterStr += `job_id:${jobId}`; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, + }, + }); + } + + if (influencersFilterQuery !== undefined) { + boolCriteria.push(influencersFilterQuery); + } + + if (influencerFieldValues && influencerFieldValues.length > 0) { + let influencerFilterStr = ''; + _.each(influencerFieldValues, (value, i) => { + if (i > 0) { + influencerFilterStr += ' OR '; + } + if (value.trim().length > 0) { + influencerFilterStr += `influencer_field_value:${escapeForElasticsearchQuery(value)}`; + } else { + // Wrap whitespace influencer field values in quotes for the query_string query. + influencerFilterStr += `influencer_field_value:"${value}"`; } }); - obj.results[jobId] = resultsForTime; - }); - - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); -} - -// Obtains the top influencers, by maximum influencer score, for the specified index, time range and job ID(s). -// Pass an empty array or ['*'] to search over all job IDs. -// An optional array of influencers may be supplied, with each object in the array having 'fieldName' -// and 'fieldValue' properties, to limit data to the supplied list of influencers. -// Returned response contains an influencers property, with a key for each of the influencer field names, -// whose value is an array of objects containing influencerFieldValue, maxAnomalyScore and sumAnomalyScore keys. -export function getTopInfluencers( - jobIds, - earliestMs, - latestMs, - maxFieldValues = 10, - influencers = [], - influencersFilterQuery -) { - return new Promise((resolve, reject) => { - const obj = { success: true, influencers: {} }; - - // Build the criteria to use in the bool filter part of the request. - // Adds criteria for the time range plus any specified job IDs. - const boolCriteria = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - { - range: { - influencer_score: { - gt: 0, - }, - }, - }, - ]; - - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - if (i > 0) { - jobIdFilterStr += ' OR '; - } - jobIdFilterStr += 'job_id:'; - jobIdFilterStr += jobId; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } - - if (influencersFilterQuery !== undefined) { - boolCriteria.push(influencersFilterQuery); - } - - // Add a should query to filter for each of the specified influencers. - if (influencers.length > 0) { - boolCriteria.push({ - bool: { - should: influencers.map((influencer) => { - return { - bool: { - must: [ - { term: { influencer_field_name: influencer.fieldName } }, - { term: { influencer_field_value: influencer.fieldValue } }, - ], - }, - }; - }), - minimum_should_match: 1, - }, - }); - } - - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:influencer', - analyze_wildcard: false, - }, - }, - { - bool: { - must: boolCriteria, - }, - }, - ], - }, - }, - aggs: { - influencerFieldNames: { - terms: { - field: 'influencer_field_name', - size: 5, - order: { - maxAnomalyScore: 'desc', - }, + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: influencerFilterStr, }, - aggs: { - maxAnomalyScore: { - max: { - field: 'influencer_score', - }, - }, - influencerFieldValues: { - terms: { - field: 'influencer_field_value', - size: maxFieldValues, - order: { - maxAnomalyScore: 'desc', - }, - }, - aggs: { - maxAnomalyScore: { - max: { - field: 'influencer_score', - }, - }, - sumAnomalyScore: { - sum: { - field: 'influencer_score', - }, - }, - }, - }, - }, - }, - }, - }, - }) - .then((resp) => { - const fieldNameBuckets = _.get( - resp, - ['aggregations', 'influencerFieldNames', 'buckets'], - [] - ); - _.each(fieldNameBuckets, (nameBucket) => { - const fieldName = nameBucket.key; - const fieldValues = []; - - const fieldValueBuckets = _.get(nameBucket, ['influencerFieldValues', 'buckets'], []); - _.each(fieldValueBuckets, (valueBucket) => { - const fieldValueResult = { - influencerFieldValue: valueBucket.key, - maxAnomalyScore: valueBucket.maxAnomalyScore.value, - sumAnomalyScore: valueBucket.sumAnomalyScore.value, - }; - fieldValues.push(fieldValueResult); }); - - obj.influencers[fieldName] = fieldValues; - }); - - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); -} - -// Obtains the top influencer field values, by maximum anomaly score, for a -// particular index, field name and job ID(s). -// Pass an empty array or ['*'] to search over all job IDs. -// Returned response contains a results property, which is an array of objects -// containing influencerFieldValue, maxAnomalyScore and sumAnomalyScore keys. -export function getTopInfluencerValues( - jobIds, - influencerFieldName, - earliestMs, - latestMs, - maxResults -) { - return new Promise((resolve, reject) => { - const obj = { success: true, results: [] }; - - // Build the criteria to use in the bool filter part of the request. - // Adds criteria for the time range plus any specified job IDs. - const boolCriteria = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - ]; - - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - if (i > 0) { - jobIdFilterStr += ' OR '; } - jobIdFilterStr += 'job_id:'; - jobIdFilterStr += jobId; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: `result_type:influencer AND influencer_field_name: ${escapeForElasticsearchQuery( - influencerFieldName - )}`, - analyze_wildcard: false, - }, - }, - { + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { bool: { - must: boolCriteria, + filter: [ + { + query_string: { + query: `result_type:influencer AND influencer_field_name: ${escapeForElasticsearchQuery( + influencerFieldName + )}`, + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], }, }, - ], - }, - }, - aggs: { - influencerFieldValues: { - terms: { - field: 'influencer_field_value', - size: maxResults !== undefined ? maxResults : 2, - order: { - maxAnomalyScore: 'desc', - }, - }, - aggs: { - maxAnomalyScore: { - max: { - field: 'influencer_score', - }, - }, - sumAnomalyScore: { - sum: { - field: 'influencer_score', - }, - }, - }, - }, - }, - }, - }) - .then((resp) => { - const buckets = _.get(resp, ['aggregations', 'influencerFieldValues', 'buckets'], []); - _.each(buckets, (bucket) => { - const result = { - influencerFieldValue: bucket.key, - maxAnomalyScore: bucket.maxAnomalyScore.value, - sumAnomalyScore: bucket.sumAnomalyScore.value, - }; - obj.results.push(result); - }); - - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); -} - -// Obtains the overall bucket scores for the specified job ID(s). -// Pass ['*'] to search over all job IDs. -// Returned response contains a results property as an object of max score by time. -export function getOverallBucketScores(jobIds, topN, earliestMs, latestMs, interval) { - return new Promise((resolve, reject) => { - const obj = { success: true, results: {} }; - - ml.overallBuckets({ - jobId: jobIds, - topN: topN, - bucketSpan: interval, - start: earliestMs, - end: latestMs, - }) - .then((resp) => { - const dataByTime = _.get(resp, ['overall_buckets'], []); - _.each(dataByTime, (dataForTime) => { - const value = _.get(dataForTime, ['overall_score']); - if (value !== undefined) { - obj.results[dataForTime.timestamp] = value; - } - }); - - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); -} - -// Obtains the maximum score by influencer_field_value and by time for the specified job ID(s) -// (pass an empty array or ['*'] to search over all job IDs), and specified influencer field -// values (pass an empty array to search over all field values). -// Returned response contains a results property with influencer field values keyed -// against max score by time. -export function getInfluencerValueMaxScoreByTime( - jobIds, - influencerFieldName, - influencerFieldValues, - earliestMs, - latestMs, - interval, - maxResults, - influencersFilterQuery -) { - return new Promise((resolve, reject) => { - const obj = { success: true, results: {} }; - - // Build the criteria to use in the bool filter part of the request. - // Adds criteria for the time range plus any specified job IDs. - const boolCriteria = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - { - range: { - influencer_score: { - gt: 0, - }, - }, - }, - ]; - - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - if (i > 0) { - jobIdFilterStr += ' OR '; - } - jobIdFilterStr += `job_id:${jobId}`; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } - - if (influencersFilterQuery !== undefined) { - boolCriteria.push(influencersFilterQuery); - } - - if (influencerFieldValues && influencerFieldValues.length > 0) { - let influencerFilterStr = ''; - _.each(influencerFieldValues, (value, i) => { - if (i > 0) { - influencerFilterStr += ' OR '; - } - if (value.trim().length > 0) { - influencerFilterStr += `influencer_field_value:${escapeForElasticsearchQuery(value)}`; - } else { - // Wrap whitespace influencer field values in quotes for the query_string query. - influencerFilterStr += `influencer_field_value:"${value}"`; - } - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: influencerFilterStr, - }, - }); - } - - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: `result_type:influencer AND influencer_field_name: ${escapeForElasticsearchQuery( - influencerFieldName - )}`, - analyze_wildcard: false, - }, - }, - { - bool: { - must: boolCriteria, - }, - }, - ], - }, - }, - aggs: { - influencerFieldValues: { - terms: { - field: 'influencer_field_value', - size: maxResults !== undefined ? maxResults : 10, - order: { - maxAnomalyScore: 'desc', - }, - }, - aggs: { - maxAnomalyScore: { - max: { - field: 'influencer_score', - }, - }, - byTime: { - date_histogram: { - field: 'timestamp', - interval, - min_doc_count: 1, - }, - aggs: { - maxAnomalyScore: { - max: { - field: 'influencer_score', + aggs: { + influencerFieldValues: { + terms: { + field: 'influencer_field_value', + size: maxResults !== undefined ? maxResults : 10, + order: { + maxAnomalyScore: 'desc', + }, + }, + aggs: { + maxAnomalyScore: { + max: { + field: 'influencer_score', + }, + }, + byTime: { + date_histogram: { + field: 'timestamp', + interval, + min_doc_count: 1, + }, + aggs: { + maxAnomalyScore: { + max: { + field: 'influencer_score', + }, + }, + }, }, }, }, }, }, - }, - }, - }, - }) - .then((resp) => { - const fieldValueBuckets = _.get( - resp, - ['aggregations', 'influencerFieldValues', 'buckets'], - [] - ); - _.each(fieldValueBuckets, (valueBucket) => { - const fieldValue = valueBucket.key; - const fieldValues = {}; + }) + .then((resp) => { + const fieldValueBuckets = _.get( + resp, + ['aggregations', 'influencerFieldValues', 'buckets'], + [] + ); + _.each(fieldValueBuckets, (valueBucket) => { + const fieldValue = valueBucket.key; + const fieldValues = {}; - const timeBuckets = _.get(valueBucket, ['byTime', 'buckets'], []); - _.each(timeBuckets, (timeBucket) => { - const time = timeBucket.key; - const score = timeBucket.maxAnomalyScore.value; - fieldValues[time] = score; + const timeBuckets = _.get(valueBucket, ['byTime', 'buckets'], []); + _.each(timeBuckets, (timeBucket) => { + const time = timeBucket.key; + const score = timeBucket.maxAnomalyScore.value; + fieldValues[time] = score; + }); + + obj.results[fieldValue] = fieldValues; + }); + + resolve(obj); + }) + .catch((resp) => { + reject(resp); }); - - obj.results[fieldValue] = fieldValues; - }); - - resolve(obj); - }) - .catch((resp) => { - reject(resp); }); - }); -} + }, -// Queries Elasticsearch to obtain record level results containing the influencers -// for the specified job(s), record score threshold, and time range. -// Pass an empty array or ['*'] to search over all job IDs. -// Returned response contains a records property, with each record containing -// only the fields job_id, detector_index, record_score and influencers. -export function getRecordInfluencers(jobIds, threshold, earliestMs, latestMs, maxResults) { - return new Promise((resolve, reject) => { - const obj = { success: true, records: [] }; + // Queries Elasticsearch to obtain record level results containing the influencers + // for the specified job(s), record score threshold, and time range. + // Pass an empty array or ['*'] to search over all job IDs. + // Returned response contains a records property, with each record containing + // only the fields job_id, detector_index, record_score and influencers. + getRecordInfluencers(jobIds, threshold, earliestMs, latestMs, maxResults) { + return new Promise((resolve, reject) => { + const obj = { success: true, records: [] }; - // Build the criteria to use in the bool filter part of the request. - // Adds criteria for the existence of the nested influencers field, time range, - // record score, plus any specified job IDs. - const boolCriteria = [ - { - nested: { - path: 'influencers', - query: { + // Build the criteria to use in the bool filter part of the request. + // Adds criteria for the existence of the nested influencers field, time range, + // record score, plus any specified job IDs. + const boolCriteria = [ + { + nested: { + path: 'influencers', + query: { + bool: { + must: [ + { + exists: { field: 'influencers' }, + }, + ], + }, + }, + }, + }, + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }, + { + range: { + record_score: { + gte: threshold, + }, + }, + }, + ]; + + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + if (i > 0) { + jobIdFilterStr += ' OR '; + } + jobIdFilterStr += 'job_id:'; + jobIdFilterStr += jobId; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, + }, + }); + } + + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: maxResults !== undefined ? maxResults : 100, + rest_total_hits_as_int: true, + body: { + _source: ['job_id', 'detector_index', 'influencers', 'record_score'], + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:record', + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], + }, + }, + sort: [{ record_score: { order: 'desc' } }], + }, + }) + .then((resp) => { + if (resp.hits.total !== 0) { + _.each(resp.hits.hits, (hit) => { + obj.records.push(hit._source); + }); + } + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); + }); + }, + + // Queries Elasticsearch to obtain the record level results containing the specified influencer(s), + // for the specified job(s), time range, and record score threshold. + // influencers parameter must be an array, with each object in the array having 'fieldName' + // 'fieldValue' properties. The influencer array uses 'should' for the nested bool query, + // so this returns record level results which have at least one of the influencers. + // Pass an empty array or ['*'] to search over all job IDs. + getRecordsForInfluencer( + jobIds, + influencers, + threshold, + earliestMs, + latestMs, + maxResults, + influencersFilterQuery + ) { + return new Promise((resolve, reject) => { + const obj = { success: true, records: [] }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the time range, record score, plus any specified job IDs. + const boolCriteria = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }, + { + range: { + record_score: { + gte: threshold, + }, + }, + }, + ]; + + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + if (i > 0) { + jobIdFilterStr += ' OR '; + } + jobIdFilterStr += 'job_id:'; + jobIdFilterStr += jobId; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, + }, + }); + } + + if (influencersFilterQuery !== undefined) { + boolCriteria.push(influencersFilterQuery); + } + + // Add a nested query to filter for each of the specified influencers. + if (influencers.length > 0) { + boolCriteria.push({ bool: { - must: [ - { - exists: { field: 'influencers' }, - }, - ], + should: influencers.map((influencer) => { + return { + nested: { + path: 'influencers', + query: { + bool: { + must: [ + { + match: { + 'influencers.influencer_field_name': influencer.fieldName, + }, + }, + { + match: { + 'influencers.influencer_field_values': influencer.fieldValue, + }, + }, + ], + }, + }, + }, + }; + }), + minimum_should_match: 1, }, - }, - }, - }, - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - { - range: { - record_score: { - gte: threshold, - }, - }, - }, - ]; - - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - if (i > 0) { - jobIdFilterStr += ' OR '; - } - jobIdFilterStr += 'job_id:'; - jobIdFilterStr += jobId; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } - - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: maxResults !== undefined ? maxResults : 100, - rest_total_hits_as_int: true, - body: { - _source: ['job_id', 'detector_index', 'influencers', 'record_score'], - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:record', - analyze_wildcard: false, - }, - }, - { - bool: { - must: boolCriteria, - }, - }, - ], - }, - }, - sort: [{ record_score: { order: 'desc' } }], - }, - }) - .then((resp) => { - if (resp.hits.total !== 0) { - _.each(resp.hits.hits, (hit) => { - obj.records.push(hit._source); }); } - resolve(obj); - }) - .catch((resp) => { - reject(resp); + + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: maxResults !== undefined ? maxResults : 100, + rest_total_hits_as_int: true, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:record', + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], + }, + }, + sort: [{ record_score: { order: 'desc' } }], + }, + }) + .then((resp) => { + if (resp.hits.total !== 0) { + _.each(resp.hits.hits, (hit) => { + obj.records.push(hit._source); + }); + } + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); }); - }); -} + }, -// Queries Elasticsearch to obtain the record level results containing the specified influencer(s), -// for the specified job(s), time range, and record score threshold. -// influencers parameter must be an array, with each object in the array having 'fieldName' -// 'fieldValue' properties. The influencer array uses 'should' for the nested bool query, -// so this returns record level results which have at least one of the influencers. -// Pass an empty array or ['*'] to search over all job IDs. -export function getRecordsForInfluencer( - jobIds, - influencers, - threshold, - earliestMs, - latestMs, - maxResults, - influencersFilterQuery -) { - return new Promise((resolve, reject) => { - const obj = { success: true, records: [] }; + // Queries Elasticsearch to obtain the record level results for the specified job and detector, + // time range, record score threshold, and whether to only return results containing influencers. + // An additional, optional influencer field name and value may also be provided. + getRecordsForDetector( + jobId, + detectorIndex, + checkForInfluencers, + influencerFieldName, + influencerFieldValue, + threshold, + earliestMs, + latestMs, + maxResults + ) { + return new Promise((resolve, reject) => { + const obj = { success: true, records: [] }; - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the time range, record score, plus any specified job IDs. - const boolCriteria = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the time range, record score, plus any specified job IDs. + const boolCriteria = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, }, - }, - }, - { - range: { - record_score: { - gte: threshold, + { + term: { job_id: jobId }, }, - }, - }, - ]; + { + term: { detector_index: detectorIndex }, + }, + { + range: { + record_score: { + gte: threshold, + }, + }, + }, + ]; - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - if (i > 0) { - jobIdFilterStr += ' OR '; - } - jobIdFilterStr += 'job_id:'; - jobIdFilterStr += jobId; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } - - if (influencersFilterQuery !== undefined) { - boolCriteria.push(influencersFilterQuery); - } - - // Add a nested query to filter for each of the specified influencers. - if (influencers.length > 0) { - boolCriteria.push({ - bool: { - should: influencers.map((influencer) => { - return { + // Add a nested query to filter for the specified influencer field name and value. + if (influencerFieldName && influencerFieldValue) { + boolCriteria.push({ + nested: { + path: 'influencers', + query: { + bool: { + must: [ + { + match: { + 'influencers.influencer_field_name': influencerFieldName, + }, + }, + { + match: { + 'influencers.influencer_field_values': influencerFieldValue, + }, + }, + ], + }, + }, + }, + }); + } else { + if (checkForInfluencers === true) { + boolCriteria.push({ nested: { path: 'influencers', query: { bool: { must: [ { - match: { - 'influencers.influencer_field_name': influencer.fieldName, - }, - }, - { - match: { - 'influencers.influencer_field_values': influencer.fieldValue, - }, + exists: { field: 'influencers' }, }, ], }, }, }, - }; - }), - minimum_should_match: 1, - }, - }); - } - - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: maxResults !== undefined ? maxResults : 100, - rest_total_hits_as_int: true, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:record', - analyze_wildcard: false, - }, - }, - { - bool: { - must: boolCriteria, - }, - }, - ], - }, - }, - sort: [{ record_score: { order: 'desc' } }], - }, - }) - .then((resp) => { - if (resp.hits.total !== 0) { - _.each(resp.hits.hits, (hit) => { - obj.records.push(hit._source); - }); + }); + } } - resolve(obj); - }) - .catch((resp) => { - reject(resp); + + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: maxResults !== undefined ? maxResults : 100, + rest_total_hits_as_int: true, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:record', + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], + }, + }, + sort: [{ record_score: { order: 'desc' } }], + }, + }) + .then((resp) => { + if (resp.hits.total !== 0) { + _.each(resp.hits.hits, (hit) => { + obj.records.push(hit._source); + }); + } + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); }); - }); -} + }, -// Queries Elasticsearch to obtain the record level results for the specified job and detector, -// time range, record score threshold, and whether to only return results containing influencers. -// An additional, optional influencer field name and value may also be provided. -export function getRecordsForDetector( - jobId, - detectorIndex, - checkForInfluencers, - influencerFieldName, - influencerFieldValue, - threshold, - earliestMs, - latestMs, - maxResults -) { - return new Promise((resolve, reject) => { - const obj = { success: true, records: [] }; + // Queries Elasticsearch to obtain all the record level results for the specified job(s), time range, + // and record score threshold. + // Pass an empty array or ['*'] to search over all job IDs. + // Returned response contains a records property, which is an array of the matching results. + getRecords(jobIds, threshold, earliestMs, latestMs, maxResults) { + return this.getRecordsForInfluencer(jobIds, [], threshold, earliestMs, latestMs, maxResults); + }, - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the time range, record score, plus any specified job IDs. - const boolCriteria = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', + // Queries Elasticsearch to obtain event rate data i.e. the count + // of documents over time. + // index can be a String, or String[], of index names to search. + // Extra query object can be supplied, or pass null if no additional query. + // Returned response contains a results property, which is an object + // of document counts against time (epoch millis). + getEventRateData(index, query, timeFieldName, earliestMs, latestMs, interval) { + return new Promise((resolve, reject) => { + const obj = { success: true, results: {} }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the time range, entity fields, + // plus any additional supplied query. + const mustCriteria = [ + { + range: { + [timeFieldName]: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, }, - }, - }, - { - term: { job_id: jobId }, - }, - { - term: { detector_index: detectorIndex }, - }, - { - range: { - record_score: { - gte: threshold, - }, - }, - }, - ]; + ]; - // Add a nested query to filter for the specified influencer field name and value. - if (influencerFieldName && influencerFieldValue) { - boolCriteria.push({ - nested: { - path: 'influencers', - query: { - bool: { - must: [ - { - match: { - 'influencers.influencer_field_name': influencerFieldName, + if (query) { + mustCriteria.push(query); + } + + mlApiServices + .esSearch({ + index, + rest_total_hits_as_int: true, + size: 0, + body: { + query: { + bool: { + must: mustCriteria, + }, + }, + _source: { + excludes: [], + }, + aggs: { + eventRate: { + date_histogram: { + field: timeFieldName, + interval: interval, + min_doc_count: 0, + extended_bounds: { + min: earliestMs, + max: latestMs, + }, }, }, + }, + }, + }) + .then((resp) => { + const dataByTimeBucket = _.get(resp, ['aggregations', 'eventRate', 'buckets'], []); + _.each(dataByTimeBucket, (dataForTime) => { + const time = dataForTime.key; + obj.results[time] = dataForTime.doc_count; + }); + obj.total = resp.hits.total; + + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); + }); + }, + + // Queries Elasticsearch to obtain event distribution i.e. the count + // of entities over time. + // index can be a String, or String[], of index names to search. + // Extra query object can be supplied, or pass null if no additional query. + // Returned response contains a results property, which is an object + // of document counts against time (epoch millis). + + getEventDistributionData( + index, + splitField, + filterField = null, + query, + metricFunction, // ES aggregation name + metricFieldName, + timeFieldName, + earliestMs, + latestMs, + interval + ) { + return new Promise((resolve, reject) => { + if (splitField === undefined) { + return resolve([]); + } + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the time range, entity fields, + // plus any additional supplied query. + const mustCriteria = []; + + mustCriteria.push({ + range: { + [timeFieldName]: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }); + + if (query) { + mustCriteria.push(query); + } + + if (filterField !== null) { + mustCriteria.push({ + term: { + [filterField.fieldName]: filterField.fieldValue, + }, + }); + } + + const body = { + query: { + // using function_score and random_score to get a random sample of documents. + // otherwise all documents would have the same score and the sampler aggregation + // would pick the first N documents instead of a random set. + function_score: { + query: { + bool: { + must: mustCriteria, + }, + }, + functions: [ { - match: { - 'influencers.influencer_field_values': influencerFieldValue, + random_score: { + // static seed to get same randomized results on every request + seed: 10, + field: '_seq_no', }, }, ], }, }, - }, - }); - } else { - if (checkForInfluencers === true) { - boolCriteria.push({ - nested: { - path: 'influencers', - query: { - bool: { - must: [ - { - exists: { field: 'influencers' }, - }, - ], - }, - }, - }, - }); - } - } - - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: maxResults !== undefined ? maxResults : 100, - rest_total_hits_as_int: true, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:record', - analyze_wildcard: false, - }, - }, - { - bool: { - must: boolCriteria, - }, - }, - ], - }, - }, - sort: [{ record_score: { order: 'desc' } }], - }, - }) - .then((resp) => { - if (resp.hits.total !== 0) { - _.each(resp.hits.hits, (hit) => { - obj.records.push(hit._source); - }); - } - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); -} - -// Queries Elasticsearch to obtain all the record level results for the specified job(s), time range, -// and record score threshold. -// Pass an empty array or ['*'] to search over all job IDs. -// Returned response contains a records property, which is an array of the matching results. -export function getRecords(jobIds, threshold, earliestMs, latestMs, maxResults) { - return this.getRecordsForInfluencer(jobIds, [], threshold, earliestMs, latestMs, maxResults); -} - -// Queries Elasticsearch to obtain event rate data i.e. the count -// of documents over time. -// index can be a String, or String[], of index names to search. -// Extra query object can be supplied, or pass null if no additional query. -// Returned response contains a results property, which is an object -// of document counts against time (epoch millis). -export function getEventRateData(index, query, timeFieldName, earliestMs, latestMs, interval) { - return new Promise((resolve, reject) => { - const obj = { success: true, results: {} }; - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the time range, entity fields, - // plus any additional supplied query. - const mustCriteria = [ - { - range: { - [timeFieldName]: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - ]; - - if (query) { - mustCriteria.push(query); - } - - ml.esSearch({ - index, - rest_total_hits_as_int: true, - size: 0, - body: { - query: { - bool: { - must: mustCriteria, - }, - }, - _source: { - excludes: [], - }, - aggs: { - eventRate: { - date_histogram: { - field: timeFieldName, - interval: interval, - min_doc_count: 0, - extended_bounds: { - min: earliestMs, - max: latestMs, - }, - }, - }, - }, - }, - }) - .then((resp) => { - const dataByTimeBucket = _.get(resp, ['aggregations', 'eventRate', 'buckets'], []); - _.each(dataByTimeBucket, (dataForTime) => { - const time = dataForTime.key; - obj.results[time] = dataForTime.doc_count; - }); - obj.total = resp.hits.total; - - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); -} - -// Queries Elasticsearch to obtain event distribution i.e. the count -// of entities over time. -// index can be a String, or String[], of index names to search. -// Extra query object can be supplied, or pass null if no additional query. -// Returned response contains a results property, which is an object -// of document counts against time (epoch millis). -const SAMPLER_TOP_TERMS_SHARD_SIZE = 20000; -const ENTITY_AGGREGATION_SIZE = 10; -const AGGREGATION_MIN_DOC_COUNT = 1; -const CARDINALITY_PRECISION_THRESHOLD = 100; -export function getEventDistributionData( - index, - splitField, - filterField = null, - query, - metricFunction, // ES aggregation name - metricFieldName, - timeFieldName, - earliestMs, - latestMs, - interval -) { - return new Promise((resolve, reject) => { - if (splitField === undefined) { - return resolve([]); - } - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the time range, entity fields, - // plus any additional supplied query. - const mustCriteria = []; - - mustCriteria.push({ - range: { - [timeFieldName]: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }); - - if (query) { - mustCriteria.push(query); - } - - if (filterField !== null) { - mustCriteria.push({ - term: { - [filterField.fieldName]: filterField.fieldValue, - }, - }); - } - - const body = { - query: { - // using function_score and random_score to get a random sample of documents. - // otherwise all documents would have the same score and the sampler aggregation - // would pick the first N documents instead of a random set. - function_score: { - query: { - bool: { - must: mustCriteria, - }, - }, - functions: [ - { - random_score: { - // static seed to get same randomized results on every request - seed: 10, - field: '_seq_no', - }, - }, - ], - }, - }, - size: 0, - _source: { - excludes: [], - }, - aggs: { - sample: { - sampler: { - shard_size: SAMPLER_TOP_TERMS_SHARD_SIZE, + size: 0, + _source: { + excludes: [], }, aggs: { - byTime: { - date_histogram: { - field: timeFieldName, - interval: interval, - min_doc_count: AGGREGATION_MIN_DOC_COUNT, + sample: { + sampler: { + shard_size: SAMPLER_TOP_TERMS_SHARD_SIZE, }, aggs: { - entities: { - terms: { - field: splitField.fieldName, - size: ENTITY_AGGREGATION_SIZE, + byTime: { + date_histogram: { + field: timeFieldName, + interval: interval, min_doc_count: AGGREGATION_MIN_DOC_COUNT, }, + aggs: { + entities: { + terms: { + field: splitField.fieldName, + size: ENTITY_AGGREGATION_SIZE, + min_doc_count: AGGREGATION_MIN_DOC_COUNT, + }, + }, + }, }, }, }, }, - }, - }, - }; + }; - if (metricFieldName !== undefined && metricFieldName !== '') { - body.aggs.sample.aggs.byTime.aggs.entities.aggs = {}; + if (metricFieldName !== undefined && metricFieldName !== '') { + body.aggs.sample.aggs.byTime.aggs.entities.aggs = {}; - const metricAgg = { - [metricFunction]: { - field: metricFieldName, - }, - }; + const metricAgg = { + [metricFunction]: { + field: metricFieldName, + }, + }; - if (metricFunction === 'percentiles') { - metricAgg[metricFunction].percents = [ML_MEDIAN_PERCENTS]; - } + if (metricFunction === 'percentiles') { + metricAgg[metricFunction].percents = [ML_MEDIAN_PERCENTS]; + } - if (metricFunction === 'cardinality') { - metricAgg[metricFunction].precision_threshold = CARDINALITY_PRECISION_THRESHOLD; - } - body.aggs.sample.aggs.byTime.aggs.entities.aggs.metric = metricAgg; - } - - ml.esSearch({ - index, - body, - rest_total_hits_as_int: true, - }) - .then((resp) => { - // Because of the sampling, results of metricFunctions which use sum or count - // can be significantly skewed. Taking into account totalHits we calculate a - // a factor to normalize results for these metricFunctions. - const totalHits = _.get(resp, ['hits', 'total'], 0); - const successfulShards = _.get(resp, ['_shards', 'successful'], 0); - - let normalizeFactor = 1; - if (totalHits > successfulShards * SAMPLER_TOP_TERMS_SHARD_SIZE) { - normalizeFactor = totalHits / (successfulShards * SAMPLER_TOP_TERMS_SHARD_SIZE); + if (metricFunction === 'cardinality') { + metricAgg[metricFunction].precision_threshold = CARDINALITY_PRECISION_THRESHOLD; + } + body.aggs.sample.aggs.byTime.aggs.entities.aggs.metric = metricAgg; } - const dataByTime = _.get(resp, ['aggregations', 'sample', 'byTime', 'buckets'], []); - const data = dataByTime.reduce((d, dataForTime) => { - const date = +dataForTime.key; - const entities = _.get(dataForTime, ['entities', 'buckets'], []); - entities.forEach((entity) => { - let value = metricFunction === 'count' ? entity.doc_count : entity.metric.value; + mlApiServices + .esSearch({ + index, + body, + rest_total_hits_as_int: true, + }) + .then((resp) => { + // Because of the sampling, results of metricFunctions which use sum or count + // can be significantly skewed. Taking into account totalHits we calculate a + // a factor to normalize results for these metricFunctions. + const totalHits = _.get(resp, ['hits', 'total'], 0); + const successfulShards = _.get(resp, ['_shards', 'successful'], 0); - if ( - metricFunction === 'count' || - metricFunction === 'cardinality' || - metricFunction === 'sum' - ) { - value = value * normalizeFactor; + let normalizeFactor = 1; + if (totalHits > successfulShards * SAMPLER_TOP_TERMS_SHARD_SIZE) { + normalizeFactor = totalHits / (successfulShards * SAMPLER_TOP_TERMS_SHARD_SIZE); } - d.push({ - date, - entity: entity.key, - value, - }); + const dataByTime = _.get(resp, ['aggregations', 'sample', 'byTime', 'buckets'], []); + const data = dataByTime.reduce((d, dataForTime) => { + const date = +dataForTime.key; + const entities = _.get(dataForTime, ['entities', 'buckets'], []); + entities.forEach((entity) => { + let value = metricFunction === 'count' ? entity.doc_count : entity.metric.value; + + if ( + metricFunction === 'count' || + metricFunction === 'cardinality' || + metricFunction === 'sum' + ) { + value = value * normalizeFactor; + } + + d.push({ + date, + entity: entity.key, + value, + }); + }); + return d; + }, []); + resolve(data); + }) + .catch((resp) => { + reject(resp); }); - return d; - }, []); - resolve(data); - }) - .catch((resp) => { - reject(resp); }); - }); -} + }, -// Queries Elasticsearch to obtain the max record score over time for the specified job, -// criteria, time range, and aggregation interval. -// criteriaFields parameter must be an array, with each object in the array having 'fieldName' -// 'fieldValue' properties. -export function getRecordMaxScoreByTime(jobId, criteriaFields, earliestMs, latestMs, interval) { - return new Promise((resolve, reject) => { - const obj = { - success: true, - results: {}, - }; + // Queries Elasticsearch to obtain the max record score over time for the specified job, + // criteria, time range, and aggregation interval. + // criteriaFields parameter must be an array, with each object in the array having 'fieldName' + // 'fieldValue' properties. + getRecordMaxScoreByTime(jobId, criteriaFields, earliestMs, latestMs, interval) { + return new Promise((resolve, reject) => { + const obj = { + success: true, + results: {}, + }; - // Build the criteria to use in the bool filter part of the request. - const mustCriteria = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - { term: { job_id: jobId } }, - ]; - - _.each(criteriaFields, (criteria) => { - mustCriteria.push({ - term: { - [criteria.fieldName]: criteria.fieldValue, - }, - }); - }); - - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:record', - analyze_wildcard: true, - }, - }, - { - bool: { - must: mustCriteria, - }, - }, - ], - }, - }, - aggs: { - times: { - date_histogram: { - field: 'timestamp', - interval: interval, - min_doc_count: 1, - }, - aggs: { - recordScore: { - max: { - field: 'record_score', - }, + // Build the criteria to use in the bool filter part of the request. + const mustCriteria = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', }, }, }, - }, - }, - }) - .then((resp) => { - const aggregationsByTime = _.get(resp, ['aggregations', 'times', 'buckets'], []); - _.each(aggregationsByTime, (dataForTime) => { - const time = dataForTime.key; - obj.results[time] = { - score: _.get(dataForTime, ['recordScore', 'value']), - }; + { term: { job_id: jobId } }, + ]; + + _.each(criteriaFields, (criteria) => { + mustCriteria.push({ + term: { + [criteria.fieldName]: criteria.fieldValue, + }, + }); }); - resolve(obj); - }) - .catch((resp) => { - reject(resp); + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:record', + analyze_wildcard: true, + }, + }, + { + bool: { + must: mustCriteria, + }, + }, + ], + }, + }, + aggs: { + times: { + date_histogram: { + field: 'timestamp', + interval: interval, + min_doc_count: 1, + }, + aggs: { + recordScore: { + max: { + field: 'record_score', + }, + }, + }, + }, + }, + }, + }) + .then((resp) => { + const aggregationsByTime = _.get(resp, ['aggregations', 'times', 'buckets'], []); + _.each(aggregationsByTime, (dataForTime) => { + const time = dataForTime.key; + obj.results[time] = { + score: _.get(dataForTime, ['recordScore', 'value']), + }; + }); + + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); }); - }); + }, + }; } diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx index b53b08e5f614..b4b25db452bd 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx @@ -7,6 +7,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { CoreStart } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; import { Subject } from 'rxjs'; import { Embeddable, @@ -25,12 +26,19 @@ import { RefreshInterval, TimeRange, } from '../../../../../../src/plugins/data/common'; +import { SwimlaneType } from '../../application/explorer/explorer_constants'; export const ANOMALY_SWIMLANE_EMBEDDABLE_TYPE = 'ml_anomaly_swimlane'; +export const getDefaultPanelTitle = (jobIds: JobId[]) => + i18n.translate('xpack.ml.swimlaneEmbeddable.title', { + defaultMessage: 'ML anomaly swimlane for {jobIds}', + values: { jobIds: jobIds.join(', ') }, + }); + export interface AnomalySwimlaneEmbeddableCustomInput { jobIds: JobId[]; - swimlaneType: string; + swimlaneType: SwimlaneType; viewBy?: string; limit?: number; @@ -43,9 +51,12 @@ export interface AnomalySwimlaneEmbeddableCustomInput { export type AnomalySwimlaneEmbeddableInput = EmbeddableInput & AnomalySwimlaneEmbeddableCustomInput; -export interface AnomalySwimlaneEmbeddableOutput extends EmbeddableOutput { +export type AnomalySwimlaneEmbeddableOutput = EmbeddableOutput & + AnomalySwimlaneEmbeddableCustomOutput; + +export interface AnomalySwimlaneEmbeddableCustomOutput { jobIds: JobId[]; - swimlaneType: string; + swimlaneType: SwimlaneType; viewBy?: string; limit?: number; } diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts index e86d738d8b80..09091b21e49b 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts @@ -23,8 +23,9 @@ import { MlStartDependencies } from '../../plugin'; import { HttpService } from '../../application/services/http_service'; import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; import { ExplorerService } from '../../application/services/explorer_service'; -import { mlResultsService } from '../../application/services/results_service'; +import { mlResultsServiceProvider } from '../../application/services/results_service'; import { resolveAnomalySwimlaneUserInput } from './anomaly_swimlane_setup_flyout'; +import { mlApiServicesProvider } from '../../application/services/ml_api_service'; export class AnomalySwimlaneEmbeddableFactory implements EmbeddableFactoryDefinition { @@ -64,8 +65,7 @@ export class AnomalySwimlaneEmbeddableFactory const explorerService = new ExplorerService( pluginsStart.data.query.timefilter.timefilter, coreStart.uiSettings, - // TODO mlResultsService to use DI - mlResultsService + mlResultsServiceProvider(mlApiServicesProvider(httpService)) ); return [coreStart, pluginsStart, { anomalyDetectorService, explorerService }]; diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx index 00d47c0d897c..4c93b9ef2323 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx @@ -20,7 +20,7 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { SWIMLANE_TYPE } from '../../application/explorer/explorer_constants'; +import { SWIMLANE_TYPE, SwimlaneType } from '../../application/explorer/explorer_constants'; import { AnomalySwimlaneEmbeddableInput } from './anomaly_swimlane_embeddable'; export interface AnomalySwimlaneInitializerProps { @@ -31,7 +31,7 @@ export interface AnomalySwimlaneInitializerProps { >; onCreate: (swimlaneProps: { panelTitle: string; - swimlaneType: string; + swimlaneType: SwimlaneType; viewBy?: string; limit?: number; }) => void; @@ -51,8 +51,8 @@ export const AnomalySwimlaneInitializer: FC = ( initialInput, }) => { const [panelTitle, setPanelTitle] = useState(defaultTitle); - const [swimlaneType, setSwimlaneType] = useState( - (initialInput?.swimlaneType ?? SWIMLANE_TYPE.OVERALL) as SWIMLANE_TYPE + const [swimlaneType, setSwimlaneType] = useState( + initialInput?.swimlaneType ?? SWIMLANE_TYPE.OVERALL ); const [viewBySwimlaneFieldName, setViewBySwimlaneFieldName] = useState(initialInput?.viewBy); const [limit, setLimit] = useState(initialInput?.limit ?? 5); @@ -135,7 +135,7 @@ export const AnomalySwimlaneInitializer: FC = ( })} options={swimlaneTypeOptions} idSelected={swimlaneType} - onChange={(id) => setSwimlaneType(id as SWIMLANE_TYPE)} + onChange={(id) => setSwimlaneType(id as SwimlaneType)} /> diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx index 83f9833109bf..54f50d2d3da3 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx @@ -6,7 +6,6 @@ import React from 'react'; import { IUiSettingsClient, OverlayStart } from 'kibana/public'; -import { i18n } from '@kbn/i18n'; import moment from 'moment'; import { VIEW_BY_JOB_LABEL } from '../../application/explorer/explorer_constants'; import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; @@ -14,7 +13,10 @@ import { AnomalySwimlaneInitializer } from './anomaly_swimlane_initializer'; import { JobSelectorFlyout } from '../../application/components/job_selector/job_selector_flyout'; import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; import { getInitialGroupsMap } from '../../application/components/job_selector/job_selector'; -import { AnomalySwimlaneEmbeddableInput } from './anomaly_swimlane_embeddable'; +import { + AnomalySwimlaneEmbeddableInput, + getDefaultPanelTitle, +} from './anomaly_swimlane_embeddable'; export async function resolveAnomalySwimlaneUserInput( { @@ -52,12 +54,7 @@ export async function resolveAnomalySwimlaneUserInput( reject(); }} onSelectionConfirmed={async ({ jobIds, groups }) => { - const title = - input?.title ?? - i18n.translate('xpack.ml.swimlaneEmbeddable.title', { - defaultMessage: 'ML anomaly swimlane for {jobIds}', - values: { jobIds: jobIds.join(', ') }, - }); + const title = input?.title ?? getDefaultPanelTitle(jobIds); const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise(); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.tsx index e5d8584683c5..0bba9b59f7bf 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useState } from 'react'; +import React, { FC, useCallback, useState } from 'react'; import { EuiCallOut, EuiFlexGroup, @@ -28,6 +28,7 @@ import { } from './anomaly_swimlane_embeddable'; import { MlTooltipComponent } from '../../application/components/chart_tooltip'; import { useSwimlaneInputResolver } from './swimlane_input_resolver'; +import { SwimlaneType } from '../../application/explorer/explorer_constants'; const RESIZE_THROTTLE_TIME_MS = 500; @@ -54,10 +55,13 @@ export const ExplorerSwimlaneContainer: FC = ({ chartWidth ); - const onResize = throttle((e: { width: number; height: number }) => { - const labelWidth = 200; - setChartWidth(e.width - labelWidth); - }, RESIZE_THROTTLE_TIME_MS); + const onResize = useCallback( + throttle((e: { width: number; height: number }) => { + const labelWidth = 200; + setChartWidth(e.width - labelWidth); + }, RESIZE_THROTTLE_TIME_MS), + [] + ); if (error) { return ( @@ -91,14 +95,14 @@ export const ExplorerSwimlaneContainer: FC = ({ {chartWidth > 0 && swimlaneData && swimlaneType ? ( - + {(tooltipService) => ( )} diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts index e704582d5d61..3829bbce5e5c 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts @@ -24,7 +24,7 @@ import { AnomalySwimlaneServices, } from './anomaly_swimlane_embeddable'; import { MlStartDependencies } from '../../plugin'; -import { SWIMLANE_TYPE } from '../../application/explorer/explorer_constants'; +import { SWIMLANE_TYPE, SwimlaneType } from '../../application/explorer/explorer_constants'; import { Filter } from '../../../../../../src/plugins/data/common/es_query/filters'; import { Query } from '../../../../../../src/plugins/data/common/query'; import { esKuery, UI_SETTINGS } from '../../../../../../src/plugins/data/public'; @@ -55,7 +55,7 @@ export function useSwimlaneInputResolver( const [{ uiSettings }, , { explorerService, anomalyDetectorService }] = services; const [swimlaneData, setSwimlaneData] = useState(); - const [swimlaneType, setSwimlaneType] = useState(); + const [swimlaneType, setSwimlaneType] = useState(); const [error, setError] = useState(); const chartWidth$ = useMemo(() => new Subject(), []); diff --git a/x-pack/plugins/ml/public/index.ts b/x-pack/plugins/ml/public/index.ts index a9ffb1a5bf57..5a956651c86d 100755 --- a/x-pack/plugins/ml/public/index.ts +++ b/x-pack/plugins/ml/public/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializer } from 'kibana/public'; +import { PluginInitializer, PluginInitializerContext } from 'kibana/public'; import './index.scss'; import { MlPlugin, @@ -19,7 +19,7 @@ export const plugin: PluginInitializer< MlPluginStart, MlSetupDependencies, MlStartDependencies -> = () => new MlPlugin(); +> = (initializerContext: PluginInitializerContext) => new MlPlugin(initializerContext); export { MlPluginSetup, MlPluginStart }; export * from './shared'; diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index fe9f602bc363..be2ebb3caa41 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -5,7 +5,13 @@ */ import { i18n } from '@kbn/i18n'; -import { Plugin, CoreStart, CoreSetup, AppMountParameters } from 'kibana/public'; +import { + Plugin, + CoreStart, + CoreSetup, + AppMountParameters, + PluginInitializerContext, +} from 'kibana/public'; import { ManagementSetup } from 'src/plugins/management/public'; import { SharePluginStart } from 'src/plugins/share/public'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; @@ -38,9 +44,13 @@ export interface MlSetupDependencies { home: HomePublicPluginSetup; embeddable: EmbeddableSetup; uiActions: UiActionsSetup; + kibanaVersion: string; + share: SharePluginStart; } export class MlPlugin implements Plugin { + constructor(private initializerContext: PluginInitializerContext) {} + setup(core: CoreSetup, pluginsSetup: MlSetupDependencies) { core.application.register({ id: PLUGIN_ID, @@ -53,6 +63,7 @@ export class MlPlugin implements Plugin { category: DEFAULT_APP_CATEGORIES.kibana, mount: async (params: AppMountParameters) => { const [coreStart, pluginsStart] = await core.getStartServices(); + const kibanaVersion = this.initializerContext.env.packageInfo.version; const { renderApp } = await import('./application/app'); return renderApp( coreStart, @@ -67,6 +78,7 @@ export class MlPlugin implements Plugin { home: pluginsSetup.home, embeddable: pluginsSetup.embeddable, uiActions: pluginsSetup.uiActions, + kibanaVersion, }, { element: params.element, diff --git a/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts b/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts index 56d00a4e1139..c23abead458f 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts @@ -61,6 +61,7 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { await esArchiver.loadIfNeeded('ml/farequote'); await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp'); + await ml.testResources.createMLTestDashboardIfNeeded(); await ml.testResources.setKibanaTimeZoneToUTC(); await ml.securityUI.loginAsMlPowerUser(); @@ -125,6 +126,12 @@ export default function ({ getService }: FtrProviderContext) { it('anomalies table is not empty', async () => { await ml.anomaliesTable.assertTableNotEmpty(); }); + + // should be the last step because it navigates away from the Anomaly Explorer page + it('should allow to attach anomaly swimlane embeddable to the dashboard', async () => { + await ml.anomalyExplorer.openAddToDashboardControl(); + await ml.anomalyExplorer.addAndEditSwimlaneInDashboard('ML Test'); + }); }); } }); diff --git a/x-pack/test/functional/apps/ml/index.ts b/x-pack/test/functional/apps/ml/index.ts index 92e836e0c4c1..2d8aac3b8ddd 100644 --- a/x-pack/test/functional/apps/ml/index.ts +++ b/x-pack/test/functional/apps/ml/index.ts @@ -22,6 +22,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await ml.securityCommon.cleanMlRoles(); await ml.testResources.deleteSavedSearches(); + await ml.testResources.deleteDashboards(); await ml.testResources.deleteIndexPatternByTitle('ft_farequote'); await ml.testResources.deleteIndexPatternByTitle('ft_ecommerce'); diff --git a/x-pack/test/functional/services/ml/anomaly_explorer.ts b/x-pack/test/functional/services/ml/anomaly_explorer.ts index 6ec72c76bb9c..7c479a423467 100644 --- a/x-pack/test/functional/services/ml/anomaly_explorer.ts +++ b/x-pack/test/functional/services/ml/anomaly_explorer.ts @@ -66,5 +66,38 @@ export function MachineLearningAnomalyExplorerProvider({ getService }: FtrProvid async assertSwimlaneViewByExists() { await testSubjects.existOrFail('mlAnomalyExplorerSwimlaneViewBy'); }, + + async openAddToDashboardControl() { + await testSubjects.click('mlAnomalyTimelinePanelMenu'); + await testSubjects.click('mlAnomalyTimelinePanelAddToDashboardButton'); + await testSubjects.existOrFail('mlAddToDashboardModal'); + }, + + async addAndEditSwimlaneInDashboard(dashboardTitle: string) { + await this.filterWithSearchString(dashboardTitle); + await testSubjects.isDisplayed('mlDashboardSelectionTable > checkboxSelectAll'); + await testSubjects.click('mlDashboardSelectionTable > checkboxSelectAll'); + expect(await testSubjects.isChecked('mlDashboardSelectionTable > checkboxSelectAll')).to.be( + true + ); + await testSubjects.clickWhenNotDisabled('mlAddAndEditDashboardButton'); + const embeddable = await testSubjects.find('mlAnomalySwimlaneEmbeddableWrapper'); + const swimlane = await embeddable.findByClassName('ml-swimlanes'); + expect(await swimlane.isDisplayed()).to.eql( + true, + 'Anomaly swimlane should be displayed in dashboard' + ); + }, + + async waitForDashboardsToLoad() { + await testSubjects.existOrFail('~mlDashboardSelectionTable', { timeout: 60 * 1000 }); + }, + + async filterWithSearchString(filter: string) { + await this.waitForDashboardsToLoad(); + const searchBarInput = await testSubjects.find('mlDashboardsSearchBox'); + await searchBarInput.clearValueWithKeyboard(); + await searchBarInput.type(filter); + }, }; } diff --git a/x-pack/test/functional/services/ml/test_resources.ts b/x-pack/test/functional/services/ml/test_resources.ts index 4e3d1d9d8627..9927c987bbea 100644 --- a/x-pack/test/functional/services/ml/test_resources.ts +++ b/x-pack/test/functional/services/ml/test_resources.ts @@ -5,7 +5,7 @@ */ import { ProvidedType } from '@kbn/test/types/ftr'; -import { savedSearches } from './test_resources_data'; +import { savedSearches, dashboards } from './test_resources_data'; import { COMMON_REQUEST_HEADERS } from './common'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -137,6 +137,20 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider return createResponse.id; }, + async createDashboard(title: string, body: object): Promise { + 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 { const title = savedSearch.requestBody.attributes.title; const savedSearchId = await this.getSavedSearchId(title); @@ -181,6 +195,21 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider await this.createSavedSearchIfNeeded(savedSearches.farequoteFilter); }, + async createMLTestDashboardIfNeeded() { + await this.createDashboardIfNeeded(dashboards.mlTestDashboard); + }, + + async createDashboardIfNeeded(dashboard: any) { + const title = dashboard.requestBody.attributes.title; + const dashboardId = await this.getDashboardId(title); + if (dashboardId !== undefined) { + log.debug(`Dashboard with title '${title}' already exists. Nothing to create.`); + return dashboardId; + } else { + return await this.createDashboard(title, dashboard.requestBody); + } + }, + async createSavedSearchFarequoteLuceneIfNeeded() { await this.createSavedSearchIfNeeded(savedSearches.farequoteLucene); }, @@ -285,6 +314,12 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider } }, + async deleteDashboards() { + for (const dashboard of Object.values(dashboards)) { + await this.deleteDashboardByTitle(dashboard.requestBody.attributes.title); + } + }, + async assertSavedObjectExistsByTitle(title: string, objectType: SavedObjectType) { await retry.waitForWithTimeout( `${objectType} with title '${title}' to exist`, diff --git a/x-pack/test/functional/services/ml/test_resources_data.ts b/x-pack/test/functional/services/ml/test_resources_data.ts index dd600077182f..2ab1f4de5422 100644 --- a/x-pack/test/functional/services/ml/test_resources_data.ts +++ b/x-pack/test/functional/services/ml/test_resources_data.ts @@ -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":[]}', + }, + }, + }, + }, +}; diff --git a/yarn.lock b/yarn.lock index 2b2eab444d04..c9bbbd8a999f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4803,6 +4803,11 @@ resolved "https://registry.yarnpkg.com/@types/delete-empty/-/delete-empty-2.0.0.tgz#1647ae9e68f708a6ba778531af667ec55bc61964" integrity sha512-sq+kwx8zA9BSugT9N+Jr8/uWjbHMZ+N/meJEzRyT3gmLq/WMtx/iSIpvdpmBUi/cvXl6Kzpvve8G2ESkabFwmg== +"@types/dragselect@^1.13.1": + version "1.13.1" + resolved "https://registry.yarnpkg.com/@types/dragselect/-/dragselect-1.13.1.tgz#f19b7b41063a7c9d5963194c83c3c364e84d46ee" + integrity sha512-3m0fvSM0cSs0DXvprytV/ZY92hNX3jJuEb/vkdqU+4QMzV2jxYKgBFTuaT2fflqbmfzUqHHIkGP55WIuigElQw== + "@types/elasticsearch@^5.0.33": version "5.0.33" resolved "https://registry.yarnpkg.com/@types/elasticsearch/-/elasticsearch-5.0.33.tgz#b0fd37dc674f498223b6d68c313bdfd71f4d812b"