[Serverless][DataUsage] Data usage UX/API updates (#203465)

This commit is contained in:
Ash 2024-12-12 22:24:42 +01:00 committed by GitHub
parent 780316832b
commit b4331195d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 879 additions and 233 deletions

View file

@ -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<Record<MetricTypes, string>>({
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<Record<Met
});
export const METRIC_TYPE_UI_OPTIONS_VALUES_TO_API_MAP = Object.freeze<Record<string, MetricTypes>>({
'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',

View file

@ -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' },

View file

@ -19,6 +19,7 @@ export const DEFAULT_DATE_RANGE_OPTIONS = Object.freeze({
recentlyUsedDateRanges: [],
});
export type ParsedDate = ReturnType<typeof momentDateParser>;
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')
);
};

View file

@ -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'
);
});

View file

@ -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<Record<string, number>>((acc, ds) => {
const filterOptions: ChartsFiltersProps['filterOptions'] = useMemo(() => {
const dataStreamsOptions = dataStreams?.reduce<
Required<ChartsFiltersProps['filterOptions']['dataStreams']>['appendOptions']
>((acc, ds) => {
acc[ds.name] = ds.storageSizeBytes;
return acc;
}, {});
@ -239,10 +229,11 @@ export const DataUsageMetrics = memo(
return (
<EuiFlexGroup alignItems="flexStart" direction="column" data-test-subj={getTestId()}>
<FlexItemWithCss>
<ChartFilters
<ChartsFilters
dateRangePickerState={dateRangePickerState}
isDataLoading={isFetchingDataStreams}
isUpdateDisabled={!enableFetchUsageMetricsData}
isValidDateRange={isValidDateRange}
onClick={refetchDataUsageMetrics}
onRefresh={onRefresh}
onRefreshChange={onRefreshChange}

View file

@ -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 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 { ChartsFilter, type ChartsFilterProps } from './charts_filter';
import { FilterName } from '../../hooks';
import { mockUseKibana, generateDataStreams } from '../../mocks';
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 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(
<TestProvider>
<ChartsFilter data-test-subj={testIdFilter} {...props} />
</TestProvider>
);
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();
});
});

View file

@ -81,6 +81,11 @@ export const ChartsFilter = memo<ChartsFilterProps>(
},
});
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<ChartsFilterProps>(
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<ChartsFilterProps>(
);
const onSelectAll = useCallback(() => {
const allItems: FilterItems = items.map((item) => {
return {
...item,
checked: 'on',
};
});
const allItems: FilterItems = items.reduce<FilterItems>((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<string[]>((acc, i) => {
if (i.checked) {
acc.push(i.label);
}
return acc;
}, []);
onChangeFilterOptions(optionsToSelect);
if (isDataStreamsFilter) {
@ -260,7 +281,7 @@ export const ChartsFilter = memo<ChartsFilterProps>(
data-test-subj={getTestId(`${filterName}-selectAllButton`)}
icon="check"
label={UX_LABELS.filterSelectAll}
isDisabled={hasActiveFilters && numFilters === 0}
isDisabled={isSelectAllDisabled}
onClick={onSelectAll}
/>
</EuiFlexItem>

View file

@ -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(
<TestProvider>
<ChartsFilters data-test-subj={testIdFilter} {...props} />
</TestProvider>
);
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();
});
});

View file

@ -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<FilterName, ChartsFilterProps['filterOptions']>;
onRefresh: () => void;
onRefreshChange: (evt: OnRefreshChangeProps) => void;
onTimeChange: ({ start, end }: DurationRange) => void;
onClick: ReturnType<typeof useGetDataUsageMetrics>['refetch'];
showMetricsTypesFilter?: boolean;
'data-test-subj'?: string;
}
export const ChartFilters = memo<ChartFiltersProps>(
export const ChartsFilters = memo<ChartsFiltersProps>(
({
dateRangePickerState,
isDataLoading,
isUpdateDisabled,
isValidDateRange,
filterOptions,
onClick,
onRefresh,
@ -61,12 +62,11 @@ export const ChartFilters = memo<ChartFiltersProps>(
const onClickRefreshButton = useCallback(() => onClick(), [onClick]);
return (
<EuiFlexGroup responsive gutterSize="m" alignItems="center" justifyContent="flexEnd">
<EuiFlexItem grow={2} />
<EuiFlexGroup responsive gutterSize="m" justifyContent="flexStart">
<EuiFlexItem grow={1}>
<EuiFilterGroup>{filters}</EuiFilterGroup>
</EuiFlexItem>
<EuiFlexItem grow={2}>
<EuiFlexItem grow={1}>
<UsageMetricsDateRangePicker
dateRangePickerState={dateRangePickerState}
isDataLoading={isDataLoading}
@ -75,6 +75,13 @@ export const ChartFilters = memo<ChartFiltersProps>(
onTimeChange={onTimeChange}
data-test-subj={dataTestSubj}
/>
{!isValidDateRange && (
<EuiText color="danger" size="s" data-test-subj={getTestId('invalid-date-range')}>
<EuiTextAlign textAlign="center">
<p>{UX_LABELS.filters.invalidDateRange}</p>
</EuiTextAlign>
</EuiText>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSuperUpdateButton
@ -85,9 +92,10 @@ export const ChartFilters = memo<ChartFiltersProps>(
onClick={onClickRefreshButton}
/>
</EuiFlexItem>
<EuiFlexItem grow={2} />
</EuiFlexGroup>
);
}
);
ChartFilters.displayName = 'ChartFilters';
ChartsFilters.displayName = 'ChartsFilters';

View file

@ -28,7 +28,7 @@ export interface DateRangePickerValues {
recentlyUsedDateRanges: EuiSuperDatePickerRecentRange[];
}
interface UsageMetricsDateRangePickerProps {
export interface UsageMetricsDateRangePickerProps {
dateRangePickerState: DateRangePickerValues;
isDataLoading: boolean;
onRefresh: () => void;

View file

@ -26,7 +26,6 @@ interface ToggleAllButtonProps {
export const ToggleAllButton = memo<ToggleAllButtonProps>(
({ color, 'data-test-subj': dataTestSubj, icon, isDisabled, label, onClick }) => {
// const getTestId = useTestIdGenerator(dataTestSubj);
return (
<EuiButtonEmpty
color={color}

View file

@ -0,0 +1,50 @@
/*
* 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 { DataUsagePage, type DataUsagePageProps } from './page';
describe('Page Component', () => {
const testId = 'test';
let renderComponent: (props: DataUsagePageProps) => RenderResult;
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(() => {
jest.clearAllMocks();
renderComponent = (props: DataUsagePageProps) =>
render(
<TestProvider>
<DataUsagePage data-test-subj={testId} {...props} />
</TestProvider>
);
});
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');
});
});

View file

@ -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<PropsWithChildren<DataUsagePageProps & CommonProps>>(
({ 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 (
<EuiFlexGroup direction="column" gutterSize="none" alignItems="flexStart">
<EuiFlexItem grow={false}>
<EuiTitle size="l">
<span data-test-subj="dataUsage-page-title">{title}</span>
<span data-test-subj={getTestId(`title`)}>{title}</span>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
);
}, [, title]);
}, [getTestId, title]);
const description = useMemo(() => {
return subtitle ? (
<span data-test-subj="dataUsage-page-description">{subtitle}</span>
<span data-test-subj={getTestId(`description`)}>{subtitle}</span>
) : undefined;
}, [subtitle]);
}, [getTestId, subtitle]);
return (
<div {...otherProps}>
<div {...otherProps} data-test-subj={dataTestSubj}>
<>
<EuiPageHeader
pageTitle={header}
description={description}
bottomBorder={hasBottomBorder}
restrictWidth={restrictWidth}
data-test-subj={'dataUsage-page-header'}
data-test-subj={getTestId('header')}
/>
<EuiSpacer size="l" />
</>

View file

@ -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(
<TestProvider>
<DataUsageMetricsPage data-test-subj={testId} />
</TestProvider>
);
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.'
);
});
});

View file

@ -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 (
<DataUsagePage
data-test-subj="DataUsagePage"
title={DATA_USAGE_PAGE.title}
subtitle={DATA_USAGE_PAGE.subTitle}
>
<DataUsageMetrics />
</DataUsagePage>
);
};
export interface DataUsageMetricsPageProps {
'data-test-subj'?: string;
}
export const DataUsageMetricsPage = memo<DataUsageMetricsPageProps>(
({ 'data-test-subj': dataTestSubj = 'data-usage' }) => {
const getTestId = useTestIdGenerator(dataTestSubj);
return (
<DataUsagePage
data-test-subj={getTestId('page')}
title={DATA_USAGE_PAGE.title}
subtitle={DATA_USAGE_PAGE.subTitle}
>
<DataUsageMetrics data-test-subj={getTestId('metrics')} />
</DataUsagePage>
);
}
);
DataUsageMetricsPage.displayName = 'DataUsageMetricsPage';

View file

@ -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<FilterItems>(
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
? 'on'
: undefined,
'data-test-subj': `${filterOptions.filterName}-filter-option`,
}))
: []
);
: undefined
: isDefaultMetricType(metricType) // default metrics are selected by default
? 'on'
: undefined,
'data-test-subj': `${filterOptions.filterName}-filter-option`,
})) as FilterItems;
}
let dataStreamOptions: FilterItems = [];
const hasActiveFilters = useMemo(() => !!items.find((item) => item.checked === 'on'), [items]);
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
: i < DEFAULT_SELECTED_OPTIONS
? 'on'
: undefined,
'data-test-subj': `${filterOptions.filterName}-filter-option`,
truncationProps: {
truncation: 'start',
truncationOffset: 15,
},
}));
}
return [
{
label: UX_LABELS.filters.dataStreams.label,
append: (
<span css={{ display: 'flex', alignItems: 'flex-end', marginLeft: 'auto' }}>
{UX_LABELS.filters.dataStreams.append}
<EuiIconTip
content={UX_LABELS.filters.dataStreams.appendTooltip}
type="iInCircle"
color="subdued"
css={{ alignContent: 'flex-start', justifyContent: 'flex-start' }}
/>
</span>
),
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<FilterItems>(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(

View file

@ -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 };
};

View file

@ -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),
}));
};

View file

@ -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<GetDataUsageDataStreamsResponse>(DATA_USAGE_DATA_STREAMS_API_ROUTE, {
signal,
version: '1',
})
.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 });
}
return acc;
},
{ selected: [], rest: [] }
);
return [
...augmentedDataStreamsBasedOnSelectedItems.selected,
...augmentedDataStreamsBasedOnSelectedItems.rest,
];
})
.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,
};
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 [
...augmentedDataStreamsBasedOnSelectedItems.selected,
...augmentedDataStreamsBasedOnSelectedItems.rest,
];
},
});
};

View file

@ -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',
}),

View file

@ -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,
});
}
});
});

View file

@ -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<Array<{ name: string; storageSizeBytes: number }>>((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<DataStreamsResponseBodySchemaBody>((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,

View file

@ -13,7 +13,7 @@ export function SvlDataUsagePageProvider({ getService }: FtrProviderContext) {
return {
async assertDataUsagePageExists(): Promise<boolean> {
return await testSubjects.exists('DataUsagePage');
return await testSubjects.exists('data-usage-page');
},
async clickDatastreamsDropdown() {
await testSubjects.click('data-usage-metrics-filter-dataStreams-popoverButton');

View file

@ -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');
}
};