diff --git a/.betterer.results b/.betterer.results index 1d9a09770e0..647193c730c 100644 --- a/.betterer.results +++ b/.betterer.results @@ -6669,6 +6669,12 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], + "public/app/plugins/datasource/prometheus/querybuilder/components/LabelFilterItem.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"], + [0, 0, 0, "Do not use any type assertions.", "3"] + ], "public/app/plugins/datasource/prometheus/querybuilder/components/LabelParamEditor.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], @@ -6681,7 +6687,8 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "3"] ], "public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"] ], "public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.test.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/LabelFilterItem.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/LabelFilterItem.tsx new file mode 100644 index 00000000000..bb1ce8c8bfd --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/LabelFilterItem.tsx @@ -0,0 +1,166 @@ +import debounce from 'debounce-promise'; +import React, { useState } from 'react'; + +import { SelectableValue, toOption } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { AccessoryButton, InputGroup } from '@grafana/experimental'; +import { AsyncSelect, Select } from '@grafana/ui'; + +import { PROMETHEUS_QUERY_BUILDER_MAX_RESULTS } from '../components/MetricSelect'; +import { QueryBuilderLabelFilter } from '../shared/types'; + +export interface Props { + defaultOp: string; + item: Partial; + onChange: (value: QueryBuilderLabelFilter) => void; + onGetLabelNames: (forLabel: Partial) => Promise; + onGetLabelValues: (forLabel: Partial) => Promise; + onDelete: () => void; + invalidLabel?: boolean; + invalidValue?: boolean; + getLabelValuesAutofillSuggestions: (query: string, labelName?: string) => Promise; +} + +export function LabelFilterItem({ + item, + defaultOp, + onChange, + onDelete, + onGetLabelNames, + onGetLabelValues, + invalidLabel, + invalidValue, + getLabelValuesAutofillSuggestions, +}: Props) { + const [state, setState] = useState<{ + labelNames?: SelectableValue[]; + labelValues?: SelectableValue[]; + isLoadingLabelNames?: boolean; + isLoadingLabelValues?: boolean; + }>({}); + + const isMultiSelect = (operator = item.op) => { + return operators.find((op) => op.label === operator)?.isMultiValue; + }; + + const getSelectOptionsFromString = (item?: string): string[] => { + if (item) { + if (item.indexOf('|') > 0) { + return item.split('|'); + } + return [item]; + } + return []; + }; + + const labelValueSearch = debounce((query: string) => getLabelValuesAutofillSuggestions(query, item.label), 350); + + return ( +
+ + {/* Label name select, loads all values at once */} + { + if (change.value != null) { + onChange({ + ...item, + op: change.value, + value: isMultiSelect(change.value) ? item.value : getSelectOptionsFromString(item?.value)[0], + // eslint-ignore + } as QueryBuilderLabelFilter); + } + }} + /> + + {/* Label value async select: autocomplete calls prometheus API */} + { + setState({ isLoadingLabelValues: true }); + const labelValues = await onGetLabelValues(item); + if (labelValues.length > PROMETHEUS_QUERY_BUILDER_MAX_RESULTS) { + labelValues.splice(0, labelValues.length - PROMETHEUS_QUERY_BUILDER_MAX_RESULTS); + } + setState({ + ...state, + labelValues, + isLoadingLabelValues: undefined, + }); + }} + defaultOptions={state.labelValues} + isMulti={isMultiSelect()} + isLoading={state.isLoadingLabelValues} + loadOptions={labelValueSearch} + onChange={(change) => { + if (change.value) { + onChange({ + ...item, + value: change.value, + op: item.op ?? defaultOp, + // eslint-ignore + } as QueryBuilderLabelFilter); + } else { + const changes = change + .map((change: { label?: string }) => { + return change.label; + }) + .join('|'); + // eslint-ignore + onChange({ ...item, value: changes, op: item.op ?? defaultOp } as QueryBuilderLabelFilter); + } + }} + invalid={invalidValue} + /> + + +
+ ); +} + +const operators = [ + { label: '=~', value: '=~', isMultiValue: true }, + { label: '=', value: '=', isMultiValue: false }, + { label: '!=', value: '!=', isMultiValue: false }, + { label: '!~', value: '!~', isMultiValue: true }, +]; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/LabelFilters.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/LabelFilters.test.tsx new file mode 100644 index 00000000000..6cf9ec1d16a --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/LabelFilters.test.tsx @@ -0,0 +1,133 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React, { ComponentProps } from 'react'; +import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; + +import { getLabelSelects } from '../testUtils'; + +import { LabelFilters, MISSING_LABEL_FILTER_ERROR_MESSAGE } from './LabelFilters'; + +describe('LabelFilters', () => { + it('renders empty input without labels', async () => { + setup(); + expect(screen.getAllByText('Select label')).toHaveLength(1); + expect(screen.getAllByText('Select value')).toHaveLength(1); + expect(screen.getByText(/=/)).toBeInTheDocument(); + expect(getAddButton()).toBeInTheDocument(); + }); + + it('renders multiple labels', async () => { + setup({ + labelsFilters: [ + { label: 'foo', op: '=', value: 'bar' }, + { label: 'baz', op: '!=', value: 'qux' }, + { label: 'quux', op: '=~', value: 'quuz' }, + ], + }); + expect(screen.getByText(/foo/)).toBeInTheDocument(); + expect(screen.getByText(/bar/)).toBeInTheDocument(); + expect(screen.getByText(/baz/)).toBeInTheDocument(); + expect(screen.getByText(/qux/)).toBeInTheDocument(); + expect(screen.getByText(/quux/)).toBeInTheDocument(); + expect(screen.getByText(/quuz/)).toBeInTheDocument(); + expect(getAddButton()).toBeInTheDocument(); + }); + + it('renders multiple values for regex selectors', async () => { + setup({ + labelsFilters: [ + { label: 'bar', op: '!~', value: 'baz|bat|bau' }, + { label: 'foo', op: '!~', value: 'fop|for|fos' }, + ], + }); + expect(screen.getByText(/bar/)).toBeInTheDocument(); + expect(screen.getByText(/baz/)).toBeInTheDocument(); + expect(screen.getByText(/bat/)).toBeInTheDocument(); + expect(screen.getByText(/bau/)).toBeInTheDocument(); + expect(screen.getByText(/foo/)).toBeInTheDocument(); + expect(screen.getByText(/for/)).toBeInTheDocument(); + expect(screen.getByText(/fos/)).toBeInTheDocument(); + expect(getAddButton()).toBeInTheDocument(); + }); + + it('adds new label', async () => { + const { onChange } = setup({ labelsFilters: [{ label: 'foo', op: '=', value: 'bar' }] }); + await userEvent.click(getAddButton()); + expect(screen.getAllByText('Select label')).toHaveLength(1); + expect(screen.getAllByText('Select value')).toHaveLength(1); + const { name, value } = getLabelSelects(1); + await selectOptionInTest(name, 'baz'); + await selectOptionInTest(value, 'qux'); + expect(onChange).toBeCalledWith([ + { label: 'foo', op: '=', value: 'bar' }, + { label: 'baz', op: '=', value: 'qux' }, + ]); + }); + + it('removes label', async () => { + const { onChange } = setup({ labelsFilters: [{ label: 'foo', op: '=', value: 'bar' }] }); + await userEvent.click(screen.getByLabelText(/remove/)); + expect(onChange).toBeCalledWith([]); + }); + + it('renders empty input when labels are deleted from outside ', async () => { + const { rerender } = setup({ labelsFilters: [{ label: 'foo', op: '=', value: 'bar' }] }); + expect(screen.getByText(/foo/)).toBeInTheDocument(); + expect(screen.getByText(/bar/)).toBeInTheDocument(); + rerender( + + ); + expect(screen.getAllByText('Select label')).toHaveLength(1); + expect(screen.getAllByText('Select value')).toHaveLength(1); + expect(screen.getByText(/=/)).toBeInTheDocument(); + expect(getAddButton()).toBeInTheDocument(); + }); + + it('shows error when filter with empty strings and label filter is required', async () => { + setup({ labelsFilters: [{ label: '', op: '=', value: '' }], labelFilterRequired: true }); + expect(screen.getByText(MISSING_LABEL_FILTER_ERROR_MESSAGE)).toBeInTheDocument(); + }); + + it('shows error when no filter and label filter is required', async () => { + setup({ labelsFilters: [], labelFilterRequired: true }); + expect(screen.getByText(MISSING_LABEL_FILTER_ERROR_MESSAGE)).toBeInTheDocument(); + }); +}); + +function setup(propOverrides?: Partial>) { + const defaultProps = { + onChange: jest.fn(), + getLabelValues: jest.fn(), + getLabelValuesAutofillSuggestions: async (query: string, labelName?: string) => [ + { label: 'bar', value: 'bar' }, + { label: 'qux', value: 'qux' }, + { label: 'quux', value: 'quux' }, + ], + onGetLabelNames: async () => [ + { label: 'foo', value: 'foo' }, + { label: 'bar', value: 'bar' }, + { label: 'baz', value: 'baz' }, + ], + onGetLabelValues: async () => [ + { label: 'bar', value: 'bar' }, + { label: 'qux', value: 'qux' }, + { label: 'quux', value: 'quux' }, + ], + labelsFilters: [], + }; + + const props = { ...defaultProps, ...propOverrides }; + + const { rerender } = render(); + return { ...props, rerender }; +} + +function getAddButton() { + return screen.getByLabelText(/Add/); +} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/LabelFilters.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/LabelFilters.tsx new file mode 100644 index 00000000000..71d3b147583 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/LabelFilters.tsx @@ -0,0 +1,81 @@ +import { isEqual } from 'lodash'; +import React, { useEffect, useState } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { EditorFieldGroup, EditorField, EditorList } from '@grafana/experimental'; + +import { QueryBuilderLabelFilter } from '../shared/types'; + +import { LabelFilterItem } from './LabelFilterItem'; + +export const MISSING_LABEL_FILTER_ERROR_MESSAGE = 'Select at least 1 label filter (label and value)'; + +export interface Props { + labelsFilters: QueryBuilderLabelFilter[]; + onChange: (labelFilters: Array>) => void; + onGetLabelNames: (forLabel: Partial) => Promise; + onGetLabelValues: (forLabel: Partial) => Promise; + /** If set to true, component will show error message until at least 1 filter is selected */ + labelFilterRequired?: boolean; + getLabelValuesAutofillSuggestions: (query: string, labelName?: string) => Promise; +} + +export function LabelFilters({ + labelsFilters, + onChange, + onGetLabelNames, + onGetLabelValues, + labelFilterRequired, + getLabelValuesAutofillSuggestions, +}: Props) { + const defaultOp = '='; + const [items, setItems] = useState>>([{ op: defaultOp }]); + + useEffect(() => { + if (labelsFilters.length > 0) { + setItems(labelsFilters); + } else { + setItems([{ op: defaultOp }]); + } + }, [labelsFilters]); + + const onLabelsChange = (newItems: Array>) => { + setItems(newItems); + + // Extract full label filters with both label & value + const newLabels = newItems.filter((x) => x.label != null && x.value != null); + if (!isEqual(newLabels, labelsFilters)) { + onChange(newLabels); + } + }; + + const hasLabelFilter = items.some((item) => item.label && item.value); + + return ( + + + ( + + )} + /> + + + ); +} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx index ce42fe854d4..43fe9936bb8 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx @@ -8,6 +8,7 @@ import { EditorField, EditorFieldGroup } from '@grafana/experimental'; import { AsyncSelect, FormatOptionLabelMeta, useStyles2 } from '@grafana/ui'; import { PrometheusDatasource } from '../../datasource'; +import { regexifyLabelValuesQueryString } from '../shared/parsingUtils'; import { QueryBuilderLabelFilter } from '../shared/types'; import { PromVisualQuery } from '../types'; @@ -82,14 +83,6 @@ export function MetricSelect({ datasource, query, onChange, onGetMetrics, labels }},__name__)`; }; - /** - * There aren't any spaces in the metric names, so let's introduce a wildcard into the regex for each space to better facilitate a fuzzy search - */ - const regexifyLabelValuesQueryString = (query: string) => { - const queryArray = query.split(' '); - return queryArray.map((query) => `${query}.*`).join(''); - }; - /** * Reformat the query string and label filters to return all valid results for current query editor state */ diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx index f6e081cb0a0..21b3b7469aa 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx @@ -8,17 +8,18 @@ import { getMetadataString } from '../../language_provider'; import promqlGrammar from '../../promql'; import { promQueryModeller } from '../PromQueryModeller'; import { buildVisualQueryFromString } from '../parsing'; -import { LabelFilters } from '../shared/LabelFilters'; import { OperationExplainedBox } from '../shared/OperationExplainedBox'; import { OperationList } from '../shared/OperationList'; import { OperationListExplained } from '../shared/OperationListExplained'; import { OperationsEditorRow } from '../shared/OperationsEditorRow'; import { QueryBuilderHints } from '../shared/QueryBuilderHints'; import { RawQuery } from '../shared/RawQuery'; +import { regexifyLabelValuesQueryString } from '../shared/parsingUtils'; import { QueryBuilderLabelFilter, QueryBuilderOperation } from '../shared/types'; import { PromVisualQuery } from '../types'; -import { MetricSelect } from './MetricSelect'; +import { LabelFilters } from './LabelFilters'; +import { MetricSelect, PROMETHEUS_QUERY_BUILDER_MAX_RESULTS } from './MetricSelect'; import { NestedQueryList } from './NestedQueryList'; import { EXPLAIN_LABEL_FILTER_CONTENT } from './PromQueryBuilderExplained'; @@ -42,7 +43,7 @@ export const PromQueryBuilder = React.memo((props) => { * Map metric metadata to SelectableValue for Select component and also adds defined template variables to the list. */ const withTemplateVariableOptions = useCallback( - async (optionsPromise: Promise>): Promise => { + async (optionsPromise: Promise): Promise => { const variables = datasource.getVariables(); const options = await optionsPromise; return [ @@ -58,7 +59,7 @@ export const PromQueryBuilder = React.memo((props) => { * Formats a promQL expression and passes that off to helper functions depending on API support * @param forLabel */ - const onGetLabelNames = async (forLabel: Partial): Promise> => { + const onGetLabelNames = async (forLabel: Partial): Promise => { // If no metric we need to use a different method if (!query.metric) { // Todo add caching but inside language provider! @@ -83,18 +84,65 @@ export const PromQueryBuilder = React.memo((props) => { .map((k) => ({ value: k })); }; + const getLabelValuesAutocompleteSuggestions = ( + queryString?: string, + labelName?: string + ): Promise => { + const forLabel = { + label: labelName ?? '__name__', + op: '=~', + value: regexifyLabelValuesQueryString(`.*${queryString}`), + }; + const labelsToConsider = query.labels.filter((x) => x.label !== forLabel.label); + labelsToConsider.push(forLabel); + if (query.metric) { + labelsToConsider.push({ label: '__name__', op: '=', value: query.metric }); + } + const interpolatedLabelsToConsider = labelsToConsider.map((labelObject) => ({ + ...labelObject, + label: datasource.interpolateString(labelObject.label), + value: datasource.interpolateString(labelObject.value), + })); + const expr = promQueryModeller.renderLabels(interpolatedLabelsToConsider); + let response; + if (datasource.hasLabelsMatchAPISupport()) { + response = getLabelValuesFromLabelValuesAPI(forLabel, expr); + } else { + response = getLabelValuesFromSeriesAPI(forLabel, expr); + } + + return response.then((response: SelectableValue[]) => { + if (response.length > PROMETHEUS_QUERY_BUILDER_MAX_RESULTS) { + response.splice(0, response.length - PROMETHEUS_QUERY_BUILDER_MAX_RESULTS); + } + return response; + }); + }; + /** * Helper function to fetch and format label value results from legacy API * @param forLabel * @param promQLExpression */ - const getLabelValuesFromSeriesAPI = async (forLabel: Partial, promQLExpression: string) => { + const getLabelValuesFromSeriesAPI = ( + forLabel: Partial, + promQLExpression: string + ): Promise => { if (!forLabel.label) { - return []; + return Promise.resolve([]); } - const result = await datasource.languageProvider.fetchSeriesLabels(promQLExpression); + const result = datasource.languageProvider.fetchSeries(promQLExpression); const forLabelInterpolated = datasource.interpolateString(forLabel.label); - return result[forLabelInterpolated].map((v) => ({ value: v })) ?? []; + return result.then((result) => { + // This query returns duplicate values, scrub them out + const set = new Set(); + result.forEach((labelValue) => { + const labelNameString = labelValue[forLabelInterpolated]; + set.add(labelNameString); + }); + + return Array.from(set).map((labelValues: string) => ({ label: labelValues, value: labelValues })); + }); }; /** @@ -102,16 +150,19 @@ export const PromQueryBuilder = React.memo((props) => { * @param forLabel * @param promQLExpression */ - const getLabelValuesFromLabelValuesAPI = async ( + const getLabelValuesFromLabelValuesAPI = ( forLabel: Partial, promQLExpression: string - ) => { + ): Promise => { if (!forLabel.label) { - return []; + return Promise.resolve([]); } - return (await datasource.languageProvider.fetchSeriesValues(forLabel.label, promQLExpression)).map((v) => ({ - value: v, - })); + return datasource.languageProvider.fetchSeriesValues(forLabel.label, promQLExpression).then((response) => { + return response.map((v) => ({ + value: v, + label: v, + })); + }); }; /** @@ -119,7 +170,7 @@ export const PromQueryBuilder = React.memo((props) => { * Formats a promQL expression and passes that into helper functions depending on API support * @param forLabel */ - const onGetLabelValues = async (forLabel: Partial) => { + const onGetLabelValues = async (forLabel: Partial): Promise => { if (!forLabel.label) { return []; } @@ -130,7 +181,14 @@ export const PromQueryBuilder = React.memo((props) => { const labelsToConsider = query.labels.filter((x) => x !== forLabel); labelsToConsider.push({ label: '__name__', op: '=', value: query.metric }); - const expr = promQueryModeller.renderLabels(labelsToConsider); + + const interpolatedLabelsToConsider = labelsToConsider.map((labelObject) => ({ + ...labelObject, + label: datasource.interpolateString(labelObject.label), + value: datasource.interpolateString(labelObject.value), + })); + + const expr = promQueryModeller.renderLabels(interpolatedLabelsToConsider); if (datasource.hasLabelsMatchAPISupport()) { return getLabelValuesFromLabelValuesAPI(forLabel, expr); @@ -156,14 +214,12 @@ export const PromQueryBuilder = React.memo((props) => { labelsFilters={query.labels} /> ) => - withTemplateVariableOptions(onGetLabelNames(forLabel)) - } - onGetLabelValues={(forLabel: Partial) => - withTemplateVariableOptions(onGetLabelValues(forLabel)) - } + // eslint-ignore + onChange={onChangeLabels as (labelFilters: Array>) => void} + onGetLabelNames={(forLabel) => withTemplateVariableOptions(onGetLabelNames(forLabel))} + onGetLabelValues={(forLabel) => withTemplateVariableOptions(onGetLabelValues(forLabel))} /> {showExplain && ( @@ -177,6 +233,7 @@ export const PromQueryBuilder = React.memo((props) => { queryModeller={promQueryModeller} + // eslint-ignore datasource={datasource as DataSourceApi} query={query} onChange={onChange} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/parsingUtils.ts b/public/app/plugins/datasource/prometheus/querybuilder/shared/parsingUtils.ts index fd3bc4061ab..f8f7314bb89 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/shared/parsingUtils.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/parsingUtils.ts @@ -200,3 +200,11 @@ function jsonToText( function nodeToString(expr: string, node: SyntaxNode) { return node.name + ': ' + getString(expr, node); } + +/** + * There aren't any spaces in the metric names, so let's introduce a wildcard into the regex for each space to better facilitate a fuzzy search + */ +export const regexifyLabelValuesQueryString = (query: string) => { + const queryArray = query.split(' '); + return queryArray.map((query) => `${query}.*`).join(''); +};