Skip to content

Commit 68f5452

Browse files
authored
Prometheus: Fix label names select component when there are too many options (grafana#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
1 parent def8104 commit 68f5452

File tree

4 files changed

+85
-10
lines changed

4 files changed

+85
-10
lines changed

packages/grafana-prometheus/src/components/VariableQueryEditor.tsx

+34-6
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/components/VariableQueryEditor.tsx
2+
import debounce from 'debounce-promise';
23
import { FormEvent, useCallback, useEffect, useState } from 'react';
34

45
import { QueryEditorProps, SelectableValue } from '@grafana/data';
56
import { selectors } from '@grafana/e2e-selectors';
6-
import { InlineField, InlineFieldRow, Input, Select, TextArea } from '@grafana/ui';
7+
import { AsyncSelect, InlineField, InlineFieldRow, Input, Select, TextArea } from '@grafana/ui';
78

89
import { PrometheusDatasource } from '../datasource';
10+
import { truncateResult } from '../language_utils';
911
import {
1012
migrateVariableEditorBackToVariableSupport,
1113
migrateVariableQueryToEditor,
@@ -56,7 +58,20 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource, range }:
5658
const [classicQuery, setClassicQuery] = useState('');
5759

5860
// list of label names for label_values(), /api/v1/labels, contains the same results as label_names() function
59-
const [labelOptions, setLabelOptions] = useState<Array<SelectableValue<string>>>([]);
61+
const [truncatedLabelOptions, setTruncatedLabelOptions] = useState<Array<SelectableValue<string>>>([]);
62+
const [allLabelOptions, setAllLabelOptions] = useState<Array<SelectableValue<string>>>([]);
63+
64+
/**
65+
* Set the both allLabels and truncatedLabels
66+
*
67+
* @param names
68+
* @param variables
69+
*/
70+
function setLabels(names: SelectableValue[], variables: SelectableValue[]) {
71+
setAllLabelOptions([...variables, ...names]);
72+
const truncatedNames = truncateResult(names);
73+
setTruncatedLabelOptions([...variables, ...truncatedNames]);
74+
}
6075

6176
// label filters have been added as a filter for metrics in label values query type
6277
const [labelFilters, setLabelFilters] = useState<QueryBuilderLabelFilter[]>([]);
@@ -100,7 +115,7 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource, range }:
100115
// get all the labels
101116
datasource.getTagKeys({ filters: [] }).then((labelNames: Array<{ text: string }>) => {
102117
const names = labelNames.map(({ text }) => ({ label: text, value: text }));
103-
setLabelOptions([...variables, ...names]);
118+
setLabels(names, variables);
104119
});
105120
} else {
106121
// fetch the labels filtered by the metric
@@ -110,7 +125,7 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource, range }:
110125
datasource.languageProvider.fetchLabelsWithMatch(expr).then((labelsIndex: Record<string, string[]>) => {
111126
const labelNames = Object.keys(labelsIndex);
112127
const names = labelNames.map((value) => ({ label: value, value: value }));
113-
setLabelOptions([...variables, ...names]);
128+
setLabels(names, variables);
114129
});
115130
}
116131
}, [datasource, qryType, metric]);
@@ -220,6 +235,18 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource, range }:
220235
return { metric: metric, labels: labelFilters, operations: [] };
221236
}, [metric, labelFilters]);
222237

238+
/**
239+
* Debounce a search through all the labels possible and truncate by .
240+
*/
241+
const labelNamesSearch = debounce((query: string) => {
242+
// we limit the select to show 1000 options,
243+
// but we still search through all the possible options
244+
const results = allLabelOptions.filter((label) => {
245+
return label.value?.includes(query);
246+
});
247+
return truncateResult(results);
248+
}, 300);
249+
223250
return (
224251
<>
225252
<InlineFieldRow>
@@ -256,14 +283,15 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource, range }:
256283
</div>
257284
}
258285
>
259-
<Select
286+
<AsyncSelect
260287
aria-label="label-select"
261288
onChange={onLabelChange}
262289
value={label}
263-
options={labelOptions}
290+
defaultOptions={truncatedLabelOptions}
264291
width={25}
265292
allowCustomValue
266293
isClearable={true}
294+
loadOptions={labelNamesSearch}
267295
data-testid={selectors.components.DataSource.Prometheus.variableQueryEditor.labelValues.labelSelect}
268296
/>
269297
</InlineField>

packages/grafana-prometheus/src/language_utils.ts

+10
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,16 @@ export function getPrometheusTime(date: string | DateTime, roundUp: boolean) {
526526
return Math.ceil(date.valueOf() / 1000);
527527
}
528528

529+
/**
530+
* Used to truncate metrics, label names and label value in the query builder select components
531+
* to improve frontend performance. This is best used with an async select component including
532+
* the loadOptions property where we should still allow users to search all results with a string.
533+
* This can be done either storing the total results or querying an api that allows for matching a query.
534+
*
535+
* @param array
536+
* @param limit
537+
* @returns
538+
*/
529539
export function truncateResult<T>(array: T[], limit?: number): T[] {
530540
if (limit === undefined) {
531541
limit = PROMETHEUS_QUERY_BUILDER_MAX_RESULTS;

packages/grafana-prometheus/src/querybuilder/components/LabelFilterItem.tsx

+21-3
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export function LabelFilterItem({
4646
// instead, we explicitly control the menu visibility and prevent showing it until the options have fully loaded
4747
const [labelNamesMenuOpen, setLabelNamesMenuOpen] = useState(false);
4848
const [labelValuesMenuOpen, setLabelValuesMenuOpen] = useState(false);
49+
const [allLabels, setAllLabels] = useState<SelectableValue[]>([]);
4950

5051
const isMultiSelect = (operator = item.op) => {
5152
return operators.find((op) => op.label === operator)?.isMultiValue;
@@ -73,13 +74,25 @@ export function LabelFilterItem({
7374
debounceDuration
7475
);
7576

77+
/**
78+
* Debounce a search through all the labels possible and truncate by .
79+
*/
80+
const labelNamesSearch = debounce((query: string) => {
81+
// we limit the select to show 1000 options,
82+
// but we still search through all the possible options
83+
const results = allLabels.filter((label) => {
84+
return label.value.includes(query);
85+
});
86+
return truncateResult(results);
87+
}, debounceDuration);
88+
7689
const itemValue = item?.value ?? '';
7790

7891
return (
7992
<div key={itemValue} data-testid="prometheus-dimensions-filter-item">
8093
<InputGroup>
8194
{/* Label name select, loads all values at once */}
82-
<Select
95+
<AsyncSelect
8396
placeholder="Select label"
8497
data-testid={selectors.components.QueryBuilder.labelSelect}
8598
inputId="prometheus-dimensions-filter-item-key"
@@ -89,15 +102,20 @@ export function LabelFilterItem({
89102
onOpenMenu={async () => {
90103
setState({ isLoadingLabelNames: true });
91104
const labelNames = await onGetLabelNames(item);
105+
// store all label names to allow for full label searching by typing in the select option, see loadOptions function labelNamesSearch
106+
setAllLabels(labelNames);
92107
setLabelNamesMenuOpen(true);
93-
setState({ labelNames, isLoadingLabelNames: undefined });
108+
// truncate the results the same amount as the metric select
109+
const truncatedLabelNames = truncateResult(labelNames);
110+
setState({ labelNames: truncatedLabelNames, isLoadingLabelNames: undefined });
94111
}}
95112
onCloseMenu={() => {
96113
setLabelNamesMenuOpen(false);
97114
}}
98115
isOpen={labelNamesMenuOpen}
99116
isLoading={state.isLoadingLabelNames ?? false}
100-
options={state.labelNames}
117+
loadOptions={labelNamesSearch}
118+
defaultOptions={state.labelNames}
101119
onChange={(change) => {
102120
if (change.label) {
103121
onChange({

packages/grafana-prometheus/src/querybuilder/components/LabelFilters.test.tsx

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,28 @@
11
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/LabelFilters.test.tsx
2-
import { render, screen } from '@testing-library/react';
2+
import { render, screen, waitFor } from '@testing-library/react';
33
import userEvent from '@testing-library/user-event';
44
import { ComponentProps } from 'react';
55

6+
import { selectors } from '@grafana/e2e-selectors';
7+
68
import { selectOptionInTest } from '../../test/helpers/selectOptionInTest';
79
import { getLabelSelects } from '../testUtils';
810

911
import { LabelFilters, MISSING_LABEL_FILTER_ERROR_MESSAGE, LabelFiltersProps } from './LabelFilters';
1012

1113
describe('LabelFilters', () => {
14+
it('truncates list of label names to 1000', async () => {
15+
const manyMockValues = [...Array(1001).keys()].map((idx: number) => {
16+
return { label: 'random_label' + idx };
17+
});
18+
19+
setup({ onGetLabelNames: jest.fn().mockResolvedValue(manyMockValues) });
20+
21+
await openLabelNamesSelect();
22+
23+
await waitFor(() => expect(screen.getAllByTestId(selectors.components.Select.option)).toHaveLength(1000));
24+
});
25+
1226
it('renders empty input without labels', async () => {
1327
setup();
1428
expect(screen.getAllByText('Select label')).toHaveLength(1);
@@ -162,3 +176,8 @@ function setup(propOverrides?: Partial<ComponentProps<typeof LabelFilters>>) {
162176
function getAddButton() {
163177
return screen.getByLabelText(/Add/);
164178
}
179+
180+
async function openLabelNamesSelect() {
181+
const select = screen.getByText('Select label').parentElement!;
182+
await userEvent.click(select);
183+
}

0 commit comments

Comments
 (0)