From c14cce61dfeac6db0559ac1be47bcd2e6a719084 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Fri, 13 Dec 2024 10:14:27 +1100 Subject: [PATCH] [8.x] [Serverless][DataUsage] Data usage UX/API updates (#203465) (#204136) # Backport This will backport the following commits from `main` to `8.x`: - [[Serverless][DataUsage] Data usage UX/API updates (#203465)](https://github.com/elastic/kibana/pull/203465) ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) Co-authored-by: Ash <1849116+ashokaditya@users.noreply.github.com> --- .../common/rest_types/usage_metrics.ts | 4 +- .../private/data_usage/common/utils.test.ts | 5 +- .../private/data_usage/common/utils.ts | 3 +- .../components/data_usage_metrics.test.tsx | 70 +------ .../app/components/data_usage_metrics.tsx | 29 +-- .../components/filters/charts_filter.test.tsx | 124 ++++++++++++ .../app/components/filters/charts_filter.tsx | 39 +++- .../filters/charts_filters.test.tsx | 180 ++++++++++++++++++ .../app/components/filters/charts_filters.tsx | 44 +++-- .../app/components/filters/date_picker.tsx | 2 +- .../components/filters/toggle_all_button.tsx | 1 - .../public/app/components/page.test.tsx | 50 +++++ .../data_usage/public/app/components/page.tsx | 28 ++- .../app/data_usage_metrics_page.test.tsx | 111 +++++++++++ .../public/app/data_usage_metrics_page.tsx | 32 ++-- .../public/app/hooks/use_charts_filter.tsx | 106 +++++++---- .../public/app/hooks/use_date_picker.tsx | 15 +- .../private/data_usage/public/app/mocks.ts | 65 +++++++ .../public/hooks/use_get_data_streams.ts | 68 +++---- .../data_usage/public/translations.tsx | 18 +- .../routes/internal/data_streams.test.ts | 65 ++++++- .../routes/internal/data_streams_handler.ts | 45 +++-- .../functional/page_objects/svl_data_usage.ts | 2 +- .../common/data_usage/privileges.ts | 4 +- 24 files changed, 878 insertions(+), 232 deletions(-) create mode 100644 x-pack/platform/plugins/private/data_usage/public/app/components/filters/charts_filter.test.tsx create mode 100644 x-pack/platform/plugins/private/data_usage/public/app/components/filters/charts_filters.test.tsx create mode 100644 x-pack/platform/plugins/private/data_usage/public/app/components/page.test.tsx create mode 100644 x-pack/platform/plugins/private/data_usage/public/app/data_usage_metrics_page.test.tsx create mode 100644 x-pack/platform/plugins/private/data_usage/public/app/mocks.ts diff --git a/x-pack/platform/plugins/private/data_usage/common/rest_types/usage_metrics.ts b/x-pack/platform/plugins/private/data_usage/common/rest_types/usage_metrics.ts index 07130c84b6fdf..3a53a141caf9d 100644 --- a/x-pack/platform/plugins/private/data_usage/common/rest_types/usage_metrics.ts +++ b/x-pack/platform/plugins/private/data_usage/common/rest_types/usage_metrics.ts @@ -28,8 +28,8 @@ export const isDefaultMetricType = (metricType: string) => DEFAULT_METRIC_TYPES.includes(metricType); export const METRIC_TYPE_API_VALUES_TO_UI_OPTIONS_MAP = Object.freeze>({ - storage_retained: 'Data Retained in Storage', ingest_rate: 'Data Ingested', + storage_retained: 'Data Retained in Storage', search_vcu: 'Search VCU', ingest_vcu: 'Ingest VCU', ml_vcu: 'ML VCU', @@ -40,8 +40,8 @@ export const METRIC_TYPE_API_VALUES_TO_UI_OPTIONS_MAP = Object.freeze>({ - 'Data Retained in Storage': 'storage_retained', 'Data Ingested': 'ingest_rate', + 'Data Retained in Storage': 'storage_retained', 'Search VCU': 'search_vcu', 'Ingest VCU': 'ingest_vcu', 'ML VCU': 'ml_vcu', diff --git a/x-pack/platform/plugins/private/data_usage/common/utils.test.ts b/x-pack/platform/plugins/private/data_usage/common/utils.test.ts index fc6b158c1caa0..c7ff57069e44e 100644 --- a/x-pack/platform/plugins/private/data_usage/common/utils.test.ts +++ b/x-pack/platform/plugins/private/data_usage/common/utils.test.ts @@ -10,7 +10,6 @@ import { isDateRangeValid } from './utils'; describe('isDateRangeValid', () => { describe('Valid ranges', () => { it.each([ - ['both start and end date is `now`', { start: 'now', end: 'now' }], ['start date is `now-10s` and end date is `now`', { start: 'now-10s', end: 'now' }], ['bounded within the min and max date range', { start: 'now-8d', end: 'now-4s' }], ])('should return true if %s', (_, { start, end }) => { @@ -20,8 +19,10 @@ describe('isDateRangeValid', () => { describe('Invalid ranges', () => { it.each([ + ['both start and end date is `now`', { start: 'now', end: 'now' }], ['starts before the min date', { start: 'now-11d', end: 'now-5s' }], - ['ends after the max date', { start: 'now-9d', end: 'now+2s' }], + ['ends after the max date in seconds', { start: 'now-9d', end: 'now+2s' }], + ['ends after the max date in days', { start: 'now-6d', end: 'now+6d' }], [ 'end date is before the start date even when both are within min and max date range', { start: 'now-3s', end: 'now-10s' }, diff --git a/x-pack/platform/plugins/private/data_usage/common/utils.ts b/x-pack/platform/plugins/private/data_usage/common/utils.ts index 3fd7240153d4d..2fe683dc8310d 100644 --- a/x-pack/platform/plugins/private/data_usage/common/utils.ts +++ b/x-pack/platform/plugins/private/data_usage/common/utils.ts @@ -19,6 +19,7 @@ export const DEFAULT_DATE_RANGE_OPTIONS = Object.freeze({ recentlyUsedDateRanges: [], }); +export type ParsedDate = ReturnType; export const momentDateParser = (date: string) => dateMath.parse(date); export const transformToUTCtime = ({ start, @@ -50,6 +51,6 @@ export const isDateRangeValid = ({ start, end }: { start: string; end: string }) return ( startDate.isSameOrAfter(minDate, 's') && endDate.isSameOrBefore(maxDate, 's') && - startDate <= endDate + startDate.isBefore(endDate, 's') ); }; diff --git a/x-pack/platform/plugins/private/data_usage/public/app/components/data_usage_metrics.test.tsx b/x-pack/platform/plugins/private/data_usage/public/app/components/data_usage_metrics.test.tsx index befae95393e1c..90257e08ead01 100644 --- a/x-pack/platform/plugins/private/data_usage/public/app/components/data_usage_metrics.test.tsx +++ b/x-pack/platform/plugins/private/data_usage/public/app/components/data_usage_metrics.test.tsx @@ -12,6 +12,7 @@ import { DataUsageMetrics } from './data_usage_metrics'; import { useGetDataUsageMetrics } from '../../hooks/use_get_usage_metrics'; import { useGetDataUsageDataStreams } from '../../hooks/use_get_data_streams'; import { coreMock as mockCore } from '@kbn/core/public/mocks'; +import { mockUseKibana, generateDataStreams } from '../mocks'; jest.mock('../../utils/use_breadcrumbs', () => { return { @@ -60,60 +61,10 @@ 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', - }, - ], - }; - return x[k]; - }; - return get(key); - }), - }, - }, - }), + useKibana: () => mockUseKibana, }; }); + const mockUseGetDataUsageMetrics = useGetDataUsageMetrics as jest.Mock; const mockUseGetDataUsageDataStreams = useGetDataUsageDataStreams as jest.Mock; const mockServices = mockCore.createStart(); @@ -131,13 +82,6 @@ const getBaseMockedDataUsageMetrics = () => ({ refetch: jest.fn(), }); -const generateDataStreams = (count: number) => { - return Array.from({ length: count }, (_, i) => ({ - name: `.ds-${i}`, - storageSizeBytes: 1024 ** 2 * (22 / 7), - })); -}; - describe('DataUsageMetrics', () => { let user: UserEvent; const testId = 'test'; @@ -228,14 +172,14 @@ describe('DataUsageMetrics', () => { expect(toggleFilterButton).toHaveTextContent('Data streams10'); await user.click(toggleFilterButton); const allFilterOptions = getAllByTestId('dataStreams-filter-option'); - // deselect 9 options - for (let i = 0; i < allFilterOptions.length; i++) { + // deselect 3 options + for (let i = 0; i < 3; i++) { await user.click(allFilterOptions[i]); } - expect(toggleFilterButton).toHaveTextContent('Data streams1'); + expect(toggleFilterButton).toHaveTextContent('Data streams7'); expect(within(toggleFilterButton).getByRole('marquee').getAttribute('aria-label')).toEqual( - '1 active filters' + '7 active filters' ); }); diff --git a/x-pack/platform/plugins/private/data_usage/public/app/components/data_usage_metrics.tsx b/x-pack/platform/plugins/private/data_usage/public/app/components/data_usage_metrics.tsx index efaa779dfe3c9..829b198e59ab3 100644 --- a/x-pack/platform/plugins/private/data_usage/public/app/components/data_usage_metrics.tsx +++ b/x-pack/platform/plugins/private/data_usage/public/app/components/data_usage_metrics.tsx @@ -17,13 +17,9 @@ import { PLUGIN_NAME } from '../../translations'; 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, - transformToUTCtime, - isDateRangeValid, -} from '../../../common/utils'; +import { DEFAULT_DATE_RANGE_OPTIONS, transformToUTCtime } from '../../../common/utils'; import { useDateRangePicker } from '../hooks/use_date_picker'; -import { ChartFilters, ChartFiltersProps } from './filters/charts_filters'; +import { ChartsFilters, ChartsFiltersProps } from './filters/charts_filters'; import { ChartsLoading } from './charts_loading'; import { NoDataCallout } from './no_data_callout'; import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; @@ -114,16 +110,8 @@ export const DataUsageMetrics = memo( })); }, [metricTypesFromUrl, dataStreamsFromUrl, startDateFromUrl, endDateFromUrl]); - const { dateRangePickerState, onRefreshChange, onTimeChange } = useDateRangePicker(); - - const isValidDateRange = useMemo( - () => - isDateRangeValid({ - start: dateRangePickerState.startDate, - end: dateRangePickerState.endDate, - }), - [dateRangePickerState.endDate, dateRangePickerState.startDate] - ); + const { dateRangePickerState, isValidDateRange, onRefreshChange, onTimeChange } = + useDateRangePicker(); const enableFetchUsageMetricsData = useMemo( () => @@ -187,8 +175,10 @@ export const DataUsageMetrics = memo( [setMetricsFilters] ); - const filterOptions: ChartFiltersProps['filterOptions'] = useMemo(() => { - const dataStreamsOptions = dataStreams?.reduce>((acc, ds) => { + const filterOptions: ChartsFiltersProps['filterOptions'] = useMemo(() => { + const dataStreamsOptions = dataStreams?.reduce< + Required['appendOptions'] + >((acc, ds) => { acc[ds.name] = ds.storageSizeBytes; return acc; }, {}); @@ -239,10 +229,11 @@ export const DataUsageMetrics = memo( return ( - ({ 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('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + useKibana: () => mockUseKibana, + }; +}); + +describe('Charts Filters', () => { + let user: UserEvent; + const testId = 'test'; + const testIdFilter = `${testId}-filter`; + + const defaultProps = { + filterOptions: { + filterName: 'dataStreams' as FilterName, + isFilterLoading: false, + appendOptions: {}, + selectedOptions: [], + options: generateDataStreams(8).map((ds) => ds.name), + onChangeFilterOptions: jest.fn(), + }, + }; + + let renderComponent: (props: ChartsFilterProps) => RenderResult; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + renderComponent = (props: ChartsFilterProps) => + render( + + + + ); + user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime, pointerEventsCheck: 0 }); + }); + + it('renders data streams filter with all options selected', async () => { + const { getByTestId, getAllByTestId } = renderComponent(defaultProps); + expect(getByTestId(`${testIdFilter}-dataStreams-popoverButton`)).toBeTruthy(); + + const filterButton = getByTestId(`${testIdFilter}-dataStreams-popoverButton`); + expect(filterButton).toBeTruthy(); + await user.click(filterButton); + const allFilterOptions = getAllByTestId('dataStreams-filter-option'); + + // checked options + const checkedOptions = allFilterOptions.filter( + (option) => option.getAttribute('aria-checked') === 'true' + ); + expect(checkedOptions).toHaveLength(8); + }); + + it('renders data streams filter with 50 options selected when more than 50 items in the filter', async () => { + const { getByTestId } = renderComponent({ + ...defaultProps, + filterOptions: { + ...defaultProps.filterOptions, + options: generateDataStreams(55).map((ds) => ds.name), + }, + }); + expect(getByTestId(`${testIdFilter}-dataStreams-popoverButton`)).toBeTruthy(); + + const toggleFilterButton = getByTestId(`${testIdFilter}-dataStreams-popoverButton`); + expect(toggleFilterButton).toBeTruthy(); + expect(toggleFilterButton).toHaveTextContent('Data streams50'); + expect( + toggleFilterButton.querySelector('.euiNotificationBadge')?.getAttribute('aria-label') + ).toBe('50 active filters'); + }); + + it('renders data streams filter with no options selected and select all is disabled', async () => { + const { getByTestId, queryByTestId } = renderComponent({ + ...defaultProps, + filterOptions: { + ...defaultProps.filterOptions, + options: [], + }, + }); + expect(getByTestId(`${testIdFilter}-dataStreams-popoverButton`)).toBeTruthy(); + + const filterButton = getByTestId(`${testIdFilter}-dataStreams-popoverButton`); + expect(filterButton).toBeTruthy(); + await user.click(filterButton); + expect(queryByTestId('dataStreams-filter-option')).toBeFalsy(); + expect(getByTestId('dataStreams-group-label')).toBeTruthy(); + expect(getByTestId(`${testIdFilter}-dataStreams-selectAllButton`)).toBeDisabled(); + }); +}); diff --git a/x-pack/platform/plugins/private/data_usage/public/app/components/filters/charts_filter.tsx b/x-pack/platform/plugins/private/data_usage/public/app/components/filters/charts_filter.tsx index 2e60561f3ed29..e041b262a7f44 100644 --- a/x-pack/platform/plugins/private/data_usage/public/app/components/filters/charts_filter.tsx +++ b/x-pack/platform/plugins/private/data_usage/public/app/components/filters/charts_filter.tsx @@ -81,6 +81,11 @@ export const ChartsFilter = memo( }, }); + const isSelectAllDisabled = useMemo( + () => options.length === 0 || (hasActiveFilters && numFilters === 0), + [hasActiveFilters, numFilters, options.length] + ); + const addHeightToPopover = useMemo( () => isDataStreamsFilter && numFilters + numActiveFilters > 15, [isDataStreamsFilter, numFilters, numActiveFilters] @@ -107,7 +112,12 @@ export const ChartsFilter = memo( const sortedDataStreamsFilterOptions = useMemo(() => { if (shouldPinSelectedDataStreams() || areDataStreamsSelectedOnMount) { // pin checked items to the top - return orderBy('checked', 'asc', items); + const sorted = orderBy( + 'checked', + 'asc', + items.filter((item) => !item.isGroupLabel) + ); + return [...items.filter((item) => item.isGroupLabel), ...sorted]; } // return options as are for other filters return items; @@ -155,14 +165,25 @@ export const ChartsFilter = memo( ); const onSelectAll = useCallback(() => { - const allItems: FilterItems = items.map((item) => { - return { - ...item, - checked: 'on', - }; - }); + const allItems: FilterItems = items.reduce((acc, item) => { + if (!item.isGroupLabel) { + acc.push({ + ...item, + checked: 'on', + }); + } else { + acc.push(item); + } + return acc; + }, []); + setItems(allItems); - const optionsToSelect = allItems.map((i) => i.label); + const optionsToSelect = allItems.reduce((acc, i) => { + if (i.checked) { + acc.push(i.label); + } + return acc; + }, []); onChangeFilterOptions(optionsToSelect); if (isDataStreamsFilter) { @@ -260,7 +281,7 @@ export const ChartsFilter = memo( data-test-subj={getTestId(`${filterName}-selectAllButton`)} icon="check" label={UX_LABELS.filterSelectAll} - isDisabled={hasActiveFilters && numFilters === 0} + isDisabled={isSelectAllDisabled} onClick={onSelectAll} /> diff --git a/x-pack/platform/plugins/private/data_usage/public/app/components/filters/charts_filters.test.tsx b/x-pack/platform/plugins/private/data_usage/public/app/components/filters/charts_filters.test.tsx new file mode 100644 index 0000000000000..d4196abeaa268 --- /dev/null +++ b/x-pack/platform/plugins/private/data_usage/public/app/components/filters/charts_filters.test.tsx @@ -0,0 +1,180 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { TestProvider } from '../../../../common/test_utils'; +import { render, type RenderResult } from '@testing-library/react'; +import userEvent, { type UserEvent } from '@testing-library/user-event'; +import { ChartsFilters, type ChartsFiltersProps } from './charts_filters'; +import { FilterName } from '../../hooks'; +import { mockUseKibana } from '../../mocks'; +import { + METRIC_TYPE_VALUES, + METRIC_TYPE_UI_OPTIONS_VALUES_TO_API_MAP, +} from '../../../../common/rest_types/usage_metrics'; + +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('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + useKibana: () => mockUseKibana, + }; +}); + +describe('Charts Filters', () => { + let user: UserEvent; + const testId = 'test'; + const testIdFilter = `${testId}-filter`; + const onClick = jest.fn(); + const dateRangePickerState = { + startDate: 'now-15m', + endDate: 'now', + recentlyUsedDateRanges: [], + autoRefreshOptions: { + enabled: false, + duration: 0, + }, + }; + const defaultProps = { + dateRangePickerState, + isDataLoading: false, + isUpdateDisabled: false, + isValidDateRange: true, + filterOptions: { + dataStreams: { + filterName: 'dataStreams' as FilterName, + isFilterLoading: false, + options: ['.ds-1', '.ds-2'], + onChangeFilterOptions: jest.fn(), + }, + metricTypes: { + filterName: 'metricTypes' as FilterName, + isFilterLoading: false, + options: METRIC_TYPE_VALUES.slice(), + onChangeFilterOptions: jest.fn(), + }, + }, + onClick, + onRefresh: jest.fn(), + onRefreshChange: jest.fn(), + onTimeChange: jest.fn(), + }; + + let renderComponent: (props: ChartsFiltersProps) => RenderResult; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + renderComponent = (props: ChartsFiltersProps) => + render( + + + + ); + user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime, pointerEventsCheck: 0 }); + }); + + it('renders data streams filter, date range filter and refresh button', () => { + const { getByTestId } = renderComponent(defaultProps); + expect(getByTestId(`${testIdFilter}-dataStreams-popoverButton`)).toBeTruthy(); + expect(getByTestId(`${testIdFilter}-date-range`)).toBeTruthy(); + expect(getByTestId(`${testIdFilter}-super-refresh-button`)).toBeTruthy(); + }); + + it('renders metric filter', () => { + const { getByTestId } = renderComponent({ ...defaultProps, showMetricsTypesFilter: true }); + expect(getByTestId(`${testIdFilter}-metricTypes-popoverButton`)).toBeTruthy(); + expect(getByTestId(`${testIdFilter}-dataStreams-popoverButton`)).toBeTruthy(); + expect(getByTestId(`${testIdFilter}-date-range`)).toBeTruthy(); + expect(getByTestId(`${testIdFilter}-super-refresh-button`)).toBeTruthy(); + }); + + it('has default metrics selected if showing metrics filter', async () => { + const { getByTestId, getAllByTestId } = renderComponent({ + ...defaultProps, + showMetricsTypesFilter: true, + }); + const metricsFilterButton = getByTestId(`${testIdFilter}-metricTypes-popoverButton`); + expect(metricsFilterButton).toBeTruthy(); + await user.click(metricsFilterButton); + const allFilterOptions = getAllByTestId('metricTypes-filter-option'); + + // checked options + const checkedOptions = allFilterOptions.filter( + (option) => option.getAttribute('aria-checked') === 'true' + ); + expect(checkedOptions).toHaveLength(2); + expect(checkedOptions.map((option) => option.title)).toEqual( + Object.keys(METRIC_TYPE_UI_OPTIONS_VALUES_TO_API_MAP).slice(0, 2) + ); + + // unchecked options + const unCheckedOptions = allFilterOptions.filter( + (option) => option.getAttribute('aria-checked') === 'false' + ); + expect(unCheckedOptions).toHaveLength(7); + expect(unCheckedOptions.map((option) => option.title)).toEqual( + Object.keys(METRIC_TYPE_UI_OPTIONS_VALUES_TO_API_MAP).slice(2) + ); + }); + + it('should show invalid date range info', () => { + const { getByTestId } = renderComponent({ + ...defaultProps, + // using this prop to set invalid date range + isValidDateRange: false, + }); + expect(getByTestId(`${testIdFilter}-invalid-date-range`)).toBeTruthy(); + }); + + it('should not show invalid date range info', () => { + const { queryByTestId } = renderComponent(defaultProps); + expect(queryByTestId(`${testIdFilter}-invalid-date-range`)).toBeNull(); + }); + + it('should disable refresh button', () => { + const { getByTestId } = renderComponent({ + ...defaultProps, + isUpdateDisabled: true, + }); + expect(getByTestId(`${testIdFilter}-super-refresh-button`)).toBeDisabled(); + }); + + it('should show `updating` on refresh button', () => { + const { getByTestId } = renderComponent({ + ...defaultProps, + isDataLoading: true, + }); + expect(getByTestId(`${testIdFilter}-super-refresh-button`)).toBeDisabled(); + expect(getByTestId(`${testIdFilter}-super-refresh-button`).textContent).toEqual('Updating'); + }); + + it('should call onClick on refresh button click', () => { + const { getByTestId } = renderComponent(defaultProps); + getByTestId(`${testIdFilter}-super-refresh-button`).click(); + expect(onClick).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/platform/plugins/private/data_usage/public/app/components/filters/charts_filters.tsx b/x-pack/platform/plugins/private/data_usage/public/app/components/filters/charts_filters.tsx index 52561aa9f26f0..8b8b3f864cd7d 100644 --- a/x-pack/platform/plugins/private/data_usage/public/app/components/filters/charts_filters.tsx +++ b/x-pack/platform/plugins/private/data_usage/public/app/components/filters/charts_filters.tsx @@ -6,35 +6,36 @@ */ import React, { memo, useCallback, useMemo } from 'react'; -import { EuiFilterGroup, EuiFlexGroup, EuiFlexItem, EuiSuperUpdateButton } from '@elastic/eui'; -import type { - DurationRange, - OnRefreshChangeProps, -} from '@elastic/eui/src/components/date_picker/types'; +import { + EuiFilterGroup, + EuiFlexGroup, + EuiFlexItem, + EuiSuperUpdateButton, + EuiText, + EuiTextAlign, +} from '@elastic/eui'; + +import { UX_LABELS } from '../../../translations'; import { useTestIdGenerator } from '../../../hooks/use_test_id_generator'; import { useGetDataUsageMetrics } from '../../../hooks/use_get_usage_metrics'; -import { DateRangePickerValues, UsageMetricsDateRangePicker } from './date_picker'; +import { type UsageMetricsDateRangePickerProps, UsageMetricsDateRangePicker } from './date_picker'; import { ChartsFilter, ChartsFilterProps } from './charts_filter'; import { FilterName } from '../../hooks'; -export interface ChartFiltersProps { - dateRangePickerState: DateRangePickerValues; - isDataLoading: boolean; +export interface ChartsFiltersProps extends UsageMetricsDateRangePickerProps { isUpdateDisabled: boolean; + isValidDateRange: boolean; filterOptions: Record; - onRefresh: () => void; - onRefreshChange: (evt: OnRefreshChangeProps) => void; - onTimeChange: ({ start, end }: DurationRange) => void; onClick: ReturnType['refetch']; showMetricsTypesFilter?: boolean; - 'data-test-subj'?: string; } -export const ChartFilters = memo( +export const ChartsFilters = memo( ({ dateRangePickerState, isDataLoading, isUpdateDisabled, + isValidDateRange, filterOptions, onClick, onRefresh, @@ -61,12 +62,11 @@ export const ChartFilters = memo( const onClickRefreshButton = useCallback(() => onClick(), [onClick]); return ( - - + {filters} - + ( onTimeChange={onTimeChange} data-test-subj={dataTestSubj} /> + {!isValidDateRange && ( + + +

{UX_LABELS.filters.invalidDateRange}

+
+
+ )}
( onClick={onClickRefreshButton} /> +
); } ); -ChartFilters.displayName = 'ChartFilters'; +ChartsFilters.displayName = 'ChartsFilters'; diff --git a/x-pack/platform/plugins/private/data_usage/public/app/components/filters/date_picker.tsx b/x-pack/platform/plugins/private/data_usage/public/app/components/filters/date_picker.tsx index 1b04587b4245d..10fbb2ab399ce 100644 --- a/x-pack/platform/plugins/private/data_usage/public/app/components/filters/date_picker.tsx +++ b/x-pack/platform/plugins/private/data_usage/public/app/components/filters/date_picker.tsx @@ -28,7 +28,7 @@ export interface DateRangePickerValues { recentlyUsedDateRanges: EuiSuperDatePickerRecentRange[]; } -interface UsageMetricsDateRangePickerProps { +export interface UsageMetricsDateRangePickerProps { dateRangePickerState: DateRangePickerValues; isDataLoading: boolean; onRefresh: () => void; diff --git a/x-pack/platform/plugins/private/data_usage/public/app/components/filters/toggle_all_button.tsx b/x-pack/platform/plugins/private/data_usage/public/app/components/filters/toggle_all_button.tsx index 3d1c4080fcc9c..e6d4f6cd3c721 100644 --- a/x-pack/platform/plugins/private/data_usage/public/app/components/filters/toggle_all_button.tsx +++ b/x-pack/platform/plugins/private/data_usage/public/app/components/filters/toggle_all_button.tsx @@ -26,7 +26,6 @@ interface ToggleAllButtonProps { export const ToggleAllButton = memo( ({ color, 'data-test-subj': dataTestSubj, icon, isDisabled, label, onClick }) => { - // const getTestId = useTestIdGenerator(dataTestSubj); return ( { + const testId = 'test'; + let renderComponent: (props: DataUsagePageProps) => RenderResult; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + renderComponent = (props: DataUsagePageProps) => + render( + + + + ); + }); + + it('renders', () => { + const { getByTestId } = renderComponent({ title: 'test' }); + expect(getByTestId(`${testId}-header`)).toBeTruthy(); + }); + + it('should show page title', () => { + const { getByTestId } = renderComponent({ title: 'test header' }); + expect(getByTestId(`${testId}-title`)).toBeTruthy(); + expect(getByTestId(`${testId}-title`)).toHaveTextContent('test header'); + }); + + it('should show page description', () => { + const { getByTestId } = renderComponent({ title: 'test', subtitle: 'test description' }); + expect(getByTestId(`${testId}-description`)).toBeTruthy(); + expect(getByTestId(`${testId}-description`)).toHaveTextContent('test description'); + }); +}); diff --git a/x-pack/platform/plugins/private/data_usage/public/app/components/page.tsx b/x-pack/platform/plugins/private/data_usage/public/app/components/page.tsx index d7ff20f5e933f..0a8f363b0a25f 100644 --- a/x-pack/platform/plugins/private/data_usage/public/app/components/page.tsx +++ b/x-pack/platform/plugins/private/data_usage/public/app/components/page.tsx @@ -16,45 +16,57 @@ import { EuiTitle, EuiSpacer, } from '@elastic/eui'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; -interface DataUsagePageProps { +export interface DataUsagePageProps { title: React.ReactNode; subtitle?: React.ReactNode; actions?: React.ReactNode; restrictWidth?: boolean | number; hasBottomBorder?: boolean; hideHeader?: boolean; + 'data-test-subj'?: string; } export const DataUsagePage = memo>( - ({ title, subtitle, children, restrictWidth = false, hasBottomBorder = true, ...otherProps }) => { + ({ + title, + subtitle, + children, + restrictWidth = false, + hasBottomBorder = true, + 'data-test-subj': dataTestSubj, + ...otherProps + }) => { + const getTestId = useTestIdGenerator(dataTestSubj); + const header = useMemo(() => { return ( - {title} + {title} ); - }, [, title]); + }, [getTestId, title]); const description = useMemo(() => { return subtitle ? ( - {subtitle} + {subtitle} ) : undefined; - }, [subtitle]); + }, [getTestId, subtitle]); return ( -
+
<> diff --git a/x-pack/platform/plugins/private/data_usage/public/app/data_usage_metrics_page.test.tsx b/x-pack/platform/plugins/private/data_usage/public/app/data_usage_metrics_page.test.tsx new file mode 100644 index 0000000000000..18f49d8042e71 --- /dev/null +++ b/x-pack/platform/plugins/private/data_usage/public/app/data_usage_metrics_page.test.tsx @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { TestProvider } from '../../common/test_utils'; +import { render, type RenderResult } from '@testing-library/react'; +import { DataUsageMetricsPage } from './data_usage_metrics_page'; +import { coreMock as mockCore } from '@kbn/core/public/mocks'; +import { useGetDataUsageMetrics } from '../hooks/use_get_usage_metrics'; +import { useGetDataUsageDataStreams } from '../hooks/use_get_data_streams'; +import { mockUseKibana } from './mocks'; + +jest.mock('../hooks/use_get_usage_metrics'); +jest.mock('../hooks/use_get_data_streams'); +const mockServices = mockCore.createStart(); +jest.mock('../utils/use_breadcrumbs', () => { + return { + useBreadcrumbs: jest.fn(), + }; +}); +jest.mock('../utils/use_kibana', () => { + return { + useKibanaContextForPlugin: () => ({ + services: mockServices, + }), + }; +}); + +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('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + useKibana: () => mockUseKibana, + }; +}); + +const mockUseGetDataUsageMetrics = useGetDataUsageMetrics as jest.Mock; +const mockUseGetDataUsageDataStreams = useGetDataUsageDataStreams as jest.Mock; + +const getBaseMockedDataStreams = () => ({ + error: undefined, + data: undefined, + isFetching: false, + refetch: jest.fn(), +}); +const getBaseMockedDataUsageMetrics = () => ({ + error: undefined, + data: undefined, + isFetching: false, + refetch: jest.fn(), +}); + +describe('DataUsageMetrics Page', () => { + const testId = 'test'; + let renderComponent: () => RenderResult; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + renderComponent = () => + render( + + + + ); + mockUseGetDataUsageMetrics.mockReturnValue(getBaseMockedDataUsageMetrics); + mockUseGetDataUsageDataStreams.mockReturnValue(getBaseMockedDataStreams); + }); + + it('renders', () => { + const { getByTestId } = renderComponent(); + expect(getByTestId(`${testId}-page-header`)).toBeTruthy(); + }); + + it('should show page title', () => { + const { getByTestId } = renderComponent(); + expect(getByTestId(`${testId}-page-title`)).toBeTruthy(); + expect(getByTestId(`${testId}-page-title`)).toHaveTextContent('Data Usage'); + }); + + it('should show page description', () => { + const { getByTestId } = renderComponent(); + expect(getByTestId(`${testId}-page-description`)).toBeTruthy(); + expect(getByTestId(`${testId}-page-description`)).toHaveTextContent( + 'Monitor data ingested and retained by data streams over the past 10 days.' + ); + }); +}); diff --git a/x-pack/platform/plugins/private/data_usage/public/app/data_usage_metrics_page.tsx b/x-pack/platform/plugins/private/data_usage/public/app/data_usage_metrics_page.tsx index adc53e12b5749..7edc2b57e360c 100644 --- a/x-pack/platform/plugins/private/data_usage/public/app/data_usage_metrics_page.tsx +++ b/x-pack/platform/plugins/private/data_usage/public/app/data_usage_metrics_page.tsx @@ -5,21 +5,29 @@ * 2.0. */ -import React from 'react'; +import React, { memo } from 'react'; import { DataUsagePage } from './components/page'; import { DATA_USAGE_PAGE } from '../translations'; import { DataUsageMetrics } from './components/data_usage_metrics'; +import { useTestIdGenerator } from '../hooks/use_test_id_generator'; -export const DataUsageMetricsPage = () => { - return ( - - - - ); -}; +export interface DataUsageMetricsPageProps { + 'data-test-subj'?: string; +} +export const DataUsageMetricsPage = memo( + ({ 'data-test-subj': dataTestSubj = 'data-usage' }) => { + const getTestId = useTestIdGenerator(dataTestSubj); + + return ( + + + + ); + } +); DataUsageMetricsPage.displayName = 'DataUsageMetricsPage'; diff --git a/x-pack/platform/plugins/private/data_usage/public/app/hooks/use_charts_filter.tsx b/x-pack/platform/plugins/private/data_usage/public/app/hooks/use_charts_filter.tsx index 012a6027aadb2..429ffab06637a 100644 --- a/x-pack/platform/plugins/private/data_usage/public/app/hooks/use_charts_filter.tsx +++ b/x-pack/platform/plugins/private/data_usage/public/app/hooks/use_charts_filter.tsx @@ -5,14 +5,15 @@ * 2.0. */ -import { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; +import { EuiIconTip } from '@elastic/eui'; import { DEFAULT_SELECTED_OPTIONS } from '../../../common'; import { METRIC_TYPE_VALUES, METRIC_TYPE_API_VALUES_TO_UI_OPTIONS_MAP, isDefaultMetricType, } from '../../../common/rest_types'; -import { FILTER_NAMES } from '../../translations'; +import { FILTER_NAMES, UX_LABELS } from '../../translations'; import { useDataUsageMetricsUrlParams } from './use_charts_url_params'; import { formatBytes } from '../../utils/format_bytes'; import { ChartsFilterProps } from '../components/filters/charts_filter'; @@ -68,41 +69,80 @@ export const useChartsFilter = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // filter options - const [items, setItems] = useState( - isMetricTypesFilter - ? METRIC_TYPE_VALUES.map((metricType) => ({ - key: metricType, - label: METRIC_TYPE_API_VALUES_TO_UI_OPTIONS_MAP[metricType], - checked: selectedMetricTypesFromUrl - ? selectedMetricTypesFromUrl.includes(metricType) - ? 'on' - : undefined - : isDefaultMetricType(metricType) // default metrics are selected by default + const initialSelectedOptions = useMemo(() => { + if (isMetricTypesFilter) { + return METRIC_TYPE_VALUES.map((metricType) => ({ + key: metricType, + label: METRIC_TYPE_API_VALUES_TO_UI_OPTIONS_MAP[metricType], + checked: selectedMetricTypesFromUrl + ? selectedMetricTypesFromUrl.includes(metricType) ? 'on' - : undefined, - 'data-test-subj': `${filterOptions.filterName}-filter-option`, - })) - : isDataStreamsFilter && !!filterOptions.options.length - ? filterOptions.options?.map((filterOption, i) => ({ - key: filterOption, - label: filterOption, - append: formatBytes(filterOptions.appendOptions?.[filterOption] ?? 0), - checked: selectedDataStreamsFromUrl - ? selectedDataStreamsFromUrl.includes(filterOption) - ? 'on' - : undefined - : i < DEFAULT_SELECTED_OPTIONS + : undefined + : isDefaultMetricType(metricType) // default metrics are selected by default + ? 'on' + : undefined, + 'data-test-subj': `${filterOptions.filterName}-filter-option`, + })) as FilterItems; + } + let dataStreamOptions: FilterItems = []; + + if (isDataStreamsFilter && !!filterOptions.options.length) { + dataStreamOptions = filterOptions.options?.map((filterOption, i) => ({ + key: filterOption, + label: filterOption, + append: formatBytes(filterOptions.appendOptions?.[filterOption] ?? 0), + checked: selectedDataStreamsFromUrl + ? selectedDataStreamsFromUrl.includes(filterOption) ? 'on' - : undefined, - 'data-test-subj': `${filterOptions.filterName}-filter-option`, - })) - : [] - ); + : undefined + : i < DEFAULT_SELECTED_OPTIONS + ? 'on' + : undefined, + 'data-test-subj': `${filterOptions.filterName}-filter-option`, + truncationProps: { + truncation: 'start', + truncationOffset: 15, + }, + })); + } - const hasActiveFilters = useMemo(() => !!items.find((item) => item.checked === 'on'), [items]); + return [ + { + label: UX_LABELS.filters.dataStreams.label, + append: ( + + {UX_LABELS.filters.dataStreams.append} + + + ), + isGroupLabel: true, + 'data-test-subj': `${filterOptions.filterName}-group-label`, + }, + ...dataStreamOptions, + ]; + }, [ + filterOptions.appendOptions, + filterOptions.filterName, + filterOptions.options, + isDataStreamsFilter, + isMetricTypesFilter, + selectedDataStreamsFromUrl, + selectedMetricTypesFromUrl, + ]); + // filter options + const [items, setItems] = useState(initialSelectedOptions); + + const hasActiveFilters = useMemo( + () => !!items.find((item) => !item.isGroupLabel && item.checked === 'on'), + [items] + ); const numActiveFilters = useMemo( - () => items.filter((item) => item.checked === 'on').length, + () => items.filter((item) => !item.isGroupLabel && item.checked === 'on').length, [items] ); const numFilters = useMemo( diff --git a/x-pack/platform/plugins/private/data_usage/public/app/hooks/use_date_picker.tsx b/x-pack/platform/plugins/private/data_usage/public/app/hooks/use_date_picker.tsx index 6b7e6f792b69b..ce5c70584946d 100644 --- a/x-pack/platform/plugins/private/data_usage/public/app/hooks/use_date_picker.tsx +++ b/x-pack/platform/plugins/private/data_usage/public/app/hooks/use_date_picker.tsx @@ -5,14 +5,14 @@ * 2.0. */ -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import type { DurationRange, OnRefreshChangeProps, } from '@elastic/eui/src/components/date_picker/types'; import { useDataUsageMetricsUrlParams } from './use_charts_url_params'; import { DateRangePickerValues } from '../components/filters/date_picker'; -import { DEFAULT_DATE_RANGE_OPTIONS } from '../../../common/utils'; +import { DEFAULT_DATE_RANGE_OPTIONS, isDateRangeValid } from '../../../common/utils'; export const useDateRangePicker = () => { const { @@ -85,5 +85,14 @@ export const useDateRangePicker = () => { ] ); - return { dateRangePickerState, onRefreshChange, onTimeChange }; + const isValidDateRange = useMemo( + () => + isDateRangeValid({ + start: dateRangePickerState.startDate, + end: dateRangePickerState.endDate, + }), + [dateRangePickerState.endDate, dateRangePickerState.startDate] + ); + + return { dateRangePickerState, isValidDateRange, onRefreshChange, onTimeChange }; }; diff --git a/x-pack/platform/plugins/private/data_usage/public/app/mocks.ts b/x-pack/platform/plugins/private/data_usage/public/app/mocks.ts new file mode 100644 index 0000000000000..eed86c4bc6dc2 --- /dev/null +++ b/x-pack/platform/plugins/private/data_usage/public/app/mocks.ts @@ -0,0 +1,65 @@ +/* + * 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 const mockUseKibana = { + 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', + }, + ], + }; + return x[k]; + }; + return get(key); + }), + }, + }, +}; + +export const generateDataStreams = (count: number) => { + return Array.from({ length: count }, (_, i) => ({ + name: `.ds-${i}`, + storageSizeBytes: 1024 ** 2 * (22 / 7), + })); +}; diff --git a/x-pack/platform/plugins/private/data_usage/public/hooks/use_get_data_streams.ts b/x-pack/platform/plugins/private/data_usage/public/hooks/use_get_data_streams.ts index d43c3fff139fb..0dc9d7d535eb1 100644 --- a/x-pack/platform/plugins/private/data_usage/public/hooks/use_get_data_streams.ts +++ b/x-pack/platform/plugins/private/data_usage/public/hooks/use_get_data_streams.ts @@ -8,14 +8,15 @@ import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query'; import type { IHttpFetchError } from '@kbn/core-http-browser'; +import { DataStreamsResponseBodySchemaBody } from '../../common/rest_types'; import { DATA_USAGE_DATA_STREAMS_API_ROUTE, DEFAULT_SELECTED_OPTIONS } from '../../common'; import { useKibanaContextForPlugin } from '../utils/use_kibana'; -type GetDataUsageDataStreamsResponse = Array<{ - name: string; - storageSizeBytes: number; - selected: boolean; -}>; +type GetDataUsageDataStreamsResponse = Array< + DataStreamsResponseBodySchemaBody[number] & { + selected: boolean; + } +>; export const useGetDataUsageDataStreams = ({ selectedDataStreams, @@ -33,41 +34,42 @@ export const useGetDataUsageDataStreams = ({ ...options, keepPreviousData: true, queryFn: async ({ signal }) => { - const dataStreamsResponse = await http + return http .get(DATA_USAGE_DATA_STREAMS_API_ROUTE, { signal, version: '1', }) - .catch((error) => { - throw error.body; - }); - - const augmentedDataStreamsBasedOnSelectedItems = dataStreamsResponse.reduce<{ - selected: GetDataUsageDataStreamsResponse; - rest: GetDataUsageDataStreamsResponse; - }>( - (acc, ds, i) => { - const item = { - name: ds.name, - storageSizeBytes: ds.storageSizeBytes, - selected: ds.selected, - }; + .then((response) => { + const augmentedDataStreamsBasedOnSelectedItems = response.reduce<{ + selected: GetDataUsageDataStreamsResponse; + rest: GetDataUsageDataStreamsResponse; + }>( + (acc, ds, i) => { + const item = { + name: ds.name, + storageSizeBytes: ds.storageSizeBytes, + selected: ds.selected, + }; - if (selectedDataStreams?.includes(ds.name) && i < DEFAULT_SELECTED_OPTIONS) { - acc.selected.push({ ...item, selected: true }); - } else { - acc.rest.push({ ...item, selected: false }); - } + if (selectedDataStreams?.includes(ds.name) && i < DEFAULT_SELECTED_OPTIONS) { + acc.selected.push({ ...item, selected: true }); + } else { + acc.rest.push({ ...item, selected: false }); + } - return acc; - }, - { selected: [], rest: [] } - ); + return acc; + }, + { selected: [], rest: [] } + ); - return [ - ...augmentedDataStreamsBasedOnSelectedItems.selected, - ...augmentedDataStreamsBasedOnSelectedItems.rest, - ]; + return [ + ...augmentedDataStreamsBasedOnSelectedItems.selected, + ...augmentedDataStreamsBasedOnSelectedItems.rest, + ]; + }) + .catch((error) => { + throw error.body; + }); }, }); }; diff --git a/x-pack/platform/plugins/private/data_usage/public/translations.tsx b/x-pack/platform/plugins/private/data_usage/public/translations.tsx index 0996ec2bb6d50..ba70f0cdc1787 100644 --- a/x-pack/platform/plugins/private/data_usage/public/translations.tsx +++ b/x-pack/platform/plugins/private/data_usage/public/translations.tsx @@ -34,11 +34,27 @@ export const DATA_USAGE_PAGE = Object.freeze({ defaultMessage: 'Data Usage', }), subTitle: i18n.translate('xpack.dataUsage.pageSubtitle', { - defaultMessage: 'Monitor data ingested and retained by data streams.', + defaultMessage: 'Monitor data ingested and retained by data streams over the past 10 days.', }), }); export const UX_LABELS = Object.freeze({ + filters: { + invalidDateRange: i18n.translate('xpack.dataUsage.metrics.filter.invalidDateRange', { + defaultMessage: 'The date range should be within 10 days from now.', + }), + dataStreams: { + label: i18n.translate('xpack.dataUsage.metrics.filter.dataStreams.label', { + defaultMessage: 'Name', + }), + append: i18n.translate('xpack.dataUsage.metrics.filter.dataStreams.append', { + defaultMessage: 'Size', + }), + appendTooltip: i18n.translate('xpack.dataUsage.metrics.filter.dataStreams.appendTooltip', { + defaultMessage: 'Storage size is not updated based on the selected date range.', + }), + }, + }, filterSelectAll: i18n.translate('xpack.dataUsage.metrics.filter.selectAll', { defaultMessage: 'Select all', }), diff --git a/x-pack/platform/plugins/private/data_usage/server/routes/internal/data_streams.test.ts b/x-pack/platform/plugins/private/data_usage/server/routes/internal/data_streams.test.ts index 9efe61bd75118..9316a64328c9b 100644 --- a/x-pack/platform/plugins/private/data_usage/server/routes/internal/data_streams.test.ts +++ b/x-pack/platform/plugins/private/data_usage/server/routes/internal/data_streams.test.ts @@ -127,6 +127,48 @@ describe('registerDataStreamsRoute', () => { }); }); + it('should not include `dot` indices/data streams that start with a `.`', async () => { + mockGetMeteringStats.mockResolvedValue({ + datastreams: [ + { + name: 'ds-1', + size_in_bytes: 100, + }, + { + name: '.ds-2', + size_in_bytes: 200, + }, + { + name: 'ds-3', + size_in_bytes: 500, + }, + { + name: '.ds-4', + size_in_bytes: 0, + }, + ], + }); + 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: 'ds-3', + storageSizeBytes: 500, + }, + { + name: 'ds-1', + storageSizeBytes: 100, + }, + ], + }); + }); + it('should return correct error if metering stats request fails with an unknown error', async () => { // using custom error for test here to avoid having to import the actual error class mockGetMeteringStats.mockRejectedValue( @@ -178,9 +220,9 @@ describe('registerDataStreamsRoute', () => { }); it.each([ - ['no datastreams', {}, []], - ['empty array', { datastreams: [] }, []], - ['an empty element', { datastreams: [{}] }, []], + ['no datastreams key in response', { indices: [] }, []], + ['empty datastreams array', { indices: [], datastreams: [] }, []], + ['an empty element', { indices: [], datastreams: [{}] }, []], ])('should return empty array when no stats data with %s', async (_, stats, res) => { mockGetMeteringStats.mockResolvedValue(stats); const mockRequest = httpServerMock.createKibanaRequest({ body: {} }); @@ -189,9 +231,18 @@ describe('registerDataStreamsRoute', () => { 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, - }); + // @ts-expect-error + if (stats && stats.datastreams && stats.datastreams.length) { + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + expect(mockResponse.ok.mock.calls[0][0]).toEqual({ + body: res, + }); + } else { + expect(mockResponse.customError).toHaveBeenCalledTimes(1); + expect(mockResponse.customError).toHaveBeenCalledWith({ + body: new CustomHttpRequestError('No user defined data streams found', 404), + statusCode: 404, + }); + } }); }); diff --git a/x-pack/platform/plugins/private/data_usage/server/routes/internal/data_streams_handler.ts b/x-pack/platform/plugins/private/data_usage/server/routes/internal/data_streams_handler.ts index 28967b9a0ee4a..0f472ca98065e 100644 --- a/x-pack/platform/plugins/private/data_usage/server/routes/internal/data_streams_handler.ts +++ b/x-pack/platform/plugins/private/data_usage/server/routes/internal/data_streams_handler.ts @@ -9,8 +9,12 @@ import { RequestHandler } from '@kbn/core/server'; import { DataUsageContext, DataUsageRequestHandlerContext } from '../../types'; import { errorHandler } from '../error_handler'; import { getMeteringStats } from '../../utils/get_metering_stats'; -import { DataStreamsRequestQuery } from '../../../common/rest_types/data_streams'; +import type { + DataStreamsRequestQuery, + DataStreamsResponseBodySchemaBody, +} from '../../../common/rest_types/data_streams'; import { NoIndicesMeteringError, NoPrivilegeMeteringError } from '../../errors'; +import { CustomHttpRequestError } from '../../utils'; export const getDataStreamsHandler = ( dataUsageContext: DataUsageContext @@ -23,24 +27,33 @@ export const getDataStreamsHandler = ( try { const core = await context.core; - const { datastreams: meteringStats } = await getMeteringStats( + const { datastreams: meteringStatsDataStreams } = await getMeteringStats( core.elasticsearch.client.asSecondaryAuthUser ); - const body = - meteringStats && !!meteringStats.length - ? meteringStats - .sort((a, b) => b.size_in_bytes - a.size_in_bytes) - .reduce>((acc, stat) => { - if (includeZeroStorage || stat.size_in_bytes > 0) { - acc.push({ - name: stat.name, - storageSizeBytes: stat.size_in_bytes ?? 0, - }); - } - return acc; - }, []) - : []; + const nonSystemDataStreams = meteringStatsDataStreams?.filter((dataStream) => { + return !dataStream.name?.startsWith('.'); + }); + + if (!nonSystemDataStreams || !nonSystemDataStreams.length) { + return errorHandler( + logger, + response, + new CustomHttpRequestError('No user defined data streams found', 404) + ); + } + + const body = nonSystemDataStreams + .reduce((acc, stat) => { + if (includeZeroStorage || stat.size_in_bytes > 0) { + acc.push({ + name: stat.name, + storageSizeBytes: stat.size_in_bytes, + }); + } + return acc; + }, []) + .sort((a, b) => b.storageSizeBytes - a.storageSizeBytes); return response.ok({ body, diff --git a/x-pack/test_serverless/functional/page_objects/svl_data_usage.ts b/x-pack/test_serverless/functional/page_objects/svl_data_usage.ts index 0f684c24fcae0..ccece1e10e113 100644 --- a/x-pack/test_serverless/functional/page_objects/svl_data_usage.ts +++ b/x-pack/test_serverless/functional/page_objects/svl_data_usage.ts @@ -13,7 +13,7 @@ export function SvlDataUsagePageProvider({ getService }: FtrProviderContext) { return { async assertDataUsagePageExists(): Promise { - return await testSubjects.exists('DataUsagePage'); + return await testSubjects.exists('data-usage-page'); }, async clickDatastreamsDropdown() { await testSubjects.click('data-usage-metrics-filter-dataStreams-popoverButton'); diff --git a/x-pack/test_serverless/functional/test_suites/common/data_usage/privileges.ts b/x-pack/test_serverless/functional/test_suites/common/data_usage/privileges.ts index 4efcdd2586a85..dab301dff34ea 100644 --- a/x-pack/test_serverless/functional/test_suites/common/data_usage/privileges.ts +++ b/x-pack/test_serverless/functional/test_suites/common/data_usage/privileges.ts @@ -30,11 +30,11 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { if (expectedVisible) { await pageObjects.svlManagementPage.assertDataUsageManagementCardExists(); await pageObjects.common.navigateToApp(dataUsageAppUrl); - await testSubjects.exists('DataUsagePage'); + await testSubjects.exists('data-usage-page'); } else { await pageObjects.svlManagementPage.assertDataUsageManagementCardDoesNotExist(); await pageObjects.common.navigateToApp(dataUsageAppUrl); - await testSubjects.missingOrFail('DataUsagePage'); + await testSubjects.missingOrFail('data-usage-page'); } };