From 331e76b96d7362ce587c63586d4e3d99cbe733b1 Mon Sep 17 00:00:00 2001 From: Milton Hultgren Date: Thu, 21 Oct 2021 09:17:05 +0200 Subject: [PATCH] [Observability] Make Alerts page use shared Kibana time range (#115192) * [Observability] Make Alerts page respect timefilter service range (#111348) * [Observability] Add useHashQuery option in UrlStateStorage * Remove unused * Add test for createKbnUrlStateStorage change * Add time range test * Add code comments * Clean up tests * Extend createKbnUrlStateStorage tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../create_kbn_url_state_storage.test.ts | 108 ++++++++++++++++ .../create_kbn_url_state_storage.ts | 14 ++- .../public/hooks/use_timefilter_service.ts | 14 +++ .../public/pages/alerts/index.tsx | 59 ++++----- .../pages/alerts/state_container/index.tsx | 9 ++ .../state_container/state_container.tsx | 53 ++++++++ .../use_alerts_page_state_container.tsx | 119 ++++++++++++++++++ .../observability/public/routes/index.tsx | 14 +-- .../services/observability/alerts/common.ts | 27 +++- .../apps/observability/alerts/index.ts | 4 +- .../alerts/state_synchronization.ts | 109 ++++++++++++++++ .../apps/observability/index.ts | 1 + 12 files changed, 484 insertions(+), 47 deletions(-) create mode 100644 x-pack/plugins/observability/public/hooks/use_timefilter_service.ts create mode 100644 x-pack/plugins/observability/public/pages/alerts/state_container/index.tsx create mode 100644 x-pack/plugins/observability/public/pages/alerts/state_container/state_container.tsx create mode 100644 x-pack/plugins/observability/public/pages/alerts/state_container/use_alerts_page_state_container.tsx create mode 100644 x-pack/test/observability_functional/apps/observability/alerts/state_synchronization.ts diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts index c42472fb959de5..1d659ae3fe61c7 100644 --- a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts @@ -191,6 +191,114 @@ describe('KbnUrlStateStorage', () => { }); }); + describe('useHashQuery: false', () => { + let urlStateStorage: IKbnUrlStateStorage; + let history: History; + const getCurrentUrl = () => history.createHref(history.location); + beforeEach(() => { + history = createBrowserHistory(); + history.push('/'); + urlStateStorage = createKbnUrlStateStorage({ useHash: false, history, useHashQuery: false }); + }); + + it('should persist state to url', async () => { + const state = { test: 'test', ok: 1 }; + const key = '_s'; + await urlStateStorage.set(key, state); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/?_s=(ok:1,test:test)"`); + expect(urlStateStorage.get(key)).toEqual(state); + }); + + it('should flush state to url', () => { + const state = { test: 'test', ok: 1 }; + const key = '_s'; + urlStateStorage.set(key, state); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + expect(!!urlStateStorage.kbnUrlControls.flush()).toBe(true); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/?_s=(ok:1,test:test)"`); + expect(urlStateStorage.get(key)).toEqual(state); + + expect(!!urlStateStorage.kbnUrlControls.flush()).toBe(false); // nothing to flush, not update + }); + + it('should cancel url updates', async () => { + const state = { test: 'test', ok: 1 }; + const key = '_s'; + const pr = urlStateStorage.set(key, state); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + urlStateStorage.cancel(); + await pr; + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + expect(urlStateStorage.get(key)).toEqual(null); + }); + + it('should cancel url updates if synchronously returned to the same state', async () => { + const state1 = { test: 'test', ok: 1 }; + const state2 = { test: 'test', ok: 2 }; + const key = '_s'; + const pr1 = urlStateStorage.set(key, state1); + await pr1; + const historyLength = history.length; + const pr2 = urlStateStorage.set(key, state2); + const pr3 = urlStateStorage.set(key, state1); + await Promise.all([pr2, pr3]); + expect(history.length).toBe(historyLength); + }); + + it('should notify about url changes', async () => { + expect(urlStateStorage.change$).toBeDefined(); + const key = '_s'; + const destroy$ = new Subject(); + const result = urlStateStorage.change$!(key).pipe(takeUntil(destroy$), toArray()).toPromise(); + + history.push(`/?${key}=(ok:1,test:test)`); + history.push(`/?query=test&${key}=(ok:2,test:test)&some=test`); + history.push(`/?query=test&some=test`); + + destroy$.next(); + destroy$.complete(); + + expect(await result).toEqual([{ test: 'test', ok: 1 }, { test: 'test', ok: 2 }, null]); + }); + + it("shouldn't throw in case of parsing error", async () => { + const key = '_s'; + history.replace(`/?${key}=(ok:2,test:`); // malformed rison + expect(() => urlStateStorage.get(key)).not.toThrow(); + expect(urlStateStorage.get(key)).toBeNull(); + }); + + it('should notify about errors', () => { + const cb = jest.fn(); + urlStateStorage = createKbnUrlStateStorage({ + useHash: false, + useHashQuery: false, + history, + onGetError: cb, + }); + const key = '_s'; + history.replace(`/?${key}=(ok:2,test:`); // malformed rison + expect(() => urlStateStorage.get(key)).not.toThrow(); + expect(cb).toBeCalledWith(expect.any(Error)); + }); + + describe('withNotifyOnErrors integration', () => { + test('toast is shown', () => { + const toasts = coreMock.createStart().notifications.toasts; + urlStateStorage = createKbnUrlStateStorage({ + useHash: true, + useHashQuery: false, + history, + ...withNotifyOnErrors(toasts), + }); + const key = '_s'; + history.replace(`/?${key}=(ok:2,test:`); // malformed rison + expect(() => urlStateStorage.get(key)).not.toThrow(); + expect(toasts.addError).toBeCalled(); + }); + }); + }); + describe('ScopedHistory integration', () => { let urlStateStorage: IKbnUrlStateStorage; let history: ScopedHistory; diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts index e3d40ede7a20a5..9a32999ad35c2c 100644 --- a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts @@ -58,16 +58,19 @@ export interface IKbnUrlStateStorage extends IStateStorage { export const createKbnUrlStateStorage = ( { useHash = false, + useHashQuery = true, history, onGetError, onSetError, }: { useHash: boolean; + useHashQuery?: boolean; history?: History; onGetError?: (error: Error) => void; onSetError?: (error: Error) => void; } = { useHash: false, + useHashQuery: true, } ): IKbnUrlStateStorage => { const url = createKbnUrlControls(history); @@ -80,7 +83,12 @@ export const createKbnUrlStateStorage = ( // syncState() utils doesn't wait for this promise return url.updateAsync((currentUrl) => { try { - return setStateToKbnUrl(key, state, { useHash }, currentUrl); + return setStateToKbnUrl( + key, + state, + { useHash, storeInHashQuery: useHashQuery }, + currentUrl + ); } catch (error) { if (onSetError) onSetError(error); } @@ -90,7 +98,7 @@ export const createKbnUrlStateStorage = ( // if there is a pending url update, then state will be extracted from that pending url, // otherwise current url will be used to retrieve state from try { - return getStateFromKbnUrl(key, url.getPendingUrl()); + return getStateFromKbnUrl(key, url.getPendingUrl(), { getFromHashQuery: useHashQuery }); } catch (e) { if (onGetError) onGetError(e); return null; @@ -106,7 +114,7 @@ export const createKbnUrlStateStorage = ( unlisten(); }; }).pipe( - map(() => getStateFromKbnUrl(key)), + map(() => getStateFromKbnUrl(key, undefined, { getFromHashQuery: useHashQuery })), catchError((error) => { if (onGetError) onGetError(error); return of(null); diff --git a/x-pack/plugins/observability/public/hooks/use_timefilter_service.ts b/x-pack/plugins/observability/public/hooks/use_timefilter_service.ts new file mode 100644 index 00000000000000..b82a35ce1f4622 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_timefilter_service.ts @@ -0,0 +1,14 @@ +/* + * 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 { useKibana } from '../../../../../src/plugins/kibana_react/public'; +import { ObservabilityPublicPluginsStart } from '../'; + +export function useTimefilterService() { + const { services } = useKibana(); + return services.data.query.timefilter.timefilter; +} diff --git a/x-pack/plugins/observability/public/pages/alerts/index.tsx b/x-pack/plugins/observability/public/pages/alerts/index.tsx index bba3b426598dfd..590c87707aa3da 100644 --- a/x-pack/plugins/observability/public/pages/alerts/index.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/index.tsx @@ -9,7 +9,6 @@ import { EuiButtonEmpty, EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiLink } from ' import { IndexPatternBase } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useRef } from 'react'; -import { useHistory } from 'react-router-dom'; import useAsync from 'react-use/lib/useAsync'; import { ParsedTechnicalFields } from '../../../../rule_registry/common/parse_technical_fields'; import type { AlertWorkflowStatus } from '../../../common/typings'; @@ -17,10 +16,11 @@ import { ExperimentalBadge } from '../../components/shared/experimental_badge'; import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; import { useFetcher } from '../../hooks/use_fetcher'; import { usePluginContext } from '../../hooks/use_plugin_context'; -import { RouteParams } from '../../routes'; +import { useTimefilterService } from '../../hooks/use_timefilter_service'; import { callObservabilityApi } from '../../services/call_observability_api'; import { AlertsSearchBar } from './alerts_search_bar'; import { AlertsTableTGrid } from './alerts_table_t_grid'; +import { Provider, alertsPageStateContainer, useAlertsPageStateContainer } from './state_container'; import './styles.scss'; import { WorkflowStatusFilter } from './workflow_status_filter'; @@ -32,18 +32,24 @@ export interface TopAlert { active: boolean; } -interface AlertsPageProps { - routeParams: RouteParams<'/alerts'>; -} +const NO_INDEX_NAMES: string[] = []; +const NO_INDEX_PATTERNS: IndexPatternBase[] = []; -export function AlertsPage({ routeParams }: AlertsPageProps) { +function AlertsPage() { const { core, plugins, ObservabilityPageTemplate } = usePluginContext(); const { prepend } = core.http.basePath; - const history = useHistory(); const refetch = useRef<() => void>(); + const timefilterService = useTimefilterService(); const { - query: { rangeFrom = 'now-15m', rangeTo = 'now', kuery = '', workflowStatus = 'open' }, - } = routeParams; + rangeFrom, + setRangeFrom, + rangeTo, + setRangeTo, + kuery, + setKuery, + workflowStatus, + setWorkflowStatus, + } = useAlertsPageStateContainer(); useBreadcrumbs([ { @@ -94,14 +100,9 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { const setWorkflowStatusFilter = useCallback( (value: AlertWorkflowStatus) => { - const nextSearchParams = new URLSearchParams(history.location.search); - nextSearchParams.set('workflowStatus', value); - history.push({ - ...history.location, - search: nextSearchParams.toString(), - }); + setWorkflowStatus(value); }, - [history] + [setWorkflowStatus] ); const onQueryChange = useCallback( @@ -109,18 +110,13 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { if (rangeFrom === dateRange.from && rangeTo === dateRange.to && kuery === (query ?? '')) { return refetch.current && refetch.current(); } - const nextSearchParams = new URLSearchParams(history.location.search); - - nextSearchParams.set('rangeFrom', dateRange.from); - nextSearchParams.set('rangeTo', dateRange.to); - nextSearchParams.set('kuery', query ?? ''); - history.push({ - ...history.location, - search: nextSearchParams.toString(), - }); + timefilterService.setTime(dateRange); + setRangeFrom(dateRange.from); + setRangeTo(dateRange.to); + setKuery(query); }, - [history, rangeFrom, rangeTo, kuery] + [rangeFrom, setRangeFrom, rangeTo, setRangeTo, kuery, setKuery, timefilterService] ); const addToQuery = useCallback( @@ -215,5 +211,12 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { ); } -const NO_INDEX_NAMES: string[] = []; -const NO_INDEX_PATTERNS: IndexPatternBase[] = []; +function WrappedAlertsPage() { + return ( + + + + ); +} + +export { WrappedAlertsPage as AlertsPage }; diff --git a/x-pack/plugins/observability/public/pages/alerts/state_container/index.tsx b/x-pack/plugins/observability/public/pages/alerts/state_container/index.tsx new file mode 100644 index 00000000000000..c057ec901686eb --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/state_container/index.tsx @@ -0,0 +1,9 @@ +/* + * 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 { Provider, alertsPageStateContainer } from './state_container'; +export { useAlertsPageStateContainer } from './use_alerts_page_state_container'; diff --git a/x-pack/plugins/observability/public/pages/alerts/state_container/state_container.tsx b/x-pack/plugins/observability/public/pages/alerts/state_container/state_container.tsx new file mode 100644 index 00000000000000..3e0a801fedbe22 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/state_container/state_container.tsx @@ -0,0 +1,53 @@ +/* + * 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 { + createStateContainer, + createStateContainerReactHelpers, +} from '../../../../../../../src/plugins/kibana_utils/public'; +import type { AlertWorkflowStatus } from '../../../../common/typings'; + +interface AlertsPageContainerState { + rangeFrom: string; + rangeTo: string; + kuery: string; + workflowStatus: AlertWorkflowStatus; +} + +interface AlertsPageStateTransitions { + setRangeFrom: ( + state: AlertsPageContainerState + ) => (rangeFrom: string) => AlertsPageContainerState; + setRangeTo: (state: AlertsPageContainerState) => (rangeTo: string) => AlertsPageContainerState; + setKuery: (state: AlertsPageContainerState) => (kuery: string) => AlertsPageContainerState; + setWorkflowStatus: ( + state: AlertsPageContainerState + ) => (workflowStatus: AlertWorkflowStatus) => AlertsPageContainerState; +} + +const defaultState: AlertsPageContainerState = { + rangeFrom: 'now-15m', + rangeTo: 'now', + kuery: '', + workflowStatus: 'open', +}; + +const transitions: AlertsPageStateTransitions = { + setRangeFrom: (state) => (rangeFrom) => ({ ...state, rangeFrom }), + setRangeTo: (state) => (rangeTo) => ({ ...state, rangeTo }), + setKuery: (state) => (kuery) => ({ ...state, kuery }), + setWorkflowStatus: (state) => (workflowStatus) => ({ ...state, workflowStatus }), +}; + +const alertsPageStateContainer = createStateContainer(defaultState, transitions); + +type AlertsPageStateContainer = typeof alertsPageStateContainer; + +const { Provider, useContainer } = createStateContainerReactHelpers(); + +export { Provider, alertsPageStateContainer, useContainer, defaultState }; +export type { AlertsPageStateContainer, AlertsPageContainerState, AlertsPageStateTransitions }; diff --git a/x-pack/plugins/observability/public/pages/alerts/state_container/use_alerts_page_state_container.tsx b/x-pack/plugins/observability/public/pages/alerts/state_container/use_alerts_page_state_container.tsx new file mode 100644 index 00000000000000..dfa4afcd939cc7 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/state_container/use_alerts_page_state_container.tsx @@ -0,0 +1,119 @@ +/* + * 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 { useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; + +import { TimefilterContract } from '../../../../../../../src/plugins/data/public'; +import { + createKbnUrlStateStorage, + syncState, + IKbnUrlStateStorage, + useContainerSelector, +} from '../../../../../../../src/plugins/kibana_utils/public'; +import { useTimefilterService } from '../../../hooks/use_timefilter_service'; + +import { + useContainer, + defaultState, + AlertsPageStateContainer, + AlertsPageContainerState, +} from './state_container'; + +export function useAlertsPageStateContainer() { + const stateContainer = useContainer(); + + useUrlStateSyncEffect(stateContainer); + + const { setRangeFrom, setRangeTo, setKuery, setWorkflowStatus } = stateContainer.transitions; + const { rangeFrom, rangeTo, kuery, workflowStatus } = useContainerSelector( + stateContainer, + (state) => state + ); + + return { + rangeFrom, + setRangeFrom, + rangeTo, + setRangeTo, + kuery, + setKuery, + workflowStatus, + setWorkflowStatus, + }; +} + +function useUrlStateSyncEffect(stateContainer: AlertsPageStateContainer) { + const history = useHistory(); + const timefilterService = useTimefilterService(); + + useEffect(() => { + const urlStateStorage = createKbnUrlStateStorage({ + history, + useHash: false, + useHashQuery: false, + }); + const { start, stop } = setupUrlStateSync(stateContainer, urlStateStorage); + + start(); + + syncUrlStateWithInitialContainerState(timefilterService, stateContainer, urlStateStorage); + + return stop; + }, [stateContainer, history, timefilterService]); +} + +function setupUrlStateSync( + stateContainer: AlertsPageStateContainer, + stateStorage: IKbnUrlStateStorage +) { + // This handles filling the state when an incomplete URL set is provided + const setWithDefaults = (changedState: Partial | null) => { + stateContainer.set({ ...defaultState, ...changedState }); + }; + + return syncState({ + storageKey: '_a', + stateContainer: { + ...stateContainer, + set: setWithDefaults, + }, + stateStorage, + }); +} + +function syncUrlStateWithInitialContainerState( + timefilterService: TimefilterContract, + stateContainer: AlertsPageStateContainer, + urlStateStorage: IKbnUrlStateStorage +) { + const urlState = urlStateStorage.get>('_a'); + + if (urlState) { + const newState = { + ...defaultState, + ...urlState, + }; + + stateContainer.set(newState); + } else if (timefilterService.isTimeTouched()) { + const { from, to } = timefilterService.getTime(); + const newState = { + ...defaultState, + rangeFrom: from, + rangeTo: to, + }; + + stateContainer.set(newState); + } else { + // Reset the state container when no URL state or timefilter range is set to avoid accidentally + // re-using state set on a previous visit to the page in the same session + stateContainer.set(defaultState); + } + + urlStateStorage.set('_a', stateContainer.get()); +} diff --git a/x-pack/plugins/observability/public/routes/index.tsx b/x-pack/plugins/observability/public/routes/index.tsx index ff03379e399630..1c2513cee9fce5 100644 --- a/x-pack/plugins/observability/public/routes/index.tsx +++ b/x-pack/plugins/observability/public/routes/index.tsx @@ -7,7 +7,6 @@ import * as t from 'io-ts'; import React from 'react'; -import { alertWorkflowStatusRt } from '../../common/typings'; import { ExploratoryViewPage } from '../components/shared/exploratory_view'; import { AlertsPage } from '../pages/alerts'; import { AllCasesPage } from '../pages/cases/all_cases'; @@ -85,18 +84,11 @@ export const routes = { }, }, '/alerts': { - handler: (routeParams: any) => { - return ; + handler: () => { + return ; }, params: { - query: t.partial({ - rangeFrom: t.string, - rangeTo: t.string, - kuery: t.string, - workflowStatus: alertWorkflowStatusRt, - refreshPaused: jsonRt.pipe(t.boolean), - refreshInterval: jsonRt.pipe(t.number), - }), + // Technically gets a '_a' param by using Kibana URL state sync helpers }, }, '/exploratory-view/': { diff --git a/x-pack/test/functional/services/observability/alerts/common.ts b/x-pack/test/functional/services/observability/alerts/common.ts index d5a2ce2a18c411..676704f849d3f5 100644 --- a/x-pack/test/functional/services/observability/alerts/common.ts +++ b/x-pack/test/functional/services/observability/alerts/common.ts @@ -5,7 +5,6 @@ * 2.0. */ -import querystring from 'querystring'; import { chunk } from 'lodash'; import { FtrProviderContext } from '../../../ftr_provider_context'; import { WebElementWrapper } from '../../../../../../test/functional/services/lib/web_element_wrapper'; @@ -27,6 +26,7 @@ export function ObservabilityAlertsCommonProvider({ getPageObjects, getService, }: FtrProviderContext) { + const find = getService('find'); const testSubjects = getService('testSubjects'); const flyoutService = getService('flyout'); const pageObjects = getPageObjects(['common']); @@ -37,7 +37,8 @@ export function ObservabilityAlertsCommonProvider({ return await pageObjects.common.navigateToUrlWithBrowserHistory( 'observability', '/alerts', - `?${querystring.stringify(DATE_WITH_DATA)}` + `?_a=(rangeFrom:'${DATE_WITH_DATA.rangeFrom}',rangeTo:'${DATE_WITH_DATA.rangeTo}')`, + { ensureCurrentUrl: false } ); }; @@ -180,6 +181,26 @@ export function ObservabilityAlertsCommonProvider({ await buttonGroupButton.click(); }; + const getWorkflowStatusFilterValue = async () => { + const selectedWorkflowStatusButton = await find.byClassName('euiButtonGroupButton-isSelected'); + return await selectedWorkflowStatusButton.getVisibleText(); + }; + + // Date picker + const getTimeRange = async () => { + const isAbsoluteRange = await testSubjects.exists('superDatePickerstartDatePopoverButton'); + + if (isAbsoluteRange) { + const startButton = await testSubjects.find('superDatePickerstartDatePopoverButton'); + const endButton = await testSubjects.find('superDatePickerendDatePopoverButton'); + return `${await startButton.getVisibleText()} - ${await endButton.getVisibleText()}`; + } + + const datePickerButton = await testSubjects.find('superDatePickerShowDatesButton'); + const buttonText = await datePickerButton.getVisibleText(); + return buttonText.substring(0, buttonText.indexOf('\n')); + }; + return { getQueryBar, clearQueryBar, @@ -202,8 +223,10 @@ export function ObservabilityAlertsCommonProvider({ openAlertsFlyout, setWorkflowStatusForRow, setWorkflowStatusFilter, + getWorkflowStatusFilterValue, submitQuery, typeInQueryBar, openActionsMenuForRow, + getTimeRange, }; } diff --git a/x-pack/test/observability_functional/apps/observability/alerts/index.ts b/x-pack/test/observability_functional/apps/observability/alerts/index.ts index 14019472eb2ca3..003cebb968479f 100644 --- a/x-pack/test/observability_functional/apps/observability/alerts/index.ts +++ b/x-pack/test/observability_functional/apps/observability/alerts/index.ts @@ -18,13 +18,12 @@ const ACTIVE_ALERTS_CELL_COUNT = 48; const RECOVERED_ALERTS_CELL_COUNT = 24; const TOTAL_ALERTS_CELL_COUNT = 72; -export default ({ getPageObjects, getService }: FtrProviderContext) => { +export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); describe('Observability alerts', function () { this.tags('includeFirefox'); - const pageObjects = getPageObjects(['common']); const testSubjects = getService('testSubjects'); const retry = getService('retry'); const observability = getService('observability'); @@ -92,7 +91,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // We shouldn't expect any data for the last 15 minutes await (await testSubjects.find('superDatePickerCommonlyUsed_Last_15 minutes')).click(); await observability.alerts.common.getNoDataStateOrFail(); - await pageObjects.common.waitUntilUrlIncludes('rangeFrom=now-15m&rangeTo=now'); }); }); }); diff --git a/x-pack/test/observability_functional/apps/observability/alerts/state_synchronization.ts b/x-pack/test/observability_functional/apps/observability/alerts/state_synchronization.ts new file mode 100644 index 00000000000000..5a03f72e540b38 --- /dev/null +++ b/x-pack/test/observability_functional/apps/observability/alerts/state_synchronization.ts @@ -0,0 +1,109 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + + describe('Observability alerts page / State synchronization', function () { + this.tags('includeFirefox'); + + const find = getService('find'); + const testSubjects = getService('testSubjects'); + const observability = getService('observability'); + const pageObjects = getPageObjects(['common']); + + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/observability/alerts'); + await esArchiver.load('x-pack/test/functional/es_archives/infra/simple_logs'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/observability/alerts'); + await esArchiver.unload('x-pack/test/functional/es_archives/infra/simple_logs'); + }); + + it('should read page state from URL', async () => { + await pageObjects.common.navigateToUrlWithBrowserHistory( + 'observability', + '/alerts', + `?_a=(kuery:'kibana.alert.evaluation.threshold > 75',rangeFrom:now-30d,rangeTo:now-10d,workflowStatus:closed)`, + { ensureCurrentUrl: false } + ); + + await assertAlertsPageState({ + kuery: 'kibana.alert.evaluation.threshold > 75', + workflowStatus: 'Closed', + timeRange: '~ a month ago - ~ 10 days ago', + }); + }); + + it('should not sync URL state to shared time range on page load ', async () => { + await (await find.byLinkText('Stream')).click(); + + await assertLogsStreamPageTimeRange('Last 1 day'); + }); + + it('should apply defaults if URL state is missing', async () => { + await (await find.byLinkText('Alerts')).click(); + + await assertAlertsPageState({ + kuery: '', + workflowStatus: 'Open', + timeRange: 'Last 15 minutes', + }); + }); + + it('should use shared time range if set', async () => { + await (await find.byLinkText('Stream')).click(); + await setTimeRangeToXDaysAgo(10); + await (await find.byLinkText('Alerts')).click(); + + expect(await observability.alerts.common.getTimeRange()).to.be('Last 10 days'); + }); + + it('should set the shared time range', async () => { + await setTimeRangeToXDaysAgo(100); + await (await find.byLinkText('Stream')).click(); + + await assertLogsStreamPageTimeRange('Last 100 days'); + }); + + async function assertAlertsPageState(expected: { + kuery: string; + workflowStatus: string; + timeRange: string; + }) { + expect(await (await observability.alerts.common.getQueryBar()).getVisibleText()).to.be( + expected.kuery + ); + expect(await observability.alerts.common.getWorkflowStatusFilterValue()).to.be( + expected.workflowStatus + ); + const timeRange = await observability.alerts.common.getTimeRange(); + expect(timeRange).to.be(expected.timeRange); + } + + async function assertLogsStreamPageTimeRange(expected: string) { + // Only handles relative time ranges + const datePickerButton = await testSubjects.find('superDatePickerShowDatesButton'); + const buttonText = await datePickerButton.getVisibleText(); + const timerange = buttonText.substring(0, buttonText.indexOf('\n')); + expect(timerange).to.be(expected); + } + + async function setTimeRangeToXDaysAgo(numberOfDays: number) { + await (await testSubjects.find('superDatePickerToggleQuickMenuButton')).click(); + const numerOfDaysField = await find.byCssSelector('[aria-label="Time value"]'); + await numerOfDaysField.clearValueWithKeyboard(); + await numerOfDaysField.type(numberOfDays.toString()); + await find.clickByButtonText('Apply'); + } + }); +}; diff --git a/x-pack/test/observability_functional/apps/observability/index.ts b/x-pack/test/observability_functional/apps/observability/index.ts index 43e056bae65c0e..341927352aa28d 100644 --- a/x-pack/test/observability_functional/apps/observability/index.ts +++ b/x-pack/test/observability_functional/apps/observability/index.ts @@ -16,5 +16,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./alerts/workflow_status')); loadTestFile(require.resolve('./alerts/pagination')); loadTestFile(require.resolve('./alerts/add_to_case')); + loadTestFile(require.resolve('./alerts/state_synchronization')); }); }