diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 9539d530bab04..9d5125532e5b8 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -7,7 +7,7 @@ import React, { FC } from 'react'; import ReactDOM from 'react-dom'; -import { AppMountParameters, CoreStart } from 'kibana/public'; +import { AppMountParameters, CoreStart, HttpStart } from 'kibana/public'; import { Storage } from '../../../../../src/plugins/kibana_utils/public'; @@ -17,6 +17,8 @@ import { setLicenseCache } from './license'; import { MlSetupDependencies, MlStartDependencies } from '../plugin'; import { MlRouter } from './routing'; +import { mlApiServicesProvider } from './services/ml_api_service'; +import { HttpService } from './services/http_service'; type MlDependencies = MlSetupDependencies & MlStartDependencies; @@ -27,6 +29,23 @@ interface AppProps { const localStorage = new Storage(window.localStorage); +/** + * Provides global services available across the entire ML app. + */ +export function getMlGlobalServices(httpStart: HttpStart) { + const httpService = new HttpService(httpStart); + return { + httpService, + mlApiServices: mlApiServicesProvider(httpService), + }; +} + +export interface MlServicesContext { + mlServices: MlGlobalServices; +} + +export type MlGlobalServices = ReturnType; + const App: FC = ({ coreStart, deps }) => { const pageDeps = { indexPatterns: deps.data.indexPatterns, @@ -47,7 +66,9 @@ const App: FC = ({ coreStart, deps }) => { const I18nContext = coreStart.i18n.Context; return ( - + 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 3fb654f35be4d..803281bcd0ce9 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 @@ -27,7 +27,6 @@ import { normalizeTimes, } from './job_select_service_utils'; import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; -import { ml } from '../../services/ml_api_service'; import { useMlKibana } from '../../contexts/kibana'; import { JobSelectionMaps } from './job_selector'; @@ -66,7 +65,10 @@ export const JobSelectorFlyout: FC = ({ withTimeRangeSelector = true, }) => { const { - services: { notifications }, + services: { + notifications, + mlServices: { mlApiServices }, + }, } = useMlKibana(); const [newSelection, setNewSelection] = useState(selectedIds); @@ -151,7 +153,7 @@ export const JobSelectorFlyout: FC = ({ async function fetchJobs() { try { - const resp = await ml.jobs.jobsWithTimerange(dateFormatTz); + const resp = await mlApiServices.jobs.jobsWithTimerange(dateFormatTz); const normalizedJobs = normalizeTimes(resp.jobs, dateFormatTz, DEFAULT_GANTT_BAR_WIDTH); const { groups: groupsWithTimerange, groupsMap } = getGroupsFromJobs(normalizedJobs); setJobs(normalizedJobs); diff --git a/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx b/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx index 27f8c822d68e3..beafae1ecd2f6 100644 --- a/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx +++ b/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx @@ -9,10 +9,7 @@ import { Subscription } from 'rxjs'; import { EuiSuperDatePicker, OnRefreshProps } from '@elastic/eui'; import { TimeRange, TimeHistoryContract } from 'src/plugins/data/public'; -import { - mlTimefilterRefresh$, - mlTimefilterTimeChange$, -} from '../../../services/timefilter_refresh_service'; +import { mlTimefilterRefresh$ } from '../../../services/timefilter_refresh_service'; import { useUrlState } from '../../../util/url_state'; import { useMlKibana } from '../../../contexts/kibana'; @@ -108,7 +105,6 @@ export const DatePickerWrapper: FC = () => { timefilter.setTime(newTime); setTime(newTime); setRecentlyUsedRanges(getRecentlyUsedRanges()); - mlTimefilterTimeChange$.next({ lastRefresh: Date.now(), timeRange: { start, end } }); } function updateInterval({ diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts index 2a156b5716ad4..3bc3b8c2c6dfd 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts @@ -13,6 +13,7 @@ import { import { SecurityPluginSetup } from '../../../../../security/public'; import { LicenseManagementUIPluginSetup } from '../../../../../license_management/public'; import { SharePluginStart } from '../../../../../../../src/plugins/share/public'; +import { MlServicesContext } from '../../app'; interface StartPlugins { data: DataPublicPluginStart; @@ -20,7 +21,8 @@ interface StartPlugins { licenseManagement?: LicenseManagementUIPluginSetup; share: SharePluginStart; } -export type StartServices = CoreStart & StartPlugins & { kibanaVersion: string }; +export type StartServices = CoreStart & + StartPlugins & { kibanaVersion: string } & MlServicesContext; // eslint-disable-next-line react-hooks/rules-of-hooks export const useMlKibana = () => useKibana(); export type MlKibanaReactContextValue = KibanaReactContextValue; diff --git a/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts b/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts index 07d5a153664b7..95ef5e5b2938c 100644 --- a/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts @@ -7,6 +7,7 @@ import React from 'react'; import { IndexPattern, IndexPatternsContract } from '../../../../../../../src/plugins/data/public'; import { SavedSearchSavedObject } from '../../../../common/types/kibana'; +import { MlServicesContext } from '../../app'; export interface MlContextValue { combinedQuery: any; @@ -34,4 +35,4 @@ export type SavedSearchQuery = object; // Multiple custom hooks can be created to access subsets of // the overall context value if necessary too, // see useCurrentIndexPattern() for example. -export const MlContext = React.createContext>({}); +export const MlContext = React.createContext>({}); diff --git a/x-pack/plugins/ml/public/application/explorer/_explorer.scss b/x-pack/plugins/ml/public/application/explorer/_explorer.scss index 7e5f354bbb402..63c471e66c49a 100644 --- a/x-pack/plugins/ml/public/application/explorer/_explorer.scss +++ b/x-pack/plugins/ml/public/application/explorer/_explorer.scss @@ -1,3 +1,5 @@ +$borderRadius: $euiBorderRadius / 2; + .ml-swimlane-selector { visibility: hidden; } @@ -104,10 +106,9 @@ // SASSTODO: This entire selector needs to be rewritten. // It looks extremely brittle with very specific sizing units - .ml-explorer-swimlane { + .mlExplorerSwimlane { user-select: none; padding: 0; - margin-bottom: $euiSizeS; line.gridLine { stroke: $euiBorderColor; @@ -218,17 +219,20 @@ div.lane { height: 30px; border-bottom: 0px; - border-radius: 2px; - margin-top: -1px; + border-radius: $borderRadius; white-space: nowrap; + &:not(:first-child) { + margin-top: -1px; + } + div.lane-label { display: inline-block; - font-size: 13px; + font-size: $euiFontSizeXS; height: 30px; text-align: right; vertical-align: middle; - border-radius: 2px; + border-radius: $borderRadius; padding-right: 5px; margin-right: 5px; border: 1px solid transparent; @@ -261,7 +265,7 @@ .sl-cell-inner-dragselect { height: 26px; margin: 1px; - border-radius: 2px; + border-radius: $borderRadius; text-align: center; } @@ -293,7 +297,7 @@ .sl-cell-inner, .sl-cell-inner-dragselect { border: 2px solid $euiColorDarkShade; - border-radius: 2px; + border-radius: $borderRadius; opacity: 1; } } diff --git a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts index 590a69283a819..095b42ffac5b7 100644 --- a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts +++ b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts @@ -11,6 +11,7 @@ import useObservable from 'react-use/lib/useObservable'; import { forkJoin, of, Observable, Subject } from 'rxjs'; import { mergeMap, switchMap, tap } from 'rxjs/operators'; +import { useCallback, useMemo } from 'react'; import { anomalyDataChange } from '../explorer_charts/explorer_charts_container_service'; import { explorerService } from '../explorer_dashboard_service'; import { @@ -22,15 +23,17 @@ import { loadAnomaliesTableData, loadDataForCharts, loadFilteredTopInfluencers, - loadOverallData, loadTopInfluencers, - loadViewBySwimlane, - loadViewByTopFieldValuesForSelectedTime, AppStateSelectedCells, ExplorerJob, TimeRangeBounds, } from '../explorer_utils'; import { ExplorerState } from '../reducers'; +import { useMlKibana, useTimefilter } from '../../contexts/kibana'; +import { AnomalyTimelineService } from '../../services/anomaly_timeline_service'; +import { mlResultsServiceProvider } from '../../services/results_service'; +import { isViewBySwimLaneData } from '../swimlane_container'; +import { ANOMALY_SWIM_LANE_HARD_LIMIT } from '../explorer_constants'; // Memoize the data fetching methods. // wrapWithLastRefreshArg() wraps any given function and preprends a `lastRefresh` argument @@ -39,13 +42,13 @@ import { ExplorerState } from '../reducers'; // about this parameter. The generic type T retains and returns the type information of // the original function. const memoizeIsEqual = (newArgs: any[], lastArgs: any[]) => isEqual(newArgs, lastArgs); -const wrapWithLastRefreshArg = any>(func: T) => { +const wrapWithLastRefreshArg = any>(func: T, context: any = null) => { return function (lastRefresh: number, ...args: Parameters): ReturnType { - return func.apply(null, args); + return func.apply(context, args); }; }; -const memoize = any>(func: T) => { - return memoizeOne(wrapWithLastRefreshArg(func), memoizeIsEqual); +const memoize = any>(func: T, context?: any) => { + return memoizeOne(wrapWithLastRefreshArg(func, context), memoizeIsEqual); }; const memoizedAnomalyDataChange = memoize(anomalyDataChange); @@ -56,9 +59,7 @@ const memoizedLoadDataForCharts = memoize(loadDataForC const memoizedLoadFilteredTopInfluencers = memoize( loadFilteredTopInfluencers ); -const memoizedLoadOverallData = memoize(loadOverallData); const memoizedLoadTopInfluencers = memoize(loadTopInfluencers); -const memoizedLoadViewBySwimlane = memoize(loadViewBySwimlane); const memoizedLoadAnomaliesTableData = memoize(loadAnomaliesTableData); export interface LoadExplorerDataConfig { @@ -73,6 +74,9 @@ export interface LoadExplorerDataConfig { tableInterval: string; tableSeverity: number; viewBySwimlaneFieldName: string; + viewByFromPage: number; + viewByPerPage: number; + swimlaneContainerWidth: number; } export const isLoadExplorerDataConfig = (arg: any): arg is LoadExplorerDataConfig => { @@ -87,183 +91,213 @@ export const isLoadExplorerDataConfig = (arg: any): arg is LoadExplorerDataConfi /** * Fetches the data necessary for the Anomaly Explorer using observables. - * - * @param config LoadExplorerDataConfig - * - * @return Partial */ -function loadExplorerData(config: LoadExplorerDataConfig): Observable> { - if (!isLoadExplorerDataConfig(config)) { - return of({}); - } - - const { - bounds, - lastRefresh, - influencersFilterQuery, - noInfluencersConfigured, - selectedCells, - selectedJobs, - swimlaneBucketInterval, - swimlaneLimit, - tableInterval, - tableSeverity, - viewBySwimlaneFieldName, - } = config; - - const selectionInfluencers = getSelectionInfluencers(selectedCells, viewBySwimlaneFieldName); - const jobIds = getSelectionJobIds(selectedCells, selectedJobs); - const timerange = getSelectionTimeRange( - selectedCells, - swimlaneBucketInterval.asSeconds(), - bounds +const loadExplorerDataProvider = (anomalyTimelineService: AnomalyTimelineService) => { + const memoizedLoadOverallData = memoize( + anomalyTimelineService.loadOverallData, + anomalyTimelineService ); + const memoizedLoadViewBySwimlane = memoize( + anomalyTimelineService.loadViewBySwimlane, + anomalyTimelineService + ); + return (config: LoadExplorerDataConfig): Observable> => { + if (!isLoadExplorerDataConfig(config)) { + return of({}); + } - const dateFormatTz = getDateFormatTz(); - - // First get the data where we have all necessary args at hand using forkJoin: - // annotationsData, anomalyChartRecords, influencers, overallState, tableData, topFieldValues - return forkJoin({ - annotationsData: memoizedLoadAnnotationsTableData( + const { + bounds, lastRefresh, + influencersFilterQuery, + noInfluencersConfigured, selectedCells, selectedJobs, + swimlaneBucketInterval, + swimlaneLimit, + tableInterval, + tableSeverity, + viewBySwimlaneFieldName, + swimlaneContainerWidth, + viewByFromPage, + viewByPerPage, + } = config; + + const selectionInfluencers = getSelectionInfluencers(selectedCells, viewBySwimlaneFieldName); + const jobIds = getSelectionJobIds(selectedCells, selectedJobs); + const timerange = getSelectionTimeRange( + selectedCells, swimlaneBucketInterval.asSeconds(), bounds - ), - anomalyChartRecords: memoizedLoadDataForCharts( - lastRefresh, - jobIds, - timerange.earliestMs, - timerange.latestMs, - selectionInfluencers, - selectedCells, - influencersFilterQuery - ), - influencers: - selectionInfluencers.length === 0 - ? memoizedLoadTopInfluencers( + ); + + const dateFormatTz = getDateFormatTz(); + + // First get the data where we have all necessary args at hand using forkJoin: + // annotationsData, anomalyChartRecords, influencers, overallState, tableData, topFieldValues + return forkJoin({ + annotationsData: memoizedLoadAnnotationsTableData( + lastRefresh, + selectedCells, + selectedJobs, + swimlaneBucketInterval.asSeconds(), + bounds + ), + anomalyChartRecords: memoizedLoadDataForCharts( + lastRefresh, + jobIds, + timerange.earliestMs, + timerange.latestMs, + selectionInfluencers, + selectedCells, + influencersFilterQuery + ), + influencers: + selectionInfluencers.length === 0 + ? memoizedLoadTopInfluencers( + lastRefresh, + jobIds, + timerange.earliestMs, + timerange.latestMs, + [], + noInfluencersConfigured, + influencersFilterQuery + ) + : Promise.resolve({}), + overallState: memoizedLoadOverallData(lastRefresh, selectedJobs, swimlaneContainerWidth), + tableData: memoizedLoadAnomaliesTableData( + lastRefresh, + selectedCells, + selectedJobs, + dateFormatTz, + swimlaneBucketInterval.asSeconds(), + bounds, + viewBySwimlaneFieldName, + tableInterval, + tableSeverity, + influencersFilterQuery + ), + topFieldValues: + selectedCells !== undefined && selectedCells.showTopFieldValues === true + ? anomalyTimelineService.loadViewByTopFieldValuesForSelectedTime( + timerange.earliestMs, + timerange.latestMs, + selectedJobs, + viewBySwimlaneFieldName, + swimlaneLimit, + viewByPerPage, + viewByFromPage, + swimlaneContainerWidth + ) + : Promise.resolve([]), + }).pipe( + // Trigger a side-effect action to reset view-by swimlane, + // show the view-by loading indicator + // and pass on the data we already fetched. + tap(explorerService.setViewBySwimlaneLoading), + // Trigger a side-effect to update the charts. + tap(({ anomalyChartRecords }) => { + if (selectedCells !== undefined && Array.isArray(anomalyChartRecords)) { + memoizedAnomalyDataChange( lastRefresh, - jobIds, + anomalyChartRecords, timerange.earliestMs, timerange.latestMs, + tableSeverity + ); + } else { + memoizedAnomalyDataChange( + lastRefresh, [], - noInfluencersConfigured, - influencersFilterQuery - ) - : Promise.resolve({}), - overallState: memoizedLoadOverallData( - lastRefresh, - selectedJobs, - swimlaneBucketInterval, - bounds - ), - tableData: memoizedLoadAnomaliesTableData( - lastRefresh, - selectedCells, - selectedJobs, - dateFormatTz, - swimlaneBucketInterval.asSeconds(), - bounds, - viewBySwimlaneFieldName, - tableInterval, - tableSeverity, - influencersFilterQuery - ), - topFieldValues: - selectedCells !== undefined && selectedCells.showTopFieldValues === true - ? loadViewByTopFieldValuesForSelectedTime( timerange.earliestMs, timerange.latestMs, - selectedJobs, - viewBySwimlaneFieldName, - swimlaneLimit, - noInfluencersConfigured - ) - : Promise.resolve([]), - }).pipe( - // Trigger a side-effect action to reset view-by swimlane, - // show the view-by loading indicator - // and pass on the data we already fetched. - tap(explorerService.setViewBySwimlaneLoading), - // Trigger a side-effect to update the charts. - tap(({ anomalyChartRecords }) => { - if (selectedCells !== undefined && Array.isArray(anomalyChartRecords)) { - memoizedAnomalyDataChange( - lastRefresh, - anomalyChartRecords, - timerange.earliestMs, - timerange.latestMs, - tableSeverity - ); - } else { - memoizedAnomalyDataChange( - lastRefresh, - [], - timerange.earliestMs, - timerange.latestMs, - tableSeverity - ); - } - }), - // Load view-by swimlane data and filtered top influencers. - // mergeMap is used to have access to the already fetched data and act on it in arg #1. - // In arg #2 of mergeMap we combine the data and pass it on in the action format - // which can be consumed by explorerReducer() later on. - mergeMap( - ({ anomalyChartRecords, influencers, overallState, topFieldValues }) => - forkJoin({ - influencers: - (selectionInfluencers.length > 0 || influencersFilterQuery !== undefined) && - anomalyChartRecords !== undefined && - anomalyChartRecords.length > 0 - ? memoizedLoadFilteredTopInfluencers( - lastRefresh, - jobIds, - timerange.earliestMs, - timerange.latestMs, - anomalyChartRecords, - selectionInfluencers, - noInfluencersConfigured, - influencersFilterQuery - ) - : Promise.resolve(influencers), - viewBySwimlaneState: memoizedLoadViewBySwimlane( - lastRefresh, - topFieldValues, - { - earliest: overallState.overallSwimlaneData.earliest, - latest: overallState.overallSwimlaneData.latest, - }, - selectedJobs, - viewBySwimlaneFieldName, - swimlaneLimit, - influencersFilterQuery, - noInfluencersConfigured - ), - }), - ( - { annotationsData, overallState, tableData }, - { influencers, viewBySwimlaneState } - ): Partial => { - return { - annotationsData, - influencers, - ...overallState, - ...viewBySwimlaneState, - tableData, - }; - } - ) - ); -} - -const loadExplorerData$ = new Subject(); -const explorerData$ = loadExplorerData$.pipe( - switchMap((config: LoadExplorerDataConfig) => loadExplorerData(config)) -); - + tableSeverity + ); + } + }), + // Load view-by swimlane data and filtered top influencers. + // mergeMap is used to have access to the already fetched data and act on it in arg #1. + // In arg #2 of mergeMap we combine the data and pass it on in the action format + // which can be consumed by explorerReducer() later on. + mergeMap( + ({ anomalyChartRecords, influencers, overallState, topFieldValues }) => + forkJoin({ + influencers: + (selectionInfluencers.length > 0 || influencersFilterQuery !== undefined) && + anomalyChartRecords !== undefined && + anomalyChartRecords.length > 0 + ? memoizedLoadFilteredTopInfluencers( + lastRefresh, + jobIds, + timerange.earliestMs, + timerange.latestMs, + anomalyChartRecords, + selectionInfluencers, + noInfluencersConfigured, + influencersFilterQuery + ) + : Promise.resolve(influencers), + viewBySwimlaneState: memoizedLoadViewBySwimlane( + lastRefresh, + topFieldValues, + { + earliest: overallState.earliest, + latest: overallState.latest, + }, + selectedJobs, + viewBySwimlaneFieldName, + ANOMALY_SWIM_LANE_HARD_LIMIT, + viewByPerPage, + viewByFromPage, + swimlaneContainerWidth, + influencersFilterQuery + ), + }), + ( + { annotationsData, overallState, tableData }, + { influencers, viewBySwimlaneState } + ): Partial => { + return { + annotationsData, + influencers, + loading: false, + viewBySwimlaneDataLoading: false, + overallSwimlaneData: overallState, + viewBySwimlaneData: viewBySwimlaneState, + tableData, + swimlaneLimit: isViewBySwimLaneData(viewBySwimlaneState) + ? viewBySwimlaneState.cardinality + : undefined, + }; + } + ) + ); + }; +}; export const useExplorerData = (): [Partial | undefined, (d: any) => void] => { + const timefilter = useTimefilter(); + + const { + services: { + mlServices: { mlApiServices }, + uiSettings, + }, + } = useMlKibana(); + const loadExplorerData = useMemo(() => { + const service = new AnomalyTimelineService( + timefilter, + uiSettings, + mlResultsServiceProvider(mlApiServices) + ); + return loadExplorerDataProvider(service); + }, []); + const loadExplorerData$ = useMemo(() => new Subject(), []); + const explorerData$ = useMemo(() => loadExplorerData$.pipe(switchMap(loadExplorerData)), []); const explorerData = useObservable(explorerData$); - return [explorerData, (c) => loadExplorerData$.next(c)]; + + const update = useCallback((c) => { + loadExplorerData$.next(c); + }, []); + + return [explorerData, update]; }; diff --git a/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx b/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx index 16e2fb47a209d..3ad749c9d0631 100644 --- a/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx +++ b/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx @@ -52,7 +52,6 @@ function getDefaultEmbeddablepaPanelConfig(jobIds: JobId[]) { interface AddToDashboardControlProps { jobIds: JobId[]; viewBy: string; - limit: number; onClose: (callback?: () => Promise) => void; } @@ -63,7 +62,6 @@ export const AddToDashboardControl: FC = ({ onClose, jobIds, viewBy, - limit, }) => { const { notifications: { toasts }, @@ -141,7 +139,6 @@ export const AddToDashboardControl: FC = ({ jobIds, swimlaneType, viewBy, - limit, }, }; } @@ -206,8 +203,8 @@ export const AddToDashboardControl: FC = ({ { id: SWIMLANE_TYPE.VIEW_BY, label: i18n.translate('xpack.ml.explorer.viewByFieldLabel', { - defaultMessage: 'View by {viewByField}, up to {limit} rows', - values: { viewByField: viewBy, limit }, + defaultMessage: 'View by {viewByField}', + values: { viewByField: viewBy }, }), }, ]; 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 b4d32e2af64b8..e00e2e1e1e2eb 100644 --- a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx @@ -22,12 +22,11 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { DRAG_SELECT_ACTION, VIEW_BY_JOB_LABEL } from './explorer_constants'; +import { DRAG_SELECT_ACTION, 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 { SelectLimit } from './select_limit'; import { ALLOW_CELL_RANGE_SELECTION, dragSelect$, @@ -36,9 +35,9 @@ import { import { ExplorerState } from './reducers/explorer_reducer'; import { hasMatchingPoints } from './has_matching_points'; import { ExplorerNoInfluencersFound } from './components/explorer_no_influencers_found/explorer_no_influencers_found'; -import { LoadingIndicator } from '../components/loading_indicator'; import { SwimlaneContainer } from './swimlane_container'; -import { OverallSwimlaneData } from './explorer_utils'; +import { OverallSwimlaneData, ViewBySwimLaneData } from './explorer_utils'; +import { NoOverallData } from './components/no_overall_data'; function mapSwimlaneOptionsToEuiOptions(options: string[]) { return options.map((option) => ({ @@ -132,8 +131,11 @@ export const AnomalyTimeline: FC = React.memo( viewBySwimlaneDataLoading, viewBySwimlaneFieldName, viewBySwimlaneOptions, - swimlaneLimit, selectedJobs, + viewByFromPage, + viewByPerPage, + swimlaneLimit, + loading, } = explorerState; const setSwimlaneSelectActive = useCallback((active: boolean) => { @@ -159,25 +161,18 @@ export const AnomalyTimeline: FC = React.memo( }, []); // Listener for click events in the swimlane to load corresponding anomaly data. - const swimlaneCellClick = useCallback((selectedCellsUpdate: any) => { - // If selectedCells is an empty object we clear any existing selection, - // otherwise we save the new selection in AppState and update the Explorer. - if (Object.keys(selectedCellsUpdate).length === 0) { - setSelectedCells(); - } else { - setSelectedCells(selectedCellsUpdate); - } - }, []); - - const showOverallSwimlane = - overallSwimlaneData !== null && - overallSwimlaneData.laneLabels && - overallSwimlaneData.laneLabels.length > 0; - - const showViewBySwimlane = - viewBySwimlaneData !== null && - viewBySwimlaneData.laneLabels && - viewBySwimlaneData.laneLabels.length > 0; + const 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 = []; @@ -235,21 +230,6 @@ export const AnomalyTimeline: FC = React.memo( /> - - - - - } - display={'columnCompressed'} - > - - -
{viewByLoadedForTimeFormatted && ( @@ -305,68 +285,84 @@ export const AnomalyTimeline: FC = React.memo(
- {showOverallSwimlane && ( - explorerService.setSwimlaneContainerWidth(width)} - /> - )} + explorerService.setSwimlaneContainerWidth(width)} + isLoading={loading} + noDataWarning={} + />
+ + {viewBySwimlaneOptions.length > 0 && ( <> - {showViewBySwimlane && ( - <> - -
- +
+ explorerService.setSwimlaneContainerWidth(width)} + fromPage={viewByFromPage} + perPage={viewByPerPage} + swimlaneLimit={swimlaneLimit} + onPaginationChange={({ perPage: perPageUpdate, fromPage: fromPageUpdate }) => { + if (perPageUpdate) { + explorerService.setViewByPerPage(perPageUpdate); } - timeBuckets={timeBuckets} - swimlaneCellClick={swimlaneCellClick} - swimlaneData={viewBySwimlaneData as OverallSwimlaneData} - swimlaneType={'viewBy'} - selection={selectedCells} - swimlaneRenderDoneListener={swimlaneRenderDoneListener} - onResize={(width) => explorerService.setSwimlaneContainerWidth(width)} - /> -
- - )} - - {viewBySwimlaneDataLoading && } - - {!showViewBySwimlane && - !viewBySwimlaneDataLoading && - typeof viewBySwimlaneFieldName === 'string' && ( - + ) : ( + + ) + ) : null + } /> - )} +
+ )} @@ -380,7 +376,6 @@ export const AnomalyTimeline: FC = React.memo( }} jobIds={selectedJobs.map(({ id }) => id)} viewBy={viewBySwimlaneFieldName!} - limit={swimlaneLimit} /> )} diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/__snapshots__/explorer_no_influencers_found.test.js.snap b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/__snapshots__/explorer_no_influencers_found.test.js.snap index 3ba4ebb2acdea..d3190d2ac1dad 100644 --- a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/__snapshots__/explorer_no_influencers_found.test.js.snap +++ b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/__snapshots__/explorer_no_influencers_found.test.js.snap @@ -1,20 +1,13 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ExplorerNoInfluencersFound snapshot 1`] = ` - - - + `; diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/explorer_no_influencers_found.tsx b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/explorer_no_influencers_found.tsx index 639c0f7b78504..24def01108584 100644 --- a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/explorer_no_influencers_found.tsx +++ b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/explorer_no_influencers_found.tsx @@ -7,7 +7,6 @@ import React, { FC } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiEmptyPrompt } from '@elastic/eui'; /* * React component for rendering EuiEmptyPrompt when no influencers were found. @@ -15,26 +14,17 @@ import { EuiEmptyPrompt } from '@elastic/eui'; export const ExplorerNoInfluencersFound: FC<{ viewBySwimlaneFieldName: string; showFilterMessage?: boolean; -}> = ({ viewBySwimlaneFieldName, showFilterMessage = false }) => ( - - {showFilterMessage === false && ( - - )} - {showFilterMessage === true && ( - - )} - - } - /> -); +}> = ({ viewBySwimlaneFieldName, showFilterMessage = false }) => + showFilterMessage === false ? ( + + ) : ( + + ); diff --git a/x-pack/plugins/ml/public/application/explorer/components/no_overall_data.tsx b/x-pack/plugins/ml/public/application/explorer/components/no_overall_data.tsx new file mode 100644 index 0000000000000..e73aac66a0d9f --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/components/no_overall_data.tsx @@ -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 React, { FC } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export const NoOverallData: FC = () => { + return ( + + ); +}; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.js b/x-pack/plugins/ml/public/application/explorer/explorer.js index 71c96840d1b57..df4cea0c07987 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer.js @@ -12,8 +12,6 @@ import PropTypes from 'prop-types'; import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; import { EuiFlexGroup, @@ -27,6 +25,7 @@ import { EuiPageHeaderSection, EuiSpacer, EuiTitle, + EuiLoadingContent, } from '@elastic/eui'; import { AnnotationFlyout } from '../components/annotations/annotation_flyout'; @@ -36,12 +35,10 @@ import { DatePickerWrapper } from '../components/navigation_menu/date_picker_wra import { InfluencersList } from '../components/influencers_list'; import { explorerService } from './explorer_dashboard_service'; import { AnomalyResultsViewSelector } from '../components/anomaly_results_view_selector'; -import { LoadingIndicator } from '../components/loading_indicator/loading_indicator'; import { NavigationMenu } from '../components/navigation_menu'; import { CheckboxShowCharts } from '../components/controls/checkbox_showcharts'; import { JobSelector } from '../components/job_selector'; import { SelectInterval } from '../components/controls/select_interval/select_interval'; -import { limit$ } from './select_limit/select_limit'; import { SelectSeverity } from '../components/controls/select_severity/select_severity'; import { ExplorerQueryBar, @@ -142,19 +139,6 @@ export class Explorer extends React.Component { state = { filterIconTriggeredQuery: undefined, language: DEFAULT_QUERY_LANG }; - _unsubscribeAll = new Subject(); - - componentDidMount() { - limit$.pipe(takeUntil(this._unsubscribeAll)).subscribe(explorerService.setSwimlaneLimit); - } - - componentWillUnmount() { - this._unsubscribeAll.next(); - this._unsubscribeAll.complete(); - } - - viewByChangeHandler = (e) => explorerService.setViewBySwimlaneFieldName(e.target.value); - // Escape regular parens from fieldName as that portion of the query is not wrapped in double quotes // and will cause a syntax error when called with getKqlQueryValues applyFilter = (fieldName, fieldValue, action) => { @@ -240,29 +224,7 @@ export class Explorer extends React.Component { const noJobsFound = selectedJobs === null || selectedJobs.length === 0; const hasResults = overallSwimlaneData.points && overallSwimlaneData.points.length > 0; - if (loading === true) { - return ( - - - - ); - } - - if (noJobsFound) { + if (noJobsFound && !loading) { return ( @@ -270,7 +232,7 @@ export class Explorer extends React.Component { ); } - if (noJobsFound && hasResults === false) { + if (noJobsFound && hasResults === false && !loading) { return ( @@ -320,7 +282,11 @@ export class Explorer extends React.Component { /> - + {loading ? ( + + ) : ( + + )}
)} @@ -352,59 +318,59 @@ export class Explorer extends React.Component { )} - -

- -

-
- - - - - - - - - + +

+ +

+
+ - -
-
- {chartsData.seriesToPlot.length > 0 && selectedCells !== undefined && ( - - - - - - )} -
- - - -
- {showCharts && } -
- - + + + + + + + + + + + {chartsData.seriesToPlot.length > 0 && selectedCells !== undefined && ( + + + + + + )} + + +
+ {showCharts && } +
+ + + )} 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 d1adf8c7ad744..21e13cb029d69 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts @@ -27,9 +27,10 @@ export const EXPLORER_ACTION = { SET_INFLUENCER_FILTER_SETTINGS: 'setInfluencerFilterSettings', SET_SELECTED_CELLS: 'setSelectedCells', SET_SWIMLANE_CONTAINER_WIDTH: 'setSwimlaneContainerWidth', - SET_SWIMLANE_LIMIT: 'setSwimlaneLimit', SET_VIEW_BY_SWIMLANE_FIELD_NAME: 'setViewBySwimlaneFieldName', SET_VIEW_BY_SWIMLANE_LOADING: 'setViewBySwimlaneLoading', + SET_VIEW_BY_PER_PAGE: 'setViewByPerPage', + SET_VIEW_BY_FROM_PAGE: 'setViewByFromPage', }; export const FILTER_ACTION = { @@ -51,9 +52,23 @@ export const CHART_TYPE = { }; export const MAX_CATEGORY_EXAMPLES = 10; + +/** + * Maximum amount of top influencer to fetch. + */ export const MAX_INFLUENCER_FIELD_VALUES = 10; export const MAX_INFLUENCER_FIELD_NAMES = 50; export const VIEW_BY_JOB_LABEL = i18n.translate('xpack.ml.explorer.jobIdLabel', { defaultMessage: 'job ID', }); +/** + * Hard limitation for the size of terms + * aggregations on influencers values. + */ +export const ANOMALY_SWIM_LANE_HARD_LIMIT = 1000; + +/** + * Default page size fot the anomaly swim lane. + */ +export const SWIM_LANE_DEFAULT_PAGE_SIZE = 10; 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 30ab918983a77..1429bf0858361 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 @@ -12,7 +12,7 @@ import { isEqual } from 'lodash'; import { from, isObservable, Observable, Subject } from 'rxjs'; -import { distinctUntilChanged, flatMap, map, scan } from 'rxjs/operators'; +import { distinctUntilChanged, flatMap, map, scan, shareReplay } from 'rxjs/operators'; import { DeepPartial } from '../../../common/types/common'; @@ -49,7 +49,9 @@ const explorerFilteredAction$ = explorerAction$.pipe( // applies action and returns state const explorerState$: Observable = explorerFilteredAction$.pipe( - scan(explorerReducer, getExplorerDefaultState()) + scan(explorerReducer, getExplorerDefaultState()), + // share the last emitted value among new subscribers + shareReplay(1) ); interface ExplorerAppState { @@ -59,6 +61,8 @@ interface ExplorerAppState { selectedTimes?: number[]; showTopFieldValues?: boolean; viewByFieldName?: string; + viewByPerPage?: number; + viewByFromPage?: number; }; mlExplorerFilter: { influencersFilterQuery?: unknown; @@ -88,6 +92,14 @@ const explorerAppState$: Observable = explorerState$.pipe( appState.mlExplorerSwimlane.viewByFieldName = state.viewBySwimlaneFieldName; } + if (state.viewByFromPage !== undefined) { + appState.mlExplorerSwimlane.viewByFromPage = state.viewByFromPage; + } + + if (state.viewByPerPage !== undefined) { + appState.mlExplorerSwimlane.viewByPerPage = state.viewByPerPage; + } + if (state.filterActive) { appState.mlExplorerFilter.influencersFilterQuery = state.influencersFilterQuery; appState.mlExplorerFilter.filterActive = state.filterActive; @@ -153,13 +165,16 @@ export const explorerService = { payload, }); }, - setSwimlaneLimit: (payload: number) => { - explorerAction$.next({ type: EXPLORER_ACTION.SET_SWIMLANE_LIMIT, payload }); - }, setViewBySwimlaneFieldName: (payload: string) => { explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_FIELD_NAME, payload }); }, setViewBySwimlaneLoading: (payload: any) => { explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_LOADING, payload }); }, + setViewByFromPage: (payload: number) => { + explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_FROM_PAGE, payload }); + }, + setViewByPerPage: (payload: number) => { + explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_PER_PAGE, payload }); + }, }; 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 4e6dcdcc5129c..aa386288ac7e0 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx @@ -29,7 +29,7 @@ import { ChartTooltipService, ChartTooltipValue, } from '../components/chart_tooltip/chart_tooltip_service'; -import { OverallSwimlaneData } from './explorer_utils'; +import { OverallSwimlaneData, ViewBySwimLaneData } from './explorer_utils'; const SCSS = { mlDragselectDragging: 'mlDragselectDragging', @@ -57,7 +57,7 @@ export interface ExplorerSwimlaneProps { maskAll?: boolean; timeBuckets: InstanceType; swimlaneCellClick?: Function; - swimlaneData: OverallSwimlaneData; + swimlaneData: OverallSwimlaneData | ViewBySwimLaneData; swimlaneType: SwimlaneType; selection?: { lanes: any[]; @@ -211,7 +211,7 @@ export class ExplorerSwimlane extends React.Component { const { swimlaneType } = this.props; // This selects both overall and viewby swimlane - const wrapper = d3.selectAll('.ml-explorer-swimlane'); + const wrapper = d3.selectAll('.mlExplorerSwimlane'); wrapper.selectAll('.lane-label').classed('lane-label-masked', true); wrapper @@ -242,7 +242,7 @@ export class ExplorerSwimlane extends React.Component { maskIrrelevantSwimlanes(maskAll: boolean) { if (maskAll === true) { // This selects both overall and viewby swimlane - const allSwimlanes = d3.selectAll('.ml-explorer-swimlane'); + const allSwimlanes = d3.selectAll('.mlExplorerSwimlane'); allSwimlanes.selectAll('.lane-label').classed('lane-label-masked', true); allSwimlanes .selectAll('.sl-cell-inner,.sl-cell-inner-dragselect') @@ -258,7 +258,7 @@ export class ExplorerSwimlane extends React.Component { clearSelection() { // This selects both overall and viewby swimlane - const wrapper = d3.selectAll('.ml-explorer-swimlane'); + const wrapper = d3.selectAll('.mlExplorerSwimlane'); wrapper.selectAll('.lane-label').classed('lane-label-masked', false); wrapper.selectAll('.sl-cell-inner').classed('sl-cell-inner-masked', false); 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 2d49fa737cef6..05fdb52e1ccb2 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 @@ -8,8 +8,6 @@ import { Moment } from 'moment'; import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; -import { TimeBucketsInterval } from '../util/time_buckets'; - interface ClearedSelectedAnomaliesState { selectedCells: undefined; viewByLoadedForTimeFormatted: null; @@ -35,6 +33,10 @@ export declare interface OverallSwimlaneData extends SwimlaneData { latest: number; } +export interface ViewBySwimLaneData extends OverallSwimlaneData { + cardinality: number; +} + export declare const getDateFormatTz: () => any; export declare const getDefaultSwimlaneData: () => SwimlaneData; @@ -163,22 +165,6 @@ declare interface LoadOverallDataResponse { overallSwimlaneData: OverallSwimlaneData; } -export declare const loadOverallData: ( - selectedJobs: ExplorerJob[], - interval: TimeBucketsInterval, - bounds: TimeRangeBounds -) => Promise; - -export declare const loadViewBySwimlane: ( - fieldValues: string[], - bounds: SwimlaneBounds, - selectedJobs: ExplorerJob[], - viewBySwimlaneFieldName: string, - swimlaneLimit: number, - influencersFilterQuery: any, - noInfluencersConfigured: boolean -) => Promise; - export declare const loadViewByTopFieldValuesForSelectedTime: ( earliestMs: number, latestMs: number, diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js index bd6a7ee59c942..23da9669ee9a5 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js @@ -8,11 +8,9 @@ * utils for Anomaly Explorer. */ -import { chain, each, get, union, uniq } from 'lodash'; +import { chain, get, union, uniq } from 'lodash'; import moment from 'moment-timezone'; -import { i18n } from '@kbn/i18n'; - import { ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE, ANOMALIES_TABLE_DEFAULT_QUERY_SIZE, @@ -27,7 +25,7 @@ import { parseInterval } from '../../../common/util/parse_interval'; import { ml } from '../services/ml_api_service'; import { mlJobService } from '../services/job_service'; import { mlResultsService } from '../services/results_service'; -import { getBoundsRoundedToInterval, getTimeBucketsFromCache } from '../util/time_buckets'; +import { getTimeBucketsFromCache } from '../util/time_buckets'; import { getTimefilter, getUiSettings } from '../util/dependency_cache'; import { @@ -36,7 +34,6 @@ import { SWIMLANE_TYPE, VIEW_BY_JOB_LABEL, } from './explorer_constants'; -import { getSwimlaneContainerWidth } from './legacy_utils'; // create new job objects based on standard job config objects // new job objects just contain job id, bucket span in seconds and a selected flag. @@ -51,6 +48,7 @@ export function getClearedSelectedAnomaliesState() { return { selectedCells: undefined, viewByLoadedForTimeFormatted: null, + swimlaneLimit: undefined, }; } @@ -267,58 +265,6 @@ export function getSwimlaneBucketInterval(selectedJobs, swimlaneContainerWidth) return buckets.getInterval(); } -export function loadViewByTopFieldValuesForSelectedTime( - earliestMs, - latestMs, - selectedJobs, - viewBySwimlaneFieldName, - swimlaneLimit, - noInfluencersConfigured -) { - const selectedJobIds = selectedJobs.map((d) => d.id); - - // Find the top field values for the selected time, and then load the 'view by' - // swimlane over the full time range for those specific field values. - return new Promise((resolve) => { - if (viewBySwimlaneFieldName !== VIEW_BY_JOB_LABEL) { - mlResultsService - .getTopInfluencers(selectedJobIds, earliestMs, latestMs, swimlaneLimit) - .then((resp) => { - if (resp.influencers[viewBySwimlaneFieldName] === undefined) { - resolve([]); - } - - const topFieldValues = []; - const topInfluencers = resp.influencers[viewBySwimlaneFieldName]; - if (Array.isArray(topInfluencers)) { - topInfluencers.forEach((influencerData) => { - if (influencerData.maxAnomalyScore > 0) { - topFieldValues.push(influencerData.influencerFieldValue); - } - }); - } - resolve(topFieldValues); - }); - } else { - mlResultsService - .getScoresByBucket( - selectedJobIds, - earliestMs, - latestMs, - getSwimlaneBucketInterval( - selectedJobs, - getSwimlaneContainerWidth(noInfluencersConfigured) - ).asSeconds() + 's', - swimlaneLimit - ) - .then((resp) => { - const topFieldValues = Object.keys(resp.results); - resolve(topFieldValues); - }); - } - }); -} - // Obtain the list of 'View by' fields per job and viewBySwimlaneFieldName export function getViewBySwimlaneOptions({ currentViewBySwimlaneFieldName, @@ -435,105 +381,6 @@ export function getViewBySwimlaneOptions({ }; } -export function processOverallResults(scoresByTime, searchBounds, interval) { - const overallLabel = i18n.translate('xpack.ml.explorer.overallLabel', { - defaultMessage: 'Overall', - }); - const dataset = { - laneLabels: [overallLabel], - points: [], - interval, - earliest: searchBounds.min.valueOf() / 1000, - latest: searchBounds.max.valueOf() / 1000, - }; - - if (Object.keys(scoresByTime).length > 0) { - // 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 swimlane charts. - each(scoresByTime, (score, timeMs) => { - const time = timeMs / 1000; - dataset.points.push({ - laneLabel: overallLabel, - time, - value: score, - }); - - dataset.earliest = Math.min(time, dataset.earliest); - dataset.latest = Math.max(time + dataset.interval, dataset.latest); - }); - } - - return dataset; -} - -export function processViewByResults( - scoresByInfluencerAndTime, - sortedLaneValues, - bounds, - viewBySwimlaneFieldName, - interval -) { - // Processes the scores for the 'view by' swimlane. - // Sorts the lanes according to the supplied array of lane - // values in the order in which they should be displayed, - // or pass an empty array to sort lanes according to max score over all time. - const dataset = { - fieldName: viewBySwimlaneFieldName, - points: [], - interval, - }; - - // Set the earliest and latest to be the same as the overall swimlane. - dataset.earliest = bounds.earliest; - dataset.latest = bounds.latest; - - const laneLabels = []; - const maxScoreByLaneLabel = {}; - - each(scoresByInfluencerAndTime, (influencerData, influencerFieldValue) => { - laneLabels.push(influencerFieldValue); - maxScoreByLaneLabel[influencerFieldValue] = 0; - - each(influencerData, (anomalyScore, timeMs) => { - const time = timeMs / 1000; - dataset.points.push({ - laneLabel: influencerFieldValue, - time, - value: anomalyScore, - }); - maxScoreByLaneLabel[influencerFieldValue] = Math.max( - maxScoreByLaneLabel[influencerFieldValue], - anomalyScore - ); - }); - }); - - const sortValuesLength = sortedLaneValues.length; - if (sortValuesLength === 0) { - // Sort lanes in descending order of max score. - // Note the keys in scoresByInfluencerAndTime received from the ES request - // are not guaranteed to be sorted by score if they can be parsed as numbers - // (e.g. if viewing by HTTP response code). - dataset.laneLabels = laneLabels.sort((a, b) => { - return maxScoreByLaneLabel[b] - maxScoreByLaneLabel[a]; - }); - } else { - // Sort lanes according to supplied order - // e.g. when a cell in the overall swimlane has been selected. - // Find the index of each lane label from the actual data set, - // rather than using sortedLaneValues as-is, just in case they differ. - dataset.laneLabels = laneLabels.sort((a, b) => { - let aIndex = sortedLaneValues.indexOf(a); - let bIndex = sortedLaneValues.indexOf(b); - aIndex = aIndex > -1 ? aIndex : sortValuesLength; - bIndex = bIndex > -1 ? bIndex : sortValuesLength; - return aIndex - bIndex; - }); - } - - return dataset; -} - export function loadAnnotationsTableData(selectedCells, selectedJobs, interval, bounds) { const jobIds = selectedCells !== undefined && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL @@ -723,138 +570,6 @@ export async function loadDataForCharts( }); } -export function loadOverallData(selectedJobs, interval, bounds) { - return new Promise((resolve) => { - // Loads the overall data components i.e. the overall swimlane and influencers list. - if (selectedJobs === null) { - resolve({ - loading: false, - hasResuts: false, - }); - return; - } - - // Ensure the search bounds align to the bucketing interval used in the swimlane so - // that the first and last buckets are complete. - const searchBounds = getBoundsRoundedToInterval(bounds, interval, false); - const selectedJobIds = selectedJobs.map((d) => d.id); - - // Load the overall bucket scores by time. - // Pass the interval in seconds as the swimlane relies on a fixed number of seconds between buckets - // which wouldn't be the case if e.g. '1M' was used. - // Pass 'true' when obtaining bucket bounds due to the way the overall_buckets endpoint works - // to ensure the search is inclusive of end time. - const overallBucketsBounds = getBoundsRoundedToInterval(bounds, interval, true); - mlResultsService - .getOverallBucketScores( - selectedJobIds, - // Note there is an optimization for when top_n == 1. - // If top_n > 1, we should test what happens when the request takes long - // and refactor the loading calls, if necessary, to avoid delays in loading other components. - 1, - overallBucketsBounds.min.valueOf(), - overallBucketsBounds.max.valueOf(), - interval.asSeconds() + 's' - ) - .then((resp) => { - const overallSwimlaneData = processOverallResults( - resp.results, - searchBounds, - interval.asSeconds() - ); - - resolve({ - loading: false, - overallSwimlaneData, - }); - }); - }); -} - -export function loadViewBySwimlane( - fieldValues, - bounds, - selectedJobs, - viewBySwimlaneFieldName, - swimlaneLimit, - influencersFilterQuery, - noInfluencersConfigured -) { - return new Promise((resolve) => { - const finish = (resp) => { - if (resp !== undefined) { - const viewBySwimlaneData = processViewByResults( - resp.results, - fieldValues, - bounds, - viewBySwimlaneFieldName, - getSwimlaneBucketInterval( - selectedJobs, - getSwimlaneContainerWidth(noInfluencersConfigured) - ).asSeconds() - ); - - resolve({ - viewBySwimlaneData, - viewBySwimlaneDataLoading: false, - }); - } else { - resolve({ viewBySwimlaneDataLoading: false }); - } - }; - - if (selectedJobs === undefined || viewBySwimlaneFieldName === undefined) { - finish(); - return; - } else { - // Ensure the search bounds align to the bucketing interval used in the swimlane so - // that the first and last buckets are complete. - const timefilter = getTimefilter(); - const timefilterBounds = timefilter.getActiveBounds(); - const searchBounds = getBoundsRoundedToInterval( - timefilterBounds, - getSwimlaneBucketInterval(selectedJobs, getSwimlaneContainerWidth(noInfluencersConfigured)), - false - ); - const selectedJobIds = selectedJobs.map((d) => d.id); - - // load scores by influencer/jobId value and time. - // Pass the interval in seconds as the swimlane relies on a fixed number of seconds between buckets - // which wouldn't be the case if e.g. '1M' was used. - const interval = `${getSwimlaneBucketInterval( - selectedJobs, - getSwimlaneContainerWidth(noInfluencersConfigured) - ).asSeconds()}s`; - if (viewBySwimlaneFieldName !== VIEW_BY_JOB_LABEL) { - mlResultsService - .getInfluencerValueMaxScoreByTime( - selectedJobIds, - viewBySwimlaneFieldName, - fieldValues, - searchBounds.min.valueOf(), - searchBounds.max.valueOf(), - interval, - swimlaneLimit, - influencersFilterQuery - ) - .then(finish); - } else { - const jobIds = - fieldValues !== undefined && fieldValues.length > 0 ? fieldValues : selectedJobIds; - mlResultsService - .getScoresByBucket( - jobIds, - searchBounds.min.valueOf(), - searchBounds.max.valueOf(), - interval, - swimlaneLimit - ) - .then(finish); - } - } - }); -} - export async function loadTopInfluencers( selectedJobIds, earliestMs, @@ -871,6 +586,8 @@ export async function loadTopInfluencers( earliestMs, latestMs, MAX_INFLUENCER_FIELD_VALUES, + 10, + 1, influencers, influencersFilterQuery ) diff --git a/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts b/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts index a19750494afdc..068f43a140c90 100644 --- a/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts +++ b/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { useCallback, useMemo } from 'react'; import { useUrlState } from '../../util/url_state'; import { SWIMLANE_TYPE } from '../explorer_constants'; import { AppStateSelectedCells } from '../explorer_utils'; @@ -14,55 +15,55 @@ export const useSelectedCells = (): [ ] => { const [appState, setAppState] = useUrlState('_a'); - let selectedCells: AppStateSelectedCells | undefined; - // keep swimlane selection, restore selectedCells from AppState - if ( - appState && - appState.mlExplorerSwimlane && - appState.mlExplorerSwimlane.selectedType !== undefined - ) { - selectedCells = { - type: appState.mlExplorerSwimlane.selectedType, - lanes: appState.mlExplorerSwimlane.selectedLanes, - times: appState.mlExplorerSwimlane.selectedTimes, - showTopFieldValues: appState.mlExplorerSwimlane.showTopFieldValues, - viewByFieldName: appState.mlExplorerSwimlane.viewByFieldName, - }; - } + const selectedCells = useMemo(() => { + return appState?.mlExplorerSwimlane?.selectedType !== undefined + ? { + type: appState.mlExplorerSwimlane.selectedType, + lanes: appState.mlExplorerSwimlane.selectedLanes, + times: appState.mlExplorerSwimlane.selectedTimes, + showTopFieldValues: appState.mlExplorerSwimlane.showTopFieldValues, + viewByFieldName: appState.mlExplorerSwimlane.viewByFieldName, + } + : undefined; + // TODO fix appState to use memoization + }, [JSON.stringify(appState?.mlExplorerSwimlane)]); - const setSelectedCells = (swimlaneSelectedCells: AppStateSelectedCells) => { - const mlExplorerSwimlane = { ...appState.mlExplorerSwimlane }; + const setSelectedCells = useCallback( + (swimlaneSelectedCells: AppStateSelectedCells) => { + const mlExplorerSwimlane = { ...appState.mlExplorerSwimlane }; - if (swimlaneSelectedCells !== undefined) { - swimlaneSelectedCells.showTopFieldValues = false; + if (swimlaneSelectedCells !== undefined) { + swimlaneSelectedCells.showTopFieldValues = false; - const currentSwimlaneType = selectedCells?.type; - const currentShowTopFieldValues = selectedCells?.showTopFieldValues; - const newSwimlaneType = swimlaneSelectedCells?.type; + const currentSwimlaneType = selectedCells?.type; + const currentShowTopFieldValues = selectedCells?.showTopFieldValues; + const newSwimlaneType = swimlaneSelectedCells?.type; - if ( - (currentSwimlaneType === SWIMLANE_TYPE.OVERALL && - newSwimlaneType === SWIMLANE_TYPE.VIEW_BY) || - newSwimlaneType === SWIMLANE_TYPE.OVERALL || - currentShowTopFieldValues === true - ) { - swimlaneSelectedCells.showTopFieldValues = true; - } + if ( + (currentSwimlaneType === SWIMLANE_TYPE.OVERALL && + newSwimlaneType === SWIMLANE_TYPE.VIEW_BY) || + newSwimlaneType === SWIMLANE_TYPE.OVERALL || + currentShowTopFieldValues === true + ) { + swimlaneSelectedCells.showTopFieldValues = true; + } - mlExplorerSwimlane.selectedType = swimlaneSelectedCells.type; - mlExplorerSwimlane.selectedLanes = swimlaneSelectedCells.lanes; - mlExplorerSwimlane.selectedTimes = swimlaneSelectedCells.times; - mlExplorerSwimlane.showTopFieldValues = swimlaneSelectedCells.showTopFieldValues; - setAppState('mlExplorerSwimlane', mlExplorerSwimlane); - } else { - delete mlExplorerSwimlane.selectedType; - delete mlExplorerSwimlane.selectedLanes; - delete mlExplorerSwimlane.selectedTimes; - delete mlExplorerSwimlane.showTopFieldValues; - setAppState('mlExplorerSwimlane', mlExplorerSwimlane); - } - }; + mlExplorerSwimlane.selectedType = swimlaneSelectedCells.type; + mlExplorerSwimlane.selectedLanes = swimlaneSelectedCells.lanes; + mlExplorerSwimlane.selectedTimes = swimlaneSelectedCells.times; + mlExplorerSwimlane.showTopFieldValues = swimlaneSelectedCells.showTopFieldValues; + setAppState('mlExplorerSwimlane', mlExplorerSwimlane); + } else { + delete mlExplorerSwimlane.selectedType; + delete mlExplorerSwimlane.selectedLanes; + delete mlExplorerSwimlane.selectedTimes; + delete mlExplorerSwimlane.showTopFieldValues; + setAppState('mlExplorerSwimlane', mlExplorerSwimlane); + } + }, + [appState?.mlExplorerSwimlane, selectedCells] + ); return [selectedCells, setSelectedCells]; }; diff --git a/x-pack/plugins/ml/public/application/explorer/legacy_utils.ts b/x-pack/plugins/ml/public/application/explorer/legacy_utils.ts index 3b92ee3fa37f6..b85b0401c45ca 100644 --- a/x-pack/plugins/ml/public/application/explorer/legacy_utils.ts +++ b/x-pack/plugins/ml/public/application/explorer/legacy_utils.ts @@ -11,8 +11,3 @@ export function getChartContainerWidth() { const chartContainer = document.querySelector('.explorer-charts'); return Math.floor((chartContainer && chartContainer.clientWidth) || 0); } - -export function getSwimlaneContainerWidth() { - const explorerContainer = document.querySelector('.ml-explorer'); - return (explorerContainer && explorerContainer.clientWidth) || 0; -} diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts index 1614da14e355a..dd1d0516b6173 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts @@ -19,5 +19,6 @@ export function clearInfluencerFilterSettings(state: ExplorerState): ExplorerSta queryString: '', tableQueryString: '', ...getClearedSelectedAnomaliesState(), + viewByFromPage: 1, }; } 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 a26c0564c6b16..49f5794273a04 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,6 +17,7 @@ export const jobSelectionChange = (state: ExplorerState, payload: ActionPayload) noInfluencersConfigured: getInfluencers(selectedJobs).length === 0, overallSwimlaneData: getDefaultSwimlaneData(), selectedJobs, + viewByFromPage: 1, }; // clear filter if selected jobs have no influencers diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts index c31b26b7adb7b..c55c06c80ab81 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts @@ -27,7 +27,7 @@ import { setKqlQueryBarPlaceholder } from './set_kql_query_bar_placeholder'; export const explorerReducer = (state: ExplorerState, nextAction: Action): ExplorerState => { const { type, payload } = nextAction; - let nextState; + let nextState: ExplorerState; switch (type) { case EXPLORER_ACTION.CLEAR_INFLUENCER_FILTER_SETTINGS: @@ -39,6 +39,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo ...state, ...getClearedSelectedAnomaliesState(), loading: false, + viewByFromPage: 1, selectedJobs: [], }; break; @@ -82,22 +83,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo break; case EXPLORER_ACTION.SET_SWIMLANE_CONTAINER_WIDTH: - if (state.noInfluencersConfigured === true) { - // swimlane is full width, minus 30 for the 'no influencers' info icon, - // minus 170 for the lane labels, minus 50 padding - nextState = { ...state, swimlaneContainerWidth: payload - 250 }; - } else { - // swimlane width is 5 sixths of the window, - // minus 170 for the lane labels, minus 50 padding - nextState = { ...state, swimlaneContainerWidth: (payload / 6) * 5 - 220 }; - } - break; - - case EXPLORER_ACTION.SET_SWIMLANE_LIMIT: - nextState = { - ...state, - swimlaneLimit: payload, - }; + nextState = { ...state, swimlaneContainerWidth: payload }; break; case EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_FIELD_NAME: @@ -117,6 +103,9 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo ...getClearedSelectedAnomaliesState(), maskAll, viewBySwimlaneFieldName, + viewBySwimlaneData: getDefaultSwimlaneData(), + viewByFromPage: 1, + viewBySwimlaneDataLoading: true, }; break; @@ -125,7 +114,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo nextState = { ...state, annotationsData, - ...overallState, + overallSwimlaneData: overallState, tableData, viewBySwimlaneData: { ...getDefaultSwimlaneData(), @@ -134,6 +123,22 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo }; break; + case EXPLORER_ACTION.SET_VIEW_BY_FROM_PAGE: + nextState = { + ...state, + viewByFromPage: payload, + }; + break; + + case EXPLORER_ACTION.SET_VIEW_BY_PER_PAGE: + nextState = { + ...state, + // reset current page on the page size change + viewByFromPage: 1, + viewByPerPage: payload, + }; + break; + default: nextState = state; } @@ -155,7 +160,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo filteredFields: nextState.filteredFields, isAndOperator: nextState.isAndOperator, selectedJobs: nextState.selectedJobs, - selectedCells: nextState.selectedCells, + selectedCells: nextState.selectedCells!, }); const { bounds, selectedCells } = nextState; diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_influencer_filter_settings.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_influencer_filter_settings.ts index 819f6ca1cac92..be87de7da8c88 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_influencer_filter_settings.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_influencer_filter_settings.ts @@ -57,5 +57,6 @@ export function setInfluencerFilterSettings( filteredFields.includes(selectedViewByFieldName) === false, viewBySwimlaneFieldName: selectedViewByFieldName, viewBySwimlaneOptions: filteredViewBySwimlaneOptions, + viewByFromPage: 1, }; } diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts index 4e1a2af9b13a6..892b46467345b 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts @@ -19,7 +19,9 @@ import { TimeRangeBounds, OverallSwimlaneData, SwimlaneData, + ViewBySwimLaneData, } from '../../explorer_utils'; +import { SWIM_LANE_DEFAULT_PAGE_SIZE } from '../../explorer_constants'; export interface ExplorerState { annotationsData: any[]; @@ -42,14 +44,16 @@ export interface ExplorerState { selectedJobs: ExplorerJob[] | null; swimlaneBucketInterval: any; swimlaneContainerWidth: number; - swimlaneLimit: number; tableData: AnomaliesTableData; tableQueryString: string; viewByLoadedForTimeFormatted: string | null; - viewBySwimlaneData: SwimlaneData | OverallSwimlaneData; + viewBySwimlaneData: SwimlaneData | ViewBySwimLaneData; viewBySwimlaneDataLoading: boolean; viewBySwimlaneFieldName?: string; + viewByPerPage: number; + viewByFromPage: number; viewBySwimlaneOptions: string[]; + swimlaneLimit?: number; } function getDefaultIndexPattern() { @@ -78,7 +82,6 @@ export function getExplorerDefaultState(): ExplorerState { selectedJobs: null, swimlaneBucketInterval: undefined, swimlaneContainerWidth: 0, - swimlaneLimit: 10, tableData: { anomalies: [], examplesByJobId: [''], @@ -92,5 +95,8 @@ export function getExplorerDefaultState(): ExplorerState { viewBySwimlaneDataLoading: false, viewBySwimlaneFieldName: undefined, viewBySwimlaneOptions: [], + viewByPerPage: SWIM_LANE_DEFAULT_PAGE_SIZE, + viewByFromPage: 1, + swimlaneLimit: undefined, }; } diff --git a/x-pack/plugins/ml/public/application/explorer/select_limit/index.ts b/x-pack/plugins/ml/public/application/explorer/select_limit/index.ts deleted file mode 100644 index 5b7040e5c3606..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/select_limit/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* - * 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. - */ - -export { useSwimlaneLimit, SelectLimit } from './select_limit'; diff --git a/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.test.tsx b/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.test.tsx deleted file mode 100644 index cf65419e4bd80..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.test.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { shallow } from 'enzyme'; -import { SelectLimit } from './select_limit'; - -describe('SelectLimit', () => { - test('creates correct initial selected value', () => { - const wrapper = shallow(); - expect(wrapper.props().value).toEqual(10); - }); - - test('state for currently selected value is updated correctly on click', () => { - const wrapper = shallow(); - expect(wrapper.props().value).toEqual(10); - - act(() => { - wrapper.simulate('change', { target: { value: 25 } }); - }); - wrapper.update(); - - expect(wrapper.props().value).toEqual(10); - }); -}); diff --git a/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.tsx b/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.tsx deleted file mode 100644 index 7a2df1a0f0535..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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. - */ - -/* - * React component for rendering a select element with limit options. - */ -import React from 'react'; -import useObservable from 'react-use/lib/useObservable'; -import { BehaviorSubject } from 'rxjs'; - -import { EuiSelect } from '@elastic/eui'; - -const limitOptions = [5, 10, 25, 50]; - -const euiOptions = limitOptions.map((limit) => ({ - value: limit, - text: `${limit}`, -})); - -export const defaultLimit = limitOptions[1]; -export const limit$ = new BehaviorSubject(defaultLimit); - -export const useSwimlaneLimit = (): [number, (newLimit: number) => void] => { - const limit = useObservable(limit$, defaultLimit); - - return [limit!, (newLimit: number) => limit$.next(newLimit)]; -}; - -export const SelectLimit = () => { - const [limit, setLimit] = useSwimlaneLimit(); - - function onChange(e: React.ChangeEvent) { - setLimit(parseInt(e.target.value, 10)); - } - - return ; -}; diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx index 57d1fd81000b7..e34e1d26c9cab 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -5,7 +5,14 @@ */ import React, { FC, useCallback, useState } from 'react'; -import { EuiResizeObserver, EuiText } from '@elastic/eui'; +import { + EuiText, + EuiLoadingChart, + EuiResizeObserver, + EuiFlexGroup, + EuiFlexItem, + EuiEmptyPrompt, +} from '@elastic/eui'; import { throttle } from 'lodash'; import { @@ -14,48 +21,139 @@ import { } from '../../application/explorer/explorer_swimlane'; import { MlTooltipComponent } from '../../application/components/chart_tooltip'; +import { SwimLanePagination } from './swimlane_pagination'; +import { SWIMLANE_TYPE } from './explorer_constants'; +import { ViewBySwimLaneData } from './explorer_utils'; +/** + * Ignore insignificant resize, e.g. browser scrollbar appearance. + */ +const RESIZE_IGNORED_DIFF_PX = 20; const RESIZE_THROTTLE_TIME_MS = 500; +export function isViewBySwimLaneData(arg: any): arg is ViewBySwimLaneData { + return arg && arg.hasOwnProperty('cardinality'); +} + +/** + * Anomaly swim lane container responsible for handling resizing, pagination and injecting + * tooltip service. + * + * @param children + * @param onResize + * @param perPage + * @param fromPage + * @param swimlaneLimit + * @param onPaginationChange + * @param props + * @constructor + */ export const SwimlaneContainer: FC< Omit & { onResize: (width: number) => void; + fromPage?: number; + perPage?: number; + swimlaneLimit?: number; + onPaginationChange?: (arg: { perPage?: number; fromPage?: number }) => void; + isLoading: boolean; + noDataWarning: string | JSX.Element | null; } -> = ({ children, onResize, ...props }) => { +> = ({ + children, + onResize, + perPage, + fromPage, + swimlaneLimit, + onPaginationChange, + isLoading, + noDataWarning, + ...props +}) => { const [chartWidth, setChartWidth] = useState(0); const resizeHandler = useCallback( throttle((e: { width: number; height: number }) => { const labelWidth = 200; - setChartWidth(e.width - labelWidth); - onResize(e.width); + const resultNewWidth = e.width - labelWidth; + if (Math.abs(resultNewWidth - chartWidth) > RESIZE_IGNORED_DIFF_PX) { + setChartWidth(resultNewWidth); + onResize(resultNewWidth); + } }, RESIZE_THROTTLE_TIME_MS), - [] + [chartWidth] ); + const showSwimlane = + props.swimlaneData && + props.swimlaneData.laneLabels && + props.swimlaneData.laneLabels.length > 0 && + props.swimlaneData.points.length > 0; + + const isPaginationVisible = + (showSwimlane || isLoading) && + swimlaneLimit !== undefined && + onPaginationChange && + props.swimlaneType === SWIMLANE_TYPE.VIEW_BY && + fromPage && + perPage; + return ( - - {(resizeRef) => ( -
{ - resizeRef(el); - }} - > -
- - - {(tooltipService) => ( - + + {(resizeRef) => ( + { + resizeRef(el); + }} + data-test-subj="mlSwimLaneContainer" + > + + + {showSwimlane && !isLoading && ( + + {(tooltipService) => ( + + )} + + )} + {isLoading && ( + + + + )} + {!isLoading && !showSwimlane && ( + {noDataWarning}} /> )} - - -
-
- )} -
+ +
+ {isPaginationVisible && ( + + + + )} + + )} + + ); }; diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_pagination.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_pagination.tsx new file mode 100644 index 0000000000000..0607f7fd35fad --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_pagination.tsx @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useCallback, useState } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiContextMenuPanel, + EuiPagination, + EuiContextMenuItem, + EuiButtonEmpty, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +interface SwimLanePaginationProps { + fromPage: number; + perPage: number; + cardinality: number; + onPaginationChange: (arg: { perPage?: number; fromPage?: number }) => void; +} + +export const SwimLanePagination: FC = ({ + cardinality, + fromPage, + perPage, + onPaginationChange, +}) => { + const componentFromPage = fromPage - 1; + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const onButtonClick = () => setIsPopoverOpen(() => !isPopoverOpen); + const closePopover = () => setIsPopoverOpen(false); + + const goToPage = useCallback((pageNumber: number) => { + onPaginationChange({ fromPage: pageNumber + 1 }); + }, []); + + const setPerPage = useCallback((perPageUpdate: number) => { + onPaginationChange({ perPage: perPageUpdate }); + }, []); + + const pageCount = Math.ceil(cardinality / perPage); + + const items = [5, 10, 20, 50, 100].map((v) => { + return ( + { + closePopover(); + setPerPage(v); + }} + > + + + ); + }); + + return ( + + + + + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + panelPaddingSize="none" + > + + + + + + + + + ); +}; 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 2e355c6073abd..52b4408d1ac5b 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx @@ -22,7 +22,6 @@ import { ml } from '../../services/ml_api_service'; import { useExplorerData } from '../../explorer/actions'; import { explorerService } from '../../explorer/explorer_dashboard_service'; import { getDateFormatTz } from '../../explorer/explorer_utils'; -import { useSwimlaneLimit } from '../../explorer/select_limit'; import { useJobSelection } from '../../components/job_selector/use_job_selection'; import { useShowCharts } from '../../components/controls/checkbox_showcharts'; import { useTableInterval } from '../../components/controls/select_interval'; @@ -30,6 +29,7 @@ import { useTableSeverity } from '../../components/controls/select_severity'; import { useUrlState } from '../../util/url_state'; import { ANOMALY_DETECTION_BREADCRUMB, ML_BREADCRUMB } from '../breadcrumbs'; import { useTimefilter } from '../../contexts/kibana'; +import { isViewBySwimLaneData } from '../../explorer/swimlane_container'; const breadcrumbs = [ ML_BREADCRUMB, @@ -151,10 +151,6 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim const [showCharts] = useShowCharts(); const [tableInterval] = useTableInterval(); const [tableSeverity] = useTableSeverity(); - const [swimlaneLimit] = useSwimlaneLimit(); - useEffect(() => { - explorerService.setSwimlaneLimit(swimlaneLimit); - }, [swimlaneLimit]); const [selectedCells, setSelectedCells] = useSelectedCells(); useEffect(() => { @@ -170,14 +166,26 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim selectedCells, selectedJobs: explorerState.selectedJobs, swimlaneBucketInterval: explorerState.swimlaneBucketInterval, - swimlaneLimit: explorerState.swimlaneLimit, tableInterval: tableInterval.val, tableSeverity: tableSeverity.val, viewBySwimlaneFieldName: explorerState.viewBySwimlaneFieldName, + swimlaneContainerWidth: explorerState.swimlaneContainerWidth, + viewByPerPage: explorerState.viewByPerPage, + viewByFromPage: explorerState.viewByFromPage, }) || undefined; + useEffect(() => { - loadExplorerData(loadExplorerDataConfig); + if (explorerState && explorerState.swimlaneContainerWidth > 0) { + loadExplorerData({ + ...loadExplorerDataConfig, + swimlaneLimit: + explorerState?.viewBySwimlaneData && + isViewBySwimLaneData(explorerState?.viewBySwimlaneData) + ? explorerState?.viewBySwimlaneData.cardinality + : undefined, + }); + } }, [JSON.stringify(loadExplorerDataConfig)]); if (explorerState === undefined || refresh === undefined || showCharts === undefined) { diff --git a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.test.tsx b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.test.tsx index ac4882b0055ae..11ec074bac1db 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.test.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.test.tsx @@ -12,41 +12,47 @@ import { I18nProvider } from '@kbn/i18n/react'; import { TimeSeriesExplorerUrlStateManager } from './timeseriesexplorer'; -jest.mock('../../contexts/kibana/kibana_context', () => ({ - useMlKibana: () => { - return { - services: { - uiSettings: { get: jest.fn() }, - data: { - query: { - timefilter: { +jest.mock('../../contexts/kibana/kibana_context', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { of } = require('rxjs'); + return { + useMlKibana: () => { + return { + services: { + uiSettings: { get: jest.fn() }, + data: { + query: { timefilter: { - disableTimeRangeSelector: jest.fn(), - disableAutoRefreshSelector: jest.fn(), - enableTimeRangeSelector: jest.fn(), - enableAutoRefreshSelector: jest.fn(), - getRefreshInterval: jest.fn(), - setRefreshInterval: jest.fn(), - getTime: jest.fn(), - isAutoRefreshSelectorEnabled: jest.fn(), - isTimeRangeSelectorEnabled: jest.fn(), - getRefreshIntervalUpdate$: jest.fn(), - getTimeUpdate$: jest.fn(), - getEnabledUpdated$: jest.fn(), + timefilter: { + disableTimeRangeSelector: jest.fn(), + disableAutoRefreshSelector: jest.fn(), + enableTimeRangeSelector: jest.fn(), + enableAutoRefreshSelector: jest.fn(), + getRefreshInterval: jest.fn(), + setRefreshInterval: jest.fn(), + getTime: jest.fn(), + isAutoRefreshSelectorEnabled: jest.fn(), + isTimeRangeSelectorEnabled: jest.fn(), + getRefreshIntervalUpdate$: jest.fn(), + getTimeUpdate$: jest.fn(() => { + return of(); + }), + getEnabledUpdated$: jest.fn(), + }, + history: { get: jest.fn() }, }, - history: { get: jest.fn() }, }, }, - }, - notifications: { - toasts: { - addDanger: () => {}, + notifications: { + toasts: { + addDanger: () => {}, + }, }, }, - }, - }; - }, -})); + }; + }, + }; +}); jest.mock('../../util/dependency_cache', () => ({ getToastNotifications: () => ({ addSuccess: jest.fn(), addDanger: jest.fn() }), 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 f0b93c876526b..c247fd9765e96 100644 --- a/x-pack/plugins/ml/public/application/routing/use_refresh.ts +++ b/x-pack/plugins/ml/public/application/routing/use_refresh.ts @@ -5,26 +5,40 @@ */ import { useObservable } from 'react-use'; -import { merge, Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { merge } from 'rxjs'; +import { map, skip } from 'rxjs/operators'; +import { useMemo } from 'react'; import { annotationsRefresh$ } from '../services/annotations_service'; -import { - mlTimefilterRefresh$, - mlTimefilterTimeChange$, -} from '../services/timefilter_refresh_service'; +import { mlTimefilterRefresh$ } from '../services/timefilter_refresh_service'; +import { useTimefilter } from '../contexts/kibana'; export interface Refresh { lastRefresh: number; timeRange?: { start: string; end: string }; } -const refresh$: Observable = merge( - mlTimefilterRefresh$, - mlTimefilterTimeChange$, - annotationsRefresh$.pipe(map((d) => ({ lastRefresh: d }))) -); - +/** + * Hook that provides the latest refresh timestamp + * and the most recent applied time range. + */ export const useRefresh = () => { + const timefilter = useTimefilter(); + + const refresh$ = useMemo(() => { + return merge( + mlTimefilterRefresh$, + timefilter.getTimeUpdate$().pipe( + // skip initially emitted value + skip(1), + map((_) => { + const { from, to } = timefilter.getTime(); + return { lastRefresh: Date.now(), timeRange: { start: from, end: to } }; + }) + ), + annotationsRefresh$.pipe(map((d) => ({ lastRefresh: d }))) + ); + }, []); + return useObservable(refresh$); }; diff --git a/x-pack/plugins/ml/public/application/services/explorer_service.ts b/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts similarity index 82% rename from x-pack/plugins/ml/public/application/services/explorer_service.ts rename to x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts index 0944328db0052..f2e362f754f2b 100644 --- a/x-pack/plugins/ml/public/application/services/explorer_service.ts +++ b/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts @@ -12,14 +12,19 @@ import { UI_SETTINGS, } from '../../../../../../src/plugins/data/public'; import { getBoundsRoundedToInterval, TimeBuckets, TimeRangeBounds } from '../util/time_buckets'; -import { ExplorerJob, OverallSwimlaneData, SwimlaneData } from '../explorer/explorer_utils'; +import { + ExplorerJob, + OverallSwimlaneData, + SwimlaneData, + ViewBySwimLaneData, +} from '../explorer/explorer_utils'; import { VIEW_BY_JOB_LABEL } from '../explorer/explorer_constants'; import { MlResultsService } from './results_service'; /** - * Anomaly Explorer Service + * Service for retrieving anomaly swim lanes data. */ -export class ExplorerService { +export class AnomalyTimelineService { private timeBuckets: TimeBuckets; private _customTimeRange: TimeRange | undefined; @@ -130,12 +135,27 @@ export class ExplorerService { return overallSwimlaneData; } + /** + * Fetches view by swim lane data. + * + * @param fieldValues + * @param bounds + * @param selectedJobs + * @param viewBySwimlaneFieldName + * @param swimlaneLimit + * @param perPage + * @param fromPage + * @param swimlaneContainerWidth + * @param influencersFilterQuery + */ public async loadViewBySwimlane( fieldValues: string[], bounds: { earliest: number; latest: number }, selectedJobs: ExplorerJob[], viewBySwimlaneFieldName: string, swimlaneLimit: number, + perPage: number, + fromPage: number, swimlaneContainerWidth: number, influencersFilterQuery?: any ): Promise { @@ -172,7 +192,8 @@ export class ExplorerService { searchBounds.min.valueOf(), searchBounds.max.valueOf(), interval, - swimlaneLimit + perPage, + fromPage ); } else { response = await this.mlResultsService.getInfluencerValueMaxScoreByTime( @@ -183,6 +204,8 @@ export class ExplorerService { searchBounds.max.valueOf(), interval, swimlaneLimit, + perPage, + fromPage, influencersFilterQuery ); } @@ -193,6 +216,7 @@ export class ExplorerService { const viewBySwimlaneData = this.processViewByResults( response.results, + response.cardinality, fieldValues, bounds, viewBySwimlaneFieldName, @@ -204,6 +228,55 @@ export class ExplorerService { return viewBySwimlaneData; } + public async loadViewByTopFieldValuesForSelectedTime( + earliestMs: number, + latestMs: number, + selectedJobs: ExplorerJob[], + viewBySwimlaneFieldName: string, + swimlaneLimit: number, + perPage: number, + fromPage: number, + swimlaneContainerWidth: number + ) { + const selectedJobIds = selectedJobs.map((d) => d.id); + + // Find the top field values for the selected time, and then load the 'view by' + // swimlane over the full time range for those specific field values. + if (viewBySwimlaneFieldName !== VIEW_BY_JOB_LABEL) { + const resp = await this.mlResultsService.getTopInfluencers( + selectedJobIds, + earliestMs, + latestMs, + swimlaneLimit, + perPage, + fromPage + ); + if (resp.influencers[viewBySwimlaneFieldName] === undefined) { + return []; + } + + const topFieldValues: any[] = []; + const topInfluencers = resp.influencers[viewBySwimlaneFieldName]; + if (Array.isArray(topInfluencers)) { + topInfluencers.forEach((influencerData) => { + if (influencerData.maxAnomalyScore > 0) { + topFieldValues.push(influencerData.influencerFieldValue); + } + }); + } + return topFieldValues; + } else { + const resp = await this.mlResultsService.getScoresByBucket( + selectedJobIds, + earliestMs, + latestMs, + this.getSwimlaneBucketInterval(selectedJobs, swimlaneContainerWidth).asSeconds() + 's', + swimlaneLimit + ); + return Object.keys(resp.results); + } + } + private getTimeBounds(): TimeRangeBounds { return this._customTimeRange !== undefined ? this.timeFilter.calculateBounds(this._customTimeRange) @@ -245,6 +318,7 @@ export class ExplorerService { private processViewByResults( scoresByInfluencerAndTime: Record, + cardinality: number, sortedLaneValues: string[], bounds: any, viewBySwimlaneFieldName: string, @@ -254,7 +328,7 @@ export class ExplorerService { // Sorts the lanes according to the supplied array of lane // values in the order in which they should be displayed, // or pass an empty array to sort lanes according to max score over all time. - const dataset: OverallSwimlaneData = { + const dataset: ViewBySwimLaneData = { fieldName: viewBySwimlaneFieldName, points: [], laneLabels: [], @@ -262,6 +336,7 @@ export class ExplorerService { // Set the earliest and latest to be the same as the overall swim lane. earliest: bounds.earliest, latest: bounds.latest, + cardinality, }; const maxScoreByLaneLabel: Record = {}; diff --git a/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts b/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts index a618534d7ae00..00adb2d325833 100644 --- a/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts +++ b/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts @@ -37,7 +37,7 @@ describe('DashboardService', () => { // assert expect(mockSavedObjectClient.find).toHaveBeenCalledWith({ type: 'dashboard', - perPage: 10, + perPage: 1000, search: `test*`, searchFields: ['title^3', 'description'], }); diff --git a/x-pack/plugins/ml/public/application/services/dashboard_service.ts b/x-pack/plugins/ml/public/application/services/dashboard_service.ts index 7f2bb71d18eb9..d6ccfc2f203e9 100644 --- a/x-pack/plugins/ml/public/application/services/dashboard_service.ts +++ b/x-pack/plugins/ml/public/application/services/dashboard_service.ts @@ -34,7 +34,7 @@ export function dashboardServiceProvider( async fetchDashboards(query?: string) { return await savedObjectClient.find({ type: 'dashboard', - perPage: 10, + perPage: 1000, search: query ? `${query}*` : '', searchFields: ['title^3', 'description'], }); diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index af6944d7ae2d2..d1b6f95f32bed 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -12,7 +12,7 @@ import { annotations } from './annotations'; import { dataFrameAnalytics } from './data_frame_analytics'; import { filters } from './filters'; import { resultsApiProvider } from './results'; -import { jobs } from './jobs'; +import { jobsApiProvider } from './jobs'; import { fileDatavisualizer } from './datavisualizer'; import { MlServerDefaults, MlServerLimits } from '../../../../common/types/ml_server_info'; @@ -726,7 +726,7 @@ export function mlApiServicesProvider(httpService: HttpService) { dataFrameAnalytics, filters, results: resultsApiProvider(httpService), - jobs, + jobs: jobsApiProvider(httpService), fileDatavisualizer, }; } diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts index 6aa62da3f0768..d356fc0ef339b 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { http } from '../http_service'; +import { HttpService } from '../http_service'; import { basePath } from './index'; import { Dictionary } from '../../../../common/types/common'; @@ -24,10 +24,10 @@ import { import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../common/constants/categorization_job'; import { Category } from '../../../../common/types/categories'; -export const jobs = { +export const jobsApiProvider = (httpService: HttpService) => ({ jobsSummary(jobIds: string[]) { const body = JSON.stringify({ jobIds }); - return http({ + return httpService.http({ path: `${basePath()}/jobs/jobs_summary`, method: 'POST', body, @@ -36,7 +36,10 @@ export const jobs = { jobsWithTimerange(dateFormatTz: string) { const body = JSON.stringify({ dateFormatTz }); - return http<{ jobs: MlJobWithTimeRange[]; jobsMap: Dictionary }>({ + return httpService.http<{ + jobs: MlJobWithTimeRange[]; + jobsMap: Dictionary; + }>({ path: `${basePath()}/jobs/jobs_with_time_range`, method: 'POST', body, @@ -45,7 +48,7 @@ export const jobs = { jobs(jobIds: string[]) { const body = JSON.stringify({ jobIds }); - return http({ + return httpService.http({ path: `${basePath()}/jobs/jobs`, method: 'POST', body, @@ -53,7 +56,7 @@ export const jobs = { }, groups() { - return http({ + return httpService.http({ path: `${basePath()}/jobs/groups`, method: 'GET', }); @@ -61,7 +64,7 @@ export const jobs = { updateGroups(updatedJobs: string[]) { const body = JSON.stringify({ jobs: updatedJobs }); - return http({ + return httpService.http({ path: `${basePath()}/jobs/update_groups`, method: 'POST', body, @@ -75,7 +78,7 @@ export const jobs = { end, }); - return http({ + return httpService.http({ path: `${basePath()}/jobs/force_start_datafeeds`, method: 'POST', body, @@ -84,7 +87,7 @@ export const jobs = { stopDatafeeds(datafeedIds: string[]) { const body = JSON.stringify({ datafeedIds }); - return http({ + return httpService.http({ path: `${basePath()}/jobs/stop_datafeeds`, method: 'POST', body, @@ -93,7 +96,7 @@ export const jobs = { deleteJobs(jobIds: string[]) { const body = JSON.stringify({ jobIds }); - return http({ + return httpService.http({ path: `${basePath()}/jobs/delete_jobs`, method: 'POST', body, @@ -102,7 +105,7 @@ export const jobs = { closeJobs(jobIds: string[]) { const body = JSON.stringify({ jobIds }); - return http({ + return httpService.http({ path: `${basePath()}/jobs/close_jobs`, method: 'POST', body, @@ -111,7 +114,7 @@ export const jobs = { forceStopAndCloseJob(jobId: string) { const body = JSON.stringify({ jobId }); - return http<{ success: boolean }>({ + return httpService.http<{ success: boolean }>({ path: `${basePath()}/jobs/force_stop_and_close_job`, method: 'POST', body, @@ -121,7 +124,7 @@ export const jobs = { jobAuditMessages(jobId: string, from?: number) { const jobIdString = jobId !== undefined ? `/${jobId}` : ''; const query = from !== undefined ? { from } : {}; - return http({ + return httpService.http({ path: `${basePath()}/job_audit_messages/messages${jobIdString}`, method: 'GET', query, @@ -129,7 +132,7 @@ export const jobs = { }, deletingJobTasks() { - return http({ + return httpService.http({ path: `${basePath()}/jobs/deleting_jobs_tasks`, method: 'GET', }); @@ -137,7 +140,7 @@ export const jobs = { jobsExist(jobIds: string[]) { const body = JSON.stringify({ jobIds }); - return http({ + return httpService.http({ path: `${basePath()}/jobs/jobs_exist`, method: 'POST', body, @@ -146,7 +149,7 @@ export const jobs = { newJobCaps(indexPatternTitle: string, isRollup: boolean = false) { const query = isRollup === true ? { rollup: true } : {}; - return http({ + return httpService.http({ path: `${basePath()}/jobs/new_job_caps/${indexPatternTitle}`, method: 'GET', query, @@ -175,7 +178,7 @@ export const jobs = { splitFieldName, splitFieldValue, }); - return http({ + return httpService.http({ path: `${basePath()}/jobs/new_job_line_chart`, method: 'POST', body, @@ -202,7 +205,7 @@ export const jobs = { aggFieldNamePairs, splitFieldName, }); - return http({ + return httpService.http({ path: `${basePath()}/jobs/new_job_population_chart`, method: 'POST', body, @@ -210,7 +213,7 @@ export const jobs = { }, getAllJobAndGroupIds() { - return http({ + return httpService.http({ path: `${basePath()}/jobs/all_jobs_and_group_ids`, method: 'GET', }); @@ -222,7 +225,7 @@ export const jobs = { start, end, }); - return http<{ progress: number; isRunning: boolean; isJobClosed: boolean }>({ + return httpService.http<{ progress: number; isRunning: boolean; isJobClosed: boolean }>({ path: `${basePath()}/jobs/look_back_progress`, method: 'POST', body, @@ -249,7 +252,7 @@ export const jobs = { end, analyzer, }); - return http<{ + return httpService.http<{ examples: CategoryFieldExample[]; sampleSize: number; overallValidStatus: CATEGORY_EXAMPLES_VALIDATION_STATUS; @@ -263,7 +266,10 @@ export const jobs = { topCategories(jobId: string, count: number) { const body = JSON.stringify({ jobId, count }); - return http<{ total: number; categories: Array<{ count?: number; category: Category }> }>({ + return httpService.http<{ + total: number; + categories: Array<{ count?: number; category: Category }>; + }>({ path: `${basePath()}/jobs/top_categories`, method: 'POST', body, @@ -278,10 +284,13 @@ export const jobs = { calendarEvents?: Array<{ start: number; end: number; description: string }> ) { const body = JSON.stringify({ jobId, snapshotId, replay, end, calendarEvents }); - return http<{ total: number; categories: Array<{ count?: number; category: Category }> }>({ + return httpService.http<{ + total: number; + categories: Array<{ count?: number; category: Category }>; + }>({ path: `${basePath()}/jobs/revert_model_snapshot`, method: 'POST', body, }); }, -}; +}); diff --git a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts index 1b2c01ab73fce..b26528b76037b 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts @@ -14,9 +14,19 @@ export function resultsServiceProvider( earliestMs: number, latestMs: number, interval: string | number, - maxResults: number + perPage?: number, + fromPage?: number + ): Promise; + getTopInfluencers( + selectedJobIds: string[], + earliestMs: number, + latestMs: number, + maxFieldValues: number, + perPage?: number, + fromPage?: number, + influencers?: any[], + influencersFilterQuery?: any ): Promise; - getTopInfluencers(): Promise; getTopInfluencerValues(): Promise; getOverallBucketScores( jobIds: any, @@ -33,6 +43,8 @@ export function resultsServiceProvider( latestMs: number, interval: string, maxResults: number, + perPage: number, + fromPage: number, influencersFilterQuery: any ): Promise; getRecordInfluencers(): Promise; diff --git a/x-pack/plugins/ml/public/application/services/results_service/results_service.js b/x-pack/plugins/ml/public/application/services/results_service/results_service.js index 9e3fed189b6f4..55ddb1de3529e 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/results_service.js +++ b/x-pack/plugins/ml/public/application/services/results_service/results_service.js @@ -9,6 +9,10 @@ import _ from 'lodash'; import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; import { escapeForElasticsearchQuery } from '../../util/string_utils'; import { ML_RESULTS_INDEX_PATTERN } from '../../../../common/constants/index_patterns'; +import { + ANOMALY_SWIM_LANE_HARD_LIMIT, + SWIM_LANE_DEFAULT_PAGE_SIZE, +} from '../../explorer/explorer_constants'; /** * Service for carrying out Elasticsearch queries to obtain data for the Ml Results dashboards. @@ -24,7 +28,7 @@ export function resultsServiceProvider(mlApiServices) { // Pass an empty array or ['*'] to search over all job IDs. // Returned response contains a results property, with a key for job // which has results for the specified time range. - getScoresByBucket(jobIds, earliestMs, latestMs, interval, maxResults) { + getScoresByBucket(jobIds, earliestMs, latestMs, interval, perPage = 10, fromPage = 1) { return new Promise((resolve, reject) => { const obj = { success: true, @@ -88,7 +92,7 @@ export function resultsServiceProvider(mlApiServices) { jobId: { terms: { field: 'job_id', - size: maxResults !== undefined ? maxResults : 5, + size: jobIds?.length ?? 1, order: { anomalyScore: 'desc', }, @@ -99,6 +103,12 @@ export function resultsServiceProvider(mlApiServices) { field: 'anomaly_score', }, }, + bucketTruncate: { + bucket_sort: { + from: (fromPage - 1) * perPage, + size: perPage === 0 ? 1 : perPage, + }, + }, byTime: { date_histogram: { field: 'timestamp', @@ -158,7 +168,9 @@ export function resultsServiceProvider(mlApiServices) { jobIds, earliestMs, latestMs, - maxFieldValues = 10, + maxFieldValues = ANOMALY_SWIM_LANE_HARD_LIMIT, + perPage = 10, + fromPage = 1, influencers = [], influencersFilterQuery ) { @@ -272,6 +284,12 @@ export function resultsServiceProvider(mlApiServices) { }, }, aggs: { + bucketTruncate: { + bucket_sort: { + from: (fromPage - 1) * perPage, + size: perPage, + }, + }, maxAnomalyScore: { max: { field: 'influencer_score', @@ -472,7 +490,9 @@ export function resultsServiceProvider(mlApiServices) { earliestMs, latestMs, interval, - maxResults, + maxResults = ANOMALY_SWIM_LANE_HARD_LIMIT, + perPage = SWIM_LANE_DEFAULT_PAGE_SIZE, + fromPage = 1, influencersFilterQuery ) { return new Promise((resolve, reject) => { @@ -565,10 +585,15 @@ export function resultsServiceProvider(mlApiServices) { }, }, aggs: { + influencerValuesCardinality: { + cardinality: { + field: 'influencer_field_value', + }, + }, influencerFieldValues: { terms: { field: 'influencer_field_value', - size: maxResults !== undefined ? maxResults : 10, + size: !!maxResults ? maxResults : ANOMALY_SWIM_LANE_HARD_LIMIT, order: { maxAnomalyScore: 'desc', }, @@ -579,6 +604,12 @@ export function resultsServiceProvider(mlApiServices) { field: 'influencer_score', }, }, + bucketTruncate: { + bucket_sort: { + from: (fromPage - 1) * perPage, + size: perPage, + }, + }, byTime: { date_histogram: { field: 'timestamp', @@ -618,6 +649,8 @@ export function resultsServiceProvider(mlApiServices) { obj.results[fieldValue] = fieldValues; }); + obj.cardinality = resp.aggregations?.influencerValuesCardinality?.value ?? 0; + resolve(obj); }) .catch((resp) => { diff --git a/x-pack/plugins/ml/public/application/services/timefilter_refresh_service.tsx b/x-pack/plugins/ml/public/application/services/timefilter_refresh_service.tsx index 86c07a3577f7b..4f5d0723d65a4 100644 --- a/x-pack/plugins/ml/public/application/services/timefilter_refresh_service.tsx +++ b/x-pack/plugins/ml/public/application/services/timefilter_refresh_service.tsx @@ -9,4 +9,3 @@ import { Subject } from 'rxjs'; import { Refresh } from '../routing/use_refresh'; export const mlTimefilterRefresh$ = new Subject>(); -export const mlTimefilterTimeChange$ = new Subject>(); 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 3b4562628051e..83070a5d94ba0 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,10 +16,10 @@ import { IContainer, } from '../../../../../../src/plugins/embeddable/public'; import { MlStartDependencies } from '../../plugin'; -import { ExplorerSwimlaneContainer } from './explorer_swimlane_container'; +import { EmbeddableSwimLaneContainer } from './embeddable_swim_lane_container'; import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; import { JobId } from '../../../common/types/anomaly_detection_jobs'; -import { ExplorerService } from '../../application/services/explorer_service'; +import { AnomalyTimelineService } from '../../application/services/anomaly_timeline_service'; import { Filter, Query, @@ -40,7 +40,7 @@ export interface AnomalySwimlaneEmbeddableCustomInput { jobIds: JobId[]; swimlaneType: SwimlaneType; viewBy?: string; - limit?: number; + perPage?: number; // Embeddable inputs which are not included in the default interface filters: Filter[]; @@ -58,12 +58,12 @@ export interface AnomalySwimlaneEmbeddableCustomOutput { jobIds: JobId[]; swimlaneType: SwimlaneType; viewBy?: string; - limit?: number; + perPage?: number; } export interface AnomalySwimlaneServices { anomalyDetectorService: AnomalyDetectorService; - explorerService: ExplorerService; + anomalyTimelineService: AnomalyTimelineService; } export type AnomalySwimlaneEmbeddableServices = [ @@ -101,14 +101,20 @@ export class AnomalySwimlaneEmbeddable extends Embeddable< super.render(node); this.node = node; + const I18nContext = this.services[0].i18n.Context; + ReactDOM.render( - this.updateOutput(output)} - />, + + { + this.updateInput(input); + }} + /> + , node ); } diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.test.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.test.tsx index 6b2ab89de8a5d..243369982ac1f 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.test.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.test.tsx @@ -46,6 +46,9 @@ describe('AnomalySwimlaneEmbeddableFactory', () => { }); expect(Object.keys(createServices[0])).toEqual(Object.keys(coreStart)); expect(createServices[1]).toMatchObject(pluginsStart); - expect(Object.keys(createServices[2])).toEqual(['anomalyDetectorService', 'explorerService']); + expect(Object.keys(createServices[2])).toEqual([ + 'anomalyDetectorService', + 'anomalyTimelineService', + ]); }); }); 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 37c2cfb3e029b..0d587b428d89b 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 @@ -22,7 +22,7 @@ import { import { MlStartDependencies } from '../../plugin'; import { HttpService } from '../../application/services/http_service'; import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; -import { ExplorerService } from '../../application/services/explorer_service'; +import { AnomalyTimelineService } from '../../application/services/anomaly_timeline_service'; import { mlResultsServiceProvider } from '../../application/services/results_service'; import { resolveAnomalySwimlaneUserInput } from './anomaly_swimlane_setup_flyout'; import { mlApiServicesProvider } from '../../application/services/ml_api_service'; @@ -44,14 +44,10 @@ export class AnomalySwimlaneEmbeddableFactory } public async getExplicitInput(): Promise> { - const [{ overlays, uiSettings }, , { anomalyDetectorService }] = await this.getServices(); + const [coreStart] = await this.getServices(); try { - return await resolveAnomalySwimlaneUserInput({ - anomalyDetectorService, - overlays, - uiSettings, - }); + return await resolveAnomalySwimlaneUserInput(coreStart); } catch (e) { return Promise.reject(); } @@ -62,13 +58,13 @@ export class AnomalySwimlaneEmbeddableFactory const httpService = new HttpService(coreStart.http); const anomalyDetectorService = new AnomalyDetectorService(httpService); - const explorerService = new ExplorerService( + const anomalyTimelineService = new AnomalyTimelineService( pluginsStart.data.query.timefilter.timefilter, coreStart.uiSettings, mlResultsServiceProvider(mlApiServicesProvider(httpService)) ); - return [coreStart, pluginsStart, { anomalyDetectorService, explorerService }]; + return [coreStart, pluginsStart, { anomalyDetectorService, anomalyTimelineService }]; } public async create( diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx index 4977ece54bb57..be9a332e51dbc 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 @@ -27,7 +27,7 @@ export interface AnomalySwimlaneInitializerProps { defaultTitle: string; influencers: string[]; initialInput?: Partial< - Pick + Pick >; onCreate: (swimlaneProps: { panelTitle: string; @@ -38,11 +38,6 @@ export interface AnomalySwimlaneInitializerProps { onCancel: () => void; } -const limitOptions = [5, 10, 25, 50].map((limit) => ({ - value: limit, - text: `${limit}`, -})); - export const AnomalySwimlaneInitializer: FC = ({ defaultTitle, influencers, @@ -55,7 +50,6 @@ export const AnomalySwimlaneInitializer: FC = ( initialInput?.swimlaneType ?? SWIMLANE_TYPE.OVERALL ); const [viewBySwimlaneFieldName, setViewBySwimlaneFieldName] = useState(initialInput?.viewBy); - const [limit, setLimit] = useState(initialInput?.limit ?? 5); const swimlaneTypeOptions = [ { @@ -154,19 +148,6 @@ export const AnomalySwimlaneInitializer: FC = ( onChange={(e) => setViewBySwimlaneFieldName(e.target.value)} /> - - } - > - setLimit(Number(e.target.value))} - /> - )} @@ -186,7 +167,6 @@ export const AnomalySwimlaneInitializer: FC = ( panelTitle, swimlaneType, viewBy: viewBySwimlaneFieldName, - limit, })} fill > diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx index 54f50d2d3da32..1ffdadb60aaa3 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx @@ -5,10 +5,13 @@ */ import React from 'react'; -import { IUiSettingsClient, OverlayStart } from 'kibana/public'; +import { CoreStart } from 'kibana/public'; import moment from 'moment'; import { VIEW_BY_JOB_LABEL } from '../../application/explorer/explorer_constants'; -import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; +import { + KibanaContextProvider, + toMountPoint, +} from '../../../../../../src/plugins/kibana_react/public'; import { AnomalySwimlaneInitializer } from './anomaly_swimlane_initializer'; import { JobSelectorFlyout } from '../../application/components/job_selector/job_selector_flyout'; import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; @@ -17,19 +20,17 @@ import { AnomalySwimlaneEmbeddableInput, getDefaultPanelTitle, } from './anomaly_swimlane_embeddable'; +import { getMlGlobalServices } from '../../application/app'; +import { HttpService } from '../../application/services/http_service'; export async function resolveAnomalySwimlaneUserInput( - { - overlays, - anomalyDetectorService, - uiSettings, - }: { - anomalyDetectorService: AnomalyDetectorService; - overlays: OverlayStart; - uiSettings: IUiSettingsClient; - }, + coreStart: CoreStart, input?: AnomalySwimlaneEmbeddableInput ): Promise> { + const { http, uiSettings, overlays } = coreStart; + + const anomalyDetectorService = new AnomalyDetectorService(new HttpService(http)); + return new Promise(async (resolve, reject) => { const maps = { groupsMap: getInitialGroupsMap([]), @@ -41,48 +42,50 @@ export async function resolveAnomalySwimlaneUserInput( const selectedIds = input?.jobIds; - const flyoutSession = overlays.openFlyout( + const flyoutSession = coreStart.overlays.openFlyout( toMountPoint( - { - flyoutSession.close(); - reject(); - }} - onSelectionConfirmed={async ({ jobIds, groups }) => { - const title = input?.title ?? getDefaultPanelTitle(jobIds); + + { + flyoutSession.close(); + reject(); + }} + onSelectionConfirmed={async ({ jobIds, groups }) => { + const title = input?.title ?? getDefaultPanelTitle(jobIds); - const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise(); + const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise(); - const influencers = anomalyDetectorService.extractInfluencers(jobs); - influencers.push(VIEW_BY_JOB_LABEL); + const influencers = anomalyDetectorService.extractInfluencers(jobs); + influencers.push(VIEW_BY_JOB_LABEL); - await flyoutSession.close(); + await flyoutSession.close(); - const modalSession = overlays.openModal( - toMountPoint( - { - modalSession.close(); - resolve({ jobIds, title: panelTitle, swimlaneType, viewBy, limit }); - }} - onCancel={() => { - modalSession.close(); - reject(); - }} - /> - ) - ); - }} - maps={maps} - /> + const modalSession = overlays.openModal( + toMountPoint( + { + modalSession.close(); + resolve({ jobIds, title: panelTitle, swimlaneType, viewBy }); + }} + onCancel={() => { + modalSession.close(); + reject(); + }} + /> + ) + ); + }} + maps={maps} + /> + ), { 'data-test-subj': 'mlAnomalySwimlaneEmbeddable', diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.test.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.test.tsx similarity index 73% rename from x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.test.tsx rename to x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.test.tsx index 63ae89b5acdd1..846a3f543c2d4 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.test.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { ExplorerSwimlaneContainer } from './explorer_swimlane_container'; +import { EmbeddableSwimLaneContainer } from './embeddable_swim_lane_container'; import { BehaviorSubject, Observable } from 'rxjs'; import { I18nProvider } from '@kbn/i18n/react'; import { @@ -17,6 +17,7 @@ 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'; jest.mock('./swimlane_input_resolver', () => ({ useSwimlaneInputResolver: jest.fn(() => { @@ -24,12 +25,11 @@ jest.mock('./swimlane_input_resolver', () => ({ }), })); -jest.mock('../../application/explorer/explorer_swimlane', () => ({ - ExplorerSwimlane: jest.fn(), -})); - -jest.mock('../../application/components/chart_tooltip', () => ({ - MlTooltipComponent: jest.fn(), +jest.mock('../../application/explorer/swimlane_container', () => ({ + SwimlaneContainer: jest.fn(() => { + return null; + }), + isViewBySwimLaneData: jest.fn(), })); const defaultOptions = { wrapper: I18nProvider }; @@ -38,6 +38,7 @@ describe('ExplorerSwimlaneContainer', () => { let embeddableInput: BehaviorSubject>; let refresh: BehaviorSubject; let services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices]; + const onInputChange = jest.fn(); beforeEach(() => { embeddableInput = new BehaviorSubject({ @@ -61,25 +62,39 @@ describe('ExplorerSwimlaneContainer', () => { }; (useSwimlaneInputResolver as jest.Mock).mockReturnValueOnce([ - mockOverallData, SWIMLANE_TYPE.OVERALL, - undefined, + mockOverallData, + 10, + jest.fn(), + {}, + false, + null, ]); - const { findByTestId } = render( - } services={services} refresh={refresh} + onInputChange={onInputChange} />, defaultOptions ); - expect( - await findByTestId('mlMaxAnomalyScoreEmbeddable_test-swimlane-embeddable') - ).toBeDefined(); + + const calledWith = ((SwimlaneContainer as unknown) as jest.Mock).mock + .calls[0][0]; + + expect(calledWith).toMatchObject({ + perPage: 10, + swimlaneType: SWIMLANE_TYPE.OVERALL, + swimlaneData: mockOverallData, + isLoading: false, + swimlaneLimit: undefined, + fromPage: 1, + }); }); test('should render an error in case it could not fetch the ML swimlane data', async () => { @@ -87,38 +102,25 @@ describe('ExplorerSwimlaneContainer', () => { undefined, undefined, undefined, + undefined, + undefined, + false, { message: 'Something went wrong' }, ]); const { findByText } = render( - } services={services} refresh={refresh} + onInputChange={onInputChange} />, defaultOptions ); const errorMessage = await findByText('Something went wrong'); expect(errorMessage).toBeDefined(); }); - - test('should render a loading indicator during the data fetching', async () => { - const { findByTestId } = render( - - } - services={services} - refresh={refresh} - />, - defaultOptions - ); - expect( - await findByTestId('loading_mlMaxAnomalyScoreEmbeddable_test-swimlane-embeddable') - ).toBeDefined(); - }); }); 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 new file mode 100644 index 0000000000000..5d91bdb41df6a --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, 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 { + AnomalySwimlaneEmbeddableInput, + AnomalySwimlaneEmbeddableOutput, + AnomalySwimlaneServices, +} from './anomaly_swimlane_embeddable'; +import { useSwimlaneInputResolver } from './swimlane_input_resolver'; +import { SwimlaneType } from '../../application/explorer/explorer_constants'; +import { + isViewBySwimLaneData, + SwimlaneContainer, +} from '../../application/explorer/swimlane_container'; + +export interface ExplorerSwimlaneContainerProps { + id: string; + embeddableInput: Observable; + services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices]; + refresh: Observable; + onInputChange: (output: Partial) => void; +} + +export const EmbeddableSwimLaneContainer: FC = ({ + id, + embeddableInput, + services, + refresh, + onInputChange, +}) => { + const [chartWidth, setChartWidth] = useState(0); + const [fromPage, setFromPage] = useState(1); + + const [ + swimlaneType, + swimlaneData, + perPage, + setPerPage, + timeBuckets, + isLoading, + error, + ] = useSwimlaneInputResolver( + embeddableInput, + onInputChange, + refresh, + services, + chartWidth, + fromPage + ); + + if (error) { + return ( + + } + color="danger" + iconType="alert" + style={{ width: '100%' }} + > +

{error.message}

+
+ ); + } + + return ( +
+ { + setChartWidth(width); + }} + onPaginationChange={(update) => { + if (update.fromPage) { + setFromPage(update.fromPage); + } + if (update.perPage) { + setFromPage(1); + setPerPage(update.perPage); + } + }} + isLoading={isLoading} + noDataWarning={ + + } + /> +
+ ); +}; diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.tsx deleted file mode 100644 index db2b9d55cfabb..0000000000000 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.tsx +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { FC, useCallback, useState } from 'react'; -import { - EuiCallOut, - EuiFlexGroup, - EuiFlexItem, - EuiLoadingChart, - EuiResizeObserver, - EuiSpacer, - EuiText, -} from '@elastic/eui'; -import { Observable } from 'rxjs'; - -import { throttle } from 'lodash'; -import { CoreStart } from 'kibana/public'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { ExplorerSwimlane } from '../../application/explorer/explorer_swimlane'; -import { MlStartDependencies } from '../../plugin'; -import { - AnomalySwimlaneEmbeddableInput, - AnomalySwimlaneEmbeddableOutput, - AnomalySwimlaneServices, -} from './anomaly_swimlane_embeddable'; -import { MlTooltipComponent } from '../../application/components/chart_tooltip'; -import { useSwimlaneInputResolver } from './swimlane_input_resolver'; -import { SwimlaneType } from '../../application/explorer/explorer_constants'; - -const RESIZE_THROTTLE_TIME_MS = 500; - -export interface ExplorerSwimlaneContainerProps { - id: string; - embeddableInput: Observable; - services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices]; - refresh: Observable; - onOutputChange?: (output: Partial) => void; -} - -export const ExplorerSwimlaneContainer: FC = ({ - id, - embeddableInput, - services, - refresh, -}) => { - const [chartWidth, setChartWidth] = useState(0); - - const [swimlaneType, swimlaneData, timeBuckets, error] = useSwimlaneInputResolver( - embeddableInput, - refresh, - services, - chartWidth - ); - - const onResize = useCallback( - throttle((e: { width: number; height: number }) => { - const labelWidth = 200; - setChartWidth(e.width - labelWidth); - }, RESIZE_THROTTLE_TIME_MS), - [] - ); - - if (error) { - return ( - - } - color="danger" - iconType="alert" - style={{ width: '100%' }} - > -

{error.message}

-
- ); - } - - return ( - - {(resizeRef) => ( -
{ - resizeRef(el); - }} - > -
- - - {chartWidth > 0 && swimlaneData && swimlaneType ? ( - - - {(tooltipService) => ( - - )} - - - ) : ( - - - - - - )} -
-
- )} -
- ); -}; diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts index 890c2bde6305d..a34955adebf62 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts @@ -19,6 +19,7 @@ describe('useSwimlaneInputResolver', () => { let embeddableInput: BehaviorSubject>; let refresh: Subject; let services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices]; + let onInputChange: jest.Mock; beforeEach(() => { jest.useFakeTimers(); @@ -41,7 +42,7 @@ describe('useSwimlaneInputResolver', () => { } as CoreStart, (null as unknown) as MlStartDependencies, ({ - explorerService: { + anomalyTimelineService: { setTimeRange: jest.fn(), loadOverallData: jest.fn(() => Promise.resolve({ @@ -69,6 +70,7 @@ describe('useSwimlaneInputResolver', () => { }, } as unknown) as AnomalySwimlaneServices, ]; + onInputChange = jest.fn(); }); afterEach(() => { jest.useRealTimers(); @@ -79,9 +81,11 @@ describe('useSwimlaneInputResolver', () => { const { result, waitForNextUpdate } = renderHook(() => useSwimlaneInputResolver( embeddableInput as Observable, + onInputChange, refresh, services, - 1000 + 1000, + 1 ) ); @@ -94,7 +98,7 @@ describe('useSwimlaneInputResolver', () => { }); expect(services[2].anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(1); - expect(services[2].explorerService.loadOverallData).toHaveBeenCalledTimes(1); + expect(services[2].anomalyTimelineService.loadOverallData).toHaveBeenCalledTimes(1); await act(async () => { embeddableInput.next({ @@ -109,7 +113,7 @@ describe('useSwimlaneInputResolver', () => { }); expect(services[2].anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(2); - expect(services[2].explorerService.loadOverallData).toHaveBeenCalledTimes(2); + expect(services[2].anomalyTimelineService.loadOverallData).toHaveBeenCalledTimes(2); await act(async () => { embeddableInput.next({ @@ -124,7 +128,7 @@ describe('useSwimlaneInputResolver', () => { }); expect(services[2].anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(2); - expect(services[2].explorerService.loadOverallData).toHaveBeenCalledTimes(3); + expect(services[2].anomalyTimelineService.loadOverallData).toHaveBeenCalledTimes(3); }); }); 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 3829bbce5e5c9..9ed6f88150f68 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 @@ -16,23 +16,31 @@ import { skipWhile, startWith, switchMap, + tap, } from 'rxjs/operators'; import { CoreStart } from 'kibana/public'; import { TimeBuckets } from '../../application/util/time_buckets'; import { AnomalySwimlaneEmbeddableInput, + AnomalySwimlaneEmbeddableOutput, AnomalySwimlaneServices, } from './anomaly_swimlane_embeddable'; import { MlStartDependencies } from '../../plugin'; -import { SWIMLANE_TYPE, SwimlaneType } from '../../application/explorer/explorer_constants'; +import { + ANOMALY_SWIM_LANE_HARD_LIMIT, + SWIM_LANE_DEFAULT_PAGE_SIZE, + SWIMLANE_TYPE, + SwimlaneType, +} from '../../application/explorer/explorer_constants'; import { Filter } from '../../../../../../src/plugins/data/common/es_query/filters'; import { Query } from '../../../../../../src/plugins/data/common/query'; import { esKuery, UI_SETTINGS } from '../../../../../../src/plugins/data/public'; import { ExplorerJob, OverallSwimlaneData } from '../../application/explorer/explorer_utils'; 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'; -const RESIZE_IGNORED_DIFF_PX = 20; const FETCH_RESULTS_DEBOUNCE_MS = 500; function getJobsObservable( @@ -48,17 +56,31 @@ function getJobsObservable( export function useSwimlaneInputResolver( embeddableInput: Observable, + onInputChange: (output: Partial) => void, refresh: Observable, services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices], - chartWidth: number -): [string | undefined, OverallSwimlaneData | undefined, TimeBuckets, Error | null | undefined] { - const [{ uiSettings }, , { explorerService, anomalyDetectorService }] = services; + chartWidth: number, + fromPage: number +): [ + string | undefined, + OverallSwimlaneData | undefined, + number, + (perPage: number) => void, + TimeBuckets, + boolean, + Error | null | undefined +] { + const [{ uiSettings }, , { anomalyTimelineService, anomalyDetectorService }] = services; const [swimlaneData, setSwimlaneData] = useState(); const [swimlaneType, setSwimlaneType] = useState(); const [error, setError] = useState(); + const [perPage, setPerPage] = useState(); + const [isLoading, setIsLoading] = useState(false); const chartWidth$ = useMemo(() => new Subject(), []); + const fromPage$ = useMemo(() => new Subject(), []); + const perPage$ = useMemo(() => new Subject(), []); const timeBuckets = useMemo(() => { return new TimeBuckets({ @@ -73,28 +95,32 @@ export function useSwimlaneInputResolver( const subscription = combineLatest([ getJobsObservable(embeddableInput, anomalyDetectorService), embeddableInput, - chartWidth$.pipe( - skipWhile((v) => !v), - distinctUntilChanged((prev, curr) => { - // emit only if the width has been changed significantly - return Math.abs(curr - prev) < RESIZE_IGNORED_DIFF_PX; - }) + chartWidth$.pipe(skipWhile((v) => !v)), + fromPage$, + perPage$.pipe( + startWith(undefined), + // no need to emit when the initial value has been set + distinctUntilChanged( + (prev, curr) => prev === undefined && curr === SWIM_LANE_DEFAULT_PAGE_SIZE + ) ), refresh.pipe(startWith(null)), ]) .pipe( + tap(setIsLoading.bind(null, true)), debounceTime(FETCH_RESULTS_DEBOUNCE_MS), - switchMap(([jobs, input, swimlaneContainerWidth]) => { + switchMap(([jobs, input, swimlaneContainerWidth, fromPageInput, perPageFromState]) => { const { viewBy, swimlaneType: swimlaneTypeInput, - limit, + perPage: perPageInput, timeRange, filters, query, + viewMode, } = input; - explorerService.setTimeRange(timeRange); + anomalyTimelineService.setTimeRange(timeRange); if (!swimlaneType) { setSwimlaneType(swimlaneTypeInput); @@ -118,18 +144,34 @@ export function useSwimlaneInputResolver( return of(undefined); } - return from(explorerService.loadOverallData(explorerJobs, swimlaneContainerWidth)).pipe( + return from( + anomalyTimelineService.loadOverallData(explorerJobs, swimlaneContainerWidth) + ).pipe( switchMap((overallSwimlaneData) => { const { earliest, latest } = overallSwimlaneData; if (overallSwimlaneData && swimlaneTypeInput === SWIMLANE_TYPE.VIEW_BY) { + if (perPageFromState === undefined) { + // set initial pagination from the input or default one + setPerPage(perPageInput ?? SWIM_LANE_DEFAULT_PAGE_SIZE); + } + + if (viewMode === ViewMode.EDIT && perPageFromState !== perPageInput) { + // store per page value when the dashboard is in the edit mode + onInputChange({ perPage: perPageFromState }); + } + return from( - explorerService.loadViewBySwimlane( + anomalyTimelineService.loadViewBySwimlane( [], { earliest, latest }, explorerJobs, viewBy!, - limit!, + isViewBySwimLaneData(swimlaneData) + ? swimlaneData.cardinality + : ANOMALY_SWIM_LANE_HARD_LIMIT, + perPageFromState ?? perPageInput ?? SWIM_LANE_DEFAULT_PAGE_SIZE, + fromPageInput, swimlaneContainerWidth, appliedFilters ) @@ -156,6 +198,7 @@ export function useSwimlaneInputResolver( if (data !== undefined) { setError(null); setSwimlaneData(data); + setIsLoading(false); } }); @@ -164,11 +207,28 @@ export function useSwimlaneInputResolver( }; }, []); + useEffect(() => { + fromPage$.next(fromPage); + }, [fromPage]); + + useEffect(() => { + if (perPage === undefined) return; + perPage$.next(perPage); + }, [perPage]); + useEffect(() => { chartWidth$.next(chartWidth); }, [chartWidth]); - return [swimlaneType, swimlaneData, timeBuckets, error]; + return [ + swimlaneType, + swimlaneData, + perPage ?? SWIM_LANE_DEFAULT_PAGE_SIZE, + setPerPage, + timeBuckets, + isLoading, + error, + ]; } export function processFilters(filters: Filter[], query: Query) { 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 312b9f31124b1..0db41c1ed104e 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 @@ -14,8 +14,6 @@ import { AnomalySwimlaneEmbeddableOutput, } from '../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; import { resolveAnomalySwimlaneUserInput } from '../embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout'; -import { HttpService } from '../application/services/http_service'; -import { AnomalyDetectorService } from '../application/services/anomaly_detector_service'; export const EDIT_SWIMLANE_PANEL_ACTION = 'editSwimlanePanelAction'; @@ -39,18 +37,10 @@ export function createEditSwimlanePanelAction(getStartServices: CoreSetup['getSt throw new Error('Not possible to execute an action without the embeddable context'); } - const [{ overlays, uiSettings, http }] = await getStartServices(); - const anomalyDetectorService = new AnomalyDetectorService(new HttpService(http)); + const [coreStart] = await getStartServices(); try { - const result = await resolveAnomalySwimlaneUserInput( - { - anomalyDetectorService, - overlays, - uiSettings, - }, - embeddable.getInput() - ); + const result = await resolveAnomalySwimlaneUserInput(coreStart, embeddable.getInput()); embeddable.updateInput(result); } catch (e) { return Promise.reject(); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4c1572ddfcad1..5c5d270d324ff 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -9790,8 +9790,6 @@ "xpack.ml.explorer.jobIdLabel": "ジョブ ID", "xpack.ml.explorer.jobScoreAcrossAllInfluencersLabel": "(すべての影響因子のジョブスコア)", "xpack.ml.explorer.kueryBar.filterPlaceholder": "影響因子フィールドでフィルタリング… ({queryExample})", - "xpack.ml.explorer.limitLabel": "制限", - "xpack.ml.explorer.loadingLabel": "読み込み中", "xpack.ml.explorer.noConfiguredInfluencersTooltip": "選択されたジョブに影響因子が構成されていないため、トップインフルエンスリストは非表示になっています。", "xpack.ml.explorer.noInfluencersFoundTitle": "{viewBySwimlaneFieldName}影響因子が見つかりません", "xpack.ml.explorer.noInfluencersFoundTitleFilterMessage": "指定されたフィルターの{viewBySwimlaneFieldName} 影響因子が見つかりません", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 97f10e77dc717..c71215d2bfb74 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9794,8 +9794,6 @@ "xpack.ml.explorer.jobIdLabel": "作业 ID", "xpack.ml.explorer.jobScoreAcrossAllInfluencersLabel": "(所有影响因素的作业分数)", "xpack.ml.explorer.kueryBar.filterPlaceholder": "按影响因素字段筛选……({queryExample})", - "xpack.ml.explorer.limitLabel": "限制", - "xpack.ml.explorer.loadingLabel": "正在加载", "xpack.ml.explorer.noConfiguredInfluencersTooltip": "“顶级影响因素”列表被隐藏,因为没有为所选作业配置影响因素。", "xpack.ml.explorer.noInfluencersFoundTitle": "未找到任何 {viewBySwimlaneFieldName} 影响因素", "xpack.ml.explorer.noInfluencersFoundTitleFilterMessage": "对于指定筛选找不到任何 {viewBySwimlaneFieldName} 影响因素", diff --git a/x-pack/test/functional/services/ml/anomaly_explorer.ts b/x-pack/test/functional/services/ml/anomaly_explorer.ts index 7c479a4234673..80df235bf6ff8 100644 --- a/x-pack/test/functional/services/ml/anomaly_explorer.ts +++ b/x-pack/test/functional/services/ml/anomaly_explorer.ts @@ -76,7 +76,7 @@ export function MachineLearningAnomalyExplorerProvider({ getService }: FtrProvid async addAndEditSwimlaneInDashboard(dashboardTitle: string) { await this.filterWithSearchString(dashboardTitle); await testSubjects.isDisplayed('mlDashboardSelectionTable > checkboxSelectAll'); - await testSubjects.click('mlDashboardSelectionTable > checkboxSelectAll'); + await testSubjects.clickWhenNotDisabled('mlDashboardSelectionTable > checkboxSelectAll'); expect(await testSubjects.isChecked('mlDashboardSelectionTable > checkboxSelectAll')).to.be( true );