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 (
+