mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Serverless][DataUsage] Data usage UX/API updates (#203465)
This commit is contained in:
parent
780316832b
commit
b4331195d6
24 changed files with 879 additions and 233 deletions
|
@ -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',
|
||||
|
|
|
@ -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' },
|
||||
|
|
|
@ -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')
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
||||
|
|
|
@ -28,7 +28,7 @@ export interface DateRangePickerValues {
|
|||
recentlyUsedDateRanges: EuiSuperDatePickerRecentRange[];
|
||||
}
|
||||
|
||||
interface UsageMetricsDateRangePickerProps {
|
||||
export interface UsageMetricsDateRangePickerProps {
|
||||
dateRangePickerState: DateRangePickerValues;
|
||||
isDataLoading: boolean;
|
||||
onRefresh: () => void;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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" />
|
||||
</>
|
||||
|
|
|
@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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';
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
@ -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),
|
||||
}));
|
||||
};
|
|
@ -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,
|
||||
];
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
}),
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue