diff --git a/x-pack/plugins/data_usage/common/test_utils/test_query_client_options.ts b/x-pack/plugins/data_usage/common/test_utils/test_query_client_options.ts new file mode 100644 index 0000000000000..c674e9b342eea --- /dev/null +++ b/x-pack/plugins/data_usage/common/test_utils/test_query_client_options.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ +/* eslint-disable no-console */ +export const dataUsageTestQueryClientOptions = { + defaultOptions: { + queries: { + retry: false, + cacheTime: 0, + }, + }, + logger: { + log: console.log, + warn: console.warn, + error: () => {}, + }, +}; diff --git a/x-pack/plugins/data_usage/public/app/components/charts.tsx b/x-pack/plugins/data_usage/public/app/components/charts.tsx index 8d04324fb2246..56857e7a63ff9 100644 --- a/x-pack/plugins/data_usage/public/app/components/charts.tsx +++ b/x-pack/plugins/data_usage/public/app/components/charts.tsx @@ -9,18 +9,21 @@ import { EuiFlexGroup } from '@elastic/eui'; import { MetricTypes } from '../../../common/rest_types'; import { ChartPanel } from './chart_panel'; import { UsageMetricsResponseSchemaBody } from '../../../common/rest_types'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; interface ChartsProps { data: UsageMetricsResponseSchemaBody; + 'data-test-subj'?: string; } -export const Charts: React.FC = ({ data }) => { +export const Charts: React.FC = ({ data, 'data-test-subj': dataTestSubj }) => { + const getTestId = useTestIdGenerator(dataTestSubj); const [popoverOpen, setPopoverOpen] = useState(null); const togglePopover = useCallback((streamName: string | null) => { setPopoverOpen((prev) => (prev === streamName ? null : streamName)); }, []); return ( - + {Object.entries(data.metrics).map(([metricType, series], idx) => ( { + return { + useBreadcrumbs: jest.fn(), + }; +}); + +jest.mock('../../utils/use_kibana', () => { + return { + useKibanaContextForPlugin: () => ({ + services: mockServices, + }), + }; +}); + +jest.mock('../../hooks/use_get_usage_metrics', () => { + const original = jest.requireActual('../../hooks/use_get_usage_metrics'); + return { + ...original, + useGetDataUsageMetrics: jest.fn(original.useGetDataUsageMetrics), + }; +}); + +const mockUseLocation = jest.fn(() => ({ pathname: '/' })); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => mockUseLocation(), + useHistory: jest.fn().mockReturnValue({ + push: jest.fn(), + listen: jest.fn(), + location: { + search: '', + }, + }), +})); + +jest.mock('../../hooks/use_get_data_streams', () => { + const original = jest.requireActual('../../hooks/use_get_data_streams'); + return { + ...original, + useGetDataUsageDataStreams: jest.fn(original.useGetDataUsageDataStreams), + }; +}); + +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + useKibana: () => ({ + services: { + uiSettings: { + get: jest.fn().mockImplementation((key) => { + const get = (k: 'dateFormat' | 'timepicker:quickRanges') => { + const x = { + dateFormat: 'MMM D, YYYY @ HH:mm:ss.SSS', + 'timepicker:quickRanges': [ + { + from: 'now/d', + to: 'now/d', + display: 'Today', + }, + { + from: 'now/w', + to: 'now/w', + display: 'This week', + }, + { + from: 'now-15m', + to: 'now', + display: 'Last 15 minutes', + }, + { + from: 'now-30m', + to: 'now', + display: 'Last 30 minutes', + }, + { + from: 'now-1h', + to: 'now', + display: 'Last 1 hour', + }, + { + from: 'now-24h', + to: 'now', + display: 'Last 24 hours', + }, + { + from: 'now-7d', + to: 'now', + display: 'Last 7 days', + }, + { + from: 'now-30d', + to: 'now', + display: 'Last 30 days', + }, + { + from: 'now-90d', + to: 'now', + display: 'Last 90 days', + }, + { + from: 'now-1y', + to: 'now', + display: 'Last 1 year', + }, + ], + }; + return x[k]; + }; + return get(key); + }), + }, + }, + }), + }; +}); +const mockUseGetDataUsageMetrics = useGetDataUsageMetrics as jest.Mock; +const mockUseGetDataUsageDataStreams = useGetDataUsageDataStreams as jest.Mock; +const mockServices = mockCore.createStart(); + +const getBaseMockedDataStreams = () => ({ + error: undefined, + data: undefined, + isFetching: false, + refetch: jest.fn(), +}); +const getBaseMockedDataUsageMetrics = () => ({ + error: undefined, + data: undefined, + isFetching: false, + refetch: jest.fn(), +}); + +describe('DataUsageMetrics', () => { + let user: UserEvent; + const testId = 'test'; + const testIdFilter = `${testId}-filter`; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime, pointerEventsCheck: 0 }); + mockUseGetDataUsageMetrics.mockReturnValue(getBaseMockedDataUsageMetrics); + mockUseGetDataUsageDataStreams.mockReturnValue(getBaseMockedDataStreams); + }); + + it('renders', () => { + const { getByTestId } = render(); + expect(getByTestId(`${testId}`)).toBeTruthy(); + }); + + it('should show date filter', () => { + const { getByTestId } = render(); + expect(getByTestId(`${testIdFilter}-date-range`)).toBeTruthy(); + expect(getByTestId(`${testIdFilter}-date-range`).textContent).toContain('Last 24 hours'); + expect(getByTestId(`${testIdFilter}-super-refresh-button`)).toBeTruthy(); + }); + + it('should not show data streams filter while fetching API', () => { + mockUseGetDataUsageDataStreams.mockReturnValue({ + ...getBaseMockedDataStreams, + isFetching: true, + }); + const { queryByTestId } = render(); + expect(queryByTestId(`${testIdFilter}-dataStreams-popoverButton`)).not.toBeTruthy(); + }); + + it('should show data streams filter', () => { + const { getByTestId } = render(); + expect(getByTestId(`${testIdFilter}-dataStreams-popoverButton`)).toBeTruthy(); + }); + + it('should show selected data streams on the filter', () => { + mockUseGetDataUsageDataStreams.mockReturnValue({ + error: undefined, + data: [ + { + name: '.ds-1', + storageSizeBytes: 10000, + }, + { + name: '.ds-2', + storageSizeBytes: 20000, + }, + { + name: '.ds-3', + storageSizeBytes: 10300, + }, + { + name: '.ds-4', + storageSizeBytes: 23000, + }, + { + name: '.ds-5', + storageSizeBytes: 23200, + }, + ], + isFetching: false, + }); + const { getByTestId } = render(); + expect(getByTestId(`${testIdFilter}-dataStreams-popoverButton`)).toHaveTextContent( + 'Data streams5' + ); + }); + + it('should allow de-selecting all but one data stream option', async () => { + mockUseGetDataUsageDataStreams.mockReturnValue({ + error: undefined, + data: [ + { + name: '.ds-1', + storageSizeBytes: 10000, + }, + { + name: '.ds-2', + storageSizeBytes: 20000, + }, + { + name: '.ds-3', + storageSizeBytes: 10300, + }, + { + name: '.ds-4', + storageSizeBytes: 23000, + }, + { + name: '.ds-5', + storageSizeBytes: 23200, + }, + ], + isFetching: false, + }); + const { getByTestId, getAllByTestId } = render(); + expect(getByTestId(`${testIdFilter}-dataStreams-popoverButton`)).toHaveTextContent( + 'Data streams5' + ); + await user.click(getByTestId(`${testIdFilter}-dataStreams-popoverButton`)); + const allFilterOptions = getAllByTestId('dataStreams-filter-option'); + for (let i = 0; i < allFilterOptions.length - 1; i++) { + await user.click(allFilterOptions[i]); + } + + expect(getByTestId(`${testIdFilter}-dataStreams-popoverButton`)).toHaveTextContent( + 'Data streams1' + ); + }); + + it('should not call usage metrics API if no data streams', async () => { + mockUseGetDataUsageDataStreams.mockReturnValue({ + ...getBaseMockedDataStreams, + data: [], + }); + render(); + expect(mockUseGetDataUsageMetrics).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ enabled: false }) + ); + }); + + it('should show charts loading if data usage metrics API is fetching', () => { + mockUseGetDataUsageMetrics.mockReturnValue({ + ...getBaseMockedDataUsageMetrics, + isFetching: true, + }); + const { getByTestId } = render(); + expect(getByTestId(`${testId}-charts-loading`)).toBeTruthy(); + }); + + it('should show charts', () => { + mockUseGetDataUsageMetrics.mockReturnValue({ + ...getBaseMockedDataUsageMetrics, + isFetched: true, + data: { + metrics: { + ingest_rate: [ + { + name: '.ds-1', + data: [{ x: new Date(), y: 1000 }], + }, + { + name: '.ds-10', + data: [{ x: new Date(), y: 1100 }], + }, + ], + storage_retained: [ + { + name: '.ds-2', + data: [{ x: new Date(), y: 2000 }], + }, + { + name: '.ds-20', + data: [{ x: new Date(), y: 2100 }], + }, + ], + }, + }, + }); + const { getByTestId } = render(); + expect(getByTestId(`${testId}-charts`)).toBeTruthy(); + }); + + it('should refetch usage metrics with `Refresh` button click', async () => { + const refetch = jest.fn(); + mockUseGetDataUsageMetrics.mockReturnValue({ + ...getBaseMockedDataUsageMetrics, + data: ['.ds-1', '.ds-2'], + isFetched: true, + }); + mockUseGetDataUsageMetrics.mockReturnValue({ + ...getBaseMockedDataUsageMetrics, + isFetched: true, + refetch, + }); + const { getByTestId } = render(); + const refreshButton = getByTestId(`${testIdFilter}-super-refresh-button`); + // click refresh 5 times + for (let i = 0; i < 5; i++) { + await user.click(refreshButton); + } + + expect(mockUseGetDataUsageMetrics).toHaveBeenLastCalledWith( + expect.any(Object), + expect.objectContaining({ enabled: false }) + ); + expect(refetch).toHaveBeenCalledTimes(5); + }); + + it('should show error toast if usage metrics API fails', async () => { + mockUseGetDataUsageMetrics.mockReturnValue({ + ...getBaseMockedDataUsageMetrics, + isFetched: true, + error: new Error('Uh oh!'), + }); + render(); + await waitFor(() => { + expect(mockServices.notifications.toasts.addDanger).toHaveBeenCalledWith({ + title: 'Error getting usage metrics', + text: 'Uh oh!', + }); + }); + }); + + it('should show error toast if data streams API fails', async () => { + mockUseGetDataUsageDataStreams.mockReturnValue({ + ...getBaseMockedDataStreams, + isFetched: true, + error: new Error('Uh oh!'), + }); + render(); + await waitFor(() => { + expect(mockServices.notifications.toasts.addDanger).toHaveBeenCalledWith({ + title: 'Error getting data streams', + text: 'Uh oh!', + }); + }); + }); +}); diff --git a/x-pack/plugins/data_usage/public/app/components/data_usage_metrics.tsx b/x-pack/plugins/data_usage/public/app/components/data_usage_metrics.tsx index 929ebf7a02490..59354a1746346 100644 --- a/x-pack/plugins/data_usage/public/app/components/data_usage_metrics.tsx +++ b/x-pack/plugins/data_usage/public/app/components/data_usage_metrics.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { css } from '@emotion/react'; import { EuiFlexGroup, EuiFlexItem, EuiLoadingElastic } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -14,11 +14,12 @@ import { useBreadcrumbs } from '../../utils/use_breadcrumbs'; import { useKibanaContextForPlugin } from '../../utils/use_kibana'; import { PLUGIN_NAME } from '../../../common'; import { useGetDataUsageMetrics } from '../../hooks/use_get_usage_metrics'; +import { useGetDataUsageDataStreams } from '../../hooks/use_get_data_streams'; import { useDataUsageMetricsUrlParams } from '../hooks/use_charts_url_params'; import { DEFAULT_DATE_RANGE_OPTIONS, useDateRangePicker } from '../hooks/use_date_picker'; import { DEFAULT_METRIC_TYPES, UsageMetricsRequestBody } from '../../../common/rest_types'; import { ChartFilters, ChartFiltersProps } from './filters/charts_filters'; -import { useGetDataUsageDataStreams } from '../../hooks/use_get_data_streams'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; const EuiItemCss = css` width: 100%; @@ -28,181 +29,188 @@ const FlexItemWithCss = ({ children }: { children: React.ReactNode }) => ( {children} ); -export const DataUsageMetrics = () => { - const { - services: { chrome, appParams, notifications }, - } = useKibanaContextForPlugin(); - useBreadcrumbs([{ text: PLUGIN_NAME }], appParams, chrome); - - const { - metricTypes: metricTypesFromUrl, - dataStreams: dataStreamsFromUrl, - startDate: startDateFromUrl, - endDate: endDateFromUrl, - setUrlMetricTypesFilter, - setUrlDataStreamsFilter, - setUrlDateRangeFilter, - } = useDataUsageMetricsUrlParams(); - - const { - error: errorFetchingDataStreams, - data: dataStreams, - isFetching: isFetchingDataStreams, - } = useGetDataUsageDataStreams({ - selectedDataStreams: dataStreamsFromUrl, - options: { - enabled: true, - retry: false, - }, - }); - - const [metricsFilters, setMetricsFilters] = useState({ - metricTypes: [...DEFAULT_METRIC_TYPES], - dataStreams: [], - from: DEFAULT_DATE_RANGE_OPTIONS.startDate, - to: DEFAULT_DATE_RANGE_OPTIONS.endDate, - }); - - useEffect(() => { - if (!metricTypesFromUrl) { - setUrlMetricTypesFilter(metricsFilters.metricTypes.join(',')); - } - if (!dataStreamsFromUrl && dataStreams) { - setUrlDataStreamsFilter(dataStreams.map((ds) => ds.name).join(',')); - } - if (!startDateFromUrl || !endDateFromUrl) { - setUrlDateRangeFilter({ startDate: metricsFilters.from, endDate: metricsFilters.to }); - } - }, [ - dataStreams, - dataStreamsFromUrl, - endDateFromUrl, - metricTypesFromUrl, - metricsFilters.dataStreams, - metricsFilters.from, - metricsFilters.metricTypes, - metricsFilters.to, - setUrlDataStreamsFilter, - setUrlDateRangeFilter, - setUrlMetricTypesFilter, - startDateFromUrl, - ]); - - useEffect(() => { - setMetricsFilters((prevState) => ({ - ...prevState, - metricTypes: metricTypesFromUrl?.length ? metricTypesFromUrl : prevState.metricTypes, - dataStreams: dataStreamsFromUrl?.length ? dataStreamsFromUrl : prevState.dataStreams, - })); - }, [metricTypesFromUrl, dataStreamsFromUrl]); - - const { dateRangePickerState, onRefreshChange, onTimeChange } = useDateRangePicker(); - - const { - error: errorFetchingDataUsageMetrics, - data, - isFetching, - isFetched, - refetch: refetchDataUsageMetrics, - } = useGetDataUsageMetrics( - { - ...metricsFilters, - from: dateRangePickerState.startDate, - to: dateRangePickerState.endDate, - }, - { - retry: false, - enabled: !!metricsFilters.dataStreams.length, - } - ); - - const onRefresh = useCallback(() => { - refetchDataUsageMetrics(); - }, [refetchDataUsageMetrics]); - - const onChangeDataStreamsFilter = useCallback( - (selectedDataStreams: string[]) => { - setMetricsFilters((prevState) => ({ ...prevState, dataStreams: selectedDataStreams })); - }, - [setMetricsFilters] - ); - - const onChangeMetricTypesFilter = useCallback( - (selectedMetricTypes: string[]) => { - setMetricsFilters((prevState) => ({ ...prevState, metricTypes: selectedMetricTypes })); - }, - [setMetricsFilters] - ); - - const filterOptions: ChartFiltersProps['filterOptions'] = useMemo(() => { - const dataStreamsOptions = dataStreams?.reduce>((acc, ds) => { - acc[ds.name] = ds.storageSizeBytes; - return acc; - }, {}); - - return { - dataStreams: { - filterName: 'dataStreams', - options: dataStreamsOptions ? Object.keys(dataStreamsOptions) : metricsFilters.dataStreams, - appendOptions: dataStreamsOptions, - selectedOptions: metricsFilters.dataStreams, - onChangeFilterOptions: onChangeDataStreamsFilter, - isFilterLoading: isFetchingDataStreams, - }, - metricTypes: { - filterName: 'metricTypes', - options: metricsFilters.metricTypes, - onChangeFilterOptions: onChangeMetricTypesFilter, +export const DataUsageMetrics = memo( + ({ 'data-test-subj': dataTestSubj = 'data-usage-metrics' }: { 'data-test-subj'?: string }) => { + const getTestId = useTestIdGenerator(dataTestSubj); + + const { + services: { chrome, appParams, notifications }, + } = useKibanaContextForPlugin(); + useBreadcrumbs([{ text: PLUGIN_NAME }], appParams, chrome); + + const { + metricTypes: metricTypesFromUrl, + dataStreams: dataStreamsFromUrl, + startDate: startDateFromUrl, + endDate: endDateFromUrl, + setUrlMetricTypesFilter, + setUrlDataStreamsFilter, + setUrlDateRangeFilter, + } = useDataUsageMetricsUrlParams(); + + const { + error: errorFetchingDataStreams, + data: dataStreams, + isFetching: isFetchingDataStreams, + } = useGetDataUsageDataStreams({ + selectedDataStreams: dataStreamsFromUrl, + options: { + enabled: true, + retry: false, }, - }; - }, [ - dataStreams, - isFetchingDataStreams, - metricsFilters.dataStreams, - metricsFilters.metricTypes, - onChangeDataStreamsFilter, - onChangeMetricTypesFilter, - ]); - - if (errorFetchingDataUsageMetrics) { - notifications.toasts.addDanger({ - title: i18n.translate('xpack.dataUsage.getMetrics.addFailure.toast.title', { - defaultMessage: 'Error getting usage metrics', - }), - text: errorFetchingDataUsageMetrics.message, }); - } - if (errorFetchingDataStreams) { - notifications.toasts.addDanger({ - title: i18n.translate('xpack.dataUsage.getDataStreams.addFailure.toast.title', { - defaultMessage: 'Error getting data streams', - }), - text: errorFetchingDataStreams.message, + + const [metricsFilters, setMetricsFilters] = useState({ + metricTypes: [...DEFAULT_METRIC_TYPES], + dataStreams: [], + from: DEFAULT_DATE_RANGE_OPTIONS.startDate, + to: DEFAULT_DATE_RANGE_OPTIONS.endDate, }); - } - return ( - - - - - - - {isFetched && data?.metrics ? ( - - ) : isFetching ? ( - - ) : null} - - - ); -}; + useEffect(() => { + if (!metricTypesFromUrl) { + setUrlMetricTypesFilter(metricsFilters.metricTypes.join(',')); + } + if (!dataStreamsFromUrl && dataStreams) { + setUrlDataStreamsFilter(dataStreams.map((ds) => ds.name).join(',')); + } + if (!startDateFromUrl || !endDateFromUrl) { + setUrlDateRangeFilter({ startDate: metricsFilters.from, endDate: metricsFilters.to }); + } + }, [ + dataStreams, + dataStreamsFromUrl, + endDateFromUrl, + metricTypesFromUrl, + metricsFilters.dataStreams, + metricsFilters.from, + metricsFilters.metricTypes, + metricsFilters.to, + setUrlDataStreamsFilter, + setUrlDateRangeFilter, + setUrlMetricTypesFilter, + startDateFromUrl, + ]); + + useEffect(() => { + setMetricsFilters((prevState) => ({ + ...prevState, + metricTypes: metricTypesFromUrl?.length ? metricTypesFromUrl : prevState.metricTypes, + dataStreams: dataStreamsFromUrl?.length ? dataStreamsFromUrl : prevState.dataStreams, + })); + }, [metricTypesFromUrl, dataStreamsFromUrl]); + + const { dateRangePickerState, onRefreshChange, onTimeChange } = useDateRangePicker(); + + const { + error: errorFetchingDataUsageMetrics, + data, + isFetching, + isFetched, + refetch: refetchDataUsageMetrics, + } = useGetDataUsageMetrics( + { + ...metricsFilters, + from: dateRangePickerState.startDate, + to: dateRangePickerState.endDate, + }, + { + retry: false, + enabled: !!metricsFilters.dataStreams.length, + } + ); + + const onRefresh = useCallback(() => { + refetchDataUsageMetrics(); + }, [refetchDataUsageMetrics]); + + const onChangeDataStreamsFilter = useCallback( + (selectedDataStreams: string[]) => { + setMetricsFilters((prevState) => ({ ...prevState, dataStreams: selectedDataStreams })); + }, + [setMetricsFilters] + ); + + const onChangeMetricTypesFilter = useCallback( + (selectedMetricTypes: string[]) => { + setMetricsFilters((prevState) => ({ ...prevState, metricTypes: selectedMetricTypes })); + }, + [setMetricsFilters] + ); + + const filterOptions: ChartFiltersProps['filterOptions'] = useMemo(() => { + const dataStreamsOptions = dataStreams?.reduce>((acc, ds) => { + acc[ds.name] = ds.storageSizeBytes; + return acc; + }, {}); + + return { + dataStreams: { + filterName: 'dataStreams', + options: dataStreamsOptions + ? Object.keys(dataStreamsOptions) + : metricsFilters.dataStreams, + appendOptions: dataStreamsOptions, + selectedOptions: metricsFilters.dataStreams, + onChangeFilterOptions: onChangeDataStreamsFilter, + isFilterLoading: isFetchingDataStreams, + }, + metricTypes: { + filterName: 'metricTypes', + options: metricsFilters.metricTypes, + onChangeFilterOptions: onChangeMetricTypesFilter, + }, + }; + }, [ + dataStreams, + isFetchingDataStreams, + metricsFilters.dataStreams, + metricsFilters.metricTypes, + onChangeDataStreamsFilter, + onChangeMetricTypesFilter, + ]); + + if (errorFetchingDataUsageMetrics) { + notifications.toasts.addDanger({ + title: i18n.translate('xpack.dataUsage.getMetrics.addFailure.toast.title', { + defaultMessage: 'Error getting usage metrics', + }), + text: errorFetchingDataUsageMetrics.message, + }); + } + if (errorFetchingDataStreams) { + notifications.toasts.addDanger({ + title: i18n.translate('xpack.dataUsage.getDataStreams.addFailure.toast.title', { + defaultMessage: 'Error getting data streams', + }), + text: errorFetchingDataStreams.message, + }); + } + + return ( + + + + + + + {isFetched && data?.metrics ? ( + + ) : isFetching ? ( + + ) : null} + + + ); + } +); diff --git a/x-pack/plugins/data_usage/public/app/components/filters/charts_filter.tsx b/x-pack/plugins/data_usage/public/app/components/filters/charts_filter.tsx index 83d417565f012..6b4806537e74b 100644 --- a/x-pack/plugins/data_usage/public/app/components/filters/charts_filter.tsx +++ b/x-pack/plugins/data_usage/public/app/components/filters/charts_filter.tsx @@ -193,13 +193,10 @@ export const ChartsFilter = memo( > {(list, search) => { return ( -
+
{isSearchable && ( {search} diff --git a/x-pack/plugins/data_usage/public/app/components/filters/charts_filter_popover.tsx b/x-pack/plugins/data_usage/public/app/components/filters/charts_filter_popover.tsx index 2ed96f012c497..3c0237c84a0c9 100644 --- a/x-pack/plugins/data_usage/public/app/components/filters/charts_filter_popover.tsx +++ b/x-pack/plugins/data_usage/public/app/components/filters/charts_filter_popover.tsx @@ -42,7 +42,7 @@ export const ChartsFilterPopover = memo( const button = useMemo( () => ( ( const filters = useMemo(() => { return ( <> - {showMetricsTypesFilter && } + {showMetricsTypesFilter && ( + + )} {!filterOptions.dataStreams.isFilterLoading && ( - + )} ); - }, [filterOptions, showMetricsTypesFilter]); + }, [dataTestSubj, filterOptions, showMetricsTypesFilter]); const onClickRefreshButton = useCallback(() => onClick(), [onClick]); @@ -68,6 +70,7 @@ export const ChartFilters = memo( onRefresh={onRefresh} onRefreshChange={onRefreshChange} onTimeChange={onTimeChange} + data-test-subj={dataTestSubj} /> diff --git a/x-pack/plugins/data_usage/public/app/components/filters/date_picker.tsx b/x-pack/plugins/data_usage/public/app/components/filters/date_picker.tsx index 4d9b280d763ce..044a036eea61f 100644 --- a/x-pack/plugins/data_usage/public/app/components/filters/date_picker.tsx +++ b/x-pack/plugins/data_usage/public/app/components/filters/date_picker.tsx @@ -15,6 +15,7 @@ import type { OnRefreshChangeProps, } from '@elastic/eui/src/components/date_picker/types'; import { UI_SETTINGS } from '@kbn/data-plugin/common'; +import { useTestIdGenerator } from '../../../hooks/use_test_id_generator'; export interface DateRangePickerValues { autoRefreshOptions: { @@ -32,10 +33,19 @@ interface UsageMetricsDateRangePickerProps { onRefresh: () => void; onRefreshChange: (evt: OnRefreshChangeProps) => void; onTimeChange: ({ start, end }: DurationRange) => void; + 'data-test-subj'?: string; } export const UsageMetricsDateRangePicker = memo( - ({ dateRangePickerState, isDataLoading, onRefresh, onRefreshChange, onTimeChange }) => { + ({ + dateRangePickerState, + isDataLoading, + onRefresh, + onRefreshChange, + onTimeChange, + 'data-test-subj': dataTestSubj, + }) => { + const getTestId = useTestIdGenerator(dataTestSubj); const kibana = useKibana(); const { uiSettings } = kibana.services; const [commonlyUsedRanges] = useState(() => { @@ -54,6 +64,7 @@ export const UsageMetricsDateRangePicker = memo { + const getMetricTypesAsArray = (): MetricTypes[] => { + return [...METRIC_TYPE_VALUES]; + }; + + it('should not use invalid `metricTypes` values from URL params', () => { + expect(getDataUsageMetricsFiltersFromUrlParams({ metricTypes: 'bar,foo' })).toEqual({}); + }); + + it('should use valid `metricTypes` values from URL params', () => { + expect( + getDataUsageMetricsFiltersFromUrlParams({ + metricTypes: `${getMetricTypesAsArray().join()},foo,bar`, + }) + ).toEqual({ + metricTypes: getMetricTypesAsArray().sort(), + }); + }); + + it('should use given `dataStreams` values from URL params', () => { + expect( + getDataUsageMetricsFiltersFromUrlParams({ + dataStreams: 'ds-3,ds-1,ds-2', + }) + ).toEqual({ + dataStreams: ['ds-3', 'ds-1', 'ds-2'], + }); + }); + + it('should use valid `metricTypes` along with given `dataStreams` and date values from URL params', () => { + expect( + getDataUsageMetricsFiltersFromUrlParams({ + metricTypes: getMetricTypesAsArray().join(), + dataStreams: 'ds-5,ds-1,ds-2', + startDate: '2022-09-12T08:00:00.000Z', + endDate: '2022-09-12T08:30:33.140Z', + }) + ).toEqual({ + metricTypes: getMetricTypesAsArray().sort(), + endDate: '2022-09-12T08:30:33.140Z', + dataStreams: ['ds-5', 'ds-1', 'ds-2'], + startDate: '2022-09-12T08:00:00.000Z', + }); + }); + + it('should use given relative startDate and endDate values URL params', () => { + expect( + getDataUsageMetricsFiltersFromUrlParams({ + startDate: 'now-24h/h', + endDate: 'now', + }) + ).toEqual({ + endDate: 'now', + startDate: 'now-24h/h', + }); + }); + + it('should use given absolute startDate and endDate values URL params', () => { + expect( + getDataUsageMetricsFiltersFromUrlParams({ + startDate: '2022-09-12T08:00:00.000Z', + endDate: '2022-09-12T08:30:33.140Z', + }) + ).toEqual({ + endDate: '2022-09-12T08:30:33.140Z', + startDate: '2022-09-12T08:00:00.000Z', + }); + }); +}); diff --git a/x-pack/plugins/data_usage/public/hooks/use_get_data_streams.test.tsx b/x-pack/plugins/data_usage/public/hooks/use_get_data_streams.test.tsx new file mode 100644 index 0000000000000..04cee589a523d --- /dev/null +++ b/x-pack/plugins/data_usage/public/hooks/use_get_data_streams.test.tsx @@ -0,0 +1,120 @@ +/* + * 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, { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider, useQuery as _useQuery } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react-hooks'; +import { useGetDataUsageDataStreams } from './use_get_data_streams'; +import { DATA_USAGE_DATA_STREAMS_API_ROUTE } from '../../common'; +import { coreMock as mockCore } from '@kbn/core/public/mocks'; +import { dataUsageTestQueryClientOptions } from '../../common/test_utils/test_query_client_options'; + +const useQueryMock = _useQuery as jest.Mock; + +jest.mock('@tanstack/react-query', () => { + const actualReactQueryModule = jest.requireActual('@tanstack/react-query'); + + return { + ...actualReactQueryModule, + useQuery: jest.fn((...args) => actualReactQueryModule.useQuery(...args)), + }; +}); + +const mockServices = mockCore.createStart(); +const createWrapper = () => { + const queryClient = new QueryClient(dataUsageTestQueryClientOptions); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +}; + +jest.mock('../utils/use_kibana', () => { + return { + useKibanaContextForPlugin: () => ({ + services: mockServices, + }), + }; +}); + +const defaultDataStreamsRequestParams = { + options: { enabled: true }, +}; + +describe('useGetDataUsageDataStreams', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call the correct API', async () => { + await renderHook(() => useGetDataUsageDataStreams(defaultDataStreamsRequestParams), { + wrapper: createWrapper(), + }); + + expect(mockServices.http.get).toHaveBeenCalledWith(DATA_USAGE_DATA_STREAMS_API_ROUTE, { + signal: expect.any(AbortSignal), + version: '1', + }); + }); + + it('should not send selected data stream names provided in the param when calling the API', async () => { + await renderHook( + () => + useGetDataUsageDataStreams({ + ...defaultDataStreamsRequestParams, + selectedDataStreams: ['ds-1'], + }), + { + wrapper: createWrapper(), + } + ); + + expect(mockServices.http.get).toHaveBeenCalledWith(DATA_USAGE_DATA_STREAMS_API_ROUTE, { + signal: expect.any(AbortSignal), + version: '1', + }); + }); + + it('should not call the API if disabled', async () => { + await renderHook( + () => + useGetDataUsageDataStreams({ + ...defaultDataStreamsRequestParams, + options: { enabled: false }, + }), + { + wrapper: createWrapper(), + } + ); + + expect(mockServices.http.get).not.toHaveBeenCalled(); + }); + + it('should allow custom options to be used', async () => { + await renderHook( + () => + useGetDataUsageDataStreams({ + selectedDataStreams: undefined, + options: { + queryKey: ['test-query-key'], + enabled: true, + retry: false, + }, + }), + { + wrapper: createWrapper(), + } + ); + + expect(useQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['test-query-key'], + enabled: true, + retry: false, + }) + ); + }); +}); diff --git a/x-pack/plugins/data_usage/public/hooks/use_get_data_streams.ts b/x-pack/plugins/data_usage/public/hooks/use_get_data_streams.ts index 598acca3c1faf..acb41e45f4eb6 100644 --- a/x-pack/plugins/data_usage/public/hooks/use_get_data_streams.ts +++ b/x-pack/plugins/data_usage/public/hooks/use_get_data_streams.ts @@ -31,15 +31,16 @@ export const useGetDataUsageDataStreams = ({ selectedDataStreams?: string[]; options?: UseQueryOptions; }): UseQueryResult => { - const http = useKibanaContextForPlugin().services.http; + const { http } = useKibanaContextForPlugin().services; return useQuery({ queryKey: ['get-data-usage-data-streams'], ...options, keepPreviousData: true, - queryFn: async () => { + queryFn: async ({ signal }) => { const dataStreamsResponse = await http .get(DATA_USAGE_DATA_STREAMS_API_ROUTE, { + signal, version: '1', }) .catch((error) => { diff --git a/x-pack/plugins/data_usage/public/hooks/use_get_usage_metrics.test.tsx b/x-pack/plugins/data_usage/public/hooks/use_get_usage_metrics.test.tsx new file mode 100644 index 0000000000000..efc3d2a9f4640 --- /dev/null +++ b/x-pack/plugins/data_usage/public/hooks/use_get_usage_metrics.test.tsx @@ -0,0 +1,102 @@ +/* + * 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, { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider, useQuery as _useQuery } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react-hooks'; +import { useGetDataUsageMetrics } from './use_get_usage_metrics'; +import { DATA_USAGE_METRICS_API_ROUTE } from '../../common'; +import { coreMock as mockCore } from '@kbn/core/public/mocks'; +import { dataUsageTestQueryClientOptions } from '../../common/test_utils/test_query_client_options'; + +const useQueryMock = _useQuery as jest.Mock; + +jest.mock('@tanstack/react-query', () => { + const actualReactQueryModule = jest.requireActual('@tanstack/react-query'); + + return { + ...actualReactQueryModule, + useQuery: jest.fn((...args) => actualReactQueryModule.useQuery(...args)), + }; +}); + +const mockServices = mockCore.createStart(); +const createWrapper = () => { + const queryClient = new QueryClient(dataUsageTestQueryClientOptions); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +}; + +jest.mock('../utils/use_kibana', () => { + return { + useKibanaContextForPlugin: () => ({ + services: mockServices, + }), + }; +}); + +const defaultUsageMetricsRequestBody = { + from: 'now-15m', + to: 'now', + metricTypes: ['ingest_rate'], + dataStreams: ['ds-1'], +}; + +describe('useGetDataUsageMetrics', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call the correct API', async () => { + await renderHook( + () => useGetDataUsageMetrics(defaultUsageMetricsRequestBody, { enabled: true }), + { + wrapper: createWrapper(), + } + ); + + expect(mockServices.http.post).toHaveBeenCalledWith(DATA_USAGE_METRICS_API_ROUTE, { + signal: expect.any(AbortSignal), + version: '1', + body: JSON.stringify(defaultUsageMetricsRequestBody), + }); + }); + + it('should not call the API if disabled', async () => { + await renderHook( + () => useGetDataUsageMetrics(defaultUsageMetricsRequestBody, { enabled: false }), + { + wrapper: createWrapper(), + } + ); + + expect(mockServices.http.post).not.toHaveBeenCalled(); + }); + + it('should allow custom options to be used', async () => { + await renderHook( + () => + useGetDataUsageMetrics(defaultUsageMetricsRequestBody, { + queryKey: ['test-query-key'], + enabled: true, + retry: false, + }), + { + wrapper: createWrapper(), + } + ); + + expect(useQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['test-query-key'], + enabled: true, + retry: false, + }) + ); + }); +}); diff --git a/x-pack/plugins/data_usage/public/hooks/use_get_usage_metrics.ts b/x-pack/plugins/data_usage/public/hooks/use_get_usage_metrics.ts index 7e7406d72b9c0..6b2ef5316b0f6 100644 --- a/x-pack/plugins/data_usage/public/hooks/use_get_usage_metrics.ts +++ b/x-pack/plugins/data_usage/public/hooks/use_get_usage_metrics.ts @@ -21,7 +21,7 @@ export const useGetDataUsageMetrics = ( body: UsageMetricsRequestBody, options: UseQueryOptions> = {} ): UseQueryResult> => { - const http = useKibanaContextForPlugin().services.http; + const { http } = useKibanaContextForPlugin().services; return useQuery>({ queryKey: ['get-data-usage-metrics', body], diff --git a/x-pack/plugins/data_usage/public/utils/format_bytes.test.ts b/x-pack/plugins/data_usage/public/utils/format_bytes.test.ts new file mode 100644 index 0000000000000..ccc7a4c2f0aa2 --- /dev/null +++ b/x-pack/plugins/data_usage/public/utils/format_bytes.test.ts @@ -0,0 +1,24 @@ +/* + * 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 { formatBytes } from './format_bytes'; + +const exponentN = (number: number, exponent: number) => number ** exponent; + +describe('formatBytes', () => { + it('should format bytes to human readable format with decimal', () => { + expect(formatBytes(84 + 5)).toBe('89.0 B'); + expect(formatBytes(1024 + 256)).toBe('1.3 KB'); + expect(formatBytes(1024 + 582)).toBe('1.6 KB'); + expect(formatBytes(exponentN(1024, 2) + 582 * 1024)).toBe('1.6 MB'); + expect(formatBytes(exponentN(1024, 3) + 582 * exponentN(1024, 2))).toBe('1.6 GB'); + expect(formatBytes(exponentN(1024, 4) + 582 * exponentN(1024, 3))).toBe('1.6 TB'); + expect(formatBytes(exponentN(1024, 5) + 582 * exponentN(1024, 4))).toBe('1.6 PB'); + expect(formatBytes(exponentN(1024, 6) + 582 * exponentN(1024, 5))).toBe('1.6 EB'); + expect(formatBytes(exponentN(1024, 7) + 582 * exponentN(1024, 6))).toBe('1.6 ZB'); + expect(formatBytes(exponentN(1024, 8) + 582 * exponentN(1024, 7))).toBe('1.6 YB'); + }); +}); diff --git a/x-pack/plugins/data_usage/server/mocks/index.ts b/x-pack/plugins/data_usage/server/mocks/index.ts new file mode 100644 index 0000000000000..54260f7309fc6 --- /dev/null +++ b/x-pack/plugins/data_usage/server/mocks/index.ts @@ -0,0 +1,37 @@ +/* + * 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 { loggingSystemMock } from '@kbn/core/server/mocks'; +import { DeepReadonly } from 'utility-types'; +import { PluginInitializerContext } from '@kbn/core/server'; +import { Observable } from 'rxjs'; +import { DataUsageContext } from '../types'; +import { DataUsageConfigType } from '../config'; + +export interface MockedDataUsageContext extends DataUsageContext { + logFactory: ReturnType['get']>; + config$?: Observable; + configInitialValue: DataUsageConfigType; + serverConfig: DeepReadonly; + kibanaInstanceId: PluginInitializerContext['env']['instanceUuid']; + kibanaVersion: PluginInitializerContext['env']['packageInfo']['version']; + kibanaBranch: PluginInitializerContext['env']['packageInfo']['branch']; +} + +export const createMockedDataUsageContext = ( + context: PluginInitializerContext +): MockedDataUsageContext => { + return { + logFactory: loggingSystemMock.create().get(), + config$: context.config.create(), + configInitialValue: context.config.get(), + serverConfig: context.config.get(), + kibanaInstanceId: context.env.instanceUuid, + kibanaVersion: context.env.packageInfo.version, + kibanaBranch: context.env.packageInfo.branch, + }; +}; diff --git a/x-pack/plugins/data_usage/server/routes/internal/data_streams.test.ts b/x-pack/plugins/data_usage/server/routes/internal/data_streams.test.ts new file mode 100644 index 0000000000000..7282dbc969fc7 --- /dev/null +++ b/x-pack/plugins/data_usage/server/routes/internal/data_streams.test.ts @@ -0,0 +1,124 @@ +/* + * 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 { MockedKeys } from '@kbn/utility-types-jest'; +import type { CoreSetup } from '@kbn/core/server'; +import { registerDataStreamsRoute } from './data_streams'; +import { coreMock } from '@kbn/core/server/mocks'; +import { httpServerMock } from '@kbn/core/server/mocks'; +import { DataUsageService } from '../../services'; +import type { + DataUsageRequestHandlerContext, + DataUsageRouter, + DataUsageServerStart, +} from '../../types'; +import { DATA_USAGE_DATA_STREAMS_API_ROUTE } from '../../../common'; +import { createMockedDataUsageContext } from '../../mocks'; +import { getMeteringStats } from '../../utils/get_metering_stats'; +import { CustomHttpRequestError } from '../../utils'; + +jest.mock('../../utils/get_metering_stats'); +const mockGetMeteringStats = getMeteringStats as jest.Mock; + +describe('registerDataStreamsRoute', () => { + let mockCore: MockedKeys>; + let router: DataUsageRouter; + let dataUsageService: DataUsageService; + let context: DataUsageRequestHandlerContext; + + beforeEach(() => { + mockCore = coreMock.createSetup(); + router = mockCore.http.createRouter(); + context = coreMock.createCustomRequestHandlerContext( + coreMock.createRequestHandlerContext() + ) as unknown as DataUsageRequestHandlerContext; + + const mockedDataUsageContext = createMockedDataUsageContext( + coreMock.createPluginInitializerContext() + ); + dataUsageService = new DataUsageService(mockedDataUsageContext); + registerDataStreamsRoute(router, dataUsageService); + }); + + it('should request correct API', () => { + expect(router.versioned.get).toHaveBeenCalledTimes(1); + expect(router.versioned.get).toHaveBeenCalledWith({ + access: 'internal', + path: DATA_USAGE_DATA_STREAMS_API_ROUTE, + }); + }); + + it('should correctly sort response', async () => { + mockGetMeteringStats.mockResolvedValue({ + datastreams: [ + { + name: 'datastream1', + size_in_bytes: 100, + }, + { + name: 'datastream2', + size_in_bytes: 200, + }, + ], + }); + const mockRequest = httpServerMock.createKibanaRequest({ body: {} }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockRouter = mockCore.http.createRouter.mock.results[0].value; + const [[, handler]] = mockRouter.versioned.get.mock.results[0].value.addVersion.mock.calls; + await handler(context, mockRequest, mockResponse); + + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + expect(mockResponse.ok.mock.calls[0][0]).toEqual({ + body: [ + { + name: 'datastream2', + storageSizeBytes: 200, + }, + { + name: 'datastream1', + storageSizeBytes: 100, + }, + ], + }); + }); + + it('should return correct error if metering stats request fails', async () => { + // using custom error for test here to avoid having to import the actual error class + mockGetMeteringStats.mockRejectedValue( + new CustomHttpRequestError('Error getting metring stats!') + ); + const mockRequest = httpServerMock.createKibanaRequest({ body: {} }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockRouter = mockCore.http.createRouter.mock.results[0].value; + const [[, handler]] = mockRouter.versioned.get.mock.results[0].value.addVersion.mock.calls; + await handler(context, mockRequest, mockResponse); + + expect(mockResponse.customError).toHaveBeenCalledTimes(1); + expect(mockResponse.customError).toHaveBeenCalledWith({ + body: new CustomHttpRequestError('Error getting metring stats!'), + statusCode: 500, + }); + }); + + it.each([ + ['no datastreams', {}, []], + ['empty array', { datastreams: [] }, []], + ['an empty element', { datastreams: [{}] }, [{ name: undefined, storageSizeBytes: 0 }]], + ])('should return empty array when no stats data with %s', async (_, stats, res) => { + mockGetMeteringStats.mockResolvedValue(stats); + const mockRequest = httpServerMock.createKibanaRequest({ body: {} }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockRouter = mockCore.http.createRouter.mock.results[0].value; + const [[, handler]] = mockRouter.versioned.get.mock.results[0].value.addVersion.mock.calls; + await handler(context, mockRequest, mockResponse); + + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + expect(mockResponse.ok.mock.calls[0][0]).toEqual({ + body: res, + }); + }); +}); diff --git a/x-pack/plugins/data_usage/server/routes/internal/data_streams_handler.ts b/x-pack/plugins/data_usage/server/routes/internal/data_streams_handler.ts index bc8c5e898c35e..66c2cc0df3513 100644 --- a/x-pack/plugins/data_usage/server/routes/internal/data_streams_handler.ts +++ b/x-pack/plugins/data_usage/server/routes/internal/data_streams_handler.ts @@ -5,27 +5,11 @@ * 2.0. */ -import { type ElasticsearchClient, RequestHandler } from '@kbn/core/server'; +import { RequestHandler } from '@kbn/core/server'; import { DataUsageRequestHandlerContext } from '../../types'; import { errorHandler } from '../error_handler'; import { DataUsageService } from '../../services'; - -export interface MeteringStats { - name: string; - num_docs: number; - size_in_bytes: number; -} - -interface MeteringStatsResponse { - datastreams: MeteringStats[]; -} - -const getMeteringStats = (client: ElasticsearchClient) => { - return client.transport.request({ - method: 'GET', - path: '/_metering/stats', - }); -}; +import { getMeteringStats } from '../../utils/get_metering_stats'; export const getDataStreamsHandler = ( dataUsageService: DataUsageService @@ -41,12 +25,15 @@ export const getDataStreamsHandler = ( core.elasticsearch.client.asSecondaryAuthUser ); - const body = meteringStats - .sort((a, b) => b.size_in_bytes - a.size_in_bytes) - .map((stat) => ({ - name: stat.name, - storageSizeBytes: stat.size_in_bytes ?? 0, - })); + const body = + meteringStats && !!meteringStats.length + ? meteringStats + .sort((a, b) => b.size_in_bytes - a.size_in_bytes) + .map((stat) => ({ + name: stat.name, + storageSizeBytes: stat.size_in_bytes ?? 0, + })) + : []; return response.ok({ body, diff --git a/x-pack/plugins/data_usage/server/routes/internal/usage_metrics.test.ts b/x-pack/plugins/data_usage/server/routes/internal/usage_metrics.test.ts new file mode 100644 index 0000000000000..e95ffd11807a9 --- /dev/null +++ b/x-pack/plugins/data_usage/server/routes/internal/usage_metrics.test.ts @@ -0,0 +1,208 @@ +/* + * 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 { MockedKeys } from '@kbn/utility-types-jest'; +import type { CoreSetup } from '@kbn/core/server'; +import { registerUsageMetricsRoute } from './usage_metrics'; +import { coreMock } from '@kbn/core/server/mocks'; +import { httpServerMock } from '@kbn/core/server/mocks'; +import { DataUsageService } from '../../services'; +import type { + DataUsageRequestHandlerContext, + DataUsageRouter, + DataUsageServerStart, +} from '../../types'; +import { DATA_USAGE_METRICS_API_ROUTE } from '../../../common'; +import { createMockedDataUsageContext } from '../../mocks'; +import { CustomHttpRequestError } from '../../utils'; +import { AutoOpsError } from '../../services/errors'; + +describe('registerUsageMetricsRoute', () => { + let mockCore: MockedKeys>; + let router: DataUsageRouter; + let dataUsageService: DataUsageService; + let context: DataUsageRequestHandlerContext; + + beforeEach(() => { + mockCore = coreMock.createSetup(); + router = mockCore.http.createRouter(); + context = coreMock.createCustomRequestHandlerContext( + coreMock.createRequestHandlerContext() + ) as unknown as DataUsageRequestHandlerContext; + + const mockedDataUsageContext = createMockedDataUsageContext( + coreMock.createPluginInitializerContext() + ); + dataUsageService = new DataUsageService(mockedDataUsageContext); + }); + + it('should request correct API', () => { + registerUsageMetricsRoute(router, dataUsageService); + + expect(router.versioned.post).toHaveBeenCalledTimes(1); + expect(router.versioned.post).toHaveBeenCalledWith({ + access: 'internal', + path: DATA_USAGE_METRICS_API_ROUTE, + }); + }); + + it('should throw error if no data streams in the request', async () => { + registerUsageMetricsRoute(router, dataUsageService); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { + from: 'now-15m', + to: 'now', + metricTypes: ['ingest_rate'], + dataStreams: [], + }, + }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockRouter = mockCore.http.createRouter.mock.results[0].value; + const [[, handler]] = mockRouter.versioned.post.mock.results[0].value.addVersion.mock.calls; + await handler(context, mockRequest, mockResponse); + + expect(mockResponse.customError).toHaveBeenCalledTimes(1); + expect(mockResponse.customError).toHaveBeenCalledWith({ + body: new CustomHttpRequestError('[request body.dataStreams]: no data streams selected'), + statusCode: 400, + }); + }); + + it('should correctly transform response', async () => { + (await context.core).elasticsearch.client.asCurrentUser.indices.getDataStream = jest + .fn() + .mockResolvedValue({ + data_streams: [{ name: '.ds-1' }, { name: '.ds-2' }], + }); + + dataUsageService.getMetrics = jest.fn().mockResolvedValue({ + metrics: { + ingest_rate: [ + { + name: '.ds-1', + data: [ + [1726858530000, 13756849], + [1726862130000, 14657904], + ], + }, + { + name: '.ds-2', + data: [ + [1726858530000, 12894623], + [1726862130000, 14436905], + ], + }, + ], + storage_retained: [ + { + name: '.ds-1', + data: [ + [1726858530000, 12576413], + [1726862130000, 13956423], + ], + }, + { + name: '.ds-2', + data: [ + [1726858530000, 12894623], + [1726862130000, 14436905], + ], + }, + ], + }, + }); + + registerUsageMetricsRoute(router, dataUsageService); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { + from: 'now-15m', + to: 'now', + metricTypes: ['ingest_rate', 'storage_retained'], + dataStreams: ['.ds-1', '.ds-2'], + }, + }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockRouter = mockCore.http.createRouter.mock.results[0].value; + const [[, handler]] = mockRouter.versioned.post.mock.results[0].value.addVersion.mock.calls; + await handler(context, mockRequest, mockResponse); + + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + expect(mockResponse.ok.mock.calls[0][0]).toEqual({ + body: { + metrics: { + ingest_rate: [ + { + name: '.ds-1', + data: [ + { x: 1726858530000, y: 13756849 }, + { x: 1726862130000, y: 14657904 }, + ], + }, + { + name: '.ds-2', + data: [ + { x: 1726858530000, y: 12894623 }, + { x: 1726862130000, y: 14436905 }, + ], + }, + ], + storage_retained: [ + { + name: '.ds-1', + data: [ + { x: 1726858530000, y: 12576413 }, + { x: 1726862130000, y: 13956423 }, + ], + }, + { + name: '.ds-2', + data: [ + { x: 1726858530000, y: 12894623 }, + { x: 1726862130000, y: 14436905 }, + ], + }, + ], + }, + }, + }); + }); + + it('should throw error if error on requesting auto ops service', async () => { + (await context.core).elasticsearch.client.asCurrentUser.indices.getDataStream = jest + .fn() + .mockResolvedValue({ + data_streams: [{ name: '.ds-1' }, { name: '.ds-2' }], + }); + + dataUsageService.getMetrics = jest + .fn() + .mockRejectedValue(new AutoOpsError('Uh oh, something went wrong!')); + + registerUsageMetricsRoute(router, dataUsageService); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { + from: 'now-15m', + to: 'now', + metricTypes: ['ingest_rate'], + dataStreams: ['.ds-1', '.ds-2'], + }, + }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockRouter = mockCore.http.createRouter.mock.results[0].value; + const [[, handler]] = mockRouter.versioned.post.mock.results[0].value.addVersion.mock.calls; + await handler(context, mockRequest, mockResponse); + + expect(mockResponse.customError).toHaveBeenCalledTimes(1); + expect(mockResponse.customError).toHaveBeenCalledWith({ + body: new AutoOpsError('Uh oh, something went wrong!'), + statusCode: 503, + }); + }); +}); diff --git a/x-pack/plugins/data_usage/server/utils/get_metering_stats.ts b/x-pack/plugins/data_usage/server/utils/get_metering_stats.ts new file mode 100644 index 0000000000000..4ba30f5bd3601 --- /dev/null +++ b/x-pack/plugins/data_usage/server/utils/get_metering_stats.ts @@ -0,0 +1,24 @@ +/* + * 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 ElasticsearchClient } from '@kbn/core/server'; + +export interface MeteringStats { + name: string; + num_docs: number; + size_in_bytes: number; +} + +interface MeteringStatsResponse { + datastreams: MeteringStats[]; +} + +export const getMeteringStats = (client: ElasticsearchClient) => { + return client.transport.request({ + method: 'GET', + path: '/_metering/stats', + }); +}; diff --git a/x-pack/plugins/data_usage/tsconfig.json b/x-pack/plugins/data_usage/tsconfig.json index 78c501922f239..66c8a5247858b 100644 --- a/x-pack/plugins/data_usage/tsconfig.json +++ b/x-pack/plugins/data_usage/tsconfig.json @@ -31,6 +31,7 @@ "@kbn/repo-info", "@kbn/cloud-plugin", "@kbn/server-http-tools", + "@kbn/utility-types-jest", ], "exclude": ["target/**/*"] }