From bf6056f2fab4016dc5ac948c9b0a3f2fd76be671 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Thu, 31 Oct 2024 10:36:27 +0000 Subject: [PATCH] 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 --- .../components/MetricCombobox.test.tsx | 127 ++++++++++++++++++ .../components/MetricCombobox.tsx | 124 +++++++++++++++++ .../components/MetricsLabelsSection.tsx | 6 +- .../src/components/Combobox/Combobox.tsx | 16 ++- 4 files changed, 268 insertions(+), 5 deletions(-) create mode 100644 packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.test.tsx create mode 100644 packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.tsx diff --git a/packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.test.tsx new file mode 100644 index 00000000000..46a1396aaa3 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.test.tsx @@ -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; + + 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(); + expect(screen.getByPlaceholderText('Select metric')).toBeInTheDocument(); + }); + + it('fetches top metrics when the combobox is opened ', async () => { + render(); + + 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(); + + 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(); + + 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: [] }); + }); +}); diff --git a/packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.tsx b/packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.tsx new file mode 100644 index 00000000000..796b794b6c3 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.tsx @@ -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; + datasource: PrometheusDatasource; + labelsFilters: QueryBuilderLabelFilter[]; + onBlur?: () => void; + variableEditor?: boolean; +} + +export function MetricCombobox({ + datasource, + query, + onChange, + onGetMetrics, + labelsFilters, + variableEditor, +}: Readonly) { + /** + * 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 | null) => { + onChange({ ...query, metric: opt?.value ?? '' }); + }, + [onChange, query] + ); + + const loadOptions = useCallback( + async (input: string): Promise => { + 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 ( + + ); + }; + + return ( + <> + {variableEditor ? ( + + Optional: returns a list of label values for the label name in the specified metric.} + > + {asyncSelect()} + + + ) : ( + + {asyncSelect()} + + )} + + ); +} + +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); +}; diff --git a/packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.tsx b/packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.tsx index ce91d04bbdb..ae6353e8b1a 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.tsx @@ -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 ( <> - ({ 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; } },