From a83459dbd2bf03b7c61f20598acf627407d9c935 Mon Sep 17 00:00:00 2001 From: rbrtj Date: Wed, 4 Dec 2024 15:47:03 +0100 Subject: [PATCH 01/35] useGlobalUrlState service --- x-pack/packages/ml/url_state/index.ts | 2 +- .../packages/ml/url_state/src/url_state.tsx | 157 ++++++++++++------ .../explorer/anomaly_charts_state_service.ts | 8 +- .../anomaly_timeline_state_service.ts | 4 +- .../explorer/hooks/use_explorer_url_state.ts | 5 +- 5 files changed, 114 insertions(+), 62 deletions(-) diff --git a/x-pack/packages/ml/url_state/index.ts b/x-pack/packages/ml/url_state/index.ts index 13443af51bc4d..3bfc7f4ba1fa1 100644 --- a/x-pack/packages/ml/url_state/index.ts +++ b/x-pack/packages/ml/url_state/index.ts @@ -10,7 +10,7 @@ export { parseUrlState, usePageUrlState, useUrlState, - PageUrlStateService, + UrlStateService, Provider, UrlStateProvider, type Accessor, diff --git a/x-pack/packages/ml/url_state/src/url_state.tsx b/x-pack/packages/ml/url_state/src/url_state.tsx index 7cd6bc1d812e6..d9ec933a4c8d2 100644 --- a/x-pack/packages/ml/url_state/src/url_state.tsx +++ b/x-pack/packages/ml/url_state/src/url_state.tsx @@ -211,27 +211,26 @@ export const useUrlState = ( /** * Service for managing URL state of particular page. */ -export class PageUrlStateService { - private _pageUrlState$ = new BehaviorSubject(null); - private _pageUrlStateCallback: ((update: Partial, replaceState?: boolean) => void) | null = - null; +export class UrlStateService { + private _urlState$ = new BehaviorSubject(null); + private _urlStateCallback: ((update: Partial, replaceState?: boolean) => void) | null = null; /** * Provides updates for the page URL state. */ - public getPageUrlState$(): Observable { - return this._pageUrlState$.pipe(distinctUntilChanged(isEqual)); + public getUrlState$(): Observable { + return this._urlState$.pipe(distinctUntilChanged(isEqual)); } - public getPageUrlState(): T | null { - return this._pageUrlState$.getValue(); + public getUrlState(): T | null { + return this._urlState$.getValue(); } public updateUrlState(update: Partial, replaceState?: boolean): void { - if (!this._pageUrlStateCallback) { + if (!this._urlStateCallback) { throw new Error('Callback has not been initialized.'); } - this._pageUrlStateCallback(update, replaceState); + this._urlStateCallback(update, replaceState); } /** @@ -239,7 +238,7 @@ export class PageUrlStateService { * @param currentState */ public setCurrentState(currentState: T): void { - this._pageUrlState$.next(currentState); + this._urlState$.next(currentState); } /** @@ -247,7 +246,7 @@ export class PageUrlStateService { * @param callback */ public setUpdateCallback(callback: (update: Partial, replaceState?: boolean) => void): void { - this._pageUrlStateCallback = callback; + this._urlStateCallback = callback; } } @@ -256,32 +255,45 @@ export interface PageUrlState { pageUrlState: object; } -/** - * Hook for managing the URL state of the page. - */ -export const usePageUrlState = ( - pageKey: T['pageKey'], - defaultState?: T['pageUrlState'] -): [ - T['pageUrlState'], - (update: Partial, replaceState?: boolean) => void, - PageUrlStateService -] => { - const [appState, setAppState] = useUrlState('_a'); - const pageState = appState?.[pageKey]; +interface AppStateOptions { + pageKey: string; + defaultState?: T; +} + +interface GlobalStateOptions { + defaultState?: T; +} + +type UrlStateOptions = K extends '_a' + ? AppStateOptions + : GlobalStateOptions; + +function isAppStateOptions( + _stateKey: Accessor, + options: Partial> +): options is AppStateOptions { + return 'pageKey' in options; +} + +export const useUrlStateService = ( + stateKey: K, + options: UrlStateOptions +): [T, (update: Partial, replaceState?: boolean) => void, UrlStateService] => { + const [state, setState] = useUrlState(stateKey); + const urlState = isAppStateOptions(stateKey, options) ? state?.[options.pageKey] : state; - const setCallback = useRef(); + const setCallback = useRef(); useEffect(() => { - setCallback.current = setAppState; - }, [setAppState]); + setCallback.current = setState; + }, [setState]); - const prevPageState = useRef(); + const prevPageState = useRef(); - const resultPageState: T['pageUrlState'] = useMemo(() => { + const resultState: T = useMemo(() => { const result = { - ...(defaultState ?? {}), - ...(pageState ?? {}), + ...(options.defaultState ?? {}), + ...(urlState ?? {}), }; if (isEqual(result, prevPageState.current)) { @@ -301,37 +313,78 @@ export const usePageUrlState = ( return result; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pageState]); + }, [urlState]); const onStateUpdate = useCallback( - (update: Partial, replaceState?: boolean) => { + (update: Partial, replaceState?: boolean) => { if (!setCallback?.current) { throw new Error('Callback for URL state update has not been initialized.'); } - - setCallback.current( - pageKey, - { - ...resultPageState, - ...update, - }, - replaceState - ); + if (isAppStateOptions(stateKey, options)) { + setCallback.current( + options.pageKey, + { + ...resultState, + ...update, + }, + replaceState + ); + } else { + setCallback.current({ ...resultState, ...update }); + } }, - [pageKey, resultPageState] + [stateKey, options, resultState] ); - const pageUrlStateService = useMemo(() => new PageUrlStateService(), []); + const urlStateService = useMemo(() => new UrlStateService(), []); useEffect( - function updatePageUrlService() { - pageUrlStateService.setCurrentState(resultPageState); - pageUrlStateService.setUpdateCallback(onStateUpdate); + function updateUrlStateService() { + urlStateService.setCurrentState(resultState); + urlStateService.setUpdateCallback(onStateUpdate); }, - [pageUrlStateService, onStateUpdate, resultPageState] + [urlStateService, onStateUpdate, resultState] ); - return useMemo(() => { - return [resultPageState, onStateUpdate, pageUrlStateService]; - }, [resultPageState, onStateUpdate, pageUrlStateService]); + return useMemo( + () => [resultState, onStateUpdate, urlStateService], + [resultState, onStateUpdate, urlStateService] + ); +}; + +/** + * Hook for managing the URL state of the page. + */ +export const usePageUrlState = ( + pageKey: T['pageKey'], + defaultState?: T['pageUrlState'] +): [ + T['pageUrlState'], + (update: Partial, replaceState?: boolean) => void, + UrlStateService +] => { + return useUrlStateService<'_a', T['pageUrlState']>('_a', { pageKey, defaultState }); +}; + +export interface GlobalState { + ml: { + jobIds: string[]; + }; + time?: { + from: string; + to: string; + }; +} + +/** + * Hook for managing the global URL state. + */ +export const useGlobalUrlState = ( + defaultState?: GlobalState +): [ + GlobalState, + (update: Partial, replaceState?: boolean) => void, + UrlStateService +] => { + return useUrlStateService<'_g', GlobalState>('_g', { defaultState }); }; diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_charts_state_service.ts b/x-pack/plugins/ml/public/application/explorer/anomaly_charts_state_service.ts index 05c6bda4057d4..cb3ab83a7e554 100644 --- a/x-pack/plugins/ml/public/application/explorer/anomaly_charts_state_service.ts +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_charts_state_service.ts @@ -8,7 +8,7 @@ import type { Observable } from 'rxjs'; import { BehaviorSubject, combineLatest, of, Subscription } from 'rxjs'; import { distinctUntilChanged, map, skipWhile, switchMap } from 'rxjs'; -import type { PageUrlStateService } from '@kbn/ml-url-state'; +import type { UrlStateService } from '@kbn/ml-url-state'; import { StateService } from '../services/state_service'; import type { AnomalyExplorerCommonStateService } from './anomaly_explorer_common_state'; import type { AnomalyTimelineStateService } from './anomaly_timeline_state_service'; @@ -29,7 +29,7 @@ export class AnomalyChartsStateService extends StateService { private _anomalyTimelineStateServices: AnomalyTimelineStateService, private _anomalyExplorerChartsService: AnomalyExplorerChartsService, private _anomalyExplorerUrlStateService: AnomalyExplorerUrlStateService, - private _tableSeverityState: PageUrlStateService + private _tableSeverityState: UrlStateService ) { super(); this._init(); @@ -40,7 +40,7 @@ export class AnomalyChartsStateService extends StateService { subscription.add( this._anomalyExplorerUrlStateService - .getPageUrlState$() + .getUrlState$() .pipe( map((urlState) => urlState?.mlShowCharts ?? true), distinctUntilChanged() @@ -60,7 +60,7 @@ export class AnomalyChartsStateService extends StateService { this._anomalyTimelineStateServices.getContainerWidth$().pipe(skipWhile((v) => v === 0)), this._anomalyTimelineStateServices.getSelectedCells$(), this._anomalyTimelineStateServices.getViewBySwimlaneFieldName$(), - this._tableSeverityState.getPageUrlState$(), + this._tableSeverityState.getUrlState$(), ]) .pipe( switchMap( diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline_state_service.ts b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline_state_service.ts index 371284d0ac047..131b08f9891f2 100644 --- a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline_state_service.ts +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline_state_service.ts @@ -124,7 +124,7 @@ export class AnomalyTimelineStateService extends StateService { update: AnomalyExplorerSwimLaneUrlState, replaceState?: boolean ) => { - const explorerUrlState = this.anomalyExplorerUrlStateService.getPageUrlState(); + const explorerUrlState = this.anomalyExplorerUrlStateService.getUrlState(); const mlExplorerSwimLaneState = explorerUrlState?.mlExplorerSwimlane; const resultUpdate = replaceState ? update : { ...mlExplorerSwimLaneState, ...update }; return this.anomalyExplorerUrlStateService.updateUrlState({ @@ -145,7 +145,7 @@ export class AnomalyTimelineStateService extends StateService { subscription.add( this.anomalyExplorerUrlStateService - .getPageUrlState$() + .getUrlState$() .pipe( map((v) => v?.mlExplorerSwimlane), distinctUntilChanged(isEqual) diff --git a/x-pack/plugins/ml/public/application/explorer/hooks/use_explorer_url_state.ts b/x-pack/plugins/ml/public/application/explorer/hooks/use_explorer_url_state.ts index cfa3fdc03d343..cbea33986eab7 100644 --- a/x-pack/plugins/ml/public/application/explorer/hooks/use_explorer_url_state.ts +++ b/x-pack/plugins/ml/public/application/explorer/hooks/use_explorer_url_state.ts @@ -5,12 +5,11 @@ * 2.0. */ -import type { PageUrlStateService } from '@kbn/ml-url-state'; -import { usePageUrlState } from '@kbn/ml-url-state'; +import { usePageUrlState, type UrlStateService } from '@kbn/ml-url-state'; import type { ExplorerAppState } from '../../../../common/types/locator'; import { ML_PAGES } from '../../../../common/constants/locator'; -export type AnomalyExplorerUrlStateService = PageUrlStateService; +export type AnomalyExplorerUrlStateService = UrlStateService; interface LegacyExplorerPageUrlState { pageKey: 'mlExplorerSwimlane'; From 8a063accbee0904d5167308559acbadee88a914f Mon Sep 17 00:00:00 2001 From: rbrtj Date: Wed, 4 Dec 2024 15:52:27 +0100 Subject: [PATCH 02/35] remove set_charts_data_loading explorer action --- .../ml/public/application/explorer/explorer_constants.ts | 1 - .../application/explorer/explorer_dashboard_service.ts | 3 --- .../explorer/reducers/explorer_reducer/reducer.ts | 9 --------- 3 files changed, 13 deletions(-) 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 9deb2ca8aa74f..ee019928b9bda 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts @@ -22,7 +22,6 @@ export const EXPLORER_ACTION = { CLEAR_INFLUENCER_FILTER_SETTINGS: 'clearInfluencerFilterSettings', CLEAR_JOBS: 'clearJobs', JOB_SELECTION_CHANGE: 'jobSelectionChange', - SET_CHARTS_DATA_LOADING: 'setChartsDataLoading', SET_EXPLORER_DATA: 'setExplorerData', } as const; 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 2f5ed99c9a401..2650df45d0e06 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 @@ -89,9 +89,6 @@ export const explorerServiceFactory = ( setExplorerData: (payload: DeepPartial) => { explorerAction$.next({ type: EXPLORER_ACTION.SET_EXPLORER_DATA, payload }); }, - setChartsDataLoading: () => { - explorerAction$.next({ type: EXPLORER_ACTION.SET_CHARTS_DATA_LOADING }); - }, }); export type ExplorerService = ReturnType; 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 4be342c9333ad..50de00c937fa2 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 @@ -5,7 +5,6 @@ * 2.0. */ -import { getDefaultChartsData } from '../../explorer_charts/explorer_charts_container_service'; import { EXPLORER_ACTION } from '../../explorer_constants'; import type { ExplorerActionPayloads, ExplorerActions } from '../../explorer_dashboard_service'; import { getClearedSelectedAnomaliesState } from '../../explorer_utils'; @@ -50,14 +49,6 @@ export const explorerReducer = ( ); break; - case EXPLORER_ACTION.SET_CHARTS_DATA_LOADING: - nextState = { - ...state, - anomalyChartsDataLoading: true, - chartsData: getDefaultChartsData(), - }; - break; - case EXPLORER_ACTION.SET_EXPLORER_DATA: nextState = { ...state, ...(payload as Partial) }; break; From 6fc0569fded683d5b19bd5c8bef0a54721320c8b Mon Sep 17 00:00:00 2001 From: rbrtj Date: Fri, 6 Dec 2024 11:39:38 +0100 Subject: [PATCH 03/35] anomaly explorer refactor --- .../components/job_selector/job_selector.tsx | 41 ++++--- .../job_selector/use_job_selection.ts | 93 ++++------------ .../application/explorer/actions/index.ts | 1 - .../explorer/actions/job_selection.ts | 48 -------- .../explorer/actions/load_explorer_data.ts | 3 +- .../explorer/anomaly_explorer_common_state.ts | 104 ++++++++++++++---- .../explorer/anomaly_explorer_context.tsx | 13 +-- .../public/application/explorer/explorer.tsx | 38 +++++-- .../explorer/explorer_dashboard_service.ts | 94 ---------------- .../state.ts => explorer_data.ts} | 16 +-- .../application/explorer/explorer_utils.ts | 18 +++ .../clear_influencer_filter_settings.ts | 21 ---- .../explorer_reducer/get_index_pattern.ts | 23 ---- .../reducers/explorer_reducer/index.ts | 11 -- .../explorer_reducer/job_selection_change.ts | 45 -------- .../reducers/explorer_reducer/reducer.ts | 68 ------------ .../set_kql_query_bar_placeholder.ts | 32 ------ .../application/explorer/reducers/index.ts | 9 -- .../routing/routes/explorer/state_manager.tsx | 94 +++++++--------- .../timeseriesexplorer/state_manager.tsx | 22 ++++ .../timeseriesexplorer.d.ts | 9 ++ .../timeseriesexplorer/timeseriesexplorer.js | 14 ++- .../timeseriesexplorer_page.tsx | 21 +++- x-pack/plugins/ml/public/maps/util.ts | 8 +- 24 files changed, 301 insertions(+), 545 deletions(-) delete mode 100644 x-pack/plugins/ml/public/application/explorer/actions/job_selection.ts delete mode 100644 x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts rename x-pack/plugins/ml/public/application/explorer/{reducers/explorer_reducer/state.ts => explorer_data.ts} (76%) delete mode 100644 x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts delete mode 100644 x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/get_index_pattern.ts delete mode 100644 x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/index.ts delete mode 100644 x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts delete mode 100644 x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts delete mode 100644 x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_kql_query_bar_placeholder.ts delete mode 100644 x-pack/plugins/ml/public/application/explorer/reducers/index.ts diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx index 063aa303944be..e2d92b03195f4 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx @@ -16,7 +16,6 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useUrlState } from '@kbn/ml-url-state'; import './_index.scss'; import { useStorage } from '@kbn/ml-local-storage'; @@ -29,7 +28,7 @@ import type { MlJobWithTimeRange } from '../../../../common/types/anomaly_detect import { ML_APPLY_TIME_RANGE_CONFIG } from '../../../../common/types/storage'; import { FeedBackButton } from '../feedback_button'; -interface GroupObj { +export interface GroupObj { groupId: string; jobIds: string[]; } @@ -78,6 +77,17 @@ export interface JobSelectorProps { dateFormatTz: string; singleSelection: boolean; timeseriesOnly: boolean; + onSelectionChange?: ({ + jobIds, + groups, + time, + }: { + jobIds: string[]; + groups: GroupObj[]; + time?: { from: string; to: string }; + }) => void; + selectedJobIds?: string[]; + selectedGroups?: GroupObj[]; } export interface JobSelectionMaps { @@ -85,23 +95,27 @@ export interface JobSelectionMaps { groupsMap: Dictionary; } -export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: JobSelectorProps) { - const [globalState, setGlobalState] = useUrlState('_g'); +export function JobSelector({ + dateFormatTz, + singleSelection, + timeseriesOnly, + selectedJobIds = [], + selectedGroups = [], + onSelectionChange, +}: JobSelectorProps) { const [applyTimeRangeConfig, setApplyTimeRangeConfig] = useStorage( ML_APPLY_TIME_RANGE_CONFIG, true ); - const selectedJobIds = globalState?.ml?.jobIds ?? []; - const selectedGroups = globalState?.ml?.groups ?? []; - const [maps, setMaps] = useState({ - groupsMap: getInitialGroupsMap(selectedGroups), + groupsMap: {}, jobsMap: {}, }); const [selectedIds, setSelectedIds] = useState( mergeSelection(selectedJobIds, selectedGroups, singleSelection) ); + const [showAllBarBadges, setShowAllBarBadges] = useState(false); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); @@ -127,17 +141,10 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J ({ newSelection, jobIds, groups: newGroups, time }) => { setSelectedIds(newSelection); - setGlobalState({ - ml: { - jobIds, - groups: newGroups, - }, - ...(time !== undefined ? { time } : {}), - }); - + onSelectionChange?.({ jobIds, groups: newGroups, time }); closeFlyout(); }, - [setGlobalState, setSelectedIds] + [onSelectionChange] ); function renderJobSelectionBar() { diff --git a/x-pack/plugins/ml/public/application/components/job_selector/use_job_selection.ts b/x-pack/plugins/ml/public/application/components/job_selector/use_job_selection.ts index 51d1882084d3e..8d2fd779f8271 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/use_job_selection.ts +++ b/x-pack/plugins/ml/public/application/components/job_selector/use_job_selection.ts @@ -5,43 +5,13 @@ * 2.0. */ -import { difference } from 'lodash'; import { useEffect, useMemo } from 'react'; - +import useObservable from 'react-use/lib/useObservable'; import { i18n } from '@kbn/i18n'; -import { useUrlState } from '@kbn/ml-url-state'; - import type { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; - import { useNotifications } from '../../contexts/kibana'; import { useJobSelectionFlyout } from '../../contexts/ml/use_job_selection_flyout'; - -// check that the ids read from the url exist by comparing them to the -// jobs loaded via mlJobsService. -function getInvalidJobIds(jobs: MlJobWithTimeRange[], ids: string[]) { - return ids.filter((id) => { - const jobExists = jobs.some((job) => job.job_id === id); - return jobExists === false && id !== '*'; - }); -} - -// This is useful when redirecting from dashboards where groupIds are treated as jobIds -const getJobIdsFromGroups = (jobIds: string[], jobs: MlJobWithTimeRange[]) => { - const result = new Set(); - - jobIds.forEach((id) => { - const jobsInGroup = jobs.filter((job) => job.groups?.includes(id)); - - if (jobsInGroup.length > 0) { - jobsInGroup.forEach((job) => result.add(job.job_id)); - } else { - // If it's not a group ID, keep it (regardless of whether it's valid or not) - result.add(id); - } - }); - - return Array.from(result); -}; +import { useAnomalyExplorerContext } from '../../explorer/anomaly_explorer_context'; export interface JobSelection { jobIds: string[]; @@ -49,67 +19,52 @@ export interface JobSelection { } export const useJobSelection = (jobs: MlJobWithTimeRange[]) => { - const [globalState, setGlobalState] = useUrlState('_g'); const { toasts: toastNotifications } = useNotifications(); + const { anomalyExplorerCommonStateService } = useAnomalyExplorerContext(); - const getJobSelection = useJobSelectionFlyout(); + const selectedJobs = useObservable( + anomalyExplorerCommonStateService.getSelectedJobs$(), + anomalyExplorerCommonStateService.getSelectedJobs() + ); + const invalidJobIds = useObservable( + anomalyExplorerCommonStateService.getInvalidJobIds$(), + anomalyExplorerCommonStateService.getInvalidJobIds() + ); - const tmpIds = useMemo(() => { - const ids = getJobIdsFromGroups(globalState?.ml?.jobIds || [], jobs); - return (typeof ids === 'string' ? [ids] : ids).map((id: string) => String(id)); - }, [globalState?.ml?.jobIds, jobs]); - - const invalidIds = useMemo(() => { - return getInvalidJobIds(jobs, tmpIds); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tmpIds]); - - const validIds = useMemo(() => { - const res = difference(tmpIds, invalidIds); - res.sort(); - return res; - }, [tmpIds, invalidIds]); - - const jobSelection: JobSelection = useMemo(() => { - const selectedGroups = globalState?.ml?.groups ?? []; - return { jobIds: validIds, selectedGroups }; - }, [validIds, globalState?.ml?.groups]); + const getJobSelection = useJobSelectionFlyout(); + const selectedIds = useMemo(() => { + return selectedJobs?.map((j) => j.id); + }, [selectedJobs]); useEffect(() => { - if (invalidIds.length > 0) { + if (invalidJobIds.length > 0) { toastNotifications.addWarning( i18n.translate('xpack.ml.jobSelect.requestedJobsDoesNotExistWarningMessage', { defaultMessage: `Requested -{invalidIdsLength, plural, one {job {invalidIds} does not exist} other {jobs {invalidIds} do not exist}}`, + {invalidIdsLength, plural, one {job {invalidIds} does not exist} other {jobs {invalidIds} do not exist}}`, values: { - invalidIdsLength: invalidIds.length, - invalidIds: invalidIds.join(), + invalidIdsLength: invalidJobIds.length, + invalidIds: invalidJobIds.join(), }, }) ); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [invalidIds]); + }, [invalidJobIds]); useEffect(() => { // if there are no valid ids, ask the user to provide job selection with the flyout - if (validIds.length === 0 && jobs.length > 0) { + if (!selectedIds || (selectedIds.length === 0 && jobs.length > 0)) { getJobSelection({ singleSelection: false }) .then(({ jobIds, time }) => { - const mlGlobalState = globalState?.ml || {}; - mlGlobalState.jobIds = jobIds; - - setGlobalState({ - ...{ ml: mlGlobalState }, - ...(time !== undefined ? { time } : {}), - }); + anomalyExplorerCommonStateService.setSelectedJobs(jobIds, time); }) .catch(() => { // flyout closed without selection }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [jobs, validIds, setGlobalState, globalState?.ml]); + }, [jobs]); - return jobSelection; + return { selectedIds, selectedJobs }; }; diff --git a/x-pack/plugins/ml/public/application/explorer/actions/index.ts b/x-pack/plugins/ml/public/application/explorer/actions/index.ts index 2a7b30d4ed6d8..12185d2799174 100644 --- a/x-pack/plugins/ml/public/application/explorer/actions/index.ts +++ b/x-pack/plugins/ml/public/application/explorer/actions/index.ts @@ -5,5 +5,4 @@ * 2.0. */ -export { jobSelectionActionCreator } from './job_selection'; export { useExplorerData } from './load_explorer_data'; diff --git a/x-pack/plugins/ml/public/application/explorer/actions/job_selection.ts b/x-pack/plugins/ml/public/application/explorer/actions/job_selection.ts deleted file mode 100644 index bd6bcd6e95657..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/actions/job_selection.ts +++ /dev/null @@ -1,48 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { Observable } from 'rxjs'; -import { from } from 'rxjs'; -import { map } from 'rxjs'; - -import type { MlFieldFormatService } from '../../services/field_format_service'; -import type { MlJobService } from '../../services/job_service'; - -import { EXPLORER_ACTION } from '../explorer_constants'; -import { createJobs, getInfluencers } from '../explorer_utils'; -import type { ExplorerActions } from '../explorer_dashboard_service'; - -export function jobSelectionActionCreator( - mlJobService: MlJobService, - mlFieldFormatService: MlFieldFormatService, - selectedJobIds: string[] -): Observable { - return from(mlFieldFormatService.populateFormats(selectedJobIds)).pipe( - map((resp) => { - if (resp.error) { - return null; - } - - const jobs = createJobs(mlJobService.jobs).map((job) => { - job.selected = selectedJobIds.some((id) => job.id === id); - return job; - }); - - const selectedJobs = jobs.filter((job) => job.selected); - const noInfluencersConfigured = getInfluencers(mlJobService, selectedJobs).length === 0; - - return { - type: EXPLORER_ACTION.JOB_SELECTION_CHANGE, - payload: { - loading: false, - selectedJobs, - noInfluencersConfigured, - }, - }; - }) - ); -} 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 41a3da0586e02..97a65d7c13273 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 @@ -31,7 +31,7 @@ import { loadTopInfluencers, loadOverallAnnotations, } from '../explorer_utils'; -import type { ExplorerState } from '../reducers'; + import { useMlApi, useUiSettings } from '../../contexts/kibana'; import type { MlResultsService } from '../../services/results_service'; import { mlResultsServiceProvider } from '../../services/results_service'; @@ -39,6 +39,7 @@ import type { AnomalyExplorerChartsService } from '../../services/anomaly_explor import { useAnomalyExplorerContext } from '../anomaly_explorer_context'; import type { MlApi } from '../../services/ml_api_service'; import { useMlJobService, type MlJobService } from '../../services/job_service'; +import type { ExplorerState } from '../explorer_data'; // Memoize the data fetching methods. // wrapWithLastRefreshArg() wraps any given function and preprends a `lastRefresh` argument diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_common_state.ts b/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_common_state.ts index 800b3b2bc5eca..c1d3165ed6a44 100644 --- a/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_common_state.ts +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_common_state.ts @@ -5,16 +5,19 @@ * 2.0. */ -import type { Observable, Subscription } from 'rxjs'; +import type { Observable } from 'rxjs'; +import { Subscription } from 'rxjs'; import { BehaviorSubject } from 'rxjs'; import { distinctUntilChanged, map, shareReplay, filter } from 'rxjs'; import { isEqual } from 'lodash'; import type { InfluencersFilterQuery } from '@kbn/ml-anomaly-utils'; -import type { ExplorerJob } from './explorer_utils'; +import type { GlobalState, UrlStateService } from '@kbn/ml-url-state/src/url_state'; +import { createJobs, type ExplorerJob } from './explorer_utils'; import type { AnomalyExplorerUrlStateService } from './hooks/use_explorer_url_state'; import type { AnomalyExplorerFilterUrlState } from '../../../common/types/locator'; import type { KQLFilterSettings } from './components/explorer_query_bar/explorer_query_bar'; import { StateService } from '../services/state_service'; +import type { MlJobService } from '../services/job_service'; export interface AnomalyExplorerState { selectedJobs: ExplorerJob[]; @@ -30,8 +33,10 @@ export type FilterSettings = Required< * Manages related values in the URL state and applies required formatting. */ export class AnomalyExplorerCommonStateService extends StateService { - private _selectedJobs$ = new BehaviorSubject(undefined); + private _selectedJobs$ = new BehaviorSubject([]); + private _selectedGroups$ = new BehaviorSubject(undefined); private _filterSettings$ = new BehaviorSubject(this._getDefaultFilterSettings()); + private _invalidJobIds$ = new BehaviorSubject([]); private _getDefaultFilterSettings(): FilterSettings { return { @@ -42,29 +47,88 @@ export class AnomalyExplorerCommonStateService extends StateService { }; } - constructor(private anomalyExplorerUrlStateService: AnomalyExplorerUrlStateService) { + constructor( + private anomalyExplorerUrlStateService: AnomalyExplorerUrlStateService, + private globalUrlStateService: UrlStateService, + private mlJobsService: MlJobService + ) { super(); + this._init(); } protected _initSubscriptions(): Subscription { - return this.anomalyExplorerUrlStateService - .getPageUrlState$() - .pipe( - map((urlState) => urlState?.mlExplorerFilter), - distinctUntilChanged(isEqual) - ) - .subscribe((v) => { - const result = { - ...this._getDefaultFilterSettings(), - ...v, - }; - this._filterSettings$.next(result); - }); + const subscriptions = new Subscription(); + + subscriptions.add( + this.anomalyExplorerUrlStateService + .getUrlState$() + .pipe( + map((urlState) => urlState?.mlExplorerFilter), + distinctUntilChanged(isEqual) + ) + .subscribe((v) => { + const result = { + ...this._getDefaultFilterSettings(), + ...v, + }; + this._filterSettings$.next(result); + }) + ); + + subscriptions.add( + this.globalUrlStateService + .getUrlState$() + .pipe( + map((urlState) => urlState?.ml?.jobIds), + distinctUntilChanged(isEqual) + ) + .subscribe((selectedJobIds: string[]) => { + this._processSelectedJobs(selectedJobIds); + }) + ); + + return subscriptions; + } + + private _processSelectedJobs(selectedJobIds: string[]) { + if (!selectedJobIds || selectedJobIds.length === 0) { + this._selectedJobs$.next([]); + this._invalidJobIds$.next([]); + return; + } + const selectedJobs = this.mlJobsService.jobs.filter( + (j) => selectedJobIds.includes(j.job_id) || j.groups?.some((g) => selectedJobIds.includes(g)) + ); + const mappedJobs = createJobs(selectedJobs); + + const invalidJobIds = this._getInvalidJobIds(selectedJobIds); + + this._invalidJobIds$.next(invalidJobIds); + this._selectedJobs$.next(mappedJobs); } - public setSelectedJobs(explorerJobs: ExplorerJob[] | undefined) { - this._selectedJobs$.next(explorerJobs); + private _getInvalidJobIds(jobIds: string[]): string[] { + return jobIds.filter( + (id) => !this.mlJobsService.jobs.some((j) => j.job_id === id || j.groups?.includes(id)) + ); + } + + public getInvalidJobIds$(): Observable { + return this._invalidJobIds$.pipe(distinctUntilChanged(isEqual), shareReplay(1)); + } + + public getInvalidJobIds(): string[] { + return this._invalidJobIds$.getValue(); + } + + public setSelectedJobs(jobIds: string[], time?: { from: string; to: string }) { + this.globalUrlStateService.updateUrlState({ + ml: { + jobIds, + }, + ...(time ? { time } : {}), + }); } public getSelectedJobs$(): Observable { @@ -84,7 +148,7 @@ export class AnomalyExplorerCommonStateService extends StateService { return this._smvJobs$; } - public getSelectedJobs(): ExplorerJob[] | undefined { + public getSelectedJobs(): ExplorerJob[] { return this._selectedJobs$.getValue(); } diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_context.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_context.tsx index 2c1fb6dc8c182..cb29924d2373c 100644 --- a/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_context.tsx +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_context.tsx @@ -8,6 +8,7 @@ import type { PropsWithChildren } from 'react'; import React, { useContext, useEffect, useMemo, useState, type FC } from 'react'; import { useTimefilter } from '@kbn/ml-date-picker'; +import { useGlobalUrlState } from '@kbn/ml-url-state/src/url_state'; import { AnomalyTimelineStateService } from './anomaly_timeline_state_service'; import { AnomalyExplorerCommonStateService } from './anomaly_explorer_common_state'; import { useMlKibana } from '../contexts/kibana'; @@ -18,7 +19,6 @@ import { AnomalyChartsStateService } from './anomaly_charts_state_service'; import { AnomalyExplorerChartsService } from '../services/anomaly_explorer_charts_service'; import { useTableSeverity } from '../components/controls/select_severity'; import { AnomalyDetectionAlertsStateService } from './alerts'; -import { explorerServiceFactory, type ExplorerService } from './explorer_dashboard_service'; import { useMlJobService } from '../services/job_service'; export interface AnomalyExplorerContextValue { @@ -28,7 +28,6 @@ export interface AnomalyExplorerContextValue { anomalyTimelineStateService: AnomalyTimelineStateService; chartsStateService: AnomalyChartsStateService; anomalyDetectionAlertsStateService: AnomalyDetectionAlertsStateService; - explorerService: ExplorerService; } /** @@ -57,11 +56,12 @@ export function useAnomalyExplorerContext() { export const AnomalyExplorerContextProvider: FC> = ({ children }) => { const [, , anomalyExplorerUrlStateService] = useExplorerUrlState(); + const [, , globalUrlStateService] = useGlobalUrlState(); const timefilter = useTimefilter(); const { services: { - mlServices: { mlApi, mlFieldFormatService }, + mlServices: { mlApi }, uiSettings, data, }, @@ -82,8 +82,6 @@ export const AnomalyExplorerContextProvider: FC> = ({ // updates so using `useEffect` is the right thing to do here to not get errors // related to React lifecycle methods. useEffect(() => { - const explorerService = explorerServiceFactory(mlJobService, mlFieldFormatService); - const anomalyTimelineService = new AnomalyTimelineService( timefilter, uiSettings, @@ -91,7 +89,9 @@ export const AnomalyExplorerContextProvider: FC> = ({ ); const anomalyExplorerCommonStateService = new AnomalyExplorerCommonStateService( - anomalyExplorerUrlStateService + anomalyExplorerUrlStateService, + globalUrlStateService, + mlJobService ); const anomalyTimelineStateService = new AnomalyTimelineStateService( @@ -129,7 +129,6 @@ export const AnomalyExplorerContextProvider: FC> = ({ anomalyTimelineStateService, chartsStateService, anomalyDetectionAlertsStateService, - explorerService, }); return () => { diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.tsx b/x-pack/plugins/ml/public/application/explorer/explorer.tsx index ab503c11d7955..abe8aa7adebd5 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer.tsx @@ -72,13 +72,13 @@ import { AnomaliesTable } from '../components/anomalies_table/anomalies_table'; import { ANOMALY_DETECTION_DEFAULT_TIME_RANGE } from '../../../common/constants/settings'; import { AnomalyContextMenu } from './anomaly_context_menu'; import type { JobSelectorProps } from '../components/job_selector/job_selector'; -import type { ExplorerState } from './reducers'; import { useToastNotificationService } from '../services/toast_notification_service'; import { useMlKibana, useMlLocator } from '../contexts/kibana'; import { useAnomalyExplorerContext } from './anomaly_explorer_context'; import { ML_ANOMALY_EXPLORER_PANELS } from '../../../common/types/storage'; import { AlertsPanel } from './alerts'; import { useMlIndexUtils } from '../util/index_service'; +import type { ExplorerState } from './explorer_data'; const AnnotationFlyout = dynamic(async () => ({ default: (await import('../components/annotations/annotation_flyout')).AnnotationFlyout, @@ -155,6 +155,7 @@ interface ExplorerUIProps { timeBuckets: TimeBuckets; selectedCells: AppStateSelectedCells | undefined | null; swimLaneSeverity?: number; + noInfluencersConfigured?: boolean; } export function getDefaultPanelsState() { @@ -182,6 +183,7 @@ export const Explorer: FC = ({ swimLaneSeverity, explorerState, overallSwimlaneData, + noInfluencersConfigured, }) => { const isMobile = useIsWithinBreakpoints(['xs', 's']); @@ -387,15 +389,8 @@ export const Explorer: FC = ({ const mlIndexUtils = useMlIndexUtils(); const mlLocator = useMlLocator(); - const { - annotations, - filterPlaceHolder, - indexPattern, - influencers, - loading, - noInfluencersConfigured, - tableData, - } = explorerState; + const { annotations, filterPlaceHolder, indexPattern, influencers, loading, tableData } = + explorerState; const chartsData = useObservable( chartsStateService.getChartsData$(), @@ -442,11 +437,31 @@ export const Explorer: FC = ({ ); + const handleJobSelectionChange = useCallback( + ({ + jobIds, + groups, + time, + }: { + jobIds: string[]; + groups: string[]; + time?: { from: string; to: string }; + }) => { + anomalyExplorerCommonStateService.setSelectedJobs(jobIds, time); + }, + [anomalyExplorerCommonStateService] + ); + + const selectedJobIds = Array.isArray(selectedJobs) ? selectedJobs.map((job) => job.id) : []; + const jobSelectorProps = { dateFormatTz: getDateFormatTz(uiSettings), - } as JobSelectorProps; + onSelectionChange: handleJobSelectionChange, + selectedJobIds, + } as unknown as JobSelectorProps; const noJobsSelected = !selectedJobs || selectedJobs.length === 0; + const hasResults: boolean = !!overallSwimlaneData?.points && overallSwimlaneData.points.length > 0; const hasResultsWithAnomalies = @@ -454,7 +469,6 @@ export const Explorer: FC = ({ tableData.anomalies?.length > 0; const hasActiveFilter = isDefined(swimLaneSeverity); - const selectedJobIds = Array.isArray(selectedJobs) ? selectedJobs.map((job) => job.id) : []; useEffect(() => { if (!noJobsSelected) { 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 deleted file mode 100644 index 2650df45d0e06..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts +++ /dev/null @@ -1,94 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/* - * Service for firing and registering for events across the different - * components in the Explorer dashboard. - */ - -import { isEqual } from 'lodash'; -import type { Observable } from 'rxjs'; -import { from, isObservable, Subject } from 'rxjs'; -import { distinctUntilChanged, flatMap, scan, shareReplay } from 'rxjs'; -import type { DeepPartial } from '@kbn/utility-types'; -import { jobSelectionActionCreator } from './actions'; -import { EXPLORER_ACTION } from './explorer_constants'; -import type { ExplorerState } from './reducers'; -import { explorerReducer, getExplorerDefaultState } from './reducers'; -import type { MlFieldFormatService } from '../services/field_format_service'; -import type { MlJobService } from '../services/job_service'; -import type { ExplorerJob } from './explorer_utils'; - -type ExplorerAction = (typeof EXPLORER_ACTION)[keyof typeof EXPLORER_ACTION]; - -export interface ExplorerActionPayloads { - [EXPLORER_ACTION.SET_EXPLORER_DATA]: DeepPartial; - [EXPLORER_ACTION.JOB_SELECTION_CHANGE]: { - loading: boolean; - selectedJobs: ExplorerJob[]; - noInfluencersConfigured: boolean; - }; -} - -export type ExplorerActions = { - [K in ExplorerAction]: K extends keyof ExplorerActionPayloads - ? { - type: K; - payload: ExplorerActionPayloads[K]; - } - : { - type: K; - }; -}[ExplorerAction]; - -type ExplorerActionMaybeObservable = ExplorerActions | Observable; - -export const explorerAction$ = new Subject(); - -const explorerFilteredAction$ = explorerAction$.pipe( - // consider observables as side-effects - flatMap((action: ExplorerActionMaybeObservable) => - isObservable(action) ? action : (from([action]) as Observable) - ), - distinctUntilChanged(isEqual) -); - -// applies action and returns state -const explorerState$: Observable = explorerFilteredAction$.pipe( - scan(explorerReducer, getExplorerDefaultState()), - // share the last emitted value among new subscribers - shareReplay(1) -); - -// Export observable state and action dispatchers as service -export const explorerServiceFactory = ( - mlJobService: MlJobService, - mlFieldFormatService: MlFieldFormatService -) => ({ - state$: explorerState$, - clearExplorerData: () => { - explorerAction$.next({ type: EXPLORER_ACTION.CLEAR_EXPLORER_DATA }); - }, - clearInfluencerFilterSettings: () => { - explorerAction$.next({ - type: EXPLORER_ACTION.CLEAR_INFLUENCER_FILTER_SETTINGS, - }); - }, - clearJobs: () => { - explorerAction$.next({ type: EXPLORER_ACTION.CLEAR_JOBS }); - }, - updateJobSelection: (selectedJobIds: string[]) => { - explorerAction$.next( - jobSelectionActionCreator(mlJobService, mlFieldFormatService, selectedJobIds) - ); - }, - setExplorerData: (payload: DeepPartial) => { - explorerAction$.next({ type: EXPLORER_ACTION.SET_EXPLORER_DATA, payload }); - }, -}); - -export type ExplorerService = ReturnType; diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts b/x-pack/plugins/ml/public/application/explorer/explorer_data.ts similarity index 76% rename from x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts rename to x-pack/plugins/ml/public/application/explorer/explorer_data.ts index 3eed0c410b0da..7e40dd07268d2 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_data.ts @@ -6,12 +6,14 @@ */ import type { DataView } from '@kbn/data-views-plugin/common'; -import { ML_RESULTS_INDEX_PATTERN } from '../../../../../common/constants/index_patterns'; -import type { ExplorerChartsData } from '../../explorer_charts/explorer_charts_container_service'; -import { getDefaultChartsData } from '../../explorer_charts/explorer_charts_container_service'; -import type { AnomaliesTableData, ExplorerJob } from '../../explorer_utils'; -import type { AnnotationsTable } from '../../../../../common/types/annotations'; -import type { InfluencerValueData } from '../../../components/influencers_list/influencers_list'; +import { ML_RESULTS_INDEX_PATTERN } from '../../../common/constants/index_patterns'; +import type { AnomaliesTableData, ExplorerJob } from './explorer_utils'; +import type { AnnotationsTable } from '../../../common/types/annotations'; +import type { InfluencerValueData } from '../components/influencers_list/influencers_list'; +import { + type ExplorerChartsData, + getDefaultChartsData, +} from './explorer_charts/explorer_charts_container_service'; export interface ExplorerState { overallAnnotations: AnnotationsTable; @@ -56,7 +58,7 @@ export function getExplorerDefaultState(): ExplorerState { indexPattern: getDefaultIndexPattern(), influencers: {}, isAndOperator: false, - loading: true, + loading: false, maskAll: false, noInfluencersConfigured: true, queryString: '', diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.ts b/x-pack/plugins/ml/public/application/explorer/explorer_utils.ts index abe921ee4352e..34e96968b2762 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.ts @@ -54,6 +54,7 @@ import type { MlResultsService } from '../services/results_service'; import type { Annotations, AnnotationsTable } from '../../../common/types/annotations'; import { useMlKibana } from '../contexts/kibana'; import type { MlApi } from '../services/ml_api_service'; +import { ML_RESULTS_INDEX_PATTERN } from '../../../common/constants/index_patterns'; export interface ExplorerJob { id: string; @@ -62,6 +63,7 @@ export interface ExplorerJob { isSingleMetricViewerJob?: boolean; sourceIndices?: string[]; modelPlotEnabled: boolean; + groups?: string[]; } export function isExplorerJob(arg: unknown): arg is ExplorerJob { @@ -149,6 +151,7 @@ export function createJobs(jobs: CombinedJob[]): ExplorerJob[] { isSingleMetricViewerJob: isTimeSeriesViewJob(job), sourceIndices: job.datafeed_config.indices, modelPlotEnabled: job.model_plot_config?.enabled === true, + groups: job.groups, }; }); } @@ -488,6 +491,7 @@ export async function loadAnomaliesTableData( influencersFilterQuery?: InfluencersFilterQuery ): Promise { const jobIds = getSelectionJobIds(selectedCells, selectedJobs); + const influencers = getSelectionInfluencers(selectedCells, fieldName); const timeRange = getSelectionTimeRange(selectedCells, bounds); @@ -700,3 +704,17 @@ export async function getDataViewsAndIndicesWithGeoFields( } return { sourceIndicesWithGeoFieldsMap, dataViews: [...dataViewsMap.values()] }; } + +// Creates index pattern in the format expected by the kuery bar/kuery autocomplete provider +// Field objects required fields: name, type, aggregatable, searchable +export function getIndexPattern(influencers: ExplorerJob[]) { + return { + title: ML_RESULTS_INDEX_PATTERN, + fields: influencers.map((influencer) => ({ + name: influencer.id, + type: 'string', + aggregatable: true, + searchable: true, + })), + }; +} 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 deleted file mode 100644 index 5eec96170b238..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts +++ /dev/null @@ -1,21 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getClearedSelectedAnomaliesState } from '../../explorer_utils'; - -import type { ExplorerState } from './state'; - -export function clearInfluencerFilterSettings(state: ExplorerState): ExplorerState { - return { - ...state, - isAndOperator: false, - maskAll: false, - queryString: '', - tableQueryString: '', - ...getClearedSelectedAnomaliesState(), - }; -} diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/get_index_pattern.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/get_index_pattern.ts deleted file mode 100644 index 878ba9370c95b..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/get_index_pattern.ts +++ /dev/null @@ -1,23 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ML_RESULTS_INDEX_PATTERN } from '../../../../../common/constants/index_patterns'; -import type { ExplorerJob } from '../../explorer_utils'; - -// Creates index pattern in the format expected by the kuery bar/kuery autocomplete provider -// Field objects required fields: name, type, aggregatable, searchable -export function getIndexPattern(influencers: ExplorerJob[]) { - return { - title: ML_RESULTS_INDEX_PATTERN, - fields: influencers.map((influencer) => ({ - name: influencer.id, - type: 'string', - aggregatable: true, - searchable: true, - })), - }; -} diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/index.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/index.ts deleted file mode 100644 index 74b6c88fba8d4..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/index.ts +++ /dev/null @@ -1,11 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export { getIndexPattern } from './get_index_pattern'; -export { explorerReducer } from './reducer'; -export type { ExplorerState } from './state'; -export { getExplorerDefaultState } from './state'; 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 deleted file mode 100644 index 58f7461b11047..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts +++ /dev/null @@ -1,45 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { EXPLORER_ACTION } from '../../explorer_constants'; -import type { ExplorerActionPayloads } from '../../explorer_dashboard_service'; - -import { getIndexPattern } from './get_index_pattern'; -import type { ExplorerState } from './state'; - -export const jobSelectionChange = ( - state: ExplorerState, - payload: ExplorerActionPayloads[typeof EXPLORER_ACTION.JOB_SELECTION_CHANGE] -): ExplorerState => { - const { selectedJobs, noInfluencersConfigured } = payload; - const stateUpdate: ExplorerState = { - ...state, - noInfluencersConfigured, - selectedJobs, - }; - - // clear filter if selected jobs have no influencers - if (stateUpdate.noInfluencersConfigured === true) { - const noFilterState = { - filterActive: false, - filteredFields: [], - influencersFilterQuery: undefined, - maskAll: false, - queryString: '', - tableQueryString: '', - }; - - Object.assign(stateUpdate, noFilterState); - } else { - // indexPattern will not be used if there are no influencers so set up can be skipped - // indexPattern is passed to KqlFilterBar which is only shown if (noInfluencersConfigured === false) - stateUpdate.indexPattern = getIndexPattern(selectedJobs); - } - - stateUpdate.loading = true; - return stateUpdate; -}; 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 deleted file mode 100644 index 50de00c937fa2..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts +++ /dev/null @@ -1,68 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EXPLORER_ACTION } from '../../explorer_constants'; -import type { ExplorerActionPayloads, ExplorerActions } from '../../explorer_dashboard_service'; -import { getClearedSelectedAnomaliesState } from '../../explorer_utils'; - -import { clearInfluencerFilterSettings } from './clear_influencer_filter_settings'; -import { jobSelectionChange } from './job_selection_change'; -import type { ExplorerState } from './state'; -import { getExplorerDefaultState } from './state'; -import { setKqlQueryBarPlaceholder } from './set_kql_query_bar_placeholder'; - -export const explorerReducer = ( - state: ExplorerState, - nextAction: ExplorerActions -): ExplorerState => { - const { type } = nextAction; - const payload = 'payload' in nextAction ? nextAction.payload : {}; - - let nextState: ExplorerState; - - switch (type) { - case EXPLORER_ACTION.CLEAR_EXPLORER_DATA: - nextState = getExplorerDefaultState(); - break; - - case EXPLORER_ACTION.CLEAR_INFLUENCER_FILTER_SETTINGS: - nextState = clearInfluencerFilterSettings(state); - break; - - case EXPLORER_ACTION.CLEAR_JOBS: - nextState = { - ...state, - ...getClearedSelectedAnomaliesState(), - loading: false, - selectedJobs: [], - }; - break; - - case EXPLORER_ACTION.JOB_SELECTION_CHANGE: - nextState = jobSelectionChange( - state, - payload as ExplorerActionPayloads[typeof EXPLORER_ACTION.JOB_SELECTION_CHANGE] - ); - break; - - case EXPLORER_ACTION.SET_EXPLORER_DATA: - nextState = { ...state, ...(payload as Partial) }; - break; - - default: - nextState = state; - } - - if (nextState.selectedJobs === null) { - return nextState; - } - - return { - ...nextState, - ...setKqlQueryBarPlaceholder(nextState), - }; -}; diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_kql_query_bar_placeholder.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_kql_query_bar_placeholder.ts deleted file mode 100644 index e68037f0da471..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_kql_query_bar_placeholder.ts +++ /dev/null @@ -1,32 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -import type { ExplorerState } from './state'; - -// Set the KQL query bar placeholder value -export const setKqlQueryBarPlaceholder = (state: ExplorerState) => { - const { influencers, noInfluencersConfigured } = state; - - if (influencers !== undefined && !noInfluencersConfigured) { - for (const influencerName in influencers) { - if (influencers[influencerName][0] && influencers[influencerName][0].influencerFieldValue) { - return { - filterPlaceHolder: i18n.translate('xpack.ml.explorer.kueryBar.filterPlaceholder', { - defaultMessage: 'Filter by influencer fields… ({queryExample})', - values: { - queryExample: `${influencerName} : ${influencers[influencerName][0].influencerFieldValue}`, - }, - }), - }; - } - } - } - - return {}; -}; diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/index.ts b/x-pack/plugins/ml/public/application/explorer/reducers/index.ts deleted file mode 100644 index db44d1864daa1..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/reducers/index.ts +++ /dev/null @@ -1,9 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export type { ExplorerState } from './explorer_reducer'; -export { explorerReducer, getExplorerDefaultState, getIndexPattern } from './explorer_reducer'; diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer/state_manager.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer/state_manager.tsx index 87983d2c61603..c3a65f1b65508 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer/state_manager.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer/state_manager.tsx @@ -6,7 +6,7 @@ */ import type { FC } from 'react'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import useObservable from 'react-use/lib/useObservable'; import { i18n } from '@kbn/i18n'; @@ -30,6 +30,10 @@ import { PageTitle } from '../../../components/page_title'; import { AnomalyResultsViewSelector } from '../../../components/anomaly_results_view_selector'; import { AnomalyDetectionEmptyState } from '../../../jobs/jobs_list/components/anomaly_detection_empty_state'; import { useAnomalyExplorerContext } from '../../../explorer/anomaly_explorer_context'; +import { getInfluencers } from '../../../explorer/explorer_utils'; +import { useMlJobService } from '../../../services/job_service'; +import type { ExplorerState } from '../../../explorer/explorer_data'; +import { getExplorerDefaultState } from '../../../explorer/explorer_data'; export interface ExplorerUrlStateManagerProps { jobsWithTimeRange: MlJobWithTimeRange[]; @@ -49,19 +53,21 @@ export const ExplorerUrlStateManager: FC = ({ const timeBuckets = useTimeBuckets(uiSettings); const timefilter = useTimefilter({ timeRangeSelector: true, autoRefreshSelector: true }); + const mlJobService = useMlJobService(); + const { selectedIds: jobIds, selectedJobs } = useJobSelection(jobsWithTimeRange); + const noInfluencersConfigured = getInfluencers(mlJobService, selectedJobs).length === 0; - const { jobIds } = useJobSelection(jobsWithTimeRange); const selectedJobsRunning = jobsWithTimeRange.some( - (job) => jobIds.includes(job.id) && job.isRunning === true + (job) => jobIds?.includes(job.id) && job.isRunning === true ); const anomalyExplorerContext = useAnomalyExplorerContext(); - const { explorerService } = anomalyExplorerContext; - const explorerState = useObservable(anomalyExplorerContext.explorerService.state$); + const [explorerState, setExplorerState] = useState(getExplorerDefaultState()); const refresh = useRefresh(); const lastRefresh = refresh?.lastRefresh ?? 0; + // TODO: Revalidate if this is still needed // We cannot simply infer bounds from the globalState's `time` attribute // with `moment` since it can contain custom strings such as `now-15m`. // So when globalState's `time` changes, we update the timefilter and use @@ -97,35 +103,18 @@ export const ExplorerUrlStateManager: FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - useEffect( - function handleJobSelection() { - if (jobIds.length > 0) { - explorerService.updateJobSelection(jobIds); - getJobsWithStoppedPartitions(jobIds); - } else { - explorerService.clearJobs(); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [JSON.stringify(jobIds)] - ); - useEffect(() => { - return () => { - // upon component unmounting - // clear any data to prevent next page from rendering old charts - explorerService.clearExplorerData(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + if (jobIds && jobIds.length > 0) { + getJobsWithStoppedPartitions(jobIds); + } + }, [getJobsWithStoppedPartitions, jobIds]); const [explorerData, loadExplorerData] = useExplorerData(); useEffect(() => { if (explorerData !== undefined && Object.keys(explorerData).length > 0) { - explorerService.setExplorerData(explorerData); + setExplorerState((prevState) => ({ ...prevState, ...explorerData })); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [explorerData]); const [tableInterval] = useTableInterval(); @@ -154,32 +143,33 @@ export const ExplorerUrlStateManager: FC = ({ anomalyExplorerContext.anomalyExplorerCommonStateService.getInfluencerFilterQuery$() ); - const loadExplorerDataConfig = - explorerState !== undefined - ? { - lastRefresh, - influencersFilterQuery, - noInfluencersConfigured: explorerState.noInfluencersConfigured, - selectedCells, - selectedJobs: explorerState.selectedJobs, - tableInterval: tableInterval.val, - tableSeverity: tableSeverity.val, - viewBySwimlaneFieldName: viewByFieldName, - } - : undefined; - - useEffect( - function updateAnomalyExplorerCommonState() { - anomalyExplorerContext.anomalyExplorerCommonStateService.setSelectedJobs( - loadExplorerDataConfig?.selectedJobs! - ); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [loadExplorerDataConfig] + const loadExplorerDataConfig = useMemo( + () => ({ + lastRefresh, + influencersFilterQuery, + noInfluencersConfigured, + selectedCells, + selectedJobs, + tableInterval: tableInterval.val, + tableSeverity: tableSeverity.val, + viewBySwimlaneFieldName: viewByFieldName, + }), + [ + lastRefresh, + influencersFilterQuery, + noInfluencersConfigured, + selectedCells, + selectedJobs, + tableInterval, + tableSeverity, + viewByFieldName, + ] ); useEffect(() => { if (!loadExplorerDataConfig || loadExplorerDataConfig?.selectedCells === undefined) return; + // TODO: Find other way to set loading state as it causes unnecessary re-renders + setExplorerState((prevState) => ({ ...prevState, loading: true })); loadExplorerData(loadExplorerDataConfig); // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(loadExplorerDataConfig)]); @@ -202,10 +192,7 @@ export const ExplorerUrlStateManager: FC = ({ - + = ({ { + setGlobalState({ + ml: { + jobIds, + groups, + }, + ...(time !== undefined ? { time } : {}), + }); + }, + [setGlobalState] + ); + // Use a side effect to clear appState when changing jobs. useEffect(() => { if (selectedJobIds !== undefined && previousSelectedJobIds !== undefined) { @@ -306,6 +327,7 @@ export const TimeSeriesExplorerUrlStateManager: FC ); diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts index 90dfe24946195..f2566c8d056b2 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts @@ -23,6 +23,15 @@ interface TimeSeriesExplorerProps { tableInterval?: string; tableSeverity?: number; zoom?: { from?: string; to?: string }; + handleJobSelectionChange: ({ + jobIds, + groups, + time, + }: { + jobIds: string[]; + groups: string[]; + time?: { from: string; to: string }; + }) => void; } // eslint-disable-next-line react/prefer-stateless-function diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index 57ded98fc8374..6622d268756ba 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -115,6 +115,7 @@ export class TimeSeriesExplorer extends React.Component { tableInterval: PropTypes.string, tableSeverity: PropTypes.number, zoom: PropTypes.object, + handleJobSelectionChange: PropTypes.func, }; state = getTimeseriesexplorerDefaultState(); @@ -1009,7 +1010,11 @@ export class TimeSeriesExplorer extends React.Component { if (selectedDetectorIndex === undefined || mlJobService.getJob(selectedJobId) === undefined) { return ( - + ); @@ -1039,7 +1044,12 @@ export class TimeSeriesExplorer extends React.Component { this.previousShowModelBounds = showModelBounds; return ( - + {fieldNamesWithEmptyValues.length > 0 && ( <> void; + selectedJobId?: string[]; } const timeseriesExplorerStyles = getTimeseriesExplorerStyles(); @@ -35,6 +46,8 @@ export const TimeSeriesExplorerPage: FC { const { services: { cases, docLinks }, @@ -66,7 +79,13 @@ export const TimeSeriesExplorerPage: FC {noSingleMetricJobsFound ? null : ( - + )} {children} diff --git a/x-pack/plugins/ml/public/maps/util.ts b/x-pack/plugins/ml/public/maps/util.ts index f661c08b6c5f6..8563380da5642 100644 --- a/x-pack/plugins/ml/public/maps/util.ts +++ b/x-pack/plugins/ml/public/maps/util.ts @@ -24,9 +24,9 @@ import { formatHumanReadableDateTimeSeconds } from '@kbn/ml-date-utils'; import { SEARCH_QUERY_LANGUAGE } from '@kbn/ml-query-utils'; import type { MlApi } from '../application/services/ml_api_service'; import { tabColor } from '../../common/util/group_color_utils'; -import { getIndexPattern } from '../application/explorer/reducers/explorer_reducer/get_index_pattern'; import { AnomalySource } from './anomaly_source'; -import type { SourceIndexGeoFields } from '../application/explorer/explorer_utils'; +import type { ExplorerJob } from '../application/explorer/explorer_utils'; +import { getIndexPattern, type SourceIndexGeoFields } from '../application/explorer/explorer_utils'; export const ML_ANOMALY_LAYERS = { TYPICAL: 'typical', @@ -170,8 +170,8 @@ export async function getResultsForJobId( const { query, timeFilters } = searchFilters; const hasQuery = query && query.query !== ''; let queryFilter; - // @ts-ignore missing properties from ExplorerJob - those fields aren't required for this - const indexPattern = getIndexPattern([{ id: jobId }]); + + const indexPattern = getIndexPattern([{ id: jobId }] as ExplorerJob[]); if (hasQuery && query.language === SEARCH_QUERY_LANGUAGE.KUERY) { queryFilter = toElasticsearchQuery(fromKueryExpression(query.query), indexPattern); From bb308916360c13e970ed90ca60f20caf8cf2fb1e Mon Sep 17 00:00:00 2001 From: rbrtj Date: Fri, 6 Dec 2024 11:44:53 +0100 Subject: [PATCH 04/35] remove unused ExplorerPage props --- x-pack/plugins/ml/public/application/explorer/explorer.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.tsx b/x-pack/plugins/ml/public/application/explorer/explorer.tsx index abe8aa7adebd5..9005a7fa4b6b5 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer.tsx @@ -94,8 +94,6 @@ const ExplorerChartsContainer = dynamic(async () => ({ interface ExplorerPageProps { jobSelectorProps: JobSelectorProps; - noInfluencersConfigured?: boolean; - influencers?: ExplorerState['influencers']; filterActive?: boolean; filterPlaceHolder?: string; indexPattern?: DataView; @@ -107,8 +105,6 @@ interface ExplorerPageProps { const ExplorerPage: FC> = ({ children, jobSelectorProps, - noInfluencersConfigured, - influencers, filterActive, filterPlaceHolder, indexPattern, @@ -664,8 +660,6 @@ export const Explorer: FC = ({ Date: Fri, 6 Dec 2024 17:28:59 +0100 Subject: [PATCH 05/35] Badges refactor --- .../job_selector/id_badges/id_badges.js | 97 ------------------- .../job_selector/id_badges/id_badges.tsx | 88 +++++++++++++++++ .../id_badges/{index.js => index.ts} | 0 .../components/job_selector/job_selector.tsx | 16 +-- .../job_selector/job_selector_flyout.tsx | 45 +++------ .../new_selection_id_badges.tsx | 7 +- .../explorer/anomaly_explorer_common_state.ts | 29 +++++- .../public/application/explorer/explorer.tsx | 16 ++- .../routing/routes/explorer/state_manager.tsx | 2 +- .../timeseriesexplorer/state_manager.tsx | 4 +- .../timeseriesexplorer_page.tsx | 3 - 11 files changed, 147 insertions(+), 160 deletions(-) delete mode 100644 x-pack/plugins/ml/public/application/components/job_selector/id_badges/id_badges.js create mode 100644 x-pack/plugins/ml/public/application/components/job_selector/id_badges/id_badges.tsx rename x-pack/plugins/ml/public/application/components/job_selector/id_badges/{index.js => index.ts} (100%) diff --git a/x-pack/plugins/ml/public/application/components/job_selector/id_badges/id_badges.js b/x-pack/plugins/ml/public/application/components/job_selector/id_badges/id_badges.js deleted file mode 100644 index b0fac87389e44..0000000000000 --- a/x-pack/plugins/ml/public/application/components/job_selector/id_badges/id_badges.js +++ /dev/null @@ -1,97 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { PropTypes } from 'prop-types'; -import { EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; -import { JobSelectorBadge } from '../job_selector_badge'; -import { i18n } from '@kbn/i18n'; - -export function IdBadges({ limit, maps, onLinkClick, selectedIds, showAllBarBadges }) { - const badges = []; - const currentGroups = []; - // Create group badges. Skip job ids here. - for (let i = 0; i < selectedIds.length; i++) { - const currentId = selectedIds[i]; - if (maps.groupsMap[currentId] !== undefined) { - currentGroups.push(currentId); - - badges.push( - - - - ); - } else { - continue; - } - } - // Create jobId badges for jobs with no groups or with groups not selected - for (let i = 0; i < selectedIds.length; i++) { - const currentId = selectedIds[i]; - if (maps.groupsMap[currentId] === undefined) { - const jobGroups = maps.jobsMap[currentId] || []; - - if (jobGroups.some((g) => currentGroups.includes(g)) === false) { - badges.push( - - - - ); - } else { - continue; - } - } else { - continue; - } - } - - if (showAllBarBadges || badges.length <= limit) { - if (badges.length > limit) { - badges.push( - - - {i18n.translate('xpack.ml.jobSelector.hideBarBadges', { - defaultMessage: 'Hide', - })} - - - ); - } - - return <>{badges}; - } else { - const overFlow = badges.length - limit; - - badges.splice(limit); - badges.push( - - - {i18n.translate('xpack.ml.jobSelector.showBarBadges', { - defaultMessage: `And {overFlow} more`, - values: { overFlow }, - })} - - - ); - - return <>{badges}; - } -} -IdBadges.propTypes = { - limit: PropTypes.number, - maps: PropTypes.shape({ - jobsMap: PropTypes.object, - groupsMap: PropTypes.object, - }), - onLinkClick: PropTypes.func, - selectedIds: PropTypes.array, - showAllBarBadges: PropTypes.bool, -}; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/id_badges/id_badges.tsx b/x-pack/plugins/ml/public/application/components/job_selector/id_badges/id_badges.tsx new file mode 100644 index 0000000000000..8e57e057fb9c5 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/job_selector/id_badges/id_badges.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { JobSelectorBadge } from '../job_selector_badge'; +import type { GroupObj } from '../job_selector'; + +interface IdBadgesProps { + limit: number; + selectedGroups: GroupObj[]; + selectedJobIds: string[]; + onLinkClick: () => void; + showAllBarBadges: boolean; +} + +export function IdBadges({ + limit, + selectedGroups, + onLinkClick, + selectedJobIds, + showAllBarBadges, +}: IdBadgesProps) { + const badges = []; + + // Create group badges. Skip job ids here. + for (let i = 0; i < selectedGroups.length; i++) { + const currentGroup = selectedGroups[i]; + badges.push( + + + + ); + } + // Create badges for jobs with no groups + for (let i = 0; i < selectedJobIds.length; i++) { + const currentId = selectedJobIds[i]; + if (selectedGroups.some((g) => g.jobIds.includes(currentId))) { + continue; + } + badges.push( + + + + ); + } + + if (showAllBarBadges || badges.length <= limit) { + if (badges.length > limit) { + badges.push( + + + {i18n.translate('xpack.ml.jobSelector.hideBarBadges', { + defaultMessage: 'Hide', + })} + + + ); + } + + return <>{badges}; + } else { + const overFlow = badges.length - limit; + + badges.splice(limit); + badges.push( + + + {i18n.translate('xpack.ml.jobSelector.showBarBadges', { + defaultMessage: `And {overFlow} more`, + values: { overFlow }, + })} + + + ); + + return <>{badges}; + } +} diff --git a/x-pack/plugins/ml/public/application/components/job_selector/id_badges/index.js b/x-pack/plugins/ml/public/application/components/job_selector/id_badges/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/components/job_selector/id_badges/index.js rename to x-pack/plugins/ml/public/application/components/job_selector/id_badges/index.ts diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx index e2d92b03195f4..848da20c66e65 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx @@ -79,11 +79,9 @@ export interface JobSelectorProps { timeseriesOnly: boolean; onSelectionChange?: ({ jobIds, - groups, time, }: { jobIds: string[]; - groups: GroupObj[]; time?: { from: string; to: string }; }) => void; selectedJobIds?: string[]; @@ -108,10 +106,6 @@ export function JobSelector({ true ); - const [maps, setMaps] = useState({ - groupsMap: {}, - jobsMap: {}, - }); const [selectedIds, setSelectedIds] = useState( mergeSelection(selectedJobIds, selectedGroups, singleSelection) ); @@ -138,10 +132,10 @@ export function JobSelector({ } const applySelection: JobSelectorFlyoutProps['onSelectionConfirmed'] = useCallback( - ({ newSelection, jobIds, groups: newGroups, time }) => { + ({ newSelection, jobIds, time }) => { setSelectedIds(newSelection); - onSelectionChange?.({ jobIds, groups: newGroups, time }); + onSelectionChange?.({ jobIds, time }); closeFlyout(); }, [onSelectionChange] @@ -162,9 +156,9 @@ export function JobSelector({ > setShowAllBarBadges(!showAllBarBadges)} - selectedIds={selectedIds} + selectedJobIds={selectedJobIds} + selectedGroups={selectedGroups} showAllBarBadges={showAllBarBadges} /> @@ -218,9 +212,7 @@ export function JobSelector({ singleSelection={singleSelection} selectedIds={selectedIds} onSelectionConfirmed={applySelection} - onJobsFetched={setMaps} onFlyoutClose={closeFlyout} - maps={maps} applyTimeRangeConfig={applyTimeRangeConfig} onTimeRangeConfigChange={setApplyTimeRangeConfig} /> 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 02fb52c120303..eff23a17c49e0 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 @@ -39,7 +39,6 @@ export const DEFAULT_GANTT_BAR_WIDTH = 299; // pixels export interface JobSelectionResult { newSelection: string[]; jobIds: string[]; - groups: Array<{ groupId: string; jobIds: string[] }>; time: { from: string; to: string } | undefined; } @@ -52,7 +51,6 @@ export interface JobSelectorFlyoutProps { onSelectionConfirmed: (payload: JobSelectionResult) => void; singleSelection: boolean; timeseriesOnly: boolean; - maps: JobSelectionMaps; withTimeRangeSelector?: boolean; applyTimeRangeConfig?: boolean; onTimeRangeConfigChange?: (v: boolean) => void; @@ -66,7 +64,6 @@ export const JobSelectorFlyoutContent: FC = ({ onJobsFetched, onSelectionConfirmed, onFlyoutClose, - maps, applyTimeRangeConfig, onTimeRangeConfigChange, withTimeRangeSelector = true, @@ -83,42 +80,33 @@ export const JobSelectorFlyoutContent: FC = ({ const [isLoading, setIsLoading] = useState(true); const [showAllBadges, setShowAllBadges] = useState(false); const [jobs, setJobs] = useState([]); - const [groups, setGroups] = useState([]); + const [groups, setGroups] = useState>([]); + const [ganttBarWidth, setGanttBarWidth] = useState(DEFAULT_GANTT_BAR_WIDTH); - const [jobGroupsMaps, setJobGroupsMaps] = useState(maps); const flyoutEl = useRef(null); const applySelection = useCallback(() => { - // allNewSelection will be a list of all job ids (including those from groups) selected from the table - const allNewSelection: string[] = []; - const groupSelection: Array<{ groupId: string; jobIds: string[] }> = []; + const selectedGroupIds = newSelection.filter((id) => groups.some((group) => group.id === id)); - newSelection.forEach((id) => { - if (jobGroupsMaps.groupsMap[id] !== undefined) { - // Push all jobs from selected groups into the newSelection list - allNewSelection.push(...jobGroupsMaps.groupsMap[id]); - // if it's a group - push group obj to set in global state - groupSelection.push({ groupId: id, jobIds: jobGroupsMaps.groupsMap[id] }); - } else { - allNewSelection.push(id); - } - }); - // create a Set to remove duplicate values - const allNewSelectionUnique = Array.from(new Set(allNewSelection)); + const jobsInSelectedGroups = groups + .filter((group) => selectedGroupIds.includes(group.id)) + .flatMap((group) => group.jobIds); + + const standaloneJobs = newSelection.filter( + (id) => !selectedGroupIds.includes(id) && !jobsInSelectedGroups.includes(id) + ); - const time = applyTimeRangeConfig - ? getTimeRangeFromSelection(jobs, allNewSelectionUnique) - : undefined; + const finalSelection = [...selectedGroupIds, ...standaloneJobs]; + const time = applyTimeRangeConfig ? getTimeRangeFromSelection(jobs, finalSelection) : undefined; onSelectionConfirmed({ - newSelection: allNewSelectionUnique, - jobIds: allNewSelectionUnique, - groups: groupSelection, + newSelection: finalSelection, + jobIds: finalSelection, time, }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [onSelectionConfirmed, newSelection, jobGroupsMaps, applyTimeRangeConfig]); + }, [onSelectionConfirmed, newSelection, applyTimeRangeConfig]); function removeId(id: string) { setNewSelection(newSelection.filter((item) => item !== id)); @@ -168,7 +156,6 @@ export const JobSelectorFlyoutContent: FC = ({ const { groups: groupsWithTimerange, groupsMap } = getGroupsFromJobs(normalizedJobs); setJobs(normalizedJobs); setGroups(groupsWithTimerange); - setJobGroupsMaps({ groupsMap, jobsMap: resp.jobsMap }); if (onJobsFetched) { onJobsFetched({ groupsMap, jobsMap: resp.jobsMap }); @@ -215,7 +202,7 @@ export const JobSelectorFlyoutContent: FC = ({ setShowAllBadges(!showAllBadges)} diff --git a/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx b/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx index 71db8bdbbf85a..d4e787344e7fe 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx +++ b/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx @@ -10,24 +10,23 @@ import React from 'react'; import { EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { JobSelectorBadge } from '../job_selector_badge'; -import type { JobSelectionMaps } from '../job_selector'; interface NewSelectionIdBadgesProps { limit: number; - maps: JobSelectionMaps; newSelection: string[]; onDeleteClick?: Function; onLinkClick?: MouseEventHandler; showAllBadges?: boolean; + groups: Array<{ id: string; jobIds: string[] }>; } export const NewSelectionIdBadges: FC = ({ limit, - maps, newSelection, onDeleteClick, onLinkClick, showAllBadges, + groups, }) => { const badges = []; @@ -41,7 +40,7 @@ export const NewSelectionIdBadges: FC = ({ g.id === newSelection[i])} removeId={onDeleteClick} /> diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_common_state.ts b/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_common_state.ts index c1d3165ed6a44..9b578b17d206f 100644 --- a/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_common_state.ts +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_common_state.ts @@ -18,6 +18,7 @@ import type { AnomalyExplorerFilterUrlState } from '../../../common/types/locato import type { KQLFilterSettings } from './components/explorer_query_bar/explorer_query_bar'; import { StateService } from '../services/state_service'; import type { MlJobService } from '../services/job_service'; +import type { GroupObj } from '../components/job_selector/job_selector'; export interface AnomalyExplorerState { selectedJobs: ExplorerJob[]; @@ -34,7 +35,7 @@ export type FilterSettings = Required< */ export class AnomalyExplorerCommonStateService extends StateService { private _selectedJobs$ = new BehaviorSubject([]); - private _selectedGroups$ = new BehaviorSubject(undefined); + private _selectedGroups$ = new BehaviorSubject([]); private _filterSettings$ = new BehaviorSubject(this._getDefaultFilterSettings()); private _invalidJobIds$ = new BehaviorSubject([]); @@ -95,17 +96,33 @@ export class AnomalyExplorerCommonStateService extends StateService { if (!selectedJobIds || selectedJobIds.length === 0) { this._selectedJobs$.next([]); this._invalidJobIds$.next([]); + this._selectedGroups$.next([]); return; } + // TODO: We are using mlJobService jobs, which has stale data. + + const groupIds = selectedJobIds.filter((id) => + this.mlJobsService.jobs.some((job) => job.groups?.includes(id)) + ); + + const selectedGroups = groupIds.map((groupId) => ({ + groupId, + jobIds: this.mlJobsService.jobs + .filter((job) => job.groups?.includes(groupId)) + .map((job) => job.job_id), + })); + const selectedJobs = this.mlJobsService.jobs.filter( - (j) => selectedJobIds.includes(j.job_id) || j.groups?.some((g) => selectedJobIds.includes(g)) + (j) => selectedJobIds.includes(j.job_id) || j.groups?.some((g) => groupIds.includes(g)) ); + const mappedJobs = createJobs(selectedJobs); const invalidJobIds = this._getInvalidJobIds(selectedJobIds); this._invalidJobIds$.next(invalidJobIds); this._selectedJobs$.next(mappedJobs); + this._selectedGroups$.next(selectedGroups); } private _getInvalidJobIds(jobIds: string[]): string[] { @@ -114,6 +131,14 @@ export class AnomalyExplorerCommonStateService extends StateService { ); } + public getSelectedGroups$(): Observable { + return this._selectedGroups$.pipe(distinctUntilChanged(isEqual), shareReplay(1)); + } + + public getSelectedGroups(): GroupObj[] { + return this._selectedGroups$.getValue(); + } + public getInvalidJobIds$(): Observable { return this._invalidJobIds$.pipe(distinctUntilChanged(isEqual), shareReplay(1)); } diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.tsx b/x-pack/plugins/ml/public/application/explorer/explorer.tsx index 9005a7fa4b6b5..79717e0eb8c30 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer.tsx @@ -298,6 +298,11 @@ export const Explorer: FC = ({ anomalyExplorerCommonStateService.getSelectedJobs() ); + const selectedGroups = useObservable( + anomalyExplorerCommonStateService.getSelectedGroups$(), + anomalyExplorerCommonStateService.getSelectedGroups() + ); + const alertsData = useObservable(anomalyDetectionAlertsStateService.anomalyDetectionAlerts$, []); const applyFilter = useCallback( @@ -434,15 +439,7 @@ export const Explorer: FC = ({ ); const handleJobSelectionChange = useCallback( - ({ - jobIds, - groups, - time, - }: { - jobIds: string[]; - groups: string[]; - time?: { from: string; to: string }; - }) => { + ({ jobIds, time }: { jobIds: string[]; time?: { from: string; to: string } }) => { anomalyExplorerCommonStateService.setSelectedJobs(jobIds, time); }, [anomalyExplorerCommonStateService] @@ -454,6 +451,7 @@ export const Explorer: FC = ({ dateFormatTz: getDateFormatTz(uiSettings), onSelectionChange: handleJobSelectionChange, selectedJobIds, + selectedGroups, } as unknown as JobSelectorProps; const noJobsSelected = !selectedJobs || selectedJobs.length === 0; diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer/state_manager.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer/state_manager.tsx index c3a65f1b65508..249e6c3acdb36 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer/state_manager.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer/state_manager.tsx @@ -168,7 +168,7 @@ export const ExplorerUrlStateManager: FC = ({ useEffect(() => { if (!loadExplorerDataConfig || loadExplorerDataConfig?.selectedCells === undefined) return; - // TODO: Find other way to set loading state as it causes unnecessary re-renders + // TODO: Find other way to set loading state as it causes unnecessary re-renders - handle it in anomaly_explorer_common_state setExplorerState((prevState) => ({ ...prevState, loading: true })); loadExplorerData(loadExplorerDataConfig); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer/state_manager.tsx b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer/state_manager.tsx index e664e1ee6b631..fbad84fd19e27 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer/state_manager.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer/state_manager.tsx @@ -192,17 +192,15 @@ export const TimeSeriesExplorerUrlStateManager: FC { setGlobalState({ ml: { jobIds, - groups, }, ...(time !== undefined ? { time } : {}), }); diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_page.tsx b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_page.tsx index 0ed10df176688..fdf6ffb660725 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_page.tsx +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_page.tsx @@ -20,7 +20,6 @@ import { useMlKibana } from '../contexts/kibana'; import { MlPageHeader } from '../components/page_header'; import { PageTitle } from '../components/page_title'; import { getAnnotationStyles, getTimeseriesExplorerStyles } from './styles'; -import type { GroupObj } from '../components/job_selector/job_selector'; interface TimeSeriesExplorerPageProps { dateFormatTz?: string; @@ -28,11 +27,9 @@ interface TimeSeriesExplorerPageProps { noSingleMetricJobsFound?: boolean; handleJobSelectionChange?: ({ jobIds, - groups, time, }: { jobIds: string[]; - groups: GroupObj[]; time?: { from: string; to: string }; }) => void; selectedJobId?: string[]; From 8885e9121b0265e27bb6084accd5916c0c3550bb Mon Sep 17 00:00:00 2001 From: rbrtj Date: Fri, 6 Dec 2024 18:01:51 +0100 Subject: [PATCH 06/35] improve memoization for useUrlStateService --- .../packages/ml/url_state/src/url_state.tsx | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/x-pack/packages/ml/url_state/src/url_state.tsx b/x-pack/packages/ml/url_state/src/url_state.tsx index d9ec933a4c8d2..93d0d521dd3fc 100644 --- a/x-pack/packages/ml/url_state/src/url_state.tsx +++ b/x-pack/packages/ml/url_state/src/url_state.tsx @@ -26,6 +26,7 @@ import type { Observable } from 'rxjs'; import { BehaviorSubject } from 'rxjs'; import { distinctUntilChanged } from 'rxjs'; import { isPopulatedObject } from '@kbn/ml-is-populated-object'; +import useDeepCompareEffect from 'react-use/lib/useDeepCompareEffect'; export interface Dictionary { [id: string]: TValue; @@ -279,8 +280,16 @@ export const useUrlStateService = ( stateKey: K, options: UrlStateOptions ): [T, (update: Partial, replaceState?: boolean) => void, UrlStateService] => { + const optionsRef = useRef(options); + + useDeepCompareEffect(() => { + optionsRef.current = options; + }, [options]); + const [state, setState] = useUrlState(stateKey); - const urlState = isAppStateOptions(stateKey, options) ? state?.[options.pageKey] : state; + const urlState = isAppStateOptions(stateKey, optionsRef.current) + ? state?.[optionsRef.current.pageKey] + : state; const setCallback = useRef(); @@ -292,7 +301,7 @@ export const useUrlStateService = ( const resultState: T = useMemo(() => { const result = { - ...(options.defaultState ?? {}), + ...(optionsRef.current.defaultState ?? {}), ...(urlState ?? {}), }; @@ -312,7 +321,6 @@ export const useUrlStateService = ( prevPageState.current = result; return result; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [urlState]); const onStateUpdate = useCallback( @@ -320,9 +328,9 @@ export const useUrlStateService = ( if (!setCallback?.current) { throw new Error('Callback for URL state update has not been initialized.'); } - if (isAppStateOptions(stateKey, options)) { + if (isAppStateOptions(stateKey, optionsRef.current)) { setCallback.current( - options.pageKey, + optionsRef.current.pageKey, { ...resultState, ...update, @@ -333,7 +341,7 @@ export const useUrlStateService = ( setCallback.current({ ...resultState, ...update }); } }, - [stateKey, options, resultState] + [stateKey, resultState] ); const urlStateService = useMemo(() => new UrlStateService(), []); From c23276f9af824387379e1afcf9b66a0ccbc5f038 Mon Sep 17 00:00:00 2001 From: rbrtj Date: Mon, 9 Dec 2024 12:26:25 +0100 Subject: [PATCH 07/35] pass groups to dashboard embeddable instead of individual jobs --- .../application/explorer/anomaly_timeline.tsx | 39 +++++++++++++------ .../application/explorer/explorer_utils.ts | 12 ++++++ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx index ae12b8b1fa4ac..83aa271cbccf1 100644 --- a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx @@ -49,13 +49,13 @@ import { ANOMALY_SWIMLANE_EMBEDDABLE_TYPE } from '../..'; import type { SwimlaneType } from './explorer_constants'; import { OVERALL_LABEL, SWIMLANE_TYPE, VIEW_BY_JOB_LABEL } from './explorer_constants'; import { useMlKibana } from '../contexts/kibana'; -import type { ExplorerState } from './reducers/explorer_reducer'; import { ExplorerNoInfluencersFound } from './components/explorer_no_influencers_found'; import { SwimlaneContainer } from './swimlane_container'; -import type { - AppStateSelectedCells, - OverallSwimlaneData, - ViewBySwimLaneData, +import { + getMergedGroupsAndJobsIds, + type AppStateSelectedCells, + type OverallSwimlaneData, + type ViewBySwimLaneData, } from './explorer_utils'; import { NoOverallData } from './components/no_overall_data'; import { SeverityControl } from '../components/severity_control'; @@ -67,6 +67,7 @@ import { useAnomalyExplorerContext } from './anomaly_explorer_context'; import { getTimeBoundsFromSelection } from './hooks/use_selected_cells'; import { SwimLaneWrapper } from './alerts'; import { Y_AXIS_LABEL_WIDTH } from './constants'; +import type { ExplorerState } from './explorer_data'; function mapSwimlaneOptionsToEuiOptions(options: string[]) { return options.map((option) => ({ @@ -131,6 +132,16 @@ export const AnomalyTimeline: FC = React.memo( anomalyExplorerCommonStateService.getSelectedJobs() ); + const selectedGroups = useObservable( + anomalyExplorerCommonStateService.getSelectedGroups$(), + anomalyExplorerCommonStateService.getSelectedGroups() + ); + + const mergedGroupsAndJobsIds = useMemo( + () => getMergedGroupsAndJobsIds(selectedGroups, selectedJobs), + [selectedGroups, selectedJobs] + ); + const loading = useObservable(anomalyTimelineStateService.isOverallSwimLaneLoading$(), true); const swimlaneContainerWidth = useObservable( @@ -196,6 +207,7 @@ export const AnomalyTimeline: FC = React.memo( openCasesModalCallback({ swimlaneType: swimLaneType, ...(swimLaneType === SWIMLANE_TYPE.VIEW_BY ? { viewBy: viewBySwimlaneFieldName } : {}), + // For cases attachment, pass just the job IDs to maintain stale data jobIds: selectedJobs?.map((v) => v.id), timeRange: globalTimeRange, ...(isDefined(queryString) && queryString !== '' @@ -359,15 +371,13 @@ export const AnomalyTimeline: FC = React.memo( const stateTransfer = embeddable!.getStateTransfer(); - const jobIds = selectedJobs.map((j) => j.id); - - const config = getDefaultEmbeddablePanelConfig(jobIds, queryString); + const config = getDefaultEmbeddablePanelConfig(mergedGroupsAndJobsIds, queryString); const embeddableInput: Partial = { id: config.id, title: newTitle, description: newDescription, - jobIds, + jobIds: mergedGroupsAndJobsIds, swimlaneType: selectedSwimlane, ...(selectedSwimlane === SWIMLANE_TYPE.VIEW_BY ? { viewBy: viewBySwimlaneFieldName } @@ -389,7 +399,14 @@ export const AnomalyTimeline: FC = React.memo( path, }); }, - [embeddable, queryString, selectedJobs, selectedSwimlane, viewBySwimlaneFieldName] + [ + embeddable, + mergedGroupsAndJobsIds, + queryString, + selectedJobs, + selectedSwimlane, + viewBySwimlaneFieldName, + ] ); return ( @@ -621,7 +638,7 @@ export const AnomalyTimeline: FC = React.memo( defaultMessage: 'Anomaly swim lane', })} documentInfo={{ - title: getDefaultSwimlanePanelTitle(selectedJobs.map(({ id }) => id)), + title: getDefaultSwimlanePanelTitle(mergedGroupsAndJobsIds), }} onClose={() => { setSelectedSwimlane(undefined); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.ts b/x-pack/plugins/ml/public/application/explorer/explorer_utils.ts index 34e96968b2762..ce68528040b0d 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.ts @@ -55,6 +55,7 @@ import type { Annotations, AnnotationsTable } from '../../../common/types/annota import { useMlKibana } from '../contexts/kibana'; import type { MlApi } from '../services/ml_api_service'; import { ML_RESULTS_INDEX_PATTERN } from '../../../common/constants/index_patterns'; +import type { GroupObj } from '../components/job_selector/job_selector'; export interface ExplorerJob { id: string; @@ -718,3 +719,14 @@ export function getIndexPattern(influencers: ExplorerJob[]) { })), }; } + +// Returns a list of unique group ids and job ids +export function getMergedGroupsAndJobsIds(groups: GroupObj[], selectedJobs: ExplorerJob[]) { + const jobIdsFromGroups = groups.flatMap((group) => group.jobIds); + const groupIds = groups.map((group) => group.groupId); + const uniqueJobIds = selectedJobs + .filter((job) => !jobIdsFromGroups.includes(job.id)) + .map((job) => job.id); + + return [...groupIds, ...uniqueJobIds]; +} From 334f73606d33c9310154c2f53e41e71a0dc580f3 Mon Sep 17 00:00:00 2001 From: rbrtj Date: Mon, 9 Dec 2024 13:25:48 +0100 Subject: [PATCH 08/35] global state interface comment --- x-pack/packages/ml/url_state/src/url_state.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/x-pack/packages/ml/url_state/src/url_state.tsx b/x-pack/packages/ml/url_state/src/url_state.tsx index 93d0d521dd3fc..cd8d11c4167ac 100644 --- a/x-pack/packages/ml/url_state/src/url_state.tsx +++ b/x-pack/packages/ml/url_state/src/url_state.tsx @@ -374,6 +374,10 @@ export const usePageUrlState = ( return useUrlStateService<'_a', T['pageUrlState']>('_a', { pageKey, defaultState }); }; +/** + * Global state type, to add more state types, add them here + */ + export interface GlobalState { ml: { jobIds: string[]; From 7b8dd0e89b6e46a0914df2a609ef173228a327de Mon Sep 17 00:00:00 2001 From: rbrtj Date: Mon, 9 Dec 2024 13:38:57 +0100 Subject: [PATCH 09/35] remove invalidTimeRangeError --- .../ml/public/application/explorer/explorer.tsx | 17 ----------------- .../routing/routes/explorer/state_manager.tsx | 17 ----------------- 2 files changed, 34 deletions(-) diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.tsx b/x-pack/plugins/ml/public/application/explorer/explorer.tsx index 79717e0eb8c30..5610aa3f911a8 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer.tsx @@ -143,7 +143,6 @@ interface ExplorerUIProps { showCharts: boolean; selectedJobsRunning: boolean; overallSwimlaneData: OverallSwimlaneData | null; - invalidTimeRangeError?: boolean; stoppedPartitions?: string[]; // TODO Remove timefilter: TimefilterContract; @@ -168,7 +167,6 @@ export function getDefaultPanelsState() { } export const Explorer: FC = ({ - invalidTimeRangeError, showCharts, severity, stoppedPartitions, @@ -364,21 +362,6 @@ export const Explorer: FC = ({ [explorerState, language, filterSettings] ); - useEffect(() => { - if (invalidTimeRangeError) { - displayWarningToast( - i18n.translate('xpack.ml.explorer.invalidTimeRangeInUrlCallout', { - defaultMessage: - 'The time filter was changed to the full range due to an invalid default time filter. Check the advanced settings for {field}.', - values: { - field: ANOMALY_DETECTION_DEFAULT_TIME_RANGE, - }, - }) - ); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const { services: { charts: chartsService, diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer/state_manager.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer/state_manager.tsx index 249e6c3acdb36..44c5aaa14fdea 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer/state_manager.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer/state_manager.tsx @@ -47,9 +47,7 @@ export const ExplorerUrlStateManager: FC = ({ } = useMlKibana(); const { mlApi } = mlServices; - const [globalState] = useUrlState('_g'); const [stoppedPartitions, setStoppedPartitions] = useState(); - const [invalidTimeRangeError, setInValidTimeRangeError] = useState(false); const timeBuckets = useTimeBuckets(uiSettings); const timefilter = useTimefilter({ timeRangeSelector: true, autoRefreshSelector: true }); @@ -67,20 +65,6 @@ export const ExplorerUrlStateManager: FC = ({ const refresh = useRefresh(); const lastRefresh = refresh?.lastRefresh ?? 0; - // TODO: Revalidate if this is still needed - // We cannot simply infer bounds from the globalState's `time` attribute - // with `moment` since it can contain custom strings such as `now-15m`. - // So when globalState's `time` changes, we update the timefilter and use - // `timefilter.getBounds()` to update `bounds` in this component's state. - useEffect(() => { - if (globalState?.time !== undefined) { - if (globalState.time.mode === 'invalid') { - setInValidTimeRangeError(true); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [globalState?.time?.from, globalState?.time?.to, globalState?.time?.ts]); - const getJobsWithStoppedPartitions = useCallback(async (selectedJobIds: string[]) => { try { const fetchedStoppedPartitions = await mlApi.results.getCategoryStoppedPartitions( @@ -215,7 +199,6 @@ export const ExplorerUrlStateManager: FC = ({ showCharts, severity: tableSeverity.val, stoppedPartitions, - invalidTimeRangeError, selectedJobsRunning, timeBuckets, timefilter, From 91facf8dc629c91d95a67e170299749d11354135 Mon Sep 17 00:00:00 2001 From: rbrtj Date: Mon, 9 Dec 2024 13:44:08 +0100 Subject: [PATCH 10/35] remove unused import --- x-pack/plugins/ml/public/application/explorer/explorer.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.tsx b/x-pack/plugins/ml/public/application/explorer/explorer.tsx index 5610aa3f911a8..8657db9416927 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer.tsx @@ -69,7 +69,6 @@ import { FILTER_ACTION } from './explorer_constants'; // Anomalies Table // @ts-ignore import { AnomaliesTable } from '../components/anomalies_table/anomalies_table'; -import { ANOMALY_DETECTION_DEFAULT_TIME_RANGE } from '../../../common/constants/settings'; import { AnomalyContextMenu } from './anomaly_context_menu'; import type { JobSelectorProps } from '../components/job_selector/job_selector'; import { useToastNotificationService } from '../services/toast_notification_service'; From 0727a30140cf7af5d2115a28d05e77d64837eb4f Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 9 Dec 2024 13:26:43 +0000 Subject: [PATCH 11/35] [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' --- .../public/application/routing/routes/explorer/state_manager.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer/state_manager.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer/state_manager.tsx index 44c5aaa14fdea..cb99df78799f3 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer/state_manager.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer/state_manager.tsx @@ -12,7 +12,6 @@ import useObservable from 'react-use/lib/useObservable'; import { i18n } from '@kbn/i18n'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { useUrlState } from '@kbn/ml-url-state'; import { useTimefilter } from '@kbn/ml-date-picker'; import { ML_JOB_ID } from '@kbn/ml-anomaly-utils'; import { useTimeBuckets } from '@kbn/ml-time-buckets'; From 654ef68e2c3ecd26a410ba6a90f1b79812a78ced Mon Sep 17 00:00:00 2001 From: rbrtj Date: Mon, 9 Dec 2024 15:57:41 +0100 Subject: [PATCH 12/35] fix eslint issues --- .../contexts/ml/use_job_selection_flyout.tsx | 14 +++---- .../public/application/explorer/explorer.tsx | 2 +- .../common/components/job_selector_flyout.tsx | 39 ------------------- 3 files changed, 6 insertions(+), 49 deletions(-) delete mode 100644 x-pack/plugins/ml/public/embeddables/common/components/job_selector_flyout.tsx diff --git a/x-pack/plugins/ml/public/application/contexts/ml/use_job_selection_flyout.tsx b/x-pack/plugins/ml/public/application/contexts/ml/use_job_selection_flyout.tsx index a95c9ad41fd42..fd19689d68b5e 100644 --- a/x-pack/plugins/ml/public/application/contexts/ml/use_job_selection_flyout.tsx +++ b/x-pack/plugins/ml/public/application/contexts/ml/use_job_selection_flyout.tsx @@ -10,9 +10,10 @@ import moment from 'moment'; import type { KibanaReactOverlays } from '@kbn/kibana-react-plugin/public'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { useMlKibana } from '../kibana'; -import { JobSelectorFlyout } from '../../../embeddables/common/components/job_selector_flyout'; -import { getInitialGroupsMap } from '../../components/job_selector/job_selector'; -import type { JobSelectionResult } from '../../components/job_selector/job_selector_flyout'; +import { + JobSelectorFlyoutContent, + type JobSelectionResult, +} from '../../components/job_selector/job_selector_flyout'; export type GetJobSelection = ReturnType; @@ -49,16 +50,12 @@ export function useJobSelectionFlyout() { const tzConfig = uiSettings.get('dateFormat:tz'); const dateFormatTz = tzConfig !== 'Browser' ? tzConfig : moment.tz.guess(); - const maps = { - groupsMap: getInitialGroupsMap([]), - jobsMap: {}, - }; return new Promise(async (resolve, reject) => { try { flyoutRef.current = overlays.openFlyout( - ); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.tsx b/x-pack/plugins/ml/public/application/explorer/explorer.tsx index 23ffda3e6a6f2..9e070522db7d0 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer.tsx @@ -270,7 +270,7 @@ export const Explorer: FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [anomalyExplorerPanelState]); - const { displayWarningToast, displayDangerToast } = useToastNotificationService(); + const { displayDangerToast } = useToastNotificationService(); const { anomalyTimelineStateService, anomalyExplorerCommonStateService, diff --git a/x-pack/plugins/ml/public/embeddables/common/components/job_selector_flyout.tsx b/x-pack/plugins/ml/public/embeddables/common/components/job_selector_flyout.tsx deleted file mode 100644 index 44888067c839e..0000000000000 --- a/x-pack/plugins/ml/public/embeddables/common/components/job_selector_flyout.tsx +++ /dev/null @@ -1,39 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { FC } from 'react'; -import React, { useState } from 'react'; -import type { JobSelectorFlyoutProps } from '../../../application/components/job_selector/job_selector_flyout'; -import { JobSelectorFlyoutContent } from '../../../application/components/job_selector/job_selector_flyout'; - -export const JobSelectorFlyout: FC = ({ - selectedIds, - withTimeRangeSelector, - dateFormatTz, - singleSelection, - timeseriesOnly, - onFlyoutClose, - onSelectionConfirmed, - maps, -}) => { - const [applyTimeRangeState, setApplyTimeRangeState] = useState(true); - - return ( - - ); -}; From 30c841c65066223e330b2f810fc50e190d2ab89c Mon Sep 17 00:00:00 2001 From: rbrtj Date: Mon, 9 Dec 2024 16:05:27 +0100 Subject: [PATCH 13/35] fix apply time range on job selection --- .../components/job_selector/job_select_service_utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_select_service_utils.ts b/x-pack/plugins/ml/public/application/components/job_selector/job_select_service_utils.ts index 26110819fd1ed..b5277564263ee 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_select_service_utils.ts +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_select_service_utils.ts @@ -86,7 +86,7 @@ export function getTimeRangeFromSelection(jobs: MlJobWithTimeRange[], selection: if (jobs.length > 0) { const times: number[] = []; jobs.forEach((job) => { - if (selection.includes(job.job_id)) { + if (selection.includes(job.job_id) || selection.some((s) => job.groups?.includes(s))) { if (job.timeRange.from !== undefined) { times.push(job.timeRange.from); } From f4ca91c1c9804f1d9f6dd73e9402a536d1f8744b Mon Sep 17 00:00:00 2001 From: rbrtj Date: Mon, 9 Dec 2024 17:19:37 +0100 Subject: [PATCH 14/35] remove unused translations --- x-pack/plugins/translations/translations/fr-FR.json | 1 - x-pack/plugins/translations/translations/ja-JP.json | 1 - x-pack/plugins/translations/translations/zh-CN.json | 2 -- 3 files changed, 4 deletions(-) diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 397c73a512c10..f71ad58ab24df 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -29659,7 +29659,6 @@ "xpack.ml.explorer.distributionChart.valueWithoutAnomalyScoreLabel": "valeur", "xpack.ml.explorer.invalidKuerySyntaxErrorMessageFromTable": "Syntaxe non valide dans la barre de requête. L'entrée doit être du code KQL (Kibana Query Language) valide", "xpack.ml.explorer.invalidKuerySyntaxErrorMessageQueryBar": "Requête non valide", - "xpack.ml.explorer.invalidTimeRangeInUrlCallout": "Le filtre de temps a été modifié pour inclure la plage entière en raison d'un filtre de temps par défaut non valide. Vérifiez les paramètres avancés pour {field}.", "xpack.ml.explorer.jobIdLabel": "ID tâche", "xpack.ml.explorer.jobScoreAcrossAllInfluencersLabel": "(score de tâche pour tous les influenceurs)", "xpack.ml.explorer.kueryBar.filterPlaceholder": "Filtrer par champ d'influenceur… ({queryExample})", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 37f24051c6001..8e614ec707257 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -29519,7 +29519,6 @@ "xpack.ml.explorer.distributionChart.valueWithoutAnomalyScoreLabel": "値", "xpack.ml.explorer.invalidKuerySyntaxErrorMessageFromTable": "クエリーバーに無効な構文。インプットは有効な Kibana クエリー言語(KQL)でなければなりません", "xpack.ml.explorer.invalidKuerySyntaxErrorMessageQueryBar": "無効なクエリー", - "xpack.ml.explorer.invalidTimeRangeInUrlCallout": "無効なデフォルト時間フィルターのため、時間フィルターが全範囲に変更されました。{field}の詳細設定を確認してください。", "xpack.ml.explorer.jobIdLabel": "ジョブID", "xpack.ml.explorer.jobScoreAcrossAllInfluencersLabel": "(すべての影響因子のジョブスコア)", "xpack.ml.explorer.kueryBar.filterPlaceholder": "影響因子フィールドでフィルタリング…({queryExample})", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 67f0cee7591ff..c125dc4377b25 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -29044,10 +29044,8 @@ "xpack.ml.explorer.distributionChart.valueWithoutAnomalyScoreLabel": "值", "xpack.ml.explorer.invalidKuerySyntaxErrorMessageFromTable": "查询栏中的语法无效。输入必须是有效的 Kibana 查询语言 (KQL)", "xpack.ml.explorer.invalidKuerySyntaxErrorMessageQueryBar": "无效查询", - "xpack.ml.explorer.invalidTimeRangeInUrlCallout": "由于默认时间筛选无效,时间筛选已更改为完整范围。检查 {field} 的高级设置。", "xpack.ml.explorer.jobIdLabel": "作业 ID", "xpack.ml.explorer.jobScoreAcrossAllInfluencersLabel": "(所有影响因素的作业分数)", - "xpack.ml.explorer.kueryBar.filterPlaceholder": "按影响因素字段筛选……({queryExample})", "xpack.ml.explorer.mapTitle": "异常计数(按位置){infoTooltip}", "xpack.ml.explorer.noAnomaliesFoundLabel": "找不到异常", "xpack.ml.explorer.noConfiguredInfluencersTooltip": "'排名最前影响因素'列表被隐藏,因为没有为所选作业配置影响因素。", From e20f30aaae4af1f220b0cca3433d8e473d11b6d5 Mon Sep 17 00:00:00 2001 From: rbrtj Date: Tue, 10 Dec 2024 13:32:09 +0100 Subject: [PATCH 15/35] remove file after merge conflicts --- .../reducers/explorer_reducer/state.ts | 71 ------------------- 1 file changed, 71 deletions(-) delete mode 100644 x-pack/platform/plugins/shared/ml/public/application/explorer/reducers/explorer_reducer/state.ts diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/reducers/explorer_reducer/state.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/reducers/explorer_reducer/state.ts deleted file mode 100644 index 8e659fe9ffab0..0000000000000 --- a/x-pack/platform/plugins/shared/ml/public/application/explorer/reducers/explorer_reducer/state.ts +++ /dev/null @@ -1,71 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { DataView } from '@kbn/data-views-plugin/common'; -import type { ExplorerChartsData } from '../../../../../common/types/results'; -import type { AnnotationsTable } from '../../../../../common/types/annotations'; -import { InfluencerValueData } from '../../../components/influencers_list/influencers_list'; -import { ExplorerJob } from '../../explorer_utils'; - -export interface ExplorerState { - overallAnnotations: AnnotationsTable; - annotations: AnnotationsTable; - anomalyChartsDataLoading: boolean; - chartsData: ExplorerChartsData; - fieldFormatsLoading: boolean; - filterPlaceHolder: string | undefined; - indexPattern: { - title: string; - fields: Array<{ name: string; type: string; aggregatable: boolean; searchable: boolean }>; - }; - influencers: Record; - isAndOperator: boolean; - loading: boolean; - maskAll: boolean; - noInfluencersConfigured: boolean; - queryString: string; - selectedJobs: ExplorerJob[] | null; - tableData: AnomaliesTableData; - tableQueryString: string; -} - -function getDefaultIndexPattern() { - return { title: ML_RESULTS_INDEX_PATTERN, fields: [] } as unknown as DataView; -} - -export function getExplorerDefaultState(): ExplorerState { - return { - overallAnnotations: { - error: undefined, - annotationsData: [], - }, - annotations: { - error: undefined, - annotationsData: [], - }, - anomalyChartsDataLoading: true, - chartsData: getDefaultChartsData(), - fieldFormatsLoading: false, - filterPlaceHolder: undefined, - indexPattern: getDefaultIndexPattern(), - influencers: {}, - isAndOperator: false, - loading: false, - maskAll: false, - noInfluencersConfigured: true, - queryString: '', - selectedJobs: null, - tableData: { - anomalies: [], - examplesByJobId: [''], - interval: 0, - jobIds: [], - showViewSeriesLink: false, - }, - tableQueryString: '', - }; -} From 2f673e6458ef7bef3c884c9f424b034c5b524c6f Mon Sep 17 00:00:00 2001 From: rbrtj Date: Tue, 10 Dec 2024 13:53:29 +0100 Subject: [PATCH 16/35] support replace state in global state --- x-pack/platform/packages/private/ml/url_state/src/url_state.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/platform/packages/private/ml/url_state/src/url_state.tsx b/x-pack/platform/packages/private/ml/url_state/src/url_state.tsx index cd8d11c4167ac..e24176321094a 100644 --- a/x-pack/platform/packages/private/ml/url_state/src/url_state.tsx +++ b/x-pack/platform/packages/private/ml/url_state/src/url_state.tsx @@ -338,7 +338,7 @@ export const useUrlStateService = ( replaceState ); } else { - setCallback.current({ ...resultState, ...update }); + setCallback.current({ ...resultState, ...update }, replaceState); } }, [stateKey, resultState] From 752cfcb49c885286dd02cfd3c429db5039ad322c Mon Sep 17 00:00:00 2001 From: rbrtj Date: Tue, 10 Dec 2024 14:08:48 +0100 Subject: [PATCH 17/35] GroupWithTimeRange type --- .../job_selector/job_select_service_utils.ts | 3 ++- .../job_selector/job_selector_flyout.tsx | 18 +++++++++++++++++- .../new_selection_id_badges.tsx | 3 ++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/job_select_service_utils.ts b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/job_select_service_utils.ts index b5277564263ee..c114ba4c636a4 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/job_select_service_utils.ts +++ b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/job_select_service_utils.ts @@ -11,9 +11,10 @@ import d3 from 'd3'; import type { Dictionary } from '../../../../common/types/common'; import type { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; +import type { GroupWithTimerange } from './job_selector_flyout'; export function getGroupsFromJobs(jobs: MlJobWithTimeRange[]) { - const groups: Dictionary = {}; + const groups: Dictionary = {}; const groupsMap: Dictionary = {}; jobs.forEach((job) => { diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/job_selector_flyout.tsx b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/job_selector_flyout.tsx index eff23a17c49e0..c64642f9a50dc 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/job_selector_flyout.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/job_selector_flyout.tsx @@ -21,6 +21,7 @@ import { EuiResizeObserver, EuiProgress, } from '@elastic/eui'; +import type { Moment } from 'moment'; import { NewSelectionIdBadges } from './new_selection_id_badges'; // @ts-ignore import { JobSelectorTable } from './job_selector_table'; @@ -56,6 +57,21 @@ export interface JobSelectorFlyoutProps { onTimeRangeConfigChange?: (v: boolean) => void; } +export interface GroupWithTimerange { + id: string; + jobIds: string[]; + timeRange: { + from: number; + to: number; + fromPx: number; + toPx: number; + fromMoment: Moment | null; + toMoment: Moment | null; + widthPx?: number | null; + label?: string; + }; +} + export const JobSelectorFlyoutContent: FC = ({ dateFormatTz, selectedIds = [], @@ -80,7 +96,7 @@ export const JobSelectorFlyoutContent: FC = ({ const [isLoading, setIsLoading] = useState(true); const [showAllBadges, setShowAllBadges] = useState(false); const [jobs, setJobs] = useState([]); - const [groups, setGroups] = useState>([]); + const [groups, setGroups] = useState([]); const [ganttBarWidth, setGanttBarWidth] = useState(DEFAULT_GANTT_BAR_WIDTH); diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx index d4e787344e7fe..5b3661e93e9d0 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { JobSelectorBadge } from '../job_selector_badge'; +import type { GroupWithTimerange } from '../job_selector_flyout'; interface NewSelectionIdBadgesProps { limit: number; @@ -17,7 +18,7 @@ interface NewSelectionIdBadgesProps { onDeleteClick?: Function; onLinkClick?: MouseEventHandler; showAllBadges?: boolean; - groups: Array<{ id: string; jobIds: string[] }>; + groups: GroupWithTimerange[]; } export const NewSelectionIdBadges: FC = ({ From b926fc4b99d2c0123ba3e6e2283e42f478c7028e Mon Sep 17 00:00:00 2001 From: rbrtj Date: Tue, 10 Dec 2024 15:03:12 +0100 Subject: [PATCH 18/35] anomaly explorer common state service - preserving reference while exposing observables --- .../job_selector/use_job_selection.ts | 8 +- .../explorer/anomaly_charts_state_service.ts | 4 +- .../explorer/anomaly_context_menu.tsx | 4 +- .../explorer/anomaly_explorer_common_state.ts | 102 +++++++++--------- .../application/explorer/anomaly_timeline.tsx | 12 +-- .../anomaly_timeline_state_service.ts | 20 ++-- .../public/application/explorer/explorer.tsx | 12 +-- .../routing/routes/explorer/state_manager.tsx | 2 +- 8 files changed, 80 insertions(+), 84 deletions(-) diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/use_job_selection.ts b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/use_job_selection.ts index 8d2fd779f8271..87914c25f944b 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/use_job_selection.ts +++ b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/use_job_selection.ts @@ -23,12 +23,12 @@ export const useJobSelection = (jobs: MlJobWithTimeRange[]) => { const { anomalyExplorerCommonStateService } = useAnomalyExplorerContext(); const selectedJobs = useObservable( - anomalyExplorerCommonStateService.getSelectedJobs$(), - anomalyExplorerCommonStateService.getSelectedJobs() + anomalyExplorerCommonStateService.selectedJobs$, + anomalyExplorerCommonStateService.selectedJobs ); const invalidJobIds = useObservable( - anomalyExplorerCommonStateService.getInvalidJobIds$(), - anomalyExplorerCommonStateService.getInvalidJobIds() + anomalyExplorerCommonStateService.invalidJobIds$, + anomalyExplorerCommonStateService.invalidJobIds ); const getJobSelection = useJobSelectionFlyout(); diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_charts_state_service.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_charts_state_service.ts index cb3ab83a7e554..9944dd2d6591f 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_charts_state_service.ts +++ b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_charts_state_service.ts @@ -55,8 +55,8 @@ export class AnomalyChartsStateService extends StateService { private initChartDataSubscription() { return combineLatest([ - this._anomalyExplorerCommonStateService.getSelectedJobs$(), - this._anomalyExplorerCommonStateService.getInfluencerFilterQuery$(), + this._anomalyExplorerCommonStateService.selectedJobs$, + this._anomalyExplorerCommonStateService.influencerFilterQuery$, this._anomalyTimelineStateServices.getContainerWidth$().pipe(skipWhile((v) => v === 0)), this._anomalyTimelineStateServices.getSelectedCells$(), this._anomalyTimelineStateServices.getViewBySwimlaneFieldName$(), diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_context_menu.tsx b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_context_menu.tsx index b27f8efe4fcc6..00d83d96af4b6 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_context_menu.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_context_menu.tsx @@ -104,8 +104,8 @@ export const AnomalyContextMenu: FC = ({ const { anomalyExplorerCommonStateService, chartsStateService } = useAnomalyExplorerContext(); const { queryString } = useObservable( - anomalyExplorerCommonStateService.getFilterSettings$(), - anomalyExplorerCommonStateService.getFilterSettings() + anomalyExplorerCommonStateService.filterSettings$, + anomalyExplorerCommonStateService.filterSettings ); const chartsData = useObservable( diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_explorer_common_state.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_explorer_common_state.ts index 9b578b17d206f..cacaf78ad83e7 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_explorer_common_state.ts +++ b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_explorer_common_state.ts @@ -54,10 +54,58 @@ export class AnomalyExplorerCommonStateService extends StateService { private mlJobsService: MlJobService ) { super(); - this._init(); } + public readonly selectedGroups$: Observable = this._selectedGroups$.pipe( + distinctUntilChanged(isEqual), + shareReplay(1) + ); + + public readonly invalidJobIds$: Observable = this._invalidJobIds$.pipe( + distinctUntilChanged(isEqual), + shareReplay(1) + ); + + public readonly selectedJobs$: Observable = this._selectedJobs$.pipe( + filter((v) => Array.isArray(v) && v.length > 0), + distinctUntilChanged(isEqual), + shareReplay(1) + ); + + public readonly singleMetricJobs$: Observable = this._selectedJobs$.pipe( + map((jobs) => jobs.filter((j) => j.isSingleMetricViewerJob)), + shareReplay(1) + ); + + public readonly influencerFilterQuery$: Observable = + this._filterSettings$.pipe( + map((v) => v?.influencersFilterQuery), + distinctUntilChanged(isEqual) + ); + + public readonly filterSettings$ = this._filterSettings$.asObservable(); + + public get selectedGroups(): GroupObj[] { + return this._selectedGroups$.getValue(); + } + + public get invalidJobIds(): string[] { + return this._invalidJobIds$.getValue(); + } + + public get selectedJobs(): ExplorerJob[] { + return this._selectedJobs$.getValue(); + } + + public get singleMetricJobs(): ExplorerJob[] { + return this._selectedJobs$.getValue().filter((j) => j.isSingleMetricViewerJob); + } + + public get filterSettings(): FilterSettings { + return this._filterSettings$.getValue(); + } + protected _initSubscriptions(): Subscription { const subscriptions = new Subscription(); @@ -131,22 +179,6 @@ export class AnomalyExplorerCommonStateService extends StateService { ); } - public getSelectedGroups$(): Observable { - return this._selectedGroups$.pipe(distinctUntilChanged(isEqual), shareReplay(1)); - } - - public getSelectedGroups(): GroupObj[] { - return this._selectedGroups$.getValue(); - } - - public getInvalidJobIds$(): Observable { - return this._invalidJobIds$.pipe(distinctUntilChanged(isEqual), shareReplay(1)); - } - - public getInvalidJobIds(): string[] { - return this._invalidJobIds$.getValue(); - } - public setSelectedJobs(jobIds: string[], time?: { from: string; to: string }) { this.globalUrlStateService.updateUrlState({ ml: { @@ -156,42 +188,6 @@ export class AnomalyExplorerCommonStateService extends StateService { }); } - public getSelectedJobs$(): Observable { - return this._selectedJobs$.pipe( - filter((v) => Array.isArray(v) && v.length > 0), - distinctUntilChanged(isEqual), - shareReplay(1) - ); - } - - private readonly _smvJobs$ = this.getSelectedJobs$().pipe( - map((jobs) => jobs.filter((j) => j.isSingleMetricViewerJob)), - shareReplay(1) - ); - - public getSingleMetricJobs$(): Observable { - return this._smvJobs$; - } - - public getSelectedJobs(): ExplorerJob[] { - return this._selectedJobs$.getValue(); - } - - public getInfluencerFilterQuery$(): Observable { - return this._filterSettings$.pipe( - map((v) => v?.influencersFilterQuery), - distinctUntilChanged(isEqual) - ); - } - - public getFilterSettings$(): Observable { - return this._filterSettings$.asObservable(); - } - - public getFilterSettings(): FilterSettings { - return this._filterSettings$.getValue(); - } - public setFilterSettings(update: KQLFilterSettings) { this.anomalyExplorerUrlStateService.updateUrlState({ mlExplorerFilter: { diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_timeline.tsx b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_timeline.tsx index 0d37f3afb88cb..229865a58e160 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_timeline.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_timeline.tsx @@ -121,20 +121,20 @@ export const AnomalyTimeline: FC = React.memo( const { overallAnnotations } = explorerState; const { filterActive, queryString } = useObservable( - anomalyExplorerCommonStateService.getFilterSettings$(), - anomalyExplorerCommonStateService.getFilterSettings() + anomalyExplorerCommonStateService.filterSettings$, + anomalyExplorerCommonStateService.filterSettings ); const swimlaneLimit = useObservable(anomalyTimelineStateService.getSwimLaneCardinality$()); const selectedJobs = useObservable( - anomalyExplorerCommonStateService.getSelectedJobs$(), - anomalyExplorerCommonStateService.getSelectedJobs() + anomalyExplorerCommonStateService.selectedJobs$, + anomalyExplorerCommonStateService.selectedJobs ); const selectedGroups = useObservable( - anomalyExplorerCommonStateService.getSelectedGroups$(), - anomalyExplorerCommonStateService.getSelectedGroups() + anomalyExplorerCommonStateService.selectedGroups$, + anomalyExplorerCommonStateService.selectedGroups ); const mergedGroupsAndJobsIds = useMemo( diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_timeline_state_service.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_timeline_state_service.ts index 131b08f9891f2..0da777072052f 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_timeline_state_service.ts +++ b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_timeline_state_service.ts @@ -171,7 +171,7 @@ export class AnomalyTimelineStateService extends StateService { subscription.add( combineLatest([ - this.anomalyExplorerCommonStateService.getSelectedJobs$(), + this.anomalyExplorerCommonStateService.selectedJobs$, this.getContainerWidth$(), this._timeBounds$, ]).subscribe(([selectedJobs, containerWidth]) => { @@ -192,8 +192,8 @@ export class AnomalyTimelineStateService extends StateService { map((v) => v?.viewByFieldName), distinctUntilChanged() ), - this.anomalyExplorerCommonStateService.getSelectedJobs$(), - this.anomalyExplorerCommonStateService.getFilterSettings$(), + this.anomalyExplorerCommonStateService.selectedJobs$, + this.anomalyExplorerCommonStateService.filterSettings$, this._selectedCells$, ]).subscribe(([currentlySelected, selectedJobs, filterSettings, selectedCells]) => { const { viewBySwimlaneFieldName, viewBySwimlaneOptions } = this._getViewBySwimlaneOptions( @@ -220,7 +220,7 @@ export class AnomalyTimelineStateService extends StateService { }), distinctUntilChanged(isEqual) ), - this.anomalyExplorerCommonStateService.getInfluencerFilterQuery$(), + this.anomalyExplorerCommonStateService.influencerFilterQuery$, this._timeBounds$, ]).subscribe(([pagination, influencersFilerQuery]) => { let resultPaginaiton: SwimLanePagination = pagination; @@ -233,7 +233,7 @@ export class AnomalyTimelineStateService extends StateService { private _initOverallSwimLaneData() { return combineLatest([ - this.anomalyExplorerCommonStateService.getSelectedJobs$(), + this.anomalyExplorerCommonStateService.selectedJobs$, this._swimLaneSeverity$, this.getSwimLaneBucketInterval$(), this._timeBounds$, @@ -263,8 +263,8 @@ export class AnomalyTimelineStateService extends StateService { private _initTopFieldValues() { return ( combineLatest([ - this.anomalyExplorerCommonStateService.getSelectedJobs$(), - this.anomalyExplorerCommonStateService.getInfluencerFilterQuery$(), + this.anomalyExplorerCommonStateService.selectedJobs$, + this.anomalyExplorerCommonStateService.influencerFilterQuery$, this.getViewBySwimlaneFieldName$(), this.getSwimLanePagination$(), this.getSwimLaneCardinality$(), @@ -331,8 +331,8 @@ export class AnomalyTimelineStateService extends StateService { private _initViewBySwimLaneData() { return combineLatest([ this._overallSwimLaneData$.pipe(skipWhile((v) => !v)), - this.anomalyExplorerCommonStateService.getSelectedJobs$(), - this.anomalyExplorerCommonStateService.getInfluencerFilterQuery$(), + this.anomalyExplorerCommonStateService.selectedJobs$, + this.anomalyExplorerCommonStateService.influencerFilterQuery$, this._swimLaneSeverity$, this.getSwimLaneBucketInterval$(), this.getViewBySwimlaneFieldName$(), @@ -671,7 +671,7 @@ export class AnomalyTimelineStateService extends StateService { */ public getSwimLaneJobs$(): Observable { return combineLatest([ - this.anomalyExplorerCommonStateService.getSelectedJobs$(), + this.anomalyExplorerCommonStateService.selectedJobs$, this.getViewBySwimlaneFieldName$(), this._viewBySwimLaneData$, this._selectedCells$, diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer.tsx b/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer.tsx index 9e070522db7d0..bf89ec2ba4a80 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer.tsx @@ -286,18 +286,18 @@ export const Explorer: FC = ({ const [dataViews, setDataViews] = useState(); const filterSettings = useObservable( - anomalyExplorerCommonStateService.getFilterSettings$(), - anomalyExplorerCommonStateService.getFilterSettings() + anomalyExplorerCommonStateService.filterSettings$, + anomalyExplorerCommonStateService.filterSettings ); const selectedJobs = useObservable( - anomalyExplorerCommonStateService.getSelectedJobs$(), - anomalyExplorerCommonStateService.getSelectedJobs() + anomalyExplorerCommonStateService.selectedJobs$, + anomalyExplorerCommonStateService.selectedJobs ); const selectedGroups = useObservable( - anomalyExplorerCommonStateService.getSelectedGroups$(), - anomalyExplorerCommonStateService.getSelectedGroups() + anomalyExplorerCommonStateService.selectedGroups$, + anomalyExplorerCommonStateService.selectedGroups ); const alertsData = useObservable(anomalyDetectionAlertsStateService.anomalyDetectionAlerts$, []); diff --git a/x-pack/platform/plugins/shared/ml/public/application/routing/routes/explorer/state_manager.tsx b/x-pack/platform/plugins/shared/ml/public/application/routing/routes/explorer/state_manager.tsx index cb99df78799f3..a077afae69b3b 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/routing/routes/explorer/state_manager.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/routing/routes/explorer/state_manager.tsx @@ -123,7 +123,7 @@ export const ExplorerUrlStateManager: FC = ({ ); const influencersFilterQuery = useObservable( - anomalyExplorerContext.anomalyExplorerCommonStateService.getInfluencerFilterQuery$() + anomalyExplorerContext.anomalyExplorerCommonStateService.influencerFilterQuery$ ); const loadExplorerDataConfig = useMemo( From 0debdc0e8126b8a220fc6c7776f1386aa4e975f4 Mon Sep 17 00:00:00 2001 From: rbrtj Date: Tue, 10 Dec 2024 20:33:16 +0100 Subject: [PATCH 19/35] improve job not found error message --- .../shared/ml/server/lib/ml_client/ml_client.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/x-pack/platform/plugins/shared/ml/server/lib/ml_client/ml_client.ts b/x-pack/platform/plugins/shared/ml/server/lib/ml_client/ml_client.ts index aa8bb89c47ea5..05dedf4a6ca05 100644 --- a/x-pack/platform/plugins/shared/ml/server/lib/ml_client/ml_client.ts +++ b/x-pack/platform/plugins/shared/ml/server/lib/ml_client/ml_client.ts @@ -472,7 +472,16 @@ export function getMlClient( throw error; } if (error.statusCode === 404) { - throw new MLJobNotFound(error.body.error.reason); + const failingJobMatch = error.body.error.reason.match(/No known job with id '([^']+)'/); + const failingJobIds = failingJobMatch?.[1]?.split(','); + + const errorMessage = failingJobIds?.length + ? `No known job or group with ${ + failingJobIds.length === 1 ? 'id' : 'ids' + } '${failingJobIds.join("', '")}'` + : error.body.error.reason; + + throw new MLJobNotFound(errorMessage); } throw error; } From 758468476f20766bc1f76aea79683f82ec1d11c7 Mon Sep 17 00:00:00 2001 From: rbrtj Date: Tue, 10 Dec 2024 20:42:52 +0100 Subject: [PATCH 20/35] formatJobNotFound extracted to a function --- .../ml/server/lib/ml_client/ml_client.ts | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/x-pack/platform/plugins/shared/ml/server/lib/ml_client/ml_client.ts b/x-pack/platform/plugins/shared/ml/server/lib/ml_client/ml_client.ts index 05dedf4a6ca05..bbd5b7ef7d90f 100644 --- a/x-pack/platform/plugins/shared/ml/server/lib/ml_client/ml_client.ts +++ b/x-pack/platform/plugins/shared/ml/server/lib/ml_client/ml_client.ts @@ -472,16 +472,7 @@ export function getMlClient( throw error; } if (error.statusCode === 404) { - const failingJobMatch = error.body.error.reason.match(/No known job with id '([^']+)'/); - const failingJobIds = failingJobMatch?.[1]?.split(','); - - const errorMessage = failingJobIds?.length - ? `No known job or group with ${ - failingJobIds.length === 1 ? 'id' : 'ids' - } '${failingJobIds.join("', '")}'` - : error.body.error.reason; - - throw new MLJobNotFound(errorMessage); + throw new MLJobNotFound(formatJobNotFoundError(error.body.error.reason)); } throw error; } @@ -795,3 +786,14 @@ function filterAll(ids: string[]) { // something called _all, which will subsequently fail. return ids.length === 1 && ids[0] === '_all' ? [] : ids; } + +function formatJobNotFoundError(errorReason: string) { + const failingJobMatch = errorReason.match(/No known job with id '([^']+)'/); + const failingJobIds = failingJobMatch?.[1]?.split(','); + const errorMessage = failingJobIds?.length + ? `No known job or group with ${ + failingJobIds.length === 1 ? 'id' : 'ids' + } '${failingJobIds.join("', '")}'` + : errorReason; + return errorMessage; +} From 692d97b66708b56d72fdb9fe583cb5b60e7e959b Mon Sep 17 00:00:00 2001 From: rbrtj Date: Wed, 11 Dec 2024 00:27:36 +0100 Subject: [PATCH 21/35] fix navigation from overview panel to anomaly explorer --- .../overview/components/anomaly_detection_panel/actions.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/x-pack/platform/plugins/shared/ml/public/application/overview/components/anomaly_detection_panel/actions.tsx b/x-pack/platform/plugins/shared/ml/public/application/overview/components/anomaly_detection_panel/actions.tsx index d46610a69483f..9f3c11f4bc04d 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/overview/components/anomaly_detection_panel/actions.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/overview/components/anomaly_detection_panel/actions.tsx @@ -58,8 +58,8 @@ export function useGroupActions(): Array> { const path = await locator?.getUrl({ page: ML_PAGES.ANOMALY_EXPLORER, pageState: { - jobIds: item.jobIds, timeRange: timefilter.getTime(), + jobIds: isUngrouped(item) ? item.jobIds : [item.id], }, }); await navigateToPath(path); @@ -67,3 +67,5 @@ export function useGroupActions(): Array> { }, ]; } + +const isUngrouped = (item: Group) => item.id === 'ungrouped'; From d18d929b557caa9988fd87834d8311b4c7188343 Mon Sep 17 00:00:00 2001 From: rbrtj Date: Wed, 11 Dec 2024 01:01:09 +0100 Subject: [PATCH 22/35] fix action for adding anomaly charts to a dashboard --- .../explorer/anomaly_context_menu.tsx | 21 ++++++++++++------- .../public/application/explorer/explorer.tsx | 1 + 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_context_menu.tsx b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_context_menu.tsx index 00d83d96af4b6..24aee345882c2 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_context_menu.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_context_menu.tsx @@ -50,8 +50,13 @@ import type { AnomalyChartsEmbeddableState } from '../../embeddables'; import { ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE } from '../../embeddables'; import { useMlKibana } from '../contexts/kibana'; import type { AppStateSelectedCells, ExplorerJob } from './explorer_utils'; -import { getSelectionInfluencers, getSelectionTimeRange } from './explorer_utils'; +import { + getMergedGroupsAndJobsIds, + getSelectionInfluencers, + getSelectionTimeRange, +} from './explorer_utils'; import { getDefaultExplorerChartsPanelTitle } from '../../embeddables/anomaly_charts/utils'; +import type { GroupObj } from '../components/job_selector/job_selector'; interface AnomalyContextMenuProps { selectedJobs: ExplorerJob[]; @@ -59,6 +64,7 @@ interface AnomalyContextMenuProps { bounds?: TimeRangeBounds; interval?: number; chartsCount: number; + selectedGroups: GroupObj[]; } const SavedObjectSaveModalDashboard = withSuspense(LazySavedObjectSaveModalDashboard); @@ -72,6 +78,7 @@ function getDefaultEmbeddablePanelConfig(jobIds: JobId[], queryString?: string) export const AnomalyContextMenu: FC = ({ selectedJobs, + selectedGroups, selectedCells, bounds, interval, @@ -97,6 +104,7 @@ export const AnomalyContextMenu: FC = ({ [setIsMenuOpen] ); + const mergedGroupsAndJobsIds = getMergedGroupsAndJobsIds(selectedGroups, selectedJobs); const openCasesModal = useCasesModal(ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE); const canEditDashboards = capabilities.dashboard?.createNew ?? false; @@ -137,8 +145,6 @@ export const AnomalyContextMenu: FC = ({ maxSeriesToPlot >= 1 && maxSeriesToPlot <= MAX_ANOMALY_CHARTS_ALLOWED; - const jobIds = selectedJobs.map(({ id }) => id); - const getEmbeddableInput = useCallback( (timeRange?: TimeRange) => { // Respect the query and the influencers selected @@ -151,7 +157,8 @@ export const AnomalyContextMenu: FC = ({ ); const influencers = selectionInfluencers ?? []; - const config = getDefaultEmbeddablePanelConfig(jobIds, queryString); + const config = getDefaultEmbeddablePanelConfig(mergedGroupsAndJobsIds, queryString); + const queryFromSelectedCells = influencers .map((s) => escapeKueryForEmbeddableFieldValuePair(s.fieldName, s.fieldValue)) .join(' or '); @@ -161,7 +168,7 @@ export const AnomalyContextMenu: FC = ({ return { ...config, ...(timeRange ? { timeRange } : {}), - jobIds, + jobIds: mergedGroupsAndJobsIds, maxSeriesToPlot: maxSeriesToPlot ?? DEFAULT_MAX_SERIES_TO_PLOT, severityThreshold: severity.val, ...((isDefined(queryString) && queryString !== '') || @@ -175,7 +182,7 @@ export const AnomalyContextMenu: FC = ({ : {}), }; }, - [jobIds, maxSeriesToPlot, severity, queryString, selectedCells] + [selectedCells, mergedGroupsAndJobsIds, queryString, maxSeriesToPlot, severity.val] ); const onSaveCallback: SaveModalDashboardProps['onSave'] = useCallback( @@ -350,7 +357,7 @@ export const AnomalyContextMenu: FC = ({ defaultMessage: 'Anomaly charts', })} documentInfo={{ - title: getDefaultExplorerChartsPanelTitle(selectedJobs.map(({ id }) => id)), + title: getDefaultExplorerChartsPanelTitle(mergedGroupsAndJobsIds), }} onClose={setIsAddDashboardActive.bind(null, false)} onSave={onSaveCallback} diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer.tsx b/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer.tsx index bf89ec2ba4a80..e3f057a18caf0 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer.tsx @@ -582,6 +582,7 @@ export const Explorer: FC = ({ Date: Wed, 11 Dec 2024 10:47:26 +0100 Subject: [PATCH 23/35] remove unused translations --- .../plugins/private/translations/translations/fr-FR.json | 1 - .../plugins/private/translations/translations/ja-JP.json | 1 - 2 files changed, 2 deletions(-) diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index 3104dfdf01b3a..13ec7adffc401 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -29652,7 +29652,6 @@ "xpack.ml.explorer.invalidKuerySyntaxErrorMessageQueryBar": "Requête non valide", "xpack.ml.explorer.jobIdLabel": "ID tâche", "xpack.ml.explorer.jobScoreAcrossAllInfluencersLabel": "(score de tâche pour tous les influenceurs)", - "xpack.ml.explorer.kueryBar.filterPlaceholder": "Filtrer par champ d'influenceur… ({queryExample})", "xpack.ml.explorer.mapTitle": "Nombre d'anomalies par emplacement {infoTooltip}", "xpack.ml.explorer.noAnomaliesFoundLabel": "Aucune anomalie n'a été trouvée", "xpack.ml.explorer.noConfiguredInfluencersTooltip": "La liste Principaux influenceurs est masquée, car aucun influenceur n'a été configuré pour les tâches sélectionnées.", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index 8f53c77e4a865..186ead32521bd 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -29513,7 +29513,6 @@ "xpack.ml.explorer.invalidKuerySyntaxErrorMessageQueryBar": "無効なクエリー", "xpack.ml.explorer.jobIdLabel": "ジョブID", "xpack.ml.explorer.jobScoreAcrossAllInfluencersLabel": "(すべての影響因子のジョブスコア)", - "xpack.ml.explorer.kueryBar.filterPlaceholder": "影響因子フィールドでフィルタリング…({queryExample})", "xpack.ml.explorer.mapTitle": "場所別異常件数{infoTooltip}", "xpack.ml.explorer.noAnomaliesFoundLabel": "異常値が見つかりませんでした", "xpack.ml.explorer.noConfiguredInfluencersTooltip": "選択されたジョブに影響因子が構成されていないため、トップインフルエンスリストは非表示になっています。", From 22a85e0de0623b4c3bb4a1950d7718a3ed4d8d21 Mon Sep 17 00:00:00 2001 From: rbrtj Date: Thu, 12 Dec 2024 13:19:11 +0100 Subject: [PATCH 24/35] move id badges test to tsx && fix tests --- .../{id_badges.test.js => id_badges.test.tsx} | 37 +++++++++++-------- .../job_selector/id_badges/id_badges.tsx | 2 +- 2 files changed, 22 insertions(+), 17 deletions(-) rename x-pack/platform/plugins/shared/ml/public/application/components/job_selector/id_badges/{id_badges.test.js => id_badges.test.tsx} (78%) diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/id_badges/id_badges.test.js b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/id_badges/id_badges.test.tsx similarity index 78% rename from x-pack/platform/plugins/shared/ml/public/application/components/job_selector/id_badges/id_badges.test.js rename to x-pack/platform/plugins/shared/ml/public/application/components/job_selector/id_badges/id_badges.test.tsx index cd99398c578a3..424c3d8231863 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/id_badges/id_badges.test.js +++ b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/id_badges/id_badges.test.tsx @@ -6,29 +6,28 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; // eslint-disable-line import/no-extraneous-dependencies +import { render } from '@testing-library/react'; +import type { IdBadgesProps } from './id_badges'; import { IdBadges } from './id_badges'; -const props = { +const props: IdBadgesProps = { limit: 2, - maps: { - groupsMap: { - group1: ['job1', 'job2'], - group2: ['job3'], + selectedGroups: [ + { + groupId: 'group1', + jobIds: ['job1', 'job2'], }, - jobsMap: { - job1: ['group1'], - job2: ['group1'], - job3: ['group2'], - job4: [], + { + groupId: 'group2', + jobIds: ['job3'], }, - }, + ], + selectedJobIds: ['job1', 'job2', 'job3'], onLinkClick: jest.fn(), - selectedIds: ['group1', 'job1', 'job3'], showAllBarBadges: false, }; -const overLimitProps = { ...props, selectedIds: ['group1', 'job1', 'job3', 'job4'] }; +const overLimitProps: IdBadgesProps = { ...props, selectedJobIds: ['job4'] }; describe('IdBadges', () => { test('When group selected renders groupId and not corresponding jobIds', () => { @@ -56,10 +55,16 @@ describe('IdBadges', () => { }); describe('showAllBarBadges is true', () => { - const overLimitShowAllProps = { + const overLimitShowAllProps: IdBadgesProps = { ...props, showAllBarBadges: true, - selectedIds: ['group1', 'job1', 'job3', 'job4'], + selectedGroups: [ + { + groupId: 'group1', + jobIds: ['job1', 'job2'], + }, + ], + selectedJobIds: ['job3', 'job4'], }; test('shows all badges when selection is over limit', () => { diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/id_badges/id_badges.tsx b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/id_badges/id_badges.tsx index 8e57e057fb9c5..b03ece5aaac55 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/id_badges/id_badges.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/id_badges/id_badges.tsx @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { JobSelectorBadge } from '../job_selector_badge'; import type { GroupObj } from '../job_selector'; -interface IdBadgesProps { +export interface IdBadgesProps { limit: number; selectedGroups: GroupObj[]; selectedJobIds: string[]; From a4c8b496b67f2aa080afd5e8510155927b4cfbee Mon Sep 17 00:00:00 2001 From: rbrtj Date: Thu, 12 Dec 2024 13:25:20 +0100 Subject: [PATCH 25/35] move new selection id badges test to tsx && fix tests --- ...st.js => new_selection_id_badges.test.tsx} | 35 +++++++++++++++---- .../new_selection_id_badges.tsx | 2 +- 2 files changed, 29 insertions(+), 8 deletions(-) rename x-pack/platform/plugins/shared/ml/public/application/components/job_selector/new_selection_id_badges/{new_selection_id_badges.test.js => new_selection_id_badges.test.tsx} (77%) diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.test.js b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.test.tsx similarity index 77% rename from x-pack/platform/plugins/shared/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.test.js rename to x-pack/platform/plugins/shared/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.test.tsx index 4ff3e992c6e19..65f292f9b0dd5 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.test.js +++ b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.test.tsx @@ -6,17 +6,38 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; // eslint-disable-line import/no-extraneous-dependencies +import { render } from '@testing-library/react'; +import type { NewSelectionIdBadgesProps } from './new_selection_id_badges'; import { NewSelectionIdBadges } from './new_selection_id_badges'; -const props = { +const props: NewSelectionIdBadgesProps = { limit: 2, - maps: { - groupsMap: { - group1: ['job1', 'job2'], - group2: ['job3'], + groups: [ + { + id: 'group1', + jobIds: ['job1', 'job2'], + timeRange: { + from: 0, + to: 0, + fromPx: 0, + toPx: 0, + fromMoment: null, + toMoment: null, + }, }, - }, + { + id: 'group2', + jobIds: ['job3', 'job4'], + timeRange: { + from: 0, + to: 0, + fromPx: 0, + toPx: 0, + fromMoment: null, + toMoment: null, + }, + }, + ], onLinkClick: jest.fn(), onDeleteClick: jest.fn(), newSelection: ['group1', 'job1', 'job3'], diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx index 5b3661e93e9d0..c9599297e276c 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx @@ -12,7 +12,7 @@ import { i18n } from '@kbn/i18n'; import { JobSelectorBadge } from '../job_selector_badge'; import type { GroupWithTimerange } from '../job_selector_flyout'; -interface NewSelectionIdBadgesProps { +export interface NewSelectionIdBadgesProps { limit: number; newSelection: string[]; onDeleteClick?: Function; From 844adfaaa27cd79b32835b3131ff8de90f9292ac Mon Sep 17 00:00:00 2001 From: rbrtj Date: Thu, 12 Dec 2024 14:28:11 +0100 Subject: [PATCH 26/35] timeseries explorer selected jobs fix --- .../routes/timeseriesexplorer/state_manager.tsx | 12 ++++++++++-- .../timeseriesexplorer/timeseriesexplorer.d.ts | 2 -- .../timeseriesexplorer/timeseriesexplorer_page.tsx | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/x-pack/platform/plugins/shared/ml/public/application/routing/routes/timeseriesexplorer/state_manager.tsx b/x-pack/platform/plugins/shared/ml/public/application/routing/routes/timeseriesexplorer/state_manager.tsx index fbad84fd19e27..8975f65e5d970 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/routing/routes/timeseriesexplorer/state_manager.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/routing/routes/timeseriesexplorer/state_manager.tsx @@ -287,7 +287,11 @@ export const TimeSeriesExplorerUrlStateManager: FC + ); @@ -295,7 +299,11 @@ export const TimeSeriesExplorerUrlStateManager: FC + ); diff --git a/x-pack/platform/plugins/shared/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts b/x-pack/platform/plugins/shared/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts index f2566c8d056b2..bcba172b9523b 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts +++ b/x-pack/platform/plugins/shared/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts @@ -25,11 +25,9 @@ interface TimeSeriesExplorerProps { zoom?: { from?: string; to?: string }; handleJobSelectionChange: ({ jobIds, - groups, time, }: { jobIds: string[]; - groups: string[]; time?: { from: string; to: string }; }) => void; } diff --git a/x-pack/platform/plugins/shared/ml/public/application/timeseriesexplorer/timeseriesexplorer_page.tsx b/x-pack/platform/plugins/shared/ml/public/application/timeseriesexplorer/timeseriesexplorer_page.tsx index fdf6ffb660725..c998dda0bcd91 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/timeseriesexplorer/timeseriesexplorer_page.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/timeseriesexplorer/timeseriesexplorer_page.tsx @@ -25,7 +25,7 @@ interface TimeSeriesExplorerPageProps { dateFormatTz?: string; resizeRef?: any; noSingleMetricJobsFound?: boolean; - handleJobSelectionChange?: ({ + handleJobSelectionChange: ({ jobIds, time, }: { From b687d6f904e12f2ab58f298c925b2ee22e705f7e Mon Sep 17 00:00:00 2001 From: rbrtj Date: Thu, 12 Dec 2024 14:47:58 +0100 Subject: [PATCH 27/35] basic unit tests for usePageUrlState and useGlobalUrlState hooks --- .../ml/url_state/src/url_state.test.tsx | 94 ++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/x-pack/platform/packages/private/ml/url_state/src/url_state.test.tsx b/x-pack/platform/packages/private/ml/url_state/src/url_state.test.tsx index 033ecd77fadf4..4d77d408a0afc 100644 --- a/x-pack/platform/packages/private/ml/url_state/src/url_state.test.tsx +++ b/x-pack/platform/packages/private/ml/url_state/src/url_state.test.tsx @@ -9,7 +9,13 @@ import React, { useEffect, type FC } from 'react'; import { render, act } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; -import { parseUrlState, useUrlState, UrlStateProvider } from './url_state'; +import { + parseUrlState, + useUrlState, + UrlStateProvider, + usePageUrlState, + useGlobalUrlState, +} from './url_state'; const mockHistoryInitialState = "?_a=(mlExplorerFilter:(),mlExplorerSwimlane:(viewByFieldName:action),query:(query_string:(analyze_wildcard:!t,query:'*')))&_g=(ml:(jobIds:!(dec-2)),refreshInterval:(display:Off,pause:!f,value:0),time:(from:'2019-01-01T00:03:40.000Z',mode:absolute,to:'2019-08-30T11:55:07.000Z'))&savedSearchId=571aaf70-4c88-11e8-b3d7-01146121b73d"; @@ -143,3 +149,89 @@ describe('useUrlState', () => { expect(getByTestId('appState').innerHTML).toBe('the updated query'); }); }); + +describe('usePageUrlState', () => { + it('manages page-specific state with default values', () => { + const TestComponent: FC = () => { + const [pageState, setPageState] = usePageUrlState<{ + pageKey: 'testPage'; + pageUrlState: { + defaultValue: string; + }; + }>('testPage', { + defaultValue: 'initial', + }); + + return ( + <> + +
{pageState?.defaultValue}
+ + ); + }; + + const { getByText, getByTestId } = render( + + + + + + ); + + expect(getByTestId('pageState').innerHTML).toBe('initial'); + + act(() => { + getByText('Update').click(); + }); + + expect(getByTestId('pageState').innerHTML).toBe('updated'); + }); +}); + +describe('useGlobalUrlState', () => { + it('manages global state with ML and time properties', () => { + const defaultState = { + ml: { jobIds: ['initial-job'] }, + time: { from: 'now-15m', to: 'now' }, + }; + + const TestComponent: FC = () => { + const [globalState, setGlobalState] = useGlobalUrlState(defaultState); + + return ( + <> + +
{JSON.stringify(globalState)}
+ + ); + }; + + const { getByText, getByTestId } = render( + + + + + + ); + + expect(JSON.parse(getByTestId('globalState').innerHTML)).toEqual(defaultState); + + act(() => { + getByText('Update').click(); + }); + + expect(JSON.parse(getByTestId('globalState').innerHTML)).toEqual({ + ml: { jobIds: ['updated-job'] }, + time: { from: 'now-1h', to: 'now' }, + }); + }); +}); From 7894672f05315003169dd0837e1d55d189ccb658 Mon Sep 17 00:00:00 2001 From: rbrtj Date: Thu, 12 Dec 2024 14:58:01 +0100 Subject: [PATCH 28/35] rename GroupWithTimerange for consistency && share TimeRange type --- .../anomaly_detection_jobs/summary_job.ts | 22 ++++++++++--------- .../job_selector/job_select_service_utils.ts | 4 ++-- .../job_selector/job_selector_flyout.tsx | 21 ++++++------------ .../new_selection_id_badges.tsx | 4 ++-- 4 files changed, 23 insertions(+), 28 deletions(-) diff --git a/x-pack/platform/plugins/shared/ml/common/types/anomaly_detection_jobs/summary_job.ts b/x-pack/platform/plugins/shared/ml/common/types/anomaly_detection_jobs/summary_job.ts index 4dace0284e011..1609c473dffc1 100644 --- a/x-pack/platform/plugins/shared/ml/common/types/anomaly_detection_jobs/summary_job.ts +++ b/x-pack/platform/plugins/shared/ml/common/types/anomaly_detection_jobs/summary_job.ts @@ -169,18 +169,20 @@ export interface AuditMessage { export type MlSummaryJobs = MlSummaryJob[]; +export interface MlJobTimeRange { + from: number; + to: number; + fromPx: number; + toPx: number; + fromMoment: Moment | null; + toMoment: Moment | null; + widthPx: number | null; + label?: string; +} + export interface MlJobWithTimeRange extends CombinedJobWithStats { id: string; isRunning?: boolean; isNotSingleMetricViewerJobMessage?: string; - timeRange: { - from: number; - to: number; - fromPx: number; - toPx: number; - fromMoment: Moment; - toMoment: Moment; - widthPx: number; - label: string; - }; + timeRange: MlJobTimeRange; } diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/job_select_service_utils.ts b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/job_select_service_utils.ts index c114ba4c636a4..47507a1e760a4 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/job_select_service_utils.ts +++ b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/job_select_service_utils.ts @@ -11,10 +11,10 @@ import d3 from 'd3'; import type { Dictionary } from '../../../../common/types/common'; import type { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; -import type { GroupWithTimerange } from './job_selector_flyout'; +import type { MlJobGroupWithTimeRange } from './job_selector_flyout'; export function getGroupsFromJobs(jobs: MlJobWithTimeRange[]) { - const groups: Dictionary = {}; + const groups: Dictionary = {}; const groupsMap: Dictionary = {}; jobs.forEach((job) => { diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/job_selector_flyout.tsx b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/job_selector_flyout.tsx index c64642f9a50dc..12b66b072f265 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/job_selector_flyout.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/job_selector_flyout.tsx @@ -21,7 +21,6 @@ import { EuiResizeObserver, EuiProgress, } from '@elastic/eui'; -import type { Moment } from 'moment'; import { NewSelectionIdBadges } from './new_selection_id_badges'; // @ts-ignore import { JobSelectorTable } from './job_selector_table'; @@ -30,7 +29,10 @@ import { getTimeRangeFromSelection, normalizeTimes, } from './job_select_service_utils'; -import type { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; +import type { + MlJobTimeRange, + MlJobWithTimeRange, +} from '../../../../common/types/anomaly_detection_jobs'; import { useMlKibana } from '../../contexts/kibana'; import type { JobSelectionMaps } from './job_selector'; @@ -57,19 +59,10 @@ export interface JobSelectorFlyoutProps { onTimeRangeConfigChange?: (v: boolean) => void; } -export interface GroupWithTimerange { +export interface MlJobGroupWithTimeRange { id: string; jobIds: string[]; - timeRange: { - from: number; - to: number; - fromPx: number; - toPx: number; - fromMoment: Moment | null; - toMoment: Moment | null; - widthPx?: number | null; - label?: string; - }; + timeRange: MlJobTimeRange; } export const JobSelectorFlyoutContent: FC = ({ @@ -96,7 +89,7 @@ export const JobSelectorFlyoutContent: FC = ({ const [isLoading, setIsLoading] = useState(true); const [showAllBadges, setShowAllBadges] = useState(false); const [jobs, setJobs] = useState([]); - const [groups, setGroups] = useState([]); + const [groups, setGroups] = useState([]); const [ganttBarWidth, setGanttBarWidth] = useState(DEFAULT_GANTT_BAR_WIDTH); diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx index c9599297e276c..06c53f427e39a 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { JobSelectorBadge } from '../job_selector_badge'; -import type { GroupWithTimerange } from '../job_selector_flyout'; +import type { MlJobGroupWithTimeRange } from '../job_selector_flyout'; export interface NewSelectionIdBadgesProps { limit: number; @@ -18,7 +18,7 @@ export interface NewSelectionIdBadgesProps { onDeleteClick?: Function; onLinkClick?: MouseEventHandler; showAllBadges?: boolean; - groups: GroupWithTimerange[]; + groups: MlJobGroupWithTimeRange[]; } export const NewSelectionIdBadges: FC = ({ From f42b979682b46482bf061d22de7a4f1279660960 Mon Sep 17 00:00:00 2001 From: rbrtj Date: Thu, 12 Dec 2024 15:13:14 +0100 Subject: [PATCH 29/35] use set to remove duplicates from jobsInSelectedGroups list --- .../components/job_selector/job_selector_flyout.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/job_selector_flyout.tsx b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/job_selector_flyout.tsx index 12b66b072f265..4684ef1e63b43 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/job_selector_flyout.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/job_selector_flyout.tsx @@ -98,9 +98,13 @@ export const JobSelectorFlyoutContent: FC = ({ const applySelection = useCallback(() => { const selectedGroupIds = newSelection.filter((id) => groups.some((group) => group.id === id)); - const jobsInSelectedGroups = groups - .filter((group) => selectedGroupIds.includes(group.id)) - .flatMap((group) => group.jobIds); + const jobsInSelectedGroups = [ + ...new Set( + groups + .filter((group) => selectedGroupIds.includes(group.id)) + .flatMap((group) => group.jobIds) + ), + ]; const standaloneJobs = newSelection.filter( (id) => !selectedGroupIds.includes(id) && !jobsInSelectedGroups.includes(id) From f725b4daac45d744485f1015f2084310f03a2dc8 Mon Sep 17 00:00:00 2001 From: rbrtj Date: Thu, 12 Dec 2024 15:20:10 +0100 Subject: [PATCH 30/35] remove unused smv jobs from anomaly explorer common state --- .../explorer/anomaly_explorer_common_state.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_explorer_common_state.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_explorer_common_state.ts index cacaf78ad83e7..ebd3c9f4ded6d 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_explorer_common_state.ts +++ b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_explorer_common_state.ts @@ -73,11 +73,6 @@ export class AnomalyExplorerCommonStateService extends StateService { shareReplay(1) ); - public readonly singleMetricJobs$: Observable = this._selectedJobs$.pipe( - map((jobs) => jobs.filter((j) => j.isSingleMetricViewerJob)), - shareReplay(1) - ); - public readonly influencerFilterQuery$: Observable = this._filterSettings$.pipe( map((v) => v?.influencersFilterQuery), @@ -98,10 +93,6 @@ export class AnomalyExplorerCommonStateService extends StateService { return this._selectedJobs$.getValue(); } - public get singleMetricJobs(): ExplorerJob[] { - return this._selectedJobs$.getValue().filter((j) => j.isSingleMetricViewerJob); - } - public get filterSettings(): FilterSettings { return this._filterSettings$.getValue(); } From e2e5c006dff8c443062996b3b069b03913f75ec0 Mon Sep 17 00:00:00 2001 From: rbrtj Date: Thu, 12 Dec 2024 15:22:40 +0100 Subject: [PATCH 31/35] remove unused explorer constants --- .../application/explorer/explorer_constants.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer_constants.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer_constants.ts index ee019928b9bda..0e891e9ac5cca 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer_constants.ts +++ b/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer_constants.ts @@ -11,20 +11,6 @@ import { i18n } from '@kbn/i18n'; -export const DRAG_SELECT_ACTION = { - NEW_SELECTION: 'newSelection', - ELEMENT_SELECT: 'elementSelect', - DRAG_START: 'dragStart', -} as const; - -export const EXPLORER_ACTION = { - CLEAR_EXPLORER_DATA: 'clearExplorerData', - CLEAR_INFLUENCER_FILTER_SETTINGS: 'clearInfluencerFilterSettings', - CLEAR_JOBS: 'clearJobs', - JOB_SELECTION_CHANGE: 'jobSelectionChange', - SET_EXPLORER_DATA: 'setExplorerData', -} as const; - export const FILTER_ACTION = { ADD: '+', REMOVE: '-', From 74778d6bab25326880dfc07e696644921cc4dc34 Mon Sep 17 00:00:00 2001 From: rbrtj Date: Thu, 12 Dec 2024 15:36:56 +0100 Subject: [PATCH 32/35] unit tests for getIndexPattern and getMergedGroupsAndJobsIds --- .../explorer/explorer_utils.test.ts | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 x-pack/platform/plugins/shared/ml/public/application/explorer/explorer_utils.test.ts diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer_utils.test.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer_utils.test.ts new file mode 100644 index 0000000000000..f784b5c16412c --- /dev/null +++ b/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer_utils.test.ts @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ML_RESULTS_INDEX_PATTERN } from '../../../common/constants/index_patterns'; +import type { GroupObj } from '../components/job_selector/job_selector'; +import type { ExplorerJob } from './explorer_utils'; +import { getIndexPattern, getMergedGroupsAndJobsIds } from './explorer_utils'; + +describe('getIndexPattern', () => { + it('should create correct index pattern format from a list of Explorer jobs', () => { + const mockExplorerJobs: ExplorerJob[] = [ + { + id: 'job-1', + selected: true, + bucketSpanSeconds: 3600, + modelPlotEnabled: false, + }, + { + id: 'job-2', + selected: false, + bucketSpanSeconds: 7200, + modelPlotEnabled: true, + sourceIndices: ['index-1'], + groups: ['group-1'], + }, + ]; + + const result = getIndexPattern(mockExplorerJobs); + + expect(result).toEqual({ + title: ML_RESULTS_INDEX_PATTERN, + fields: [ + { + name: 'job-1', + type: 'string', + aggregatable: true, + searchable: true, + }, + { + name: 'job-2', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + }); + }); + + it('should handle empty jobs array', () => { + const result = getIndexPattern([]); + + expect(result).toEqual({ + title: ML_RESULTS_INDEX_PATTERN, + fields: [], + }); + }); +}); + +describe('getMergedGroupsAndJobsIds', () => { + it('should merge group ids and standalone job ids correctly', () => { + const mockGroups: GroupObj[] = [ + { + groupId: 'group-1', + jobIds: ['job-1', 'job-2'], + }, + { + groupId: 'group-2', + jobIds: ['job-3', 'job-4'], + }, + ]; + + const mockSelectedJobs: ExplorerJob[] = [ + { + id: 'job-1', // part of group-1 + selected: true, + bucketSpanSeconds: 3600, + modelPlotEnabled: false, + }, + { + id: 'job-5', // standalone job + selected: true, + bucketSpanSeconds: 3600, + modelPlotEnabled: false, + }, + { + id: 'job-6', // standalone job + selected: true, + bucketSpanSeconds: 3600, + modelPlotEnabled: false, + }, + ]; + + const result = getMergedGroupsAndJobsIds(mockGroups, mockSelectedJobs); + + expect(result).toEqual(['group-1', 'group-2', 'job-5', 'job-6']); + }); + + it('should handle empty groups and jobs', () => { + const result = getMergedGroupsAndJobsIds([], []); + + expect(result).toEqual([]); + }); + + it('should handle overlapping jobs between groups', () => { + const mockGroups: GroupObj[] = [ + { + groupId: 'group-1', + jobIds: ['job-1', 'job-2'], + }, + { + groupId: 'group-2', + jobIds: ['job-2', 'job-3'], // job-2 is in both groups + }, + ]; + + const mockSelectedJobs: ExplorerJob[] = [ + { + id: 'job-4', + selected: true, + bucketSpanSeconds: 3600, + modelPlotEnabled: false, + }, + ]; + + const result = getMergedGroupsAndJobsIds(mockGroups, mockSelectedJobs); + + expect(result).toEqual(['group-1', 'group-2', 'job-4']); + }); + + it('should handle groups with no jobs', () => { + const mockGroups: GroupObj[] = [ + { + groupId: 'group-1', + jobIds: [], + }, + { + groupId: 'group-2', + jobIds: ['job-1'], + }, + ]; + + const mockSelectedJobs: ExplorerJob[] = [ + { + id: 'job-2', + selected: true, + bucketSpanSeconds: 3600, + modelPlotEnabled: false, + }, + ]; + + const result = getMergedGroupsAndJobsIds(mockGroups, mockSelectedJobs); + + expect(result).toEqual(['group-1', 'group-2', 'job-2']); + }); +}); From ab69786db39828c33b44eea0b6259058d393189f Mon Sep 17 00:00:00 2001 From: rbrtj Date: Thu, 12 Dec 2024 16:27:23 +0100 Subject: [PATCH 33/35] new selection id badges tests type fix --- .../new_selection_id_badges/new_selection_id_badges.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.test.tsx b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.test.tsx index 65f292f9b0dd5..101c6f53d33fd 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.test.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.test.tsx @@ -23,6 +23,7 @@ const props: NewSelectionIdBadgesProps = { toPx: 0, fromMoment: null, toMoment: null, + widthPx: 0, }, }, { @@ -35,6 +36,7 @@ const props: NewSelectionIdBadgesProps = { toPx: 0, fromMoment: null, toMoment: null, + widthPx: 0, }, }, ], From 7df5acb257026f016ab3f8744ed6e4b0173b8278 Mon Sep 17 00:00:00 2001 From: rbrtj Date: Mon, 16 Dec 2024 09:45:24 +0100 Subject: [PATCH 34/35] url state hooks test improvements --- .../ml/url_state/src/url_state.test.tsx | 81 ++++++------------- 1 file changed, 24 insertions(+), 57 deletions(-) diff --git a/x-pack/platform/packages/private/ml/url_state/src/url_state.test.tsx b/x-pack/platform/packages/private/ml/url_state/src/url_state.test.tsx index 4d77d408a0afc..ab7726d99f238 100644 --- a/x-pack/platform/packages/private/ml/url_state/src/url_state.test.tsx +++ b/x-pack/platform/packages/private/ml/url_state/src/url_state.test.tsx @@ -6,7 +6,7 @@ */ import React, { useEffect, type FC } from 'react'; -import { render, act } from '@testing-library/react'; +import { render, act, renderHook } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { @@ -20,6 +20,12 @@ import { const mockHistoryInitialState = "?_a=(mlExplorerFilter:(),mlExplorerSwimlane:(viewByFieldName:action),query:(query_string:(analyze_wildcard:!t,query:'*')))&_g=(ml:(jobIds:!(dec-2)),refreshInterval:(display:Off,pause:!f,value:0),time:(from:'2019-01-01T00:03:40.000Z',mode:absolute,to:'2019-08-30T11:55:07.000Z'))&savedSearchId=571aaf70-4c88-11e8-b3d7-01146121b73d"; +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + describe('getUrlState', () => { test('properly decode url with _g and _a', () => { expect(parseUrlState(mockHistoryInitialState)).toEqual({ @@ -152,39 +158,24 @@ describe('useUrlState', () => { describe('usePageUrlState', () => { it('manages page-specific state with default values', () => { - const TestComponent: FC = () => { - const [pageState, setPageState] = usePageUrlState<{ - pageKey: 'testPage'; - pageUrlState: { - defaultValue: string; - }; - }>('testPage', { - defaultValue: 'initial', - }); + const pageKey = 'testPage'; + const defaultPageState = { + defaultValue: 'initial', + }; - return ( - <> - -
{pageState?.defaultValue}
- - ); + const updatedPageState = { + defaultValue: 'updated', }; - const { getByText, getByTestId } = render( - - - - - - ); + const { result } = renderHook(() => usePageUrlState(pageKey, defaultPageState), { wrapper }); - expect(getByTestId('pageState').innerHTML).toBe('initial'); + expect(result.current[0]).toEqual(defaultPageState); act(() => { - getByText('Update').click(); + result.current[1](updatedPageState); }); - expect(getByTestId('pageState').innerHTML).toBe('updated'); + expect(result.current[0]).toEqual(updatedPageState); }); }); @@ -195,43 +186,19 @@ describe('useGlobalUrlState', () => { time: { from: 'now-15m', to: 'now' }, }; - const TestComponent: FC = () => { - const [globalState, setGlobalState] = useGlobalUrlState(defaultState); - - return ( - <> - -
{JSON.stringify(globalState)}
- - ); + const updatedState = { + ml: { jobIds: ['updated-job'] }, + time: { from: 'now-1h', to: 'now' }, }; - const { getByText, getByTestId } = render( - - - - - - ); + const { result } = renderHook(() => useGlobalUrlState(defaultState), { wrapper }); - expect(JSON.parse(getByTestId('globalState').innerHTML)).toEqual(defaultState); + expect(result.current[0]).toEqual(defaultState); act(() => { - getByText('Update').click(); + result.current[1](updatedState); }); - expect(JSON.parse(getByTestId('globalState').innerHTML)).toEqual({ - ml: { jobIds: ['updated-job'] }, - time: { from: 'now-1h', to: 'now' }, - }); + expect(result.current[0]).toEqual(updatedState); }); }); From e854c74bcd76887d9b3840ee2c560cb1c987866e Mon Sep 17 00:00:00 2001 From: rbrtj Date: Mon, 16 Dec 2024 10:08:44 +0100 Subject: [PATCH 35/35] use job selection hook --- .../explorer/anomaly_context_menu.tsx | 12 ++----- .../application/explorer/anomaly_timeline.tsx | 17 ++------- .../public/application/explorer/explorer.tsx | 13 ++----- .../explorer/hooks/use_job_selection.ts | 36 +++++++++++++++++++ 4 files changed, 44 insertions(+), 34 deletions(-) create mode 100644 x-pack/platform/plugins/shared/ml/public/application/explorer/hooks/use_job_selection.ts diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_context_menu.tsx b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_context_menu.tsx index 24aee345882c2..7904a55264d08 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_context_menu.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_context_menu.tsx @@ -50,13 +50,8 @@ import type { AnomalyChartsEmbeddableState } from '../../embeddables'; import { ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE } from '../../embeddables'; import { useMlKibana } from '../contexts/kibana'; import type { AppStateSelectedCells, ExplorerJob } from './explorer_utils'; -import { - getMergedGroupsAndJobsIds, - getSelectionInfluencers, - getSelectionTimeRange, -} from './explorer_utils'; +import { getSelectionInfluencers, getSelectionTimeRange } from './explorer_utils'; import { getDefaultExplorerChartsPanelTitle } from '../../embeddables/anomaly_charts/utils'; -import type { GroupObj } from '../components/job_selector/job_selector'; interface AnomalyContextMenuProps { selectedJobs: ExplorerJob[]; @@ -64,7 +59,7 @@ interface AnomalyContextMenuProps { bounds?: TimeRangeBounds; interval?: number; chartsCount: number; - selectedGroups: GroupObj[]; + mergedGroupsAndJobsIds: string[]; } const SavedObjectSaveModalDashboard = withSuspense(LazySavedObjectSaveModalDashboard); @@ -78,11 +73,11 @@ function getDefaultEmbeddablePanelConfig(jobIds: JobId[], queryString?: string) export const AnomalyContextMenu: FC = ({ selectedJobs, - selectedGroups, selectedCells, bounds, interval, chartsCount, + mergedGroupsAndJobsIds, }) => { const { services: { @@ -104,7 +99,6 @@ export const AnomalyContextMenu: FC = ({ [setIsMenuOpen] ); - const mergedGroupsAndJobsIds = getMergedGroupsAndJobsIds(selectedGroups, selectedJobs); const openCasesModal = useCasesModal(ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE); const canEditDashboards = capabilities.dashboard?.createNew ?? false; diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_timeline.tsx b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_timeline.tsx index 229865a58e160..cad2ef9376890 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_timeline.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_timeline.tsx @@ -52,7 +52,6 @@ import { useMlKibana } from '../contexts/kibana'; import { ExplorerNoInfluencersFound } from './components/explorer_no_influencers_found'; import { SwimlaneContainer } from './swimlane_container'; import { - getMergedGroupsAndJobsIds, type AppStateSelectedCells, type OverallSwimlaneData, type ViewBySwimLaneData, @@ -68,6 +67,7 @@ import { getTimeBoundsFromSelection } from './hooks/use_selected_cells'; import { SwimLaneWrapper } from './alerts'; import { Y_AXIS_LABEL_WIDTH } from './constants'; import type { ExplorerState } from './explorer_data'; +import { useJobSelection } from './hooks/use_job_selection'; function mapSwimlaneOptionsToEuiOptions(options: string[]) { return options.map((option) => ({ @@ -127,20 +127,7 @@ export const AnomalyTimeline: FC = React.memo( const swimlaneLimit = useObservable(anomalyTimelineStateService.getSwimLaneCardinality$()); - const selectedJobs = useObservable( - anomalyExplorerCommonStateService.selectedJobs$, - anomalyExplorerCommonStateService.selectedJobs - ); - - const selectedGroups = useObservable( - anomalyExplorerCommonStateService.selectedGroups$, - anomalyExplorerCommonStateService.selectedGroups - ); - - const mergedGroupsAndJobsIds = useMemo( - () => getMergedGroupsAndJobsIds(selectedGroups, selectedJobs), - [selectedGroups, selectedJobs] - ); + const { selectedJobs, mergedGroupsAndJobsIds } = useJobSelection(); const loading = useObservable(anomalyTimelineStateService.isOverallSwimLaneLoading$(), true); diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer.tsx b/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer.tsx index e3f057a18caf0..0ffb38f3631d1 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer.tsx @@ -78,6 +78,7 @@ import { ML_ANOMALY_EXPLORER_PANELS } from '../../../common/types/storage'; import { AlertsPanel } from './alerts'; import { useMlIndexUtils } from '../util/index_service'; import type { ExplorerState } from './explorer_data'; +import { useJobSelection } from './hooks/use_job_selection'; const AnnotationFlyout = dynamic(async () => ({ default: (await import('../components/annotations/annotation_flyout')).AnnotationFlyout, @@ -290,15 +291,7 @@ export const Explorer: FC = ({ anomalyExplorerCommonStateService.filterSettings ); - const selectedJobs = useObservable( - anomalyExplorerCommonStateService.selectedJobs$, - anomalyExplorerCommonStateService.selectedJobs - ); - - const selectedGroups = useObservable( - anomalyExplorerCommonStateService.selectedGroups$, - anomalyExplorerCommonStateService.selectedGroups - ); + const { selectedJobs, selectedGroups, mergedGroupsAndJobsIds } = useJobSelection(); const alertsData = useObservable(anomalyDetectionAlertsStateService.anomalyDetectionAlerts$, []); @@ -582,7 +575,7 @@ export const Explorer: FC = ({ { + const { anomalyExplorerCommonStateService } = useAnomalyExplorerContext(); + + const selectedJobs = useObservable( + anomalyExplorerCommonStateService.selectedJobs$, + anomalyExplorerCommonStateService.selectedJobs + ); + + const selectedGroups = useObservable( + anomalyExplorerCommonStateService.selectedGroups$, + anomalyExplorerCommonStateService.selectedGroups + ); + + const mergedGroupsAndJobsIds = useMemo( + () => getMergedGroupsAndJobsIds(selectedGroups, selectedJobs), + [selectedGroups, selectedJobs] + ); + + return { + selectedJobs, + selectedGroups, + mergedGroupsAndJobsIds, + }; +};