Prometheus: Variable query editor improvements (#69884)

* refactor metric select and label filters, add variables to label names dropdown

* use MetricsLabelsSection in variable editor

* use variable editor UI with MetricsLabelsSection

* filter label names by optional metric and allow metric to be cleared

* fix tests

* testing types for onChangeLabels

* testing types for onChangeLabels

* testing types for onChangeLabels

* Ismails review, remove comment and indent

* wrap label filters

* improved behavior for selects and inputs, add tests and document behavior
This commit is contained in:
Brendan O'Handley
2023-06-16 15:03:34 -04:00
committed by GitHub
parent 160ff360c4
commit 3425012ee4
8 changed files with 672 additions and 433 deletions

View File

@@ -4587,8 +4587,7 @@ 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.", "1"]
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]

View File

@@ -68,8 +68,15 @@ describe('PromVariableQueryEditor', () => {
syntax: () => {},
getLabelKeys: () => [],
metrics: [],
metricsMetadata: {},
getLabelValues: jest.fn().mockImplementation(() => ['that']),
fetchSeriesLabelsMatch: jest.fn().mockImplementation(() => Promise.resolve({ those: 'those' })),
},
getInitHints: () => [],
getDebounceTimeInMilliseconds: jest.fn(),
getTagKeys: jest.fn().mockImplementation(() => Promise.resolve(['this'])),
getVariables: jest.fn().mockImplementation(() => []),
metricFindQuery: jest.fn().mockImplementation(() => Promise.resolve(['that'])),
} as unknown as PrometheusDatasource,
query: {
refId: 'test',
@@ -108,6 +115,7 @@ describe('PromVariableQueryEditor', () => {
expect(onChange).toHaveBeenCalledWith({
query: 'label_names()',
labelFilters: [],
refId,
});
});
@@ -117,6 +125,7 @@ describe('PromVariableQueryEditor', () => {
render(<PromVariableQueryEditor {...props} onChange={onChange} />);
await selectOptionInTest(screen.getByLabelText('Query type'), 'Label values');
await selectOptionInTest(screen.getByLabelText('Query type'), 'Metrics');
await selectOptionInTest(screen.getByLabelText('Query type'), 'Query result');
await selectOptionInTest(screen.getByLabelText('Query type'), 'Series query');
@@ -124,25 +133,79 @@ describe('PromVariableQueryEditor', () => {
expect(onChange).not.toHaveBeenCalled();
});
test('Calls onChange for metrics() with argument onBlur', async () => {
test('Calls onChange for metrics() after input', async () => {
const onChange = jest.fn();
props.query = {
refId: 'test',
query: 'metrics(a)',
query: 'label_names()',
};
render(<PromVariableQueryEditor {...props} onChange={onChange} />);
const labelSelect = screen.getByLabelText('Metric selector');
await userEvent.click(labelSelect);
const functionSelect = screen.getByLabelText('Query type').parentElement!;
await userEvent.click(functionSelect);
await selectOptionInTest(screen.getByLabelText('Query type'), 'Metrics');
const metricInput = screen.getByLabelText('Metric selector');
await userEvent.type(metricInput, 'a');
expect(onChange).toHaveBeenCalledWith({
query: 'metrics(a)',
refId,
});
waitFor(() =>
expect(onChange).toHaveBeenCalledWith({
query: 'metrics(a)',
labelFilters: [],
refId,
})
);
});
test('Calls onChange for label_values() after selecting label', async () => {
const onChange = jest.fn();
props.query = {
refId: 'test',
query: 'label_names()',
};
render(<PromVariableQueryEditor {...props} onChange={onChange} />);
await selectOptionInTest(screen.getByLabelText('Query type'), 'Label values');
const labelSelect = screen.getByLabelText('label-select');
await userEvent.type(labelSelect, 'this');
await selectOptionInTest(labelSelect, 'this');
waitFor(() =>
expect(onChange).toHaveBeenCalledWith({
query: 'label_values(this)',
labelFilters: [],
refId,
})
);
});
test('Calls onChange for label_values() after selecting metric', async () => {
const onChange = jest.fn();
props.query = {
refId: 'test',
query: 'label_names()',
};
render(<PromVariableQueryEditor {...props} onChange={onChange} />);
await selectOptionInTest(screen.getByLabelText('Query type'), 'Label values');
const labelSelect = screen.getByLabelText('label-select');
await userEvent.type(labelSelect, 'this');
await selectOptionInTest(labelSelect, 'this');
const metricSelect = screen.getByLabelText('Metric');
await userEvent.type(metricSelect, 'that');
await selectOptionInTest(metricSelect, 'that');
waitFor(() =>
expect(onChange).toHaveBeenCalledWith({
query: 'label_values(that,this)',
labelFilters: [],
refId,
})
);
});
test('Calls onChange for query_result() with argument onBlur', async () => {
@@ -162,6 +225,7 @@ describe('PromVariableQueryEditor', () => {
expect(onChange).toHaveBeenCalledWith({
query: 'query_result(a)',
labelFilters: [],
refId,
});
});
@@ -183,6 +247,7 @@ describe('PromVariableQueryEditor', () => {
expect(onChange).toHaveBeenCalledWith({
query: '{a: "example"}',
labelFilters: [],
refId,
});
});

View File

@@ -1,4 +1,5 @@
import React, { FormEvent, useEffect, useState } from 'react';
import { debounce } from 'lodash';
import React, { FormEvent, useCallback, useEffect, useState } from 'react';
import { QueryEditorProps, SelectableValue } from '@grafana/data';
import { InlineField, InlineFieldRow, Input, Select, TextArea } from '@grafana/ui';
@@ -8,6 +9,10 @@ import {
migrateVariableEditorBackToVariableSupport,
migrateVariableQueryToEditor,
} from '../migrations/variableMigration';
import { promQueryModeller } from '../querybuilder/PromQueryModeller';
import { MetricsLabelsSection } from '../querybuilder/components/MetricsLabelsSection';
import { QueryBuilderLabelFilter } from '../querybuilder/shared/types';
import { PromVisualQuery } from '../querybuilder/types';
import {
PromOptions,
PromQuery,
@@ -46,6 +51,9 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource }: Props)
// list of label names for label_values(), /api/v1/labels, contains the same results as label_names() function
const [labelOptions, setLabelOptions] = useState<Array<SelectableValue<string>>>([]);
// label filters have been added as a filter for metrics in label values query type
const [labelFilters, setLabelFilters] = useState<QueryBuilderLabelFilter[]>([]);
useEffect(() => {
if (!query) {
return;
@@ -57,13 +65,9 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource }: Props)
setQryType(variableQuery.qryType);
setLabel(variableQuery.label ?? '');
setMetric(variableQuery.metric ?? '');
setLabelFilters(query.labelFilters ?? []);
setVarQuery(variableQuery.varQuery ?? '');
setSeriesQuery(variableQuery.seriesQuery ?? '');
// set the migrated label in the label options
if (variableQuery.label) {
setLabelOptions([{ label: variableQuery.label, value: variableQuery.label }]);
}
}, [query]);
// set the label names options for the label values var query
@@ -71,15 +75,40 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource }: Props)
if (qryType !== QueryType.LabelValues) {
return;
}
const variables = datasource.getVariables().map((variable: string) => ({ label: variable, value: variable }));
if (!metric) {
// get all the labels
datasource.getTagKeys().then((labelNames: Array<{ text: string }>) => {
const names = labelNames.map(({ text }) => ({ label: text, value: text }));
setLabelOptions([...variables, ...names]);
});
} else {
// fetch the labels filtered by the metric
const labelToConsider = [{ label: '__name__', op: '=', value: metric }];
const expr = promQueryModeller.renderLabels(labelToConsider);
datasource.getTagKeys().then((labelNames: Array<{ text: string }>) => {
setLabelOptions(labelNames.map(({ text }) => ({ label: text, value: text })));
});
}, [datasource, qryType]);
if (datasource.hasLabelsMatchAPISupport()) {
datasource.languageProvider.fetchSeriesLabelsMatch(expr).then((labelsIndex: Record<string, string[]>) => {
const labelNames = Object.keys(labelsIndex);
const names = labelNames.map((value) => ({ label: value, value: value }));
setLabelOptions([...variables, ...names]);
});
} else {
datasource.languageProvider.fetchSeriesLabels(expr).then((labelsIndex: Record<string, string[]>) => {
const labelNames = Object.keys(labelsIndex);
const names = labelNames.map((value) => ({ label: value, value: value }));
setLabelOptions([...variables, ...names]);
});
}
}
}, [datasource, qryType, metric]);
const onChangeWithVariableString = (qryType: QueryType) => {
const onChangeWithVariableString = (
updateVar: { [key: string]: QueryType | string },
updLabelFilters?: QueryBuilderLabelFilter[]
) => {
const queryVar = {
qryType: qryType,
qryType,
label,
metric,
varQuery,
@@ -87,113 +116,146 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource }: Props)
refId: 'PrometheusVariableQueryEditor-VariableQuery',
};
const queryString = migrateVariableEditorBackToVariableSupport(queryVar);
const updatedVar = { ...queryVar, ...updateVar };
const queryString = migrateVariableEditorBackToVariableSupport(updatedVar);
const lblFltrs = updLabelFilters ? updLabelFilters : labelFilters;
// setting query.query property allows for update of variable definition
onChange({
query: queryString,
labelFilters: lblFltrs,
refId,
});
};
/** Call onchange for label names query type change */
const onQueryTypeChange = (newType: SelectableValue<QueryType>) => {
setQryType(newType.value);
if (newType.value === QueryType.LabelNames) {
onChangeWithVariableString(newType.value);
onChangeWithVariableString({ qryType: newType.value });
}
};
/** Call onchange for label select when query type is label values */
const onLabelChange = (newLabel: SelectableValue<string>) => {
setLabel(newLabel.value ?? '');
const newLabelvalue = newLabel && newLabel.value ? newLabel.value : '';
setLabel(newLabelvalue);
if (qryType === QueryType.LabelValues && newLabelvalue) {
onChangeWithVariableString({ label: newLabelvalue });
}
};
const onMetricChange = (e: FormEvent<HTMLInputElement>) => {
setMetric(e.currentTarget.value);
/**
* Call onChange for MetricsLabels component change for label values query type
* if there is a label (required) and
* if the labels or metric are updated.
*/
const metricsLabelsChange = (update: PromVisualQuery) => {
setMetric(update.metric);
setLabelFilters(update.labels);
const updMetric = update.metric;
const updLabelFilters = update.labels ?? [];
if (qryType === QueryType.LabelValues && label && (updMetric || updLabelFilters)) {
onChangeWithVariableString({ qryType, metric: updMetric }, updLabelFilters);
}
};
/**
* Call onchange for metric change if metrics names (regex) query type
* Debounce this because to not call the API for every keystroke.
*/
const onMetricChange = debounce((value: string) => {
if (qryType === QueryType.MetricNames && value) {
onChangeWithVariableString({ metric: value });
}
}, 300);
/**
* Do not call onchange for variable query result when query type is var query result
* because the query may not be finished typing and an error is returned
* for incorrectly formatted series. Call onchange for blur instead.
*/
const onVarQueryChange = (e: FormEvent<HTMLTextAreaElement>) => {
setVarQuery(e.currentTarget.value);
};
/**
* Do not call onchange for seriesQuery when query type is series query
* because the series may not be finished typing and an error is returned
* for incorrectly formatted series. Call onchange for blur instead.
*/
const onSeriesQueryChange = (e: FormEvent<HTMLInputElement>) => {
setSeriesQuery(e.currentTarget.value);
};
const handleBlur = () => {
if (qryType === QueryType.LabelNames) {
onChangeWithVariableString(qryType);
} else if (qryType === QueryType.LabelValues && label) {
onChangeWithVariableString(qryType);
} else if (qryType === QueryType.MetricNames && metric) {
onChangeWithVariableString(qryType);
} else if (qryType === QueryType.VarQueryResult && varQuery) {
onChangeWithVariableString(qryType);
} else if (qryType === QueryType.SeriesQuery && seriesQuery) {
onChangeWithVariableString(qryType);
}
};
const promVisualQuery = useCallback(() => {
return { metric: metric, labels: labelFilters, operations: [] };
}, [metric, labelFilters]);
return (
<InlineFieldRow>
<InlineField
label="Query Type"
labelWidth={20}
tooltip={
<div>The Prometheus data source plugin provides the following query types for template variables.</div>
}
>
<Select
placeholder="Select query type"
aria-label="Query type"
onChange={onQueryTypeChange}
onBlur={handleBlur}
value={qryType}
options={variableOptions}
width={25}
/>
</InlineField>
<>
<InlineFieldRow>
<InlineField
label="Query type"
labelWidth={20}
tooltip={
<div>The Prometheus data source plugin provides the following query types for template variables.</div>
}
>
<Select
placeholder="Select query type"
aria-label="Query type"
onChange={onQueryTypeChange}
value={qryType}
options={variableOptions}
width={25}
/>
</InlineField>
</InlineFieldRow>
{qryType === QueryType.LabelValues && (
<>
<InlineField
label="Label"
labelWidth={20}
required
tooltip={
<div>
Returns a list of label values for the label name in all metrics unless the metric is specified.
</div>
}
>
<Select
aria-label="label-select"
onChange={onLabelChange}
onBlur={handleBlur}
value={label}
options={labelOptions}
width={25}
allowCustomValue
/>
</InlineField>
<InlineField
label="Metric"
labelWidth={20}
tooltip={<div>Optional: returns a list of label values for the label name in the specified metric.</div>}
>
<Input
type="text"
aria-label="Metric selector"
placeholder="Optional metric selector"
value={metric}
onChange={onMetricChange}
onBlur={handleBlur}
width={25}
/>
</InlineField>
<InlineFieldRow>
<InlineField
label="Label"
labelWidth={20}
required
aria-labelledby="label-select"
tooltip={
<div>
Returns a list of label values for the label name in all metrics unless the metric is specified.
</div>
}
>
<Select
aria-label="label-select"
onChange={onLabelChange}
value={label}
options={labelOptions}
width={25}
allowCustomValue
isClearable={true}
/>
</InlineField>
</InlineFieldRow>
{/* Used to select an optional metric with optional label filters */}
<MetricsLabelsSection
query={promVisualQuery()}
datasource={datasource}
onChange={metricsLabelsChange}
variableEditor={true}
/>
</>
)}
{qryType === QueryType.MetricNames && (
<>
<InlineFieldRow>
<InlineField
label="Metric Regex"
label="Metric regex"
labelWidth={20}
tooltip={<div>Returns a list of metrics matching the specified metric regex.</div>}
>
@@ -202,15 +264,18 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource }: Props)
aria-label="Metric selector"
placeholder="Metric Regex"
value={metric}
onChange={onMetricChange}
onBlur={handleBlur}
onChange={(e) => {
setMetric(e.currentTarget.value);
onMetricChange(e.currentTarget.value);
}}
width={25}
/>
</InlineField>
</>
</InlineFieldRow>
)}
{qryType === QueryType.VarQueryResult && (
<>
<InlineFieldRow>
<InlineField
label="Query"
labelWidth={20}
@@ -227,14 +292,19 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource }: Props)
placeholder="Prometheus Query"
value={varQuery}
onChange={onVarQueryChange}
onBlur={handleBlur}
onBlur={() => {
if (qryType === QueryType.VarQueryResult && varQuery) {
onChangeWithVariableString({ qryType });
}
}}
cols={100}
/>
</InlineField>
</>
</InlineFieldRow>
)}
{qryType === QueryType.SeriesQuery && (
<>
<InlineFieldRow>
<InlineField
label="Series Query"
labelWidth={20}
@@ -253,13 +323,17 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource }: Props)
placeholder="Series Query"
value={seriesQuery}
onChange={onSeriesQueryChange}
onBlur={handleBlur}
onBlur={() => {
if (qryType === QueryType.SeriesQuery && seriesQuery) {
onChangeWithVariableString({ qryType });
}
}}
width={100}
/>
</InlineField>
</>
</InlineFieldRow>
)}
</InlineFieldRow>
</>
);
};

View File

@@ -1,8 +1,10 @@
import { css, cx } from '@emotion/css';
import { isEqual } from 'lodash';
import React, { useEffect, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { EditorFieldGroup, EditorField, EditorList } from '@grafana/experimental';
import { InlineFieldRow, InlineLabel } from '@grafana/ui';
import { QueryBuilderLabelFilter } from '../shared/types';
@@ -19,6 +21,7 @@ export interface Props {
labelFilterRequired?: boolean;
getLabelValuesAutofillSuggestions: (query: string, labelName?: string) => Promise<SelectableValue[]>;
debounceDuration: number;
variableEditor?: boolean;
}
export function LabelFilters({
@@ -29,6 +32,7 @@ export function LabelFilters({
labelFilterRequired,
getLabelValuesAutofillSuggestions,
debounceDuration,
variableEditor,
}: Props) {
const defaultOp = '=';
const [items, setItems] = useState<Array<Partial<QueryBuilderLabelFilter>>>([{ op: defaultOp }]);
@@ -53,32 +57,60 @@ export function LabelFilters({
const hasLabelFilter = items.some((item) => item.label && item.value);
const editorList = () => {
return (
<EditorList
items={items}
onChange={onLabelsChange}
renderItem={(item: Partial<QueryBuilderLabelFilter>, onChangeItem, onDelete) => (
<LabelFilterItem
debounceDuration={debounceDuration}
item={item}
defaultOp={defaultOp}
onChange={onChangeItem}
onDelete={onDelete}
onGetLabelNames={onGetLabelNames}
onGetLabelValues={onGetLabelValues}
invalidLabel={labelFilterRequired && !item.label}
invalidValue={labelFilterRequired && !item.value}
getLabelValuesAutofillSuggestions={getLabelValuesAutofillSuggestions}
/>
)}
/>
);
};
return (
<EditorFieldGroup>
<EditorField
label="Label filters"
error={MISSING_LABEL_FILTER_ERROR_MESSAGE}
invalid={labelFilterRequired && !hasLabelFilter}
>
<EditorList
items={items}
onChange={onLabelsChange}
renderItem={(item: Partial<QueryBuilderLabelFilter>, onChangeItem, onDelete) => (
<LabelFilterItem
debounceDuration={debounceDuration}
item={item}
defaultOp={defaultOp}
onChange={onChangeItem}
onDelete={onDelete}
onGetLabelNames={onGetLabelNames}
onGetLabelValues={onGetLabelValues}
invalidLabel={labelFilterRequired && !item.label}
invalidValue={labelFilterRequired && !item.value}
getLabelValuesAutofillSuggestions={getLabelValuesAutofillSuggestions}
/>
)}
/>
</EditorField>
</EditorFieldGroup>
<>
{variableEditor ? (
<InlineFieldRow>
<div
className={cx(
css`
display: flex;
`
)}
>
<InlineLabel
width={20}
tooltip={<div>Optional: used to filter the metric select for this query type.</div>}
>
Label filters
</InlineLabel>
{editorList()}
</div>
</InlineFieldRow>
) : (
<EditorFieldGroup>
<EditorField
label="Label filters"
error={MISSING_LABEL_FILTER_ERROR_MESSAGE}
invalid={labelFilterRequired && !hasLabelFilter}
>
{editorList()}
</EditorField>
</EditorFieldGroup>
)}
</>
);
}

View File

@@ -6,7 +6,7 @@ import Highlighter from 'react-highlight-words';
import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data';
import { EditorField, EditorFieldGroup } from '@grafana/experimental';
import { config } from '@grafana/runtime';
import { AsyncSelect, Button, FormatOptionLabelMeta, Icon, useStyles2 } from '@grafana/ui';
import { AsyncSelect, Button, FormatOptionLabelMeta, Icon, InlineField, InlineFieldRow, useStyles2 } from '@grafana/ui';
import { SelectMenuOptions } from '@grafana/ui/src/components/Select/SelectMenu';
import { PrometheusDatasource } from '../../datasource';
@@ -27,12 +27,12 @@ export interface Props {
onGetMetrics: () => Promise<SelectableValue[]>;
datasource: PrometheusDatasource;
labelsFilters: QueryBuilderLabelFilter[];
onBlur?: () => void;
variableEditor?: boolean;
}
export const PROMETHEUS_QUERY_BUILDER_MAX_RESULTS = 1000;
const prometheusMetricEncyclopedia = config.featureToggles.prometheusMetricEncyclopedia;
export function MetricSelect({
datasource,
query,
@@ -40,6 +40,8 @@ export function MetricSelect({
onGetMetrics,
labelsFilters,
metricLookupDisabled,
onBlur,
variableEditor,
}: Props) {
const styles = useStyles2(getStyles);
const [state, setState] = useState<{
@@ -49,6 +51,8 @@ export function MetricSelect({
initialMetrics?: string[];
}>({});
const prometheusMetricEncyclopedia = config.featureToggles.prometheusMetricEncyclopedia;
const metricsModalOption: SelectableValue[] = [
{
value: 'BrowseMetrics',
@@ -57,29 +61,32 @@ export function MetricSelect({
},
];
const customFilterOption = useCallback((option: SelectableValue<any>, 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());
let browseOption = false;
if (prometheusMetricEncyclopedia) {
browseOption = label === 'Metrics explorer';
const customFilterOption = useCallback(
(option: SelectableValue<any>, searchQuery: string) => {
const label = option.label ?? option.value;
if (!label) {
return false;
}
return acc && (matcheSearch || browseOption);
}, true);
}, []);
// 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());
let browseOption = false;
if (prometheusMetricEncyclopedia) {
browseOption = label === 'Metrics explorer';
}
return acc && (matcheSearch || browseOption);
}, true);
},
[prometheusMetricEncyclopedia]
);
const formatOptionLabel = useCallback(
(option: SelectableValue<any>, meta: FormatOptionLabelMeta<any>) => {
@@ -192,6 +199,63 @@ export function MetricSelect({
return SelectMenuOptions(props);
};
const asyncSelect = () => {
return (
<AsyncSelect
isClearable={variableEditor ? true : false}
inputId="prometheus-metric-select"
className={styles.select}
value={query.metric ? toOption(query.metric) : undefined}
placeholder={'Select metric'}
allowCustomValue
formatOptionLabel={formatOptionLabel}
filterOption={customFilterOption}
onOpenMenu={async () => {
if (metricLookupDisabled) {
return;
}
setState({ isLoading: true });
const metrics = await onGetMetrics();
const initialMetrics: string[] = metrics.map((m) => m.value);
if (metrics.length > PROMETHEUS_QUERY_BUILDER_MAX_RESULTS) {
metrics.splice(0, metrics.length - PROMETHEUS_QUERY_BUILDER_MAX_RESULTS);
}
if (prometheusMetricEncyclopedia) {
setState({
// add the modal butoon option to the options
metrics: [...metricsModalOption, ...metrics],
isLoading: undefined,
// pass the initial metrics into the Metrics Modal
initialMetrics: initialMetrics,
});
} else {
setState({ metrics, isLoading: undefined });
}
}}
loadOptions={metricLookupDisabled ? metricLookupDisabledSearch : debouncedSearch}
isLoading={state.isLoading}
defaultOptions={state.metrics}
onChange={(input) => {
const value = input?.value;
if (value) {
// if there is no metric and the m.e. is enabled, open the modal
if (prometheusMetricEncyclopedia && value === 'BrowseMetrics') {
tracking('grafana_prometheus_metric_encyclopedia_open', null, '', query);
setState({ ...state, metricsModalOpen: true });
} else {
onChange({ ...query, metric: value });
}
} else {
onChange({ ...query, metric: '' });
}
}}
components={prometheusMetricEncyclopedia ? { Option: CustomOption } : {}}
onBlur={onBlur ? onBlur : () => {}}
/>
);
};
return (
<>
{prometheusMetricEncyclopedia && !datasource.lookupsDisabled && state.metricsModalOpen && (
@@ -204,57 +268,22 @@ export function MetricSelect({
initialMetrics={state.initialMetrics ?? []}
/>
)}
<EditorFieldGroup>
<EditorField label="Metric">
<AsyncSelect
inputId="prometheus-metric-select"
className={styles.select}
value={query.metric ? toOption(query.metric) : undefined}
placeholder={'Select metric'}
allowCustomValue
formatOptionLabel={formatOptionLabel}
filterOption={customFilterOption}
onOpenMenu={async () => {
if (metricLookupDisabled) {
return;
}
setState({ isLoading: true });
const metrics = await onGetMetrics();
const initialMetrics: string[] = metrics.map((m) => m.value);
if (metrics.length > PROMETHEUS_QUERY_BUILDER_MAX_RESULTS) {
metrics.splice(0, metrics.length - PROMETHEUS_QUERY_BUILDER_MAX_RESULTS);
}
if (prometheusMetricEncyclopedia) {
setState({
// add the modal butoon option to the options
metrics: [...metricsModalOption, ...metrics],
isLoading: undefined,
// pass the initial metrics into the Metrics Modal
initialMetrics: initialMetrics,
});
} else {
setState({ metrics, isLoading: undefined });
}
}}
loadOptions={metricLookupDisabled ? metricLookupDisabledSearch : debouncedSearch}
isLoading={state.isLoading}
defaultOptions={state.metrics}
onChange={({ value }) => {
if (value) {
// if there is no metric and the m.e. is enabled, open the modal
if (prometheusMetricEncyclopedia && value === 'BrowseMetrics') {
tracking('grafana_prometheus_metric_encyclopedia_open', null, '', query);
setState({ ...state, metricsModalOpen: true });
} else {
onChange({ ...query, metric: value });
}
}
}}
components={prometheusMetricEncyclopedia ? { Option: CustomOption } : {}}
/>
</EditorField>
</EditorFieldGroup>
{/* 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>
)}
</>
);
}

View File

@@ -0,0 +1,258 @@
import React, { useCallback } from 'react';
import { SelectableValue } from '@grafana/data';
import { PrometheusDatasource } from '../../datasource';
import { getMetadataString } from '../../language_provider';
import { promQueryModeller } from '../PromQueryModeller';
import { regexifyLabelValuesQueryString } from '../shared/parsingUtils';
import { QueryBuilderLabelFilter } from '../shared/types';
import { PromVisualQuery } from '../types';
import { LabelFilters } from './LabelFilters';
import { MetricSelect, PROMETHEUS_QUERY_BUILDER_MAX_RESULTS } from './MetricSelect';
export interface MetricsLabelsSectionProps {
query: PromVisualQuery;
datasource: PrometheusDatasource;
onChange: (update: PromVisualQuery) => void;
variableEditor?: boolean;
onBlur?: () => void;
}
export function MetricsLabelsSection({
datasource,
query,
onChange,
onBlur,
variableEditor,
}: MetricsLabelsSectionProps) {
// fixing the use of 'as' from refactoring
// @ts-ignore
const onChangeLabels = (labels) => {
onChange({ ...query, labels });
};
/**
* Map metric metadata to SelectableValue for Select component and also adds defined template variables to the list.
*/
const withTemplateVariableOptions = useCallback(
async (optionsPromise: Promise<SelectableValue[]>): Promise<SelectableValue[]> => {
const variables = datasource.getVariables();
const options = await optionsPromise;
return [
...variables.map((value: string) => ({ label: value, value })),
...options.map((option: SelectableValue) => ({
label: option.value,
value: option.value,
title: option.description,
})),
];
},
[datasource]
);
/**
* Function kicked off when user interacts with label in label filters.
* Formats a promQL expression and passes that off to helper functions depending on API support
* @param forLabel
*/
const onGetLabelNames = async (forLabel: Partial<QueryBuilderLabelFilter>): Promise<SelectableValue[]> => {
// If no metric we need to use a different method
if (!query.metric) {
await datasource.languageProvider.fetchLabels();
return datasource.languageProvider.getLabelKeys().map((k) => ({ value: k }));
}
const labelsToConsider = query.labels.filter((x) => x !== forLabel);
labelsToConsider.push({ label: '__name__', op: '=', value: query.metric });
const expr = promQueryModeller.renderLabels(labelsToConsider);
let labelsIndex: Record<string, string[]>;
if (datasource.hasLabelsMatchAPISupport()) {
labelsIndex = await datasource.languageProvider.fetchSeriesLabelsMatch(expr);
} else {
labelsIndex = await datasource.languageProvider.fetchSeriesLabels(expr);
}
// filter out already used labels
return Object.keys(labelsIndex)
.filter((labelName) => !labelsToConsider.find((filter) => filter.label === labelName))
.map((k) => ({ value: k }));
};
const getLabelValuesAutocompleteSuggestions = (
queryString?: string,
labelName?: string
): Promise<SelectableValue[]> => {
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: Promise<SelectableValue[]>;
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 = (
forLabel: Partial<QueryBuilderLabelFilter>,
promQLExpression: string
): Promise<SelectableValue[]> => {
if (!forLabel.label) {
return Promise.resolve([]);
}
const result = datasource.languageProvider.fetchSeries(promQLExpression);
const forLabelInterpolated = datasource.interpolateString(forLabel.label);
return result.then((result) => {
// This query returns duplicate values, scrub them out
const set = new Set<string>();
result.forEach((labelValue) => {
const labelNameString = labelValue[forLabelInterpolated];
set.add(labelNameString);
});
return Array.from(set).map((labelValues: string) => ({ label: labelValues, value: labelValues }));
});
};
/**
* Helper function to fetch label values from a promql string expression and a label
* @param forLabel
* @param promQLExpression
*/
const getLabelValuesFromLabelValuesAPI = (
forLabel: Partial<QueryBuilderLabelFilter>,
promQLExpression: string
): Promise<SelectableValue[]> => {
if (!forLabel.label) {
return Promise.resolve([]);
}
return datasource.languageProvider.fetchSeriesValuesWithMatch(forLabel.label, promQLExpression).then((response) => {
return response.map((v) => ({
value: v,
label: v,
}));
});
};
/**
* Function kicked off when users interact with the value of the label filters
* Formats a promQL expression and passes that into helper functions depending on API support
* @param forLabel
*/
const onGetLabelValues = async (forLabel: Partial<QueryBuilderLabelFilter>): Promise<SelectableValue[]> => {
if (!forLabel.label) {
return [];
}
// If no metric is selected, we can get the raw list of labels
if (!query.metric) {
return (await datasource.languageProvider.getLabelValues(forLabel.label)).map((v) => ({ value: v }));
}
const labelsToConsider = query.labels.filter((x) => x !== forLabel);
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);
if (datasource.hasLabelsMatchAPISupport()) {
return getLabelValuesFromLabelValuesAPI(forLabel, expr);
} else {
return getLabelValuesFromSeriesAPI(forLabel, expr);
}
};
const onGetMetrics = useCallback(() => {
return withTemplateVariableOptions(getMetrics(datasource, query));
}, [datasource, query, withTemplateVariableOptions]);
return (
<>
<MetricSelect
query={query}
onChange={onChange}
onGetMetrics={onGetMetrics}
datasource={datasource}
labelsFilters={query.labels}
metricLookupDisabled={datasource.lookupsDisabled}
onBlur={onBlur ? onBlur : () => {}}
variableEditor={variableEditor}
/>
<LabelFilters
debounceDuration={datasource.getDebounceTimeInMilliseconds()}
getLabelValuesAutofillSuggestions={getLabelValuesAutocompleteSuggestions}
labelsFilters={query.labels}
onChange={onChangeLabels}
onGetLabelNames={(forLabel) => withTemplateVariableOptions(onGetLabelNames(forLabel))}
onGetLabelValues={(forLabel) => withTemplateVariableOptions(onGetLabelValues(forLabel))}
variableEditor={variableEditor}
/>
</>
);
}
/**
* Returns list of metrics, either all or filtered by query param. It also adds description string to each metric if it
* exists.
* @param datasource
* @param query
*/
async function getMetrics(
datasource: PrometheusDatasource,
query: PromVisualQuery
): Promise<Array<{ value: string; description?: string }>> {
// Makes sure we loaded the metadata for metrics. Usually this is done in the start() method of the provider but we
// don't use it with the visual builder and there is no need to run all the start() setup anyway.
if (!datasource.languageProvider.metricsMetadata) {
await datasource.languageProvider.loadMetricsMetadata();
}
// Error handling for when metrics metadata returns as undefined
if (!datasource.languageProvider.metricsMetadata) {
datasource.languageProvider.metricsMetadata = {};
}
let metrics: string[];
if (query.labels.length > 0) {
const expr = promQueryModeller.renderLabels(query.labels);
metrics = (await datasource.languageProvider.getSeries(expr, true))['__name__'] ?? [];
} else {
metrics = (await datasource.languageProvider.getLabelValues('__name__')) ?? [];
}
return metrics.map((m) => ({
value: m,
description: getMetadataString(m, datasource.languageProvider.metricsMetadata!),
}));
}

View File

@@ -1,10 +1,9 @@
import React, { useCallback, useState } from 'react';
import React, { useState } from 'react';
import { DataSourceApi, PanelData, SelectableValue } from '@grafana/data';
import { DataSourceApi, PanelData } from '@grafana/data';
import { EditorRow } from '@grafana/experimental';
import { PrometheusDatasource } from '../../datasource';
import { getMetadataString } from '../../language_provider';
import promqlGrammar from '../../promql';
import { promQueryModeller } from '../PromQueryModeller';
import { buildVisualQueryFromString } from '../parsing';
@@ -14,12 +13,10 @@ 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 { QueryBuilderOperation } from '../shared/types';
import { PromVisualQuery } from '../types';
import { LabelFilters } from './LabelFilters';
import { MetricSelect, PROMETHEUS_QUERY_BUILDER_MAX_RESULTS } from './MetricSelect';
import { MetricsLabelsSection } from './MetricsLabelsSection';
import { NestedQueryList } from './NestedQueryList';
import { EXPLAIN_LABEL_FILTER_CONTENT } from './PromQueryBuilderExplained';
@@ -35,171 +32,6 @@ export interface Props {
export const PromQueryBuilder = React.memo<Props>((props) => {
const { datasource, query, onChange, onRunQuery, data, showExplain } = props;
const [highlightedOp, setHighlightedOp] = useState<QueryBuilderOperation | undefined>();
const onChangeLabels = (labels: QueryBuilderLabelFilter[]) => {
onChange({ ...query, labels });
};
/**
* Map metric metadata to SelectableValue for Select component and also adds defined template variables to the list.
*/
const withTemplateVariableOptions = useCallback(
async (optionsPromise: Promise<SelectableValue[]>): Promise<SelectableValue[]> => {
const variables = datasource.getVariables();
const options = await optionsPromise;
return [
...variables.map((value) => ({ label: value, value })),
...options.map((option) => ({ label: option.value, value: option.value, title: option.description })),
];
},
[datasource]
);
/**
* Function kicked off when user interacts with label in label filters.
* Formats a promQL expression and passes that off to helper functions depending on API support
* @param forLabel
*/
const onGetLabelNames = async (forLabel: Partial<QueryBuilderLabelFilter>): Promise<SelectableValue[]> => {
// If no metric we need to use a different method
if (!query.metric) {
// Todo add caching but inside language provider!
await datasource.languageProvider.fetchLabels();
return datasource.languageProvider.getLabelKeys().map((k) => ({ value: k }));
}
const labelsToConsider = query.labels.filter((x) => x !== forLabel);
labelsToConsider.push({ label: '__name__', op: '=', value: query.metric });
const expr = promQueryModeller.renderLabels(labelsToConsider);
let labelsIndex;
if (datasource.hasLabelsMatchAPISupport()) {
labelsIndex = await datasource.languageProvider.fetchSeriesLabelsMatch(expr);
} else {
labelsIndex = await datasource.languageProvider.fetchSeriesLabels(expr);
}
// filter out already used labels
return Object.keys(labelsIndex)
.filter((labelName) => !labelsToConsider.find((filter) => filter.label === labelName))
.map((k) => ({ value: k }));
};
const getLabelValuesAutocompleteSuggestions = (
queryString?: string,
labelName?: string
): Promise<SelectableValue[]> => {
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 = (
forLabel: Partial<QueryBuilderLabelFilter>,
promQLExpression: string
): Promise<SelectableValue[]> => {
if (!forLabel.label) {
return Promise.resolve([]);
}
const result = datasource.languageProvider.fetchSeries(promQLExpression);
const forLabelInterpolated = datasource.interpolateString(forLabel.label);
return result.then((result) => {
// This query returns duplicate values, scrub them out
const set = new Set<string>();
result.forEach((labelValue) => {
const labelNameString = labelValue[forLabelInterpolated];
set.add(labelNameString);
});
return Array.from(set).map((labelValues: string) => ({ label: labelValues, value: labelValues }));
});
};
/**
* Helper function to fetch label values from a promql string expression and a label
* @param forLabel
* @param promQLExpression
*/
const getLabelValuesFromLabelValuesAPI = (
forLabel: Partial<QueryBuilderLabelFilter>,
promQLExpression: string
): Promise<SelectableValue[]> => {
if (!forLabel.label) {
return Promise.resolve([]);
}
return datasource.languageProvider.fetchSeriesValuesWithMatch(forLabel.label, promQLExpression).then((response) => {
return response.map((v) => ({
value: v,
label: v,
}));
});
};
/**
* Function kicked off when users interact with the value of the label filters
* Formats a promQL expression and passes that into helper functions depending on API support
* @param forLabel
*/
const onGetLabelValues = async (forLabel: Partial<QueryBuilderLabelFilter>): Promise<SelectableValue[]> => {
if (!forLabel.label) {
return [];
}
// If no metric is selected, we can get the raw list of labels
if (!query.metric) {
return (await datasource.languageProvider.getLabelValues(forLabel.label)).map((v) => ({ value: v }));
}
const labelsToConsider = query.labels.filter((x) => x !== forLabel);
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);
if (datasource.hasLabelsMatchAPISupport()) {
return getLabelValuesFromLabelValuesAPI(forLabel, expr);
} else {
return getLabelValuesFromSeriesAPI(forLabel, expr);
}
};
const onGetMetrics = useCallback(() => {
return withTemplateVariableOptions(getMetrics(datasource, query));
}, [datasource, query, withTemplateVariableOptions]);
const lang = { grammar: promqlGrammar, name: 'promql' };
@@ -208,23 +40,7 @@ export const PromQueryBuilder = React.memo<Props>((props) => {
return (
<>
<EditorRow>
<MetricSelect
query={query}
onChange={onChange}
onGetMetrics={onGetMetrics}
datasource={datasource}
labelsFilters={query.labels}
metricLookupDisabled={datasource.lookupsDisabled}
/>
<LabelFilters
debounceDuration={datasource.getDebounceTimeInMilliseconds()}
getLabelValuesAutofillSuggestions={getLabelValuesAutocompleteSuggestions}
labelsFilters={query.labels}
// eslint-ignore
onChange={onChangeLabels as (labelFilters: Array<Partial<QueryBuilderLabelFilter>>) => void}
onGetLabelNames={(forLabel) => withTemplateVariableOptions(onGetLabelNames(forLabel))}
onGetLabelValues={(forLabel) => withTemplateVariableOptions(onGetLabelValues(forLabel))}
/>
<MetricsLabelsSection query={query} onChange={onChange} datasource={datasource} />
</EditorRow>
{initHints.length ? (
<div className="query-row-break">
@@ -288,39 +104,4 @@ export const PromQueryBuilder = React.memo<Props>((props) => {
);
});
/**
* Returns list of metrics, either all or filtered by query param. It also adds description string to each metric if it
* exists.
* @param datasource
* @param query
*/
async function getMetrics(
datasource: PrometheusDatasource,
query: PromVisualQuery
): Promise<Array<{ value: string; description?: string }>> {
// Makes sure we loaded the metadata for metrics. Usually this is done in the start() method of the provider but we
// don't use it with the visual builder and there is no need to run all the start() setup anyway.
if (!datasource.languageProvider.metricsMetadata) {
await datasource.languageProvider.loadMetricsMetadata();
}
// Error handling for when metrics metadata returns as undefined
if (!datasource.languageProvider.metricsMetadata) {
datasource.languageProvider.metricsMetadata = {};
}
let metrics;
if (query.labels.length > 0) {
const expr = promQueryModeller.renderLabels(query.labels);
metrics = (await datasource.languageProvider.getSeries(expr, true))['__name__'] ?? [];
} else {
metrics = (await datasource.languageProvider.getLabelValues('__name__')) ?? [];
}
return metrics.map((m) => ({
value: m,
description: getMetadataString(m, datasource.languageProvider.metricsMetadata!),
}));
}
PromQueryBuilder.displayName = 'PromQueryBuilder';

View File

@@ -4,7 +4,7 @@ import { DataQuery } from '@grafana/schema';
import { PromApplication } from '../../../types/unified-alerting-dto';
import { Prometheus as GenPromQuery } from './dataquery.gen';
import { QueryEditorMode } from './querybuilder/shared/types';
import { QueryBuilderLabelFilter, QueryEditorMode } from './querybuilder/shared/types';
export interface PromQuery extends GenPromQuery, DataQuery {
/**
@@ -191,6 +191,7 @@ export interface PromVariableQuery extends DataQuery {
metric?: string;
varQuery?: string;
seriesQuery?: string;
labelFilters?: QueryBuilderLabelFilter[];
}
export type StandardPromVariableQuery = {