Loki: Implement hints for query builder (#51795)

* Loki: Implement hints for query builder

* Update name of file

* Update imports

* Refactor

* Remove unused import

* Unify

* Revert "Unify"

This reverts commit 78da0e27e3.

* Unify

* Fix types

* Fix tests

* Fix type error

* Simplify

* Update test

* Add documentation

* Update comment

* Add tests for addParserToQuery

* Smaller updates
pull/51918/head
Ivana Huckova 3 years ago committed by GitHub
parent 2b2c09b8d5
commit 10cb84e401
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 22509
      .betterer.results
  2. 4
      docs/sources/datasources/loki.md
  3. 90
      public/app/plugins/datasource/loki/addToQuery.ts
  4. 26
      public/app/plugins/datasource/loki/addtoQuery.test.ts
  5. 19
      public/app/plugins/datasource/loki/datasource.test.ts
  6. 61
      public/app/plugins/datasource/loki/datasource.ts
  7. 73
      public/app/plugins/datasource/loki/queryHints.test.ts
  8. 42
      public/app/plugins/datasource/loki/queryHints.ts
  9. 41
      public/app/plugins/datasource/loki/query_utils.test.ts
  10. 43
      public/app/plugins/datasource/loki/query_utils.ts
  11. 68
      public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.test.tsx
  12. 27
      public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx
  13. 6
      public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderContainer.test.tsx
  14. 4
      public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx
  15. 15
      public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.test.tsx
  16. 25
      public/app/plugins/datasource/loki/responseUtils.ts
  17. 12
      public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx
  18. 46
      public/app/plugins/datasource/prometheus/querybuilder/shared/QueryBuilderHints.tsx

File diff suppressed because it is too large Load Diff

@ -123,6 +123,10 @@ Operation can have additional parameters under the operation header. See the ope
Some operations make sense only in specific order, if adding an operation would result in nonsensical query, operation will be added to the correct place. To order operations manually drag operation box by the operation name and drop in appropriate place.
##### Hints
In same cases the query editor can detect which operations would be most appropriate for a selected log stream. In such cases it will show a hint next to the `+ Operations` button. Click on the hint to add the operations to your query.
#### Raw query
This section is shown only if the `Raw query` switch from the query editor top toolbar is set to `on`. It shows the raw query that will be created and executed by the query editor.

@ -4,8 +4,8 @@ import { QueryBuilderLabelFilter } from '../prometheus/querybuilder/shared/types
import { LokiQueryModeller } from './querybuilder/LokiQueryModeller';
import { buildVisualQueryFromString } from './querybuilder/parsing';
import { LokiVisualQuery } from './querybuilder/types';
type Position = { from: number; to: number };
/**
* Adds label filter to existing query. Useful for query modification for example for ad hoc filters.
*
@ -39,22 +39,36 @@ export function addLabelToQuery(query: string, key: string, operator: string, va
}
}
type StreamSelectorPosition = { from: number; to: number; query: LokiVisualQuery };
type PipelineStagePosition = { from: number; to: number };
/**
* Adds parser to existing query. Useful for query modification for hints.
* It uses LogQL parser to find instances of stream selectors or line filters and adds parser after them.
*
* @param query
* @param parser
*/
export function addParserToQuery(query: string, parser: string): string {
const lineFilterPositions = getLineFiltersPositions(query);
if (lineFilterPositions.length) {
return addParser(query, lineFilterPositions, parser);
} else {
const streamSelectorPositions = getStreamSelectorPositions(query);
return addParser(query, streamSelectorPositions, parser);
}
}
/**
* Parse the string and get all Selector positions in the query together with parsed representation of the
* selector.
* @param query
*/
function getStreamSelectorPositions(query: string): StreamSelectorPosition[] {
function getStreamSelectorPositions(query: string): Position[] {
const tree = parser.parse(query);
const positions: StreamSelectorPosition[] = [];
const positions: Position[] = [];
tree.iterate({
enter: (type, from, to, get): false | void => {
if (type.name === 'Selector') {
const visQuery = buildVisualQueryFromString(query.substring(from, to));
positions.push({ query: visQuery.query, from, to });
positions.push({ from, to });
return false;
}
},
@ -66,9 +80,9 @@ function getStreamSelectorPositions(query: string): StreamSelectorPosition[] {
* Parse the string and get all LabelParser positions in the query.
* @param query
*/
function getParserPositions(query: string): PipelineStagePosition[] {
export function getParserPositions(query: string): Position[] {
const tree = parser.parse(query);
const positions: PipelineStagePosition[] = [];
const positions: Position[] = [];
tree.iterate({
enter: (type, from, to, get): false | void => {
if (type.name === 'LabelParser') {
@ -80,6 +94,24 @@ function getParserPositions(query: string): PipelineStagePosition[] {
return positions;
}
/**
* Parse the string and get all Line filter positions in the query.
* @param query
*/
function getLineFiltersPositions(query: string): Position[] {
const tree = parser.parse(query);
const positions: Position[] = [];
tree.iterate({
enter: (type, from, to, get): false | void => {
if (type.name === 'LineFilters') {
positions.push({ from, to });
return false;
}
},
});
return positions;
}
function toLabelFilter(key: string, value: string, operator: string): QueryBuilderLabelFilter {
// We need to make sure that we convert the value back to string because it may be a number
return { label: key, op: operator, value };
@ -93,7 +125,7 @@ function toLabelFilter(key: string, value: string, operator: string): QueryBuild
*/
function addFilterToStreamSelector(
query: string,
vectorSelectorPositions: StreamSelectorPosition[],
vectorSelectorPositions: Position[],
filter: QueryBuilderLabelFilter
): string {
const modeller = new LokiQueryModeller();
@ -108,12 +140,13 @@ function addFilterToStreamSelector(
const start = query.substring(prev, match.from);
const end = isLast ? query.substring(match.to) : '';
const matchVisQuery = buildVisualQueryFromString(query.substring(match.from, match.to));
if (!labelExists(match.query.labels, filter)) {
if (!labelExists(matchVisQuery.query.labels, filter)) {
// We don't want to add duplicate labels.
match.query.labels.push(filter);
matchVisQuery.query.labels.push(filter);
}
const newLabels = modeller.renderQuery(match.query);
const newLabels = modeller.renderQuery(matchVisQuery.query);
newQuery += start + newLabels + end;
prev = match.to;
}
@ -126,11 +159,7 @@ function addFilterToStreamSelector(
* @param parserPositions
* @param filter
*/
function addFilterAsLabelFilter(
query: string,
parserPositions: PipelineStagePosition[],
filter: QueryBuilderLabelFilter
): string {
function addFilterAsLabelFilter(query: string, parserPositions: Position[], filter: QueryBuilderLabelFilter): string {
let newQuery = '';
let prev = 0;
@ -149,6 +178,31 @@ function addFilterAsLabelFilter(
return newQuery;
}
/**
* Add parser after line filter or stream selector
* @param query
* @param queryPartPositions
* @param parser
*/
function addParser(query: string, queryPartPositions: Position[], parser: string): string {
let newQuery = '';
let prev = 0;
for (let i = 0; i < queryPartPositions.length; i++) {
// Splice on a string for each matched vector selector
const match = queryPartPositions[i];
const isLast = i === queryPartPositions.length - 1;
const start = query.substring(prev, match.to);
const end = isLast ? query.substring(match.to) : '';
// Add parser
newQuery += start + ` | ${parser}` + end;
prev = match.to;
}
return newQuery;
}
/**
* Check if label exists in the list of labels but ignore the operator.
* @param labels

@ -1,4 +1,4 @@
import { addLabelToQuery } from './add_label_to_query';
import { addLabelToQuery, addParserToQuery } from './addToQuery';
describe('addLabelToQuery()', () => {
it('should add label to simple query', () => {
@ -153,3 +153,27 @@ describe('addLabelToQuery()', () => {
});
});
});
describe('addParserToQuery', () => {
describe('when query had line filter', () => {
it('should add parser after line filter', () => {
expect(addParserToQuery('{job="grafana"} |= "error"', 'logfmt')).toBe('{job="grafana"} |= "error" | logfmt');
});
it('should add parser after multiple line filters', () => {
expect(addParserToQuery('{job="grafana"} |= "error" |= "info" |= "debug"', 'logfmt')).toBe(
'{job="grafana"} |= "error" |= "info" |= "debug" | logfmt'
);
});
});
describe('when query has no line filters', () => {
it('should add parser after log stream selector in logs query', () => {
expect(addParserToQuery('{job="grafana"}', 'logfmt')).toBe('{job="grafana"} | logfmt');
});
it('should add parser after log stream selector in metric query', () => {
expect(addParserToQuery('rate({job="grafana"} [5m])', 'logfmt')).toBe('rate({job="grafana"} | logfmt [5m])');
});
});
});

@ -22,7 +22,7 @@ import { TemplateSrv } from 'app/features/templating/template_srv';
import { initialCustomVariableModelState } from '../../../features/variables/custom/reducer';
import { CustomVariableModel } from '../../../features/variables/types';
import { isMetricsQuery, LokiDatasource } from './datasource';
import { LokiDatasource } from './datasource';
import { makeMockLokiDatasource } from './mocks';
import { LokiQuery, LokiQueryType } from './types';
@ -802,23 +802,6 @@ describe('LokiDatasource', () => {
});
});
describe('isMetricsQuery', () => {
it('should return true for metrics query', () => {
const query = 'rate({label=value}[1m])';
expect(isMetricsQuery(query)).toBeTruthy();
});
it('should return false for logs query', () => {
const query = '{label=value}';
expect(isMetricsQuery(query)).toBeFalsy();
});
it('should not blow up on empty query', () => {
const query = '';
expect(isMetricsQuery(query)).toBeFalsy();
});
});
describe('applyTemplateVariables', () => {
it('should add the adhoc filter to the query', () => {
const ds = createLokiDSForTests();

@ -1,6 +1,5 @@
// Libraries
import { cloneDeep, map as lodashMap } from 'lodash';
import Prism from 'prismjs';
import { lastValueFrom, merge, Observable, of, throwError } from 'rxjs';
import { catchError, map, switchMap } from 'rxjs/operators';
@ -33,6 +32,8 @@ import {
TimeRange,
rangeUtil,
toUtc,
QueryHint,
getDefaultTimeRange,
} from '@grafana/data';
import { FetchError, config, DataSourceWithBackend } from '@grafana/runtime';
import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider';
@ -44,16 +45,16 @@ import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_sr
import { serializeParams } from '../../../core/utils/fetch';
import { renderLegendFormat } from '../prometheus/legend';
import { addLabelToQuery } from './add_label_to_query';
import { addLabelToQuery, addParserToQuery } from './addToQuery';
import { transformBackendResult } from './backendResultTransformer';
import { LokiAnnotationsQueryEditor } from './components/AnnotationsQueryEditor';
import LanguageProvider from './language_provider';
import { escapeLabelValueInSelector } from './language_utils';
import { LiveStreams, LokiLiveTarget } from './live_streams';
import { getNormalizedLokiQuery } from './query_utils';
import { getQueryHints } from './queryHints';
import { getNormalizedLokiQuery, isLogsQuery, isValidQuery } from './query_utils';
import { sortDataFrameByTime } from './sortDataFrame';
import { doLokiChannelStream } from './streaming';
import syntax from './syntax';
import { LokiOptions, LokiQuery, LokiQueryDirection, LokiQueryType } from './types';
export type RangeQueryOptions = DataQueryRequest<LokiQuery> | AnnotationQueryRequest<LokiQuery>;
@ -108,7 +109,7 @@ export class LokiDatasource
const normalized = getNormalizedLokiQuery(query);
const { expr } = normalized;
// it has to be a logs-producing range-query
return expr && !isMetricsQuery(expr) && normalized.queryType === LokiQueryType.Range;
return expr && isLogsQuery(expr) && normalized.queryType === LokiQueryType.Range;
};
const isLogsVolumeAvailable = request.targets.some(isQuerySuitable);
@ -171,7 +172,7 @@ export class LokiDatasource
runLiveQueryThroughBackend(request: DataQueryRequest<LokiQuery>): Observable<DataQueryResponse> {
// this only works in explore-mode, so variables don't need to be handled,
// and only for logs-queries, not metric queries
const logsQueries = request.targets.filter((query) => query.expr !== '' && !isMetricsQuery(query.expr));
const logsQueries = request.targets.filter((query) => query.expr !== '' && isLogsQuery(query.expr));
if (logsQueries.length === 0) {
return of({
@ -348,7 +349,26 @@ export class LokiDatasource
return Array.from(streams);
}
// By implementing getTagKeys and getTagValues we add ad-hoc filtters functionality
async getDataSamples(query: LokiQuery): Promise<DataFrame[]> {
// Currently works only for log samples
if (!isValidQuery(query.expr) || !isLogsQuery(query.expr)) {
return [];
}
const lokiLogsQuery: LokiQuery = {
expr: query.expr,
queryType: LokiQueryType.Range,
refId: 'log-samples',
maxLines: 10,
};
// For samples, we use defaultTimeRange (now-6h/now) and limit od 10 lines so queries are small and fast
const timeRange = getDefaultTimeRange();
const request = makeRequest(lokiLogsQuery, timeRange, CoreApp.Explore, 'log-samples');
return await lastValueFrom(this.query(request).pipe(switchMap((res) => of(res.data))));
}
// By implementing getTagKeys and getTagValues we add ad-hoc filters functionality
async getTagKeys() {
return await this.labelNamesQuery();
}
@ -382,6 +402,14 @@ export class LokiDatasource
expression = this.addLabelToQuery(expression, action.key, '!=', action.value);
break;
}
case 'ADD_LOGFMT_PARSER': {
expression = addParserToQuery(expression, 'logfmt');
break;
}
case 'ADD_JSON_PARSER': {
expression = addParserToQuery(expression, 'json');
break;
}
default:
break;
}
@ -681,6 +709,10 @@ export class LokiDatasource
getVariables(): string[] {
return this.templateSrv.getVariables().map((v) => `$${v.name}`);
}
getQueryHints(query: LokiQuery, result: DataFrame[]): QueryHint[] {
return getQueryHints(query.expr, result);
}
}
export function lokiRegularEscape(value: any) {
@ -697,21 +729,6 @@ export function lokiSpecialRegexEscape(value: any) {
return value;
}
/**
* Checks if the query expression uses function and so should return a time series instead of logs.
* Sometimes important to know that before we actually do the query.
*/
export function isMetricsQuery(query: string): boolean {
if (!query) {
return false;
}
const tokens = Prism.tokenize(query, syntax);
return tokens.some((t) => {
// Not sure in which cases it can be string maybe if nothing matched which means it should not be a function
return typeof t !== 'string' && t.type === 'function';
});
}
function extractLevel(dataFrame: DataFrame): LogLevel {
let valueField;
try {

@ -0,0 +1,73 @@
import { ArrayVector, DataFrame, FieldType } from '@grafana/data';
import { getQueryHints } from './queryHints';
describe('getQueryHints', () => {
describe('when series with json logs', () => {
const jsonSeries: DataFrame = {
name: 'logs',
length: 2,
fields: [
{
name: 'Line',
type: FieldType.string,
config: {},
values: new ArrayVector(['{"foo": "bar", "bar": "baz"}', '{"foo": "bar", "bar": "baz"}']),
},
],
};
it('suggest json parser when no parser in query', () => {
expect(getQueryHints('{job="grafana"', [jsonSeries])).toMatchObject([{ type: 'ADD_JSON_PARSER' }]);
});
it('does not suggest parser when parser in query', () => {
expect(getQueryHints('{job="grafana" | json', [jsonSeries])).toEqual([]);
});
});
describe('when series with logfmt logs', () => {
const logfmtSeries: DataFrame = {
name: 'logs',
length: 2,
fields: [
{
name: 'Line',
type: FieldType.string,
config: {},
values: new ArrayVector(['foo="bar" bar="baz"', 'foo="bar" bar="baz"']),
},
],
};
it('suggest logfmt parser when no parser in query', () => {
expect(getQueryHints('{job="grafana"', [logfmtSeries])).toMatchObject([{ type: 'ADD_LOGFMT_PARSER' }]);
});
it('does not suggest parser when parser in query', () => {
expect(getQueryHints('{job="grafana" | json', [logfmtSeries])).toEqual([]);
});
});
describe('when series with json and logfmt logs', () => {
const jsonAndLogfmtSeries: DataFrame = {
name: 'logs',
length: 2,
fields: [
{
name: 'Line',
type: FieldType.string,
config: {},
values: new ArrayVector(['{"foo": "bar", "bar": "baz"}', 'foo="bar" bar="baz"']),
},
],
};
it('suggest logfmt parser when no parser in query', () => {
expect(getQueryHints('{job="grafana"', [jsonAndLogfmtSeries])).toMatchObject([
{ type: 'ADD_JSON_PARSER' },
{ type: 'ADD_LOGFMT_PARSER' },
]);
});
it('does not suggest parser when parser in query', () => {
expect(getQueryHints('{job="grafana" | json', [jsonAndLogfmtSeries])).toEqual([]);
});
});
});

@ -0,0 +1,42 @@
import { DataFrame, QueryHint } from '@grafana/data';
import { isQueryWithParser } from './query_utils';
import { extractLogParserFromDataFrame } from './responseUtils';
export function getQueryHints(query: string, series: DataFrame[]): QueryHint[] {
const hints: QueryHint[] = [];
if (series.length > 0) {
const { hasLogfmt, hasJSON } = extractLogParserFromDataFrame(series[0]);
const queryWithParser = isQueryWithParser(query);
if (hasJSON && !queryWithParser) {
hints.push({
type: 'ADD_JSON_PARSER',
label: 'Selected log stream selector has JSON formatted logs.',
fix: {
label: 'Consider using JSON parser.',
action: {
type: 'ADD_JSON_PARSER',
query,
},
},
});
}
if (hasLogfmt && !queryWithParser) {
hints.push({
type: 'ADD_LOGFMT_PARSER',
label: 'Selected log stream selector has logfmt formatted logs.',
fix: {
label: 'Consider using logfmt parser.',
action: {
type: 'ADD_LOGFMT_PARSER',
query,
},
},
});
}
}
return hints;
}

@ -1,4 +1,10 @@
import { getHighlighterExpressionsFromQuery, getNormalizedLokiQuery } from './query_utils';
import {
getHighlighterExpressionsFromQuery,
getNormalizedLokiQuery,
isLogsQuery,
isQueryWithParser,
isValidQuery,
} from './query_utils';
import { LokiQuery, LokiQueryType } from './types';
describe('getHighlighterExpressionsFromQuery', () => {
@ -26,7 +32,7 @@ describe('getHighlighterExpressionsFromQuery', () => {
expect(getHighlighterExpressionsFromQuery('{foo="bar"} |= "x" | logfmt')).toEqual(['x']);
});
it('returns expressions for query with filter chain folowed by log parser', () => {
it('returns expressions for query with filter chain followed by log parser', () => {
expect(getHighlighterExpressionsFromQuery('{foo="bar"} |= "x" |~ "y" | logfmt')).toEqual(['x', 'y']);
});
@ -110,3 +116,34 @@ describe('getNormalizedLokiQuery', () => {
expectNormalized({ instant: true, range: false, queryType: 'invalid' }, LokiQueryType.Instant);
});
});
describe('isValidQuery', () => {
it('returns false if invalid query', () => {
expect(isValidQuery('{job="grafana')).toBe(false);
});
it('returns true if valid query', () => {
expect(isValidQuery('{job="grafana"}')).toBe(true);
});
});
describe('isLogsQuery', () => {
it('returns false if metrics query', () => {
expect(isLogsQuery('rate({job="grafana"}[5m])')).toBe(false);
});
it('returns true if valid query', () => {
expect(isLogsQuery('{job="grafana"}')).toBe(true);
});
});
describe('isQueryWithParser', () => {
it('returns false if query without parser', () => {
expect(isQueryWithParser('rate({job="grafana" |= "error" }[5m])')).toBe(false);
});
it('returns true if log query with parser', () => {
expect(isQueryWithParser('{job="grafana"} | json')).toBe(true);
});
it('returns true if metric query with parser', () => {
expect(isQueryWithParser('rate({job="grafana"} | json [5m])')).toBe(true);
});
});

@ -1,5 +1,9 @@
import { escapeRegExp } from 'lodash';
import { parser } from '@grafana/lezer-logql';
import { ErrorName } from '../prometheus/querybuilder/shared/parsingUtils';
import { LokiQuery, LokiQueryType } from './types';
export function formatQuery(selector: string | undefined): string {
@ -90,3 +94,42 @@ export function getNormalizedLokiQuery(query: LokiQuery): LokiQuery {
const { instant, range, ...rest } = query;
return { ...rest, queryType: LokiQueryType.Range };
}
export function isValidQuery(query: string): boolean {
let isValid = true;
const tree = parser.parse(query);
tree.iterate({
enter: (type): false | void => {
if (type.name === ErrorName) {
isValid = false;
}
},
});
return isValid;
}
export function isLogsQuery(query: string): boolean {
let isLogsQuery = true;
const tree = parser.parse(query);
tree.iterate({
enter: (type): false | void => {
if (type.name === 'MetricExpr') {
isLogsQuery = false;
}
},
});
return isLogsQuery;
}
export function isQueryWithParser(query: string): boolean {
let hasParser = false;
const tree = parser.parse(query);
tree.iterate({
enter: (type): false | void => {
if (type.name === 'LabelParser') {
hasParser = true;
}
},
});
return hasParser;
}

@ -2,8 +2,6 @@ import { render, screen, getAllByRole, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { PanelData } from '@grafana/data';
import { LokiDatasource } from '../../datasource';
import { LokiOperationId, LokiVisualQuery } from '../types';
@ -14,29 +12,7 @@ const defaultQuery: LokiVisualQuery = {
operations: [],
};
describe('LokiQueryBuilder', () => {
it('tries to load labels when no labels are selected', async () => {
const { datasource } = setup();
datasource.languageProvider.fetchSeriesLabels = jest.fn().mockReturnValue({ job: ['a'], instance: ['b'] });
await userEvent.click(screen.getByLabelText('Add'));
const labels = screen.getByText(/Labels/);
const selects = getAllByRole(labels.parentElement!.parentElement!.parentElement!, 'combobox');
await userEvent.click(selects[3]);
await waitFor(() => expect(screen.getByText('job')).toBeInTheDocument());
});
it('shows error for query with operations and no stream selector', async () => {
setup({ labels: [], operations: [{ id: LokiOperationId.Logfmt, params: [] }] });
expect(screen.getByText('You need to specify at least 1 label filter (stream selector)')).toBeInTheDocument();
});
it('shows no error for query with empty __line_contains operation and no stream selector', async () => {
setup({ labels: [], operations: [{ id: LokiOperationId.LineContains, params: [''] }] });
expect(screen.queryByText('You need to specify at least 1 label filter (stream selector)')).not.toBeInTheDocument();
});
});
function setup(query: LokiVisualQuery = defaultQuery, data?: PanelData) {
const createDefaultProps = () => {
const datasource = new LokiDatasource(
{
url: '',
@ -46,13 +22,47 @@ function setup(query: LokiVisualQuery = defaultQuery, data?: PanelData) {
undefined,
undefined
);
const props = {
datasource,
onRunQuery: () => {},
onChange: () => {},
data,
};
const { container } = render(<LokiQueryBuilder {...props} query={query} />);
return { datasource, container };
}
return props;
};
describe('LokiQueryBuilder', () => {
it('tries to load labels when no labels are selected', async () => {
const props = createDefaultProps();
props.datasource.getDataSamples = jest.fn().mockResolvedValue([]);
props.datasource.languageProvider.fetchSeriesLabels = jest.fn().mockReturnValue({ job: ['a'], instance: ['b'] });
render(<LokiQueryBuilder {...props} query={defaultQuery} />);
await userEvent.click(screen.getByLabelText('Add'));
const labels = screen.getByText(/Labels/);
const selects = getAllByRole(labels.parentElement!.parentElement!.parentElement!, 'combobox');
await userEvent.click(selects[3]);
await waitFor(() => expect(screen.getByText('job')).toBeInTheDocument());
});
it('shows error for query with operations and no stream selector', async () => {
const query = { labels: [], operations: [{ id: LokiOperationId.Logfmt, params: [] }] };
render(<LokiQueryBuilder {...createDefaultProps()} query={query} />);
expect(
await screen.findByText('You need to specify at least 1 label filter (stream selector)')
).toBeInTheDocument();
});
it('shows no error for query with empty __line_contains operation and no stream selector', async () => {
const query = { labels: [], operations: [{ id: LokiOperationId.LineContains, params: [''] }] };
render(<LokiQueryBuilder {...createDefaultProps()} query={query} />);
await waitFor(() => {
expect(
screen.queryByText('You need to specify at least 1 label filter (stream selector)')
).not.toBeInTheDocument();
});
});
});

@ -1,15 +1,17 @@
import React, { useMemo } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { DataSourceApi, SelectableValue } from '@grafana/data';
import { DataSourceApi, getDefaultTimeRange, LoadingState, PanelData, SelectableValue } from '@grafana/data';
import { EditorRow } from '@grafana/experimental';
import { LabelFilters } from 'app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters';
import { OperationList } from 'app/plugins/datasource/prometheus/querybuilder/shared/OperationList';
import { OperationsEditorRow } from 'app/plugins/datasource/prometheus/querybuilder/shared/OperationsEditorRow';
import { QueryBuilderHints } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryBuilderHints';
import { QueryBuilderLabelFilter } from 'app/plugins/datasource/prometheus/querybuilder/shared/types';
import { LokiDatasource } from '../../datasource';
import { escapeLabelValueInSelector } from '../../language_utils';
import { lokiQueryModeller } from '../LokiQueryModeller';
import { buildVisualQueryFromString } from '../parsing';
import { LokiOperationId, LokiVisualQuery } from '../types';
import { NestedQueryList } from './NestedQueryList';
@ -22,6 +24,8 @@ export interface Props {
}
export const LokiQueryBuilder = React.memo<Props>(({ datasource, query, onChange, onRunQuery }) => {
const [sampleData, setSampleData] = useState<PanelData>();
const onChangeLabels = (labels: QueryBuilderLabelFilter[]) => {
onChange({ ...query, labels });
};
@ -74,6 +78,17 @@ export const LokiQueryBuilder = React.memo<Props>(({ datasource, query, onChange
return undefined;
}, [query]);
useEffect(() => {
const onGetSampleData = async () => {
const lokiQuery = { expr: lokiQueryModeller.renderQuery(query), refId: 'data-samples' };
const series = await datasource.getDataSamples(lokiQuery);
const sampleData = { series, state: LoadingState.Done, timeRange: getDefaultTimeRange() };
setSampleData(sampleData);
};
onGetSampleData().catch(console.error);
}, [datasource, query]);
return (
<>
<EditorRow>
@ -97,6 +112,14 @@ export const LokiQueryBuilder = React.memo<Props>(({ datasource, query, onChange
onRunQuery={onRunQuery}
datasource={datasource as DataSourceApi}
/>
<QueryBuilderHints<LokiVisualQuery>
datasource={datasource}
query={query}
onChange={onChange}
data={sampleData}
queryModeller={lokiQueryModeller}
buildVisualQueryFromString={buildVisualQueryFromString}
/>
</OperationsEditorRow>
{query.binaryQueries && query.binaryQueries.length > 0 && (
<NestedQueryList query={query} datasource={datasource} onChange={onChange} onRunQuery={onRunQuery} />

@ -32,9 +32,13 @@ describe('LokiQueryBuilderContainer', () => {
onRunQuery: () => {},
showRawQuery: true,
};
props.datasource.getDataSamples = jest.fn().mockResolvedValue([]);
render(<LokiQueryBuilderContainer {...props} />);
expect(screen.getByText('testjob')).toBeInTheDocument();
const selector = await screen.findByLabelText('selector');
expect(selector.textContent).toBe('{job="testjob"}');
await addOperation('Range functions', 'Rate');
expect(await screen.findByText('Rate')).toBeInTheDocument();
expect(props.onChange).toBeCalledWith({
expr: 'rate({job="testjob"} [$__interval])',
refId: 'A',

@ -7,7 +7,7 @@ import { RadioButtonGroup, Select, AutoSizeInput } from '@grafana/ui';
import { QueryOptionGroup } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryOptionGroup';
import { preprocessMaxLines, queryTypeOptions, RESOLUTION_OPTIONS } from '../../components/LokiOptionFields';
import { isMetricsQuery } from '../../datasource';
import { isLogsQuery } from '../../query_utils';
import { LokiQuery, LokiQueryType } from '../../types';
export interface Props {
@ -46,7 +46,7 @@ export const LokiQueryBuilderOptions = React.memo<Props>(({ app, query, onChange
}
let queryType = query.queryType ?? (query.instant ? LokiQueryType.Instant : LokiQueryType.Range);
let showMaxLines = !isMetricsQuery(query.expr);
let showMaxLines = isLogsQuery(query.expr);
return (
<EditorRow>

@ -50,6 +50,7 @@ const datasource = new LokiDatasource(
);
datasource.languageProvider.fetchLabels = jest.fn().mockResolvedValue([]);
datasource.getDataSamples = jest.fn().mockResolvedValue([]);
const defaultProps = {
datasource,
@ -75,7 +76,7 @@ describe('LokiQueryEditorSelector', () => {
}}
/>
);
expectBuilder();
await expectBuilder();
});
it('shows code editor when code mode is set', async () => {
@ -85,7 +86,7 @@ describe('LokiQueryEditorSelector', () => {
it('shows builder when builder mode is set', async () => {
renderWithMode(QueryEditorMode.Builder);
expectBuilder();
await expectBuilder();
});
it('shows explain when explain mode is set', async () => {
@ -106,7 +107,7 @@ describe('LokiQueryEditorSelector', () => {
it('Can enable raw query', async () => {
renderWithMode(QueryEditorMode.Builder);
expect(screen.queryByLabelText('selector')).toBeInTheDocument();
expect(await screen.findByLabelText('selector')).toBeInTheDocument();
screen.getByLabelText('Raw query').click();
expect(screen.queryByLabelText('selector')).not.toBeInTheDocument();
});
@ -116,7 +117,9 @@ describe('LokiQueryEditorSelector', () => {
editorMode: QueryEditorMode.Builder,
expr: '{job="grafana"}',
});
expect(screen.getByLabelText('selector').textContent).toBe('{job="grafana"}');
const selector = await screen.findByLabelText('selector');
expect(selector).toBeInTheDocument();
expect(selector.textContent).toBe('{job="grafana"}');
});
it('changes to code mode', async () => {
@ -182,8 +185,8 @@ function expectCodeEditor() {
expect(screen.getByText('Loading labels...')).toBeInTheDocument();
}
function expectBuilder() {
expect(screen.getByText('Labels')).toBeInTheDocument();
async function expectBuilder() {
expect(await screen.findByText('Labels')).toBeInTheDocument();
}
function expectExplain() {

@ -1,6 +1,29 @@
import { DataFrame, Labels } from '@grafana/data';
import { DataFrame, FieldType, getParser, Labels, LogsParsers } from '@grafana/data';
export function dataFrameHasLokiError(frame: DataFrame): boolean {
const labelSets: Labels[] = frame.fields.find((f) => f.name === 'labels')?.values.toArray() ?? [];
return labelSets.some((labels) => labels.__error__ !== undefined);
}
export function extractLogParserFromDataFrame(frame: DataFrame): { hasLogfmt: boolean; hasJSON: boolean } {
const lineField = frame.fields.find((field) => field.type === FieldType.string);
if (lineField == null) {
return { hasJSON: false, hasLogfmt: false };
}
const logLines: string[] = lineField.values.toArray();
let hasJSON = false;
let hasLogfmt = false;
logLines.forEach((line) => {
const parser = getParser(line);
if (parser === LogsParsers.JSON) {
hasJSON = true;
}
if (parser === LogsParsers.logfmt) {
hasLogfmt = true;
}
});
return { hasLogfmt, hasJSON };
}

@ -6,15 +6,16 @@ import { EditorRow } from '@grafana/experimental';
import { PrometheusDatasource } from '../../datasource';
import { getMetadataString } from '../../language_provider';
import { promQueryModeller } from '../PromQueryModeller';
import { buildVisualQueryFromString } from '../parsing';
import { LabelFilters } from '../shared/LabelFilters';
import { OperationList } from '../shared/OperationList';
import { OperationsEditorRow } from '../shared/OperationsEditorRow';
import { QueryBuilderHints } from '../shared/QueryBuilderHints';
import { QueryBuilderLabelFilter } from '../shared/types';
import { PromVisualQuery } from '../types';
import { MetricSelect } from './MetricSelect';
import { NestedQueryList } from './NestedQueryList';
import { PromQueryBuilderHints } from './PromQueryBuilderHints';
export interface Props {
query: PromVisualQuery;
@ -108,7 +109,14 @@ export const PromQueryBuilder = React.memo<Props>(({ datasource, query, onChange
onChange={onChange}
onRunQuery={onRunQuery}
/>
<PromQueryBuilderHints datasource={datasource} query={query} onChange={onChange} data={data} />
<QueryBuilderHints<PromVisualQuery>
datasource={datasource}
query={query}
onChange={onChange}
data={data}
queryModeller={promQueryModeller}
buildVisualQueryFromString={buildVisualQueryFromString}
/>
</OperationsEditorRow>
{query.binaryQueries && query.binaryQueries.length > 0 && (
<NestedQueryList query={query} datasource={datasource} onChange={onChange} onRunQuery={onRunQuery} />

@ -3,29 +3,38 @@ import React, { useState, useEffect } from 'react';
import { GrafanaTheme2, PanelData, QueryHint } from '@grafana/data';
import { Button, Tooltip, useStyles2 } from '@grafana/ui';
import { LokiDatasource } from 'app/plugins/datasource/loki/datasource';
import { PrometheusDatasource } from '../../datasource';
import { promQueryModeller } from '../PromQueryModeller';
import { buildVisualQueryFromString } from '../parsing';
import { PromVisualQuery } from '../types';
export interface Props {
query: PromVisualQuery;
datasource: PrometheusDatasource;
onChange: (update: PromVisualQuery) => void;
import { LokiAndPromQueryModellerBase, PromLokiVisualQuery } from './LokiAndPromQueryModellerBase';
export interface Props<T extends PromLokiVisualQuery> {
query: T;
datasource: PrometheusDatasource | LokiDatasource;
queryModeller: LokiAndPromQueryModellerBase;
buildVisualQueryFromString: (expr: string) => { query: T };
onChange: (update: T) => void;
data?: PanelData;
}
export const PromQueryBuilderHints = React.memo<Props>(({ datasource, query, onChange, data }) => {
export const QueryBuilderHints = <T extends PromLokiVisualQuery>({
datasource,
query: visualQuery,
onChange,
data,
queryModeller,
buildVisualQueryFromString,
}: Props<T>) => {
const [hints, setHints] = useState<QueryHint[]>([]);
const styles = useStyles2(getStyles);
useEffect(() => {
const promQuery = { expr: promQueryModeller.renderQuery(query), refId: '' };
const query = { expr: queryModeller.renderQuery(visualQuery), refId: '' };
// For now show only actionable hints
const hints = datasource.getQueryHints(promQuery, data?.series || []).filter((hint) => hint.fix?.action);
const hints = datasource.getQueryHints(query, data?.series || []).filter((hint) => hint.fix?.action);
setHints(hints);
}, [datasource, query, onChange, data, styles.hint]);
}, [datasource, visualQuery, data, queryModeller]);
return (
<>
@ -36,10 +45,10 @@ export const PromQueryBuilderHints = React.memo<Props>(({ datasource, query, onC
<Tooltip content={`${hint.label} ${hint.fix?.label}`} key={hint.type}>
<Button
onClick={() => {
const promQuery = { expr: promQueryModeller.renderQuery(query), refId: '' };
const newPromQuery = datasource.modifyQuery(promQuery, hint!.fix!.action);
const visualQuery = buildVisualQueryFromString(newPromQuery.expr);
return onChange(visualQuery.query);
const query = { expr: queryModeller.renderQuery(visualQuery), refId: '' };
const newQuery = datasource.modifyQuery(query, hint!.fix!.action);
const newVisualQuery = buildVisualQueryFromString(newQuery.expr);
return onChange(newVisualQuery.query);
}}
fill="outline"
size="sm"
@ -54,16 +63,15 @@ export const PromQueryBuilderHints = React.memo<Props>(({ datasource, query, onC
)}
</>
);
});
};
PromQueryBuilderHints.displayName = 'PromQueryBuilderHints';
QueryBuilderHints.displayName = 'QueryBuilderHints';
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css`
display: flex;
margin-bottom: ${theme.spacing(1)};
align-items: center;
align-items: start;
`,
hint: css`
margin-right: ${theme.spacing(1)};
Loading…
Cancel
Save