[8.x] [DataUsage][Serverless] Data usage metrics page enhancements (#195556) (#196202)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[DataUsage][Serverless] Data usage metrics page enhancements
(#195556)](https://github.com/elastic/kibana/pull/195556)

<!--- Backport version: 8.9.8 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT
[{"author":{"name":"Ash","email":"1849116+ashokaditya@users.noreply.github.com"},"sourceCommit":{"committedDate":"2024-10-14T09:54:22Z","message":"[DataUsage][Serverless]
Data usage metrics page enhancements (#195556)\n\n## Summary\r\n\r\nThis
PR is a follow-up of elastic/kibana/pull/193966 and adds: \r\n\r\n1.
Datastreams filter to data usage metrics page.\r\n2. Metrics filter
(hidden for now) that lists out metric types to\r\nrequest.\r\n3.
Refactors to make code easier to maintain.\r\n4. Shows a callout if no
data stream is selected.\r\n\r\n### screen\r\n![Screenshot 2024-10-09 at
17
36\r\n32](https://github.com/user-attachments/assets/a0779c91-25ae-4a64-819e-bc8a626f1f96)\r\n\r\n###
clip\r\n\r\n![latest-metrics-ux](https://github.com/user-attachments/assets/0f4b1a9b-d160-435b-917b-f59c3a5cc9f8)\r\n\r\n###
Checklist\r\n- [x] Any text added follows [EUI's
writing\r\nguidelines](https://elastic.github.io/eui/#/guidelines/writing),
uses\r\nsentence case text and includes
[i18n\r\nsupport](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)\r\n-
[ ] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n- [ ] [Flaky
Test\r\nRunner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1)
was\r\nused on any tests changed\r\n- [x] Any UI touched in this PR does
not create any new axe failures\r\n(run axe in
browser:\r\n[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),\r\n[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))\r\n-
[x] This renders correctly on smaller devices using a
responsive\r\nlayout. (You can test this [in
your\r\nbrowser](https://www.browserstack.com/guide/responsive-testing-on-local-server))\r\n-
[x] This was checked for
[cross-browser\r\ncompatibility](https://www.elastic.co/support/matrix#matrix_browsers)\r\n\r\n---------\r\n\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"a7332ad11611d224a16f2bb3c0d3f207cf746065","branchLabelMapping":{"^v9.0.0$":"main","^v8.16.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","v9.0.0","v8.16.0","backport:version"],"number":195556,"url":"https://github.com/elastic/kibana/pull/195556","mergeCommit":{"message":"[DataUsage][Serverless]
Data usage metrics page enhancements (#195556)\n\n## Summary\r\n\r\nThis
PR is a follow-up of elastic/kibana/pull/193966 and adds: \r\n\r\n1.
Datastreams filter to data usage metrics page.\r\n2. Metrics filter
(hidden for now) that lists out metric types to\r\nrequest.\r\n3.
Refactors to make code easier to maintain.\r\n4. Shows a callout if no
data stream is selected.\r\n\r\n### screen\r\n![Screenshot 2024-10-09 at
17
36\r\n32](https://github.com/user-attachments/assets/a0779c91-25ae-4a64-819e-bc8a626f1f96)\r\n\r\n###
clip\r\n\r\n![latest-metrics-ux](https://github.com/user-attachments/assets/0f4b1a9b-d160-435b-917b-f59c3a5cc9f8)\r\n\r\n###
Checklist\r\n- [x] Any text added follows [EUI's
writing\r\nguidelines](https://elastic.github.io/eui/#/guidelines/writing),
uses\r\nsentence case text and includes
[i18n\r\nsupport](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)\r\n-
[ ] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n- [ ] [Flaky
Test\r\nRunner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1)
was\r\nused on any tests changed\r\n- [x] Any UI touched in this PR does
not create any new axe failures\r\n(run axe in
browser:\r\n[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),\r\n[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))\r\n-
[x] This renders correctly on smaller devices using a
responsive\r\nlayout. (You can test this [in
your\r\nbrowser](https://www.browserstack.com/guide/responsive-testing-on-local-server))\r\n-
[x] This was checked for
[cross-browser\r\ncompatibility](https://www.elastic.co/support/matrix#matrix_browsers)\r\n\r\n---------\r\n\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"a7332ad11611d224a16f2bb3c0d3f207cf746065"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","labelRegex":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/195556","number":195556,"mergeCommit":{"message":"[DataUsage][Serverless]
Data usage metrics page enhancements (#195556)\n\n## Summary\r\n\r\nThis
PR is a follow-up of elastic/kibana/pull/193966 and adds: \r\n\r\n1.
Datastreams filter to data usage metrics page.\r\n2. Metrics filter
(hidden for now) that lists out metric types to\r\nrequest.\r\n3.
Refactors to make code easier to maintain.\r\n4. Shows a callout if no
data stream is selected.\r\n\r\n### screen\r\n![Screenshot 2024-10-09 at
17
36\r\n32](https://github.com/user-attachments/assets/a0779c91-25ae-4a64-819e-bc8a626f1f96)\r\n\r\n###
clip\r\n\r\n![latest-metrics-ux](https://github.com/user-attachments/assets/0f4b1a9b-d160-435b-917b-f59c3a5cc9f8)\r\n\r\n###
Checklist\r\n- [x] Any text added follows [EUI's
writing\r\nguidelines](https://elastic.github.io/eui/#/guidelines/writing),
uses\r\nsentence case text and includes
[i18n\r\nsupport](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)\r\n-
[ ] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n- [ ] [Flaky
Test\r\nRunner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1)
was\r\nused on any tests changed\r\n- [x] Any UI touched in this PR does
not create any new axe failures\r\n(run axe in
browser:\r\n[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),\r\n[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))\r\n-
[x] This renders correctly on smaller devices using a
responsive\r\nlayout. (You can test this [in
your\r\nbrowser](https://www.browserstack.com/guide/responsive-testing-on-local-server))\r\n-
[x] This was checked for
[cross-browser\r\ncompatibility](https://www.elastic.co/support/matrix#matrix_browsers)\r\n\r\n---------\r\n\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"a7332ad11611d224a16f2bb3c0d3f207cf746065"}},{"branch":"8.x","label":"v8.16.0","labelRegex":"^v8.16.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->
This commit is contained in:
Ash 2024-10-14 23:01:38 +02:00 committed by GitHub
parent 84faa5c7e9
commit 06a2faa6a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1083 additions and 230 deletions

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { schema } from '@kbn/config-schema';
import { schema, TypeOf } from '@kbn/config-schema';
export const DataStreamsResponseSchema = {
body: () =>
@ -16,3 +16,5 @@ export const DataStreamsResponseSchema = {
})
),
};
export type DataStreamsResponseBodySchemaBody = TypeOf<typeof DataStreamsResponseSchema.body>;

View file

@ -41,7 +41,7 @@ describe('usage_metrics schemas', () => {
).not.toThrow();
});
it('should error if `dataStream` list is empty', () => {
it('should not error if `dataStream` list is empty', () => {
expect(() =>
UsageMetricsRequestSchema.validate({
from: new Date().toISOString(),
@ -49,7 +49,7 @@ describe('usage_metrics schemas', () => {
metricTypes: ['storage_retained'],
dataStreams: [],
})
).toThrowError('[dataStreams]: array size is [0], but cannot be smaller than [1]');
).not.toThrow();
});
it('should error if `dataStream` is given type not array', () => {
@ -71,7 +71,7 @@ describe('usage_metrics schemas', () => {
metricTypes: ['storage_retained'],
dataStreams: ['ds_1', ' '],
})
).toThrow('[dataStreams]: [dataStreams] list cannot contain empty values');
).toThrow('[dataStreams]: list cannot contain empty values');
});
it('should error if `metricTypes` is empty string', () => {
@ -82,7 +82,7 @@ describe('usage_metrics schemas', () => {
dataStreams: ['data_stream_1', 'data_stream_2', 'data_stream_3'],
metricTypes: ' ',
})
).toThrow();
).toThrow('[metricTypes]: could not parse array value from json input');
});
it('should error if `metricTypes` contains an empty item', () => {
@ -93,7 +93,7 @@ describe('usage_metrics schemas', () => {
dataStreams: ['data_stream_1', 'data_stream_2', 'data_stream_3'],
metricTypes: [' ', 'storage_retained'], // First item is invalid
})
).toThrowError(/list cannot contain empty values/);
).toThrow('list cannot contain empty values');
});
it('should error if `metricTypes` is not a valid type', () => {
@ -116,7 +116,7 @@ describe('usage_metrics schemas', () => {
metricTypes: ['storage_retained', 'foo'],
})
).toThrow(
'[metricTypes] must be one of storage_retained, ingest_rate, search_vcu, ingest_vcu, ml_vcu, index_latency, index_rate, search_latency, search_rate'
'[metricTypes]: must be one of ingest_rate, storage_retained, search_vcu, ingest_vcu, ml_vcu, index_latency, index_rate, search_latency, search_rate'
);
});

View file

@ -7,9 +7,11 @@
import { schema, type TypeOf } from '@kbn/config-schema';
const METRIC_TYPE_VALUES = [
'storage_retained',
'ingest_rate',
// note these should be sorted alphabetically as we sort the URL params on the browser side
// before making the request, else the cache key will be different and that would invoke a new request
export const DEFAULT_METRIC_TYPES = ['ingest_rate', 'storage_retained'] as const;
export const METRIC_TYPE_VALUES = [
...DEFAULT_METRIC_TYPES,
'search_vcu',
'ingest_vcu',
'ml_vcu',
@ -21,6 +23,22 @@ const METRIC_TYPE_VALUES = [
export type MetricTypes = (typeof METRIC_TYPE_VALUES)[number];
export const isDefaultMetricType = (metricType: string) =>
// @ts-ignore
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',
search_vcu: 'Search VCU',
ingest_vcu: 'Ingest VCU',
ml_vcu: 'ML VCU',
index_latency: 'Index Latency',
index_rate: 'Index Rate',
search_latency: 'Search Latency',
search_rate: 'Search Rate',
});
// type guard for MetricTypes
export const isMetricType = (type: string): type is MetricTypes =>
METRIC_TYPE_VALUES.includes(type as MetricTypes);
@ -47,21 +65,20 @@ export const UsageMetricsRequestSchema = schema.object({
if (trimmedValues.some((v) => !v.length)) {
return '[metricTypes] list cannot contain empty values';
} else if (trimmedValues.some((v) => !isValidMetricType(v))) {
return `[metricTypes] must be one of ${METRIC_TYPE_VALUES.join(', ')}`;
return `must be one of ${METRIC_TYPE_VALUES.join(', ')}`;
}
},
}),
dataStreams: schema.arrayOf(schema.string(), {
minSize: 1,
validate: (values) => {
if (values.map((v) => v.trim()).some((v) => !v.length)) {
return '[dataStreams] list cannot contain empty values';
return 'list cannot contain empty values';
}
},
}),
});
export type UsageMetricsRequestSchemaQueryParams = TypeOf<typeof UsageMetricsRequestSchema>;
export type UsageMetricsRequestBody = TypeOf<typeof UsageMetricsRequestSchema>;
export const UsageMetricsResponseSchema = {
body: () =>

View file

@ -0,0 +1,150 @@
/*
* 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, { useCallback, useEffect, memo, useState } from 'react';
import { css } from '@emotion/react';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingElastic, EuiCallOut } from '@elastic/eui';
import { Charts } from './charts';
import { useBreadcrumbs } from '../../utils/use_breadcrumbs';
import { useKibanaContextForPlugin } from '../../utils/use_kibana';
import { PLUGIN_NAME } from '../../../common';
import { useGetDataUsageMetrics } from '../../hooks/use_get_usage_metrics';
import { useDataUsageMetricsUrlParams } from '../hooks/use_charts_url_params';
import { DEFAULT_DATE_RANGE_OPTIONS, useDateRangePicker } from '../hooks/use_date_picker';
import { DEFAULT_METRIC_TYPES, UsageMetricsRequestBody } from '../../../common/rest_types';
import { ChartFilters } from './filters/charts_filters';
import { UX_LABELS } from '../translations';
const EuiItemCss = css`
width: 100%;
`;
const FlexItemWithCss = memo(({ children }: { children: React.ReactNode }) => (
<EuiFlexItem css={EuiItemCss}>{children}</EuiFlexItem>
));
export const DataUsageMetrics = () => {
const {
services: { chrome, appParams },
} = useKibanaContextForPlugin();
const {
metricTypes: metricTypesFromUrl,
dataStreams: dataStreamsFromUrl,
startDate: startDateFromUrl,
endDate: endDateFromUrl,
setUrlMetricTypesFilter,
setUrlDateRangeFilter,
} = useDataUsageMetricsUrlParams();
const [metricsFilters, setMetricsFilters] = useState<UsageMetricsRequestBody>({
metricTypes: [...DEFAULT_METRIC_TYPES],
dataStreams: [],
from: DEFAULT_DATE_RANGE_OPTIONS.startDate,
to: DEFAULT_DATE_RANGE_OPTIONS.endDate,
});
useEffect(() => {
if (!metricTypesFromUrl) {
setUrlMetricTypesFilter(metricsFilters.metricTypes.join(','));
}
if (!startDateFromUrl || !endDateFromUrl) {
setUrlDateRangeFilter({ startDate: metricsFilters.from, endDate: metricsFilters.to });
}
}, [
endDateFromUrl,
metricTypesFromUrl,
metricsFilters.from,
metricsFilters.metricTypes,
metricsFilters.to,
setUrlDateRangeFilter,
setUrlMetricTypesFilter,
startDateFromUrl,
]);
useEffect(() => {
setMetricsFilters((prevState) => ({
...prevState,
metricTypes: metricTypesFromUrl?.length ? metricTypesFromUrl : prevState.metricTypes,
dataStreams: dataStreamsFromUrl?.length ? dataStreamsFromUrl : prevState.dataStreams,
}));
}, [metricTypesFromUrl, dataStreamsFromUrl]);
const { dateRangePickerState, onRefreshChange, onTimeChange } = useDateRangePicker();
const {
error,
data,
isFetching,
isFetched,
refetch: refetchDataUsageMetrics,
} = useGetDataUsageMetrics(
{
...metricsFilters,
from: dateRangePickerState.startDate,
to: dateRangePickerState.endDate,
},
{
retry: false,
}
);
const onRefresh = useCallback(() => {
refetchDataUsageMetrics();
}, [refetchDataUsageMetrics]);
const onChangeDataStreamsFilter = useCallback(
(selectedDataStreams: string[]) => {
setMetricsFilters((prevState) => ({ ...prevState, dataStreams: selectedDataStreams }));
},
[setMetricsFilters]
);
const onChangeMetricTypesFilter = useCallback(
(selectedMetricTypes: string[]) => {
setMetricsFilters((prevState) => ({ ...prevState, metricTypes: selectedMetricTypes }));
},
[setMetricsFilters]
);
useBreadcrumbs([{ text: PLUGIN_NAME }], appParams, chrome);
return (
<EuiFlexGroup alignItems="flexStart" direction="column">
<FlexItemWithCss>
<ChartFilters
dateRangePickerState={dateRangePickerState}
isDataLoading={isFetching}
onClick={refetchDataUsageMetrics}
onRefresh={onRefresh}
onRefreshChange={onRefreshChange}
onTimeChange={onTimeChange}
onChangeDataStreamsFilter={onChangeDataStreamsFilter}
onChangeMetricTypesFilter={onChangeMetricTypesFilter}
showMetricsTypesFilter={false}
/>
</FlexItemWithCss>
{!isFetching && error?.message && (
<FlexItemWithCss>
<EuiCallOut
size="s"
title={UX_LABELS.noDataStreamsSelected}
iconType="iInCircle"
color="warning"
/>
</FlexItemWithCss>
)}
<FlexItemWithCss>
{isFetched && data?.metrics ? (
<Charts data={data} />
) : isFetching ? (
<EuiLoadingElastic />
) : null}
</FlexItemWithCss>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,238 @@
/*
* 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 { orderBy } from 'lodash/fp';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiPopoverTitle, EuiSelectable } from '@elastic/eui';
import { useTestIdGenerator } from '../../../hooks/use_test_id_generator';
import {
METRIC_TYPE_API_VALUES_TO_UI_OPTIONS_MAP,
type MetricTypes,
} from '../../../../common/rest_types';
import { ClearAllButton } from './clear_all_button';
import { UX_LABELS } from '../../translations';
import { ChartsFilterPopover } from './charts_filter_popover';
import { FilterItems, FilterName, useChartsFilter } from '../../hooks';
const getSearchPlaceholder = (filterName: FilterName) => {
if (filterName === 'dataStreams') {
return UX_LABELS.filterSearchPlaceholder('data streams');
}
return UX_LABELS.filterSearchPlaceholder('metric types');
};
export const ChartsFilter = memo(
({
filterName,
onChangeFilterOptions,
'data-test-subj': dataTestSubj,
}: {
filterName: FilterName;
onChangeFilterOptions?: (selectedOptions: string[]) => void;
'data-test-subj'?: string;
}) => {
const getTestId = useTestIdGenerator(dataTestSubj);
const isMetricsFilter = filterName === 'metricTypes';
const isDataStreamsFilter = filterName === 'dataStreams';
// popover states and handlers
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const onPopoverButtonClick = useCallback(() => {
setIsPopoverOpen(!isPopoverOpen);
}, [setIsPopoverOpen, isPopoverOpen]);
const onClosePopover = useCallback(() => {
setIsPopoverOpen(false);
}, [setIsPopoverOpen]);
// search string state
const [searchString, setSearchString] = useState('');
const {
areDataStreamsSelectedOnMount,
isLoading,
items,
setItems,
hasActiveFilters,
numActiveFilters,
numFilters,
setAreDataStreamsSelectedOnMount,
setUrlDataStreamsFilter,
setUrlMetricTypesFilter,
} = useChartsFilter({
filterName,
searchString,
});
// track popover state to pin selected options
const wasPopoverOpen = useRef(isPopoverOpen);
useEffect(() => {
return () => {
wasPopoverOpen.current = isPopoverOpen;
};
}, [isPopoverOpen, wasPopoverOpen]);
// compute if selected dataStreams should be pinned
const shouldPinSelectedDataStreams = useCallback(
(isNotChangingOptions: boolean = true) => {
// case 1: when no dataStreams are selected initially
return (
isNotChangingOptions &&
wasPopoverOpen.current &&
isPopoverOpen &&
filterName === 'dataStreams'
);
},
[filterName, isPopoverOpen]
);
// augmented options based on the dataStreams filter
const sortedHostsFilterOptions = useMemo(() => {
if (shouldPinSelectedDataStreams() || areDataStreamsSelectedOnMount) {
// pin checked items to the top
return orderBy('checked', 'asc', items);
}
// return options as are for other filters
return items;
}, [areDataStreamsSelectedOnMount, shouldPinSelectedDataStreams, items]);
const isSearchable = useMemo(() => !isMetricsFilter, [isMetricsFilter]);
const onOptionsChange = useCallback(
(newOptions: FilterItems) => {
// update filter UI options state
setItems(newOptions.map((option) => option));
// compute a selected list of options
const selectedItems = newOptions.reduce<string[]>((acc, curr) => {
if (curr.checked === 'on' && curr.key) {
acc.push(curr.key);
}
return acc;
}, []);
// update URL params
if (isMetricsFilter) {
setUrlMetricTypesFilter(
selectedItems
.map((item) => METRIC_TYPE_API_VALUES_TO_UI_OPTIONS_MAP[item as MetricTypes])
.join()
);
} else if (isDataStreamsFilter) {
setUrlDataStreamsFilter(selectedItems.join());
}
// reset shouldPinSelectedDataStreams, setAreDataStreamsSelectedOnMount
shouldPinSelectedDataStreams(false);
setAreDataStreamsSelectedOnMount(false);
// update overall query state
if (typeof onChangeFilterOptions !== 'undefined') {
onChangeFilterOptions(selectedItems);
}
},
[
setItems,
isMetricsFilter,
isDataStreamsFilter,
shouldPinSelectedDataStreams,
setAreDataStreamsSelectedOnMount,
onChangeFilterOptions,
setUrlMetricTypesFilter,
setUrlDataStreamsFilter,
]
);
// clear all selected options
const onClearAll = useCallback(() => {
// update filter UI options state
setItems(
items.map((option) => {
option.checked = undefined;
return option;
})
);
// update URL params based on filter on page
if (isMetricsFilter) {
setUrlMetricTypesFilter('');
} else if (isDataStreamsFilter) {
setUrlDataStreamsFilter('');
}
if (typeof onChangeFilterOptions !== 'undefined') {
onChangeFilterOptions([]);
}
}, [
setItems,
items,
isMetricsFilter,
isDataStreamsFilter,
onChangeFilterOptions,
setUrlMetricTypesFilter,
setUrlDataStreamsFilter,
]);
return (
<ChartsFilterPopover
closePopover={onClosePopover}
filterName={filterName}
hasActiveFilters={hasActiveFilters}
isPopoverOpen={isPopoverOpen}
numActiveFilters={numActiveFilters}
numFilters={numFilters}
onButtonClick={onPopoverButtonClick}
data-test-subj={dataTestSubj}
>
<EuiSelectable
aria-label={`${filterName}`}
emptyMessage={UX_LABELS.filterEmptyMessage(filterName)}
isLoading={isLoading}
onChange={onOptionsChange}
options={sortedHostsFilterOptions}
searchable={isSearchable ? true : undefined}
searchProps={{
placeholder: getSearchPlaceholder(filterName),
compressed: true,
onChange: (searchValue) => setSearchString(searchValue.trim()),
}}
>
{(list, search) => {
return (
<div
style={{ width: 300 }}
data-test-subj={getTestId(`${filterName}-filter-popoverList`)}
>
{isSearchable && (
<EuiPopoverTitle
data-test-subj={getTestId(`${filterName}-filter-search`)}
paddingSize="s"
>
{search}
</EuiPopoverTitle>
)}
{list}
{!isMetricsFilter && (
<EuiFlexGroup>
<EuiFlexItem>
<ClearAllButton
data-test-subj={getTestId(`${filterName}-filter-clearAllButton`)}
isDisabled={!hasActiveFilters}
onClick={onClearAll}
/>
</EuiFlexItem>
</EuiFlexGroup>
)}
</div>
);
}}
</EuiSelectable>
</ChartsFilterPopover>
);
}
);
ChartsFilter.displayName = 'ChartsFilter';

View file

@ -0,0 +1,81 @@
/*
* 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, { memo, useMemo } from 'react';
import { EuiFilterButton, EuiPopover, useGeneratedHtmlId } from '@elastic/eui';
import { useTestIdGenerator } from '../../../hooks/use_test_id_generator';
import { type FilterName } from '../../hooks/use_charts_filter';
import { FILTER_NAMES } from '../../translations';
export const ChartsFilterPopover = memo(
({
children,
closePopover,
filterName,
hasActiveFilters,
isPopoverOpen,
numActiveFilters,
numFilters,
onButtonClick,
'data-test-subj': dataTestSubj,
}: {
children: React.ReactNode;
closePopover: () => void;
filterName: FilterName;
hasActiveFilters: boolean;
isPopoverOpen: boolean;
numActiveFilters: number;
numFilters: number;
onButtonClick: () => void;
'data-test-subj'?: string;
}) => {
const getTestId = useTestIdGenerator(dataTestSubj);
const filterGroupPopoverId = useGeneratedHtmlId({
prefix: 'filterGroupPopover',
});
const button = useMemo(
() => (
<EuiFilterButton
data-test-subj={getTestId(`${filterName}-filter-popoverButton`)}
iconType="arrowDown"
onClick={onButtonClick}
isSelected={isPopoverOpen}
numFilters={numFilters}
hasActiveFilters={hasActiveFilters}
numActiveFilters={numActiveFilters}
>
{FILTER_NAMES[filterName]}
</EuiFilterButton>
),
[
filterName,
getTestId,
hasActiveFilters,
isPopoverOpen,
numActiveFilters,
numFilters,
onButtonClick,
]
);
return (
<EuiPopover
button={button}
closePopover={closePopover}
id={filterGroupPopoverId}
isOpen={isPopoverOpen}
panelPaddingSize="none"
>
{children}
</EuiPopover>
);
}
);
ChartsFilterPopover.displayName = 'ChartsFilterPopover';

View file

@ -0,0 +1,93 @@
/*
* 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, { 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 { useTestIdGenerator } from '../../../hooks/use_test_id_generator';
import { useGetDataUsageMetrics } from '../../../hooks/use_get_usage_metrics';
import { DateRangePickerValues, UsageMetricsDateRangePicker } from './date_picker';
import { ChartsFilter } from './charts_filter';
interface ChartFiltersProps {
dateRangePickerState: DateRangePickerValues;
isDataLoading: boolean;
onChangeDataStreamsFilter: (selectedDataStreams: string[]) => void;
onChangeMetricTypesFilter?: (selectedMetricTypes: string[]) => void;
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>(
({
dateRangePickerState,
isDataLoading,
onClick,
onChangeMetricTypesFilter,
onChangeDataStreamsFilter,
onRefresh,
onRefreshChange,
onTimeChange,
showMetricsTypesFilter = false,
'data-test-subj': dataTestSubj,
}) => {
const getTestId = useTestIdGenerator(dataTestSubj);
const filters = useMemo(() => {
return (
<>
{showMetricsTypesFilter && (
<ChartsFilter
filterName={'metricTypes'}
onChangeFilterOptions={onChangeMetricTypesFilter}
/>
)}
<ChartsFilter
filterName={'dataStreams'}
onChangeFilterOptions={onChangeDataStreamsFilter}
/>
</>
);
}, [onChangeDataStreamsFilter, onChangeMetricTypesFilter, showMetricsTypesFilter]);
const onClickRefreshButton = useCallback(() => onClick(), [onClick]);
return (
<EuiFlexGroup responsive gutterSize="m">
<EuiFlexItem grow={1}>
<EuiFilterGroup>{filters}</EuiFilterGroup>
</EuiFlexItem>
<EuiFlexItem grow={2}>
<UsageMetricsDateRangePicker
dateRangePickerState={dateRangePickerState}
isDataLoading={isDataLoading}
onRefresh={onRefresh}
onRefreshChange={onRefreshChange}
onTimeChange={onTimeChange}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSuperUpdateButton
data-test-subj={getTestId('super-refresh-button')}
fill={false}
isLoading={isDataLoading}
onClick={onClickRefreshButton}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
);
ChartFilters.displayName = 'ChartFilters';

View file

@ -0,0 +1,43 @@
/*
* 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, { memo } from 'react';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { EuiButtonEmpty } from '@elastic/eui';
import { UX_LABELS } from '../../translations';
const buttonCss = css`
border-top: ${euiThemeVars.euiBorderThin};
border-radius: 0;
`;
export const ClearAllButton = memo(
({
'data-test-subj': dataTestSubj,
isDisabled,
onClick,
}: {
'data-test-subj'?: string;
isDisabled: boolean;
onClick: () => void;
}) => {
return (
<EuiButtonEmpty
css={buttonCss}
data-test-subj={dataTestSubj}
isDisabled={isDisabled}
onClick={onClick}
iconType="cross"
color="danger"
>
{UX_LABELS.filterClearAll}
</EuiButtonEmpty>
);
}
);
ClearAllButton.displayName = 'ClearAllButton';

View file

@ -6,8 +6,7 @@
*/
import React, { memo, useState } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiSuperDatePicker, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { EuiSuperDatePicker } from '@elastic/eui';
import type { IUnifiedSearchPluginServices } from '@kbn/unified-search-plugin/public';
import type { EuiSuperDatePickerRecentRange } from '@elastic/eui';
import { useKibana } from '@kbn/kibana-react-plugin/public';
@ -37,7 +36,6 @@ interface UsageMetricsDateRangePickerProps {
export const UsageMetricsDateRangePicker = memo<UsageMetricsDateRangePickerProps>(
({ dateRangePickerState, isDataLoading, onRefresh, onRefreshChange, onTimeChange }) => {
const { euiTheme } = useEuiTheme();
const kibana = useKibana<IUnifiedSearchPluginServices>();
const { uiSettings } = kibana.services;
const [commonlyUsedRanges] = useState(() => {
@ -55,32 +53,22 @@ export const UsageMetricsDateRangePicker = memo<UsageMetricsDateRangePickerProps
});
return (
<div
css={css`
padding-bottom: ${euiTheme.size.l};
`}
>
<EuiFlexGroup alignItems="center" direction="row" responsive={false} gutterSize="s">
<EuiFlexItem>
<EuiSuperDatePicker
isLoading={isDataLoading}
dateFormat={uiSettings.get('dateFormat')}
commonlyUsedRanges={commonlyUsedRanges}
end={dateRangePickerState.endDate}
isPaused={!dateRangePickerState.autoRefreshOptions.enabled}
onTimeChange={onTimeChange}
onRefreshChange={onRefreshChange}
refreshInterval={dateRangePickerState.autoRefreshOptions.duration}
onRefresh={onRefresh}
recentlyUsedRanges={dateRangePickerState.recentlyUsedDateRanges}
start={dateRangePickerState.startDate}
showUpdateButton
updateButtonProps={{ iconOnly: false, fill: false }}
width="auto"
/>
</EuiFlexItem>
</EuiFlexGroup>
</div>
<EuiSuperDatePicker
isLoading={isDataLoading}
dateFormat={uiSettings.get('dateFormat')}
commonlyUsedRanges={commonlyUsedRanges}
end={dateRangePickerState.endDate}
isPaused={!dateRangePickerState.autoRefreshOptions.enabled}
onTimeChange={onTimeChange}
onRefreshChange={onRefreshChange}
refreshInterval={dateRangePickerState.autoRefreshOptions.duration}
onRefresh={onRefresh}
recentlyUsedRanges={dateRangePickerState.recentlyUsedDateRanges}
start={dateRangePickerState.startDate}
showUpdateButton={false}
updateButtonProps={{ iconOnly: false, fill: false }}
width="auto"
/>
);
}
);

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { PropsWithChildren } from 'react';
import React, { memo, useMemo } from 'react';
import type { CommonProps } from '@elastic/eui';
import {
EuiPageHeader,
EuiPageSection,
EuiFlexGroup,
EuiFlexItem,
EuiTitle,
EuiSpacer,
} from '@elastic/eui';
interface DataUsagePageProps {
title: React.ReactNode;
subtitle?: React.ReactNode;
actions?: React.ReactNode;
restrictWidth?: boolean | number;
hasBottomBorder?: boolean;
hideHeader?: boolean;
}
export const DataUsagePage = memo<PropsWithChildren<DataUsagePageProps & CommonProps>>(
({ title, subtitle, children, restrictWidth = false, hasBottomBorder = true, ...otherProps }) => {
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>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
);
}, [, title]);
const description = useMemo(() => {
return subtitle ? (
<span data-test-subj="dataUsage-page-description">{subtitle}</span>
) : undefined;
}, [subtitle]);
return (
<div {...otherProps}>
<>
<EuiPageHeader
pageTitle={header}
description={description}
bottomBorder={hasBottomBorder}
restrictWidth={restrictWidth}
data-test-subj={'dataUsage-page-header'}
/>
<EuiSpacer size="l" />
</>
<EuiPageSection paddingSize="none" color="transparent" restrictWidth={restrictWidth}>
{children}
</EuiPageSection>
</div>
);
}
);
DataUsagePage.displayName = 'DataUsagePage';

View file

@ -1,146 +0,0 @@
/*
* 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, { useCallback, useEffect, useState } from 'react';
import {
EuiTitle,
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingElastic,
EuiPageSection,
EuiText,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { UsageMetricsRequestSchemaQueryParams } from '../../common/rest_types';
import { Charts } from './components/charts';
import { UsageMetricsDateRangePicker } from './components/date_picker';
import { useBreadcrumbs } from '../utils/use_breadcrumbs';
import { useKibanaContextForPlugin } from '../utils/use_kibana';
import { PLUGIN_NAME } from '../../common';
import { useGetDataUsageMetrics } from '../hooks/use_get_usage_metrics';
import { DEFAULT_DATE_RANGE_OPTIONS, useDateRangePicker } from './hooks/use_date_picker';
import { useDataUsageMetricsUrlParams } from './hooks/use_charts_url_params';
export const DataUsage = () => {
const {
services: { chrome, appParams },
} = useKibanaContextForPlugin();
const {
metricTypes: metricTypesFromUrl,
dataStreams: dataStreamsFromUrl,
startDate: startDateFromUrl,
endDate: endDateFromUrl,
setUrlMetricTypesFilter,
setUrlDateRangeFilter,
} = useDataUsageMetricsUrlParams();
const [metricsFilters, setMetricsFilters] = useState<UsageMetricsRequestSchemaQueryParams>({
metricTypes: ['storage_retained', 'ingest_rate'],
// TODO: Replace with data streams from /data_streams api
dataStreams: [
'.alerts-ml.anomaly-detection-health.alerts-default',
'.alerts-stack.alerts-default',
],
from: DEFAULT_DATE_RANGE_OPTIONS.startDate,
to: DEFAULT_DATE_RANGE_OPTIONS.endDate,
});
useEffect(() => {
if (!metricTypesFromUrl) {
setUrlMetricTypesFilter(metricsFilters.metricTypes.join(','));
}
if (!startDateFromUrl || !endDateFromUrl) {
setUrlDateRangeFilter({ startDate: metricsFilters.from, endDate: metricsFilters.to });
}
}, [
endDateFromUrl,
metricTypesFromUrl,
metricsFilters.from,
metricsFilters.metricTypes,
metricsFilters.to,
setUrlDateRangeFilter,
setUrlMetricTypesFilter,
startDateFromUrl,
]);
useEffect(() => {
setMetricsFilters((prevState) => ({
...prevState,
metricTypes: metricTypesFromUrl?.length ? metricTypesFromUrl : prevState.metricTypes,
dataStreams: dataStreamsFromUrl?.length ? dataStreamsFromUrl : prevState.dataStreams,
}));
}, [metricTypesFromUrl, dataStreamsFromUrl]);
const { dateRangePickerState, onRefreshChange, onTimeChange } = useDateRangePicker();
const {
error,
data,
isFetching,
isFetched,
refetch: refetchDataUsageMetrics,
} = useGetDataUsageMetrics(
{
...metricsFilters,
from: dateRangePickerState.startDate,
to: dateRangePickerState.endDate,
},
{
retry: false,
}
);
const onRefresh = useCallback(() => {
refetchDataUsageMetrics();
}, [refetchDataUsageMetrics]);
useBreadcrumbs([{ text: PLUGIN_NAME }], appParams, chrome);
// TODO: show a toast?
if (!isFetching && error?.body) {
return <div>{error.body.message}</div>;
}
return (
<>
<EuiTitle size="l">
<h2>
{i18n.translate('xpack.dataUsage.pageTitle', {
defaultMessage: 'Data Usage',
})}
</h2>
</EuiTitle>
<EuiSpacer size="m" />
<EuiPageSection paddingSize="none">
<EuiFlexGroup alignItems="flexStart">
<EuiFlexItem>
<EuiText color="subdued">
<FormattedMessage
id="xpack.dataUsage.description"
defaultMessage="Monitor data ingested and retained by data streams."
/>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<UsageMetricsDateRangePicker
dateRangePickerState={dateRangePickerState}
isDataLoading={isFetching}
onRefresh={onRefresh}
onRefreshChange={onRefreshChange}
onTimeChange={onTimeChange}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
{isFetched && data ? <Charts data={data} /> : <EuiLoadingElastic />}
</EuiPageSection>
</>
);
};

View file

@ -0,0 +1,25 @@
/*
* 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 { DataUsagePage } from './components/page';
import { DATA_USAGE_PAGE } from './translations';
import { DataUsageMetrics } from './components/data_usage_metrics';
export const DataUsageMetricsPage = () => {
return (
<DataUsagePage
data-test-subj="DataUsagePage"
title={DATA_USAGE_PAGE.title}
subtitle={DATA_USAGE_PAGE.subTitle}
>
<DataUsageMetrics />
</DataUsagePage>
);
};
DataUsageMetricsPage.displayName = 'DataUsageMetricsPage';

View file

@ -5,5 +5,6 @@
* 2.0.
*/
// temporary type until agreed on
export type MetricKey = 'ingestedMax' | 'retainedMax';
export { useChartsFilter, type FilterName, type FilterItems } from './use_charts_filter';
export { useDataUsageMetricsUrlParams } from './use_charts_url_params';
export { useDateRangePicker } from './use_date_picker';

View file

@ -0,0 +1,123 @@
/*
* 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 { useState, useEffect, useMemo } from 'react';
import {
isDefaultMetricType,
METRIC_TYPE_API_VALUES_TO_UI_OPTIONS_MAP,
METRIC_TYPE_VALUES,
} from '../../../common/rest_types';
import { useGetDataUsageDataStreams } from '../../hooks/use_get_data_streams';
import { FILTER_NAMES } from '../translations';
import { useDataUsageMetricsUrlParams } from './use_charts_url_params';
export type FilterName = keyof typeof FILTER_NAMES;
export type FilterItems = Array<{
key?: string;
label: string;
isGroupLabel?: boolean;
checked?: 'on' | undefined;
'data-test-subj'?: string;
}>;
export const useChartsFilter = ({
filterName,
searchString,
}: {
filterName: FilterName;
searchString: string;
}): {
areDataStreamsSelectedOnMount: boolean;
isLoading: boolean;
items: FilterItems;
setItems: React.Dispatch<React.SetStateAction<FilterItems>>;
hasActiveFilters: boolean;
numActiveFilters: number;
numFilters: number;
setAreDataStreamsSelectedOnMount: (value: React.SetStateAction<boolean>) => void;
setUrlDataStreamsFilter: ReturnType<
typeof useDataUsageMetricsUrlParams
>['setUrlDataStreamsFilter'];
setUrlMetricTypesFilter: ReturnType<
typeof useDataUsageMetricsUrlParams
>['setUrlMetricTypesFilter'];
} => {
const {
dataStreams: selectedDataStreamsFromUrl,
setUrlMetricTypesFilter,
setUrlDataStreamsFilter,
} = useDataUsageMetricsUrlParams();
const isMetricTypesFilter = filterName === 'metricTypes';
const isDataStreamsFilter = filterName === 'dataStreams';
const { data: dataStreams, isFetching } = useGetDataUsageDataStreams({
searchString,
selectedDataStreams: selectedDataStreamsFromUrl,
});
// track the state of selected data streams via URL
// when the page is loaded via selected data streams on URL
const [areDataStreamsSelectedOnMount, setAreDataStreamsSelectedOnMount] =
useState<boolean>(false);
useEffect(() => {
if (selectedDataStreamsFromUrl && selectedDataStreamsFromUrl.length > 0) {
setAreDataStreamsSelectedOnMount(true);
}
// don't sync with changes to further selectedDataStreamsFromUrl
// 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: isDefaultMetricType(metricType) ? 'on' : undefined, // default metrics are selected by default
disabled: isDefaultMetricType(metricType),
'data-test-subj': `${filterName}-filter-option`,
}))
: []
);
useEffect(() => {
if (isDataStreamsFilter && dataStreams) {
setItems(
dataStreams?.map((dataStream) => ({
key: dataStream.name,
label: dataStream.name,
checked: dataStream.selected ? 'on' : undefined,
'data-test-subj': `${filterName}-filter-option`,
}))
);
}
}, [dataStreams, filterName, isDataStreamsFilter, setItems]);
const hasActiveFilters = useMemo(() => !!items.find((item) => item.checked === 'on'), [items]);
const numActiveFilters = useMemo(
() => items.filter((item) => item.checked === 'on').length,
[items]
);
const numFilters = useMemo(
() => items.filter((item) => item.key && item.checked !== 'on').length,
[items]
);
return {
areDataStreamsSelectedOnMount,
isLoading: isDataStreamsFilter && isFetching,
items,
setItems,
hasActiveFilters,
numActiveFilters,
numFilters,
setAreDataStreamsSelectedOnMount,
setUrlMetricTypesFilter,
setUrlDataStreamsFilter,
};
};

View file

@ -11,7 +11,7 @@ import type {
OnRefreshChangeProps,
} from '@elastic/eui/src/components/date_picker/types';
import { useDataUsageMetricsUrlParams } from './use_charts_url_params';
import { DateRangePickerValues } from '../components/date_picker';
import { DateRangePickerValues } from '../components/filters/date_picker';
export const DEFAULT_DATE_RANGE_OPTIONS = Object.freeze({
autoRefreshOptions: {

View file

@ -0,0 +1,54 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const FILTER_NAMES = Object.freeze({
metricTypes: i18n.translate('xpack.dataUsage.metrics.filter.metricTypes', {
defaultMessage: 'Metric types',
}),
dataStreams: i18n.translate('xpack.dataUsage.metrics.filter.dataStreams', {
defaultMessage: 'Data streams',
}),
});
export const CHART_TITLES = Object.freeze({
ingest_rate: i18n.translate('xpack.dataUsage.charts.ingestedMax', {
defaultMessage: 'Data Ingested',
}),
storage_retained: i18n.translate('xpack.dataUsage.charts.retainedMax', {
defaultMessage: 'Data Retained in Storage',
}),
});
export const DATA_USAGE_PAGE = Object.freeze({
title: i18n.translate('xpack.dataUsage.name', {
defaultMessage: 'Data Usage',
}),
subTitle: i18n.translate('xpack.dataUsage.pageSubtitle', {
defaultMessage: 'Monitor data ingested and retained by data streams.',
}),
});
export const UX_LABELS = Object.freeze({
filterClearAll: i18n.translate('xpack.dataUsage.metrics.filter.clearAll', {
defaultMessage: 'Clear all',
}),
filterSearchPlaceholder: (filterName: string) =>
i18n.translate('xpack.dataUsage.metrics.filter.searchPlaceholder', {
defaultMessage: 'Search {filterName}',
values: { filterName },
}),
filterEmptyMessage: (filterName: string) =>
i18n.translate('xpack.dataUsage.metrics.filter.emptyMessage', {
defaultMessage: 'No {filterName} available',
values: { filterName },
}),
noDataStreamsSelected: i18n.translate('xpack.dataUsage.metrics.noDataStreamsSelected', {
defaultMessage: 'Select one or more data streams to view data usage metrics.',
}),
});

View file

@ -16,7 +16,7 @@ import { PerformanceContextProvider } from '@kbn/ebt-tools';
import { useKibanaContextForPluginProvider } from './utils/use_kibana';
import { DataUsageStartDependencies, DataUsagePublicStart } from './types';
import { PLUGIN_ID } from '../common';
import { DataUsage } from './app/data_usage';
import { DataUsageMetricsPage } from './app/data_usage_metrics_page';
import { DataUsageReactQueryClientProvider } from '../common/query_client';
export const renderApp = (
@ -53,7 +53,7 @@ const AppWithExecutionContext = ({
<Router history={params.history}>
<PerformanceContextProvider>
<Routes>
<Route path="/" exact={true} component={DataUsage} />
<Route path="/" exact={true} component={DataUsageMetricsPage} />
</Routes>
</PerformanceContextProvider>
</Router>

View file

@ -0,0 +1,84 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import { DATA_USAGE_DATA_STREAMS_API_ROUTE } from '../../common';
import { useKibanaContextForPlugin } from '../utils/use_kibana';
type GetDataUsageDataStreamsResponse = Array<{
name: string;
selected: boolean;
}>;
const PAGING_PARAMS = Object.freeze({
default: 50,
all: 10000,
});
export const useGetDataUsageDataStreams = ({
searchString,
selectedDataStreams,
options = {},
}: {
searchString: string;
selectedDataStreams?: string[];
options?: UseQueryOptions<GetDataUsageDataStreamsResponse, IHttpFetchError>;
}): UseQueryResult<GetDataUsageDataStreamsResponse, IHttpFetchError> => {
const http = useKibanaContextForPlugin().services.http;
return useQuery<GetDataUsageDataStreamsResponse, IHttpFetchError>({
queryKey: ['get-data-usage-data-streams'],
...options,
keepPreviousData: true,
queryFn: async () => {
const dataStreamsResponse = await http.get<GetDataUsageDataStreamsResponse>(
DATA_USAGE_DATA_STREAMS_API_ROUTE,
{
version: '1',
query: {},
}
);
const augmentedDataStreamsBasedOnSelectedItems = dataStreamsResponse.reduce<{
selected: GetDataUsageDataStreamsResponse;
rest: GetDataUsageDataStreamsResponse;
}>(
(acc, list) => {
const item = {
name: list.name,
};
if (selectedDataStreams?.includes(list.name)) {
acc.selected.push({ ...item, selected: true });
} else {
acc.rest.push({ ...item, selected: false });
}
return acc;
},
{ selected: [], rest: [] }
);
let selectedDataStreamsCount = 0;
if (selectedDataStreams) {
selectedDataStreamsCount = selectedDataStreams.length;
}
return [
...augmentedDataStreamsBasedOnSelectedItems.selected,
...augmentedDataStreamsBasedOnSelectedItems.rest,
].slice(
0,
selectedDataStreamsCount >= PAGING_PARAMS.default
? selectedDataStreamsCount + 10
: PAGING_PARAMS.default
);
},
});
};

View file

@ -8,10 +8,7 @@
import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import {
UsageMetricsRequestSchemaQueryParams,
UsageMetricsResponseSchemaBody,
} from '../../common/rest_types';
import { UsageMetricsRequestBody, UsageMetricsResponseSchemaBody } from '../../common/rest_types';
import { DATA_USAGE_METRICS_API_ROUTE } from '../../common';
import { useKibanaContextForPlugin } from '../utils/use_kibana';
@ -21,7 +18,7 @@ interface ErrorType {
}
export const useGetDataUsageMetrics = (
body: UsageMetricsRequestSchemaQueryParams,
body: UsageMetricsRequestBody,
options: UseQueryOptions<UsageMetricsResponseSchemaBody, IHttpFetchError<ErrorType>> = {}
): UseQueryResult<UsageMetricsResponseSchemaBody, IHttpFetchError<ErrorType>> => {
const http = useKibanaContextForPlugin().services.http;

View file

@ -0,0 +1,19 @@
/*
* 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 { useCallback } from 'react';
export const useTestIdGenerator = (prefix?: string): ((suffix?: string) => string | undefined) => {
return useCallback(
(suffix: string = ''): string | undefined => {
if (prefix) {
return `${prefix}${suffix ? `-${suffix}` : ''}`;
}
},
[prefix]
);
};

View file

@ -5,36 +5,50 @@
* 2.0.
*/
import { RequestHandler } from '@kbn/core/server';
import { type ElasticsearchClient, RequestHandler } from '@kbn/core/server';
import { DataUsageContext, DataUsageRequestHandlerContext } from '../../types';
import { errorHandler } from '../error_handler';
export interface MeteringStats {
name: string;
num_docs: number;
size_in_bytes: number;
}
interface MeteringStatsResponse {
datastreams: MeteringStats[];
}
const getMeteringStats = (client: ElasticsearchClient) => {
return client.transport.request<MeteringStatsResponse>({
method: 'GET',
path: '/_metering/stats',
});
};
export const getDataStreamsHandler = (
dataUsageContext: DataUsageContext
): RequestHandler<never, unknown, DataUsageRequestHandlerContext> => {
const logger = dataUsageContext.logFactory.get('dataStreamsRoute');
return async (context, _, response) => {
logger.debug(`Retrieving user data streams`);
logger.debug('Retrieving user data streams');
try {
const core = await context.core;
const esClient = core.elasticsearch.client.asCurrentUser;
const { datastreams: meteringStats } = await getMeteringStats(
core.elasticsearch.client.asSecondaryAuthUser
);
const { data_streams: dataStreamsResponse } = await esClient.indices.dataStreamsStats({
name: '*',
expand_wildcards: 'all',
});
const sorted = dataStreamsResponse
.sort((a, b) => b.store_size_bytes - a.store_size_bytes)
.map((dataStream) => ({
name: dataStream.data_stream,
storageSizeBytes: dataStream.store_size_bytes,
const body = meteringStats
.sort((a, b) => b.size_in_bytes - a.size_in_bytes)
.map((stat) => ({
name: stat.name,
storageSizeBytes: stat.size_in_bytes,
}));
return response.ok({
body: sorted,
body,
});
} catch (error) {
return errorHandler(logger, response, error);

View file

@ -11,24 +11,20 @@ import {
MetricTypes,
UsageMetricsAutoOpsResponseSchema,
UsageMetricsAutoOpsResponseSchemaBody,
UsageMetricsRequestSchemaQueryParams,
UsageMetricsRequestBody,
UsageMetricsResponseSchemaBody,
} from '../../../common/rest_types';
import { DataUsageContext, DataUsageRequestHandlerContext } from '../../types';
import { errorHandler } from '../error_handler';
import { CustomHttpRequestError } from '../../utils';
const formatStringParams = <T extends string>(value: T | T[]): T[] | MetricTypes[] =>
typeof value === 'string' ? [value] : value;
export const getUsageMetricsHandler = (
dataUsageContext: DataUsageContext
): RequestHandler<
never,
UsageMetricsRequestSchemaQueryParams,
unknown,
DataUsageRequestHandlerContext
> => {
): RequestHandler<never, unknown, UsageMetricsRequestBody, DataUsageRequestHandlerContext> => {
const logger = dataUsageContext.logFactory.get('usageMetricsRoute');
return async (context, request, response) => {
@ -36,8 +32,16 @@ export const getUsageMetricsHandler = (
const core = await context.core;
const esClient = core.elasticsearch.client.asCurrentUser;
const { from, to, metricTypes, dataStreams: requestDsNames } = request.query;
logger.debug(`Retrieving usage metrics`);
const { from, to, metricTypes, dataStreams: requestDsNames } = request.body;
if (!requestDsNames?.length) {
return errorHandler(
logger,
response,
new CustomHttpRequestError('[request body.dataStreams]: no data streams selected', 400)
);
}
const { data_streams: dataStreamsResponse }: IndicesGetDataStreamResponse =
await esClient.indices.getDataStream({
@ -52,10 +56,10 @@ export const getUsageMetricsHandler = (
dataStreams: formatStringParams(dataStreamsResponse.map((ds) => ds.name)),
});
const processedMetrics = transformMetricsData(metrics);
const body = transformMetricsData(metrics);
return response.ok({
body: processedMetrics,
body,
});
} catch (error) {
logger.error(`Error retrieving usage metrics: ${error.message}`);

View file

@ -12,15 +12,12 @@ import apm from 'elastic-apm-node';
import type { AxiosError, AxiosRequestConfig } from 'axios';
import axios from 'axios';
import { LogMeta } from '@kbn/core/server';
import {
UsageMetricsRequestSchemaQueryParams,
UsageMetricsResponseSchemaBody,
} from '../../common/rest_types';
import { UsageMetricsResponseSchemaBody } from '../../common/rest_types';
import { appContextService } from '../app_context';
import { AutoOpsConfig } from '../types';
class AutoOpsAPIService {
public async autoOpsUsageMetricsAPI(requestBody: UsageMetricsRequestSchemaQueryParams) {
public async autoOpsUsageMetricsAPI(requestBody: UsageMetricsResponseSchemaBody) {
const logger = appContextService.getLogger().get();
const traceId = apm.currentTransaction?.traceparent;
const withRequestIdMessage = (message: string) => `${message} [Request Id: ${traceId}]`;

View file

@ -24,11 +24,11 @@
"@kbn/logging",
"@kbn/deeplinks-observability",
"@kbn/unified-search-plugin",
"@kbn/i18n-react",
"@kbn/core-http-browser",
"@kbn/core-chrome-browser",
"@kbn/features-plugin",
"@kbn/index-management-shared-types",
"@kbn/ui-theme",
"@kbn/repo-info",
"@kbn/cloud-plugin",
"@kbn/server-http-tools",