Loki: Redesign and improve query patterns (#55097)

* WIP

* WIP

* Query patterns: Redesign and improve feature

* Remove duplicated pattern

* Remove empty line

* Refactor

* Add tests

* Update docs and e2e test

* Update public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.ts

Co-authored-by: Matias Chomicki <matyax@gmail.com>

* Update public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.ts

Co-authored-by: Matias Chomicki <matyax@gmail.com>

* Update public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.ts

Co-authored-by: Matias Chomicki <matyax@gmail.com>

* Use capitalize

* Refactor to use QueryPatternsCard component

* Update public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.ts

Co-authored-by: Matias Chomicki <matyax@gmail.com>

* Update feature tracking for v2

* QueryPatternsCard: Remove unnecessary key

* Update naming for card

* Mock reportInteraction in tests

Co-authored-by: Matias Chomicki <matyax@gmail.com>
This commit is contained in:
Ivana Huckova 2022-09-26 14:03:13 +02:00 committed by GitHub
parent 231de5a32f
commit c0ecdf6783
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 446 additions and 55 deletions

View File

@ -107,8 +107,8 @@ With Loki log browser you can easily navigate through your list of labels and va
In addition to `Run query` button and mode switcher, in builder mode additional elements are available: In addition to `Run query` button and mode switcher, in builder mode additional elements are available:
| Name | Description | | Name | Description |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------- | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| Query patterns | A list of useful operation patterns that can be used to quickly add multiple operations to your query to achieve a specific goal. | | Kick start your query | A list of useful operation patterns that can be used to quickly add multiple operations to your query to achieve a specific goal. |
| Explain | Toggle to show a step by step explanation of all query parts and the operations. | | Explain | Toggle to show a step by step explanation of all query parts and the operations. |
| Raw query | Toggle to show raw query generated by the builder that will be sent to Loki instance. | | Raw query | Toggle to show raw query generated by the builder that will be sent to Loki instance. |
@ -293,6 +293,6 @@ datasources:
type: jaeger type: jaeger
url: http://jaeger-tracing-query:16686/ url: http://jaeger-tracing-query:16686/
access: proxy access: proxy
# UID should match the datasourceUid in dervidedFields. # UID should match the datasourceUid in derivedFields.
uid: my_jaeger_uid uid: my_jaeger_uid
``` ```

View File

@ -45,7 +45,9 @@ describe('Loki query builder', () => {
e2e().contains(dataSourceName).scrollIntoView().should('be.visible').click(); e2e().contains(dataSourceName).scrollIntoView().should('be.visible').click();
// Start in builder mode, click and choose query pattern // Start in builder mode, click and choose query pattern
e2e.components.QueryBuilder.queryPatterns().click().type('Log query with parsing{enter}'); e2e.components.QueryBuilder.queryPatterns().click();
e2e().contains('Log query starters').click();
e2e().contains('Use this query').click();
e2e().contains('No pipeline errors').should('be.visible'); e2e().contains('No pipeline errors').should('be.visible');
e2e().contains('Logfmt').should('be.visible'); e2e().contains('Logfmt').should('be.visible');
e2e().contains('{} | logfmt | __error__=``').should('be.visible'); e2e().contains('{} | logfmt | __error__=``').should('be.visible');

View File

@ -2,7 +2,7 @@ import { LokiAndPromQueryModellerBase } from '../../prometheus/querybuilder/shar
import { QueryBuilderLabelFilter } from '../../prometheus/querybuilder/shared/types'; import { QueryBuilderLabelFilter } from '../../prometheus/querybuilder/shared/types';
import { getOperationDefinitions } from './operations'; import { getOperationDefinitions } from './operations';
import { LokiOperationId, LokiQueryPattern, LokiVisualQueryOperationCategory } from './types'; import { LokiOperationId, LokiQueryPattern, LokiQueryPatternType, LokiVisualQueryOperationCategory } from './types';
export class LokiQueryModeller extends LokiAndPromQueryModellerBase { export class LokiQueryModeller extends LokiAndPromQueryModellerBase {
constructor() { constructor() {
@ -29,7 +29,8 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase {
getQueryPatterns(): LokiQueryPattern[] { getQueryPatterns(): LokiQueryPattern[] {
return [ return [
{ {
name: 'Log query with parsing', name: 'Parse log lines with logfmt parser',
type: LokiQueryPatternType.Log,
// {} | logfmt | __error__=`` // {} | logfmt | __error__=``
operations: [ operations: [
{ id: LokiOperationId.Logfmt, params: [] }, { id: LokiOperationId.Logfmt, params: [] },
@ -37,7 +38,17 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase {
], ],
}, },
{ {
name: 'Log query with filtering and parsing', name: 'Parse log lines with JSON parser',
type: LokiQueryPatternType.Log,
// {} | json | __error__=``
operations: [
{ id: LokiOperationId.Json, params: [] },
{ id: LokiOperationId.LabelFilterNoErrors, params: [] },
],
},
{
name: 'Filter log line and parse with logfmt parser',
type: LokiQueryPatternType.Log,
// {} |= `` | logfmt | __error__=`` // {} |= `` | logfmt | __error__=``
operations: [ operations: [
{ id: LokiOperationId.LineContains, params: [''] }, { id: LokiOperationId.LineContains, params: [''] },
@ -46,7 +57,18 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase {
], ],
}, },
{ {
name: 'Log query with parsing and label filter', name: 'Filter log lines and parse with json parser',
type: LokiQueryPatternType.Log,
// {} |= `` | json | __error__=``
operations: [
{ id: LokiOperationId.LineContains, params: [''] },
{ id: LokiOperationId.Json, params: [] },
{ id: LokiOperationId.LabelFilterNoErrors, params: [] },
],
},
{
name: 'Parse log line with logfmt parser and use label filter',
type: LokiQueryPatternType.Log,
// {} |= `` | logfmt | __error__=`` | label=`value` // {} |= `` | logfmt | __error__=`` | label=`value`
operations: [ operations: [
{ id: LokiOperationId.LineContains, params: [''] }, { id: LokiOperationId.LineContains, params: [''] },
@ -56,7 +78,8 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase {
], ],
}, },
{ {
name: 'Log query with parsing of nested json', name: 'Parse log lines with nested json',
type: LokiQueryPatternType.Log,
// {} |= `` | json | line_format `{{ .message}}` | json // {} |= `` | json | line_format `{{ .message}}` | json
operations: [ operations: [
{ id: LokiOperationId.LineContains, params: [''] }, { id: LokiOperationId.LineContains, params: [''] },
@ -68,7 +91,8 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase {
], ],
}, },
{ {
name: 'Log query with reformatted log line', name: 'Reformat log lines',
type: LokiQueryPatternType.Log,
// {} |= `` | logfmt | line_format `{{.message}}` // {} |= `` | logfmt | line_format `{{.message}}`
operations: [ operations: [
{ id: LokiOperationId.LineContains, params: [''] }, { id: LokiOperationId.LineContains, params: [''] },
@ -78,7 +102,8 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase {
], ],
}, },
{ {
name: 'Log query with mapped log level', name: 'Rename lvl label to level',
type: LokiQueryPatternType.Log,
// {} |= `` | logfmt | label_format level=lvl // {} |= `` | logfmt | label_format level=lvl
operations: [ operations: [
{ id: LokiOperationId.LineContains, params: [''] }, { id: LokiOperationId.LineContains, params: [''] },
@ -88,7 +113,8 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase {
], ],
}, },
{ {
name: 'Metrics query on value inside log line', name: 'Query on value inside a log line',
type: LokiQueryPatternType.Metric,
// sum(sum_over_time({ | logfmt | __error__=`` | unwrap | __error__=`` [$__interval])) // sum(sum_over_time({ | logfmt | __error__=`` | unwrap | __error__=`` [$__interval]))
operations: [ operations: [
{ id: LokiOperationId.LineContains, params: [''] }, { id: LokiOperationId.LineContains, params: [''] },
@ -101,7 +127,8 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase {
], ],
}, },
{ {
name: 'Metrics query for total requests per label of streams', name: 'Total requests per label of streams',
type: LokiQueryPatternType.Metric,
// sum by() (count_over_time({}[$__interval) // sum by() (count_over_time({}[$__interval)
operations: [ operations: [
{ id: LokiOperationId.LineContains, params: [''] }, { id: LokiOperationId.LineContains, params: [''] },
@ -110,7 +137,8 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase {
], ],
}, },
{ {
name: 'Metrics query for total requests per parsed label or label of streams', name: 'Total requests per parsed label or label of streams',
type: LokiQueryPatternType.Metric,
// sum by() (count_over_time({}| logfmt | __error__=`` [$__interval)) // sum by() (count_over_time({}| logfmt | __error__=`` [$__interval))
operations: [ operations: [
{ id: LokiOperationId.LineContains, params: [''] }, { id: LokiOperationId.LineContains, params: [''] },
@ -121,7 +149,8 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase {
], ],
}, },
{ {
name: 'Metrics query for bytes used by log stream', name: 'Bytes used by a log stream',
type: LokiQueryPatternType.Metric,
// bytes_over_time({}[$__interval]) // bytes_over_time({}[$__interval])
operations: [ operations: [
{ id: LokiOperationId.LineContains, params: [''] }, { id: LokiOperationId.LineContains, params: [''] },
@ -129,7 +158,8 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase {
], ],
}, },
{ {
name: 'Metrics query for count of log lines per stream', name: 'Count of log lines per stream',
type: LokiQueryPatternType.Metric,
// count_over_time({}[$__interval]) // count_over_time({}[$__interval])
operations: [ operations: [
{ id: LokiOperationId.LineContains, params: [''] }, { id: LokiOperationId.LineContains, params: [''] },
@ -137,7 +167,8 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase {
], ],
}, },
{ {
name: 'Metrics query for top n results by label or parsed label', name: 'Top N results by label or parsed label',
type: LokiQueryPatternType.Metric,
// topk(10, sum by () (count_over_time({} | logfmt | __error__=`` [$__interval]))) // topk(10, sum by () (count_over_time({} | logfmt | __error__=`` [$__interval])))
operations: [ operations: [
{ id: LokiOperationId.Logfmt, params: [] }, { id: LokiOperationId.Logfmt, params: [] },
@ -148,7 +179,8 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase {
], ],
}, },
{ {
name: 'Metrics query for extracted quantile', name: 'Extracted quantile',
type: LokiQueryPatternType.Metric,
// quantile_over_time(0.5,{} | logfmt | unwrap latency[$__interval]) by () // quantile_over_time(0.5,{} | logfmt | unwrap latency[$__interval]) by ()
operations: [ operations: [
{ id: LokiOperationId.Logfmt, params: [] }, { id: LokiOperationId.Logfmt, params: [] },

View File

@ -1,9 +1,9 @@
import React, { SyntheticEvent, useCallback, useEffect, useState } from 'react'; import React, { SyntheticEvent, useCallback, useEffect, useState } from 'react';
import { CoreApp, LoadingState, SelectableValue } from '@grafana/data'; import { CoreApp, LoadingState } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { reportInteraction } from '@grafana/runtime'; import { reportInteraction } from '@grafana/runtime';
import { Button, ConfirmModal, EditorHeader, EditorRows, FlexItem, InlineSelect, Space } from '@grafana/ui'; import { Button, ConfirmModal, EditorHeader, EditorRows, FlexItem, Space } from '@grafana/ui';
import { QueryEditorModeToggle } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryEditorModeToggle'; import { QueryEditorModeToggle } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryEditorModeToggle';
import { QueryHeaderSwitch } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryHeaderSwitch'; import { QueryHeaderSwitch } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryHeaderSwitch';
import { QueryEditorMode } from 'app/plugins/datasource/prometheus/querybuilder/shared/types'; import { QueryEditorMode } from 'app/plugins/datasource/prometheus/querybuilder/shared/types';
@ -15,18 +15,18 @@ import {
} from '../../../prometheus/querybuilder/shared/hooks/useFlag'; } from '../../../prometheus/querybuilder/shared/hooks/useFlag';
import { LokiQueryEditorProps } from '../../components/types'; import { LokiQueryEditorProps } from '../../components/types';
import { LokiQuery } from '../../types'; import { LokiQuery } from '../../types';
import { lokiQueryModeller } from '../LokiQueryModeller';
import { buildVisualQueryFromString } from '../parsing'; import { buildVisualQueryFromString } from '../parsing';
import { changeEditorMode, getQueryWithDefaults } from '../state'; import { changeEditorMode, getQueryWithDefaults } from '../state';
import { LokiQueryPattern } from '../types';
import { LokiQueryBuilderContainer } from './LokiQueryBuilderContainer'; import { LokiQueryBuilderContainer } from './LokiQueryBuilderContainer';
import { LokiQueryBuilderOptions } from './LokiQueryBuilderOptions'; import { LokiQueryBuilderOptions } from './LokiQueryBuilderOptions';
import { LokiQueryCodeEditor } from './LokiQueryCodeEditor'; import { LokiQueryCodeEditor } from './LokiQueryCodeEditor';
import { QueryPatternsModal } from './QueryPatternsModal';
export const LokiQueryEditorSelector = React.memo<LokiQueryEditorProps>((props) => { export const LokiQueryEditorSelector = React.memo<LokiQueryEditorProps>((props) => {
const { onChange, onRunQuery, data, app } = props; const { onChange, onRunQuery, onAddQuery, data, app, queries } = props;
const [parseModalOpen, setParseModalOpen] = useState(false); const [parseModalOpen, setParseModalOpen] = useState(false);
const [queryPatternsModalOpen, setQueryPatternsModalOpen] = useState(false);
const [dataIsStale, setDataIsStale] = useState(false); const [dataIsStale, setDataIsStale] = useState(false);
const { flag: explain, setFlag: setExplain } = useFlag(lokiQueryEditorExplainKey); const { flag: explain, setFlag: setExplain } = useFlag(lokiQueryEditorExplainKey);
const { flag: rawQuery, setFlag: setRawQuery } = useFlag(lokiQueryEditorRawQueryKey, true); const { flag: rawQuery, setFlag: setRawQuery } = useFlag(lokiQueryEditorRawQueryKey, true);
@ -88,42 +88,35 @@ export const LokiQueryEditorSelector = React.memo<LokiQueryEditorProps>((props)
}} }}
onDismiss={() => setParseModalOpen(false)} onDismiss={() => setParseModalOpen(false)}
/> />
<QueryPatternsModal
isOpen={queryPatternsModalOpen}
onClose={() => setQueryPatternsModalOpen(false)}
query={query}
queries={queries}
app={app}
onChange={onChange}
onAddQuery={onAddQuery}
/>
<EditorHeader> <EditorHeader>
<InlineSelect <Button
value={null} aria-label={selectors.components.QueryBuilder.queryPatterns}
onOpenMenu={() => { variant="secondary"
size="sm"
onClick={() => {
setQueryPatternsModalOpen((prevValue) => !prevValue);
const visualQuery = buildVisualQueryFromString(query.expr || ''); const visualQuery = buildVisualQueryFromString(query.expr || '');
reportInteraction('grafana_loki_query_patterns_opened', { reportInteraction('grafana_loki_query_patterns_opened', {
version: 'v1', version: 'v2',
app: app ?? '', app: app ?? '',
editorMode: query.editorMode, editorMode: query.editorMode,
preSelectedOperationsCount: visualQuery.query.operations.length, preSelectedOperationsCount: visualQuery.query.operations.length,
preSelectedLabelsCount: visualQuery.query.labels.length, preSelectedLabelsCount: visualQuery.query.labels.length,
}); });
}} }}
placeholder="Query patterns" >
aria-label={selectors.components.QueryBuilder.queryPatterns} Kick start your query
allowCustomValue </Button>
onChange={({ value }: SelectableValue<LokiQueryPattern>) => {
const visualQuery = buildVisualQueryFromString(query.expr || '');
reportInteraction('grafana_loki_query_patterns_selected', {
version: 'v1',
app: app ?? '',
editorMode: query.editorMode,
selectedPattern: value?.name,
preSelectedOperationsCount: visualQuery.query.operations.length,
preSelectedLabelsCount: visualQuery.query.labels.length,
});
// Update operations
visualQuery.query.operations = value?.operations!;
onChange({
...query,
expr: lokiQueryModeller.renderQuery(visualQuery.query),
});
}}
options={lokiQueryModeller.getQueryPatterns().map((x) => ({ label: x.name, value: x }))}
/>
<QueryHeaderSwitch label="Explain" value={explain} onChange={onExplainChange} /> <QueryHeaderSwitch label="Explain" value={explain} onChange={onExplainChange} />
{editorMode === QueryEditorMode.Builder && ( {editorMode === QueryEditorMode.Builder && (
<> <>

View File

@ -0,0 +1,109 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Card, useStyles2 } from '@grafana/ui';
import { RawQuery } from 'app/plugins/datasource/prometheus/querybuilder/shared/RawQuery';
import logqlGrammar from '../../syntax';
import { lokiQueryModeller } from '../LokiQueryModeller';
import { LokiQueryPattern } from '../types';
type Props = {
pattern: LokiQueryPattern;
hasNewQueryOption: boolean;
hasPreviousQuery: boolean;
selectedPatternName: string | null;
setSelectedPatternName: (name: string | null) => void;
onPatternSelect: (pattern: LokiQueryPattern, selectAsNewQuery?: boolean) => void;
};
export const QueryPattern = (props: Props) => {
const { pattern, onPatternSelect, hasNewQueryOption, hasPreviousQuery, selectedPatternName, setSelectedPatternName } =
props;
const styles = useStyles2(getStyles);
const lang = { grammar: logqlGrammar, name: 'logql' };
return (
<Card className={styles.card}>
<Card.Heading>{pattern.name}</Card.Heading>
<div className={styles.rawQueryContainer}>
<RawQuery
query={lokiQueryModeller.renderQuery({ labels: [], operations: pattern.operations })}
lang={lang}
className={styles.rawQuery}
/>
</div>
<Card.Actions>
{selectedPatternName !== pattern.name ? (
<Button
size="sm"
onClick={() => {
if (hasPreviousQuery) {
// If user has previous query, we need to confirm that they want to replace it
setSelectedPatternName(pattern.name);
} else {
onPatternSelect(pattern);
}
}}
>
Use this query
</Button>
) : (
<>
<div className={styles.spacing}>
{`If you would like to use this query, ${
hasNewQueryOption
? 'you can either replace your current query or create a new query'
: 'your current query will be replaced'
}.`}
</div>
<Button size="sm" fill="outline" onClick={() => setSelectedPatternName(null)}>
Back
</Button>
<Button
size="sm"
onClick={() => {
onPatternSelect(pattern);
}}
>
Replace query
</Button>
{hasNewQueryOption && (
<Button
size="sm"
onClick={() => {
onPatternSelect(pattern, true);
}}
>
Create new query
</Button>
)}
</>
)}
</Card.Actions>
</Card>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
card: css`
width: 49.5%;
display: flex;
flex-direction: column;
`,
rawQueryContainer: css`
flex-grow: 1;
`,
rawQuery: css`
background-color: ${theme.colors.background.primary};
padding: ${theme.spacing(1)};
margin-top: ${theme.spacing(1)};
`,
spacing: css`
margin-bottom: ${theme.spacing(1)};
`,
};
};

View File

@ -0,0 +1,124 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { lokiQueryModeller } from '../LokiQueryModeller';
import { LokiQueryPatternType } from '../types';
import { QueryPatternsModal } from './QueryPatternsModal';
// don't care about interaction tracking in our unit tests
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
reportInteraction: jest.fn(),
}));
const defaultProps = {
isOpen: true,
onClose: jest.fn(),
onChange: jest.fn(),
onAddQuery: jest.fn(),
query: {
refId: 'A',
expr: '{label1="foo", label2="bar"} |= "baz" |~ "qux"',
},
queries: [
{
refId: 'A',
expr: '{label1="foo", label2="bar"}',
},
],
};
const queryPatterns = {
logQueryPatterns: lokiQueryModeller.getQueryPatterns().filter((pattern) => pattern.type === LokiQueryPatternType.Log),
metricQueryPatterns: lokiQueryModeller
.getQueryPatterns()
.filter((pattern) => pattern.type === LokiQueryPatternType.Metric),
};
describe('QueryPatternsModal', () => {
it('renders the modal', () => {
render(<QueryPatternsModal {...defaultProps} />);
expect(screen.getByText('Kick start your query')).toBeInTheDocument();
});
it('renders collapsible elements with all query pattern types', () => {
render(<QueryPatternsModal {...defaultProps} />);
Object.values(LokiQueryPatternType).forEach((pattern) => {
expect(screen.getByText(new RegExp(`${pattern} query starters`, 'i'))).toBeInTheDocument();
});
});
it('can open and close query patterns section', async () => {
render(<QueryPatternsModal {...defaultProps} />);
await userEvent.click(screen.getByText('Log query starters'));
expect(screen.getByText(queryPatterns.logQueryPatterns[0].name)).toBeInTheDocument();
await userEvent.click(screen.getByText('Log query starters'));
expect(screen.queryByText(queryPatterns.logQueryPatterns[0].name)).not.toBeInTheDocument();
});
it('can open and close multiple query patterns section', async () => {
render(<QueryPatternsModal {...defaultProps} />);
await userEvent.click(screen.getByText('Log query starters'));
expect(screen.getByText(queryPatterns.logQueryPatterns[0].name)).toBeInTheDocument();
await userEvent.click(screen.getByText('Metric query starters'));
expect(screen.getByText(queryPatterns.metricQueryPatterns[0].name)).toBeInTheDocument();
await userEvent.click(screen.getByText('Log query starters'));
expect(screen.queryByText(queryPatterns.logQueryPatterns[0].name)).not.toBeInTheDocument();
// Metric patterns should still be open
expect(screen.getByText(queryPatterns.metricQueryPatterns[0].name)).toBeInTheDocument();
});
it('uses pattern if there is no existing query', async () => {
render(<QueryPatternsModal {...defaultProps} query={{ expr: '{job="grafana"}', refId: 'A' }} />);
await userEvent.click(screen.getByText('Log query starters'));
expect(screen.getByText(queryPatterns.logQueryPatterns[0].name)).toBeInTheDocument();
const firstUseQueryButton = screen.getAllByRole('button', { name: 'Use this query' })[0];
await userEvent.click(firstUseQueryButton);
await waitFor(() => {
expect(defaultProps.onChange).toHaveBeenCalledWith({
expr: '{job="grafana"} | logfmt | __error__=``',
refId: 'A',
});
});
});
it('gives warning when selecting pattern if there is already existing query', async () => {
render(<QueryPatternsModal {...defaultProps} />);
await userEvent.click(screen.getByText('Log query starters'));
expect(screen.getByText(queryPatterns.logQueryPatterns[0].name)).toBeInTheDocument();
const firstUseQueryButton = screen.getAllByRole('button', { name: 'Use this query' })[0];
await userEvent.click(firstUseQueryButton);
expect(screen.getByText(/replace your current query or create a new query/)).toBeInTheDocument();
});
it('can use create new query when selecting pattern if there is already existing query', async () => {
render(<QueryPatternsModal {...defaultProps} />);
await userEvent.click(screen.getByText('Log query starters'));
expect(screen.getByText(queryPatterns.logQueryPatterns[0].name)).toBeInTheDocument();
const firstUseQueryButton = screen.getAllByRole('button', { name: 'Use this query' })[0];
await userEvent.click(firstUseQueryButton);
const createNewQueryButton = screen.getByRole('button', { name: 'Create new query' });
expect(createNewQueryButton).toBeInTheDocument();
await userEvent.click(createNewQueryButton);
await waitFor(() => {
expect(defaultProps.onAddQuery).toHaveBeenCalledWith({
expr: '{} | logfmt | __error__=``',
refId: 'B',
});
});
});
it('does not show create new query option if onAddQuery function is not provided ', async () => {
render(<QueryPatternsModal {...defaultProps} onAddQuery={undefined} />);
await userEvent.click(screen.getByText('Log query starters'));
expect(screen.getByText(queryPatterns.logQueryPatterns[0].name)).toBeInTheDocument();
const useQueryButton = screen.getAllByRole('button', { name: 'Use this query' })[0];
await userEvent.click(useQueryButton);
expect(screen.queryByRole('button', { name: 'Create new query' })).not.toBeInTheDocument();
expect(screen.getByText(/your current query will be replaced/)).toBeInTheDocument();
});
});

View File

@ -0,0 +1,125 @@
import { css } from '@emotion/css';
import { capitalize } from 'lodash';
import React, { useMemo, useState } from 'react';
import { CoreApp, DataQuery, GrafanaTheme2 } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { Button, Collapse, Modal, useStyles2 } from '@grafana/ui';
import { getNextRefIdChar } from 'app/core/utils/query';
import { LokiQuery } from '../../types';
import { lokiQueryModeller } from '../LokiQueryModeller';
import { buildVisualQueryFromString } from '../parsing';
import { LokiQueryPattern, LokiQueryPatternType } from '../types';
import { QueryPattern } from './QueryPattern';
type Props = {
isOpen: boolean;
query: LokiQuery;
queries: DataQuery[] | undefined;
app?: CoreApp;
onClose: () => void;
onChange: (query: LokiQuery) => void;
onAddQuery?: (query: LokiQuery) => void;
};
export const QueryPatternsModal = (props: Props) => {
const { isOpen, onClose, onChange, onAddQuery, query, queries, app } = props;
const [openTabs, setOpenTabs] = useState<string[]>([]);
const [selectedPatternName, setSelectedPatternName] = useState<string | null>(null);
const styles = useStyles2(getStyles);
const hasNewQueryOption = !!onAddQuery;
const hasPreviousQuery = useMemo(
() => buildVisualQueryFromString(query.expr).query.operations.length > 0,
[query.expr]
);
const onPatternSelect = (pattern: LokiQueryPattern, selectAsNewQuery = false) => {
const visualQuery = buildVisualQueryFromString(selectAsNewQuery ? '' : query.expr);
reportInteraction('grafana_loki_query_patterns_selected', {
version: 'v2',
app: app ?? '',
editorMode: query.editorMode,
selectedPattern: pattern.name,
preSelectedOperationsCount: visualQuery.query.operations.length,
preSelectedLabelsCount: visualQuery.query.labels.length,
createNewQuery: hasNewQueryOption && selectAsNewQuery,
});
visualQuery.query.operations = pattern.operations;
if (hasNewQueryOption && selectAsNewQuery) {
onAddQuery({
...query,
refId: getNextRefIdChar(queries ?? [query]),
expr: lokiQueryModeller.renderQuery(visualQuery.query),
});
} else {
onChange({
...query,
expr: lokiQueryModeller.renderQuery(visualQuery.query),
});
}
setSelectedPatternName(null);
onClose();
};
return (
<Modal isOpen={isOpen} title="Kick start your query" onDismiss={onClose}>
<div className={styles.spacing}>
Kick start your query by selecting one of these queries. You can then continue to complete your query.
</div>
{Object.values(LokiQueryPatternType).map((patternType) => {
return (
<Collapse
key={patternType}
label={`${capitalize(patternType)} query starters`}
isOpen={openTabs.includes(patternType)}
collapsible={true}
onToggle={() =>
setOpenTabs((tabs) =>
// close tab if it's already open, otherwise open it
tabs.includes(patternType) ? tabs.filter((t) => t !== patternType) : [...tabs, patternType]
)
}
>
<div className={styles.cardsContainer}>
{lokiQueryModeller
.getQueryPatterns()
.filter((pattern) => pattern.type === patternType)
.map((pattern) => (
<QueryPattern
key={pattern.name}
pattern={pattern}
hasNewQueryOption={hasNewQueryOption}
hasPreviousQuery={hasPreviousQuery}
onPatternSelect={onPatternSelect}
selectedPatternName={selectedPatternName}
setSelectedPatternName={setSelectedPatternName}
/>
))}
</div>
</Collapse>
);
})}
<Button variant="secondary" onClick={onClose}>
Close
</Button>
</Modal>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
cardsContainer: css`
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
`,
spacing: css`
margin-bottom: ${theme.spacing(1)};
`,
};
};

View File

@ -11,10 +11,15 @@ export interface LokiVisualQuery {
} }
export type LokiVisualQueryBinary = VisualQueryBinary<LokiVisualQuery>; export type LokiVisualQueryBinary = VisualQueryBinary<LokiVisualQuery>;
export enum LokiQueryPatternType {
Log = 'log',
Metric = 'metric',
}
export interface LokiQueryPattern { export interface LokiQueryPattern {
name: string; name: string;
operations: QueryBuilderOperation[]; operations: QueryBuilderOperation[];
type: LokiQueryPatternType;
} }
export enum LokiVisualQueryOperationCategory { export enum LokiVisualQueryOperationCategory {

View File

@ -11,15 +11,16 @@ export interface Props {
grammar: Grammar; grammar: Grammar;
name: string; name: string;
}; };
className?: string;
} }
export function RawQuery({ query, lang }: Props) { export function RawQuery({ query, lang, className }: Props) {
const theme = useTheme2(); const theme = useTheme2();
const styles = getStyles(theme); const styles = getStyles(theme);
const highlighted = Prism.highlight(query, lang.grammar, lang.name); const highlighted = Prism.highlight(query, lang.grammar, lang.name);
return ( return (
<div <div
className={cx(styles.editorField, 'prism-syntax-highlight')} className={cx(styles.editorField, 'prism-syntax-highlight', className)}
aria-label="selector" aria-label="selector"
dangerouslySetInnerHTML={{ __html: highlighted }} dangerouslySetInnerHTML={{ __html: highlighted }}
/> />