mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
e40b19c7a6
commit
bf6056f2fa
@ -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: [] });
|
||||
});
|
||||
});
|
@ -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);
|
||||
};
|
@ -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}
|
||||
|
@ -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;
|
||||
}
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user