diff --git a/public/app/plugins/datasource/loki/components/__snapshots__/LokiExploreQueryEditor.test.tsx.snap b/public/app/plugins/datasource/loki/components/__snapshots__/LokiExploreQueryEditor.test.tsx.snap index c7c3761cb88..7a4bb2db700 100644 --- a/public/app/plugins/datasource/loki/components/__snapshots__/LokiExploreQueryEditor.test.tsx.snap +++ b/public/app/plugins/datasource/loki/components/__snapshots__/LokiExploreQueryEditor.test.tsx.snap @@ -54,6 +54,7 @@ exports[`LokiExploreQueryEditor should render component 1`] = ` datasource={ Object { "getTimeRangeParams": [Function], + "interpolateString": [Function], "languageProvider": LokiLanguageProvider { "cleanText": [Function], "datasource": [Circular], diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts index cc9aebcfeda..9c14d7a87a0 100644 --- a/public/app/plugins/datasource/loki/datasource.ts +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -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) { diff --git a/public/app/plugins/datasource/loki/language_provider.test.ts b/public/app/plugins/datasource/loki/language_provider.test.ts index 70fdfd10f8f..61f664bb448 100644 --- a/public/app/plugins/datasource/loki/language_provider.test.ts +++ b/public/app/plugins/datasource/loki/language_provider.test.ts @@ -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: [] }); diff --git a/public/app/plugins/datasource/loki/language_provider.ts b/public/app/plugins/datasource/loki/language_provider.ts index fce76be1f9b..80bf63afd0d 100644 --- a/public/app/plugins/datasource/loki/language_provider.ts +++ b/public/app/plugins/datasource/loki/language_provider.ts @@ -396,15 +396,16 @@ export default class LokiLanguageProvider extends LanguageProvider { * @param name */ fetchSeriesLabels = async (match: string): Promise> => { + 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 { - 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); diff --git a/public/app/plugins/datasource/loki/mocks.ts b/public/app/plugins/datasource/loki/mocks.ts index d2849697227..35d37f6e0ef 100644 --- a/public/app/plugins/datasource/loki/mocks.ts +++ b/public/app/plugins/datasource/loki/mocks.ts @@ -43,6 +43,7 @@ export function makeMockLokiDatasource(labelsAndValues: Labels, series?: SeriesF } } }, + interpolateString: (string: string) => string, } as any; } diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx index 104c84847e4..27f9b722ee8 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx @@ -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(({ datasource, query, nested, onChange({ ...query, labels }); }; + const withTemplateVariableOptions = async (optionsPromise: Promise): Promise => { + const options = await optionsPromise; + return [...datasource.getVariables(), ...options].map((value) => ({ label: value, value })); + }; + const onGetLabelNames = async (forLabel: Partial): Promise => { const labelsToConsider = query.labels.filter((x) => x !== forLabel); @@ -46,15 +51,20 @@ export const LokiQueryBuilder = React.memo(({ 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 ( ) => + withTemplateVariableOptions(onGetLabelNames(forLabel)) + } + onGetLabelValues={(forLabel: Partial) => + withTemplateVariableOptions(onGetLabelValues(forLabel)) + } labelsFilters={query.labels} onChange={onChangeLabels} /> diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index f2417ade731..4e64d73abbe 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -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); + } } /** diff --git a/public/app/plugins/datasource/prometheus/language_provider.test.ts b/public/app/plugins/datasource/prometheus/language_provider.test.ts index 814a9995fe5..950021b60fc 100644 --- a/public/app/plugins/datasource/prometheus/language_provider.test.ts +++ b/public/app/plugins/datasource/prometheus/language_provider.test.ts @@ -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); diff --git a/public/app/plugins/datasource/prometheus/language_provider.ts b/public/app/plugins/datasource/prometheus/language_provider.ts index e1da3ae2163..1e1a63df6b5 100644 --- a/public/app/plugins/datasource/prometheus/language_provider.ts +++ b/public/app/plugins/datasource/prometheus/language_provider.ts @@ -460,7 +460,7 @@ export default class PromQlLanguageProvider extends LanguageProvider { fetchLabelValues = async (key: string): Promise => { 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> => { + 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', diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx index 7e798ffa987..3951a1a30f2 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx @@ -8,7 +8,7 @@ import { css } from '@emotion/css'; export interface Props { query: PromVisualQuery; onChange: (query: PromVisualQuery) => void; - onGetMetrics: () => Promise; + onGetMetrics: () => Promise; } 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 ( @@ -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} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.test.tsx index ea9ff3fe71b..655a00dd434 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.test.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.test.tsx @@ -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(); - return { languageProvider }; + return { languageProvider, datasource }; } function getMetricSelect() { diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx index ee77f15392b..a681cc534be 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx @@ -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(({ datasource, query, onChange onChange({ ...query, labels }); }; + const withTemplateVariableOptions = async (optionsPromise: Promise): Promise => { + const variables = datasource.getVariables(); + const options = await optionsPromise; + return [...variables, ...options].map((value) => ({ label: value, value })); + }; + const onGetLabelNames = async (forLabel: Partial): Promise => { // If no metric we need to use a different method if (!query.metric) { @@ -58,7 +64,8 @@ export const PromQueryBuilder = React.memo(({ 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(({ datasource, query, onChange return ( - + withTemplateVariableOptions(onGetMetrics())} + /> ) => + withTemplateVariableOptions(onGetLabelNames(forLabel)) + } + onGetLabelValues={(forLabel: Partial) => + withTemplateVariableOptions(onGetLabelValues(forLabel)) + } /> diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilterItem.tsx b/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilterItem.tsx index f0d238e38c5..dec8b2c6431 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilterItem.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilterItem.tsx @@ -8,8 +8,8 @@ export interface Props { defaultOp: string; item: Partial; onChange: (value: QueryBuilderLabelFilter) => void; - onGetLabelNames: (forLabel: Partial) => Promise; - onGetLabelValues: (forLabel: Partial) => Promise; + onGetLabelNames: (forLabel: Partial) => Promise; + onGetLabelValues: (forLabel: Partial) => Promise; 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, }); }} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.test.tsx index a80b252f4ef..b13702c4226 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.test.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.test.tsx @@ -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(); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.tsx b/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.tsx index e6fcd229c57..d3c4daf546b 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.tsx @@ -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) => Promise; - onGetLabelValues: (forLabel: Partial) => Promise; + onGetLabelNames: (forLabel: Partial) => Promise; + onGetLabelValues: (forLabel: Partial) => Promise; } export function LabelFilters({ labelsFilters, onChange, onGetLabelNames, onGetLabelValues }: Props) {