diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_context.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_context.tsx index bd3b163fda585..ec099521c79a5 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_context.tsx +++ b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_context.tsx @@ -18,10 +18,10 @@ import { type DataViewField } from '@kbn/data-views-plugin/public'; import { startWith } from 'rxjs'; import type { Filter, Query } from '@kbn/es-query'; import { usePageUrlState } from '@kbn/ml-url-state'; -import { useTimefilter, useTimeRangeUpdates } from '@kbn/ml-date-picker'; +import { useTimefilter } from '@kbn/ml-date-picker'; import { ES_FIELD_TYPES } from '@kbn/field-types'; import { type QueryDslQueryContainer } from '@kbn/data-views-plugin/common/types'; -import { FilterQueryContextProvider } from '../../hooks/use_filters_query'; +import { useFilterQueryUpdates } from '../../hooks/use_filters_query'; import { type ChangePointType, DEFAULT_AGG_FUNCTION } from './constants'; import { createMergedEsQuery, @@ -155,14 +155,14 @@ export const ChangePointDetectionContextProvider: FC = ({ children }) => { const timefilter = useTimefilter(); const timeBuckets = useTimeBuckets(); + const { searchBounds } = useFilterQueryUpdates(); + const [resultFilters, setResultFilter] = useState([]); const [selectedChangePoints, setSelectedChangePoints] = useState< Record >({}); const [bucketInterval, setBucketInterval] = useState(); - const timeRange = useTimeRangeUpdates(true); - useEffect(function updateIntervalOnTimeBoundsChange() { const timeUpdateSubscription = timefilter .getTimeUpdate$() @@ -267,14 +267,14 @@ export const ChangePointDetectionContextProvider: FC = ({ children }) => { mergedQuery.bool!.filter.push({ range: { [dataView.timeFieldName!]: { - from: timeRange.from, - to: timeRange.to, + from: searchBounds.min?.valueOf(), + to: searchBounds.max?.valueOf(), }, }, }); return mergedQuery; - }, [resultFilters, resultQuery, uiSettings, dataView, timeRange]); + }, [resultFilters, resultQuery, uiSettings, dataView, searchBounds]); if (!bucketInterval) return null; @@ -295,7 +295,7 @@ export const ChangePointDetectionContextProvider: FC = ({ children }) => { return ( - {children} + {children} ); }; diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_root.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_root.tsx index b63e17e2e0d86..53eb107ddfe6a 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_root.tsx +++ b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_root.tsx @@ -38,6 +38,7 @@ import { import { timeSeriesDataViewWarning } from '../../application/utils/time_series_dataview_check'; import { ReloadContextProvider } from '../../hooks/use_reload'; import { AIOPS_TELEMETRY_ID } from '../../../common/constants'; +import { FilterQueryContextProvider } from '../../hooks/use_filters_query'; const localStorage = new Storage(window.localStorage); @@ -97,11 +98,13 @@ export const ChangePointDetectionAppState: FC - - - - - + + + + + + + diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/change_points_table.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/change_points_table.tsx index 6abf5102a37ca..0ee0a316b0811 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/change_points_table.tsx +++ b/x-pack/plugins/aiops/public/components/change_point_detection/change_points_table.tsx @@ -398,6 +398,7 @@ export const MiniChartPreview: FC = ({ id={`mini_changePointChart_${annotation.group ? annotation.group.value : annotation.label}`} style={{ height: 80 }} timeRange={timeRange} + noPadding query={query} filters={filters} // @ts-ignore diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/use_common_chart_props.ts b/x-pack/plugins/aiops/public/components/change_point_detection/use_common_chart_props.ts index c33667ee93ee4..71b0e2afaf127 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/use_common_chart_props.ts +++ b/x-pack/plugins/aiops/public/components/change_point_detection/use_common_chart_props.ts @@ -5,12 +5,10 @@ * 2.0. */ -import moment from 'moment'; -import { FilterStateStore, type TimeRange } from '@kbn/es-query'; +import { FilterStateStore } from '@kbn/es-query'; import { type TypedLensByValueInput } from '@kbn/lens-plugin/public'; -import { getAbsoluteTimeRange } from '@kbn/data-plugin/common'; import { useMemo } from 'react'; -import { useFilerQueryUpdates } from '../../hooks/use_filters_query'; +import { useFilterQueryUpdates } from '../../hooks/use_filters_query'; import { fnOperationTypeMapping } from './constants'; import { useDataSource } from '../../hooks/use_data_source'; import { ChangePointAnnotation, FieldConfig } from './change_point_detection_context'; @@ -32,20 +30,7 @@ export const useCommonChartProps = ({ }): Partial => { const { dataView } = useDataSource(); - const { filters: resultFilters, query: resultQuery, timeRange } = useFilerQueryUpdates(); - - /** - * In order to correctly render annotations for change points at the edges, - * we need to adjust time bound based on the change point timestamp. - */ - const chartTimeRange = useMemo(() => { - const absoluteTimeRange = getAbsoluteTimeRange(timeRange); - - return { - from: moment.min(moment(absoluteTimeRange.from), moment(annotation.timestamp)).toISOString(), - to: moment.max(moment(absoluteTimeRange.to), moment(annotation.timestamp)).toISOString(), - }; - }, [timeRange, annotation.timestamp]); + const { filters: resultFilters, query: resultQuery, searchBounds } = useFilterQueryUpdates(); const filters = useMemo(() => { return [ @@ -230,8 +215,13 @@ export const useCommonChartProps = ({ gridAndLabelsVisibility, ]); + const boundsTimeRange = { + from: searchBounds.min?.toISOString()!, + to: searchBounds.max?.toISOString()!, + }; + return { - timeRange: chartTimeRange, + timeRange: boundsTimeRange, filters, query: resultQuery, attributes, diff --git a/x-pack/plugins/aiops/public/embeddable/embeddable_chart_component_wrapper.tsx b/x-pack/plugins/aiops/public/embeddable/embeddable_chart_component_wrapper.tsx index 0cb49eb4ccf39..ae34abd065f27 100644 --- a/x-pack/plugins/aiops/public/embeddable/embeddable_chart_component_wrapper.tsx +++ b/x-pack/plugins/aiops/public/embeddable/embeddable_chart_component_wrapper.tsx @@ -8,7 +8,6 @@ import { BehaviorSubject, combineLatest, type Observable } from 'rxjs'; import { distinctUntilChanged, map } from 'rxjs/operators'; import React, { FC, useEffect, useMemo, useState } from 'react'; -import { useTimefilter } from '@kbn/ml-date-picker'; import { css } from '@emotion/react'; import useObservable from 'react-use/lib/useObservable'; import { ChangePointsTable } from '../components/change_point_detection/change_points_table'; @@ -24,10 +23,9 @@ import type { EmbeddableChangePointChartOutput, } from './embeddable_change_point_chart'; import { EmbeddableChangePointChartProps } from './embeddable_change_point_chart_component'; -import { FilterQueryContextProvider, useFilerQueryUpdates } from '../hooks/use_filters_query'; +import { FilterQueryContextProvider, useFilterQueryUpdates } from '../hooks/use_filters_query'; import { DataSourceContextProvider, useDataSource } from '../hooks/use_data_source'; import { useAiopsAppContext } from '../hooks/use_aiops_app_context'; -import { useTimeBuckets } from '../hooks/use_time_buckets'; import { createMergedEsQuery } from '../application/utils/search_utils'; import { useChangePointResults } from '../components/change_point_detection/use_change_point_agg_request'; import { ChartsGrid } from '../components/change_point_detection/charts_grid'; @@ -85,8 +83,8 @@ export const EmbeddableInputTracker: FC = ({ return ( - - + + = ({ onChange={input.onChange} emptyState={input.emptyState} /> - - + + ); @@ -139,7 +137,7 @@ export const ChartGridEmbeddableWrapper: FC< onChange, emptyState, }) => { - const { filters, query, timeRange } = useFilerQueryUpdates(); + const { filters, query, searchBounds, interval } = useFilterQueryUpdates(); const fieldConfig = useMemo(() => { return { fn, metricField, splitField }; @@ -147,14 +145,6 @@ export const ChartGridEmbeddableWrapper: FC< const { dataView } = useDataSource(); const { uiSettings } = useAiopsAppContext(); - const timeBuckets = useTimeBuckets(); - const timefilter = useTimefilter(); - - const interval = useMemo(() => { - timeBuckets.setInterval('auto'); - timeBuckets.setBounds(timefilter.calculateBounds(timeRange)); - return timeBuckets.getInterval().expression; - }, [timeRange, timeBuckets, timefilter]); const combinedQuery = useMemo(() => { const mergedQuery = createMergedEsQuery(query, filters, dataView, uiSettings); @@ -168,8 +158,9 @@ export const ChartGridEmbeddableWrapper: FC< mergedQuery.bool!.filter.push({ range: { [dataView.timeFieldName!]: { - from: timeRange.from, - to: timeRange.to, + from: searchBounds.min?.valueOf(), + to: searchBounds.max?.valueOf(), + format: 'epoch_millis', }, }, }); @@ -183,16 +174,7 @@ export const ChartGridEmbeddableWrapper: FC< } return mergedQuery; - }, [ - dataView, - fieldConfig.splitField, - filters, - partitions, - query, - timeRange.from, - timeRange.to, - uiSettings, - ]); + }, [dataView, fieldConfig.splitField, filters, partitions, query, searchBounds, uiSettings]); const requestParams = useMemo(() => { return { interval } as ChangePointDetectionRequestParams; diff --git a/x-pack/plugins/aiops/public/hooks/__mocks__/use_aiops_app_context.ts b/x-pack/plugins/aiops/public/hooks/__mocks__/use_aiops_app_context.ts new file mode 100644 index 0000000000000..d02a88e6760b7 --- /dev/null +++ b/x-pack/plugins/aiops/public/hooks/__mocks__/use_aiops_app_context.ts @@ -0,0 +1,40 @@ +/* + * 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 { uiSettingsServiceMock } from '@kbn/core-ui-settings-browser-mocks'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; + +const staticContext = { + data: dataPluginMock.createStartContract(), + uiSettings: uiSettingsServiceMock.createStartContract(), +}; + +jest.spyOn(staticContext.uiSettings, 'get').mockImplementation((key: string) => { + if (key === 'dateFormat') { + return 'MMM D, YYYY @ HH:mm:ss.SSS'; + } + if (key === 'dateFormat:scaled') { + return [ + ['', 'HH:mm:ss.SSS'], + ['PT1S', 'HH:mm:ss'], + ['PT1M', 'HH:mm'], + ['PT1H', 'YYYY-MM-DD HH:mm'], + ['P1DT', 'YYYY-MM-DD'], + ['P1YT', 'YYYY'], + ]; + } + if (key === 'histogram:maxBars') { + return 1000; + } + if (key === 'histogram:barTarget') { + return 50; + } + return ''; +}); + +export const useAiopsAppContext = jest.fn(() => { + return staticContext; +}); diff --git a/x-pack/plugins/aiops/public/hooks/__mocks__/use_reload.tsx b/x-pack/plugins/aiops/public/hooks/__mocks__/use_reload.tsx new file mode 100644 index 0000000000000..7aa2d8fe16f95 --- /dev/null +++ b/x-pack/plugins/aiops/public/hooks/__mocks__/use_reload.tsx @@ -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. + */ + +const staticMock = { + refreshTimestamp: 1708711043761, +}; + +export const useReload = jest.fn(() => { + return staticMock; +}); diff --git a/x-pack/plugins/aiops/public/hooks/use_filters_query.tsx b/x-pack/plugins/aiops/public/hooks/use_filters_query.tsx index 4db832902d7fa..bec5d86fe1c4a 100644 --- a/x-pack/plugins/aiops/public/hooks/use_filters_query.tsx +++ b/x-pack/plugins/aiops/public/hooks/use_filters_query.tsx @@ -5,16 +5,22 @@ * 2.0. */ -import React, { type FC, createContext, useEffect, useState, useContext } from 'react'; +import React, { type FC, createContext, useEffect, useState, useContext, useMemo } from 'react'; import type { Filter, Query, TimeRange } from '@kbn/es-query'; import { useTimeRangeUpdates } from '@kbn/ml-date-picker'; import { type AggregateQuery } from '@kbn/es-query'; +import type { TimeRangeBounds } from '@kbn/data-plugin/common'; +import { getBoundsRoundedToInterval } from '../../common/time_buckets'; +import { useTimeBuckets } from './use_time_buckets'; import { useAiopsAppContext } from './use_aiops_app_context'; +import { useReload } from './use_reload'; export const FilterQueryContext = createContext<{ filters: Filter[]; query: Query; timeRange: TimeRange; + searchBounds: TimeRangeBounds; + interval: string; }>({ get filters(): Filter[] { throw new Error('FilterQueryContext is not initialized'); @@ -25,10 +31,19 @@ export const FilterQueryContext = createContext<{ get timeRange(): TimeRange { throw new Error('FilterQueryContext is not initialized'); }, + get searchBounds(): TimeRangeBounds { + throw new Error('FilterQueryContext is not initialized'); + }, + get interval(): string { + throw new Error('FilterQueryContext is not initialized'); + }, }); /** - * Helper context to provide the latest filter, query and time range values + * Helper context to provide the latest + * - filter + * - query + * - time range * from the data plugin. * Also merges custom filters and queries provided with an input. * @@ -41,10 +56,13 @@ export const FilterQueryContextProvider: FC<{ timeRange?: TimeRange }> = ({ }) => { const { data: { - query: { filterManager, queryString }, + query: { filterManager, queryString, timefilter }, }, } = useAiopsAppContext(); + const timeBuckets = useTimeBuckets(); + const reload = useReload(); + const [resultFilters, setResultFilter] = useState(filterManager.getFilters()); const [resultQuery, setResultQuery] = useState(queryString.getQuery()); @@ -68,12 +86,40 @@ export const FilterQueryContextProvider: FC<{ timeRange?: TimeRange }> = ({ }; }, [queryString]); + const resultTimeRange = useMemo(() => { + return timeRange ?? timeRangeUpdates; + }, [timeRangeUpdates, timeRange]); + + /** + * Search bounds derived from the time range. + * Has to be updated on reload, in case relative time range is used. + */ + const bounds = useMemo(() => { + return timefilter.timefilter.calculateBounds(resultTimeRange); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resultTimeRange, timefilter, reload.refreshTimestamp]); + + const timeBucketsInterval = useMemo(() => { + timeBuckets.setInterval('auto'); + timeBuckets.setBounds(bounds); + return timeBuckets.getInterval(); + }, [bounds, timeBuckets]); + + /** + * Search bounds rounded to the time buckets interval. + */ + const searchBounds = useMemo(() => { + return getBoundsRoundedToInterval(bounds, timeBucketsInterval, false); + }, [bounds, timeBucketsInterval]); + return ( {children} @@ -81,6 +127,6 @@ export const FilterQueryContextProvider: FC<{ timeRange?: TimeRange }> = ({ ); }; -export const useFilerQueryUpdates = () => { +export const useFilterQueryUpdates = () => { return useContext(FilterQueryContext); }; diff --git a/x-pack/plugins/aiops/public/hooks/use_filters_query.tsx.test.tsx b/x-pack/plugins/aiops/public/hooks/use_filters_query.tsx.test.tsx new file mode 100644 index 0000000000000..2f45f627495b8 --- /dev/null +++ b/x-pack/plugins/aiops/public/hooks/use_filters_query.tsx.test.tsx @@ -0,0 +1,87 @@ +/* + * 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 { FilterQueryContextProvider, useFilterQueryUpdates } from './use_filters_query'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { dataPluginMock as mockDataPlugin } from '@kbn/data-plugin/public/mocks'; +import { Timefilter } from '@kbn/data-plugin/public/query'; +import { useAiopsAppContext } from './use_aiops_app_context'; +import { useReload } from './use_reload'; + +const mockCurrentDate = new Date('2024-02-23T00:13:45.000Z'); + +jest.mock('./use_aiops_app_context'); + +jest.mock('./use_reload'); + +jest.mock('@kbn/ml-date-picker', () => ({ + useTimeRangeUpdates: jest.fn(() => { + return { from: 'now-24h', to: 'now' }; + }), +})); + +jest.mock('@kbn/ml-date-picker', () => ({ + useTimeRangeUpdates: jest.fn(() => { + return { from: 'now-24h', to: 'now' }; + }), +})); + +describe('useFilterQueryUpdates', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(mockCurrentDate); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + test('provides correct search bounds for relative time range on each reload', async () => { + const mockDataContract = mockDataPlugin.createStartContract(); + + const mockTimefilterConfig = { + timeDefaults: { from: 'now-15m', to: 'now' }, + refreshIntervalDefaults: { pause: false, value: 0 }, + }; + + useAiopsAppContext().data.query.timefilter.timefilter = new Timefilter( + mockTimefilterConfig, + mockDataContract.query.timefilter.history, + // @ts-ignore + mockDataContract.nowProvider + ); + + const { result, rerender } = renderHook(() => useFilterQueryUpdates(), { + wrapper: FilterQueryContextProvider, + }); + + const firstResult = result.current; + + expect(firstResult.timeRange).toEqual({ from: 'now-24h', to: 'now' }); + expect(firstResult.interval).toEqual('30m'); + expect(firstResult.searchBounds.min?.toISOString()).toEqual('2024-02-22T00:00:00.000Z'); + expect(firstResult.searchBounds.max?.toISOString()).toEqual('2024-02-23T00:29:59.999Z'); + + act(() => { + // 30 minutes later... + const nextMockDate = new Date('2024-02-23T00:53:45.000Z'); + jest.setSystemTime(nextMockDate); + + (useReload as jest.MockedFunction).mockReturnValue({ + refreshTimestamp: nextMockDate.getTime(), + }); + + rerender(); + }); + + const secondResult = result.current; + expect(secondResult.timeRange).toEqual({ from: 'now-24h', to: 'now' }); + expect(secondResult.interval).toEqual('30m'); + expect(secondResult.searchBounds.min?.toISOString()).toEqual('2024-02-22T00:30:00.000Z'); + expect(secondResult.searchBounds.max?.toISOString()).toEqual('2024-02-23T00:59:59.999Z'); + }); +}); diff --git a/x-pack/plugins/aiops/tsconfig.json b/x-pack/plugins/aiops/tsconfig.json index 4e2e4bfc8a19f..a8b58134c4174 100644 --- a/x-pack/plugins/aiops/tsconfig.json +++ b/x-pack/plugins/aiops/tsconfig.json @@ -67,6 +67,7 @@ "@kbn/analytics", "@kbn/ml-ui-actions", "@kbn/core-http-server", + "@kbn/core-ui-settings-browser-mocks", ], "exclude": [ "target/**/*",