From 7a1c6524bd0890ea102c43fe1ed09c21fd74ffb2 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Thu, 2 Jul 2020 18:25:45 +0200 Subject: [PATCH 01/19] [ML] dragSelect as part of ExplorerSwimlane component --- .../application/explorer/anomaly_timeline.tsx | 221 +++++------------- .../explorer/explorer_dashboard_service.ts | 7 +- .../explorer/explorer_swimlane.test.tsx | 23 +- .../explorer/explorer_swimlane.tsx | 127 ++++++++-- 4 files changed, 173 insertions(+), 205 deletions(-) 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..ea7a24864e045 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,16 +21,12 @@ 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 { 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'; @@ -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) { @@ -284,86 +197,68 @@ export const AnomalyTimeline: FC = React.memo( -
+ filterActive={filterActive} + maskAll={maskAll} + timeBuckets={timeBuckets} + swimlaneData={overallSwimlaneData as OverallSwimlaneData} + swimlaneType={'overall'} + selection={selectedCells} + setSelectedCells={setSelectedCells} + onResize={(width) => explorerService.setSwimlaneContainerWidth(width)} + isLoading={loading} + noDataWarning={} + /> + + + + {viewBySwimlaneOptions.length > 0 && ( explorerService.setSwimlaneContainerWidth(width)} - isLoading={loading} - noDataWarning={} + 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_dashboard_service.ts b/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts index 1429bf0858361..66fe7def65e1f 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(); 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..a4fc87e43691f 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,14 +26,6 @@ jest.mock('d3', () => { }; }); -jest.mock('./explorer_dashboard_service', () => ({ - dragSelect$: { - subscribe: jest.fn(() => ({ - unsubscribe: jest.fn(), - })), - }, -})); - function getExplorerSwimlaneMocks() { const swimlaneData = ({ laneLabels: [] } as unknown) as OverallSwimlaneData; @@ -74,16 +65,14 @@ describe('ExplorerSwimlane', () => { test('Minimal initialization', () => { const mocks = getExplorerSwimlaneMocks(); - const swimlaneRenderDoneListener = jest.fn(); const wrapper = mountWithIntl( ); @@ -95,28 +84,23 @@ 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); }); test('Overall swimlane', () => { const mocks = getExplorerSwimlaneMocks(); - const swimlaneRenderDoneListener = jest.fn(); const wrapper = mountWithIntl( ); @@ -125,13 +109,10 @@ 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..3d385e118a083 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,16 @@ 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 { 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'; @@ -56,7 +57,6 @@ export interface ExplorerSwimlaneProps { filterActive?: boolean; maskAll?: boolean; timeBuckets: InstanceType; - swimlaneCellClick?: Function; swimlaneData: OverallSwimlaneData | ViewBySwimLaneData; swimlaneType: SwimlaneType; selection?: { @@ -64,8 +64,9 @@ export interface ExplorerSwimlaneProps { type: string; times: number[]; }; - swimlaneRenderDoneListener?: Function; + setSelectedCells: Function; tooltipService: ChartTooltipService; + 'data-test-subj'?: string; } export class ExplorerSwimlane extends React.Component { @@ -78,13 +79,63 @@ 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[]; + }>(); + + /** + * Initialize drag select instance + */ + dragSelect = 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) { + this.dragSelect$.next({ + action: DRAG_SELECT_ACTION.NEW_SELECTION, + elements, + }); + } + + this.disableDragSelectOnMouseLeave = 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) { + 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 +205,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 +235,7 @@ export class ExplorerSwimlane extends React.Component { } if (triggerNewSelection === false) { - swimlaneCellClick({}); + this.swimlaneCellClick({}); return; } @@ -194,7 +245,7 @@ export class ExplorerSwimlane extends React.Component { times: d3.extent(times), type: swimlaneType, }; - swimlaneCellClick(selectedCells); + this.swimlaneCellClick(selectedCells); } highlightOverall(times: number[]) { @@ -288,7 +339,6 @@ export class ExplorerSwimlane extends React.Component { filterActive, maskAll, timeBuckets, - swimlaneCellClick, swimlaneData, swimlaneType, selection, @@ -413,8 +463,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 +617,11 @@ 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 (this.props.swimlaneRenderDoneListener) { + // this.props.swimlaneRenderDoneListener(); + // } if ( (swimlaneType !== selectedType || @@ -632,9 +684,54 @@ 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: 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) { + this.props.setSelectedCells(); + } else { + this.props.setSelectedCells(selectedCellsUpdate); + } + } + + /** + * Listens to render updates of the swim lanes to update dragSelect + */ + swimlaneRenderDoneListener() { + this.dragSelect.clearSelection(); + this.dragSelect.setSelectables(document.querySelectorAll('.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('.sl-cell')); + this.isSwimlaneSelectActive = active; + } + } + render() { const { swimlaneType } = this.props; - return
; + return ( +
+
+
+ ); } } From 2179b300c14804229a165035b93f7551ca17f91f Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Fri, 3 Jul 2020 10:46:04 +0200 Subject: [PATCH 02/19] [ML] use wrapper ref --- .../explorer/explorer_swimlane.tsx | 15 ++++- .../explorer/swimlane_container.tsx | 61 ++++++++++--------- 2 files changed, 45 insertions(+), 31 deletions(-) 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 3d385e118a083..3d1c527102134 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx @@ -67,6 +67,12 @@ export interface ExplorerSwimlaneProps { setSelectedCells: Function; 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 { @@ -408,9 +414,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(''); @@ -725,7 +734,7 @@ export class ExplorerSwimlane extends React.Component { return (
{ 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 && ( Date: Fri, 3 Jul 2020 10:51:20 +0200 Subject: [PATCH 03/19] [ML] rename callback --- .../ml/public/application/explorer/anomaly_timeline.tsx | 4 ++-- .../ml/public/application/explorer/explorer_swimlane.tsx | 6 +++--- .../ml/public/application/explorer/swimlane_container.tsx | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) 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 ea7a24864e045..320e250481b49 100644 --- a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx @@ -205,7 +205,7 @@ export const AnomalyTimeline: FC = React.memo( swimlaneData={overallSwimlaneData as OverallSwimlaneData} swimlaneType={'overall'} selection={selectedCells} - setSelectedCells={setSelectedCells} + onCellsSelection={setSelectedCells} onResize={(width) => explorerService.setSwimlaneContainerWidth(width)} isLoading={loading} noDataWarning={} @@ -228,7 +228,7 @@ export const AnomalyTimeline: FC = React.memo( swimlaneData={viewBySwimlaneData as ViewBySwimLaneData} swimlaneType={SWIMLANE_TYPE.VIEW_BY} selection={selectedCells} - setSelectedCells={setSelectedCells} + onCellsSelection={setSelectedCells} onResize={(width) => explorerService.setSwimlaneContainerWidth(width)} fromPage={viewByFromPage} perPage={viewByPerPage} 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 3d1c527102134..c8d5d56609761 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx @@ -64,7 +64,7 @@ export interface ExplorerSwimlaneProps { type: string; times: number[]; }; - setSelectedCells: Function; + onCellsSelection: Function; tooltipService: ChartTooltipService; 'data-test-subj'?: string; /** @@ -701,9 +701,9 @@ export class ExplorerSwimlane extends React.Component { // 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) { - this.props.setSelectedCells(); + this.props.onCellsSelection(); } else { - this.props.setSelectedCells(selectedCellsUpdate); + this.props.onCellsSelection(selectedCellsUpdate); } } 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 233bbf05c16ae..51ea0f00d5f6a 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -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; From 52807cc5aafa23ec592a172de74d6204d9613806 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Fri, 3 Jul 2020 16:27:39 +0200 Subject: [PATCH 04/19] [ML] WIP open in anomaly explorer --- x-pack/plugins/ml/public/application/app.tsx | 2 +- .../anomaly_swimlane_embeddable.tsx | 5 + x-pack/plugins/ml/public/plugin.ts | 25 +++- .../ui_actions/edit_swimlane_panel_action.tsx | 16 +-- x-pack/plugins/ml/public/ui_actions/index.ts | 10 +- .../open_in_anomaly_explorer_action.tsx | 58 +++++++++ x-pack/plugins/ml/public/url_generator.ts | 123 ++++++++++++++++++ 7 files changed, 224 insertions(+), 15 deletions(-) create mode 100644 x-pack/plugins/ml/public/ui_actions/open_in_anomaly_explorer_action.tsx create mode 100644 x-pack/plugins/ml/public/url_generator.ts diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 9d5125532e5b8..c3e433a32fd06 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; +type MlDependencies = Omit & MlStartDependencies; interface AppProps { coreStart: CoreStart; 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..ad581b2a7a915 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,6 +14,7 @@ import { EmbeddableInput, EmbeddableOutput, IContainer, + IEmbeddable, } from '../../../../../../src/plugins/embeddable/public'; import { MlStartDependencies } from '../../plugin'; import { EmbeddableSwimLaneContainer } from './embeddable_swim_lane_container'; @@ -49,6 +50,10 @@ export interface AnomalySwimlaneEmbeddableCustomInput { timeRange: TimeRange; } +export interface EditSwimlanePanelContext { + embeddable: IEmbeddable; +} + export type AnomalySwimlaneEmbeddableInput = EmbeddableInput & AnomalySwimlaneEmbeddableCustomInput; export type AnomalySwimlaneEmbeddableOutput = EmbeddableOutput & diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 7f7544a44efa7..cb6cf36dd5446 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'; @@ -31,6 +31,7 @@ import { registerEmbeddables } from './embeddables'; import { UiActionsSetup } 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; @@ -47,13 +48,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', { @@ -113,6 +131,7 @@ export class MlPlugin implements Plugin { }); return {}; } + public stop() {} } 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..016158dcbf4a7 100644 --- a/x-pack/plugins/ml/public/ui_actions/index.ts +++ b/x-pack/plugins/ml/public/ui_actions/index.ts @@ -8,8 +8,12 @@ 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'; @@ -19,12 +23,16 @@ export function registerMlUiActions( core: CoreSetup ) { const editSwimlanePanelAction = createEditSwimlanePanelAction(core.getStartServices); + const openInExplorerAction = createOpenInExplorerAction(core.getStartServices); uiActions.registerAction(editSwimlanePanelAction); + uiActions.registerAction(openInExplorerAction); uiActions.attachAction(CONTEXT_MENU_TRIGGER, editSwimlanePanelAction.id); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, openInExplorerAction.id); } declare module '../../../../../src/plugins/ui_actions/public' { export interface ActionContextMapping { [EDIT_SWIMLANE_PANEL_ACTION]: EditSwimlanePanelContext; + [OPEN_IN_ANOMALY_EXPLORER_ACTION]: EditSwimlanePanelContext; } } 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..740836aa76174 --- /dev/null +++ b/x-pack/plugins/ml/public/ui_actions/open_in_anomaly_explorer_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 { ActionContextMapping, createAction } from '../../../../../src/plugins/ui_actions/public'; +import { + AnomalySwimlaneEmbeddable, + EditSwimlanePanelContext, +} from '../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; +import { ViewMode } from '../../../../../src/plugins/embeddable/public'; +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: () => + i18n.translate('xpack.ml.actions.openInAnomalyExplorerTitle', { + defaultMessage: 'Open in Anomaly Explorer', + }), + async getHref({ embeddable }: EditSwimlanePanelContext): Promise { + const [, pluginsStart] = await getStartServices(); + const urlGenerator = pluginsStart.share.urlGenerators.getUrlGenerator(ML_APP_URL_GENERATOR); + const { perPage, jobIds, query, filters, timeRange } = embeddable.getInput(); + return urlGenerator.createUrl({ + page: 'explorer', + jobIds, + query, + filters, + timeRange, + viewByPerPage: perPage, + }); + }, + async execute({ embeddable }: EditSwimlanePanelContext) { + 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 }); + await application.navigateToUrl(anomalyExplorerUrl!); + }, + isCompatible: async ({ embeddable }: EditSwimlanePanelContext) => { + return ( + embeddable instanceof AnomalySwimlaneEmbeddable && + embeddable.getInput().viewMode === ViewMode.VIEW + ); + }, + }); +} 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..593eed293fec8 --- /dev/null +++ b/x-pack/plugins/ml/public/url_generator.ts @@ -0,0 +1,123 @@ +/* + * 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, + Filter, + Query, + esFilters, + QueryState, + RefreshInterval, +} from '../../../../src/plugins/data/public'; +import { setStateToKbnUrl } from '../../../../src/plugins/kibana_utils/public'; +import { JobId } from '../../reporting/common/types'; + +export const ML_APP_URL_GENERATOR = 'ML_APP_URL_GENERATOR'; + +export interface ExplorerUrlState { + /** + * ML App Page + */ + page: 'explorer'; + /** + * Job IDs + */ + jobIds: JobId[]; + /** + * Page size of a swim lane. + */ + viewByPerPage?: number; + /** + * Optionally set the time range in the time picker. + */ + timeRange?: TimeRange; + + /** + * Optionally set the refresh interval. + */ + refreshInterval?: RefreshInterval; + + /** + * Optionally apply filers. + */ + filters?: Filter[]; + + /** + * Optionally set a query. + */ + query?: Query; + + /** + * If not given, will use the uiSettings configuration for `storeInSessionStorage`. useHash determines + * whether to hash the data in the url to avoid url length issues. + */ + useHash?: boolean; +} + +/** + * Union type of ML URL state based on page + */ +export type MlUrlGeneratorState = ExplorerUrlState; + +export type ExplorerQueryState = QueryState & { ml: { jobIds: JobId[] } }; + +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, + query, + filters, + refreshInterval, + useHash = false, + }: Omit): string { + const appState: { + query?: Query; + filters?: Filter[]; + index?: string; + } = {}; + + const queryState: ExplorerQueryState = { + ml: { + jobIds, + }, + }; + + if (query) appState.query = query; + if (filters && filters.length) + appState.filters = filters?.filter((f) => !esFilters.isFilterPinned(f)); + + if (timeRange) queryState.time = timeRange; + if (filters && filters.length) + queryState.filters = filters?.filter((f) => esFilters.isFilterPinned(f)); + if (refreshInterval) queryState.refreshInterval = refreshInterval; + + let url = `${this.params.appBasePath}#/explorer`; + url = setStateToKbnUrl('_g', queryState, { useHash }, url); + url = setStateToKbnUrl('_a', appState, { useHash }, url); + + return url; + } +} From 19a78c07b976ea283d2dc70019105d2f168aa8cb Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Fri, 3 Jul 2020 17:20:05 +0200 Subject: [PATCH 05/19] [ML] MlUrlGenerator unit tests --- .../plugins/ml/public/url_generator.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 x-pack/plugins/ml/public/url_generator.test.ts 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..7f76d29649e5a --- /dev/null +++ b/x-pack/plugins/ml/public/url_generator.test.ts @@ -0,0 +1,28 @@ +/* + * 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'] }); + expect(url).toBe('/app/ml#/explorer?_g=(ml:(jobIds:!(test-job)))&_a=()'); + }); + + 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'); + }); + }); +}); From 293a9f1e908e2b498019a951e78583e3ba2fe936 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Tue, 7 Jul 2020 11:34:41 +0200 Subject: [PATCH 06/19] [ML] WIP actions --- x-pack/plugins/ml/public/application/app.tsx | 2 +- .../explorer/explorer_dashboard_service.ts | 2 +- .../explorer/explorer_swimlane.tsx | 12 ++--- .../application/explorer/explorer_utils.d.ts | 7 +-- .../services/anomaly_timeline_service.ts | 4 +- .../anomaly_swimlane_embeddable.tsx | 20 ++++++-- .../anomaly_swimlane_embeddable_factory.ts | 4 +- .../embeddable_swim_lane_container.tsx | 34 +++++++++++-- x-pack/plugins/ml/public/plugin.ts | 11 ++-- .../ui_actions/apply_current_view_action.tsx | 50 +++++++++++++++++++ x-pack/plugins/ml/public/ui_actions/index.ts | 28 ++++++++++- .../open_in_anomaly_explorer_action.tsx | 29 +++++++---- .../plugins/ml/public/ui_actions/triggers.ts | 17 +++++++ x-pack/plugins/ml/public/url_generator.ts | 24 ++++----- 14 files changed, 191 insertions(+), 53 deletions(-) create mode 100644 x-pack/plugins/ml/public/ui_actions/apply_current_view_action.tsx create mode 100644 x-pack/plugins/ml/public/ui_actions/triggers.ts diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index c3e433a32fd06..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 = Omit & MlStartDependencies; +export type MlDependencies = Omit & MlStartDependencies; interface AppProps { coreStart: CoreStart; 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 66fe7def65e1f..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 @@ -49,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.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx index c8d5d56609761..70fd5959004f7 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx @@ -30,7 +30,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', @@ -64,7 +64,7 @@ export interface ExplorerSwimlaneProps { type: string; times: number[]; }; - onCellsSelection: Function; + onCellsSelection: (payload?: AppStateSelectedCells) => void; tooltipService: ChartTooltipService; 'data-test-subj'?: string; /** @@ -241,7 +241,7 @@ export class ExplorerSwimlane extends React.Component { } if (triggerNewSelection === false) { - this.swimlaneCellClick({}); + this.swimlaneCellClick(); return; } @@ -473,7 +473,7 @@ export class ExplorerSwimlane extends React.Component { }) .on('click', () => { if (selection && typeof selection.lanes !== 'undefined') { - this.swimlaneCellClick({}); + this.swimlaneCellClick(); } }) .each(function (this: HTMLElement) { @@ -697,10 +697,10 @@ export class ExplorerSwimlane extends React.Component { * Listener for click events in the swim lane and execute a prop callback. * @param selectedCellsUpdate */ - swimlaneCellClick(selectedCellsUpdate: any) { + 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 (Object.keys(selectedCellsUpdate).length === 0) { + if (!selectedCellsUpdate) { this.props.onCellsSelection(); } else { this.props.onCellsSelection(selectedCellsUpdate); 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/services/anomaly_timeline_service.ts b/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts index f2e362f754f2b..da7a7d4e9854e 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 @@ -302,7 +302,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 +346,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 ad581b2a7a915..f765b3cf90fe8 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 @@ -16,7 +16,6 @@ import { 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'; @@ -28,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'; @@ -54,6 +56,13 @@ export interface EditSwimlanePanelContext { embeddable: IEmbeddable; } +export interface SwimLaneDrilldownContext extends EditSwimlanePanelContext { + /** + * Optional data provided by swim lane selection + */ + data?: AppStateSelectedCells & { interval: number }; +} + export type AnomalySwimlaneEmbeddableInput = EmbeddableInput & AnomalySwimlaneEmbeddableCustomInput; export type AnomalySwimlaneEmbeddableOutput = EmbeddableOutput & @@ -73,7 +82,7 @@ export interface AnomalySwimlaneServices { export type AnomalySwimlaneEmbeddableServices = [ CoreStart, - MlStartDependencies, + MlDependencies, AnomalySwimlaneServices ]; @@ -87,7 +96,7 @@ export class AnomalySwimlaneEmbeddable extends Embeddable< constructor( initialInput: AnomalySwimlaneEmbeddableInput, - private services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices], + public services: [CoreStart, MlDependencies, AnomalySwimlaneServices], parent?: IContainer ) { super( @@ -112,6 +121,7 @@ export class AnomalySwimlaneEmbeddable extends Embeddable< { public readonly type = ANOMALY_SWIMLANE_EMBEDDABLE_TYPE; - constructor(private getStartServices: StartServicesAccessor) {} + constructor(private getStartServices: StartServicesAccessor) {} public async isEditable() { return true; 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..7be6fcdad4c24 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 } 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,17 +22,22 @@ 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; } export const EmbeddableSwimLaneContainer: FC = ({ id, + embeddableContext, embeddableInput, services, refresh, @@ -41,6 +46,10 @@ export const EmbeddableSwimLaneContainer: FC = ( const [chartWidth, setChartWidth] = useState(0); const [fromPage, setFromPage] = useState(1); + const [{}, { uiActions }] = services; + + const [selectedCells, setSelectedCells] = useState(); + const [ swimlaneType, swimlaneData, @@ -58,6 +67,23 @@ export const EmbeddableSwimLaneContainer: FC = ( fromPage ); + const onCellsSelection = useCallback( + (update?: AppStateSelectedCells) => { + setSelectedCells(update); + + if (update) { + uiActions.getTrigger(SWIM_LANE_SELECTION_TRIGGER).exec({ + embeddable: embeddableContext, + data: { + ...update, + interval: swimlaneData?.interval, + }, + }); + } + }, + [swimlaneData] + ); + if (error) { return ( = ( onResize={(width) => { setChartWidth(width); }} + selection={selectedCells} + onCellsSelection={onCellsSelection} onPaginationChange={(update) => { if (update.fromPage) { setFromPage(update.fromPage); diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index cb6cf36dd5446..449d8baa2a184 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -28,7 +28,7 @@ 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'; @@ -37,6 +37,7 @@ export interface MlStartDependencies { data: DataPublicPluginStart; share: SharePluginStart; kibanaLegacy: KibanaLegacyStart; + uiActions: UiActionsStart; } export interface MlSetupDependencies { security?: SecurityPluginSetup; @@ -51,7 +52,7 @@ export interface MlSetupDependencies { share: SharePluginSetup; } -declare module 'src/plugins/share/public' { +declare module '../../../../src/plugins/share/public' { export interface UrlGeneratorStateMapping { [ML_APP_URL_GENERATOR]: UrlGeneratorState; } @@ -98,7 +99,7 @@ export class MlPlugin implements Plugin { licenseManagement: pluginsSetup.licenseManagement, home: pluginsSetup.home, embeddable: pluginsSetup.embeddable, - uiActions: pluginsSetup.uiActions, + uiActions: pluginsStart.uiActions, kibanaVersion, }, { @@ -114,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 {}; } diff --git a/x-pack/plugins/ml/public/ui_actions/apply_current_view_action.tsx b/x-pack/plugins/ml/public/ui_actions/apply_current_view_action.tsx new file mode 100644 index 0000000000000..f3d28f69aaa8c --- /dev/null +++ b/x-pack/plugins/ml/public/ui_actions/apply_current_view_action.tsx @@ -0,0 +1,50 @@ +/* + * 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_TO_CURRENT_VIEW_ACTION = 'openInAnomalyExplorerAction'; + +export function createApplyToCurrentViewAction(getStartServices: MlCoreSetup['getStartServices']) { + return createAction({ + id: 'apply-to-current-view', + type: APPLY_TO_CURRENT_VIEW_ACTION, + getIconType(context: ActionContextMapping[typeof APPLY_TO_CURRENT_VIEW_ACTION]): string { + return 'filter'; + }, + getDisplayName: () => + i18n.translate('xpack.ml.actions.applyToCurrentViewTitle', { + defaultMessage: 'Apply to current view', + }), + 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; + + let [from, to] = data.times; + from = from * 1000; + to = to * 1000; + + timefilter.setTime({ + from: moment(from), + to: moment(from === to ? to + data.interval * 1000 : to), + mode: 'absolute', + }); + }, + async isCompatible({ embeddable, data }: SwimLaneDrilldownContext) { + return embeddable instanceof AnomalySwimlaneEmbeddable && data !== undefined; + }, + }); +} diff --git a/x-pack/plugins/ml/public/ui_actions/index.ts b/x-pack/plugins/ml/public/ui_actions/index.ts index 016158dcbf4a7..31b7368cba443 100644 --- a/x-pack/plugins/ml/public/ui_actions/index.ts +++ b/x-pack/plugins/ml/public/ui_actions/index.ts @@ -17,22 +17,48 @@ import { EditSwimlanePanelContext } from '../embeddables/anomaly_swimlane/anomal import { UiActionsSetup } from '../../../../../src/plugins/ui_actions/public'; import { MlPluginStart, MlStartDependencies } from '../plugin'; import { CONTEXT_MENU_TRIGGER } from '../../../../../src/plugins/embeddable/public'; +import { + APPLY_TO_CURRENT_VIEW_ACTION, + createApplyToCurrentViewAction, +} from './apply_current_view_action'; +import { SWIM_LANE_SELECTION_TRIGGER, swimLaneSelectionTrigger } from './triggers'; +import { SwimLaneDrilldownContext } from '../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; +/** + * Register ML UI actions + */ export function registerMlUiActions( uiActions: UiActionsSetup, core: CoreSetup ) { + // Initialize actions const editSwimlanePanelAction = createEditSwimlanePanelAction(core.getStartServices); const openInExplorerAction = createOpenInExplorerAction(core.getStartServices); + const applyToCurrentViewAction = createApplyToCurrentViewAction(core.getStartServices); + + // Register actions uiActions.registerAction(editSwimlanePanelAction); uiActions.registerAction(openInExplorerAction); + uiActions.registerAction(applyToCurrentViewAction); + + // 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, applyToCurrentViewAction); + 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]: EditSwimlanePanelContext; + [OPEN_IN_ANOMALY_EXPLORER_ACTION]: SwimLaneDrilldownContext; + [APPLY_TO_CURRENT_VIEW_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 index 740836aa76174..cc729e0d93752 100644 --- 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 @@ -8,9 +8,8 @@ import { i18n } from '@kbn/i18n'; import { ActionContextMapping, createAction } from '../../../../../src/plugins/ui_actions/public'; import { AnomalySwimlaneEmbeddable, - EditSwimlanePanelContext, + SwimLaneDrilldownContext, } from '../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; -import { ViewMode } from '../../../../../src/plugins/embeddable/public'; import { MlCoreSetup } from '../plugin'; import { ML_APP_URL_GENERATOR } from '../url_generator'; @@ -27,7 +26,7 @@ export function createOpenInExplorerAction(getStartServices: MlCoreSetup['getSta i18n.translate('xpack.ml.actions.openInAnomalyExplorerTitle', { defaultMessage: 'Open in Anomaly Explorer', }), - async getHref({ embeddable }: EditSwimlanePanelContext): Promise { + async getHref({ embeddable, data }: SwimLaneDrilldownContext): Promise { const [, pluginsStart] = await getStartServices(); const urlGenerator = pluginsStart.share.urlGenerators.getUrlGenerator(ML_APP_URL_GENERATOR); const { perPage, jobIds, query, filters, timeRange } = embeddable.getInput(); @@ -37,22 +36,30 @@ export function createOpenInExplorerAction(getStartServices: MlCoreSetup['getSta query, filters, timeRange, - viewByPerPage: perPage, + mlExplorerSwimlane: { + viewByPerPage: perPage, + viewByFromPage: 1, + ...(data + ? { + selectedType: data.type, + selectedTimes: data.times, + selectedLanes: data.lanes, + viewByFieldName: data.viewByFieldName, + } + : {}), + }, }); }, - async execute({ embeddable }: EditSwimlanePanelContext) { + 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 }); + const anomalyExplorerUrl = await this.getHref!({ embeddable, data }); await application.navigateToUrl(anomalyExplorerUrl!); }, - isCompatible: async ({ embeddable }: EditSwimlanePanelContext) => { - return ( - embeddable instanceof AnomalySwimlaneEmbeddable && - embeddable.getInput().viewMode === ViewMode.VIEW - ); + 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.ts b/x-pack/plugins/ml/public/url_generator.ts index 593eed293fec8..493b84d378b8b 100644 --- a/x-pack/plugins/ml/public/url_generator.ts +++ b/x-pack/plugins/ml/public/url_generator.ts @@ -15,6 +15,7 @@ import { } 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'; @@ -27,15 +28,14 @@ export interface ExplorerUrlState { * Job IDs */ jobIds: JobId[]; - /** - * Page size of a swim lane. - */ - viewByPerPage?: number; /** * Optionally set the time range in the time picker. */ timeRange?: TimeRange; - + /** + * Optional state for the swim lane + */ + mlExplorerSwimlane?: ExplorerAppState['mlExplorerSwimlane']; /** * Optionally set the refresh interval. */ @@ -92,12 +92,12 @@ export class MlUrlGenerator implements UrlGeneratorsDefinition): string { - const appState: { - query?: Query; - filters?: Filter[]; - index?: string; - } = {}; + const appState: ExplorerAppState = { + mlExplorerSwimlane, + mlExplorerFilter: {}, + }; const queryState: ExplorerQueryState = { ml: { @@ -105,10 +105,6 @@ export class MlUrlGenerator implements UrlGeneratorsDefinition !esFilters.isFilterPinned(f)); - if (timeRange) queryState.time = timeRange; if (filters && filters.length) queryState.filters = filters?.filter((f) => esFilters.isFilterPinned(f)); From 63bba93e19180d6ce0f26556c7b4878394e43c09 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Wed, 8 Jul 2020 15:07:08 +0200 Subject: [PATCH 07/19] [ML] restore pagination --- .../job_selector/use_job_selection.ts | 64 ++++++++++--------- .../application/routing/routes/explorer.tsx | 28 +++++--- .../anomaly_swimlane_embeddable.tsx | 9 +-- .../embeddable_swim_lane_container.tsx | 26 +++++--- .../open_in_anomaly_explorer_action.tsx | 6 +- 5 files changed, 79 insertions(+), 54 deletions(-) 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..97af1434ce671 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,55 @@ 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 tmpIds = useMemo(() => { + const ids = globalState?.ml?.jobIds || []; + return (typeof ids === 'string' ? [ids] : ids).map((id: string) => String(id)); + }, [globalState?.ml?.jobIds]); + + const invalidIds = useMemo(() => { + return getInvalidJobIds(jobs, tmpIds); + }, [tmpIds]); - const jobSelection: JobSelection = { jobIds: [], selectedGroups: [] }; + const validIds = useMemo(() => { + const res = difference(tmpIds, invalidIds); + res.sort(); + return res; + }, [tmpIds, invalidIds]); - 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 jobSelection: JobSelection = useMemo(() => { + const jobIds = validIds; + const selectedGroups = globalState?.ml?.groups ?? []; - jobSelection.jobIds = validIds; - jobSelection.selectedGroups = globalState?.ml?.groups ?? []; + return { jobIds, 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/routing/routes/explorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx index 7a7865c9bd738..5c22a440a103e 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx @@ -20,7 +20,7 @@ import { useSelectedCells } from '../../explorer/hooks/use_selected_cells'; import { mlJobService } from '../../services/job_service'; import { ml } from '../../services/ml_api_service'; import { useExplorerData } from '../../explorer/actions'; -import { explorerService } from '../../explorer/explorer_dashboard_service'; +import { ExplorerAppState, explorerService } from '../../explorer/explorer_dashboard_service'; import { getDateFormatTz } from '../../explorer/explorer_utils'; import { useJobSelection } from '../../components/job_selector/use_job_selection'; import { useShowCharts } from '../../components/controls/checkbox_showcharts'; @@ -72,7 +72,7 @@ const ExplorerUrlStateManager: FC = ({ 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/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx index f765b3cf90fe8..fc8add28ac760 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 @@ -60,7 +60,7 @@ export interface SwimLaneDrilldownContext extends EditSwimlanePanelContext { /** * Optional data provided by swim lane selection */ - data?: AppStateSelectedCells & { interval: number }; + data?: AppStateSelectedCells; } export type AnomalySwimlaneEmbeddableInput = EmbeddableInput & AnomalySwimlaneEmbeddableCustomInput; @@ -73,6 +73,8 @@ export interface AnomalySwimlaneEmbeddableCustomOutput { swimlaneType: SwimlaneType; viewBy?: string; perPage?: number; + fromPage?: number; + interval?: number; } export interface AnomalySwimlaneServices { @@ -125,9 +127,8 @@ export class AnomalySwimlaneEmbeddable extends Embeddable< embeddableInput={this.getInput$()} services={this.services} refresh={this.reload$.asObservable()} - onInputChange={(input) => { - this.updateInput(input); - }} + onInputChange={this.updateInput.bind(this)} + onOutputChange={this.updateOutput.bind(this)} /> , node 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 7be6fcdad4c24..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,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, useState, useEffect } from 'react'; import { EuiCallOut } from '@elastic/eui'; import { Observable } from 'rxjs'; @@ -32,7 +32,8 @@ export interface ExplorerSwimlaneContainerProps { embeddableInput: Observable; services: [CoreStart, MlDependencies, AnomalySwimlaneServices]; refresh: Observable; - onInputChange: (output: Partial) => void; + onInputChange: (input: Partial) => void; + onOutputChange: (output: Partial) => void; } export const EmbeddableSwimLaneContainer: FC = ({ @@ -42,6 +43,7 @@ export const EmbeddableSwimLaneContainer: FC = ( services, refresh, onInputChange, + onOutputChange, }) => { const [chartWidth, setChartWidth] = useState(0); const [fromPage, setFromPage] = useState(1); @@ -67,6 +69,14 @@ export const EmbeddableSwimLaneContainer: FC = ( fromPage ); + useEffect(() => { + onOutputChange({ + perPage, + fromPage, + interval: swimlaneData?.interval, + }); + }, [perPage, fromPage, swimlaneData]); + const onCellsSelection = useCallback( (update?: AppStateSelectedCells) => { setSelectedCells(update); @@ -74,14 +84,11 @@ export const EmbeddableSwimLaneContainer: FC = ( if (update) { uiActions.getTrigger(SWIM_LANE_SELECTION_TRIGGER).exec({ embeddable: embeddableContext, - data: { - ...update, - interval: swimlaneData?.interval, - }, + data: update, }); } }, - [swimlaneData] + [swimlaneData, perPage, fromPage] ); if (error) { @@ -108,15 +115,14 @@ export const EmbeddableSwimLaneContainer: FC = ( data-test-subj="mlAnomalySwimlaneEmbeddableWrapper" > { - setChartWidth(width); - }} + onResize={setChartWidth} selection={selectedCells} onCellsSelection={onCellsSelection} onPaginationChange={(update) => { 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 index cc729e0d93752..f8d392c52d1ab 100644 --- 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 @@ -29,7 +29,9 @@ export function createOpenInExplorerAction(getStartServices: MlCoreSetup['getSta async getHref({ embeddable, data }: SwimLaneDrilldownContext): Promise { const [, pluginsStart] = await getStartServices(); const urlGenerator = pluginsStart.share.urlGenerators.getUrlGenerator(ML_APP_URL_GENERATOR); - const { perPage, jobIds, query, filters, timeRange } = embeddable.getInput(); + const { jobIds, query, filters, timeRange } = embeddable.getInput(); + const { perPage, fromPage } = embeddable.getOutput(); + return urlGenerator.createUrl({ page: 'explorer', jobIds, @@ -37,8 +39,8 @@ export function createOpenInExplorerAction(getStartServices: MlCoreSetup['getSta filters, timeRange, mlExplorerSwimlane: { + viewByFromPage: fromPage, viewByPerPage: perPage, - viewByFromPage: 1, ...(data ? { selectedType: data.type, From 3359c84706c585940428f8be0d7dda6d1396d76c Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Wed, 8 Jul 2020 16:42:59 +0200 Subject: [PATCH 08/19] [ML] fix fromPage on initial load --- .../reducers/explorer_reducer/job_selection_change.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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..805d6ce22d1ac 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 @@ -17,7 +17,10 @@ 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 ? state.viewByFromPage : 1, }; // clear filter if selected jobs have no influencers From ec75f9fc540fed45c4528c61c6e8a50c98b05c41 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Thu, 9 Jul 2020 14:45:49 +0200 Subject: [PATCH 09/19] [ML] fix cell selection, filter and time range actions --- .../application/explorer/anomaly_timeline.tsx | 25 ++++-- .../explorer/explorer_constants.ts | 5 ++ .../explorer/explorer_swimlane.tsx | 29 ++++--- .../services/anomaly_timeline_service.ts | 7 +- .../anomaly_swimlane_embeddable.tsx | 6 -- .../embeddable_swim_lane_container.test.tsx | 11 ++- .../swimlane_input_resolver.ts | 5 +- .../apply_influencer_filters_action.tsx | 83 +++++++++++++++++++ ...action.tsx => apply_time_range_action.tsx} | 31 ++++--- x-pack/plugins/ml/public/ui_actions/index.ts | 22 +++-- 10 files changed, 174 insertions(+), 50 deletions(-) create mode 100644 x-pack/plugins/ml/public/ui_actions/apply_influencer_filters_action.tsx rename x-pack/plugins/ml/public/ui_actions/{apply_current_view_action.tsx => apply_time_range_action.tsx} (58%) 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 320e250481b49..45dada84de20a 100644 --- a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx @@ -21,7 +21,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { 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'; @@ -31,7 +31,7 @@ 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[]) { @@ -106,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 ( <> @@ -203,10 +216,10 @@ export const AnomalyTimeline: FC = React.memo( maskAll={maskAll} timeBuckets={timeBuckets} swimlaneData={overallSwimlaneData as OverallSwimlaneData} - swimlaneType={'overall'} - selection={selectedCells} + swimlaneType={SWIMLANE_TYPE.OVERALL} + selection={overallCellSelection} onCellsSelection={setSelectedCells} - onResize={(width) => explorerService.setSwimlaneContainerWidth(width)} + onResize={explorerService.setSwimlaneContainerWidth} isLoading={loading} noDataWarning={} /> @@ -229,7 +242,7 @@ export const AnomalyTimeline: FC = React.memo( swimlaneType={SWIMLANE_TYPE.VIEW_BY} selection={selectedCells} onCellsSelection={setSelectedCells} - onResize={(width) => explorerService.setSwimlaneContainerWidth(width)} + onResize={explorerService.setSwimlaneContainerWidth} fromPage={viewByFromPage} perPage={viewByPerPage} swimlaneLimit={swimlaneLimit} 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_swimlane.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx index 70fd5959004f7..d63e1d80670e5 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx @@ -18,6 +18,7 @@ import DragSelect from 'dragselect'; import { i18n } from '@kbn/i18n'; 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'; @@ -94,12 +95,17 @@ export class ExplorerSwimlane extends React.Component { elements?: any[]; }>(); + /** + * Unique id for swim lane instance + */ + rootNodeId = htmlIdGenerator()(); + /** * Initialize drag select instance */ dragSelect = new DragSelect({ selectorClass: 'ml-swimlane-selector', - selectables: document.querySelectorAll('.sl-cell'), + selectables: document.querySelectorAll(`#${this.rootNodeId} .sl-cell`), callback: (elements) => { if (elements.length > 1 && !ALLOW_CELL_RANGE_SELECTION) { elements = [elements[0]]; @@ -265,10 +271,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 @@ -289,11 +293,6 @@ 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); - } } maskIrrelevantSwimlanes(maskAll: boolean) { @@ -712,7 +711,7 @@ export class ExplorerSwimlane extends React.Component { */ swimlaneRenderDoneListener() { this.dragSelect.clearSelection(); - this.dragSelect.setSelectables(document.querySelectorAll('.sl-cell')); + this.dragSelect.setSelectables(document.querySelectorAll(`#${this.rootNodeId} .sl-cell`)); } setSwimlaneSelectActive(active: boolean) { @@ -724,7 +723,7 @@ export class ExplorerSwimlane extends React.Component { if (!this.isSwimlaneSelectActive && active) { this.dragSelect.start(); this.dragSelect.clearSelection(); - this.dragSelect.setSelectables(document.querySelectorAll('.sl-cell')); + this.dragSelect.setSelectables(document.querySelectorAll(`#${this.rootNodeId} .sl-cell`)); this.isSwimlaneSelectActive = active; } } @@ -739,7 +738,11 @@ export class ExplorerSwimlane extends React.Component { onMouseLeave={this.setSwimlaneSelectActive.bind(this, false)} data-test-subj={this.props['data-test-subj'] ?? null} > -
+
); } 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 da7a7d4e9854e..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: [], 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 fc8add28ac760..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 @@ -69,9 +69,6 @@ export type AnomalySwimlaneEmbeddableOutput = EmbeddableOutput & AnomalySwimlaneEmbeddableCustomOutput; export interface AnomalySwimlaneEmbeddableCustomOutput { - jobIds: JobId[]; - swimlaneType: SwimlaneType; - viewBy?: string; perPage?: number; fromPage?: number; interval?: number; @@ -104,10 +101,7 @@ export class AnomalySwimlaneEmbeddable extends Embeddable< super( initialInput, { - jobIds: initialInput.jobIds, - swimlaneType: initialInput.swimlaneType, defaultTitle: initialInput.title, - ...(initialInput.viewBy ? { viewBy: initialInput.viewBy } : {}), }, parent ); 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..89c90eaf0b365 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 @@ -10,14 +10,15 @@ import { EmbeddableSwimLaneContainer } 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'; jest.mock('./swimlane_input_resolver', () => ({ useSwimlaneInputResolver: jest.fn(() => { @@ -37,8 +38,10 @@ const defaultOptions = { wrapper: I18nProvider }; describe('ExplorerSwimlaneContainer', () => { let embeddableInput: BehaviorSubject>; let refresh: BehaviorSubject; - let services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices]; + let services: [CoreStart, MlDependencies, AnomalySwimlaneServices]; + let embeddableContext: AnomalySwimlaneEmbeddable; const onInputChange = jest.fn(); + const onOutputChange = jest.fn(); beforeEach(() => { embeddableInput = new BehaviorSubject({ @@ -74,12 +77,14 @@ describe('ExplorerSwimlaneContainer', () => { render( } services={services} refresh={refresh} onInputChange={onInputChange} + onOutputChange={onOutputChange} />, defaultOptions ); @@ -110,6 +115,7 @@ describe('ExplorerSwimlaneContainer', () => { const { findByText } = render( @@ -117,6 +123,7 @@ describe('ExplorerSwimlaneContainer', () => { services={services} refresh={refresh} onInputChange={onInputChange} + onOutputChange={onOutputChange} />, defaultOptions ); 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/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..21cf75cd4e2b8 --- /dev/null +++ b/x-pack/plugins/ml/public/ui_actions/apply_influencer_filters_action.tsx @@ -0,0 +1,83 @@ +/* + * 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: 'Apply influencer filters', + }); + }, + 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 + return ( + embeddable instanceof AnomalySwimlaneEmbeddable && + data !== undefined && + data.type === SWIMLANE_TYPE.VIEW_BY && + data.viewByFieldName !== VIEW_BY_JOB_LABEL + ); + }, + }); +} diff --git a/x-pack/plugins/ml/public/ui_actions/apply_current_view_action.tsx b/x-pack/plugins/ml/public/ui_actions/apply_time_range_action.tsx similarity index 58% rename from x-pack/plugins/ml/public/ui_actions/apply_current_view_action.tsx rename to x-pack/plugins/ml/public/ui_actions/apply_time_range_action.tsx index f3d28f69aaa8c..9918878da16c6 100644 --- a/x-pack/plugins/ml/public/ui_actions/apply_current_view_action.tsx +++ b/x-pack/plugins/ml/public/ui_actions/apply_time_range_action.tsx @@ -13,18 +13,20 @@ import { } from '../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; import { MlCoreSetup } from '../plugin'; -export const APPLY_TO_CURRENT_VIEW_ACTION = 'openInAnomalyExplorerAction'; +export const APPLY_TIME_RANGE_SELECTION_ACTION = 'applyTimeRangeSelectionAction'; -export function createApplyToCurrentViewAction(getStartServices: MlCoreSetup['getStartServices']) { - return createAction({ - id: 'apply-to-current-view', - type: APPLY_TO_CURRENT_VIEW_ACTION, - getIconType(context: ActionContextMapping[typeof APPLY_TO_CURRENT_VIEW_ACTION]): string { - return 'filter'; +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.applyToCurrentViewTitle', { - defaultMessage: 'Apply to current view', + i18n.translate('xpack.ml.actions.applyTimeRangeSelectionTitle', { + defaultMessage: 'Apply time range selection', }), async execute({ embeddable, data }: SwimLaneDrilldownContext) { if (!data) { @@ -32,14 +34,23 @@ export function createApplyToCurrentViewAction(getStartServices: MlCoreSetup['ge } const [, pluginStart] = await getStartServices(); const timefilter = pluginStart.data.query.timefilter.timefilter; + const { interval } = embeddable.getOutput(); let [from, to] = data.times; from = from * 1000; to = to * 1000; + if (from === to) { + // single cell from a swim lane has been selected + if (!interval) { + throw new Error('Interval is required to set a time range'); + } + to = to + interval * 1000; + } + timefilter.setTime({ from: moment(from), - to: moment(from === to ? to + data.interval * 1000 : to), + to: moment(to), mode: 'absolute', }); }, diff --git a/x-pack/plugins/ml/public/ui_actions/index.ts b/x-pack/plugins/ml/public/ui_actions/index.ts index 31b7368cba443..b7262a330b310 100644 --- a/x-pack/plugins/ml/public/ui_actions/index.ts +++ b/x-pack/plugins/ml/public/ui_actions/index.ts @@ -18,11 +18,15 @@ import { UiActionsSetup } from '../../../../../src/plugins/ui_actions/public'; import { MlPluginStart, MlStartDependencies } from '../plugin'; import { CONTEXT_MENU_TRIGGER } from '../../../../../src/plugins/embeddable/public'; import { - APPLY_TO_CURRENT_VIEW_ACTION, - createApplyToCurrentViewAction, -} from './apply_current_view_action'; + 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 @@ -34,12 +38,14 @@ export function registerMlUiActions( // Initialize actions const editSwimlanePanelAction = createEditSwimlanePanelAction(core.getStartServices); const openInExplorerAction = createOpenInExplorerAction(core.getStartServices); - const applyToCurrentViewAction = createApplyToCurrentViewAction(core.getStartServices); + const applyInfluencerFiltersAction = createApplyInfluencerFiltersAction(core.getStartServices); + const applyTimeRangeSelectionAction = createApplyTimeRangeSelectionAction(core.getStartServices); // Register actions uiActions.registerAction(editSwimlanePanelAction); uiActions.registerAction(openInExplorerAction); - uiActions.registerAction(applyToCurrentViewAction); + uiActions.registerAction(applyInfluencerFiltersAction); + uiActions.registerAction(applyTimeRangeSelectionAction); // Assign triggers uiActions.attachAction(CONTEXT_MENU_TRIGGER, editSwimlanePanelAction.id); @@ -47,7 +53,8 @@ export function registerMlUiActions( uiActions.registerTrigger(swimLaneSelectionTrigger); - uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, applyToCurrentViewAction); + uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, applyInfluencerFiltersAction); + uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, applyTimeRangeSelectionAction); uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, openInExplorerAction); } @@ -55,7 +62,8 @@ declare module '../../../../../src/plugins/ui_actions/public' { export interface ActionContextMapping { [EDIT_SWIMLANE_PANEL_ACTION]: EditSwimlanePanelContext; [OPEN_IN_ANOMALY_EXPLORER_ACTION]: SwimLaneDrilldownContext; - [APPLY_TO_CURRENT_VIEW_ACTION]: SwimLaneDrilldownContext; + [APPLY_INFLUENCER_FILTERS_ACTION]: SwimLaneDrilldownContext; + [APPLY_TIME_RANGE_SELECTION_ACTION]: SwimLaneDrilldownContext; } export interface TriggerContextMapping { From 666dfbb623bbcb96eca142d5ee7dce1236ec2fcb Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Thu, 9 Jul 2020 15:49:17 +0200 Subject: [PATCH 10/19] [ML] update url generator params --- .../plugins/ml/public/url_generator.test.ts | 10 ++++- x-pack/plugins/ml/public/url_generator.ts | 45 ++++--------------- 2 files changed, 16 insertions(+), 39 deletions(-) diff --git a/x-pack/plugins/ml/public/url_generator.test.ts b/x-pack/plugins/ml/public/url_generator.test.ts index 7f76d29649e5a..45e2932b7781a 100644 --- a/x-pack/plugins/ml/public/url_generator.test.ts +++ b/x-pack/plugins/ml/public/url_generator.test.ts @@ -13,8 +13,14 @@ describe('MlUrlGenerator', () => { }); it('should generate valid URL for the Anomaly Explorer page', async () => { - const url = await urlGenerator.createUrl({ page: 'explorer', jobIds: ['test-job'] }); - expect(url).toBe('/app/ml#/explorer?_g=(ml:(jobIds:!(test-job)))&_a=()'); + 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 () => { diff --git a/x-pack/plugins/ml/public/url_generator.ts b/x-pack/plugins/ml/public/url_generator.ts index 493b84d378b8b..110230313a66e 100644 --- a/x-pack/plugins/ml/public/url_generator.ts +++ b/x-pack/plugins/ml/public/url_generator.ts @@ -5,14 +5,7 @@ */ import { UrlGeneratorsDefinition } from '../../../../src/plugins/share/public'; -import { - TimeRange, - Filter, - Query, - esFilters, - QueryState, - RefreshInterval, -} from '../../../../src/plugins/data/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'; @@ -36,26 +29,6 @@ export interface ExplorerUrlState { * Optional state for the swim lane */ mlExplorerSwimlane?: ExplorerAppState['mlExplorerSwimlane']; - /** - * Optionally set the refresh interval. - */ - refreshInterval?: RefreshInterval; - - /** - * Optionally apply filers. - */ - filters?: Filter[]; - - /** - * Optionally set a query. - */ - query?: Query; - - /** - * If not given, will use the uiSettings configuration for `storeInSessionStorage`. useHash determines - * whether to hash the data in the url to avoid url length issues. - */ - useHash?: boolean; } /** @@ -63,7 +36,10 @@ export interface ExplorerUrlState { */ export type MlUrlGeneratorState = ExplorerUrlState; -export type ExplorerQueryState = QueryState & { ml: { jobIds: JobId[] } }; +export interface ExplorerQueryState { + ml: { jobIds: JobId[] }; + time?: TimeRange; +} interface Params { appBasePath: string; @@ -88,15 +64,13 @@ export class MlUrlGenerator implements UrlGeneratorsDefinition): string { const appState: ExplorerAppState = { mlExplorerSwimlane, - mlExplorerFilter: {}, + mlExplorerFilter, }; const queryState: ExplorerQueryState = { @@ -106,12 +80,9 @@ export class MlUrlGenerator implements UrlGeneratorsDefinition esFilters.isFilterPinned(f)); - if (refreshInterval) queryState.refreshInterval = refreshInterval; let url = `${this.params.appBasePath}#/explorer`; - url = setStateToKbnUrl('_g', queryState, { useHash }, url); + url = setStateToKbnUrl('_g', queryState, { useHash }, url); url = setStateToKbnUrl('_a', appState, { useHash }, url); return url; From e6c487ed11d77987ac3b424144df222608d0340a Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Fri, 10 Jul 2020 18:03:32 +0200 Subject: [PATCH 11/19] [ML] prevent label text selection on drag select --- .../application/explorer/explorer_swimlane.tsx | 15 +++++++-------- .../open_in_anomaly_explorer_action.tsx | 11 +++++------ 2 files changed, 12 insertions(+), 14 deletions(-) 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 d63e1d80670e5..0f92278e90445 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx @@ -121,6 +121,8 @@ export class ExplorerSwimlane extends React.Component { 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; @@ -295,6 +297,10 @@ export class ExplorerSwimlane extends React.Component { }); } + /** + * TODO should happen with props instead of imperative check + * @param maskAll + */ maskIrrelevantSwimlanes(maskAll: boolean) { if (maskAll === true) { // This selects both overall and viewby swimlane @@ -627,10 +633,6 @@ export class ExplorerSwimlane extends React.Component { this.swimlaneRenderDoneListener(); - // if (this.props.swimlaneRenderDoneListener) { - // this.props.swimlaneRenderDoneListener(); - // } - if ( (swimlaneType !== selectedType || (swimlaneData.fieldName !== undefined && @@ -653,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); 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 index f8d392c52d1ab..0c29928aec062 100644 --- 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 @@ -22,21 +22,20 @@ export function createOpenInExplorerAction(getStartServices: MlCoreSetup['getSta getIconType(context: ActionContextMapping[typeof OPEN_IN_ANOMALY_EXPLORER_ACTION]): string { return 'tableOfContents'; }, - getDisplayName: () => - i18n.translate('xpack.ml.actions.openInAnomalyExplorerTitle', { + 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, query, filters, timeRange } = embeddable.getInput(); + const { jobIds, timeRange } = embeddable.getInput(); const { perPage, fromPage } = embeddable.getOutput(); return urlGenerator.createUrl({ page: 'explorer', jobIds, - query, - filters, timeRange, mlExplorerSwimlane: { viewByFromPage: fromPage, From 1d41ab33a7914bd41e68e61489666884640ba992 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Mon, 13 Jul 2020 11:40:33 +0200 Subject: [PATCH 12/19] [ML] fix types and unit tests --- .../explorer/explorer_swimlane.test.tsx | 7 ++++-- .../anomaly_swimlane_embeddable_factory.ts | 11 ++++++-- .../embeddable_swim_lane_container.test.tsx | 25 +++++++++++++++++-- x-pack/plugins/ml/public/embeddables/index.ts | 8 ++---- x-pack/plugins/ml/public/url_generator.ts | 6 ++--- 5 files changed, 42 insertions(+), 15 deletions(-) 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 a4fc87e43691f..b51fe11587bf5 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 @@ -43,6 +43,7 @@ function getExplorerSwimlaneMocks() { timeBuckets, swimlaneData, tooltipService, + parentRef: {} as React.RefObject, }; } @@ -70,10 +71,11 @@ describe('ExplorerSwimlane', () => { ); @@ -98,10 +100,11 @@ describe('ExplorerSwimlane', () => { ); 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 c7941315ed841..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 @@ -25,13 +25,16 @@ import { AnomalyTimelineService } from '../../application/services/anomaly_timel 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/embeddable_swim_lane_container.test.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.test.tsx index 89c90eaf0b365..9a65a5a3b42c6 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,7 +6,10 @@ 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 { @@ -19,6 +22,9 @@ 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(() => { @@ -38,15 +44,30 @@ const defaultOptions = { wrapper: I18nProvider }; describe('ExplorerSwimlaneContainer', () => { let embeddableInput: BehaviorSubject>; let refresh: BehaviorSubject; - let services: [CoreStart, MlDependencies, 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' }; 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 () => { 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/url_generator.ts b/x-pack/plugins/ml/public/url_generator.ts index 110230313a66e..65d5077e081a3 100644 --- a/x-pack/plugins/ml/public/url_generator.ts +++ b/x-pack/plugins/ml/public/url_generator.ts @@ -29,6 +29,7 @@ export interface ExplorerUrlState { * Optional state for the swim lane */ mlExplorerSwimlane?: ExplorerAppState['mlExplorerSwimlane']; + mlExplorerFilter?: ExplorerAppState['mlExplorerFilter']; } /** @@ -64,7 +65,6 @@ export class MlUrlGenerator implements UrlGeneratorsDefinition): string { @@ -82,8 +82,8 @@ export class MlUrlGenerator implements UrlGeneratorsDefinition('_g', queryState, { useHash }, url); - url = setStateToKbnUrl('_a', appState, { useHash }, url); + url = setStateToKbnUrl('_g', queryState, { useHash: false }, url); + url = setStateToKbnUrl('_a', appState, { useHash: false }, url); return url; } From 5c39b47ef5c96ce0d8357f5ea033e3ea2a0e82cf Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Mon, 13 Jul 2020 14:30:44 +0200 Subject: [PATCH 13/19] [ML] fix embeddable init --- .../components/job_selector/job_selector_flyout.tsx | 1 - .../anomaly_swimlane/anomaly_swimlane_initializer.tsx | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) 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/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx index be9a332e51dbc..51b3f5864628c 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 ( -
+ = ( /> -
+ ); }; From 9f1acdc323137fc54440b9091c194cbd3ab2a7b6 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Mon, 13 Jul 2020 15:00:53 +0200 Subject: [PATCH 14/19] [ML] fix swim lane unit tests --- .../explorer_swimlane.test.tsx.snap | 2 +- .../explorer/explorer_swimlane.test.tsx | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) 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/explorer_swimlane.test.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.test.tsx index b51fe11587bf5..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 @@ -26,6 +26,16 @@ jest.mock('d3', () => { }; }); +jest.mock('@elastic/eui', () => { + return { + htmlIdGenerator: jest.fn(() => { + return jest.fn(() => { + return 'test-gen-id'; + }); + }), + }; +}); + function getExplorerSwimlaneMocks() { const swimlaneData = ({ laneLabels: [] } as unknown) as OverallSwimlaneData; @@ -80,14 +90,11 @@ describe('ExplorerSwimlane', () => { ); expect(wrapper.html()).toBe( - `
` + - `
` + '
' ); // test calls to mock functions // @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); @@ -112,8 +119,6 @@ describe('ExplorerSwimlane', () => { // test calls to mock functions // @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); From 9693776fae7909a55a96a6852acadd9d3684ce4b Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Mon, 13 Jul 2020 15:11:56 +0200 Subject: [PATCH 15/19] [ML] change action label, use filter action only for single cell click --- .../public/ui_actions/apply_influencer_filters_action.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 index 21cf75cd4e2b8..3af39993d39fd 100644 --- 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 @@ -29,7 +29,7 @@ export function createApplyInfluencerFiltersAction( }, getDisplayName() { return i18n.translate('xpack.ml.actions.applyInfluencersFiltersTitle', { - defaultMessage: 'Apply influencer filters', + defaultMessage: 'Filer for value', }); }, async execute({ data }: SwimLaneDrilldownContext) { @@ -71,12 +71,13 @@ export function createApplyInfluencerFiltersAction( ); }, async isCompatible({ embeddable, data }: SwimLaneDrilldownContext) { - // Only compatible with view by influencer swim lanes + // 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.viewByFieldName !== VIEW_BY_JOB_LABEL && + data.lanes.length === 1 ); }, }); From 98bd9b2f950a5c4529ac4d0fa4df7d93faab9f12 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Mon, 13 Jul 2020 17:04:06 +0200 Subject: [PATCH 16/19] [ML] fix time range bounds --- .../public/ui_actions/apply_time_range_action.tsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) 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 index 9918878da16c6..ec59ba20acf98 100644 --- 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 @@ -36,17 +36,14 @@ export function createApplyTimeRangeSelectionAction( 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; - to = to * 1000; - - if (from === to) { - // single cell from a swim lane has been selected - if (!interval) { - throw new Error('Interval is required to set a time range'); - } - to = to + interval * 1000; - } + // extend bounds with the interval + to = to * 1000 + interval * 1000; timefilter.setTime({ from: moment(from), From 90d83794ab713b77fdf20de61fb7af3e3c2d3343 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Mon, 13 Jul 2020 17:27:29 +0200 Subject: [PATCH 17/19] [ML] fix TS issues --- .../anomaly_swimlane/anomaly_swimlane_initializer.tsx | 2 +- .../anomaly_swimlane/embeddable_swim_lane_container.test.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 51b3f5864628c..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 @@ -81,7 +81,7 @@ export const AnomalySwimlaneInitializer: FC = ( (swimlaneType === SWIMLANE_TYPE.VIEW_BY && !!viewBySwimlaneFieldName)); return ( - + { const onOutputChange = jest.fn(); beforeEach(() => { - embeddableContext = { id: 'test-id' }; + embeddableContext = { id: 'test-id' } as AnomalySwimlaneEmbeddable; embeddableInput = new BehaviorSubject({ id: 'test-swimlane-embeddable', } as Partial); From 5fa4baf276770f907eacffbaf42ce061b3f279be Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Tue, 14 Jul 2020 12:22:42 +0200 Subject: [PATCH 18/19] [ML] fix pagination persistence --- .../components/job_selector/use_job_selection.ts | 4 +--- .../reducers/explorer_reducer/job_selection_change.ts | 4 +++- x-pack/plugins/ml/public/application/routing/use_refresh.ts | 6 ++---- 3 files changed, 6 insertions(+), 8 deletions(-) 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 97af1434ce671..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 @@ -50,10 +50,8 @@ export const useJobSelection = (jobs: MlJobWithTimeRange[]) => { }, [tmpIds, invalidIds]); const jobSelection: JobSelection = useMemo(() => { - const jobIds = validIds; const selectedGroups = globalState?.ml?.groups ?? []; - - return { jobIds, selectedGroups }; + return { jobIds: validIds, selectedGroups }; }, [validIds, globalState?.ml?.groups]); useEffect(() => { 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 805d6ce22d1ac..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'; @@ -20,7 +21,8 @@ export const jobSelectionChange = (state: ExplorerState, payload: ActionPayload) // currently job selection set asynchronously so // we want to preserve the pagination from the url state // on initial load - viewByFromPage: !state.selectedJobs ? state.viewByFromPage : 1, + 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/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 } }; }) From d0b1b8273923135cff419d09d8572c80bd308ed3 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Tue, 14 Jul 2020 14:22:44 +0200 Subject: [PATCH 19/19] [ML] use viewByFrom the embeddable input --- .../ml/public/ui_actions/open_in_anomaly_explorer_action.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 0c29928aec062..211840467e38c 100644 --- 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 @@ -30,7 +30,7 @@ export function createOpenInExplorerAction(getStartServices: MlCoreSetup['getSta async getHref({ embeddable, data }: SwimLaneDrilldownContext): Promise { const [, pluginsStart] = await getStartServices(); const urlGenerator = pluginsStart.share.urlGenerators.getUrlGenerator(ML_APP_URL_GENERATOR); - const { jobIds, timeRange } = embeddable.getInput(); + const { jobIds, timeRange, viewBy } = embeddable.getInput(); const { perPage, fromPage } = embeddable.getOutput(); return urlGenerator.createUrl({ @@ -40,12 +40,12 @@ export function createOpenInExplorerAction(getStartServices: MlCoreSetup['getSta mlExplorerSwimlane: { viewByFromPage: fromPage, viewByPerPage: perPage, + viewByFieldName: viewBy, ...(data ? { selectedType: data.type, selectedTimes: data.times, selectedLanes: data.lanes, - viewByFieldName: data.viewByFieldName, } : {}), },