Prometheus: Fix label names select component when there are too many options (#92026)

* add more doc info for truncate function and how we use it

* truncate label names and allow users to search all labels on typing

* remove unused import

* handle labels select in variable query in addition with truncated list
This commit is contained in:
Brendan O'Handley 2024-08-16 13:55:03 -05:00 committed by GitHub
parent def8104e74
commit 68f545210d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 85 additions and 10 deletions

View File

@ -1,11 +1,13 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/components/VariableQueryEditor.tsx
import debounce from 'debounce-promise';
import { FormEvent, useCallback, useEffect, useState } from 'react';
import { QueryEditorProps, SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { InlineField, InlineFieldRow, Input, Select, TextArea } from '@grafana/ui';
import { AsyncSelect, InlineField, InlineFieldRow, Input, Select, TextArea } from '@grafana/ui';
import { PrometheusDatasource } from '../datasource';
import { truncateResult } from '../language_utils';
import {
migrateVariableEditorBackToVariableSupport,
migrateVariableQueryToEditor,
@ -56,7 +58,20 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource, range }:
const [classicQuery, setClassicQuery] = useState('');
// list of label names for label_values(), /api/v1/labels, contains the same results as label_names() function
const [labelOptions, setLabelOptions] = useState<Array<SelectableValue<string>>>([]);
const [truncatedLabelOptions, setTruncatedLabelOptions] = useState<Array<SelectableValue<string>>>([]);
const [allLabelOptions, setAllLabelOptions] = useState<Array<SelectableValue<string>>>([]);
/**
* Set the both allLabels and truncatedLabels
*
* @param names
* @param variables
*/
function setLabels(names: SelectableValue[], variables: SelectableValue[]) {
setAllLabelOptions([...variables, ...names]);
const truncatedNames = truncateResult(names);
setTruncatedLabelOptions([...variables, ...truncatedNames]);
}
// label filters have been added as a filter for metrics in label values query type
const [labelFilters, setLabelFilters] = useState<QueryBuilderLabelFilter[]>([]);
@ -100,7 +115,7 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource, range }:
// get all the labels
datasource.getTagKeys({ filters: [] }).then((labelNames: Array<{ text: string }>) => {
const names = labelNames.map(({ text }) => ({ label: text, value: text }));
setLabelOptions([...variables, ...names]);
setLabels(names, variables);
});
} else {
// fetch the labels filtered by the metric
@ -110,7 +125,7 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource, range }:
datasource.languageProvider.fetchLabelsWithMatch(expr).then((labelsIndex: Record<string, string[]>) => {
const labelNames = Object.keys(labelsIndex);
const names = labelNames.map((value) => ({ label: value, value: value }));
setLabelOptions([...variables, ...names]);
setLabels(names, variables);
});
}
}, [datasource, qryType, metric]);
@ -220,6 +235,18 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource, range }:
return { metric: metric, labels: labelFilters, operations: [] };
}, [metric, labelFilters]);
/**
* Debounce a search through all the labels possible and truncate by .
*/
const labelNamesSearch = debounce((query: string) => {
// we limit the select to show 1000 options,
// but we still search through all the possible options
const results = allLabelOptions.filter((label) => {
return label.value?.includes(query);
});
return truncateResult(results);
}, 300);
return (
<>
<InlineFieldRow>
@ -256,14 +283,15 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource, range }:
</div>
}
>
<Select
<AsyncSelect
aria-label="label-select"
onChange={onLabelChange}
value={label}
options={labelOptions}
defaultOptions={truncatedLabelOptions}
width={25}
allowCustomValue
isClearable={true}
loadOptions={labelNamesSearch}
data-testid={selectors.components.DataSource.Prometheus.variableQueryEditor.labelValues.labelSelect}
/>
</InlineField>

View File

@ -526,6 +526,16 @@ export function getPrometheusTime(date: string | DateTime, roundUp: boolean) {
return Math.ceil(date.valueOf() / 1000);
}
/**
* Used to truncate metrics, label names and label value in the query builder select components
* to improve frontend performance. This is best used with an async select component including
* the loadOptions property where we should still allow users to search all results with a string.
* This can be done either storing the total results or querying an api that allows for matching a query.
*
* @param array
* @param limit
* @returns
*/
export function truncateResult<T>(array: T[], limit?: number): T[] {
if (limit === undefined) {
limit = PROMETHEUS_QUERY_BUILDER_MAX_RESULTS;

View File

@ -46,6 +46,7 @@ export function LabelFilterItem({
// instead, we explicitly control the menu visibility and prevent showing it until the options have fully loaded
const [labelNamesMenuOpen, setLabelNamesMenuOpen] = useState(false);
const [labelValuesMenuOpen, setLabelValuesMenuOpen] = useState(false);
const [allLabels, setAllLabels] = useState<SelectableValue[]>([]);
const isMultiSelect = (operator = item.op) => {
return operators.find((op) => op.label === operator)?.isMultiValue;
@ -73,13 +74,25 @@ export function LabelFilterItem({
debounceDuration
);
/**
* Debounce a search through all the labels possible and truncate by .
*/
const labelNamesSearch = debounce((query: string) => {
// we limit the select to show 1000 options,
// but we still search through all the possible options
const results = allLabels.filter((label) => {
return label.value.includes(query);
});
return truncateResult(results);
}, debounceDuration);
const itemValue = item?.value ?? '';
return (
<div key={itemValue} data-testid="prometheus-dimensions-filter-item">
<InputGroup>
{/* Label name select, loads all values at once */}
<Select
<AsyncSelect
placeholder="Select label"
data-testid={selectors.components.QueryBuilder.labelSelect}
inputId="prometheus-dimensions-filter-item-key"
@ -89,15 +102,20 @@ export function LabelFilterItem({
onOpenMenu={async () => {
setState({ isLoadingLabelNames: true });
const labelNames = await onGetLabelNames(item);
// store all label names to allow for full label searching by typing in the select option, see loadOptions function labelNamesSearch
setAllLabels(labelNames);
setLabelNamesMenuOpen(true);
setState({ labelNames, isLoadingLabelNames: undefined });
// truncate the results the same amount as the metric select
const truncatedLabelNames = truncateResult(labelNames);
setState({ labelNames: truncatedLabelNames, isLoadingLabelNames: undefined });
}}
onCloseMenu={() => {
setLabelNamesMenuOpen(false);
}}
isOpen={labelNamesMenuOpen}
isLoading={state.isLoadingLabelNames ?? false}
options={state.labelNames}
loadOptions={labelNamesSearch}
defaultOptions={state.labelNames}
onChange={(change) => {
if (change.label) {
onChange({

View File

@ -1,14 +1,28 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/LabelFilters.test.tsx
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ComponentProps } from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { selectOptionInTest } from '../../test/helpers/selectOptionInTest';
import { getLabelSelects } from '../testUtils';
import { LabelFilters, MISSING_LABEL_FILTER_ERROR_MESSAGE, LabelFiltersProps } from './LabelFilters';
describe('LabelFilters', () => {
it('truncates list of label names to 1000', async () => {
const manyMockValues = [...Array(1001).keys()].map((idx: number) => {
return { label: 'random_label' + idx };
});
setup({ onGetLabelNames: jest.fn().mockResolvedValue(manyMockValues) });
await openLabelNamesSelect();
await waitFor(() => expect(screen.getAllByTestId(selectors.components.Select.option)).toHaveLength(1000));
});
it('renders empty input without labels', async () => {
setup();
expect(screen.getAllByText('Select label')).toHaveLength(1);
@ -162,3 +176,8 @@ function setup(propOverrides?: Partial<ComponentProps<typeof LabelFilters>>) {
function getAddButton() {
return screen.getByLabelText(/Add/);
}
async function openLabelNamesSelect() {
const select = screen.getByText('Select label').parentElement!;
await userEvent.click(select);
}