diff --git a/x-pack/platform/packages/private/ml/url_state/index.ts b/x-pack/platform/packages/private/ml/url_state/index.ts
index 13443af51bc4d..3bfc7f4ba1fa1 100644
--- a/x-pack/platform/packages/private/ml/url_state/index.ts
+++ b/x-pack/platform/packages/private/ml/url_state/index.ts
@@ -10,7 +10,7 @@ export {
parseUrlState,
usePageUrlState,
useUrlState,
- PageUrlStateService,
+ UrlStateService,
Provider,
UrlStateProvider,
type Accessor,
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..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,14 +6,26 @@
*/
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 { 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";
+const wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
+
describe('getUrlState', () => {
test('properly decode url with _g and _a', () => {
expect(parseUrlState(mockHistoryInitialState)).toEqual({
@@ -143,3 +155,50 @@ describe('useUrlState', () => {
expect(getByTestId('appState').innerHTML).toBe('the updated query');
});
});
+
+describe('usePageUrlState', () => {
+ it('manages page-specific state with default values', () => {
+ const pageKey = 'testPage';
+ const defaultPageState = {
+ defaultValue: 'initial',
+ };
+
+ const updatedPageState = {
+ defaultValue: 'updated',
+ };
+
+ const { result } = renderHook(() => usePageUrlState(pageKey, defaultPageState), { wrapper });
+
+ expect(result.current[0]).toEqual(defaultPageState);
+
+ act(() => {
+ result.current[1](updatedPageState);
+ });
+
+ expect(result.current[0]).toEqual(updatedPageState);
+ });
+});
+
+describe('useGlobalUrlState', () => {
+ it('manages global state with ML and time properties', () => {
+ const defaultState = {
+ ml: { jobIds: ['initial-job'] },
+ time: { from: 'now-15m', to: 'now' },
+ };
+
+ const updatedState = {
+ ml: { jobIds: ['updated-job'] },
+ time: { from: 'now-1h', to: 'now' },
+ };
+
+ const { result } = renderHook(() => useGlobalUrlState(defaultState), { wrapper });
+
+ expect(result.current[0]).toEqual(defaultState);
+
+ act(() => {
+ result.current[1](updatedState);
+ });
+
+ expect(result.current[0]).toEqual(updatedState);
+ });
+});
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 7cd6bc1d812e6..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
@@ -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;
@@ -211,27 +212,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 +239,7 @@ export class PageUrlStateService {
* @param currentState
*/
public setCurrentState(currentState: T): void {
- this._pageUrlState$.next(currentState);
+ this._urlState$.next(currentState);
}
/**
@@ -247,7 +247,7 @@ export class PageUrlStateService {
* @param callback
*/
public setUpdateCallback(callback: (update: Partial, replaceState?: boolean) => void): void {
- this._pageUrlStateCallback = callback;
+ this._urlStateCallback = callback;
}
}
@@ -256,32 +256,53 @@ 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;
+}
- const setCallback = useRef();
+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 optionsRef = useRef(options);
+
+ useDeepCompareEffect(() => {
+ optionsRef.current = options;
+ }, [options]);
+
+ const [state, setState] = useUrlState(stateKey);
+ const urlState = isAppStateOptions(stateKey, optionsRef.current)
+ ? state?.[optionsRef.current.pageKey]
+ : state;
+
+ 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 ?? {}),
+ ...(optionsRef.current.defaultState ?? {}),
+ ...(urlState ?? {}),
};
if (isEqual(result, prevPageState.current)) {
@@ -300,38 +321,82 @@ export const usePageUrlState = (
prevPageState.current = result;
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, optionsRef.current)) {
+ setCallback.current(
+ optionsRef.current.pageKey,
+ {
+ ...resultState,
+ ...update,
+ },
+ replaceState
+ );
+ } else {
+ setCallback.current({ ...resultState, ...update }, replaceState);
+ }
},
- [pageKey, resultPageState]
+ [stateKey, 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 });
+};
+
+/**
+ * Global state type, to add more state types, add them here
+ */
+
+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/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json
index 6c6c7f8516d5a..b87a7bbb2dafd 100644
--- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json
+++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json
@@ -29621,10 +29621,8 @@
"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})",
"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 569228e7d4088..27c6fdf29f42d 100644
--- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json
+++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json
@@ -29482,10 +29482,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": "選択されたジョブに影響因子が構成されていないため、トップインフルエンスリストは非表示になっています。",
diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json
index 611dbf21d2a4a..17fd1f80e6d39 100644
--- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json
+++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json
@@ -29006,10 +29006,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": "'排名最前影响因素'列表被隐藏,因为没有为所选作业配置影响因素。",
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/id_badges/id_badges.js b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/id_badges/id_badges.js
deleted file mode 100644
index b0fac87389e44..0000000000000
--- a/x-pack/platform/plugins/shared/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/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
new file mode 100644
index 0000000000000..b03ece5aaac55
--- /dev/null
+++ b/x-pack/platform/plugins/shared/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';
+
+export 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/platform/plugins/shared/ml/public/application/components/job_selector/id_badges/index.js b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/id_badges/index.ts
similarity index 100%
rename from x-pack/platform/plugins/shared/ml/public/application/components/job_selector/id_badges/index.js
rename to x-pack/platform/plugins/shared/ml/public/application/components/job_selector/id_badges/index.ts
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 26110819fd1ed..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,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 { MlJobGroupWithTimeRange } from './job_selector_flyout';
export function getGroupsFromJobs(jobs: MlJobWithTimeRange[]) {
- const groups: Dictionary = {};
+ const groups: Dictionary = {};
const groupsMap: Dictionary = {};
jobs.forEach((job) => {
@@ -86,7 +87,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);
}
diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/job_selector.tsx b/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/job_selector.tsx
index 063aa303944be..848da20c66e65 100644
--- a/x-pack/platform/plugins/shared/ml/public/application/components/job_selector/job_selector.tsx
+++ b/x-pack/platform/plugins/shared/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,15 @@ export interface JobSelectorProps {
dateFormatTz: string;
singleSelection: boolean;
timeseriesOnly: boolean;
+ onSelectionChange?: ({
+ jobIds,
+ time,
+ }: {
+ jobIds: string[];
+ time?: { from: string; to: string };
+ }) => void;
+ selectedJobIds?: string[];
+ selectedGroups?: GroupObj[];
}
export interface JobSelectionMaps {
@@ -85,23 +93,23 @@ 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),
- jobsMap: {},
- });
const [selectedIds, setSelectedIds] = useState(
mergeSelection(selectedJobIds, selectedGroups, singleSelection)
);
+
const [showAllBarBadges, setShowAllBarBadges] = useState(false);
const [isFlyoutVisible, setIsFlyoutVisible] = useState(false);
@@ -124,20 +132,13 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J
}
const applySelection: JobSelectorFlyoutProps['onSelectionConfirmed'] = useCallback(
- ({ newSelection, jobIds, groups: newGroups, time }) => {
+ ({ newSelection, jobIds, time }) => {
setSelectedIds(newSelection);
- setGlobalState({
- ml: {
- jobIds,
- groups: newGroups,
- },
- ...(time !== undefined ? { time } : {}),
- });
-
+ onSelectionChange?.({ jobIds, time });
closeFlyout();
},
- [setGlobalState, setSelectedIds]
+ [onSelectionChange]
);
function renderJobSelectionBar() {
@@ -155,9 +156,9 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J
>
setShowAllBarBadges(!showAllBarBadges)}
- selectedIds={selectedIds}
+ selectedJobIds={selectedJobIds}
+ selectedGroups={selectedGroups}
showAllBarBadges={showAllBarBadges}
/>
@@ -211,9 +212,7 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J
singleSelection={singleSelection}
selectedIds={selectedIds}
onSelectionConfirmed={applySelection}
- onJobsFetched={setMaps}
onFlyoutClose={closeFlyout}
- maps={maps}
applyTimeRangeConfig={applyTimeRangeConfig}
onTimeRangeConfigChange={setApplyTimeRangeConfig}
/>
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 02fb52c120303..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
@@ -29,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';
@@ -39,7 +42,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,12 +54,17 @@ export interface JobSelectorFlyoutProps {
onSelectionConfirmed: (payload: JobSelectionResult) => void;
singleSelection: boolean;
timeseriesOnly: boolean;
- maps: JobSelectionMaps;
withTimeRangeSelector?: boolean;
applyTimeRangeConfig?: boolean;
onTimeRangeConfigChange?: (v: boolean) => void;
}
+export interface MlJobGroupWithTimeRange {
+ id: string;
+ jobIds: string[];
+ timeRange: MlJobTimeRange;
+}
+
export const JobSelectorFlyoutContent: FC = ({
dateFormatTz,
selectedIds = [],
@@ -66,7 +73,6 @@ export const JobSelectorFlyoutContent: FC = ({
onJobsFetched,
onSelectionConfirmed,
onFlyoutClose,
- maps,
applyTimeRangeConfig,
onTimeRangeConfigChange,
withTimeRangeSelector = true,
@@ -83,42 +89,37 @@ 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 = [
+ ...new Set(
+ 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 +169,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 +215,7 @@ export const JobSelectorFlyoutContent: FC = ({
setShowAllBadges(!showAllBadges)}
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 76%
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..101c6f53d33fd 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,40 @@
*/
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,
+ widthPx: 0,
+ },
},
- },
+ {
+ id: 'group2',
+ jobIds: ['job3', 'job4'],
+ timeRange: {
+ from: 0,
+ to: 0,
+ fromPx: 0,
+ toPx: 0,
+ fromMoment: null,
+ toMoment: null,
+ widthPx: 0,
+ },
+ },
+ ],
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 71db8bdbbf85a..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,24 +10,24 @@ 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';
+import type { MlJobGroupWithTimeRange } from '../job_selector_flyout';
-interface NewSelectionIdBadgesProps {
+export interface NewSelectionIdBadgesProps {
limit: number;
- maps: JobSelectionMaps;
newSelection: string[];
onDeleteClick?: Function;
onLinkClick?: MouseEventHandler;
showAllBadges?: boolean;
+ groups: MlJobGroupWithTimeRange[];
}
export const NewSelectionIdBadges: FC = ({
limit,
- maps,
newSelection,
onDeleteClick,
onLinkClick,
showAllBadges,
+ groups,
}) => {
const badges = [];
@@ -41,7 +41,7 @@ export const NewSelectionIdBadges: FC = ({
g.id === newSelection[i])}
removeId={onDeleteClick}
/>
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 51d1882084d3e..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
@@ -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.selectedJobs$,
+ anomalyExplorerCommonStateService.selectedJobs
+ );
+ const invalidJobIds = useObservable(
+ anomalyExplorerCommonStateService.invalidJobIds$,
+ anomalyExplorerCommonStateService.invalidJobIds
+ );
- 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/platform/plugins/shared/ml/public/application/contexts/ml/use_job_selection_flyout.tsx b/x-pack/platform/plugins/shared/ml/public/application/contexts/ml/use_job_selection_flyout.tsx
index a95c9ad41fd42..fd19689d68b5e 100644
--- a/x-pack/platform/plugins/shared/ml/public/application/contexts/ml/use_job_selection_flyout.tsx
+++ b/x-pack/platform/plugins/shared/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/platform/plugins/shared/ml/public/application/explorer/actions/index.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/actions/index.ts
index 2a7b30d4ed6d8..12185d2799174 100644
--- a/x-pack/platform/plugins/shared/ml/public/application/explorer/actions/index.ts
+++ b/x-pack/platform/plugins/shared/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/platform/plugins/shared/ml/public/application/explorer/actions/job_selection.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/actions/job_selection.ts
deleted file mode 100644
index bd6bcd6e95657..0000000000000
--- a/x-pack/platform/plugins/shared/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/platform/plugins/shared/ml/public/application/explorer/actions/load_explorer_data.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/actions/load_explorer_data.ts
index 19bf333e0d2bd..6035b0327740f 100644
--- a/x-pack/platform/plugins/shared/ml/public/application/explorer/actions/load_explorer_data.ts
+++ b/x-pack/platform/plugins/shared/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/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 05c6bda4057d4..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
@@ -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()
@@ -55,12 +55,12 @@ 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$(),
- this._tableSeverityState.getPageUrlState$(),
+ this._tableSeverityState.getUrlState$(),
])
.pipe(
switchMap(
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..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
@@ -59,6 +59,7 @@ interface AnomalyContextMenuProps {
bounds?: TimeRangeBounds;
interval?: number;
chartsCount: number;
+ mergedGroupsAndJobsIds: string[];
}
const SavedObjectSaveModalDashboard = withSuspense(LazySavedObjectSaveModalDashboard);
@@ -76,6 +77,7 @@ export const AnomalyContextMenu: FC = ({
bounds,
interval,
chartsCount,
+ mergedGroupsAndJobsIds,
}) => {
const {
services: {
@@ -104,8 +106,8 @@ export const AnomalyContextMenu: FC = ({
const { anomalyExplorerCommonStateService, chartsStateService } = useAnomalyExplorerContext();
const { queryString } = useObservable(
- anomalyExplorerCommonStateService.getFilterSettings$(),
- anomalyExplorerCommonStateService.getFilterSettings()
+ anomalyExplorerCommonStateService.filterSettings$,
+ anomalyExplorerCommonStateService.filterSettings
);
const chartsData = useObservable(
@@ -137,8 +139,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 +151,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 +162,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 +176,7 @@ export const AnomalyContextMenu: FC = ({
: {}),
};
},
- [jobIds, maxSeriesToPlot, severity, queryString, selectedCells]
+ [selectedCells, mergedGroupsAndJobsIds, queryString, maxSeriesToPlot, severity.val]
);
const onSaveCallback: SaveModalDashboardProps['onSave'] = useCallback(
@@ -350,7 +351,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/anomaly_explorer_common_state.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_explorer_common_state.ts
index 800b3b2bc5eca..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
@@ -5,16 +5,20 @@
* 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';
+import type { GroupObj } from '../components/job_selector/job_selector';
export interface AnomalyExplorerState {
selectedJobs: ExplorerJob[];
@@ -30,8 +34,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([]);
private _filterSettings$ = new BehaviorSubject(this._getDefaultFilterSettings());
+ private _invalidJobIds$ = new BehaviorSubject([]);
private _getDefaultFilterSettings(): FilterSettings {
return {
@@ -42,65 +48,135 @@ 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);
- });
- }
-
- public setSelectedJobs(explorerJobs: ExplorerJob[] | undefined) {
- this._selectedJobs$.next(explorerJobs);
- }
+ public readonly selectedGroups$: Observable = this._selectedGroups$.pipe(
+ distinctUntilChanged(isEqual),
+ shareReplay(1)
+ );
- public getSelectedJobs$(): Observable {
- return this._selectedJobs$.pipe(
- filter((v) => Array.isArray(v) && v.length > 0),
- distinctUntilChanged(isEqual),
- shareReplay(1)
- );
- }
+ public readonly invalidJobIds$: Observable = this._invalidJobIds$.pipe(
+ distinctUntilChanged(isEqual),
+ shareReplay(1)
+ );
- private readonly _smvJobs$ = this.getSelectedJobs$().pipe(
- map((jobs) => jobs.filter((j) => j.isSingleMetricViewerJob)),
+ public readonly selectedJobs$: Observable = this._selectedJobs$.pipe(
+ filter((v) => Array.isArray(v) && v.length > 0),
+ distinctUntilChanged(isEqual),
shareReplay(1)
);
- public getSingleMetricJobs$(): Observable {
- return this._smvJobs$;
+ 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 getSelectedJobs(): ExplorerJob[] | undefined {
+ public get invalidJobIds(): string[] {
+ return this._invalidJobIds$.getValue();
+ }
+
+ public get selectedJobs(): ExplorerJob[] {
return this._selectedJobs$.getValue();
}
- public getInfluencerFilterQuery$(): Observable {
- return this._filterSettings$.pipe(
- map((v) => v?.influencersFilterQuery),
- distinctUntilChanged(isEqual)
+ public get filterSettings(): FilterSettings {
+ return this._filterSettings$.getValue();
+ }
+
+ protected _initSubscriptions(): Subscription {
+ 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;
}
- public getFilterSettings$(): Observable {
- return this._filterSettings$.asObservable();
+ private _processSelectedJobs(selectedJobIds: string[]) {
+ 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) => groupIds.includes(g))
+ );
+
+ const mappedJobs = createJobs(selectedJobs);
+
+ const invalidJobIds = this._getInvalidJobIds(selectedJobIds);
+
+ this._invalidJobIds$.next(invalidJobIds);
+ this._selectedJobs$.next(mappedJobs);
+ this._selectedGroups$.next(selectedGroups);
}
- public getFilterSettings(): FilterSettings {
- return this._filterSettings$.getValue();
+ private _getInvalidJobIds(jobIds: string[]): string[] {
+ return jobIds.filter(
+ (id) => !this.mlJobsService.jobs.some((j) => j.job_id === id || j.groups?.includes(id))
+ );
+ }
+
+ public setSelectedJobs(jobIds: string[], time?: { from: string; to: string }) {
+ this.globalUrlStateService.updateUrlState({
+ ml: {
+ jobIds,
+ },
+ ...(time ? { time } : {}),
+ });
}
public setFilterSettings(update: KQLFilterSettings) {
diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_explorer_context.tsx b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_explorer_context.tsx
index 2c1fb6dc8c182..cb29924d2373c 100644
--- a/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_explorer_context.tsx
+++ b/x-pack/platform/plugins/shared/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/platform/plugins/shared/ml/public/application/explorer/anomaly_timeline.tsx b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_timeline.tsx
index b64afb345f5bc..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
@@ -49,13 +49,12 @@ 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 {
+ type AppStateSelectedCells,
+ type OverallSwimlaneData,
+ type ViewBySwimLaneData,
} from './explorer_utils';
import { NoOverallData } from './components/no_overall_data';
import { SeverityControl } from '../components/severity_control';
@@ -67,6 +66,8 @@ 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';
+import { useJobSelection } from './hooks/use_job_selection';
function mapSwimlaneOptionsToEuiOptions(options: string[]) {
return options.map((option) => ({
@@ -120,16 +121,13 @@ 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()
- );
+ const { selectedJobs, mergedGroupsAndJobsIds } = useJobSelection();
const loading = useObservable(anomalyTimelineStateService.isOverallSwimLaneLoading$(), true);
@@ -196,6 +194,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 +358,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 +386,14 @@ export const AnomalyTimeline: FC = React.memo(
path,
});
},
- [embeddable, queryString, selectedJobs, selectedSwimlane, viewBySwimlaneFieldName]
+ [
+ embeddable,
+ mergedGroupsAndJobsIds,
+ queryString,
+ selectedJobs,
+ selectedSwimlane,
+ viewBySwimlaneFieldName,
+ ]
);
return (
@@ -621,7 +625,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/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 371284d0ac047..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
@@ -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)
@@ -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 ba95fc6671bcb..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
@@ -69,16 +69,16 @@ 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 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';
+import { useJobSelection } from './hooks/use_job_selection';
const AnnotationFlyout = dynamic(async () => ({
default: (await import('../components/annotations/annotation_flyout')).AnnotationFlyout,
@@ -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,
@@ -147,7 +143,6 @@ interface ExplorerUIProps {
showCharts: boolean;
selectedJobsRunning: boolean;
overallSwimlaneData: OverallSwimlaneData | null;
- invalidTimeRangeError?: boolean;
stoppedPartitions?: string[];
// TODO Remove
timefilter: TimefilterContract;
@@ -155,6 +150,7 @@ interface ExplorerUIProps {
timeBuckets: TimeBuckets;
selectedCells: AppStateSelectedCells | undefined | null;
swimLaneSeverity?: number;
+ noInfluencersConfigured?: boolean;
}
export function getDefaultPanelsState() {
@@ -171,7 +167,6 @@ export function getDefaultPanelsState() {
}
export const Explorer: FC = ({
- invalidTimeRangeError,
showCharts,
severity,
stoppedPartitions,
@@ -182,6 +177,7 @@ export const Explorer: FC = ({
swimLaneSeverity,
explorerState,
overallSwimlaneData,
+ noInfluencersConfigured,
}) => {
const isMobile = useIsWithinBreakpoints(['xs', 's']);
@@ -275,7 +271,7 @@ export const Explorer: FC = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [anomalyExplorerPanelState]);
- const { displayWarningToast, displayDangerToast } = useToastNotificationService();
+ const { displayDangerToast } = useToastNotificationService();
const {
anomalyTimelineStateService,
anomalyExplorerCommonStateService,
@@ -291,14 +287,11 @@ 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()
- );
+ const { selectedJobs, selectedGroups, mergedGroupsAndJobsIds } = useJobSelection();
const alertsData = useObservable(anomalyDetectionAlertsStateService.anomalyDetectionAlerts$, []);
@@ -361,21 +354,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,
@@ -387,15 +365,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 +413,24 @@ export const Explorer: FC = ({
);
+ const handleJobSelectionChange = useCallback(
+ ({ jobIds, time }: { jobIds: 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,
+ selectedGroups,
+ } as unknown as JobSelectorProps;
const noJobsSelected = !selectedJobs || selectedJobs.length === 0;
+
const hasResults: boolean =
!!overallSwimlaneData?.points && overallSwimlaneData.points.length > 0;
const hasResultsWithAnomalies =
@@ -454,7 +438,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) {
@@ -592,6 +575,7 @@ export const Explorer: FC = ({
= ({
;
- [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 });
- },
- setChartsDataLoading: () => {
- explorerAction$.next({ type: EXPLORER_ACTION.SET_CHARTS_DATA_LOADING });
- },
-});
-
-export type ExplorerService = ReturnType;
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/explorer_data.ts
similarity index 76%
rename from x-pack/platform/plugins/shared/ml/public/application/explorer/reducers/explorer_reducer/state.ts
rename to x-pack/platform/plugins/shared/ml/public/application/explorer/explorer_data.ts
index 3eed0c410b0da..7e40dd07268d2 100644
--- 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/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/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']);
+ });
+});
diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer_utils.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer_utils.ts
index abe921ee4352e..ce68528040b0d 100644
--- a/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer_utils.ts
+++ b/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer_utils.ts
@@ -54,6 +54,8 @@ 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';
+import type { GroupObj } from '../components/job_selector/job_selector';
export interface ExplorerJob {
id: string;
@@ -62,6 +64,7 @@ export interface ExplorerJob {
isSingleMetricViewerJob?: boolean;
sourceIndices?: string[];
modelPlotEnabled: boolean;
+ groups?: string[];
}
export function isExplorerJob(arg: unknown): arg is ExplorerJob {
@@ -149,6 +152,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 +492,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 +705,28 @@ 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,
+ })),
+ };
+}
+
+// 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];
+}
diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/hooks/use_explorer_url_state.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/hooks/use_explorer_url_state.ts
index cfa3fdc03d343..cbea33986eab7 100644
--- a/x-pack/platform/plugins/shared/ml/public/application/explorer/hooks/use_explorer_url_state.ts
+++ b/x-pack/platform/plugins/shared/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';
diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/hooks/use_job_selection.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/hooks/use_job_selection.ts
new file mode 100644
index 0000000000000..36fdc076e4cef
--- /dev/null
+++ b/x-pack/platform/plugins/shared/ml/public/application/explorer/hooks/use_job_selection.ts
@@ -0,0 +1,36 @@
+/*
+ * 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 useObservable from 'react-use/lib/useObservable';
+import { useMemo } from 'react';
+import { useAnomalyExplorerContext } from '../anomaly_explorer_context';
+import { getMergedGroupsAndJobsIds } from '../explorer_utils';
+
+export const useJobSelection = () => {
+ 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,
+ };
+};
diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts
deleted file mode 100644
index 5eec96170b238..0000000000000
--- a/x-pack/platform/plugins/shared/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/platform/plugins/shared/ml/public/application/explorer/reducers/explorer_reducer/get_index_pattern.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/reducers/explorer_reducer/get_index_pattern.ts
deleted file mode 100644
index 878ba9370c95b..0000000000000
--- a/x-pack/platform/plugins/shared/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/platform/plugins/shared/ml/public/application/explorer/reducers/explorer_reducer/index.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/reducers/explorer_reducer/index.ts
deleted file mode 100644
index 74b6c88fba8d4..0000000000000
--- a/x-pack/platform/plugins/shared/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/platform/plugins/shared/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts
deleted file mode 100644
index 58f7461b11047..0000000000000
--- a/x-pack/platform/plugins/shared/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/platform/plugins/shared/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts
deleted file mode 100644
index 4be342c9333ad..0000000000000
--- a/x-pack/platform/plugins/shared/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts
+++ /dev/null
@@ -1,77 +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 { 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';
-
-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_CHARTS_DATA_LOADING:
- nextState = {
- ...state,
- anomalyChartsDataLoading: true,
- chartsData: getDefaultChartsData(),
- };
- 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/platform/plugins/shared/ml/public/application/explorer/reducers/explorer_reducer/set_kql_query_bar_placeholder.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/reducers/explorer_reducer/set_kql_query_bar_placeholder.ts
deleted file mode 100644
index e68037f0da471..0000000000000
--- a/x-pack/platform/plugins/shared/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/platform/plugins/shared/ml/public/application/explorer/reducers/index.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/reducers/index.ts
deleted file mode 100644
index db44d1864daa1..0000000000000
--- a/x-pack/platform/plugins/shared/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/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';
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 87983d2c61603..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
@@ -6,13 +6,12 @@
*/
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';
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';
@@ -30,6 +29,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[];
@@ -43,38 +46,24 @@ 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 });
+ 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;
- // 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(
@@ -97,35 +86,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();
@@ -151,35 +123,36 @@ export const ExplorerUrlStateManager: FC = ({
);
const influencersFilterQuery = useObservable(
- anomalyExplorerContext.anomalyExplorerCommonStateService.getInfluencerFilterQuery$()
+ anomalyExplorerContext.anomalyExplorerCommonStateService.influencerFilterQuery$
);
- 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 - handle it in anomaly_explorer_common_state
+ setExplorerState((prevState) => ({ ...prevState, loading: true }));
loadExplorerData(loadExplorerDataConfig);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(loadExplorerDataConfig)]);
@@ -202,10 +175,7 @@ export const ExplorerUrlStateManager: FC = ({
-
+
= ({
{
+ setGlobalState({
+ ml: {
+ jobIds,
+ },
+ ...(time !== undefined ? { time } : {}),
+ });
+ },
+ [setGlobalState]
+ );
+
// Use a side effect to clear appState when changing jobs.
useEffect(() => {
if (selectedJobIds !== undefined && previousSelectedJobIds !== undefined) {
@@ -268,7 +287,11 @@ export const TimeSeriesExplorerUrlStateManager: FC
+
);
@@ -276,7 +299,11 @@ export const TimeSeriesExplorerUrlStateManager: FC
+
);
@@ -306,6 +333,7 @@ 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 90dfe24946195..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
@@ -23,6 +23,13 @@ interface TimeSeriesExplorerProps {
tableInterval?: string;
tableSeverity?: number;
zoom?: { from?: string; to?: string };
+ handleJobSelectionChange: ({
+ jobIds,
+ time,
+ }: {
+ jobIds: string[];
+ time?: { from: string; to: string };
+ }) => void;
}
// eslint-disable-next-line react/prefer-stateless-function
diff --git a/x-pack/platform/plugins/shared/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/platform/plugins/shared/ml/public/application/timeseriesexplorer/timeseriesexplorer.js
index 9fce79d3d1fab..92b3c480a47ee 100644
--- a/x-pack/platform/plugins/shared/ml/public/application/timeseriesexplorer/timeseriesexplorer.js
+++ b/x-pack/platform/plugins/shared/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 +43,8 @@ export const TimeSeriesExplorerPage: FC {
const {
services: { cases, docLinks },
@@ -66,7 +76,13 @@ export const TimeSeriesExplorerPage: FC
{noSingleMetricJobsFound ? null : (
-
+
)}
{children}
diff --git a/x-pack/platform/plugins/shared/ml/public/embeddables/common/components/job_selector_flyout.tsx b/x-pack/platform/plugins/shared/ml/public/embeddables/common/components/job_selector_flyout.tsx
deleted file mode 100644
index 44888067c839e..0000000000000
--- a/x-pack/platform/plugins/shared/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 (
-
- );
-};
diff --git a/x-pack/platform/plugins/shared/ml/public/maps/util.ts b/x-pack/platform/plugins/shared/ml/public/maps/util.ts
index f661c08b6c5f6..8563380da5642 100644
--- a/x-pack/platform/plugins/shared/ml/public/maps/util.ts
+++ b/x-pack/platform/plugins/shared/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);
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..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,7 +472,7 @@ export function getMlClient(
throw error;
}
if (error.statusCode === 404) {
- throw new MLJobNotFound(error.body.error.reason);
+ throw new MLJobNotFound(formatJobNotFoundError(error.body.error.reason));
}
throw error;
}
@@ -786,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;
+}