Chore: Remove prometheusUsesCombobox feature toggle (#103940)

* remove prometheusUsesCombobox feature toggle

* betterer

* fix the unit test

* create MetricsLabelsSection unit tests

* fix unit tests

* fix unit tests in PromQueryBuilder.test.tsx

* prettier

* remove timeouts

* Revert "remove timeouts"

This reverts commit 84af1fd46b.
pull/104375/head
ismail simsek 8 months ago committed by GitHub
parent 576bf66e03
commit 8ef8471b23
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      .betterer.results
  2. 1
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  3. 5
      packages/grafana-data/src/types/featureToggles.gen.ts
  4. 21
      packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx
  5. 2
      packages/grafana-prometheus/src/index.ts
  6. 3
      packages/grafana-prometheus/src/language_utils.ts
  7. 18
      packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.test.tsx
  8. 198
      packages/grafana-prometheus/src/querybuilder/components/MetricSelect.test.tsx
  9. 401
      packages/grafana-prometheus/src/querybuilder/components/MetricSelect.tsx
  10. 391
      packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.test.tsx
  11. 6
      packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.tsx
  12. 35
      packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.test.tsx
  13. 1
      packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderContainer.test.tsx
  14. 7
      pkg/services/featuremgmt/registry.go
  15. 1
      pkg/services/featuremgmt/toggles_gen.csv
  16. 4
      pkg/services/featuremgmt/toggles_gen.go
  17. 3
      pkg/services/featuremgmt/toggles_gen.json

@ -428,9 +428,6 @@ exports[`better eslint`] = {
"packages/grafana-prometheus/src/querybuilder/components/LabelParamEditor.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-prometheus/src/querybuilder/components/MetricSelect.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],

@ -75,7 +75,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
| `alertingQueryAndExpressionsStepMode` | Enables step mode for alerting queries and expressions | Yes |
| `useSessionStorageForRedirection` | Use session storage for handling the redirection after login | Yes |
| `pluginsSriChecks` | Enables SRI checks for plugin assets | |
| `prometheusUsesCombobox` | Use new **Combobox** component for Prometheus query editor | Yes |
| `azureMonitorDisableLogLimit` | Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. | |
| `preinstallAutoUpdate` | Enables automatic updates for pre-installed plugins | Yes |
| `reportingUseRawTimeRange` | Uses the original report or dashboard time range instead of making an absolute transformation | Yes |

@ -762,11 +762,6 @@ export interface FeatureToggles {
*/
timeRangeProvider?: boolean;
/**
* Use new **Combobox** component for Prometheus query editor
* @default true
*/
prometheusUsesCombobox?: boolean;
/**
* Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default.
* @default false
*/

@ -145,10 +145,19 @@ describe('PromVariableQueryEditor', () => {
fetchLabelsWithMatch: jest.fn().mockImplementation(() => Promise.resolve({ those: 'those' })),
} as Partial<PrometheusLanguageProvider> as PrometheusLanguageProvider,
getDebounceTimeInMilliseconds: jest.fn(),
getTagKeys: jest.fn().mockImplementation(() => Promise.resolve(['this'])),
getTagKeys: jest
.fn()
.mockImplementation(() => Promise.resolve([{ text: 'this', value: 'this', label: 'this' }])),
getVariables: jest.fn().mockImplementation(() => []),
metricFindQuery: jest.fn().mockImplementation(() => Promise.resolve(['that'])),
getSeriesLabels: jest.fn().mockImplementation(() => Promise.resolve(['that'])),
metricFindQuery: jest.fn().mockImplementation(() =>
Promise.resolve([
{
text: 'that',
value: 'that',
label: 'that',
},
])
),
} as Partial<PrometheusDatasource> as PrometheusDatasource,
query: {
refId: 'test',
@ -291,9 +300,9 @@ describe('PromVariableQueryEditor', () => {
await userEvent.type(labelSelect, 'this');
await selectOptionInTest(labelSelect, 'this');
const metricSelect = screen.getByLabelText('Metric');
await userEvent.type(metricSelect, 'that');
await selectOptionInTest(metricSelect, 'that');
const combobox = screen.getByPlaceholderText('Select metric');
await userEvent.type(combobox, 'that');
await userEvent.keyboard('{Enter}');
await waitFor(() =>
expect(onChange).toHaveBeenCalledWith({

@ -42,7 +42,7 @@ export { QueryPatternsModal } from './querybuilder/QueryPatternsModal';
export { LabelFilterItem } from './querybuilder/components/LabelFilterItem';
export { LabelFilters } from './querybuilder/components/LabelFilters';
export { LabelParamEditor } from './querybuilder/components/LabelParamEditor';
export { MetricSelect } from './querybuilder/components/MetricSelect';
export { MetricCombobox } from './querybuilder/components/MetricCombobox';
export { MetricsLabelsSection } from './querybuilder/components/MetricsLabelsSection';
export { NestedQuery } from './querybuilder/components/NestedQuery';
export { NestedQueryList } from './querybuilder/components/NestedQueryList';

@ -15,9 +15,10 @@ import {
import { addLabelToQuery } from './add_label_to_query';
import { SUGGESTIONS_LIMIT } from './language_provider';
import { PROMETHEUS_QUERY_BUILDER_MAX_RESULTS } from './querybuilder/components/MetricSelect';
import { PrometheusCacheLevel, PromMetricsMetadata, PromMetricsMetadataItem, RecordingRuleIdentifier } from './types';
export const PROMETHEUS_QUERY_BUILDER_MAX_RESULTS = 1000;
export const processHistogramMetrics = (metrics: string[]) => {
const resultSet: Set<string> = new Set();
const regexp = new RegExp('_bucket($|:)');

@ -134,6 +134,24 @@ describe('MetricCombobox', () => {
expect(screen.queryByRole('button', { name: /open metrics explorer/i })).toBeInTheDocument();
});
it('displays the default metric value from query prop', () => {
// Render with a query that has a default metric value
render(
<MetricCombobox
{...defaultProps}
query={{
metric: 'default_metric_value',
labels: [],
operations: [],
}}
/>
);
// The Combobox should display the default metric value
const combobox = screen.getByPlaceholderText('Select metric');
expect(combobox).toHaveValue('default_metric_value');
});
it('opens the metrics explorer when the button is clicked', async () => {
render(<MetricCombobox {...defaultProps} onGetMetrics={() => Promise.resolve([])} />);

@ -1,198 +0,0 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DataSourceInstanceSettings, MetricFindValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { PrometheusDatasource } from '../../datasource';
import { PromOptions } from '../../types';
import {
formatPrometheusLabelFilters,
formatPrometheusLabelFiltersToString,
MetricSelect,
MetricSelectProps,
} from './MetricSelect';
const instanceSettings = {
url: 'proxied',
id: 1,
user: 'test',
password: 'mupp',
jsonData: { httpMethod: 'GET' },
} as unknown as DataSourceInstanceSettings<PromOptions>;
const dataSourceMock = new PrometheusDatasource(instanceSettings);
const mockValues = [{ label: 'random_metric' }, { label: 'unique_metric' }, { label: 'more_unique_metric' }];
// Mock metricFindQuery which will call backend API
//@ts-ignore
dataSourceMock.metricFindQuery = jest.fn((query: string) => {
// 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] as string;
// 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 props: MetricSelectProps = {
labelsFilters: [],
datasource: dataSourceMock,
query: {
metric: '',
labels: [],
operations: [],
},
onChange: jest.fn(),
onGetMetrics: jest.fn().mockResolvedValue(mockValues),
metricLookupDisabled: false,
};
describe('MetricSelect', () => {
it('shows all metric options', async () => {
render(<MetricSelect {...props} />);
await openMetricSelect();
await waitFor(() => expect(screen.getByText('random_metric')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('unique_metric')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('more_unique_metric')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Metrics explorer')).toBeInTheDocument());
await waitFor(() => expect(screen.getAllByTestId(selectors.components.Select.option)).toHaveLength(4));
});
it('truncates list of metrics to 1000', async () => {
const manyMockValues = [...Array(1001).keys()].map((idx: number) => {
return { label: 'random_metric' + idx };
});
props.onGetMetrics = jest.fn().mockResolvedValue(manyMockValues);
render(<MetricSelect {...props} />);
await openMetricSelect();
// the metrics explorer is added as a custom option
const optionsLength = screen.getAllByTestId(selectors.components.Select.option).length;
const optionsLengthMinusMetricsExplorer = optionsLength - 1;
await waitFor(() => expect(optionsLengthMinusMetricsExplorer).toBe(1000));
});
it('shows option to set custom value when typing', async () => {
render(<MetricSelect {...props} />);
await openMetricSelect();
const input = screen.getByRole('combobox');
await userEvent.type(input, 'custom value');
await waitFor(() => expect(screen.getByText('custom value')).toBeInTheDocument());
});
it('shows searched options when typing', async () => {
render(<MetricSelect {...props} />);
await openMetricSelect();
const input = screen.getByRole('combobox');
await userEvent.type(input, 'unique');
const optionsLength = mockValues.length + 1; // the metrics explorer is added as a custom option
await waitFor(() => expect(screen.getAllByTestId(selectors.components.Select.option)).toHaveLength(optionsLength));
});
it('searches on split words', async () => {
render(<MetricSelect {...props} />);
await openMetricSelect();
const input = screen.getByRole('combobox');
await userEvent.type(input, 'more unique');
// the metrics explorer is added as a custom option
await waitFor(() => expect(screen.getAllByTestId(selectors.components.Select.option)).toHaveLength(3));
});
it('searches on multiple split words', async () => {
render(<MetricSelect {...props} />);
await openMetricSelect();
const input = screen.getByRole('combobox');
await userEvent.type(input, 'more unique metric');
// the metrics explorer is added as a custom option
await waitFor(() => expect(screen.getAllByTestId(selectors.components.Select.option)).toHaveLength(3));
});
it('highlights matching string', async () => {
render(<MetricSelect {...props} />);
await openMetricSelect();
const input = screen.getByRole('combobox');
await userEvent.type(input, 'more');
await waitFor(() => expect(document.querySelectorAll('mark')).toHaveLength(1));
});
it('highlights multiple matching strings in 1 input row', async () => {
render(<MetricSelect {...props} />);
await openMetricSelect();
const input = screen.getByRole('combobox');
await userEvent.type(input, 'more metric');
await waitFor(() => expect(document.querySelectorAll('mark')).toHaveLength(2));
});
it('highlights multiple matching strings in multiple input rows', async () => {
render(<MetricSelect {...props} />);
await openMetricSelect();
const input = screen.getByRole('combobox');
await userEvent.type(input, 'unique metric');
await waitFor(() => expect(document.querySelectorAll('mark')).toHaveLength(4));
});
it('does not highlight matching string in create option', async () => {
render(<MetricSelect {...props} />);
await openMetricSelect();
const input = screen.getByRole('combobox');
await userEvent.type(input, 'new');
await waitFor(() => expect(document.querySelector('mark')).not.toBeInTheDocument());
});
it('label filters properly join', () => {
const query = formatPrometheusLabelFilters([
{
value: 'value',
label: 'label',
op: '=',
},
{
value: 'value2',
label: 'label2',
op: '=',
},
]);
query.forEach((label) => {
expect(label.includes(',', 0));
});
});
it('label filter creation', () => {
const labels = [
{
value: 'value',
label: 'label',
op: '=',
},
{
value: 'value2',
label: 'label2',
op: '=',
},
];
const queryString = formatPrometheusLabelFiltersToString('query', labels);
queryString.split(',').forEach((queryChunk) => {
expect(queryChunk.length).toBeGreaterThan(1); // must be longer then ','
});
});
});
async function openMetricSelect() {
const select = screen.getByText('Select metric').parentElement!;
await userEvent.click(select);
}

@ -1,401 +0,0 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx
import { css } from '@emotion/css';
import debounce from 'debounce-promise';
import { RefCallback, useCallback, useState } from 'react';
import * as React from 'react';
import Highlighter from 'react-highlight-words';
import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { EditorField, EditorFieldGroup } from '@grafana/plugin-ui';
import {
AsyncSelect,
Button,
FormatOptionLabelMeta,
getSelectStyles,
Icon,
InlineField,
InlineFieldRow,
ScrollContainer,
SelectMenuOptions,
useStyles2,
useTheme2,
} from '@grafana/ui';
import { PrometheusDatasource } from '../../datasource';
import { truncateResult } from '../../language_utils';
import { regexifyLabelValuesQueryString } from '../parsingUtils';
import { QueryBuilderLabelFilter } from '../shared/types';
import { PromVisualQuery } from '../types';
import { MetricsModal } from './metrics-modal/MetricsModal';
import { tracking } from './metrics-modal/state/helpers';
// We are matching words split with space
const splitSeparator = ' ';
export interface MetricSelectProps {
metricLookupDisabled: boolean;
query: PromVisualQuery;
onChange: (query: PromVisualQuery) => void;
onGetMetrics: () => Promise<SelectableValue[]>;
datasource: PrometheusDatasource;
labelsFilters: QueryBuilderLabelFilter[];
onBlur?: () => void;
variableEditor?: boolean;
}
export const PROMETHEUS_QUERY_BUILDER_MAX_RESULTS = 1000;
export function MetricSelect({
datasource,
query,
onChange,
onGetMetrics,
labelsFilters,
metricLookupDisabled,
onBlur,
variableEditor,
}: Readonly<MetricSelectProps>) {
const styles = useStyles2(getStyles);
const [state, setState] = useState<{
metrics?: SelectableValue[];
isLoading?: boolean;
metricsModalOpen?: boolean;
initialMetrics?: string[];
resultsTruncated?: boolean;
}>({});
const metricsModalOption: SelectableValue[] = [
{
value: 'BrowseMetrics',
label: 'Metrics explorer',
description: 'Browse and filter all metrics and metadata with a fuzzy search',
},
];
const customFilterOption = useCallback((option: SelectableValue, searchQuery: string) => {
const label = option.label ?? option.value;
if (!label) {
return false;
}
// custom value is not a string label but a react node
if (!label.toLowerCase) {
return true;
}
const searchWords = searchQuery.split(splitSeparator);
return searchWords.reduce((acc, cur) => {
const matcheSearch = label.toLowerCase().includes(cur.toLowerCase());
const browseOption = label === 'Metrics explorer';
return acc && (matcheSearch || browseOption);
}, true);
}, []);
const formatOptionLabel = useCallback(
(option: SelectableValue, meta: FormatOptionLabelMeta<any>) => {
// For newly created custom value we don't want to add highlight
if (option['__isNew__']) {
return option.label;
}
// only matches on input, does not match on regex
// look into matching for regex input
return (
<Highlighter
searchWords={meta.inputValue.split(splitSeparator)}
textToHighlight={option.label ?? ''}
highlightClassName={styles.highlight}
/>
);
},
[styles.highlight]
);
/**
* 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);
};
/**
* Gets label_values response from prometheus API for current autocomplete query string and any existing labels filters
*/
const getMetricLabels = (query: string) => {
// Since some customers can have millions of metrics, whenever the user changes the autocomplete text we want to call the backend and request all metrics that match the current query string
const results = datasource.metricFindQuery(formatKeyValueStringsForLabelValuesQuery(query, labelsFilters));
return results.then((results) => {
const resultsLength = results.length;
truncateResult(results);
if (resultsLength > results.length) {
setState({ ...state, resultsTruncated: true });
} else {
setState({ ...state, resultsTruncated: false });
}
const resultsOptions = results.map((result) => {
return {
label: result.text,
value: result.text,
};
});
return [...metricsModalOption, ...resultsOptions];
});
};
// When metric and label lookup is disabled we won't request labels
const metricLookupDisabledSearch = () => Promise.resolve([]);
const debouncedSearch = debounce(
(query: string) => getMetricLabels(query),
datasource.getDebounceTimeInMilliseconds()
);
// No type found for the common select props so typing as any
// https://github.com/grafana/grafana/blob/main/packages/grafana-ui/src/components/Select/SelectBase.tsx/#L212-L263
// eslint-disable-next-line
const CustomOption = (props: any) => {
const option = props.data;
if (option.value === 'BrowseMetrics') {
const isFocused = props.isFocused ? styles.focus : '';
return (
// TODO: fix keyboard a11y
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
{...props.innerProps}
ref={props.innerRef}
className={`${styles.customOptionWidth} metric-encyclopedia-open`}
data-testid={selectors.components.Select.option}
onKeyDown={(e) => {
// if there is no metric and the m.e. is enabled, open the modal
if (e.code === 'Enter') {
setState({ ...state, metricsModalOpen: true });
}
}}
>
{
<div className={`${styles.customOption} ${isFocused} metric-encyclopedia-open`}>
<div>
<div className="metric-encyclopedia-open">{option.label}</div>
<div className={`${styles.customOptionDesc} metric-encyclopedia-open`}>{option.description}</div>
</div>
<Button
fill="text"
size="sm"
variant="secondary"
onClick={() => setState({ ...state, metricsModalOpen: true })}
className="metric-encyclopedia-open"
>
Open
<Icon name="arrow-right" />
</Button>
</div>
}
</div>
);
}
return SelectMenuOptions(props);
};
interface SelectMenuProps {
maxHeight: number;
innerRef: RefCallback<HTMLDivElement>;
innerProps: {};
}
const CustomMenu = ({ children, maxHeight, innerRef, innerProps }: React.PropsWithChildren<SelectMenuProps>) => {
const theme = useTheme2();
const stylesMenu = getSelectStyles(theme);
// Show the results trucated warning only if the options are loaded and the results are truncated
// The children are a react node(options loading node) or an array(not a valid element)
const optionsLoaded = !React.isValidElement(children) && state.resultsTruncated;
return (
<div
{...innerProps}
className={`${stylesMenu.menu} ${styles.customMenuContainer}`}
style={{ maxHeight: Math.round(maxHeight * 0.9) }}
aria-label="Select options menu"
>
<ScrollContainer ref={innerRef} showScrollIndicators>
{children}
</ScrollContainer>
{optionsLoaded && (
<div className={styles.customMenuFooter}>
<div>
Only the top 1000 metrics are displayed in the metric select. Use the metrics explorer to view all
metrics.
</div>
</div>
)}
</div>
);
};
const asyncSelect = () => {
return (
<AsyncSelect
data-testid={selectors.components.DataSource.Prometheus.queryEditor.builder.metricSelect}
isClearable={true}
inputId="prometheus-metric-select"
className={styles.select}
value={query.metric ? toOption(query.metric) : undefined}
placeholder={'Select metric'}
allowCustomValue
formatOptionLabel={formatOptionLabel}
filterOption={customFilterOption}
minMenuHeight={250}
onOpenMenu={async () => {
if (metricLookupDisabled) {
return;
}
setState({ isLoading: true });
const metrics = await onGetMetrics();
const initialMetrics: string[] = metrics.map((m) => m.value);
const resultsLength = metrics.length;
if (metrics.length > PROMETHEUS_QUERY_BUILDER_MAX_RESULTS) {
truncateResult(metrics);
}
setState({
// add the modal button option to the options
metrics: [...metricsModalOption, ...metrics],
isLoading: undefined,
// pass the initial metrics into the metrics explorer
initialMetrics: initialMetrics,
resultsTruncated: resultsLength > metrics.length,
});
}}
loadOptions={metricLookupDisabled ? metricLookupDisabledSearch : debouncedSearch}
isLoading={state.isLoading}
defaultOptions={state.metrics ?? Array.from(new Array(25), () => ({ value: '' }))} // We need empty values when `state.metrics` is falsy in order for the select to correctly determine top/bottom placement
onChange={(input) => {
const value = input?.value;
if (value) {
// if there is no metric and the value is the custom m.e. option, open the modal
if (value === 'BrowseMetrics') {
tracking('grafana_prometheus_metric_encyclopedia_open', null, '', query);
setState({ ...state, metricsModalOpen: true });
} else {
onChange({ ...query, metric: value });
}
} else {
onChange({ ...query, metric: '' });
}
}}
components={{ Option: CustomOption, MenuList: CustomMenu }}
onBlur={onBlur}
/>
);
};
return (
<>
{!datasource.lookupsDisabled && state.metricsModalOpen && (
<MetricsModal
datasource={datasource}
isOpen={state.metricsModalOpen}
onClose={() => setState({ ...state, metricsModalOpen: false })}
query={query}
onChange={onChange}
initialMetrics={state.initialMetrics ?? []}
/>
)}
{/* format the ui for either the query editor or the variable editor */}
{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>
)}
</>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
select: css({
minWidth: '125px',
}),
highlight: css({
label: 'select__match-highlight',
background: 'inherit',
padding: 'inherit',
color: theme.colors.warning.contrastText,
backgroundColor: theme.colors.warning.main,
}),
customOption: css({
padding: '8px',
display: 'flex',
justifyContent: 'space-between',
cursor: 'pointer',
':hover': {
backgroundColor: theme.colors.emphasize(theme.colors.background.primary, 0.1),
},
}),
customOptionlabel: css({
color: theme.colors.text.primary,
}),
customOptionDesc: css({
color: theme.colors.text.secondary,
fontSize: theme.typography.size.xs,
opacity: '50%',
}),
focus: css({
backgroundColor: theme.colors.emphasize(theme.colors.background.primary, 0.1),
}),
customOptionWidth: css({
minWidth: '400px',
}),
customMenuFooter: css({
flex: 0,
display: 'flex',
justifyContent: 'space-between',
padding: theme.spacing(1.5),
borderTop: `1px solid ${theme.colors.border.weak}`,
color: theme.colors.text.secondary,
}),
customMenuContainer: css({
display: 'flex',
flexDirection: 'column',
background: theme.colors.background.primary,
boxShadow: theme.shadows.z3,
}),
});
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}"`;
});
};

@ -0,0 +1,391 @@
import { render, screen } from '@testing-library/react';
import { dateTime } from '@grafana/data';
import { PrometheusDatasource } from '../../datasource';
import { PromVisualQuery } from '../types';
import { MetricsLabelsSection } from './MetricsLabelsSection';
// Mock dependencies
jest.mock('./MetricCombobox', () => ({
MetricCombobox: jest.fn(() => <div data-testid="metric-combobox">Metric Combobox</div>),
}));
jest.mock('./LabelFilters', () => ({
LabelFilters: jest.fn(() => <div data-testid="label-filters">Label Filters</div>),
}));
// Create mock for PrometheusDatasource
const createMockDatasource = () => {
const datasource = {
uid: 'prometheus',
getVariables: jest.fn().mockReturnValue(['$var1', '$var2']),
interpolateString: jest.fn((str) => str),
hasLabelsMatchAPISupport: jest.fn().mockReturnValue(true),
getDebounceTimeInMilliseconds: jest.fn().mockReturnValue(300),
lookupsDisabled: false,
languageProvider: {
fetchLabels: jest.fn().mockResolvedValue({}),
getLabelKeys: jest.fn().mockReturnValue(['label1', 'label2']),
fetchLabelsWithMatch: jest.fn().mockResolvedValue({ label1: [], label2: [] }),
fetchSeries: jest.fn().mockResolvedValue([{ label1: 'value1' }]),
fetchSeriesValuesWithMatch: jest.fn().mockResolvedValue(['value1', 'value2']),
getLabelValues: jest.fn().mockResolvedValue(['value1', 'value2']),
getSeries: jest.fn().mockResolvedValue({ __name__: ['metric1', 'metric2'] }),
loadMetricsMetadata: jest.fn().mockResolvedValue({}),
metricsMetadata: { metric1: { type: 'counter', help: 'help text' } },
},
};
return datasource as unknown as PrometheusDatasource;
};
const defaultQuery: PromVisualQuery = {
metric: 'metric1',
labels: [{ label: 'label1', op: '=', value: 'value1' }],
operations: [],
};
const defaultTimeRange = {
from: dateTime(1000),
to: dateTime(2000),
raw: {
from: 'now-1h',
to: 'now',
},
};
describe('MetricsLabelsSection', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render MetricCombobox and LabelFilters', () => {
const onChange = jest.fn();
const datasource = createMockDatasource();
render(
<MetricsLabelsSection
query={defaultQuery}
datasource={datasource}
onChange={onChange}
timeRange={defaultTimeRange}
/>
);
expect(screen.getByTestId('metric-combobox')).toBeInTheDocument();
expect(screen.getByTestId('label-filters')).toBeInTheDocument();
});
it('should pass correct props to MetricCombobox', async () => {
const onChange = jest.fn();
const onBlur = jest.fn();
const datasource = createMockDatasource();
const { MetricCombobox } = require('./MetricCombobox');
render(
<MetricsLabelsSection
query={defaultQuery}
datasource={datasource}
onChange={onChange}
onBlur={onBlur}
timeRange={defaultTimeRange}
/>
);
// Check that MetricCombobox was called with correct props
expect(MetricCombobox).toHaveBeenCalledWith(
expect.objectContaining({
query: defaultQuery,
onChange: onChange,
datasource: datasource,
labelsFilters: defaultQuery.labels,
metricLookupDisabled: false,
onBlur: onBlur,
variableEditor: undefined,
}),
expect.anything()
);
});
it('should pass correct props to LabelFilters', async () => {
const onChange = jest.fn();
const datasource = createMockDatasource();
const { LabelFilters } = require('./LabelFilters');
render(
<MetricsLabelsSection
query={defaultQuery}
datasource={datasource}
onChange={onChange}
timeRange={defaultTimeRange}
/>
);
// Check that LabelFilters was called with correct props
expect(LabelFilters).toHaveBeenCalledWith(
expect.objectContaining({
debounceDuration: 300,
labelsFilters: defaultQuery.labels,
variableEditor: undefined,
}),
expect.anything()
);
});
it('should handle onChangeLabels correctly', async () => {
const onChange = jest.fn();
const datasource = createMockDatasource();
const { LabelFilters } = require('./LabelFilters');
render(
<MetricsLabelsSection
query={defaultQuery}
datasource={datasource}
onChange={onChange}
timeRange={defaultTimeRange}
/>
);
// Extract the onChangeLabels callback
const onChangeLabelsCallback = LabelFilters.mock.calls[0][0].onChange;
// Call it with new labels
const newLabels = [{ label: 'newLabel', op: '=', value: 'newValue' }];
onChangeLabelsCallback(newLabels);
// Check that onChange was called with updated query
expect(onChange).toHaveBeenCalledWith({
...defaultQuery,
labels: newLabels,
});
});
it('should handle withTemplateVariableOptions correctly', async () => {
const onChange = jest.fn();
const datasource = createMockDatasource();
const { LabelFilters } = require('./LabelFilters');
render(
<MetricsLabelsSection
query={defaultQuery}
datasource={datasource}
onChange={onChange}
timeRange={defaultTimeRange}
/>
);
// Extract the onGetLabelNames callback
const onGetLabelNamesCallback = LabelFilters.mock.calls[0][0].onGetLabelNames;
// Prepare a test label filter
const forLabel = { label: 'test', op: '=', value: '' };
// Call it
const result = await onGetLabelNamesCallback(forLabel);
// Check that variables were included in the result
expect(result).toContainEqual(expect.objectContaining({ label: '$var1', value: '$var1' }));
expect(result).toContainEqual(expect.objectContaining({ label: '$var2', value: '$var2' }));
});
it('should handle onGetLabelNames with no metric correctly', async () => {
const onChange = jest.fn();
const datasource = createMockDatasource();
const { LabelFilters } = require('./LabelFilters');
const queryWithoutMetric = { ...defaultQuery, metric: '' };
render(
<MetricsLabelsSection
query={queryWithoutMetric}
datasource={datasource}
onChange={onChange}
timeRange={defaultTimeRange}
/>
);
// Extract the onGetLabelNames callback
const onGetLabelNamesCallback = LabelFilters.mock.calls[0][0].onGetLabelNames;
// Call it
await onGetLabelNamesCallback({});
// Check that fetchLabels was called
expect(datasource.languageProvider.fetchLabels).toHaveBeenCalledWith(defaultTimeRange);
// Check that getLabelKeys was called
expect(datasource.languageProvider.getLabelKeys).toHaveBeenCalled();
});
it('should handle onGetLabelNames with metric correctly', async () => {
const onChange = jest.fn();
const datasource = createMockDatasource();
const { LabelFilters } = require('./LabelFilters');
render(
<MetricsLabelsSection
query={defaultQuery}
datasource={datasource}
onChange={onChange}
timeRange={defaultTimeRange}
/>
);
// Extract the onGetLabelNames callback
const onGetLabelNamesCallback = LabelFilters.mock.calls[0][0].onGetLabelNames;
// Call it
await onGetLabelNamesCallback({});
// Check that fetchLabelsWithMatch was called
expect(datasource.languageProvider.fetchLabelsWithMatch).toHaveBeenCalled();
});
it('should handle getLabelValuesAutocompleteSuggestions correctly', async () => {
const onChange = jest.fn();
const datasource = createMockDatasource();
const { LabelFilters } = require('./LabelFilters');
render(
<MetricsLabelsSection
query={defaultQuery}
datasource={datasource}
onChange={onChange}
timeRange={defaultTimeRange}
/>
);
// Extract the getLabelValuesAutofillSuggestions callback
const getLabelValuesCallback = LabelFilters.mock.calls[0][0].getLabelValuesAutofillSuggestions;
// Call it
await getLabelValuesCallback('val', 'label1');
// Check that fetchSeriesValuesWithMatch was called (since hasLabelsMatchAPISupport is true)
expect(datasource.languageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
});
it('should handle onGetLabelValues with no metric correctly', async () => {
const onChange = jest.fn();
const datasource = createMockDatasource();
const { LabelFilters } = require('./LabelFilters');
const queryWithoutMetric = { ...defaultQuery, metric: '' };
render(
<MetricsLabelsSection
query={queryWithoutMetric}
datasource={datasource}
onChange={onChange}
timeRange={defaultTimeRange}
/>
);
// Extract the onGetLabelValues callback
const onGetLabelValuesCallback = LabelFilters.mock.calls[0][0].onGetLabelValues;
// Call it
await onGetLabelValuesCallback({ label: 'label1' });
// Check that getLabelValues was called
expect(datasource.languageProvider.getLabelValues).toHaveBeenCalledWith(defaultTimeRange, 'label1');
});
it('should handle onGetLabelValues with metric correctly', async () => {
const onChange = jest.fn();
const datasource = createMockDatasource();
const { LabelFilters } = require('./LabelFilters');
render(
<MetricsLabelsSection
query={defaultQuery}
datasource={datasource}
onChange={onChange}
timeRange={defaultTimeRange}
/>
);
// Extract the onGetLabelValues callback
const onGetLabelValuesCallback = LabelFilters.mock.calls[0][0].onGetLabelValues;
// Call it
await onGetLabelValuesCallback({ label: 'label1' });
// Check that fetchSeriesValuesWithMatch was called (since hasLabelsMatchAPISupport is true)
expect(datasource.languageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
});
it('should handle onGetLabelValues with no label correctly', async () => {
const onChange = jest.fn();
const datasource = createMockDatasource();
const { LabelFilters } = require('./LabelFilters');
render(
<MetricsLabelsSection
query={defaultQuery}
datasource={datasource}
onChange={onChange}
timeRange={defaultTimeRange}
/>
);
// Extract the onGetLabelValues callback
const onGetLabelValuesCallback = LabelFilters.mock.calls[0][0].onGetLabelValues;
// Call it with no label
const result = await onGetLabelValuesCallback({});
// In reality, the component has already added the template variables to the result
// Let's check that the result includes the template variables
expect(result).toContainEqual(expect.objectContaining({ label: '$var1', value: '$var1' }));
expect(result).toContainEqual(expect.objectContaining({ label: '$var2', value: '$var2' }));
});
it('should handle onGetMetrics correctly', async () => {
const onChange = jest.fn();
const datasource = createMockDatasource();
const { MetricCombobox } = require('./MetricCombobox');
render(
<MetricsLabelsSection
query={defaultQuery}
datasource={datasource}
onChange={onChange}
timeRange={defaultTimeRange}
/>
);
// Extract the onGetMetrics callback
const onGetMetricsCallback = MetricCombobox.mock.calls[0][0].onGetMetrics;
// Call it
const result = await onGetMetricsCallback();
// Check that we get back variables and metrics
expect(result).toContainEqual(expect.objectContaining({ label: '$var1', value: '$var1' }));
expect(result).toContainEqual(expect.objectContaining({ label: '$var2', value: '$var2' }));
// Metrics should be included too, but they come from the mocked getSeries or getLabelValues
});
it('should load metrics metadata if not present', async () => {
const onChange = jest.fn();
const datasource = createMockDatasource();
datasource.languageProvider.metricsMetadata = undefined;
render(
<MetricsLabelsSection
query={defaultQuery}
datasource={datasource}
onChange={onChange}
timeRange={defaultTimeRange}
/>
);
const { MetricCombobox } = require('./MetricCombobox');
const onGetMetricsCallback = MetricCombobox.mock.calls[0][0].onGetMetrics;
// Call it
await onGetMetricsCallback();
// loadMetricsMetadata should be called
expect(datasource.languageProvider.loadMetricsMetadata).toHaveBeenCalled();
});
});

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

@ -73,7 +73,7 @@ describe('PromQueryBuilder', () => {
it('renders all the query sections', async () => {
setup(bugQuery);
expect(screen.getByText('random_metric')).toBeInTheDocument();
expect(screen.getByDisplayValue('random_metric')).toBeInTheDocument();
expect(screen.getByText('localhost:9090')).toBeInTheDocument();
expect(screen.getByText('Rate')).toBeInTheDocument();
const sumBys = screen.getAllByTestId('operations.1.wrapper');
@ -167,8 +167,16 @@ describe('PromQueryBuilder', () => {
operations: [],
});
await openMetricSelect(container);
await userEvent.click(screen.getByText('histogram_metric_bucket'));
await waitFor(() => expect(screen.getByText('hint: add histogram_quantile')).toBeInTheDocument());
// We need to trigger the option selection to show the hint
// Just press Enter to select the current option (which should be our metric)
const input = screen.getByTestId('data-testid metric select');
await userEvent.type(input, '{enter}');
// Now check for the hint
await waitFor(() => {
expect(screen.getByText('hint: add histogram_quantile')).toBeInTheDocument();
});
});
it('shows hints for counter metrics', async () => {
@ -178,7 +186,13 @@ describe('PromQueryBuilder', () => {
operations: [],
});
await openMetricSelect(container);
await userEvent.click(screen.getByText('histogram_metric_sum'));
// We need to trigger the option selection to show the hint
// Just press Enter to select the current option (which should be our metric)
const input = screen.getByTestId('data-testid metric select');
await userEvent.type(input, '{enter}');
// Now check for the hint
await waitFor(() => expect(screen.getByText('hint: add rate')).toBeInTheDocument());
});
@ -200,7 +214,13 @@ describe('PromQueryBuilder', () => {
data
);
await openMetricSelect(container);
await userEvent.click(screen.getByText('histogram_metric_sum'));
// We need to trigger the option selection to show the hint
// Just press Enter to select the current option (which should be our metric)
const input = screen.getByTestId('data-testid metric select');
await userEvent.type(input, '{enter}');
// Now check for the hints - should be multiple in this case
await waitFor(() => expect(screen.getAllByText(/hint:/)).toHaveLength(2));
});
@ -354,9 +374,12 @@ function setup(
}
async function openMetricSelect(container: HTMLElement) {
const select = container.querySelector('#prometheus-metric-select');
const select = container.querySelector('[data-testid="data-testid metric select"]');
if (select) {
await userEvent.click(select);
// Also focus to ensure callbacks are triggered
await userEvent.type(select, ' ');
await userEvent.clear(select);
}
}

@ -17,7 +17,6 @@ describe('PromQueryBuilderContainer', () => {
it('translates query between string and model', async () => {
const { props } = setup({ expr: 'rate(metric_test{job="testjob"}[$__rate_interval])' });
expect(screen.getByText('metric_test')).toBeInTheDocument();
await addOperationInQueryBuilder('Range functions', 'Rate');
// extra fields here are for storing metrics explorer settings. Future work: store these in local storage.
expect(props.onChange).toHaveBeenCalledWith({

@ -1304,13 +1304,6 @@ var (
Stage: FeatureStageExperimental,
Owner: grafanaFrontendPlatformSquad,
},
{
Name: "prometheusUsesCombobox",
Description: "Use new **Combobox** component for Prometheus query editor",
Stage: FeatureStageGeneralAvailability,
Owner: grafanaOSSBigTent,
Expression: "true", // enabled by default
},
{
Name: "azureMonitorDisableLogLimit",
Description: "Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default.",

@ -170,7 +170,6 @@ managedDualWriter,experimental,@grafana/search-and-storage,false,false,false
pluginsSriChecks,GA,@grafana/plugins-platform-backend,false,false,false
unifiedStorageBigObjectsSupport,experimental,@grafana/search-and-storage,false,false,false
timeRangeProvider,experimental,@grafana/grafana-frontend-platform,false,false,false
prometheusUsesCombobox,GA,@grafana/oss-big-tent,false,false,false
azureMonitorDisableLogLimit,GA,@grafana/partner-datasources,false,false,false
preinstallAutoUpdate,GA,@grafana/plugins-platform-backend,false,false,false
playlistsReconciler,experimental,@grafana/grafana-app-platform-squad,false,true,false

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
170 pluginsSriChecks GA @grafana/plugins-platform-backend false false false
171 unifiedStorageBigObjectsSupport experimental @grafana/search-and-storage false false false
172 timeRangeProvider experimental @grafana/grafana-frontend-platform false false false
prometheusUsesCombobox GA @grafana/oss-big-tent false false false
173 azureMonitorDisableLogLimit GA @grafana/partner-datasources false false false
174 preinstallAutoUpdate GA @grafana/plugins-platform-backend false false false
175 playlistsReconciler experimental @grafana/grafana-app-platform-squad false true false

@ -691,10 +691,6 @@ const (
// Enables time pickers sync
FlagTimeRangeProvider = "timeRangeProvider"
// FlagPrometheusUsesCombobox
// Use new **Combobox** component for Prometheus query editor
FlagPrometheusUsesCombobox = "prometheusUsesCombobox"
// FlagAzureMonitorDisableLogLimit
// Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default.
FlagAzureMonitorDisableLogLimit = "azureMonitorDisableLogLimit"

@ -2657,7 +2657,8 @@
"metadata": {
"name": "prometheusUsesCombobox",
"resourceVersion": "1743693517832",
"creationTimestamp": "2024-10-23T11:18:33Z"
"creationTimestamp": "2024-10-23T11:18:33Z",
"deletionTimestamp": "2025-04-11T19:25:28Z"
},
"spec": {
"description": "Use new **Combobox** component for Prometheus query editor",

Loading…
Cancel
Save