diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 9d5125532e5b8..cf645404860f5 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -20,7 +20,7 @@ import { MlRouter } from './routing'; import { mlApiServicesProvider } from './services/ml_api_service'; import { HttpService } from './services/http_service'; -type MlDependencies = MlSetupDependencies & MlStartDependencies; +export type MlDependencies = Omit & MlStartDependencies; interface AppProps { coreStart: CoreStart; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx index 803281bcd0ce9..62a74ed142ccf 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx @@ -193,7 +193,6 @@ export const JobSelectorFlyout: FC = ({ ref={flyoutEl} onClose={onFlyoutClose} aria-labelledby="jobSelectorFlyout" - size="l" data-test-subj="mlFlyoutJobSelector" > diff --git a/x-pack/plugins/ml/public/application/components/job_selector/use_job_selection.ts b/x-pack/plugins/ml/public/application/components/job_selector/use_job_selection.ts index 74c238a0895ca..0717348d1db22 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/use_job_selection.ts +++ b/x-pack/plugins/ml/public/application/components/job_selector/use_job_selection.ts @@ -5,16 +5,16 @@ */ import { difference } from 'lodash'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; -import { getToastNotifications } from '../../util/dependency_cache'; import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; import { useUrlState } from '../../util/url_state'; import { getTimeRangeFromSelection } from './job_select_service_utils'; +import { useNotifications } from '../../contexts/kibana'; // check that the ids read from the url exist by comparing them to the // jobs loaded via mlJobsService. @@ -25,49 +25,53 @@ function getInvalidJobIds(jobs: MlJobWithTimeRange[], ids: string[]) { }); } -function warnAboutInvalidJobIds(invalidIds: string[]) { - if (invalidIds.length > 0) { - const toastNotifications = getToastNotifications(); - toastNotifications.addWarning( - i18n.translate('xpack.ml.jobSelect.requestedJobsDoesNotExistWarningMessage', { - defaultMessage: `Requested -{invalidIdsLength, plural, one {job {invalidIds} does not exist} other {jobs {invalidIds} do not exist}}`, - values: { - invalidIdsLength: invalidIds.length, - invalidIds: invalidIds.join(), - }, - }) - ); - } -} - export interface JobSelection { jobIds: string[]; selectedGroups: string[]; } -export const useJobSelection = (jobs: MlJobWithTimeRange[], dateFormatTz: string) => { +export const useJobSelection = (jobs: MlJobWithTimeRange[]) => { const [globalState, setGlobalState] = useUrlState('_g'); + const { toasts: toastNotifications } = useNotifications(); - const jobSelection: JobSelection = { jobIds: [], selectedGroups: [] }; + const tmpIds = useMemo(() => { + const ids = globalState?.ml?.jobIds || []; + return (typeof ids === 'string' ? [ids] : ids).map((id: string) => String(id)); + }, [globalState?.ml?.jobIds]); - const ids = globalState?.ml?.jobIds || []; - const tmpIds = (typeof ids === 'string' ? [ids] : ids).map((id: string) => String(id)); - const invalidIds = getInvalidJobIds(jobs, tmpIds); - const validIds = difference(tmpIds, invalidIds); - validIds.sort(); + const invalidIds = useMemo(() => { + return getInvalidJobIds(jobs, tmpIds); + }, [tmpIds]); - jobSelection.jobIds = validIds; - jobSelection.selectedGroups = globalState?.ml?.groups ?? []; + const validIds = useMemo(() => { + const res = difference(tmpIds, invalidIds); + res.sort(); + return res; + }, [tmpIds, invalidIds]); + + const jobSelection: JobSelection = useMemo(() => { + const selectedGroups = globalState?.ml?.groups ?? []; + return { jobIds: validIds, selectedGroups }; + }, [validIds, globalState?.ml?.groups]); useEffect(() => { - warnAboutInvalidJobIds(invalidIds); + if (invalidIds.length > 0) { + toastNotifications.addWarning( + i18n.translate('xpack.ml.jobSelect.requestedJobsDoesNotExistWarningMessage', { + defaultMessage: `Requested +{invalidIdsLength, plural, one {job {invalidIds} does not exist} other {jobs {invalidIds} do not exist}}`, + values: { + invalidIdsLength: invalidIds.length, + invalidIds: invalidIds.join(), + }, + }) + ); + } }, [invalidIds]); useEffect(() => { // if there are no valid ids, warn and then select the first job if (validIds.length === 0 && jobs.length > 0) { - const toastNotifications = getToastNotifications(); toastNotifications.addWarning( i18n.translate('xpack.ml.jobSelect.noJobsSelectedWarningMessage', { defaultMessage: 'No jobs selected, auto selecting first job', diff --git a/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap b/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap index 16b5ecc8a4600..4adaac1319d53 100644 --- a/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap +++ b/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ExplorerSwimlane Overall swimlane 1`] = `"
Overall
2017-02-07T00:00:00Z2017-02-07T00:30:00Z2017-02-07T01:00:00Z2017-02-07T01:30:00Z2017-02-07T02:00:00Z2017-02-07T02:30:00Z2017-02-07T03:00:00Z2017-02-07T03:30:00Z2017-02-07T04:00:00Z2017-02-07T04:30:00Z2017-02-07T05:00:00Z2017-02-07T05:30:00Z2017-02-07T06:00:00Z2017-02-07T06:30:00Z2017-02-07T07:00:00Z2017-02-07T07:30:00Z2017-02-07T08:00:00Z2017-02-07T08:30:00Z2017-02-07T09:00:00Z2017-02-07T09:30:00Z2017-02-07T10:00:00Z2017-02-07T10:30:00Z2017-02-07T11:00:00Z2017-02-07T11:30:00Z2017-02-07T12:00:00Z2017-02-07T12:30:00Z2017-02-07T13:00:00Z2017-02-07T13:30:00Z2017-02-07T14:00:00Z2017-02-07T14:30:00Z2017-02-07T15:00:00Z2017-02-07T15:30:00Z2017-02-07T16:00:00Z
"`; +exports[`ExplorerSwimlane Overall swimlane 1`] = `"
Overall
2017-02-07T00:00:00Z2017-02-07T00:30:00Z2017-02-07T01:00:00Z2017-02-07T01:30:00Z2017-02-07T02:00:00Z2017-02-07T02:30:00Z2017-02-07T03:00:00Z2017-02-07T03:30:00Z2017-02-07T04:00:00Z2017-02-07T04:30:00Z2017-02-07T05:00:00Z2017-02-07T05:30:00Z2017-02-07T06:00:00Z2017-02-07T06:30:00Z2017-02-07T07:00:00Z2017-02-07T07:30:00Z2017-02-07T08:00:00Z2017-02-07T08:30:00Z2017-02-07T09:00:00Z2017-02-07T09:30:00Z2017-02-07T10:00:00Z2017-02-07T10:30:00Z2017-02-07T11:00:00Z2017-02-07T11:30:00Z2017-02-07T12:00:00Z2017-02-07T12:30:00Z2017-02-07T13:00:00Z2017-02-07T13:30:00Z2017-02-07T14:00:00Z2017-02-07T14:30:00Z2017-02-07T15:00:00Z2017-02-07T15:30:00Z2017-02-07T16:00:00Z
"`; diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx index e00e2e1e1e2eb..45dada84de20a 100644 --- a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useCallback, useMemo, useRef, useState } from 'react'; +import React, { FC, useMemo, useState } from 'react'; import { isEqual } from 'lodash'; -import DragSelect from 'dragselect'; import { EuiPanel, EuiPopover, @@ -22,21 +21,17 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { DRAG_SELECT_ACTION, SWIMLANE_TYPE, VIEW_BY_JOB_LABEL } from './explorer_constants'; +import { OVERALL_LABEL, SWIMLANE_TYPE, 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 { - ALLOW_CELL_RANGE_SELECTION, - dragSelect$, - explorerService, -} from './explorer_dashboard_service'; +import { 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 { SwimlaneContainer } from './swimlane_container'; -import { OverallSwimlaneData, ViewBySwimLaneData } from './explorer_utils'; +import { AppStateSelectedCells, OverallSwimlaneData, ViewBySwimLaneData } from './explorer_utils'; import { NoOverallData } from './components/no_overall_data'; function mapSwimlaneOptionsToEuiOptions(options: string[]) { @@ -63,10 +58,6 @@ export const AnomalyTimeline: FC = React.memo( 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(() => { @@ -78,48 +69,6 @@ export const AnomalyTimeline: FC = React.memo( }); }, [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, @@ -138,42 +87,6 @@ export const AnomalyTimeline: FC = React.memo( loading, } = 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); - } - }, - [setSelectedCells] - ); - const menuItems = useMemo(() => { const items = []; if (canEditDashboards) { @@ -193,6 +106,19 @@ export const AnomalyTimeline: FC = React.memo( return items; }, [canEditDashboards]); + // If selecting a cell in the 'view by' swimlane, indicate the corresponding time in the Overall swimlane. + const overallCellSelection: AppStateSelectedCells | undefined = useMemo(() => { + if (!selectedCells) return; + + if (selectedCells.type === SWIMLANE_TYPE.OVERALL) return selectedCells; + + return { + type: SWIMLANE_TYPE.OVERALL, + lanes: [OVERALL_LABEL], + times: selectedCells.times, + }; + }, [selectedCells]); + return ( <> @@ -284,86 +210,68 @@ export const AnomalyTimeline: FC = React.memo( -
+ filterActive={filterActive} + maskAll={maskAll} + timeBuckets={timeBuckets} + swimlaneData={overallSwimlaneData as OverallSwimlaneData} + swimlaneType={SWIMLANE_TYPE.OVERALL} + selection={overallCellSelection} + onCellsSelection={setSelectedCells} + onResize={explorerService.setSwimlaneContainerWidth} + isLoading={loading} + noDataWarning={} + /> + + + + {viewBySwimlaneOptions.length > 0 && ( explorerService.setSwimlaneContainerWidth(width)} - isLoading={loading} - noDataWarning={} + onCellsSelection={setSelectedCells} + onResize={explorerService.setSwimlaneContainerWidth} + fromPage={viewByFromPage} + perPage={viewByPerPage} + swimlaneLimit={swimlaneLimit} + onPaginationChange={({ perPage: perPageUpdate, fromPage: fromPageUpdate }) => { + if (perPageUpdate) { + explorerService.setViewByPerPage(perPageUpdate); + } + if (fromPageUpdate) { + explorerService.setViewByFromPage(fromPageUpdate); + } + }} + isLoading={loading || viewBySwimlaneDataLoading} + noDataWarning={ + typeof viewBySwimlaneFieldName === 'string' ? ( + viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL ? ( + + ) : ( + + ) + ) : null + } /> -
- - - - {viewBySwimlaneOptions.length > 0 && ( - <> - <> -
- explorerService.setSwimlaneContainerWidth(width)} - fromPage={viewByFromPage} - perPage={viewByPerPage} - swimlaneLimit={swimlaneLimit} - onPaginationChange={({ perPage: perPageUpdate, fromPage: fromPageUpdate }) => { - if (perPageUpdate) { - explorerService.setViewByPerPage(perPageUpdate); - } - if (fromPageUpdate) { - explorerService.setViewByFromPage(fromPageUpdate); - } - }} - isLoading={loading || viewBySwimlaneDataLoading} - noDataWarning={ - typeof viewBySwimlaneFieldName === 'string' ? ( - viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL ? ( - - ) : ( - - ) - ) : null - } - /> -
- - )}
{isAddDashboardsActive && selectedJobs && ( 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 21e13cb029d69..7440bf3213413 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts @@ -62,6 +62,11 @@ export const MAX_INFLUENCER_FIELD_NAMES = 50; export const VIEW_BY_JOB_LABEL = i18n.translate('xpack.ml.explorer.jobIdLabel', { defaultMessage: 'job ID', }); + +export const OVERALL_LABEL = i18n.translate('xpack.ml.explorer.overallLabel', { + defaultMessage: 'Overall', +}); + /** * Hard limitation for the size of terms * aggregations on influencers values. diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts b/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts index 1429bf0858361..4d697bcda1a06 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts @@ -18,17 +18,12 @@ import { DeepPartial } from '../../../common/types/common'; import { jobSelectionActionCreator } from './actions'; import { ExplorerChartsData } from './explorer_charts/explorer_charts_container_service'; -import { DRAG_SELECT_ACTION, EXPLORER_ACTION } from './explorer_constants'; +import { EXPLORER_ACTION } from './explorer_constants'; import { AppStateSelectedCells, TimeRangeBounds } from './explorer_utils'; import { explorerReducer, getExplorerDefaultState, ExplorerState } from './reducers'; export const ALLOW_CELL_RANGE_SELECTION = true; -export const dragSelect$ = new Subject<{ - action: typeof DRAG_SELECT_ACTION[keyof typeof DRAG_SELECT_ACTION]; - elements?: any[]; -}>(); - type ExplorerAction = Action | Observable; export const explorerAction$ = new Subject(); @@ -54,7 +49,7 @@ const explorerState$: Observable = explorerFilteredAction$.pipe( shareReplay(1) ); -interface ExplorerAppState { +export interface ExplorerAppState { mlExplorerSwimlane: { selectedType?: string; selectedLanes?: string[]; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.test.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.test.tsx index df450a33a52df..f7ae5f232999e 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.test.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.test.tsx @@ -10,7 +10,6 @@ import moment from 'moment-timezone'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; -import { dragSelect$ } from './explorer_dashboard_service'; import { ExplorerSwimlane } from './explorer_swimlane'; import { TimeBuckets as TimeBucketsClass } from '../util/time_buckets'; import { ChartTooltipService } from '../components/chart_tooltip'; @@ -27,13 +26,15 @@ jest.mock('d3', () => { }; }); -jest.mock('./explorer_dashboard_service', () => ({ - dragSelect$: { - subscribe: jest.fn(() => ({ - unsubscribe: jest.fn(), - })), - }, -})); +jest.mock('@elastic/eui', () => { + return { + htmlIdGenerator: jest.fn(() => { + return jest.fn(() => { + return 'test-gen-id'; + }); + }), + }; +}); function getExplorerSwimlaneMocks() { const swimlaneData = ({ laneLabels: [] } as unknown) as OverallSwimlaneData; @@ -52,6 +53,7 @@ function getExplorerSwimlaneMocks() { timeBuckets, swimlaneData, tooltipService, + parentRef: {} as React.RefObject, }; } @@ -74,50 +76,42 @@ describe('ExplorerSwimlane', () => { test('Minimal initialization', () => { const mocks = getExplorerSwimlaneMocks(); - const swimlaneRenderDoneListener = jest.fn(); const wrapper = mountWithIntl( ); expect(wrapper.html()).toBe( - `
` + - `
` + '
' ); // test calls to mock functions // @ts-ignore - expect(dragSelect$.subscribe.mock.calls.length).toBeGreaterThanOrEqual(1); - // @ts-ignore - expect(wrapper.instance().dragSelectSubscriber.unsubscribe.mock.calls).toHaveLength(0); - // @ts-ignore expect(mocks.timeBuckets.setInterval.mock.calls.length).toBeGreaterThanOrEqual(1); // @ts-ignore expect(mocks.timeBuckets.getScaledDateFormat.mock.calls.length).toBeGreaterThanOrEqual(1); - expect(swimlaneRenderDoneListener.mock.calls.length).toBeGreaterThanOrEqual(1); }); test('Overall swimlane', () => { const mocks = getExplorerSwimlaneMocks(); - const swimlaneRenderDoneListener = jest.fn(); const wrapper = mountWithIntl( ); @@ -125,13 +119,8 @@ describe('ExplorerSwimlane', () => { // test calls to mock functions // @ts-ignore - expect(dragSelect$.subscribe.mock.calls.length).toBeGreaterThanOrEqual(1); - // @ts-ignore - expect(wrapper.instance().dragSelectSubscriber.unsubscribe.mock.calls).toHaveLength(0); - // @ts-ignore expect(mocks.timeBuckets.setInterval.mock.calls.length).toBeGreaterThanOrEqual(1); // @ts-ignore expect(mocks.timeBuckets.getScaledDateFormat.mock.calls.length).toBeGreaterThanOrEqual(1); - expect(swimlaneRenderDoneListener.mock.calls.length).toBeGreaterThanOrEqual(1); }); }); 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 aa386288ac7e0..0f92278e90445 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx @@ -13,15 +13,17 @@ import './_explorer.scss'; import _ from 'lodash'; import d3 from 'd3'; import moment from 'moment'; +import DragSelect from 'dragselect'; import { i18n } from '@kbn/i18n'; -import { Subscription } from 'rxjs'; +import { Subject, Subscription } from 'rxjs'; import { TooltipValue } from '@elastic/charts'; +import { htmlIdGenerator } from '@elastic/eui'; import { formatHumanReadableDateTime } from '../util/date_utils'; 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 { ALLOW_CELL_RANGE_SELECTION } from './explorer_dashboard_service'; 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'; @@ -29,7 +31,7 @@ import { ChartTooltipService, ChartTooltipValue, } from '../components/chart_tooltip/chart_tooltip_service'; -import { OverallSwimlaneData, ViewBySwimLaneData } from './explorer_utils'; +import { AppStateSelectedCells, OverallSwimlaneData, ViewBySwimLaneData } from './explorer_utils'; const SCSS = { mlDragselectDragging: 'mlDragselectDragging', @@ -56,7 +58,6 @@ export interface ExplorerSwimlaneProps { filterActive?: boolean; maskAll?: boolean; timeBuckets: InstanceType; - swimlaneCellClick?: Function; swimlaneData: OverallSwimlaneData | ViewBySwimLaneData; swimlaneType: SwimlaneType; selection?: { @@ -64,8 +65,15 @@ export interface ExplorerSwimlaneProps { type: string; times: number[]; }; - swimlaneRenderDoneListener?: Function; + onCellsSelection: (payload?: AppStateSelectedCells) => void; tooltipService: ChartTooltipService; + 'data-test-subj'?: string; + /** + * We need to be aware of the parent element in order to set + * the height so the swim lane widget doesn't jump during loading + * or page changes. + */ + parentRef: React.RefObject; } export class ExplorerSwimlane extends React.Component { @@ -78,13 +86,70 @@ export class ExplorerSwimlane extends React.Component { rootNode = React.createRef(); + isSwimlaneSelectActive = false; + // make sure dragSelect is only available if the mouse pointer is actually over a swimlane + disableDragSelectOnMouseLeave = true; + + dragSelect$ = new Subject<{ + action: typeof DRAG_SELECT_ACTION[keyof typeof DRAG_SELECT_ACTION]; + elements?: any[]; + }>(); + + /** + * Unique id for swim lane instance + */ + rootNodeId = htmlIdGenerator()(); + + /** + * Initialize drag select instance + */ + dragSelect = new DragSelect({ + selectorClass: 'ml-swimlane-selector', + selectables: document.querySelectorAll(`#${this.rootNodeId} .sl-cell`), + callback: (elements) => { + if (elements.length > 1 && !ALLOW_CELL_RANGE_SELECTION) { + elements = [elements[0]]; + } + + if (elements.length > 0) { + this.dragSelect$.next({ + action: DRAG_SELECT_ACTION.NEW_SELECTION, + elements, + }); + } + + this.disableDragSelectOnMouseLeave = true; + }, + onDragStart: (e) => { + // make sure we don't trigger text selection on label + e.preventDefault(); + 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) { + this.dragSelect$.next({ + action: DRAG_SELECT_ACTION.DRAG_START, + }); + this.disableDragSelectOnMouseLeave = false; + } + }, + onElementSelect: () => { + if (ALLOW_CELL_RANGE_SELECTION) { + this.dragSelect$.next({ + action: DRAG_SELECT_ACTION.ELEMENT_SELECT, + }); + } + }, + }); + componentDidMount() { // property for data comparison to be able to filter // consecutive click events with the same data. let previousSelectedData: any = null; // Listen for dragSelect events - this.dragSelectSubscriber = dragSelect$.subscribe(({ action, elements = [] }) => { + this.dragSelectSubscriber = this.dragSelect$.subscribe(({ action, elements = [] }) => { const element = d3.select(this.rootNode.current!.parentNode!); const { swimlaneType } = this.props; @@ -154,7 +219,7 @@ export class ExplorerSwimlane extends React.Component { } selectCell(cellsToSelect: any[], { laneLabels, bucketScore, times }: SelectedData) { - const { selection, swimlaneCellClick = () => {}, swimlaneData, swimlaneType } = this.props; + const { selection, swimlaneData, swimlaneType } = this.props; let triggerNewSelection = false; @@ -184,7 +249,7 @@ export class ExplorerSwimlane extends React.Component { } if (triggerNewSelection === false) { - swimlaneCellClick({}); + this.swimlaneCellClick(); return; } @@ -194,7 +259,7 @@ export class ExplorerSwimlane extends React.Component { times: d3.extent(times), type: swimlaneType, }; - swimlaneCellClick(selectedCells); + this.swimlaneCellClick(selectedCells); } highlightOverall(times: number[]) { @@ -208,10 +273,8 @@ export class ExplorerSwimlane extends React.Component { } highlightSelection(cellsToSelect: Node[], laneLabels: string[], times: number[]) { - const { swimlaneType } = this.props; - - // This selects both overall and viewby swimlane - const wrapper = d3.selectAll('.mlExplorerSwimlane'); + // This selects the embeddable container + const wrapper = d3.select(`#${this.rootNodeId}`); wrapper.selectAll('.lane-label').classed('lane-label-masked', true); wrapper @@ -232,13 +295,12 @@ export class ExplorerSwimlane extends React.Component { rootParent.selectAll('.lane-label').classed('lane-label-masked', function (this: HTMLElement) { return laneLabels.indexOf(d3.select(this).text()) === -1; }); - - if (swimlaneType === 'viewBy') { - // If selecting a cell in the 'view by' swimlane, indicate the corresponding time in the Overall swimlane. - this.highlightOverall(times); - } } + /** + * TODO should happen with props instead of imperative check + * @param maskAll + */ maskIrrelevantSwimlanes(maskAll: boolean) { if (maskAll === true) { // This selects both overall and viewby swimlane @@ -288,7 +350,6 @@ export class ExplorerSwimlane extends React.Component { filterActive, maskAll, timeBuckets, - swimlaneCellClick, swimlaneData, swimlaneType, selection, @@ -358,9 +419,12 @@ export class ExplorerSwimlane extends React.Component { const numBuckets = Math.round((endTime - startTime) / stepSecs); const cellHeight = 30; const height = (lanes.length + 1) * cellHeight - 10; - const laneLabelWidth = 170; + // Set height for the wrapper element + if (this.props.parentRef.current) { + this.props.parentRef.current.style.height = `${height + 20}px`; + } - element.style('height', `${height + 20}px`); + const laneLabelWidth = 170; const swimlanes = element.select('.ml-swimlanes'); swimlanes.html(''); @@ -413,8 +477,8 @@ export class ExplorerSwimlane extends React.Component { } }) .on('click', () => { - if (selection && typeof selection.lanes !== 'undefined' && swimlaneCellClick) { - swimlaneCellClick({}); + if (selection && typeof selection.lanes !== 'undefined') { + this.swimlaneCellClick(); } }) .each(function (this: HTMLElement) { @@ -567,9 +631,7 @@ export class ExplorerSwimlane extends React.Component { element.selectAll('.sl-cell-inner').classed('sl-cell-inner-masked', true); } - if (this.props.swimlaneRenderDoneListener) { - this.props.swimlaneRenderDoneListener(); - } + this.swimlaneRenderDoneListener(); if ( (swimlaneType !== selectedType || @@ -593,10 +655,7 @@ export class ExplorerSwimlane extends React.Component { selectedTimeExtent[1] <= endTime ) { // Locate matching cell - look for exact time, otherwise closest before. - const swimlaneElements = element.select('.ml-swimlanes'); - const laneCells = swimlaneElements.selectAll( - `div[data-lane-label="${mlEscape(selectedLane)}"]` - ); + const laneCells = element.selectAll(`div[data-lane-label="${mlEscape(selectedLane)}"]`); laneCells.each(function (this: HTMLElement) { const cell = d3.select(this); @@ -632,9 +691,58 @@ export class ExplorerSwimlane extends React.Component { return true; } + /** + * Listener for click events in the swim lane and execute a prop callback. + * @param selectedCellsUpdate + */ + swimlaneCellClick(selectedCellsUpdate?: AppStateSelectedCells) { + // If selectedCells is an empty object we clear any existing selection, + // otherwise we save the new selection in AppState and update the Explorer. + if (!selectedCellsUpdate) { + this.props.onCellsSelection(); + } else { + this.props.onCellsSelection(selectedCellsUpdate); + } + } + + /** + * Listens to render updates of the swim lanes to update dragSelect + */ + swimlaneRenderDoneListener() { + this.dragSelect.clearSelection(); + this.dragSelect.setSelectables(document.querySelectorAll(`#${this.rootNodeId} .sl-cell`)); + } + + setSwimlaneSelectActive(active: boolean) { + 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.querySelectorAll(`#${this.rootNodeId} .sl-cell`)); + this.isSwimlaneSelectActive = active; + } + } + render() { const { swimlaneType } = this.props; - return
; + return ( +
+
+
+ ); } } diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts b/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts index 05fdb52e1ccb2..0faa20295996c 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts @@ -7,6 +7,7 @@ import { Moment } from 'moment'; import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; +import { SwimlaneType } from './explorer_constants'; interface ClearedSelectedAnomaliesState { selectedCells: undefined; @@ -182,9 +183,9 @@ export declare interface FilterData { } export declare interface AppStateSelectedCells { - type: string; + type: SwimlaneType; lanes: string[]; times: number[]; - showTopFieldValues: boolean; - viewByFieldName: string; + showTopFieldValues?: boolean; + viewByFieldName?: string; } diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts index 49f5794273a04..4d5ad65065fc3 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { isEqual } from 'lodash'; import { ActionPayload } from '../../explorer_dashboard_service'; import { getDefaultSwimlaneData, getInfluencers } from '../../explorer_utils'; @@ -17,7 +18,11 @@ export const jobSelectionChange = (state: ExplorerState, payload: ActionPayload) noInfluencersConfigured: getInfluencers(selectedJobs).length === 0, overallSwimlaneData: getDefaultSwimlaneData(), selectedJobs, - viewByFromPage: 1, + // currently job selection set asynchronously so + // we want to preserve the pagination from the url state + // on initial load + viewByFromPage: + !state.selectedJobs || isEqual(state.selectedJobs, selectedJobs) ? state.viewByFromPage : 1, }; // clear filter if selected jobs have no influencers diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx index e34e1d26c9cab..51ea0f00d5f6a 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useCallback, useState } from 'react'; +import React, { FC, useCallback, useRef, useState } from 'react'; import { EuiText, EuiLoadingChart, @@ -49,7 +49,7 @@ export function isViewBySwimLaneData(arg: any): arg is ViewBySwimLaneData { * @constructor */ export const SwimlaneContainer: FC< - Omit & { + Omit & { onResize: (width: number) => void; fromPage?: number; perPage?: number; @@ -70,6 +70,7 @@ export const SwimlaneContainer: FC< ...props }) => { const [chartWidth, setChartWidth] = useState(0); + const wrapperRef = useRef(null); const resizeHandler = useCallback( throttle((e: { width: number; height: number }) => { @@ -111,36 +112,40 @@ export const SwimlaneContainer: FC< data-test-subj="mlSwimLaneContainer" > - - {showSwimlane && !isLoading && ( - - {(tooltipService) => ( - + + {showSwimlane && !isLoading && ( + + {(tooltipService) => ( + + )} + + )} + {isLoading && ( + + - )} - - )} - {isLoading && ( - - + )} + {!isLoading && !showSwimlane && ( + {noDataWarning}} /> - - )} - {!isLoading && !showSwimlane && ( - {noDataWarning}} - /> - )} - + )} + +
+ {isPaginationVisible && ( = ({ jobsWithTim const [lastRefresh, setLastRefresh] = useState(0); const timefilter = useTimefilter({ timeRangeSelector: true, autoRefreshSelector: true }); - const { jobIds } = useJobSelection(jobsWithTimeRange, getDateFormatTz()); + const { jobIds } = useJobSelection(jobsWithTimeRange); const refresh = useRefresh(); useEffect(() => { @@ -109,6 +109,14 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim } }, [globalState?.time?.from, globalState?.time?.to]); + useEffect(() => { + if (jobIds.length > 0) { + explorerService.updateJobSelection(jobIds); + } else { + explorerService.clearJobs(); + } + }, [JSON.stringify(jobIds)]); + useEffect(() => { const viewByFieldName = appState?.mlExplorerSwimlane?.viewByFieldName; if (viewByFieldName !== undefined) { @@ -119,15 +127,17 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim if (filterData !== undefined) { explorerService.setFilterData(filterData); } - }, []); - useEffect(() => { - if (jobIds.length > 0) { - explorerService.updateJobSelection(jobIds); - } else { - explorerService.clearJobs(); + const viewByPerPage = (appState as ExplorerAppState)?.mlExplorerSwimlane?.viewByPerPage; + if (viewByPerPage) { + explorerService.setViewByPerPage(viewByPerPage); } - }, [JSON.stringify(jobIds)]); + + const viewByFromPage = (appState as ExplorerAppState)?.mlExplorerSwimlane?.viewByFromPage; + if (viewByFromPage) { + explorerService.setViewByFromPage(viewByFromPage); + } + }, []); const [explorerData, loadExplorerData] = useExplorerData(); useEffect(() => { diff --git a/x-pack/plugins/ml/public/application/routing/use_refresh.ts b/x-pack/plugins/ml/public/application/routing/use_refresh.ts index c247fd9765e96..539ce6f88a421 100644 --- a/x-pack/plugins/ml/public/application/routing/use_refresh.ts +++ b/x-pack/plugins/ml/public/application/routing/use_refresh.ts @@ -6,7 +6,7 @@ import { useObservable } from 'react-use'; import { merge } from 'rxjs'; -import { map, skip } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { useMemo } from 'react'; import { annotationsRefresh$ } from '../services/annotations_service'; @@ -29,9 +29,7 @@ export const useRefresh = () => { return merge( mlTimefilterRefresh$, timefilter.getTimeUpdate$().pipe( - // skip initially emitted value - skip(1), - map((_) => { + map(() => { const { from, to } = timefilter.getTime(); return { lastRefresh: Date.now(), timeRange: { start: from, end: to } }; }) diff --git a/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts b/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts index f2e362f754f2b..2bdb758be874c 100644 --- a/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts +++ b/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts @@ -5,7 +5,6 @@ */ import { IUiSettingsClient } from 'kibana/public'; -import { i18n } from '@kbn/i18n'; import { TimefilterContract, TimeRange, @@ -18,7 +17,7 @@ import { SwimlaneData, ViewBySwimLaneData, } from '../explorer/explorer_utils'; -import { VIEW_BY_JOB_LABEL } from '../explorer/explorer_constants'; +import { OVERALL_LABEL, VIEW_BY_JOB_LABEL } from '../explorer/explorer_constants'; import { MlResultsService } from './results_service'; /** @@ -288,9 +287,7 @@ export class AnomalyTimelineService { searchBounds: Required, interval: number ): OverallSwimlaneData { - const overallLabel = i18n.translate('xpack.ml.explorer.overallLabel', { - defaultMessage: 'Overall', - }); + const overallLabel = OVERALL_LABEL; const dataset: OverallSwimlaneData = { laneLabels: [overallLabel], points: [], @@ -302,7 +299,7 @@ export class AnomalyTimelineService { // Store the earliest and latest times of the data returned by the ES aggregations, // These will be used for calculating the earliest and latest times for the swim lane charts. Object.entries(scoresByTime).forEach(([timeMs, score]) => { - const time = Number(timeMs) / 1000; + const time = +timeMs / 1000; dataset.points.push({ laneLabel: overallLabel, time, @@ -346,7 +343,7 @@ export class AnomalyTimelineService { maxScoreByLaneLabel[influencerFieldValue] = 0; Object.entries(influencerData).forEach(([timeMs, anomalyScore]) => { - const time = Number(timeMs) / 1000; + const time = +timeMs / 1000; dataset.points.push({ laneLabel: influencerFieldValue, time, 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 83070a5d94ba0..9f96b73d67c57 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 @@ -14,8 +14,8 @@ import { EmbeddableInput, EmbeddableOutput, IContainer, + IEmbeddable, } from '../../../../../../src/plugins/embeddable/public'; -import { MlStartDependencies } from '../../plugin'; import { EmbeddableSwimLaneContainer } from './embeddable_swim_lane_container'; import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; import { JobId } from '../../../common/types/anomaly_detection_jobs'; @@ -27,6 +27,9 @@ import { TimeRange, } from '../../../../../../src/plugins/data/common'; import { SwimlaneType } from '../../application/explorer/explorer_constants'; +import { MlDependencies } from '../../application/app'; +import { AppStateSelectedCells } from '../../application/explorer/explorer_utils'; +import { SWIM_LANE_SELECTION_TRIGGER } from '../../ui_actions/triggers'; export const ANOMALY_SWIMLANE_EMBEDDABLE_TYPE = 'ml_anomaly_swimlane'; @@ -49,16 +52,26 @@ export interface AnomalySwimlaneEmbeddableCustomInput { timeRange: TimeRange; } +export interface EditSwimlanePanelContext { + embeddable: IEmbeddable; +} + +export interface SwimLaneDrilldownContext extends EditSwimlanePanelContext { + /** + * Optional data provided by swim lane selection + */ + data?: AppStateSelectedCells; +} + export type AnomalySwimlaneEmbeddableInput = EmbeddableInput & AnomalySwimlaneEmbeddableCustomInput; export type AnomalySwimlaneEmbeddableOutput = EmbeddableOutput & AnomalySwimlaneEmbeddableCustomOutput; export interface AnomalySwimlaneEmbeddableCustomOutput { - jobIds: JobId[]; - swimlaneType: SwimlaneType; - viewBy?: string; perPage?: number; + fromPage?: number; + interval?: number; } export interface AnomalySwimlaneServices { @@ -68,7 +81,7 @@ export interface AnomalySwimlaneServices { export type AnomalySwimlaneEmbeddableServices = [ CoreStart, - MlStartDependencies, + MlDependencies, AnomalySwimlaneServices ]; @@ -82,16 +95,13 @@ export class AnomalySwimlaneEmbeddable extends Embeddable< constructor( initialInput: AnomalySwimlaneEmbeddableInput, - private services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices], + public services: [CoreStart, MlDependencies, AnomalySwimlaneServices], parent?: IContainer ) { super( initialInput, { - jobIds: initialInput.jobIds, - swimlaneType: initialInput.swimlaneType, defaultTitle: initialInput.title, - ...(initialInput.viewBy ? { viewBy: initialInput.viewBy } : {}), }, parent ); @@ -107,12 +117,12 @@ export class AnomalySwimlaneEmbeddable extends Embeddable< { - this.updateInput(input); - }} + onInputChange={this.updateInput.bind(this)} + onOutputChange={this.updateOutput.bind(this)} /> , node @@ -129,4 +139,8 @@ export class AnomalySwimlaneEmbeddable extends Embeddable< public reload() { this.reload$.next(); } + + public supportedTriggers() { + return [SWIM_LANE_SELECTION_TRIGGER as typeof SWIM_LANE_SELECTION_TRIGGER]; + } } 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 0d587b428d89b..14fbf77544b21 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 @@ -19,19 +19,22 @@ import { AnomalySwimlaneEmbeddableInput, AnomalySwimlaneEmbeddableServices, } from './anomaly_swimlane_embeddable'; -import { MlStartDependencies } from '../../plugin'; import { HttpService } from '../../application/services/http_service'; import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; import { AnomalyTimelineService } from '../../application/services/anomaly_timeline_service'; import { mlResultsServiceProvider } from '../../application/services/results_service'; import { resolveAnomalySwimlaneUserInput } from './anomaly_swimlane_setup_flyout'; import { mlApiServicesProvider } from '../../application/services/ml_api_service'; +import { MlPluginStart, MlStartDependencies } from '../../plugin'; +import { MlDependencies } from '../../application/app'; export class AnomalySwimlaneEmbeddableFactory implements EmbeddableFactoryDefinition { public readonly type = ANOMALY_SWIMLANE_EMBEDDABLE_TYPE; - constructor(private getStartServices: StartServicesAccessor) {} + constructor( + private getStartServices: StartServicesAccessor + ) {} public async isEditable() { return true; @@ -64,7 +67,11 @@ export class AnomalySwimlaneEmbeddableFactory mlResultsServiceProvider(mlApiServicesProvider(httpService)) ); - return [coreStart, pluginsStart, { anomalyDetectorService, anomalyTimelineService }]; + return [ + coreStart, + pluginsStart as MlDependencies, + { anomalyDetectorService, anomalyTimelineService }, + ]; } public async create( 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 be9a332e51dbc..e5a13adca05db 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 @@ -17,6 +17,7 @@ import { EuiModalHeaderTitle, EuiSelect, EuiFieldText, + EuiModal, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -33,7 +34,6 @@ export interface AnomalySwimlaneInitializerProps { panelTitle: string; swimlaneType: SwimlaneType; viewBy?: string; - limit?: number; }) => void; onCancel: () => void; } @@ -81,7 +81,7 @@ export const AnomalySwimlaneInitializer: FC = ( (swimlaneType === SWIMLANE_TYPE.VIEW_BY && !!viewBySwimlaneFieldName)); return ( -
+ = ( /> -
+ ); }; diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.test.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.test.tsx index 846a3f543c2d4..23045834eae5f 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.test.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.test.tsx @@ -6,18 +6,25 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { EmbeddableSwimLaneContainer } from './embeddable_swim_lane_container'; +import { + EmbeddableSwimLaneContainer, + ExplorerSwimlaneContainerProps, +} from './embeddable_swim_lane_container'; import { BehaviorSubject, Observable } from 'rxjs'; import { I18nProvider } from '@kbn/i18n/react'; import { + AnomalySwimlaneEmbeddable, AnomalySwimlaneEmbeddableInput, AnomalySwimlaneServices, } from './anomaly_swimlane_embeddable'; import { CoreStart } from 'kibana/public'; -import { MlStartDependencies } from '../../plugin'; import { useSwimlaneInputResolver } from './swimlane_input_resolver'; import { SWIMLANE_TYPE } from '../../application/explorer/explorer_constants'; import { SwimlaneContainer } from '../../application/explorer/swimlane_container'; +import { MlDependencies } from '../../application/app'; +import { uiActionsPluginMock } from 'src/plugins/ui_actions/public/mocks'; +import { TriggerContract } from 'src/plugins/ui_actions/public/triggers'; +import { TriggerId } from 'src/plugins/ui_actions/public'; jest.mock('./swimlane_input_resolver', () => ({ useSwimlaneInputResolver: jest.fn(() => { @@ -37,13 +44,30 @@ const defaultOptions = { wrapper: I18nProvider }; describe('ExplorerSwimlaneContainer', () => { let embeddableInput: BehaviorSubject>; let refresh: BehaviorSubject; - let services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices]; + let services: jest.Mocked<[CoreStart, MlDependencies, AnomalySwimlaneServices]>; + let embeddableContext: AnomalySwimlaneEmbeddable; + let trigger: jest.Mocked>; + const onInputChange = jest.fn(); + const onOutputChange = jest.fn(); beforeEach(() => { + embeddableContext = { id: 'test-id' } as AnomalySwimlaneEmbeddable; embeddableInput = new BehaviorSubject({ id: 'test-swimlane-embeddable', } as Partial); + + trigger = ({ exec: jest.fn() } as unknown) as jest.Mocked>; + + const uiActionsMock = uiActionsPluginMock.createStartContract(); + uiActionsMock.getTrigger.mockReturnValue(trigger); + + services = ([ + {}, + { + uiActions: uiActionsMock, + }, + ] as unknown) as ExplorerSwimlaneContainerProps['services']; }); test('should render a swimlane with a valid embeddable input', async () => { @@ -74,12 +98,14 @@ describe('ExplorerSwimlaneContainer', () => { render( } services={services} refresh={refresh} onInputChange={onInputChange} + onOutputChange={onOutputChange} />, defaultOptions ); @@ -110,6 +136,7 @@ describe('ExplorerSwimlaneContainer', () => { const { findByText } = render( @@ -117,6 +144,7 @@ describe('ExplorerSwimlaneContainer', () => { services={services} refresh={refresh} onInputChange={onInputChange} + onOutputChange={onOutputChange} />, defaultOptions ); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx index 5d91bdb41df6a..8ee4e391fcdde 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx @@ -4,14 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useState } from 'react'; +import React, { FC, useCallback, useState, useEffect } from 'react'; import { EuiCallOut } from '@elastic/eui'; import { Observable } from 'rxjs'; import { CoreStart } from 'kibana/public'; import { FormattedMessage } from '@kbn/i18n/react'; -import { MlStartDependencies } from '../../plugin'; import { + AnomalySwimlaneEmbeddable, AnomalySwimlaneEmbeddableInput, AnomalySwimlaneEmbeddableOutput, AnomalySwimlaneServices, @@ -22,25 +22,36 @@ import { isViewBySwimLaneData, SwimlaneContainer, } from '../../application/explorer/swimlane_container'; +import { AppStateSelectedCells } from '../../application/explorer/explorer_utils'; +import { MlDependencies } from '../../application/app'; +import { SWIM_LANE_SELECTION_TRIGGER } from '../../ui_actions/triggers'; export interface ExplorerSwimlaneContainerProps { id: string; + embeddableContext: AnomalySwimlaneEmbeddable; embeddableInput: Observable; - services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices]; + services: [CoreStart, MlDependencies, AnomalySwimlaneServices]; refresh: Observable; - onInputChange: (output: Partial) => void; + onInputChange: (input: Partial) => void; + onOutputChange: (output: Partial) => void; } export const EmbeddableSwimLaneContainer: FC = ({ id, + embeddableContext, embeddableInput, services, refresh, onInputChange, + onOutputChange, }) => { const [chartWidth, setChartWidth] = useState(0); const [fromPage, setFromPage] = useState(1); + const [{}, { uiActions }] = services; + + const [selectedCells, setSelectedCells] = useState(); + const [ swimlaneType, swimlaneData, @@ -58,6 +69,28 @@ export const EmbeddableSwimLaneContainer: FC = ( fromPage ); + useEffect(() => { + onOutputChange({ + perPage, + fromPage, + interval: swimlaneData?.interval, + }); + }, [perPage, fromPage, swimlaneData]); + + const onCellsSelection = useCallback( + (update?: AppStateSelectedCells) => { + setSelectedCells(update); + + if (update) { + uiActions.getTrigger(SWIM_LANE_SELECTION_TRIGGER).exec({ + embeddable: embeddableContext, + data: update, + }); + } + }, + [swimlaneData, perPage, fromPage] + ); + if (error) { return ( = ( data-test-subj="mlAnomalySwimlaneEmbeddableWrapper" > { - setChartWidth(width); - }} + onResize={setChartWidth} + selection={selectedCells} + onCellsSelection={onCellsSelection} onPaginationChange={(update) => { if (update.fromPage) { setFromPage(update.fromPage); 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 9ed6f88150f68..f17c779a00252 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 @@ -40,6 +40,7 @@ import { parseInterval } from '../../../common/util/parse_interval'; import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; import { isViewBySwimLaneData } from '../../application/explorer/swimlane_container'; import { ViewMode } from '../../../../../../src/plugins/embeddable/public'; +import { CONTROLLED_BY_SWIM_LANE_FILTER } from '../../ui_actions/apply_influencer_filters_action'; const FETCH_RESULTS_DEBOUNCE_MS = 500; @@ -240,7 +241,9 @@ export function processFilters(filters: Filter[], query: Query) { const must = [inputQuery]; const mustNot = []; for (const filter of filters) { - if (filter.meta.disabled) continue; + // ignore disabled filters as well as created by swim lane selection + if (filter.meta.disabled || filter.meta.controlledBy === CONTROLLED_BY_SWIM_LANE_FILTER) + continue; const { meta: { negate, type, key: fieldName }, diff --git a/x-pack/plugins/ml/public/embeddables/index.ts b/x-pack/plugins/ml/public/embeddables/index.ts index 5e9d54645b516..db9f094d5721e 100644 --- a/x-pack/plugins/ml/public/embeddables/index.ts +++ b/x-pack/plugins/ml/public/embeddables/index.ts @@ -4,15 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup } from 'kibana/public'; import { AnomalySwimlaneEmbeddableFactory } from './anomaly_swimlane'; -import { MlPluginStart, MlStartDependencies } from '../plugin'; +import { MlCoreSetup } from '../plugin'; import { EmbeddableSetup } from '../../../../../src/plugins/embeddable/public'; -export function registerEmbeddables( - embeddable: EmbeddableSetup, - core: CoreSetup -) { +export function registerEmbeddables(embeddable: EmbeddableSetup, core: MlCoreSetup) { const anomalySwimlaneEmbeddableFactory = new AnomalySwimlaneEmbeddableFactory( core.getStartServices ); diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 7f7544a44efa7..449d8baa2a184 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -13,7 +13,7 @@ import { PluginInitializerContext, } from 'kibana/public'; import { ManagementSetup } from 'src/plugins/management/public'; -import { SharePluginStart } from 'src/plugins/share/public'; +import { SharePluginSetup, SharePluginStart, UrlGeneratorState } from 'src/plugins/share/public'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { DataPublicPluginStart } from 'src/plugins/data/public'; @@ -28,14 +28,16 @@ import { PLUGIN_ID, PLUGIN_ICON } from '../common/constants/app'; import { registerFeature } from './register_feature'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import { registerEmbeddables } from './embeddables'; -import { UiActionsSetup } from '../../../../src/plugins/ui_actions/public'; +import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; import { registerMlUiActions } from './ui_actions'; import { KibanaLegacyStart } from '../../../../src/plugins/kibana_legacy/public'; +import { MlUrlGenerator, MlUrlGeneratorState, ML_APP_URL_GENERATOR } from './url_generator'; export interface MlStartDependencies { data: DataPublicPluginStart; share: SharePluginStart; kibanaLegacy: KibanaLegacyStart; + uiActions: UiActionsStart; } export interface MlSetupDependencies { security?: SecurityPluginSetup; @@ -47,13 +49,30 @@ export interface MlSetupDependencies { embeddable: EmbeddableSetup; uiActions: UiActionsSetup; kibanaVersion: string; - share: SharePluginStart; + share: SharePluginSetup; +} + +declare module '../../../../src/plugins/share/public' { + export interface UrlGeneratorStateMapping { + [ML_APP_URL_GENERATOR]: UrlGeneratorState; + } } +export type MlCoreSetup = CoreSetup; + export class MlPlugin implements Plugin { constructor(private initializerContext: PluginInitializerContext) {} - setup(core: CoreSetup, pluginsSetup: MlSetupDependencies) { + setup(core: MlCoreSetup, pluginsSetup: MlSetupDependencies) { + const baseUrl = core.http.basePath.prepend('/app/ml'); + + pluginsSetup.share.urlGenerators.registerUrlGenerator( + new MlUrlGenerator({ + appBasePath: baseUrl, + useHash: core.uiSettings.get('state:storeInSessionStorage'), + }) + ); + core.application.register({ id: PLUGIN_ID, title: i18n.translate('xpack.ml.plugin.title', { @@ -80,7 +99,7 @@ export class MlPlugin implements Plugin { licenseManagement: pluginsSetup.licenseManagement, home: pluginsSetup.home, embeddable: pluginsSetup.embeddable, - uiActions: pluginsSetup.uiActions, + uiActions: pluginsStart.uiActions, kibanaVersion, }, { @@ -96,10 +115,8 @@ export class MlPlugin implements Plugin { registerFeature(pluginsSetup.home); initManagementSection(pluginsSetup, core); - - registerMlUiActions(pluginsSetup.uiActions, core); - registerEmbeddables(pluginsSetup.embeddable, core); + registerMlUiActions(pluginsSetup.uiActions, core); return {}; } @@ -113,6 +130,7 @@ export class MlPlugin implements Plugin { }); return {}; } + public stop() {} } diff --git a/x-pack/plugins/ml/public/ui_actions/apply_influencer_filters_action.tsx b/x-pack/plugins/ml/public/ui_actions/apply_influencer_filters_action.tsx new file mode 100644 index 0000000000000..3af39993d39fd --- /dev/null +++ b/x-pack/plugins/ml/public/ui_actions/apply_influencer_filters_action.tsx @@ -0,0 +1,84 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ActionContextMapping, createAction } from '../../../../../src/plugins/ui_actions/public'; +import { + AnomalySwimlaneEmbeddable, + SwimLaneDrilldownContext, +} from '../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; +import { MlCoreSetup } from '../plugin'; +import { SWIMLANE_TYPE, VIEW_BY_JOB_LABEL } from '../application/explorer/explorer_constants'; +import { Filter, FilterStateStore } from '../../../../../src/plugins/data/common'; + +export const APPLY_INFLUENCER_FILTERS_ACTION = 'applyInfluencerFiltersAction'; + +export const CONTROLLED_BY_SWIM_LANE_FILTER = 'anomaly-swim-lane'; + +export function createApplyInfluencerFiltersAction( + getStartServices: MlCoreSetup['getStartServices'] +) { + return createAction({ + id: 'apply-to-current-view', + type: APPLY_INFLUENCER_FILTERS_ACTION, + getIconType(context: ActionContextMapping[typeof APPLY_INFLUENCER_FILTERS_ACTION]): string { + return 'filter'; + }, + getDisplayName() { + return i18n.translate('xpack.ml.actions.applyInfluencersFiltersTitle', { + defaultMessage: 'Filer for value', + }); + }, + async execute({ data }: SwimLaneDrilldownContext) { + if (!data) { + throw new Error('No swim lane selection data provided'); + } + const [, pluginStart] = await getStartServices(); + const filterManager = pluginStart.data.query.filterManager; + + filterManager.addFilters( + data.lanes.map((influencerValue) => { + return { + $state: { + store: FilterStateStore.APP_STATE, + }, + meta: { + alias: i18n.translate('xpack.ml.actions.influencerFilterAliasLabel', { + defaultMessage: 'Influencer {labelValue}', + values: { + labelValue: `${data.viewByFieldName}:${influencerValue}`, + }, + }), + controlledBy: CONTROLLED_BY_SWIM_LANE_FILTER, + disabled: false, + key: data.viewByFieldName, + negate: false, + params: { + query: influencerValue, + }, + type: 'phrase', + }, + query: { + match_phrase: { + [data.viewByFieldName!]: influencerValue, + }, + }, + }; + }) + ); + }, + async isCompatible({ embeddable, data }: SwimLaneDrilldownContext) { + // Only compatible with view by influencer swim lanes and single selection + return ( + embeddable instanceof AnomalySwimlaneEmbeddable && + data !== undefined && + data.type === SWIMLANE_TYPE.VIEW_BY && + data.viewByFieldName !== VIEW_BY_JOB_LABEL && + data.lanes.length === 1 + ); + }, + }); +} diff --git a/x-pack/plugins/ml/public/ui_actions/apply_time_range_action.tsx b/x-pack/plugins/ml/public/ui_actions/apply_time_range_action.tsx new file mode 100644 index 0000000000000..ec59ba20acf98 --- /dev/null +++ b/x-pack/plugins/ml/public/ui_actions/apply_time_range_action.tsx @@ -0,0 +1,58 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import { ActionContextMapping, createAction } from '../../../../../src/plugins/ui_actions/public'; +import { + AnomalySwimlaneEmbeddable, + SwimLaneDrilldownContext, +} from '../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; +import { MlCoreSetup } from '../plugin'; + +export const APPLY_TIME_RANGE_SELECTION_ACTION = 'applyTimeRangeSelectionAction'; + +export function createApplyTimeRangeSelectionAction( + getStartServices: MlCoreSetup['getStartServices'] +) { + return createAction({ + id: 'apply-time-range-selection', + type: APPLY_TIME_RANGE_SELECTION_ACTION, + getIconType(context: ActionContextMapping[typeof APPLY_TIME_RANGE_SELECTION_ACTION]): string { + return 'timeline'; + }, + getDisplayName: () => + i18n.translate('xpack.ml.actions.applyTimeRangeSelectionTitle', { + defaultMessage: 'Apply time range selection', + }), + async execute({ embeddable, data }: SwimLaneDrilldownContext) { + if (!data) { + throw new Error('No swim lane selection data provided'); + } + const [, pluginStart] = await getStartServices(); + const timefilter = pluginStart.data.query.timefilter.timefilter; + const { interval } = embeddable.getOutput(); + + if (!interval) { + throw new Error('Interval is required to set a time range'); + } + + let [from, to] = data.times; + from = from * 1000; + // extend bounds with the interval + to = to * 1000 + interval * 1000; + + timefilter.setTime({ + from: moment(from), + to: moment(to), + mode: 'absolute', + }); + }, + async isCompatible({ embeddable, data }: SwimLaneDrilldownContext) { + return embeddable instanceof AnomalySwimlaneEmbeddable && data !== undefined; + }, + }); +} diff --git a/x-pack/plugins/ml/public/ui_actions/edit_swimlane_panel_action.tsx b/x-pack/plugins/ml/public/ui_actions/edit_swimlane_panel_action.tsx index 0db41c1ed104e..cfd90f92e3238 100644 --- a/x-pack/plugins/ml/public/ui_actions/edit_swimlane_panel_action.tsx +++ b/x-pack/plugins/ml/public/ui_actions/edit_swimlane_panel_action.tsx @@ -4,24 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { ActionContextMapping, createAction } from '../../../../../src/plugins/ui_actions/public'; -import { IEmbeddable } from '../../../../../src/plugins/embeddable/public'; import { AnomalySwimlaneEmbeddable, - AnomalySwimlaneEmbeddableInput, - AnomalySwimlaneEmbeddableOutput, + EditSwimlanePanelContext, } from '../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; import { resolveAnomalySwimlaneUserInput } from '../embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout'; +import { ViewMode } from '../../../../../src/plugins/embeddable/public'; +import { MlCoreSetup } from '../plugin'; export const EDIT_SWIMLANE_PANEL_ACTION = 'editSwimlanePanelAction'; -export interface EditSwimlanePanelContext { - embeddable: IEmbeddable; -} - -export function createEditSwimlanePanelAction(getStartServices: CoreSetup['getStartServices']) { +export function createEditSwimlanePanelAction(getStartServices: MlCoreSetup['getStartServices']) { return createAction({ id: 'edit-anomaly-swimlane', type: EDIT_SWIMLANE_PANEL_ACTION, @@ -48,7 +43,8 @@ export function createEditSwimlanePanelAction(getStartServices: CoreSetup['getSt }, isCompatible: async ({ embeddable }: EditSwimlanePanelContext) => { return ( - embeddable instanceof AnomalySwimlaneEmbeddable && embeddable.getInput().viewMode === 'edit' + embeddable instanceof AnomalySwimlaneEmbeddable && + embeddable.getInput().viewMode === ViewMode.EDIT ); }, }); diff --git a/x-pack/plugins/ml/public/ui_actions/index.ts b/x-pack/plugins/ml/public/ui_actions/index.ts index 4a1535c4e8c2e..b7262a330b310 100644 --- a/x-pack/plugins/ml/public/ui_actions/index.ts +++ b/x-pack/plugins/ml/public/ui_actions/index.ts @@ -8,23 +8,65 @@ import { CoreSetup } from 'kibana/public'; import { createEditSwimlanePanelAction, EDIT_SWIMLANE_PANEL_ACTION, - EditSwimlanePanelContext, } from './edit_swimlane_panel_action'; +import { + createOpenInExplorerAction, + OPEN_IN_ANOMALY_EXPLORER_ACTION, +} from './open_in_anomaly_explorer_action'; +import { EditSwimlanePanelContext } from '../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; import { UiActionsSetup } from '../../../../../src/plugins/ui_actions/public'; import { MlPluginStart, MlStartDependencies } from '../plugin'; import { CONTEXT_MENU_TRIGGER } from '../../../../../src/plugins/embeddable/public'; +import { + APPLY_INFLUENCER_FILTERS_ACTION, + createApplyInfluencerFiltersAction, +} from './apply_influencer_filters_action'; +import { SWIM_LANE_SELECTION_TRIGGER, swimLaneSelectionTrigger } from './triggers'; +import { SwimLaneDrilldownContext } from '../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; +import { + APPLY_TIME_RANGE_SELECTION_ACTION, + createApplyTimeRangeSelectionAction, +} from './apply_time_range_action'; +/** + * Register ML UI actions + */ export function registerMlUiActions( uiActions: UiActionsSetup, core: CoreSetup ) { + // Initialize actions const editSwimlanePanelAction = createEditSwimlanePanelAction(core.getStartServices); + const openInExplorerAction = createOpenInExplorerAction(core.getStartServices); + const applyInfluencerFiltersAction = createApplyInfluencerFiltersAction(core.getStartServices); + const applyTimeRangeSelectionAction = createApplyTimeRangeSelectionAction(core.getStartServices); + + // Register actions uiActions.registerAction(editSwimlanePanelAction); + uiActions.registerAction(openInExplorerAction); + uiActions.registerAction(applyInfluencerFiltersAction); + uiActions.registerAction(applyTimeRangeSelectionAction); + + // Assign triggers uiActions.attachAction(CONTEXT_MENU_TRIGGER, editSwimlanePanelAction.id); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, openInExplorerAction.id); + + uiActions.registerTrigger(swimLaneSelectionTrigger); + + uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, applyInfluencerFiltersAction); + uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, applyTimeRangeSelectionAction); + uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, openInExplorerAction); } declare module '../../../../../src/plugins/ui_actions/public' { export interface ActionContextMapping { [EDIT_SWIMLANE_PANEL_ACTION]: EditSwimlanePanelContext; + [OPEN_IN_ANOMALY_EXPLORER_ACTION]: SwimLaneDrilldownContext; + [APPLY_INFLUENCER_FILTERS_ACTION]: SwimLaneDrilldownContext; + [APPLY_TIME_RANGE_SELECTION_ACTION]: SwimLaneDrilldownContext; + } + + export interface TriggerContextMapping { + [SWIM_LANE_SELECTION_TRIGGER]: SwimLaneDrilldownContext; } } diff --git a/x-pack/plugins/ml/public/ui_actions/open_in_anomaly_explorer_action.tsx b/x-pack/plugins/ml/public/ui_actions/open_in_anomaly_explorer_action.tsx new file mode 100644 index 0000000000000..211840467e38c --- /dev/null +++ b/x-pack/plugins/ml/public/ui_actions/open_in_anomaly_explorer_action.tsx @@ -0,0 +1,66 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ActionContextMapping, createAction } from '../../../../../src/plugins/ui_actions/public'; +import { + AnomalySwimlaneEmbeddable, + SwimLaneDrilldownContext, +} from '../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; +import { MlCoreSetup } from '../plugin'; +import { ML_APP_URL_GENERATOR } from '../url_generator'; + +export const OPEN_IN_ANOMALY_EXPLORER_ACTION = 'openInAnomalyExplorerAction'; + +export function createOpenInExplorerAction(getStartServices: MlCoreSetup['getStartServices']) { + return createAction({ + id: 'open-in-anomaly-explorer', + type: OPEN_IN_ANOMALY_EXPLORER_ACTION, + getIconType(context: ActionContextMapping[typeof OPEN_IN_ANOMALY_EXPLORER_ACTION]): string { + return 'tableOfContents'; + }, + getDisplayName() { + return i18n.translate('xpack.ml.actions.openInAnomalyExplorerTitle', { + defaultMessage: 'Open in Anomaly Explorer', + }); + }, + async getHref({ embeddable, data }: SwimLaneDrilldownContext): Promise { + const [, pluginsStart] = await getStartServices(); + const urlGenerator = pluginsStart.share.urlGenerators.getUrlGenerator(ML_APP_URL_GENERATOR); + const { jobIds, timeRange, viewBy } = embeddable.getInput(); + const { perPage, fromPage } = embeddable.getOutput(); + + return urlGenerator.createUrl({ + page: 'explorer', + jobIds, + timeRange, + mlExplorerSwimlane: { + viewByFromPage: fromPage, + viewByPerPage: perPage, + viewByFieldName: viewBy, + ...(data + ? { + selectedType: data.type, + selectedTimes: data.times, + selectedLanes: data.lanes, + } + : {}), + }, + }); + }, + async execute({ embeddable, data }: SwimLaneDrilldownContext) { + if (!embeddable) { + throw new Error('Not possible to execute an action without the embeddable context'); + } + const [{ application }] = await getStartServices(); + const anomalyExplorerUrl = await this.getHref!({ embeddable, data }); + await application.navigateToUrl(anomalyExplorerUrl!); + }, + async isCompatible({ embeddable }: SwimLaneDrilldownContext) { + return embeddable instanceof AnomalySwimlaneEmbeddable; + }, + }); +} diff --git a/x-pack/plugins/ml/public/ui_actions/triggers.ts b/x-pack/plugins/ml/public/ui_actions/triggers.ts new file mode 100644 index 0000000000000..8a8b2602573a1 --- /dev/null +++ b/x-pack/plugins/ml/public/ui_actions/triggers.ts @@ -0,0 +1,17 @@ +/* + * 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 { Trigger } from '../../../../../src/plugins/ui_actions/public'; + +export const SWIM_LANE_SELECTION_TRIGGER = 'SWIM_LANE_SELECTION_TRIGGER'; + +export const swimLaneSelectionTrigger: Trigger<'SWIM_LANE_SELECTION_TRIGGER'> = { + id: SWIM_LANE_SELECTION_TRIGGER, + // This is empty string to hide title of ui_actions context menu that appears + // when this trigger is executed. + title: '', + description: 'Swim lane selection triggered', +}; diff --git a/x-pack/plugins/ml/public/url_generator.test.ts b/x-pack/plugins/ml/public/url_generator.test.ts new file mode 100644 index 0000000000000..45e2932b7781a --- /dev/null +++ b/x-pack/plugins/ml/public/url_generator.test.ts @@ -0,0 +1,34 @@ +/* + * 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 { MlUrlGenerator } from './url_generator'; + +describe('MlUrlGenerator', () => { + const urlGenerator = new MlUrlGenerator({ + appBasePath: '/app/ml', + useHash: false, + }); + + it('should generate valid URL for the Anomaly Explorer page', async () => { + const url = await urlGenerator.createUrl({ + page: 'explorer', + jobIds: ['test-job'], + mlExplorerSwimlane: { viewByFromPage: 2, viewByPerPage: 20 }, + }); + expect(url).toBe( + '/app/ml#/explorer?_g=(ml:(jobIds:!(test-job)))&_a=(mlExplorerFilter:(),mlExplorerSwimlane:(viewByFromPage:2,viewByPerPage:20))' + ); + }); + + it('should throw an error in case the page is not provided', async () => { + expect.assertions(1); + + // @ts-ignore + await urlGenerator.createUrl({ jobIds: ['test-job'] }).catch((e) => { + expect(e.message).toEqual('Page type is not provided or unknown'); + }); + }); +}); diff --git a/x-pack/plugins/ml/public/url_generator.ts b/x-pack/plugins/ml/public/url_generator.ts new file mode 100644 index 0000000000000..65d5077e081a3 --- /dev/null +++ b/x-pack/plugins/ml/public/url_generator.ts @@ -0,0 +1,90 @@ +/* + * 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 { UrlGeneratorsDefinition } from '../../../../src/plugins/share/public'; +import { TimeRange } from '../../../../src/plugins/data/public'; +import { setStateToKbnUrl } from '../../../../src/plugins/kibana_utils/public'; +import { JobId } from '../../reporting/common/types'; +import { ExplorerAppState } from './application/explorer/explorer_dashboard_service'; + +export const ML_APP_URL_GENERATOR = 'ML_APP_URL_GENERATOR'; + +export interface ExplorerUrlState { + /** + * ML App Page + */ + page: 'explorer'; + /** + * Job IDs + */ + jobIds: JobId[]; + /** + * Optionally set the time range in the time picker. + */ + timeRange?: TimeRange; + /** + * Optional state for the swim lane + */ + mlExplorerSwimlane?: ExplorerAppState['mlExplorerSwimlane']; + mlExplorerFilter?: ExplorerAppState['mlExplorerFilter']; +} + +/** + * Union type of ML URL state based on page + */ +export type MlUrlGeneratorState = ExplorerUrlState; + +export interface ExplorerQueryState { + ml: { jobIds: JobId[] }; + time?: TimeRange; +} + +interface Params { + appBasePath: string; + useHash: boolean; +} + +export class MlUrlGenerator implements UrlGeneratorsDefinition { + constructor(private readonly params: Params) {} + + public readonly id = ML_APP_URL_GENERATOR; + + public readonly createUrl = async ({ page, ...params }: MlUrlGeneratorState): Promise => { + if (page === 'explorer') { + return this.createExplorerUrl(params); + } + throw new Error('Page type is not provided or unknown'); + }; + + /** + * Creates URL to the Anomaly Explorer page + */ + private createExplorerUrl({ + timeRange, + jobIds, + mlExplorerSwimlane = {}, + mlExplorerFilter = {}, + }: Omit): string { + const appState: ExplorerAppState = { + mlExplorerSwimlane, + mlExplorerFilter, + }; + + const queryState: ExplorerQueryState = { + ml: { + jobIds, + }, + }; + + if (timeRange) queryState.time = timeRange; + + let url = `${this.params.appBasePath}#/explorer`; + url = setStateToKbnUrl('_g', queryState, { useHash: false }, url); + url = setStateToKbnUrl('_a', appState, { useHash: false }, url); + + return url; + } +}