diff --git a/public/app/plugins/datasource/loki/language_utils.test.ts b/public/app/plugins/datasource/loki/language_utils.test.ts new file mode 100644 index 00000000000..b70c3023e6a --- /dev/null +++ b/public/app/plugins/datasource/loki/language_utils.test.ts @@ -0,0 +1,20 @@ +import { isBytesString } from './language_utils'; + +describe('isBytesString', () => { + it('correctly matches bytes string with integers', () => { + expect(isBytesString('500b')).toBe(true); + expect(isBytesString('2TB')).toBe(true); + }); + it('correctly matches bytes string with float', () => { + expect(isBytesString('500.4kib')).toBe(true); + expect(isBytesString('10.4654Mib')).toBe(true); + }); + it('does not match integer without unit', () => { + expect(isBytesString('500')).toBe(false); + expect(isBytesString('10')).toBe(false); + }); + it('does not match float without unit', () => { + expect(isBytesString('50.047')).toBe(false); + expect(isBytesString('1.234')).toBe(false); + }); +}); diff --git a/public/app/plugins/datasource/loki/language_utils.ts b/public/app/plugins/datasource/loki/language_utils.ts index 65bd5cde569..0dfcd1b35e2 100644 --- a/public/app/plugins/datasource/loki/language_utils.ts +++ b/public/app/plugins/datasource/loki/language_utils.ts @@ -51,3 +51,36 @@ export function isRegexSelector(selector?: string) { } return false; } + +export function isBytesString(string: string) { + const BYTES_KEYWORDS = [ + 'b', + 'kib', + 'Kib', + 'kb', + 'KB', + 'mib', + 'Mib', + 'mb', + 'MB', + 'gib', + 'Gib', + 'gb', + 'GB', + 'tib', + 'Tib', + 'tb', + 'TB', + 'pib', + 'Pib', + 'pb', + 'PB', + 'eib', + 'Eib', + 'eb', + 'EB', + ]; + const regex = new RegExp(`^(?:-?\\d+(?:\\.\\d+)?)(?:${BYTES_KEYWORDS.join('|')})$`); + const match = string.match(regex); + return !!match; +} diff --git a/public/app/plugins/datasource/loki/query_utils.ts b/public/app/plugins/datasource/loki/query_utils.ts index e99efe6dfd8..2485cbf7412 100644 --- a/public/app/plugins/datasource/loki/query_utils.ts +++ b/public/app/plugins/datasource/loki/query_utils.ts @@ -172,3 +172,34 @@ export function isQueryWithLabelFormat(query: string): boolean { }); return queryWithLabelFormat; } + +export function getLogQueryFromMetricsQuery(query: string): string { + if (isLogsQuery(query)) { + return query; + } + + const tree = parser.parse(query); + + // Log query in metrics query composes of Selector & PipelineExpr + let selector = ''; + tree.iterate({ + enter: (type, from, to): false | void => { + if (type.name === 'Selector') { + selector = query.substring(from, to); + return false; + } + }, + }); + + let pipelineExpr = ''; + tree.iterate({ + enter: (type, from, to): false | void => { + if (type.name === 'PipelineExpr') { + pipelineExpr = query.substring(from, to); + return false; + } + }, + }); + + return selector + pipelineExpr; +} diff --git a/public/app/plugins/datasource/loki/querybuilder/components/UnwrapParamEditor.test.tsx b/public/app/plugins/datasource/loki/querybuilder/components/UnwrapParamEditor.test.tsx new file mode 100644 index 00000000000..2d348acc733 --- /dev/null +++ b/public/app/plugins/datasource/loki/querybuilder/components/UnwrapParamEditor.test.tsx @@ -0,0 +1,147 @@ +import { screen, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React, { ComponentProps } from 'react'; + +import { DataFrame, DataSourceApi, DataSourcePluginMeta, FieldType, toDataFrame } from '@grafana/data'; +import { + QueryBuilderOperation, + QueryBuilderOperationParamDef, +} from 'app/plugins/datasource/prometheus/querybuilder/shared/types'; + +import { LokiDatasource } from '../../datasource'; +import { LokiOperationId } from '../types'; + +import { UnwrapParamEditor } from './UnwrapParamEditor'; + +describe('UnwrapParamEditor', () => { + it('shows value if value present', () => { + const props = createProps({ value: 'unique' }); + render(); + expect(screen.getByText('unique')).toBeInTheDocument(); + }); + + it('shows no label options if no samples are returned', async () => { + const props = createProps(); + render(); + const input = screen.getByRole('combobox'); + await userEvent.click(input); + expect(screen.getByText('No labels found')).toBeInTheDocument(); + }); + + it('shows no label options for non-metric query', async () => { + const props = createProps({ + query: { + labels: [{ op: '=', label: 'foo', value: 'bar' }], + operations: [ + { id: LokiOperationId.Logfmt, params: [] }, + { id: LokiOperationId.Unwrap, params: ['', ''] }, + ], + }, + }); + render(); + const input = screen.getByRole('combobox'); + await userEvent.click(input); + expect(screen.getByText('No labels found')).toBeInTheDocument(); + }); + + it('shows labels with unwrap-friendly values', async () => { + const props = createProps({}, frames); + render(); + const input = screen.getByRole('combobox'); + await userEvent.click(input); + expect(await screen.findByText('status')).toBeInTheDocument(); + expect(await screen.findByText('duration')).toBeInTheDocument(); + }); +}); + +const createProps = ( + propsOverrides?: Partial>, + mockedSample?: DataFrame[] +) => { + const propsDefault = { + value: undefined, + onChange: jest.fn(), + onRunQuery: jest.fn(), + index: 1, + operationIndex: 1, + query: { + labels: [{ op: '=', label: 'foo', value: 'bar' }], + operations: [ + { id: LokiOperationId.Logfmt, params: [] }, + { id: LokiOperationId.Unwrap, params: ['', ''] }, + { id: LokiOperationId.SumOverTime, params: ['5m'] }, + { id: '__sum_by', params: ['job'] }, + ], + }, + paramDef: {} as QueryBuilderOperationParamDef, + operation: {} as QueryBuilderOperation, + datasource: new LokiDatasource( + { + id: 1, + uid: '', + type: 'loki', + name: 'loki-test', + access: 'proxy', + url: '', + jsonData: {}, + meta: {} as DataSourcePluginMeta, + }, + undefined, + undefined + ) as DataSourceApi, + }; + const props = { ...propsDefault, ...propsOverrides }; + + if (props.datasource instanceof LokiDatasource) { + const resolvedValue = mockedSample ?? []; + props.datasource.getDataSamples = jest.fn().mockResolvedValue(resolvedValue); + } + return props; +}; + +const frames = [ + toDataFrame({ + fields: [ + { + name: 'labels', + type: FieldType.other, + values: [ + { + compose_project: 'docker-compose', + compose_service: 'app', + container_name: 'docker-compose_app_1', + duration: '2.807709ms', + filename: '/var/log/docker/37c87fe98cbfa28327c1de10c4aff72c58154d8e4d129118ff2024692360b677/json.log', + host: 'docker-desktop', + instance: 'docker-compose_app_1', + job: 'tns/app', + level: 'info', + msg: 'HTTP client success', + namespace: 'tns', + source: 'stdout', + status: '200', + traceID: '6a3d34c4225776f6', + url: 'http://db', + }, + { + compose_project: 'docker-compose', + compose_service: 'app', + container_name: 'docker-compose_app_1', + duration: '7.432542ms', + filename: '/var/log/docker/37c87fe98cbfa28327c1de10c4aff72c58154d8e4d129118ff2024692360b677/json.log', + host: 'docker-desktop', + instance: 'docker-compose_app_1', + job: 'tns/app', + level: 'info', + msg: 'HTTP client success', + namespace: 'tns', + source: 'stdout', + status: '200', + traceID: '18e99189831471f6', + url: 'http://db', + }, + ], + }, + ], + }), +]; diff --git a/public/app/plugins/datasource/loki/querybuilder/components/UnwrapParamEditor.tsx b/public/app/plugins/datasource/loki/querybuilder/components/UnwrapParamEditor.tsx new file mode 100644 index 00000000000..92dbb9179b1 --- /dev/null +++ b/public/app/plugins/datasource/loki/querybuilder/components/UnwrapParamEditor.tsx @@ -0,0 +1,96 @@ +import { isNaN } from 'lodash'; +import React, { useState } from 'react'; + +import { isValidGoDuration, SelectableValue, toOption } from '@grafana/data'; +import { Select } from '@grafana/ui'; + +import { getOperationParamId } from '../../../prometheus/querybuilder/shared/operationUtils'; +import { QueryBuilderOperationParamEditorProps } from '../../../prometheus/querybuilder/shared/types'; +import { LokiDatasource } from '../../datasource'; +import { isBytesString } from '../../language_utils'; +import { getLogQueryFromMetricsQuery, isValidQuery } from '../../query_utils'; +import { lokiQueryModeller } from '../LokiQueryModeller'; +import { LokiVisualQuery } from '../types'; + +export function UnwrapParamEditor({ + onChange, + index, + operationIndex, + value, + query, + datasource, +}: QueryBuilderOperationParamEditorProps) { + const [state, setState] = useState<{ + options?: Array>; + isLoading?: boolean; + }>({}); + + return ( +