Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ML] Anomaly Explorer: Fixes handling of job group IDs when opening from dashboard panels #203224

Merged
merged 40 commits into from
Dec 16, 2024
Merged
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
a83459d
useGlobalUrlState service
rbrtj Dec 4, 2024
8a063ac
remove set_charts_data_loading explorer action
rbrtj Dec 4, 2024
6fc0569
anomaly explorer refactor
rbrtj Dec 6, 2024
bb30891
remove unused ExplorerPage props
rbrtj Dec 6, 2024
50172b8
Badges refactor
rbrtj Dec 6, 2024
8885e91
improve memoization for useUrlStateService
rbrtj Dec 6, 2024
c23276f
pass groups to dashboard embeddable instead of individual jobs
rbrtj Dec 9, 2024
334f736
global state interface comment
rbrtj Dec 9, 2024
7b8dd0e
remove invalidTimeRangeError
rbrtj Dec 9, 2024
91facf8
remove unused import
rbrtj Dec 9, 2024
3612c08
Merge branch 'main' into anomaly-explorer-enhancements
rbrtj Dec 9, 2024
0727a30
[CI] Auto-commit changed files from 'node scripts/eslint --no-cache -…
kibanamachine Dec 9, 2024
654ef68
fix eslint issues
rbrtj Dec 9, 2024
30c841c
fix apply time range on job selection
rbrtj Dec 9, 2024
f4ca91c
remove unused translations
rbrtj Dec 9, 2024
70acbcf
Merge remote-tracking branch 'upstream/main' into anomaly-explorer-en…
rbrtj Dec 10, 2024
e20f30a
remove file after merge conflicts
rbrtj Dec 10, 2024
2f673e6
support replace state in global state
rbrtj Dec 10, 2024
752cfcb
GroupWithTimeRange type
rbrtj Dec 10, 2024
b926fc4
anomaly explorer common state service - preserving reference while ex…
rbrtj Dec 10, 2024
0debdc0
improve job not found error message
rbrtj Dec 10, 2024
7584684
formatJobNotFound extracted to a function
rbrtj Dec 10, 2024
692d97b
fix navigation from overview panel to anomaly explorer
rbrtj Dec 10, 2024
e0c1de2
Merge remote-tracking branch 'upstream/main' into anomaly-explorer-en…
rbrtj Dec 10, 2024
d18d929
fix action for adding anomaly charts to a dashboard
rbrtj Dec 11, 2024
a257c9f
remove unused translations
rbrtj Dec 11, 2024
22a85e0
move id badges test to tsx && fix tests
rbrtj Dec 12, 2024
a4c8b49
move new selection id badges test to tsx && fix tests
rbrtj Dec 12, 2024
844adfa
timeseries explorer selected jobs fix
rbrtj Dec 12, 2024
b687d6f
basic unit tests for usePageUrlState and useGlobalUrlState hooks
rbrtj Dec 12, 2024
7894672
rename GroupWithTimerange for consistency && share TimeRange type
rbrtj Dec 12, 2024
f42b979
use set to remove duplicates from jobsInSelectedGroups list
rbrtj Dec 12, 2024
f725b4d
remove unused smv jobs from anomaly explorer common state
rbrtj Dec 12, 2024
e2e5c00
remove unused explorer constants
rbrtj Dec 12, 2024
74778d6
unit tests for getIndexPattern and getMergedGroupsAndJobsIds
rbrtj Dec 12, 2024
ab69786
new selection id badges tests type fix
rbrtj Dec 12, 2024
cdc4801
Merge branch 'main' into anomaly-explorer-enhancements
rbrtj Dec 13, 2024
9c5851e
Merge branch 'main' into anomaly-explorer-enhancements
rbrtj Dec 16, 2024
7df5acb
url state hooks test improvements
rbrtj Dec 16, 2024
e854c74
use job selection hook
rbrtj Dec 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion x-pack/platform/packages/private/ml/url_state/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export {
parseUrlState,
usePageUrlState,
useUrlState,
PageUrlStateService,
UrlStateService,
Provider,
UrlStateProvider,
type Accessor,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import React, { useEffect, type FC } from 'react';
import { render, act } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

import { parseUrlState, useUrlState, UrlStateProvider } from './url_state';
import {
parseUrlState,
useUrlState,
UrlStateProvider,
usePageUrlState,
useGlobalUrlState,
} from './url_state';

const mockHistoryInitialState =
"?_a=(mlExplorerFilter:(),mlExplorerSwimlane:(viewByFieldName:action),query:(query_string:(analyze_wildcard:!t,query:'*')))&_g=(ml:(jobIds:!(dec-2)),refreshInterval:(display:Off,pause:!f,value:0),time:(from:'2019-01-01T00:03:40.000Z',mode:absolute,to:'2019-08-30T11:55:07.000Z'))&savedSearchId=571aaf70-4c88-11e8-b3d7-01146121b73d";
Expand Down Expand Up @@ -143,3 +149,89 @@ describe('useUrlState', () => {
expect(getByTestId('appState').innerHTML).toBe('the updated query');
});
});

describe('usePageUrlState', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the motivation of using components in tests? did yo try rendering the hook itself?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point, I just aligned with the existing tests, but rendering the test component seems redundant, updated in: #7df5acb

it('manages page-specific state with default values', () => {
const TestComponent: FC = () => {
const [pageState, setPageState] = usePageUrlState<{
pageKey: 'testPage';
pageUrlState: {
defaultValue: string;
};
}>('testPage', {
defaultValue: 'initial',
});

return (
<>
<button onClick={() => setPageState({ defaultValue: 'updated' })}>Update</button>
<div data-test-subj="pageState">{pageState?.defaultValue}</div>
</>
);
};

const { getByText, getByTestId } = render(
<MemoryRouter>
<UrlStateProvider>
<TestComponent />
</UrlStateProvider>
</MemoryRouter>
);

expect(getByTestId('pageState').innerHTML).toBe('initial');

act(() => {
getByText('Update').click();
});

expect(getByTestId('pageState').innerHTML).toBe('updated');
});
});

describe('useGlobalUrlState', () => {
it('manages global state with ML and time properties', () => {
const defaultState = {
ml: { jobIds: ['initial-job'] },
time: { from: 'now-15m', to: 'now' },
};

const TestComponent: FC = () => {
const [globalState, setGlobalState] = useGlobalUrlState(defaultState);

return (
<>
<button
onClick={() =>
setGlobalState({
ml: { jobIds: ['updated-job'] },
time: { from: 'now-1h', to: 'now' },
})
}
>
Update
</button>
<div data-test-subj="globalState">{JSON.stringify(globalState)}</div>
</>
);
};

const { getByText, getByTestId } = render(
<MemoryRouter>
<UrlStateProvider>
<TestComponent />
</UrlStateProvider>
</MemoryRouter>
);

expect(JSON.parse(getByTestId('globalState').innerHTML)).toEqual(defaultState);

act(() => {
getByText('Update').click();
});

expect(JSON.parse(getByTestId('globalState').innerHTML)).toEqual({
ml: { jobIds: ['updated-job'] },
time: { from: 'now-1h', to: 'now' },
});
});
});
171 changes: 118 additions & 53 deletions x-pack/platform/packages/private/ml/url_state/src/url_state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TValue> {
[id: string]: TValue;
Expand Down Expand Up @@ -211,43 +212,42 @@ export const useUrlState = (
/**
* Service for managing URL state of particular page.
*/
export class PageUrlStateService<T> {
private _pageUrlState$ = new BehaviorSubject<T | null>(null);
private _pageUrlStateCallback: ((update: Partial<T>, replaceState?: boolean) => void) | null =
null;
export class UrlStateService<T> {
private _urlState$ = new BehaviorSubject<T | null>(null);
private _urlStateCallback: ((update: Partial<T>, replaceState?: boolean) => void) | null = null;

/**
* Provides updates for the page URL state.
*/
public getPageUrlState$(): Observable<T> {
return this._pageUrlState$.pipe(distinctUntilChanged(isEqual));
public getUrlState$(): Observable<T> {
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<T>, 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);
}

/**
* Populates internal subject with currently active state.
* @param currentState
*/
public setCurrentState(currentState: T): void {
this._pageUrlState$.next(currentState);
this._urlState$.next(currentState);
}

/**
* Sets the callback for the state update.
* @param callback
*/
public setUpdateCallback(callback: (update: Partial<T>, replaceState?: boolean) => void): void {
this._pageUrlStateCallback = callback;
this._urlStateCallback = callback;
}
}

Expand All @@ -256,32 +256,53 @@ export interface PageUrlState {
pageUrlState: object;
}

/**
* Hook for managing the URL state of the page.
*/
export const usePageUrlState = <T extends PageUrlState>(
pageKey: T['pageKey'],
defaultState?: T['pageUrlState']
): [
T['pageUrlState'],
(update: Partial<T['pageUrlState']>, replaceState?: boolean) => void,
PageUrlStateService<T['pageUrlState']>
] => {
const [appState, setAppState] = useUrlState('_a');
const pageState = appState?.[pageKey];
interface AppStateOptions<T> {
pageKey: string;
defaultState?: T;
}

const setCallback = useRef<typeof setAppState>();
interface GlobalStateOptions<T> {
defaultState?: T;
}

type UrlStateOptions<K extends Accessor, T> = K extends '_a'
? AppStateOptions<T>
: GlobalStateOptions<T>;

function isAppStateOptions<T>(
_stateKey: Accessor,
options: Partial<AppStateOptions<T>>
): options is AppStateOptions<T> {
return 'pageKey' in options;
}

export const useUrlStateService = <K extends Accessor, T>(
stateKey: K,
options: UrlStateOptions<K, T>
): [T, (update: Partial<T>, replaceState?: boolean) => void, UrlStateService<T>] => {
const optionsRef = useRef(options);

useDeepCompareEffect(() => {
optionsRef.current = options;
}, [options]);

const [state, setState] = useUrlState(stateKey);
const urlState = isAppStateOptions<T>(stateKey, optionsRef.current)
? state?.[optionsRef.current.pageKey]
: state;

const setCallback = useRef<typeof setState>();

useEffect(() => {
setCallback.current = setAppState;
}, [setAppState]);
setCallback.current = setState;
}, [setState]);

const prevPageState = useRef<T['pageUrlState'] | undefined>();
const prevPageState = useRef<T | undefined>();

const resultPageState: T['pageUrlState'] = useMemo(() => {
const resultState: T = useMemo(() => {
const result = {
...(defaultState ?? {}),
...(pageState ?? {}),
...(optionsRef.current.defaultState ?? {}),
...(urlState ?? {}),
};

if (isEqual(result, prevPageState.current)) {
Expand All @@ -300,38 +321,82 @@ export const usePageUrlState = <T extends PageUrlState>(
prevPageState.current = result;

return result;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageState]);
}, [urlState]);

const onStateUpdate = useCallback(
(update: Partial<T['pageUrlState']>, replaceState?: boolean) => {
(update: Partial<T>, 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<T>(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<T['pageUrlState']>(), []);
const urlStateService = useMemo(() => new UrlStateService<T>(), []);

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 = <T extends PageUrlState>(
pageKey: T['pageKey'],
defaultState?: T['pageUrlState']
): [
T['pageUrlState'],
(update: Partial<T['pageUrlState']>, replaceState?: boolean) => void,
UrlStateService<T['pageUrlState']>
] => {
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 = (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be great if you could add some basic tests to url_state.test.tsx to have some coverage for the new exports.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in: #b687d6f

defaultState?: GlobalState
): [
GlobalState,
(update: Partial<GlobalState>, replaceState?: boolean) => void,
UrlStateService<GlobalState>
] => {
return useUrlStateService<'_g', GlobalState>('_g', { defaultState });
};
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": "選択されたジョブに影響因子が構成されていないため、トップインフルエンスリストは非表示になっています。",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": "'排名最前影响因素'列表被隐藏,因为没有为所选作业配置影响因素。",
Expand Down
Loading