mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Loki: Update log context UI (#66730)
* fix logrowcontext scrolling behavior * Loki: Update loki context ui menu * Update * Add test, update * Use escapeLabelValueInSelector when displaying labels * Update test for new appliedContextFilters --------- Co-authored-by: Sven Grossmann <svennergr@gmail.com>
This commit is contained in:
@@ -141,7 +141,7 @@ export interface DataSourceWithLogsContextSupport<TQuery extends DataQuery = Dat
|
|||||||
* @alpha
|
* @alpha
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
getLogRowContextUi?(row: LogRowModel, runContextQuery?: () => void): React.ReactNode;
|
getLogRowContextUi?(row: LogRowModel, runContextQuery?: () => void, origQuery?: TQuery): React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const hasLogsContextSupport = (datasource: unknown): datasource is DataSourceWithLogsContextSupport => {
|
export const hasLogsContextSupport = (datasource: unknown): datasource is DataSourceWithLogsContextSupport => {
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
DataHoverEvent,
|
DataHoverEvent,
|
||||||
DataHoverClearEvent,
|
DataHoverClearEvent,
|
||||||
EventBus,
|
EventBus,
|
||||||
DataSourceWithLogsContextSupport,
|
|
||||||
LogRowContextOptions,
|
LogRowContextOptions,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { reportInteraction } from '@grafana/runtime';
|
import { reportInteraction } from '@grafana/runtime';
|
||||||
@@ -80,7 +79,7 @@ interface Props extends Themeable2 {
|
|||||||
onStartScanning?: () => void;
|
onStartScanning?: () => void;
|
||||||
onStopScanning?: () => void;
|
onStopScanning?: () => void;
|
||||||
getRowContext?: (row: LogRowModel, options?: LogRowContextOptions) => Promise<any>;
|
getRowContext?: (row: LogRowModel, options?: LogRowContextOptions) => Promise<any>;
|
||||||
getLogRowContextUi?: DataSourceWithLogsContextSupport['getLogRowContextUi'];
|
getLogRowContextUi?: (row: LogRowModel, runContextQuery?: () => void) => React.ReactNode;
|
||||||
getFieldLinks: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array<LinkModel<Field>>;
|
getFieldLinks: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array<LinkModel<Field>>;
|
||||||
addResultsToCache: () => void;
|
addResultsToCache: () => void;
|
||||||
clearCache: () => void;
|
clearCache: () => void;
|
||||||
|
|||||||
@@ -65,10 +65,13 @@ class LogsContainer extends PureComponent<LogsContainerProps> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
getLogRowContextUi = (row: LogRowModel, runContextQuery?: () => void): React.ReactNode => {
|
getLogRowContextUi = (row: LogRowModel, runContextQuery?: () => void): React.ReactNode => {
|
||||||
const { datasourceInstance } = this.props;
|
const { datasourceInstance, logsQueries } = this.props;
|
||||||
|
|
||||||
if (hasLogsContextUiSupport(datasourceInstance) && datasourceInstance.getLogRowContextUi) {
|
if (hasLogsContextUiSupport(datasourceInstance) && datasourceInstance.getLogRowContextUi) {
|
||||||
return datasourceInstance.getLogRowContextUi(row, runContextQuery);
|
const query = (logsQueries ?? []).find(
|
||||||
|
(q) => q.refId === row.dataFrame.refId && q.datasource != null && q.datasource.type === datasourceInstance.type
|
||||||
|
);
|
||||||
|
return datasourceInstance.getLogRowContextUi(row, runContextQuery, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <></>;
|
return <></>;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
LogsSortOrder,
|
LogsSortOrder,
|
||||||
CoreApp,
|
CoreApp,
|
||||||
DataFrame,
|
DataFrame,
|
||||||
DataSourceWithLogsContextSupport,
|
|
||||||
DataQueryResponse,
|
DataQueryResponse,
|
||||||
LogRowContextOptions,
|
LogRowContextOptions,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
@@ -43,7 +42,7 @@ export interface Props extends Themeable2 {
|
|||||||
onClickFilterLabel?: (key: string, value: string) => void;
|
onClickFilterLabel?: (key: string, value: string) => void;
|
||||||
onClickFilterOutLabel?: (key: string, value: string) => void;
|
onClickFilterOutLabel?: (key: string, value: string) => void;
|
||||||
getRowContext?: (row: LogRowModel, options?: LogRowContextOptions) => Promise<DataQueryResponse>;
|
getRowContext?: (row: LogRowModel, options?: LogRowContextOptions) => Promise<DataQueryResponse>;
|
||||||
getLogRowContextUi?: DataSourceWithLogsContextSupport['getLogRowContextUi'];
|
getLogRowContextUi?: (row: LogRowModel, runContextQuery?: () => void) => React.ReactNode;
|
||||||
getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array<LinkModel<Field>>;
|
getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array<LinkModel<Field>>;
|
||||||
onClickShowField?: (key: string) => void;
|
onClickShowField?: (key: string) => void;
|
||||||
onClickHideField?: (key: string) => void;
|
onClickHideField?: (key: string) => void;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ describe('LogContextProvider', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
logContextProvider = new LogContextProvider(defaultDatasourceMock);
|
logContextProvider = new LogContextProvider(defaultDatasourceMock);
|
||||||
logContextProvider.getInitContextFiltersFromLabels = jest.fn(() =>
|
logContextProvider.getInitContextFiltersFromLabels = jest.fn(() =>
|
||||||
Promise.resolve([{ value: 'bar', enabled: true, fromParser: false, label: 'bar' }])
|
Promise.resolve([{ value: 'baz', enabled: true, fromParser: false, label: 'bar' }])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,8 +59,8 @@ describe('LogContextProvider', () => {
|
|||||||
|
|
||||||
it('should not call getInitContextFilters if appliedContextFilters', async () => {
|
it('should not call getInitContextFilters if appliedContextFilters', async () => {
|
||||||
logContextProvider.appliedContextFilters = [
|
logContextProvider.appliedContextFilters = [
|
||||||
{ value: 'bar', enabled: true, fromParser: false, label: 'bar' },
|
{ value: 'baz', enabled: true, fromParser: false, label: 'bar' },
|
||||||
{ value: 'xyz', enabled: true, fromParser: false, label: 'xyz' },
|
{ value: 'abc', enabled: true, fromParser: false, label: 'xyz' },
|
||||||
];
|
];
|
||||||
await logContextProvider.getLogRowContext(defaultLogRow, {
|
await logContextProvider.getLogRowContext(defaultLogRow, {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
@@ -89,9 +89,9 @@ describe('LogContextProvider', () => {
|
|||||||
|
|
||||||
it('should not apply parsed labels', async () => {
|
it('should not apply parsed labels', async () => {
|
||||||
logContextProvider.appliedContextFilters = [
|
logContextProvider.appliedContextFilters = [
|
||||||
{ value: 'bar', enabled: true, fromParser: false, label: 'bar' },
|
{ value: 'baz', enabled: true, fromParser: false, label: 'bar' },
|
||||||
{ value: 'xyz', enabled: true, fromParser: false, label: 'xyz' },
|
{ value: 'abc', enabled: true, fromParser: false, label: 'xyz' },
|
||||||
{ value: 'foo', enabled: true, fromParser: true, label: 'foo' },
|
{ value: 'uniqueParsedLabel', enabled: true, fromParser: true, label: 'foo' },
|
||||||
];
|
];
|
||||||
const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget(
|
const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget(
|
||||||
defaultLogRow,
|
defaultLogRow,
|
||||||
@@ -107,8 +107,8 @@ describe('LogContextProvider', () => {
|
|||||||
describe('query with parser', () => {
|
describe('query with parser', () => {
|
||||||
it('should apply parser', async () => {
|
it('should apply parser', async () => {
|
||||||
logContextProvider.appliedContextFilters = [
|
logContextProvider.appliedContextFilters = [
|
||||||
{ value: 'bar', enabled: true, fromParser: false, label: 'bar' },
|
{ value: 'baz', enabled: true, fromParser: false, label: 'bar' },
|
||||||
{ value: 'xyz', enabled: true, fromParser: false, label: 'xyz' },
|
{ value: 'abc', enabled: true, fromParser: false, label: 'xyz' },
|
||||||
];
|
];
|
||||||
const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget(
|
const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget(
|
||||||
defaultLogRow,
|
defaultLogRow,
|
||||||
@@ -124,9 +124,9 @@ describe('LogContextProvider', () => {
|
|||||||
|
|
||||||
it('should apply parser and parsed labels', async () => {
|
it('should apply parser and parsed labels', async () => {
|
||||||
logContextProvider.appliedContextFilters = [
|
logContextProvider.appliedContextFilters = [
|
||||||
{ value: 'bar', enabled: true, fromParser: false, label: 'bar' },
|
{ value: 'baz', enabled: true, fromParser: false, label: 'bar' },
|
||||||
{ value: 'xyz', enabled: true, fromParser: false, label: 'xyz' },
|
{ value: 'abc', enabled: true, fromParser: false, label: 'xyz' },
|
||||||
{ value: 'foo', enabled: true, fromParser: true, label: 'foo' },
|
{ value: 'uniqueParsedLabel', enabled: true, fromParser: true, label: 'foo' },
|
||||||
];
|
];
|
||||||
const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget(
|
const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget(
|
||||||
defaultLogRow,
|
defaultLogRow,
|
||||||
@@ -143,8 +143,8 @@ describe('LogContextProvider', () => {
|
|||||||
|
|
||||||
it('should not apply parser and parsed labels if more parsers in original query', async () => {
|
it('should not apply parser and parsed labels if more parsers in original query', async () => {
|
||||||
logContextProvider.appliedContextFilters = [
|
logContextProvider.appliedContextFilters = [
|
||||||
{ value: 'bar', enabled: true, fromParser: false, label: 'bar' },
|
{ value: 'baz', enabled: true, fromParser: false, label: 'bar' },
|
||||||
{ value: 'foo', enabled: true, fromParser: true, label: 'foo' },
|
{ value: 'uniqueParsedLabel', enabled: true, fromParser: true, label: 'foo' },
|
||||||
];
|
];
|
||||||
const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget(
|
const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget(
|
||||||
defaultLogRow,
|
defaultLogRow,
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export class LogContextProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getLogRowContextUi(row: LogRowModel, runContextQuery: () => void): React.ReactNode {
|
getLogRowContextUi(row: LogRowModel, runContextQuery?: () => void, originalQuery?: DataQuery): React.ReactNode {
|
||||||
const updateFilter = (contextFilters: ContextFilter[]) => {
|
const updateFilter = (contextFilters: ContextFilter[]) => {
|
||||||
this.appliedContextFilters = contextFilters;
|
this.appliedContextFilters = contextFilters;
|
||||||
|
|
||||||
@@ -153,8 +153,15 @@ export class LogContextProvider {
|
|||||||
this.appliedContextFilters = [];
|
this.appliedContextFilters = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let origQuery: LokiQuery | undefined = undefined;
|
||||||
|
// Type guard for LokiQuery
|
||||||
|
if (originalQuery && isLokiQuery(originalQuery)) {
|
||||||
|
origQuery = originalQuery;
|
||||||
|
}
|
||||||
|
|
||||||
return LokiContextUi({
|
return LokiContextUi({
|
||||||
row,
|
row,
|
||||||
|
origQuery,
|
||||||
updateFilter,
|
updateFilter,
|
||||||
onClose: this.onContextClose,
|
onClose: this.onContextClose,
|
||||||
logContextProvider: this,
|
logContextProvider: this,
|
||||||
@@ -164,10 +171,9 @@ export class LogContextProvider {
|
|||||||
processContextFiltersToExpr = (row: LogRowModel, contextFilters: ContextFilter[], query: LokiQuery | undefined) => {
|
processContextFiltersToExpr = (row: LogRowModel, contextFilters: ContextFilter[], query: LokiQuery | undefined) => {
|
||||||
const labelFilters = contextFilters
|
const labelFilters = contextFilters
|
||||||
.map((filter) => {
|
.map((filter) => {
|
||||||
const label = filter.value;
|
|
||||||
if (!filter.fromParser && filter.enabled) {
|
if (!filter.fromParser && filter.enabled) {
|
||||||
// escape backslashes in label as users can't escape them by themselves
|
// escape backslashes in label as users can't escape them by themselves
|
||||||
return `${label}="${escapeLabelValueInExactSelector(row.labels[label])}"`;
|
return `${filter.label}="${escapeLabelValueInExactSelector(filter.value)}"`;
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
})
|
})
|
||||||
@@ -186,7 +192,7 @@ export class LogContextProvider {
|
|||||||
const parsedLabels = contextFilters.filter((filter) => filter.fromParser && filter.enabled);
|
const parsedLabels = contextFilters.filter((filter) => filter.fromParser && filter.enabled);
|
||||||
for (const parsedLabel of parsedLabels) {
|
for (const parsedLabel of parsedLabels) {
|
||||||
if (parsedLabel.enabled) {
|
if (parsedLabel.enabled) {
|
||||||
expr = addLabelToQuery(expr, parsedLabel.label, '=', row.labels[parsedLabel.label]);
|
expr = addLabelToQuery(expr, parsedLabel.label, '=', parsedLabel.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,10 +208,9 @@ export class LogContextProvider {
|
|||||||
Object.entries(labels).forEach(([label, value]) => {
|
Object.entries(labels).forEach(([label, value]) => {
|
||||||
const filter: ContextFilter = {
|
const filter: ContextFilter = {
|
||||||
label,
|
label,
|
||||||
value: label, // this looks weird in the first place, but we need to set the label as value here
|
value: value,
|
||||||
enabled: allLabels.includes(label),
|
enabled: allLabels.includes(label),
|
||||||
fromParser: !allLabels.includes(label),
|
fromParser: !allLabels.includes(label),
|
||||||
description: value,
|
|
||||||
};
|
};
|
||||||
contextFilters.push(filter);
|
contextFilters.push(filter);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { selectOptionInTest } from 'test/helpers/selectOptionInTest';
|
import { selectOptionInTest } from 'test/helpers/selectOptionInTest';
|
||||||
|
|
||||||
import { LogRowModel } from '@grafana/data';
|
import { LogRowModel } from '@grafana/data';
|
||||||
|
|
||||||
import { LogContextProvider } from '../LogContextProvider';
|
import { LogContextProvider } from '../LogContextProvider';
|
||||||
|
import { ContextFilter, LokiQuery } from '../types';
|
||||||
|
|
||||||
import { LokiContextUi, LokiContextUiProps } from './LokiContextUi';
|
import { LokiContextUi, LokiContextUiProps } from './LokiContextUi';
|
||||||
|
|
||||||
@@ -14,6 +16,49 @@ jest.mock('@grafana/runtime', () => ({
|
|||||||
reportInteraction: () => null,
|
reportInteraction: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock('app/core/store', () => {
|
||||||
|
return {
|
||||||
|
set() {},
|
||||||
|
getBool() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const setupProps = (): LokiContextUiProps => {
|
||||||
|
const defaults: LokiContextUiProps = {
|
||||||
|
logContextProvider: mockLogContextProvider as unknown as LogContextProvider,
|
||||||
|
updateFilter: jest.fn(),
|
||||||
|
row: {
|
||||||
|
entry: 'WARN test 1.23 on [xxx]',
|
||||||
|
labels: {
|
||||||
|
label1: 'value1',
|
||||||
|
label3: 'value3',
|
||||||
|
},
|
||||||
|
} as unknown as LogRowModel,
|
||||||
|
onClose: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return defaults;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockLogContextProvider = {
|
||||||
|
getInitContextFiltersFromLabels: jest.fn().mockImplementation(() =>
|
||||||
|
Promise.resolve([
|
||||||
|
{ value: 'value1', enabled: true, fromParser: false, label: 'label1' },
|
||||||
|
{ value: 'value3', enabled: false, fromParser: true, label: 'label3' },
|
||||||
|
])
|
||||||
|
),
|
||||||
|
processContextFiltersToExpr: jest.fn().mockImplementation(
|
||||||
|
(row: LogRowModel, contextFilters: ContextFilter[], query: LokiQuery | undefined) =>
|
||||||
|
`{${contextFilters
|
||||||
|
.filter((filter) => filter.enabled)
|
||||||
|
.map((filter) => `${filter.label}="${filter.value}"`)
|
||||||
|
.join('` ')}}`
|
||||||
|
),
|
||||||
|
getLogRowContext: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
describe('LokiContextUi', () => {
|
describe('LokiContextUi', () => {
|
||||||
const savedGlobal = global;
|
const savedGlobal = global;
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -29,38 +74,11 @@ describe('LokiContextUi', () => {
|
|||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
global = savedGlobal;
|
global = savedGlobal;
|
||||||
});
|
});
|
||||||
const setupProps = (): LokiContextUiProps => {
|
|
||||||
const mockLogContextProvider = {
|
|
||||||
getInitContextFiltersFromLabels: jest.fn().mockImplementation(() =>
|
|
||||||
Promise.resolve([
|
|
||||||
{ value: 'label1', enabled: true, fromParser: false, label: 'label1' },
|
|
||||||
{ value: 'label3', enabled: false, fromParser: true, label: 'label3' },
|
|
||||||
])
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaults: LokiContextUiProps = {
|
it('renders and shows executed query text', async () => {
|
||||||
logContextProvider: mockLogContextProvider as unknown as LogContextProvider,
|
|
||||||
updateFilter: jest.fn(),
|
|
||||||
row: {
|
|
||||||
entry: 'WARN test 1.23 on [xxx]',
|
|
||||||
labels: {
|
|
||||||
label1: 'value1',
|
|
||||||
label3: 'value3',
|
|
||||||
},
|
|
||||||
} as unknown as LogRowModel,
|
|
||||||
onClose: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return defaults;
|
|
||||||
};
|
|
||||||
|
|
||||||
it('renders and shows basic text', async () => {
|
|
||||||
const props = setupProps();
|
const props = setupProps();
|
||||||
render(<LokiContextUi {...props} />);
|
render(<LokiContextUi {...props} />);
|
||||||
|
expect(await screen.findByText(/Executed log context query:/)).toBeInTheDocument();
|
||||||
// Initial set of labels is available and not selected
|
|
||||||
expect(await screen.findByText(/Select labels to be included in the context query/)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('initialize context filters', async () => {
|
it('initialize context filters', async () => {
|
||||||
@@ -79,7 +97,7 @@ describe('LokiContextUi', () => {
|
|||||||
expect(props.logContextProvider.getInitContextFiltersFromLabels).toHaveBeenCalled();
|
expect(props.logContextProvider.getInitContextFiltersFromLabels).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
const select = await screen.findAllByRole('combobox');
|
const select = await screen.findAllByRole('combobox');
|
||||||
await selectOptionInTest(select[0], 'label1');
|
await selectOptionInTest(select[0], 'label1="value1"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('finds label3 as a parsed label', async () => {
|
it('finds label3 as a parsed label', async () => {
|
||||||
@@ -89,7 +107,7 @@ describe('LokiContextUi', () => {
|
|||||||
expect(props.logContextProvider.getInitContextFiltersFromLabels).toHaveBeenCalled();
|
expect(props.logContextProvider.getInitContextFiltersFromLabels).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
const select = await screen.findAllByRole('combobox');
|
const select = await screen.findAllByRole('combobox');
|
||||||
await selectOptionInTest(select[1], 'label3');
|
await selectOptionInTest(select[1], 'label3="value3"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls updateFilter when selecting a label', async () => {
|
it('calls updateFilter when selecting a label', async () => {
|
||||||
@@ -100,7 +118,7 @@ describe('LokiContextUi', () => {
|
|||||||
expect(props.logContextProvider.getInitContextFiltersFromLabels).toHaveBeenCalled();
|
expect(props.logContextProvider.getInitContextFiltersFromLabels).toHaveBeenCalled();
|
||||||
expect(screen.getAllByRole('combobox')).toHaveLength(2);
|
expect(screen.getAllByRole('combobox')).toHaveLength(2);
|
||||||
});
|
});
|
||||||
await selectOptionInTest(screen.getAllByRole('combobox')[1], 'label3');
|
await selectOptionInTest(screen.getAllByRole('combobox')[1], 'label3="value3"');
|
||||||
act(() => {
|
act(() => {
|
||||||
jest.runAllTimers();
|
jest.runAllTimers();
|
||||||
});
|
});
|
||||||
@@ -117,4 +135,21 @@ describe('LokiContextUi', () => {
|
|||||||
expect(props.onClose).toHaveBeenCalled();
|
expect(props.onClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('displays executed query even if context ui closed', async () => {
|
||||||
|
const props = setupProps();
|
||||||
|
render(<LokiContextUi {...props} />);
|
||||||
|
// We start with the context ui open
|
||||||
|
expect(await screen.findByText(/Executed log context query:/)).toBeInTheDocument();
|
||||||
|
// We click on it to close
|
||||||
|
await userEvent.click(screen.getByText(/Executed log context query:/));
|
||||||
|
await waitFor(() => {
|
||||||
|
// We should see the query text (it is split into multiple spans)
|
||||||
|
expect(screen.getByText('{')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('label1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('=')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('"value1"')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('}')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,40 +1,38 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import memoizeOne from 'memoize-one';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { useAsync } from 'react-use';
|
import { useAsync } from 'react-use';
|
||||||
|
|
||||||
import { GrafanaTheme2, LogRowModel, SelectableValue } from '@grafana/data';
|
import { GrafanaTheme2, LogRowModel, SelectableValue } from '@grafana/data';
|
||||||
import { reportInteraction } from '@grafana/runtime';
|
import { reportInteraction } from '@grafana/runtime';
|
||||||
import { LoadingPlaceholder, MultiSelect, Tag, Tooltip, useStyles2 } from '@grafana/ui';
|
import { Collapse, Label, LoadingPlaceholder, MultiSelect, Tag, Tooltip, useStyles2 } from '@grafana/ui';
|
||||||
|
import store from 'app/core/store';
|
||||||
|
|
||||||
|
import { RawQuery } from '../../prometheus/querybuilder/shared/RawQuery';
|
||||||
import { LogContextProvider } from '../LogContextProvider';
|
import { LogContextProvider } from '../LogContextProvider';
|
||||||
import { ContextFilter } from '../types';
|
import { escapeLabelValueInSelector } from '../languageUtils';
|
||||||
|
import { lokiGrammar } from '../syntax';
|
||||||
|
import { ContextFilter, LokiQuery } from '../types';
|
||||||
|
|
||||||
export interface LokiContextUiProps {
|
export interface LokiContextUiProps {
|
||||||
logContextProvider: LogContextProvider;
|
logContextProvider: LogContextProvider;
|
||||||
row: LogRowModel;
|
row: LogRowModel;
|
||||||
updateFilter: (value: ContextFilter[]) => void;
|
updateFilter: (value: ContextFilter[]) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
origQuery?: LokiQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStyles(theme: GrafanaTheme2) {
|
function getStyles(theme: GrafanaTheme2) {
|
||||||
return {
|
return {
|
||||||
labels: css`
|
labels: css`
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: ${theme.spacing(0.5)};
|
||||||
`,
|
`,
|
||||||
multiSelectWrapper: css`
|
wrapper: css`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-top: ${theme.spacing(1)};
|
|
||||||
gap: ${theme.spacing(0.5)};
|
gap: ${theme.spacing(0.5)};
|
||||||
`,
|
`,
|
||||||
multiSelect: css`
|
|
||||||
& .scrollbar-view {
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
loadingPlaceholder: css`
|
loadingPlaceholder: css`
|
||||||
margin-bottom: 0px;
|
margin-bottom: 0px;
|
||||||
float: right;
|
float: right;
|
||||||
@@ -48,23 +46,37 @@ function getStyles(theme: GrafanaTheme2) {
|
|||||||
hidden: css`
|
hidden: css`
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
`,
|
`,
|
||||||
|
tag: css`
|
||||||
|
padding: ${theme.spacing(0.25)} ${theme.spacing(0.75)};
|
||||||
|
`,
|
||||||
|
label: css`
|
||||||
|
max-width: 100%;
|
||||||
|
margin: ${theme.spacing(2)} 0;
|
||||||
|
`,
|
||||||
|
query: css`
|
||||||
|
text-align: start;
|
||||||
|
line-break: anywhere;
|
||||||
|
margin-top: ${theme.spacing(0.5)};
|
||||||
|
`,
|
||||||
|
ui: css`
|
||||||
|
background-color: ${theme.colors.background.secondary};
|
||||||
|
padding: ${theme.spacing(2)};
|
||||||
|
`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatOptionLabel = memoizeOne(({ label, description }: SelectableValue<string>) => (
|
const IS_LOKI_LOG_CONTEXT_UI_OPEN = 'isLogContextQueryUiOpen';
|
||||||
<Tooltip content={`${label}="${description}"`} placement="top" interactive={true}>
|
|
||||||
<span>{label}</span>
|
|
||||||
</Tooltip>
|
|
||||||
));
|
|
||||||
|
|
||||||
export function LokiContextUi(props: LokiContextUiProps) {
|
export function LokiContextUi(props: LokiContextUiProps) {
|
||||||
const { row, logContextProvider, updateFilter, onClose } = props;
|
const { row, logContextProvider, updateFilter, onClose, origQuery } = props;
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const [contextFilters, setContextFilters] = useState<ContextFilter[]>([]);
|
const [contextFilters, setContextFilters] = useState<ContextFilter[]>([]);
|
||||||
|
|
||||||
const [initialized, setInitialized] = useState(false);
|
const [initialized, setInitialized] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isOpen, setIsOpen] = useState(store.getBool(IS_LOKI_LOG_CONTEXT_UI_OPEN, true));
|
||||||
|
|
||||||
const timerHandle = React.useRef<number>();
|
const timerHandle = React.useRef<number>();
|
||||||
const previousInitialized = React.useRef<boolean>(false);
|
const previousInitialized = React.useRef<boolean>(false);
|
||||||
const previousContextFilters = React.useRef<ContextFilter[]>([]);
|
const previousContextFilters = React.useRef<ContextFilter[]>([]);
|
||||||
@@ -136,108 +148,129 @@ export function LokiContextUi(props: LokiContextUiProps) {
|
|||||||
const parsedLabels = contextFilters.filter(({ fromParser }) => fromParser);
|
const parsedLabels = contextFilters.filter(({ fromParser }) => fromParser);
|
||||||
const parsedLabelsEnabled = parsedLabels.filter(({ enabled }) => enabled);
|
const parsedLabelsEnabled = parsedLabels.filter(({ enabled }) => enabled);
|
||||||
|
|
||||||
|
const contextFilterToSelectFilter = useCallback((contextFilter: ContextFilter): SelectableValue<string> => {
|
||||||
|
return {
|
||||||
|
label: `${contextFilter.label}="${escapeLabelValueInSelector(contextFilter.value)}"`,
|
||||||
|
value: contextFilter.label,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.multiSelectWrapper}>
|
<div className={styles.wrapper}>
|
||||||
<div className={styles.textWrapper}>
|
<LoadingPlaceholder text="" className={`${styles.loadingPlaceholder} ${loading ? '' : styles.hidden}`} />
|
||||||
{' '}
|
<Collapse
|
||||||
<Tooltip
|
collapsible={true}
|
||||||
content={
|
isOpen={isOpen}
|
||||||
'This feature is experimental and only works on log queries containing no more than 1 parser (logfmt, json).'
|
onToggle={() => {
|
||||||
}
|
store.set(IS_LOKI_LOG_CONTEXT_UI_OPEN, !isOpen);
|
||||||
placement="top"
|
setIsOpen((isOpen) => !isOpen);
|
||||||
>
|
}}
|
||||||
<Tag
|
label={
|
||||||
className={css({
|
<div className={styles.query}>
|
||||||
fontSize: 10,
|
<Label>Executed log context query:</Label>
|
||||||
padding: '1px 5px',
|
<RawQuery
|
||||||
verticalAlign: 'text-bottom',
|
lang={{ grammar: lokiGrammar, name: 'loki' }}
|
||||||
})}
|
query={logContextProvider.processContextFiltersToExpr(
|
||||||
name={'Experimental'}
|
row,
|
||||||
colorIndex={1}
|
contextFilters.filter(({ enabled }) => enabled),
|
||||||
/>
|
origQuery
|
||||||
</Tooltip>{' '}
|
)}
|
||||||
Select labels to be included in the context query:
|
/>
|
||||||
<LoadingPlaceholder text="" className={`${styles.loadingPlaceholder} ${loading ? '' : styles.hidden}`} />
|
</div>
|
||||||
</div>
|
}
|
||||||
<div>
|
>
|
||||||
<MultiSelect
|
<div className={styles.ui}>
|
||||||
className={styles.multiSelect}
|
<Tooltip
|
||||||
prefix="Labels"
|
content={
|
||||||
options={realLabels}
|
'This feature is experimental and only works on log queries containing no more than 1 parser (logfmt, json).'
|
||||||
value={realLabelsEnabled}
|
|
||||||
formatOptionLabel={formatOptionLabel}
|
|
||||||
closeMenuOnSelect={true}
|
|
||||||
maxMenuHeight={200}
|
|
||||||
menuShouldPortal={false}
|
|
||||||
noOptionsMessage="No further labels available"
|
|
||||||
onChange={(keys, actionMeta) => {
|
|
||||||
if (actionMeta.action === 'select-option') {
|
|
||||||
reportInteraction('grafana_explore_logs_loki_log_context_filtered', {
|
|
||||||
logRowUid: row.uid,
|
|
||||||
type: 'label',
|
|
||||||
action: 'select',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (actionMeta.action === 'remove-value') {
|
placement="top"
|
||||||
reportInteraction('grafana_explore_logs_loki_log_context_filtered', {
|
>
|
||||||
logRowUid: row.uid,
|
<Tag className={styles.tag} name={'Experimental feature'} colorIndex={1} />
|
||||||
type: 'label',
|
</Tooltip>{' '}
|
||||||
action: 'remove',
|
<Label
|
||||||
});
|
className={styles.label}
|
||||||
}
|
description="Context query is created from all labels defining the stream for the selected log line. Select labels to be included in log context query."
|
||||||
return setContextFilters(
|
>
|
||||||
contextFilters.map((filter) => {
|
1. Select labels
|
||||||
if (filter.fromParser) {
|
</Label>
|
||||||
return filter;
|
|
||||||
}
|
|
||||||
filter.enabled = keys.some((key) => key.value === filter.value);
|
|
||||||
return filter;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{parsedLabels.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
className={styles.multiSelect}
|
options={realLabels.map(contextFilterToSelectFilter)}
|
||||||
prefix="Parsed Labels"
|
value={realLabelsEnabled.map(contextFilterToSelectFilter)}
|
||||||
options={parsedLabels}
|
|
||||||
value={parsedLabelsEnabled}
|
|
||||||
formatOptionLabel={formatOptionLabel}
|
|
||||||
closeMenuOnSelect={true}
|
closeMenuOnSelect={true}
|
||||||
menuShouldPortal={false}
|
|
||||||
maxMenuHeight={200}
|
maxMenuHeight={200}
|
||||||
noOptionsMessage="No further labels available"
|
noOptionsMessage="No further labels available"
|
||||||
isClearable={true}
|
|
||||||
onChange={(keys, actionMeta) => {
|
onChange={(keys, actionMeta) => {
|
||||||
if (actionMeta.action === 'select-option') {
|
if (actionMeta.action === 'select-option') {
|
||||||
reportInteraction('grafana_explore_logs_loki_log_context_filtered', {
|
reportInteraction('grafana_explore_logs_loki_log_context_filtered', {
|
||||||
logRowUid: row.uid,
|
logRowUid: row.uid,
|
||||||
type: 'parsed_label',
|
type: 'label',
|
||||||
action: 'select',
|
action: 'select',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (actionMeta.action === 'remove-value') {
|
if (actionMeta.action === 'remove-value') {
|
||||||
reportInteraction('grafana_explore_logs_loki_log_context_filtered', {
|
reportInteraction('grafana_explore_logs_loki_log_context_filtered', {
|
||||||
logRowUid: row.uid,
|
logRowUid: row.uid,
|
||||||
type: 'parsed_label',
|
type: 'label',
|
||||||
action: 'remove',
|
action: 'remove',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setContextFilters(
|
return setContextFilters(
|
||||||
contextFilters.map((filter) => {
|
contextFilters.map((filter) => {
|
||||||
if (!filter.fromParser) {
|
if (filter.fromParser) {
|
||||||
return filter;
|
return filter;
|
||||||
}
|
}
|
||||||
filter.enabled = keys.some((key) => key.value === filter.value);
|
filter.enabled = keys.some((key) => key.value === filter.label);
|
||||||
return filter;
|
return filter;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{parsedLabels.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Label
|
||||||
|
className={styles.label}
|
||||||
|
description={`By using parser, you are able to filter for extracted labels. Select extracted labels to be included in log context query.`}
|
||||||
|
>
|
||||||
|
2. Add extracted label filters
|
||||||
|
</Label>
|
||||||
|
<MultiSelect
|
||||||
|
options={parsedLabels.map(contextFilterToSelectFilter)}
|
||||||
|
value={parsedLabelsEnabled.map(contextFilterToSelectFilter)}
|
||||||
|
closeMenuOnSelect={true}
|
||||||
|
maxMenuHeight={200}
|
||||||
|
noOptionsMessage="No further labels available"
|
||||||
|
isClearable={true}
|
||||||
|
onChange={(keys, actionMeta) => {
|
||||||
|
if (actionMeta.action === 'select-option') {
|
||||||
|
reportInteraction('grafana_explore_logs_loki_log_context_filtered', {
|
||||||
|
logRowUid: row.uid,
|
||||||
|
type: 'parsed_label',
|
||||||
|
action: 'select',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (actionMeta.action === 'remove-value') {
|
||||||
|
reportInteraction('grafana_explore_logs_loki_log_context_filtered', {
|
||||||
|
logRowUid: row.uid,
|
||||||
|
type: 'parsed_label',
|
||||||
|
action: 'remove',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setContextFilters(
|
||||||
|
contextFilters.map((filter) => {
|
||||||
|
if (!filter.fromParser) {
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
filter.enabled = keys.some((key) => key.value === filter.label);
|
||||||
|
return filter;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Collapse>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -651,8 +651,8 @@ export class LokiDatasource
|
|||||||
return await this.logContextProvider.getLogRowContext(row, options, origQuery);
|
return await this.logContextProvider.getLogRowContext(row, options, origQuery);
|
||||||
};
|
};
|
||||||
|
|
||||||
getLogRowContextUi(row: LogRowModel, runContextQuery: () => void): React.ReactNode {
|
getLogRowContextUi(row: LogRowModel, runContextQuery: () => void, origQuery: DataQuery): React.ReactNode {
|
||||||
return this.logContextProvider.getLogRowContextUi(row, runContextQuery);
|
return this.logContextProvider.getLogRowContextUi(row, runContextQuery, origQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
testDatasource(): Promise<{ status: string; message: string }> {
|
testDatasource(): Promise<{ status: string; message: string }> {
|
||||||
|
|||||||
Reference in New Issue
Block a user