diff --git a/public/app/plugins/datasource/loki/components/VariableQueryEditor.test.tsx b/public/app/plugins/datasource/loki/components/VariableQueryEditor.test.tsx new file mode 100644 index 00000000000..c55b0185999 --- /dev/null +++ b/public/app/plugins/datasource/loki/components/VariableQueryEditor.test.tsx @@ -0,0 +1,74 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; + +import { TemplateSrv } from '@grafana/runtime'; + +import { createLokiDatasource } from '../mocks'; +import { LokiVariableQueryType } from '../types'; + +import { LokiVariableQueryEditor, Props } from './VariableQueryEditor'; + +const props: Props = { + datasource: createLokiDatasource({} as unknown as TemplateSrv), + query: { + refId: 'test', + type: LokiVariableQueryType.LabelNames, + }, + onRunQuery: () => {}, + onChange: () => {}, +}; + +describe('LokiVariableQueryEditor', () => { + test('Allows to create a Label names variable', async () => { + const onChange = jest.fn(); + + render(); + + expect(onChange).not.toHaveBeenCalled(); + + await selectOptionInTest(screen.getByLabelText('Query type'), 'Label names'); + + expect(onChange).toHaveBeenCalledWith({ + type: LokiVariableQueryType.LabelNames, + label: '', + stream: '', + refId: 'LokiVariableQueryEditor-VariableQuery', + }); + }); + + test('Allows to create a Label values variable', async () => { + const onChange = jest.fn(); + + render(); + + expect(onChange).not.toHaveBeenCalled(); + + await selectOptionInTest(screen.getByLabelText('Query type'), 'Label values'); + await userEvent.type(screen.getByLabelText('Label'), 'label'); + await userEvent.type(screen.getByLabelText('Stream selector'), 'stream'); + + await waitFor(() => expect(screen.getByDisplayValue('stream')).toBeInTheDocument()); + + await userEvent.click(document.body); + + expect(onChange).toHaveBeenCalledWith({ + type: LokiVariableQueryType.LabelValues, + label: 'label', + stream: 'stream', + refId: 'LokiVariableQueryEditor-VariableQuery', + }); + }); + + test('Migrates legacy string queries to LokiVariableQuery instances', async () => { + const query = 'label_values(log stream selector, label)'; + + // @ts-expect-error + render( {}} query={query} />); + + await waitFor(() => expect(screen.getByText('Label values')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByDisplayValue('label')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByDisplayValue('log stream selector')).toBeInTheDocument()); + }); +}); diff --git a/public/app/plugins/datasource/loki/components/VariableQueryEditor.tsx b/public/app/plugins/datasource/loki/components/VariableQueryEditor.tsx new file mode 100644 index 00000000000..770d6380cac --- /dev/null +++ b/public/app/plugins/datasource/loki/components/VariableQueryEditor.tsx @@ -0,0 +1,89 @@ +import React, { FC, FormEvent, useState, useEffect } from 'react'; + +import { QueryEditorProps, SelectableValue } from '@grafana/data'; +import { InlineField, InlineFieldRow, Input, Select } from '@grafana/ui'; + +import { LokiDatasource } from '../datasource'; +import { migrateVariableQuery } from '../migrations/variableQueryMigrations'; +import { LokiOptions, LokiQuery, LokiVariableQuery, LokiVariableQueryType as QueryType } from '../types'; + +const variableOptions = [ + { label: 'Label names', value: QueryType.LabelNames }, + { label: 'Label values', value: QueryType.LabelValues }, +]; + +export type Props = QueryEditorProps; + +export const LokiVariableQueryEditor: FC = ({ onChange, query }) => { + const [type, setType] = useState(undefined); + const [label, setLabel] = useState(''); + const [stream, setStream] = useState(''); + + useEffect(() => { + if (!query || typeof query !== 'string') { + return; + } + + const variableQuery = migrateVariableQuery(query); + setType(variableQuery.type); + setLabel(variableQuery.label || ''); + setStream(variableQuery.stream || ''); + }, [query]); + + const onQueryTypeChange = (newType: SelectableValue) => { + setType(newType.value); + if (newType.value !== undefined) { + onChange({ + type: newType.value, + label, + stream, + refId: 'LokiVariableQueryEditor-VariableQuery', + }); + } + }; + + const onLabelChange = (e: FormEvent) => { + setLabel(e.currentTarget.value); + }; + + const onStreamChange = (e: FormEvent) => { + setStream(e.currentTarget.value); + }; + + const handleBlur = () => { + if (type !== undefined) { + onChange({ type, label, stream, refId: 'LokiVariableQueryEditor-VariableQuery' }); + } + }; + + return ( + + + + + + + + + )} + + ); +}; 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 f19f17b12f0..3f153c25092 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 @@ -321,6 +321,11 @@ exports[`LokiExploreQueryEditor should render component 1`] = ` }, "type": "", "uid": "", + "variables": LokiVariableSupport { + "datasource": [Circular], + "editor": [Function], + "query": [Function], + }, } } history={Array []} diff --git a/public/app/plugins/datasource/loki/datasource.test.ts b/public/app/plugins/datasource/loki/datasource.test.ts index 06af0b94d88..b7efeb0004c 100644 --- a/public/app/plugins/datasource/loki/datasource.test.ts +++ b/public/app/plugins/datasource/loki/datasource.test.ts @@ -24,6 +24,7 @@ import { CustomVariableModel } from '../../../features/variables/types'; import { LokiDatasource } from './datasource'; import { createMetadataRequest, createLokiDatasource } from './mocks'; import { LokiOptions, LokiQuery, LokiQueryType } from './types'; +import { LokiVariableSupport } from './variables'; const templateSrvStub = { getAdhocFilters: jest.fn(() => [] as unknown[]), @@ -823,6 +824,14 @@ describe('applyTemplateVariables', () => { }); }); +describe('Variable support', () => { + it('has Loki variable support', () => { + const ds = createLokiDatasource(templateSrvStub); + + expect(ds.variables).toBeInstanceOf(LokiVariableSupport); + }); +}); + function assertAdHocFilters(query: string, expectedResults: string, ds: LokiDatasource) { const lokiQuery: LokiQuery = { refId: 'A', expr: query }; const result = ds.addAdHocFilters(lokiQuery.expr); diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts index 19f63df0dc9..cfac3e9a51a 100644 --- a/public/app/plugins/datasource/loki/datasource.ts +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -49,6 +49,7 @@ import { LokiAnnotationsQueryEditor } from './components/AnnotationsQueryEditor' import LanguageProvider from './language_provider'; import { escapeLabelValueInSelector } from './language_utils'; import { LiveStreams, LokiLiveTarget } from './live_streams'; +import { labelNamesRegex, labelValuesRegex } from './migrations/variableQueryMigrations'; import { addLabelFormatToQuery, addLabelToQuery, @@ -61,6 +62,7 @@ import { getNormalizedLokiQuery, isLogsQuery, isValidQuery } from './query_utils import { sortDataFrameByTime } from './sortDataFrame'; import { doLokiChannelStream } from './streaming'; import { LokiOptions, LokiQuery, LokiQueryDirection, LokiQueryType } from './types'; +import { LokiVariableSupport } from './variables'; export type RangeQueryOptions = DataQueryRequest | AnnotationQueryRequest; export const DEFAULT_MAX_LINES = 1000; @@ -107,6 +109,7 @@ export class LokiDatasource this.annotations = { QueryEditor: LokiAnnotationsQueryEditor, }; + this.variables = new LokiVariableSupport(this); } getLogsVolumeDataProvider(request: DataQueryRequest): Observable | undefined { @@ -311,9 +314,6 @@ export class LokiDatasource } async processMetricFindQuery(query: string) { - const labelNamesRegex = /^label_names\(\)\s*$/; - const labelValuesRegex = /^label_values\((?:(.+),\s*)?([a-zA-Z_][a-zA-Z0-9_]*)\)\s*$/; - const labelNames = query.match(labelNamesRegex); if (labelNames) { return await this.labelNamesQuery(); diff --git a/public/app/plugins/datasource/loki/migrations/variableQueryMigrations.test.ts b/public/app/plugins/datasource/loki/migrations/variableQueryMigrations.test.ts new file mode 100644 index 00000000000..a98abf8cacd --- /dev/null +++ b/public/app/plugins/datasource/loki/migrations/variableQueryMigrations.test.ts @@ -0,0 +1,48 @@ +import { LokiVariableQuery, LokiVariableQueryType } from '../types'; + +import { migrateVariableQuery } from './variableQueryMigrations'; + +describe('Loki migrateVariableQuery()', () => { + it('Does not migrate LokiVariableQuery instances', () => { + const query: LokiVariableQuery = { + refId: 'test', + type: LokiVariableQueryType.LabelValues, + label: 'label', + stream: 'stream', + }; + + expect(migrateVariableQuery(query)).toBe(query); + expect(migrateVariableQuery(query)).toStrictEqual(query); + }); + + it('Migrates label_names() queries', () => { + const query = 'label_names()'; + + expect(migrateVariableQuery(query)).toStrictEqual({ + refId: 'LokiVariableQueryEditor-VariableQuery', + type: LokiVariableQueryType.LabelNames, + }); + }); + + it('Migrates label_values(label) queries', () => { + const query = 'label_values(label)'; + + expect(migrateVariableQuery(query)).toStrictEqual({ + refId: 'LokiVariableQueryEditor-VariableQuery', + type: LokiVariableQueryType.LabelValues, + label: 'label', + stream: undefined, + }); + }); + + it('Migrates label_values(log stream selector, label) queries', () => { + const query = 'label_values(log stream selector, label)'; + + expect(migrateVariableQuery(query)).toStrictEqual({ + refId: 'LokiVariableQueryEditor-VariableQuery', + type: LokiVariableQueryType.LabelValues, + label: 'label', + stream: 'log stream selector', + }); + }); +}); diff --git a/public/app/plugins/datasource/loki/migrations/variableQueryMigrations.ts b/public/app/plugins/datasource/loki/migrations/variableQueryMigrations.ts new file mode 100644 index 00000000000..199ea84808a --- /dev/null +++ b/public/app/plugins/datasource/loki/migrations/variableQueryMigrations.ts @@ -0,0 +1,36 @@ +import { LokiVariableQuery, LokiVariableQueryType } from '../types'; + +export const labelNamesRegex = /^label_names\(\)\s*$/; +export const labelValuesRegex = /^label_values\((?:(.+),\s*)?([a-zA-Z_][a-zA-Z0-9_]*)\)\s*$/; + +export function migrateVariableQuery(rawQuery: string | LokiVariableQuery): LokiVariableQuery { + // If not string, we assume LokiVariableQuery + if (typeof rawQuery !== 'string') { + return rawQuery; + } + + const queryBase = { + refId: 'LokiVariableQueryEditor-VariableQuery', + type: LokiVariableQueryType.LabelNames, + }; + + const labelNames = rawQuery.match(labelNamesRegex); + if (labelNames) { + return { + ...queryBase, + type: LokiVariableQueryType.LabelNames, + }; + } + + const labelValues = rawQuery.match(labelValuesRegex); + if (labelValues) { + return { + ...queryBase, + type: LokiVariableQueryType.LabelValues, + label: labelValues[2] ? labelValues[2] : labelValues[1], + stream: labelValues[2] ? labelValues[1] : undefined, + }; + } + + return queryBase; +} diff --git a/public/app/plugins/datasource/loki/types.ts b/public/app/plugins/datasource/loki/types.ts index f84982d2e76..ec900a654ab 100644 --- a/public/app/plugins/datasource/loki/types.ts +++ b/public/app/plugins/datasource/loki/types.ts @@ -142,3 +142,14 @@ export interface TransformerOptions { scopedVars: ScopedVars; meta?: QueryResultMeta; } + +export enum LokiVariableQueryType { + LabelNames, + LabelValues, +} + +export interface LokiVariableQuery extends DataQuery { + type: LokiVariableQueryType; + label?: string; + stream?: string; +} diff --git a/public/app/plugins/datasource/loki/variables.test.ts b/public/app/plugins/datasource/loki/variables.test.ts new file mode 100644 index 00000000000..0d98a07b6bb --- /dev/null +++ b/public/app/plugins/datasource/loki/variables.test.ts @@ -0,0 +1,53 @@ +import { TemplateSrv } from 'app/features/templating/template_srv'; + +import { createLokiDatasource, createMetadataRequest } from './mocks'; +import { LokiVariableQueryType } from './types'; +import { LokiVariableSupport } from './variables'; + +describe('LokiVariableSupport', () => { + let lokiVariableSupport: LokiVariableSupport; + + beforeEach(() => { + const datasource = createLokiDatasource({} as unknown as TemplateSrv); + jest + .spyOn(datasource, 'metadataRequest') + .mockImplementation( + createMetadataRequest( + { label1: ['value1', 'value2'], label2: ['value3', 'value4'] }, + { '{label1="value1", label2="value2"}': [{ label5: 'value5' }] } + ) + ); + + lokiVariableSupport = new LokiVariableSupport(datasource); + }); + + it('should return label names for Loki', async () => { + // label_names() + const response = await lokiVariableSupport.execute({ refId: 'test', type: LokiVariableQueryType.LabelNames }); + + expect(response).toEqual([{ text: 'label1' }, { text: 'label2' }]); + }); + + it('should return label values for Loki when no matcher', async () => { + // label_values(label1) + const response = await lokiVariableSupport.execute({ + refId: 'test', + type: LokiVariableQueryType.LabelValues, + label: 'label1', + }); + + expect(response).toEqual([{ text: 'value1' }, { text: 'value2' }]); + }); + + it('should return label values for Loki with matcher', async () => { + // label_values({label1="value1", label2="value2"},label5) + const response = await lokiVariableSupport.execute({ + refId: 'test', + type: LokiVariableQueryType.LabelValues, + stream: '{label1="value1", label2="value2"}', + label: 'label5', + }); + + expect(response).toEqual([{ text: 'value5' }]); + }); +}); diff --git a/public/app/plugins/datasource/loki/variables.ts b/public/app/plugins/datasource/loki/variables.ts new file mode 100644 index 00000000000..60151a23219 --- /dev/null +++ b/public/app/plugins/datasource/loki/variables.ts @@ -0,0 +1,40 @@ +import { from, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { CustomVariableSupport, DataQueryRequest, DataQueryResponse } from '@grafana/data'; + +import { LokiVariableQueryEditor } from './components/VariableQueryEditor'; +import { LokiDatasource } from './datasource'; +import { LokiVariableQuery, LokiVariableQueryType } from './types'; + +export class LokiVariableSupport extends CustomVariableSupport { + editor = LokiVariableQueryEditor; + + constructor(private datasource: LokiDatasource) { + super(); + this.query = this.query.bind(this); + } + + async execute(query: LokiVariableQuery) { + if (query.type === LokiVariableQueryType.LabelNames) { + return this.datasource.labelNamesQuery(); + } + + if (!query.label) { + return []; + } + + // If we have query expr, use /series endpoint + if (query.stream) { + return this.datasource.labelValuesSeriesQuery(query.stream, query.label); + } + + return this.datasource.labelValuesQuery(query.label); + } + + query(request: DataQueryRequest): Observable { + const result = this.execute(request.targets[0]); + + return from(result).pipe(map((data) => ({ data }))); + } +}