Prometheus: Show variable options in query builder (#44784)

* Prometheus: Show variable options

* Remove lint error

* Fix test for CodeQL

* Update public/app/plugins/datasource/prometheus/datasource.ts

Co-authored-by: Torkel Ödegaard <torkel@grafana.org>

* Update public/app/plugins/datasource/loki/datasource.ts

Co-authored-by: Torkel Ödegaard <torkel@grafana.org>

Co-authored-by: Torkel Ödegaard <torkel@grafana.org>
pull/44780/head^2
Ivana Huckova 4 years ago committed by GitHub
parent f582e6c86a
commit c23bc1e7b7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      public/app/plugins/datasource/loki/components/__snapshots__/LokiExploreQueryEditor.test.tsx.snap
  2. 8
      public/app/plugins/datasource/loki/datasource.ts
  3. 21
      public/app/plugins/datasource/loki/language_provider.test.ts
  4. 10
      public/app/plugins/datasource/loki/language_provider.ts
  5. 1
      public/app/plugins/datasource/loki/mocks.ts
  6. 18
      public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx
  7. 8
      public/app/plugins/datasource/prometheus/datasource.ts
  8. 40
      public/app/plugins/datasource/prometheus/language_provider.test.ts
  9. 7
      public/app/plugins/datasource/prometheus/language_provider.ts
  10. 10
      public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx
  11. 37
      public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.test.tsx
  12. 25
      public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx
  13. 8
      public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilterItem.tsx
  14. 12
      public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.test.tsx
  15. 5
      public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.tsx

@ -54,6 +54,7 @@ exports[`LokiExploreQueryEditor should render component 1`] = `
datasource={
Object {
"getTimeRangeParams": [Function],
"interpolateString": [Function],
"languageProvider": LokiLanguageProvider {
"cleanText": [Function],
"datasource": [Circular],

@ -754,6 +754,14 @@ export class LokiDatasource
return addLabelToQuery(queryExpr, key, value, operator, true);
}
}
interpolateString(string: string) {
return this.templateSrv.replace(string, undefined, this.interpolateQueryExpr);
}
getVariables(): string[] {
return this.templateSrv.getVariables().map((v) => `$${v.name}`);
}
}
export function lokiRegularEscape(value: any) {

@ -103,6 +103,27 @@ describe('Language completion provider', () => {
});
});
describe('fetchSeriesLabels', () => {
it('should interpolate variable in series', () => {
const datasource: LokiDatasource = {
metadataRequest: () => ({ data: { data: [] as any[] } }),
getTimeRangeParams: () => ({ start: 0, end: 1 }),
interpolateString: (string: string) => string.replace(/\$/, 'interpolated-'),
} as any as LokiDatasource;
const languageProvider = new LanguageProvider(datasource);
const fetchSeriesLabels = languageProvider.fetchSeriesLabels;
const requestSpy = jest.spyOn(languageProvider, 'request').mockResolvedValue([]);
fetchSeriesLabels('$stream');
expect(requestSpy).toHaveBeenCalled();
expect(requestSpy).toHaveBeenCalledWith('/loki/api/v1/series', {
end: 1,
'match[]': 'interpolated-stream',
start: 0,
});
});
});
describe('label key suggestions', () => {
it('returns all label suggestions on empty selector', async () => {
const datasource = makeMockLokiDatasource({ label1: [], label2: [] });

@ -396,15 +396,16 @@ export default class LokiLanguageProvider extends LanguageProvider {
* @param name
*/
fetchSeriesLabels = async (match: string): Promise<Record<string, string[]>> => {
const interpolatedMatch = this.datasource.interpolateString(match);
const url = '/loki/api/v1/series';
const { start, end } = this.datasource.getTimeRangeParams();
const cacheKey = this.generateCacheKey(url, start, end, match);
const cacheKey = this.generateCacheKey(url, start, end, interpolatedMatch);
let value = this.seriesCache.get(cacheKey);
if (!value) {
// Clear value when requesting new one. Empty object being truthy also makes sure we don't request twice.
this.seriesCache.set(cacheKey, {});
const params = { 'match[]': match, start, end };
const params = { 'match[]': interpolatedMatch, start, end };
const data = await this.request(url, params);
const { values } = processLabels(data);
value = values;
@ -442,11 +443,12 @@ export default class LokiLanguageProvider extends LanguageProvider {
}
async fetchLabelValues(key: string): Promise<string[]> {
const url = `/loki/api/v1/label/${key}/values`;
const interpolatedKey = this.datasource.interpolateString(key);
const url = `/loki/api/v1/label/${interpolatedKey}/values`;
const rangeParams = this.datasource.getTimeRangeParams();
const { start, end } = rangeParams;
const cacheKey = this.generateCacheKey(url, start, end, key);
const cacheKey = this.generateCacheKey(url, start, end, interpolatedKey);
const params = { start, end };
let labelValues = this.labelsCache.get(cacheKey);

@ -43,6 +43,7 @@ export function makeMockLokiDatasource(labelsAndValues: Labels, series?: SeriesF
}
}
},
interpolateString: (string: string) => string,
} as any;
}

@ -5,7 +5,7 @@ import { LabelFilters } from 'app/plugins/datasource/prometheus/querybuilder/sha
import { OperationList } from 'app/plugins/datasource/prometheus/querybuilder/shared/OperationList';
import { QueryBuilderLabelFilter } from 'app/plugins/datasource/prometheus/querybuilder/shared/types';
import { lokiQueryModeller } from '../LokiQueryModeller';
import { DataSourceApi } from '@grafana/data';
import { DataSourceApi, SelectableValue } from '@grafana/data';
import { EditorRow, EditorRows } from '@grafana/experimental';
import { QueryPreview } from './QueryPreview';
@ -22,6 +22,11 @@ export const LokiQueryBuilder = React.memo<Props>(({ datasource, query, nested,
onChange({ ...query, labels });
};
const withTemplateVariableOptions = async (optionsPromise: Promise<string[]>): Promise<SelectableValue[]> => {
const options = await optionsPromise;
return [...datasource.getVariables(), ...options].map((value) => ({ label: value, value }));
};
const onGetLabelNames = async (forLabel: Partial<QueryBuilderLabelFilter>): Promise<any> => {
const labelsToConsider = query.labels.filter((x) => x !== forLabel);
@ -46,15 +51,20 @@ export const LokiQueryBuilder = React.memo<Props>(({ datasource, query, nested,
const expr = lokiQueryModeller.renderLabels(labelsToConsider);
const result = await datasource.languageProvider.fetchSeriesLabels(expr);
return result[forLabel.label] ?? [];
const forLabelInterpolated = datasource.interpolateString(forLabel.label);
return result[forLabelInterpolated] ?? [];
};
return (
<EditorRows>
<EditorRow>
<LabelFilters
onGetLabelNames={onGetLabelNames}
onGetLabelValues={onGetLabelValues}
onGetLabelNames={(forLabel: Partial<QueryBuilderLabelFilter>) =>
withTemplateVariableOptions(onGetLabelNames(forLabel))
}
onGetLabelValues={(forLabel: Partial<QueryBuilderLabelFilter>) =>
withTemplateVariableOptions(onGetLabelValues(forLabel))
}
labelsFilters={query.labels}
onChange={onChangeLabels}
/>

@ -987,6 +987,14 @@ export class PrometheusDatasource
interval: this.templateSrv.replace(target.interval, variables),
};
}
getVariables(): string[] {
return this.templateSrv.getVariables().map((v) => `$${v.name}`);
}
interpolateString(string: string) {
return this.templateSrv.replace(string, undefined, this.interpolateQueryExpr);
}
}
/**

@ -11,6 +11,7 @@ describe('Language completion provider', () => {
const datasource: PrometheusDatasource = {
metadataRequest: () => ({ data: { data: [] as any[] } }),
getTimeRangeParams: () => ({ start: '0', end: '1' }),
interpolateString: (string: string) => string,
} as any as PrometheusDatasource;
describe('cleanText', () => {
@ -79,6 +80,41 @@ describe('Language completion provider', () => {
});
});
describe('fetchSeriesLabels', () => {
it('should interpolate variable in series', () => {
const languageProvider = new LanguageProvider({
...datasource,
interpolateString: (string: string) => string.replace(/\$/, 'interpolated-'),
} as PrometheusDatasource);
const fetchSeriesLabels = languageProvider.fetchSeriesLabels;
const requestSpy = jest.spyOn(languageProvider, 'request');
fetchSeriesLabels('$metric');
expect(requestSpy).toHaveBeenCalled();
expect(requestSpy).toHaveBeenCalledWith('/api/v1/series', [], {
end: '1',
'match[]': 'interpolated-metric',
start: '0',
});
});
});
describe('fetchLabelValues', () => {
it('should interpolate variable in series', () => {
const languageProvider = new LanguageProvider({
...datasource,
interpolateString: (string: string) => string.replace(/\$/, 'interpolated-'),
} as PrometheusDatasource);
const fetchLabelValues = languageProvider.fetchLabelValues;
const requestSpy = jest.spyOn(languageProvider, 'request');
fetchLabelValues('$job');
expect(requestSpy).toHaveBeenCalled();
expect(requestSpy).toHaveBeenCalledWith('/api/v1/label/interpolated-job/values', [], {
end: '1',
start: '0',
});
});
});
describe('empty query suggestions', () => {
it('returns no suggestions on empty context', async () => {
const instance = new LanguageProvider(datasource);
@ -266,6 +302,7 @@ describe('Language completion provider', () => {
const datasources: PrometheusDatasource = {
metadataRequest: () => ({ data: { data: [{ __name__: 'metric', bar: 'bazinga' }] as any[] } }),
getTimeRangeParams: () => ({ start: '0', end: '1' }),
interpolateString: (string: string) => string,
} as any as PrometheusDatasource;
const instance = new LanguageProvider(datasources);
const value = Plain.deserialize('metric{}');
@ -299,6 +336,7 @@ describe('Language completion provider', () => {
},
}),
getTimeRangeParams: () => ({ start: '0', end: '1' }),
interpolateString: (string: string) => string,
} as any as PrometheusDatasource;
const instance = new LanguageProvider(datasource);
const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",__name__="metric",}');
@ -536,6 +574,7 @@ describe('Language completion provider', () => {
const datasource: PrometheusDatasource = {
metadataRequest: jest.fn(() => ({ data: { data: [] as any[] } })),
getTimeRangeParams: jest.fn(() => ({ start: '0', end: '1' })),
interpolateString: (string: string) => string,
} as any as PrometheusDatasource;
const instance = new LanguageProvider(datasource);
@ -586,6 +625,7 @@ describe('Language completion provider', () => {
metadataRequest: jest.fn(() => ({ data: { data: ['foo', 'bar'] as string[] } })),
getTimeRangeParams: jest.fn(() => ({ start: '0', end: '1' })),
lookupsDisabled: false,
interpolateString: (string: string) => string,
} as any as PrometheusDatasource;
const instance = new LanguageProvider(datasource);

@ -460,7 +460,7 @@ export default class PromQlLanguageProvider extends LanguageProvider {
fetchLabelValues = async (key: string): Promise<string[]> => {
const params = this.datasource.getTimeRangeParams();
const url = `/api/v1/label/${key}/values`;
const url = `/api/v1/label/${this.datasource.interpolateString(key)}/values`;
return await this.request(url, [], params);
};
@ -491,10 +491,11 @@ export default class PromQlLanguageProvider extends LanguageProvider {
* @param withName
*/
fetchSeriesLabels = async (name: string, withName?: boolean): Promise<Record<string, string[]>> => {
const interpolatedName = this.datasource.interpolateString(name);
const range = this.datasource.getTimeRangeParams();
const urlParams = {
...range,
'match[]': name,
'match[]': interpolatedName,
};
const url = `/api/v1/series`;
// Cache key is a bit different here. We add the `withName` param and also round up to a minute the intervals.
@ -502,7 +503,7 @@ export default class PromQlLanguageProvider extends LanguageProvider {
// millisecond while still actually getting all the keys for the correct interval. This still can create problems
// when user does not the newest values for a minute if already cached.
const cacheParams = new URLSearchParams({
'match[]': name,
'match[]': interpolatedName,
start: roundSecToMin(parseInt(range.start, 10)).toString(),
end: roundSecToMin(parseInt(range.end, 10)).toString(),
withName: withName ? 'true' : 'false',

@ -8,7 +8,7 @@ import { css } from '@emotion/css';
export interface Props {
query: PromVisualQuery;
onChange: (query: PromVisualQuery) => void;
onGetMetrics: () => Promise<string[]>;
onGetMetrics: () => Promise<SelectableValue[]>;
}
export function MetricSelect({ query, onChange, onGetMetrics }: Props) {
@ -18,12 +18,6 @@ export function MetricSelect({ query, onChange, onGetMetrics }: Props) {
isLoading?: boolean;
}>({});
const loadMetrics = async () => {
return await onGetMetrics().then((res) => {
return res.map((value) => ({ label: value, value }));
});
};
return (
<EditorFieldGroup>
<EditorField label="Metric">
@ -35,7 +29,7 @@ export function MetricSelect({ query, onChange, onGetMetrics }: Props) {
allowCustomValue
onOpenMenu={async () => {
setState({ isLoading: true });
const metrics = await loadMetrics();
const metrics = await onGetMetrics();
setState({ metrics, isLoading: undefined });
}}
isLoading={state.isLoading}

@ -87,12 +87,26 @@ describe('PromQueryBuilder', () => {
expect(languageProvider.getSeries).toBeCalledWith('{label_name="label_value"}', true);
});
it('tries to load variables in metric field', async () => {
const { datasource } = setup();
datasource.getVariables = jest.fn().mockReturnValue([]);
openMetricSelect();
expect(datasource.getVariables).toBeCalled();
});
it('tries to load labels when metric selected', async () => {
const { languageProvider } = setup();
openLabelNameSelect();
expect(languageProvider.fetchSeriesLabels).toBeCalledWith('{__name__="random_metric"}');
});
it('tries to load variables in label field', async () => {
const { datasource } = setup();
datasource.getVariables = jest.fn().mockReturnValue([]);
openLabelNameSelect();
expect(datasource.getVariables).toBeCalled();
});
it('tries to load labels when metric selected and other labels are already present', async () => {
const { languageProvider } = setup({
...defaultQuery,
@ -117,23 +131,24 @@ describe('PromQueryBuilder', () => {
function setup(query: PromVisualQuery = defaultQuery) {
const languageProvider = new EmptyLanguageProviderMock() as unknown as PromQlLanguageProvider;
const datasource = new PrometheusDatasource(
{
url: '',
jsonData: {},
meta: {} as any,
} as any,
undefined,
undefined,
languageProvider
);
const props = {
datasource: new PrometheusDatasource(
{
url: '',
jsonData: {},
meta: {} as any,
} as any,
undefined,
undefined,
languageProvider
),
datasource,
onRunQuery: () => {},
onChange: () => {},
};
render(<PromQueryBuilder {...props} query={query} />);
return { languageProvider };
return { languageProvider, datasource };
}
function getMetricSelect() {

@ -9,7 +9,7 @@ import { NestedQueryList } from './NestedQueryList';
import { promQueryModeller } from '../PromQueryModeller';
import { QueryBuilderLabelFilter } from '../shared/types';
import { QueryPreview } from './QueryPreview';
import { DataSourceApi } from '@grafana/data';
import { DataSourceApi, SelectableValue } from '@grafana/data';
import { OperationsEditorRow } from '../shared/OperationsEditorRow';
export interface Props {
@ -25,6 +25,12 @@ export const PromQueryBuilder = React.memo<Props>(({ datasource, query, onChange
onChange({ ...query, labels });
};
const withTemplateVariableOptions = async (optionsPromise: Promise<string[]>): Promise<SelectableValue[]> => {
const variables = datasource.getVariables();
const options = await optionsPromise;
return [...variables, ...options].map((value) => ({ label: value, value }));
};
const onGetLabelNames = async (forLabel: Partial<QueryBuilderLabelFilter>): Promise<string[]> => {
// If no metric we need to use a different method
if (!query.metric) {
@ -58,7 +64,8 @@ export const PromQueryBuilder = React.memo<Props>(({ datasource, query, onChange
labelsToConsider.push({ label: '__name__', op: '=', value: query.metric });
const expr = promQueryModeller.renderLabels(labelsToConsider);
const result = await datasource.languageProvider.fetchSeriesLabels(expr);
return result[forLabel.label] ?? [];
const forLabelInterpolated = datasource.interpolateString(forLabel.label);
return result[forLabelInterpolated] ?? [];
};
const onGetMetrics = async () => {
@ -73,12 +80,20 @@ export const PromQueryBuilder = React.memo<Props>(({ datasource, query, onChange
return (
<EditorRows>
<EditorRow>
<MetricSelect query={query} onChange={onChange} onGetMetrics={onGetMetrics} />
<MetricSelect
query={query}
onChange={onChange}
onGetMetrics={() => withTemplateVariableOptions(onGetMetrics())}
/>
<LabelFilters
labelsFilters={query.labels}
onChange={onChangeLabels}
onGetLabelNames={onGetLabelNames}
onGetLabelValues={onGetLabelValues}
onGetLabelNames={(forLabel: Partial<QueryBuilderLabelFilter>) =>
withTemplateVariableOptions(onGetLabelNames(forLabel))
}
onGetLabelValues={(forLabel: Partial<QueryBuilderLabelFilter>) =>
withTemplateVariableOptions(onGetLabelValues(forLabel))
}
/>
</EditorRow>
<OperationsEditorRow>

@ -8,8 +8,8 @@ export interface Props {
defaultOp: string;
item: Partial<QueryBuilderLabelFilter>;
onChange: (value: QueryBuilderLabelFilter) => void;
onGetLabelNames: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<string[]>;
onGetLabelValues: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<string[]>;
onGetLabelNames: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<SelectableValue[]>;
onGetLabelValues: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<SelectableValue[]>;
onDelete: () => void;
}
@ -53,7 +53,7 @@ export function LabelFilterItem({ item, defaultOp, onChange, onDelete, onGetLabe
allowCustomValue
onOpenMenu={async () => {
setState({ isLoadingLabelNames: true });
const labelNames = (await onGetLabelNames(item)).map((x) => ({ label: x, value: x }));
const labelNames = await onGetLabelNames(item);
setState({ labelNames, isLoadingLabelNames: undefined });
}}
isLoading={state.isLoadingLabelNames}
@ -90,7 +90,7 @@ export function LabelFilterItem({ item, defaultOp, onChange, onDelete, onGetLabe
const labelValues = await onGetLabelValues(item);
setState({
...state,
labelValues: labelValues.map((value) => ({ label: value, value })),
labelValues,
isLoadingLabelValues: undefined,
});
}}

@ -52,8 +52,16 @@ describe('LabelFilters', () => {
function setup(labels: QueryBuilderLabelFilter[] = []) {
const props = {
onChange: jest.fn(),
onGetLabelNames: async () => ['foo', 'bar', 'baz'],
onGetLabelValues: async () => ['bar', 'qux', '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' },
],
};
render(<LabelFilters {...props} labelsFilters={labels} />);

@ -1,3 +1,4 @@
import { SelectableValue } from '@grafana/data';
import { EditorField, EditorFieldGroup, EditorList } from '@grafana/experimental';
import { isEqual } from 'lodash';
import React, { useState } from 'react';
@ -7,8 +8,8 @@ import { LabelFilterItem } from './LabelFilterItem';
export interface Props {
labelsFilters: QueryBuilderLabelFilter[];
onChange: (labelFilters: QueryBuilderLabelFilter[]) => void;
onGetLabelNames: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<string[]>;
onGetLabelValues: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<string[]>;
onGetLabelNames: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<SelectableValue[]>;
onGetLabelValues: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<SelectableValue[]>;
}
export function LabelFilters({ labelsFilters, onChange, onGetLabelNames, onGetLabelValues }: Props) {

Loading…
Cancel
Save