Prometheus: Use Combobox for metric select (#93262)

* Uses Combobox in Prometheus for the metrics select, behind feature toggle

* switch to new async combobox api

* clean up MetricCombobox, add in dev slowness

* wip

* reset combobox changes to other pr https://github.com/grafana/grafana/pull/95191

* restore placeholder

* wip

* tests :)

* remove history comment

* use main import
This commit is contained in:
Josh Hunt 2024-10-31 10:36:27 +00:00 committed by GitHub
parent e40b19c7a6
commit bf6056f2fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 268 additions and 5 deletions

View File

@ -0,0 +1,127 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import { DataSourceInstanceSettings, MetricFindValue } from '@grafana/data';
import { PrometheusDatasource } from '../../datasource';
import { PromOptions } from '../../types';
import { MetricCombobox, MetricComboboxProps } from './MetricCombobox';
describe('MetricCombobox', () => {
beforeAll(() => {
const mockGetBoundingClientRect = jest.fn(() => ({
width: 120,
height: 120,
top: 0,
left: 0,
bottom: 0,
right: 0,
}));
Object.defineProperty(Element.prototype, 'getBoundingClientRect', {
value: mockGetBoundingClientRect,
});
});
const instanceSettings = {
url: 'proxied',
id: 1,
user: 'test',
password: 'mupp',
jsonData: { httpMethod: 'GET' },
} as unknown as DataSourceInstanceSettings<PromOptions>;
const mockDatasource = new PrometheusDatasource(instanceSettings);
const mockValues = [{ label: 'random_metric' }, { label: 'unique_metric' }, { label: 'more_unique_metric' }];
// Mock metricFindQuery which will call backend API
mockDatasource.metricFindQuery = jest.fn((query: string) => {
// return Promise.resolve([]);
// Use the label values regex to get the values inside the label_values function call
const labelValuesRegex = /^label_values\((?:(.+),\s*)?([a-zA-Z_][a-zA-Z0-9_]*)\)\s*$/;
const queryValueArray = query.match(labelValuesRegex) as RegExpMatchArray;
const queryValueRaw = queryValueArray[1];
// Remove the wrapping regex
const queryValue = queryValueRaw.substring(queryValueRaw.indexOf('".*') + 3, queryValueRaw.indexOf('.*"'));
// Run the regex that we'd pass into prometheus API against the strings in the test
return Promise.resolve(
mockValues
.filter((value) => value.label.match(queryValue))
.map((result) => {
return {
text: result.label,
};
}) as MetricFindValue[]
);
});
const mockOnChange = jest.fn();
const mockOnGetMetrics = jest.fn(() => Promise.resolve(mockValues.map((v) => ({ value: v.label }))));
const defaultProps: MetricComboboxProps = {
metricLookupDisabled: false,
query: {
metric: '',
labels: [],
operations: [],
},
onChange: mockOnChange,
onGetMetrics: mockOnGetMetrics,
datasource: mockDatasource,
labelsFilters: [],
variableEditor: false,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly', () => {
render(<MetricCombobox {...defaultProps} />);
expect(screen.getByPlaceholderText('Select metric')).toBeInTheDocument();
});
it('fetches top metrics when the combobox is opened ', async () => {
render(<MetricCombobox {...defaultProps} />);
const combobox = screen.getByPlaceholderText('Select metric');
await userEvent.click(combobox);
expect(mockOnGetMetrics).toHaveBeenCalledTimes(1);
const item = await screen.findByRole('option', { name: 'random_metric' });
expect(item).toBeInTheDocument();
});
it('fetches metrics for the users query', async () => {
render(<MetricCombobox {...defaultProps} />);
const combobox = screen.getByPlaceholderText('Select metric');
await userEvent.click(combobox);
await userEvent.type(combobox, 'unique');
expect(jest.mocked(mockDatasource.metricFindQuery)).toHaveBeenCalled();
const item = await screen.findByRole('option', { name: 'unique_metric' });
expect(item).toBeInTheDocument();
const negativeItem = await screen.queryByRole('option', { name: 'random_metric' });
expect(negativeItem).not.toBeInTheDocument();
});
it('calls onChange with the correct value when a metric is selected', async () => {
render(<MetricCombobox {...defaultProps} />);
const combobox = screen.getByPlaceholderText('Select metric');
await userEvent.click(combobox);
const item = await screen.findByRole('option', { name: 'random_metric' });
await userEvent.click(item);
expect(mockOnChange).toHaveBeenCalledWith({ metric: 'random_metric', labels: [], operations: [] });
});
});

View File

@ -0,0 +1,124 @@
import { useCallback } from 'react';
import { SelectableValue } from '@grafana/data';
import { EditorField, EditorFieldGroup } from '@grafana/experimental';
import { InlineField, InlineFieldRow, Combobox, ComboboxOption } from '@grafana/ui';
import { PrometheusDatasource } from '../../datasource';
import { regexifyLabelValuesQueryString } from '../parsingUtils';
import { QueryBuilderLabelFilter } from '../shared/types';
import { PromVisualQuery } from '../types';
export interface MetricComboboxProps {
metricLookupDisabled: boolean;
query: PromVisualQuery;
onChange: (query: PromVisualQuery) => void;
onGetMetrics: () => Promise<SelectableValue[]>;
datasource: PrometheusDatasource;
labelsFilters: QueryBuilderLabelFilter[];
onBlur?: () => void;
variableEditor?: boolean;
}
export function MetricCombobox({
datasource,
query,
onChange,
onGetMetrics,
labelsFilters,
variableEditor,
}: Readonly<MetricComboboxProps>) {
/**
* Gets label_values response from prometheus API for current autocomplete query string and any existing labels filters
*/
const getMetricLabels = useCallback(
async (query: string) => {
const results = await datasource.metricFindQuery(formatKeyValueStringsForLabelValuesQuery(query, labelsFilters));
const resultsOptions = results.map((result) => {
return {
label: result.text,
value: result.text,
};
});
return resultsOptions;
},
[datasource, labelsFilters]
);
const onComboboxChange = useCallback(
(opt: ComboboxOption<string> | null) => {
onChange({ ...query, metric: opt?.value ?? '' });
},
[onChange, query]
);
const loadOptions = useCallback(
async (input: string): Promise<ComboboxOption[]> => {
const metrics = input.length ? await getMetricLabels(input) : await onGetMetrics();
return metrics.map((option) => ({
label: option.label ?? option.value,
value: option.value,
}));
},
[getMetricLabels, onGetMetrics]
);
const asyncSelect = () => {
return (
<Combobox
placeholder="Select metric"
width="auto"
minWidth={25}
options={loadOptions}
value={query.metric}
onChange={onComboboxChange}
/>
);
};
return (
<>
{variableEditor ? (
<InlineFieldRow>
<InlineField
label="Metric"
labelWidth={20}
tooltip={<div>Optional: returns a list of label values for the label name in the specified metric.</div>}
>
{asyncSelect()}
</InlineField>
</InlineFieldRow>
) : (
<EditorFieldGroup>
<EditorField label="Metric">{asyncSelect()}</EditorField>
</EditorFieldGroup>
)}
</>
);
}
export const formatPrometheusLabelFiltersToString = (
queryString: string,
labelsFilters: QueryBuilderLabelFilter[] | undefined
): string => {
const filterArray = labelsFilters ? formatPrometheusLabelFilters(labelsFilters) : [];
return `label_values({__name__=~".*${queryString}"${filterArray ? filterArray.join('') : ''}},__name__)`;
};
export const formatPrometheusLabelFilters = (labelsFilters: QueryBuilderLabelFilter[]): string[] => {
return labelsFilters.map((label) => {
return `,${label.label}="${label.value}"`;
});
};
/**
* Reformat the query string and label filters to return all valid results for current query editor state
*/
const formatKeyValueStringsForLabelValuesQuery = (query: string, labelsFilters?: QueryBuilderLabelFilter[]): string => {
const queryString = regexifyLabelValuesQueryString(query);
return formatPrometheusLabelFiltersToString(queryString, labelsFilters);
};

View File

@ -2,6 +2,7 @@
import { useCallback } from 'react';
import { SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { PrometheusDatasource } from '../../datasource';
import { getMetadataString } from '../../language_provider';
@ -12,6 +13,7 @@ import { QueryBuilderLabelFilter } from '../shared/types';
import { PromVisualQuery } from '../types';
import { LabelFilters } from './LabelFilters';
import { MetricCombobox } from './MetricCombobox';
import { MetricSelect } from './MetricSelect';
export interface MetricsLabelsSectionProps {
@ -192,9 +194,11 @@ export function MetricsLabelsSection({
return withTemplateVariableOptions(getMetrics(datasource, query));
}, [datasource, query, withTemplateVariableOptions]);
const MetricSelectComponent = config.featureToggles.prometheusUsesCombobox ? MetricCombobox : MetricSelect;
return (
<>
<MetricSelect
<MetricSelectComponent
query={query}
onChange={onChange}
onGetMetrics={onGetMetrics}

View File

@ -213,10 +213,18 @@ export const Combobox = <T extends string | number>({
if (isOpen && isAsync) {
setAsyncLoading(true);
loadOptions(inputValue ?? '').then((options) => {
setItems(options);
setAsyncLoading(false);
});
loadOptions(inputValue ?? '')
.then((options) => {
setItems(options);
setAsyncLoading(false);
})
.catch((err) => {
if (!(err instanceof StaleResultError)) {
// TODO: handle error
setAsyncLoading(false);
throw err;
}
});
return;
}
},