Prometheus: Kickstart your query, formerly query patterns (#60718)

* add query pattern prom types

* rebase main

* update docs

replace query patterns with kick start your query

* update docs, remove raw query from prom docs

Raw query toggle was removed from the code

* add binary query pattern to query patterns

* add aria labels for accessibility

* fix tests

* apply/create a query pattern behavior if anything exists in the query editor

* fix tests

* rebase main
This commit is contained in:
Brendan O'Handley 2023-01-17 20:04:50 -05:00 committed by GitHub
parent b5fa9e3501
commit 872df59de5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 456 additions and 32 deletions

View File

@ -147,11 +147,10 @@ This video demonstrates how to use the visual Prometheus query builder available
In addition to the **Run query** button and mode switcher, Builder mode includes additional elements:
| Name | Description |
| ------------------ | ----------------------------------------------------------------------------------------- |
| **Query patterns** | A list of operation patterns that help you quickly add multiple operations to your query. |
| **Explain** | Displays a step-by-step explanation of all query parts and its operations. |
| **Raw query** | Displays the raw query generated by the Builder that will be sent to Prometheus instance. |
| Name | Description |
| ------------------------- | ----------------------------------------------------------------------------------------- |
| **Kick start your query** | A list of operation patterns that help you quickly add multiple operations to your query. |
| **Explain** | Displays a step-by-step explanation of all query parts and its operations. |
### Metric and labels
@ -203,13 +202,6 @@ To add the operations to your query, click the hint.
Explain mode helps you understand a query by displaying a step-by-step explanation of all query components and operations.
### Raw query
{{< figure src="/static/img/docs/prometheus/raw-query-8-5.png" max-width="500px" class="docs-image--no-shadow" caption="Raw query" >}}
The query editor displays the raw query only if the **Raw query** switch from the query editor toolbar is enabled.
If visible, it displays the raw query that the query editor has created.
### Additional options
In addition to these Builder mode-specific options, the query editor also displays the options it shares in common with Code mode.

View File

@ -3,7 +3,7 @@ import { FUNCTIONS } from '../promql';
import { getAggregationOperations } from './aggregations';
import { getOperationDefinitions } from './operations';
import { LokiAndPromQueryModellerBase } from './shared/LokiAndPromQueryModellerBase';
import { PromQueryPattern, PromVisualQueryOperationCategory } from './types';
import { PromQueryPattern, PromQueryPatternType, PromVisualQueryOperationCategory } from './types';
export class PromQueryModeller extends LokiAndPromQueryModellerBase {
constructor() {
@ -32,6 +32,7 @@ export class PromQueryModeller extends LokiAndPromQueryModellerBase {
return [
{
name: 'Rate then sum',
type: PromQueryPatternType.Rate,
operations: [
{ id: 'rate', params: ['$__rate_interval'] },
{ id: 'sum', params: [] },
@ -39,6 +40,7 @@ export class PromQueryModeller extends LokiAndPromQueryModellerBase {
},
{
name: 'Rate then sum by(label) then avg',
type: PromQueryPatternType.Rate,
operations: [
{ id: 'rate', params: ['$__rate_interval'] },
{ id: '__sum_by', params: [''] },
@ -47,6 +49,7 @@ export class PromQueryModeller extends LokiAndPromQueryModellerBase {
},
{
name: 'Histogram quantile on rate',
type: PromQueryPatternType.Histogram,
operations: [
{ id: 'rate', params: ['$__rate_interval'] },
{ id: '__sum_by', params: ['le'] },
@ -54,13 +57,35 @@ export class PromQueryModeller extends LokiAndPromQueryModellerBase {
],
},
{
name: 'Histogram quantile on increase ',
name: 'Histogram quantile on increase',
type: PromQueryPatternType.Histogram,
operations: [
{ id: 'increase', params: ['$__rate_interval'] },
{ id: '__max_by', params: ['le'] },
{ id: 'histogram_quantile', params: [0.95] },
],
},
{
name: 'Binary Query',
type: PromQueryPatternType.Binary,
operations: [
{ id: 'rate', params: ['$__rate_interval'] },
{ id: 'sum', params: [] },
],
binaryQueries: [
{
operator: '/',
query: {
metric: '',
labels: [],
operations: [
{ id: 'rate', params: ['$__rate_interval'] },
{ id: 'sum', params: [] },
],
},
},
],
},
];
}
}

View File

@ -0,0 +1,118 @@
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 promqlGrammar from '../promql';
import { promQueryModeller } from './PromQueryModeller';
import { PromQueryPattern } from './types';
type Props = {
pattern: PromQueryPattern;
hasNewQueryOption: boolean;
hasPreviousQuery: boolean | string;
selectedPatternName: string | null;
setSelectedPatternName: (name: string | null) => void;
onPatternSelect: (pattern: PromQueryPattern, selectAsNewQuery?: boolean) => void;
};
export const QueryPattern = (props: Props) => {
const { pattern, onPatternSelect, hasNewQueryOption, hasPreviousQuery, selectedPatternName, setSelectedPatternName } =
props;
const styles = useStyles2(getStyles);
const lang = { grammar: promqlGrammar, name: 'promql' };
return (
<Card className={styles.card}>
<Card.Heading>{pattern.name}</Card.Heading>
<div className={styles.rawQueryContainer}>
<RawQuery
aria-label={`${pattern.name} raw query`}
query={promQueryModeller.renderQuery({
labels: [],
operations: pattern.operations,
binaryQueries: pattern.binaryQueries,
})}
lang={lang}
className={styles.rawQuery}
/>
</div>
<Card.Actions>
{selectedPatternName !== pattern.name ? (
<Button
size="sm"
aria-label="use this query button"
onClick={() => {
if (hasPreviousQuery) {
// If user has previous query, we need to confirm that they want to apply this query pattern
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 apply this query pattern or create a new query'
: 'this query pattern will be applied to your current query'
}.`}
</div>
<Button size="sm" aria-label="back button" fill="outline" onClick={() => setSelectedPatternName(null)}>
Back
</Button>
<Button
size="sm"
aria-label="apply query starter button"
onClick={() => {
onPatternSelect(pattern);
}}
>
Apply query
</Button>
{hasNewQueryOption && (
<Button
size="sm"
aria-label="create new query button"
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,143 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { promQueryModeller } from './PromQueryModeller';
import { QueryPatternsModal } from './QueryPatternsModal';
import { PromQueryPatternType } from './types';
// 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: 'sum(rate({job="grafana"}[$__rate_interval]))',
},
queries: [
{
refId: 'A',
expr: 'go_goroutines{instance="localhost:9090"}',
},
],
};
const queryPatterns = {
rateQueryPatterns: promQueryModeller
.getQueryPatterns()
.filter((pattern) => pattern.type === PromQueryPatternType.Rate),
histogramQueryPatterns: promQueryModeller
.getQueryPatterns()
.filter((pattern) => pattern.type === PromQueryPatternType.Histogram),
binaryQueryPatterns: promQueryModeller
.getQueryPatterns()
.filter((pattern) => pattern.type === PromQueryPatternType.Binary),
};
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(PromQueryPatternType).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('Rate query starters'));
expect(screen.getByText(queryPatterns.rateQueryPatterns[0].name)).toBeInTheDocument();
await userEvent.click(screen.getByText('Rate query starters'));
expect(screen.queryByText(queryPatterns.rateQueryPatterns[0].name)).not.toBeInTheDocument();
});
it('can open and close multiple query patterns section', async () => {
render(<QueryPatternsModal {...defaultProps} />);
await userEvent.click(screen.getByText('Rate query starters'));
expect(screen.getByText(queryPatterns.rateQueryPatterns[0].name)).toBeInTheDocument();
await userEvent.click(screen.getByText('Histogram query starters'));
expect(screen.getByText(queryPatterns.histogramQueryPatterns[0].name)).toBeInTheDocument();
await userEvent.click(screen.getByText('Rate query starters'));
expect(screen.queryByText(queryPatterns.rateQueryPatterns[0].name)).not.toBeInTheDocument();
// Histogram patterns should still be open
expect(screen.getByText(queryPatterns.histogramQueryPatterns[0].name)).toBeInTheDocument();
});
it('uses pattern if there is no existing query', async () => {
render(<QueryPatternsModal {...defaultProps} query={{ expr: '', refId: 'A' }} />);
await userEvent.click(screen.getByText('Rate query starters'));
expect(screen.getByText(queryPatterns.rateQueryPatterns[0].name)).toBeInTheDocument();
const firstUseQueryButton = screen.getAllByRole('button', { name: 'use this query button' })[0];
await userEvent.click(firstUseQueryButton);
await waitFor(() => {
expect(defaultProps.onChange).toHaveBeenCalledWith({
expr: 'sum(rate([$__rate_interval]))',
refId: 'A',
});
});
});
it('gives warning when selecting pattern if there are already existing query', async () => {
render(<QueryPatternsModal {...defaultProps} />);
await userEvent.click(screen.getByText('Rate query starters'));
expect(screen.getByText(queryPatterns.rateQueryPatterns[0].name)).toBeInTheDocument();
const firstUseQueryButton = screen.getAllByRole('button', { name: 'use this query button' })[0];
await userEvent.click(firstUseQueryButton);
expect(screen.getByText(/you can either apply this query pattern 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('Rate query starters'));
expect(screen.getByText(queryPatterns.rateQueryPatterns[0].name)).toBeInTheDocument();
const firstUseQueryButton = screen.getAllByRole('button', { name: 'use this query button' })[0];
await userEvent.click(firstUseQueryButton);
const createNewQueryButton = screen.getByRole('button', { name: 'create new query button' });
expect(createNewQueryButton).toBeInTheDocument();
await userEvent.click(createNewQueryButton);
await waitFor(() => {
expect(defaultProps.onAddQuery).toHaveBeenCalledWith({
expr: 'sum(rate([$__rate_interval]))',
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('Rate query starters'));
expect(screen.getByText(queryPatterns.rateQueryPatterns[0].name)).toBeInTheDocument();
const useQueryButton = screen.getAllByRole('button', { name: 'use this query button' })[0];
await userEvent.click(useQueryButton);
expect(screen.queryByRole('button', { name: 'Create new query' })).not.toBeInTheDocument();
expect(screen.getByText(/this query pattern will be applied to your current query/)).toBeInTheDocument();
});
it('applies binary query patterns to query', async () => {
render(<QueryPatternsModal {...defaultProps} query={{ expr: '', refId: 'A' }} />);
await userEvent.click(screen.getByText('Binary query starters'));
expect(screen.getByText(queryPatterns.binaryQueryPatterns[0].name)).toBeInTheDocument();
const firstUseQueryButton = screen.getAllByRole('button', { name: 'use this query button' })[0];
await userEvent.click(firstUseQueryButton);
await waitFor(() => {
expect(defaultProps.onChange).toHaveBeenCalledWith({
expr: 'sum(rate([$__rate_interval])) / sum(rate([$__rate_interval]))',
refId: 'A',
});
});
});
});

View File

@ -0,0 +1,132 @@
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 { PromQuery } from '../types';
import { promQueryModeller } from './PromQueryModeller';
import { QueryPattern } from './QueryPattern';
import { buildVisualQueryFromString } from './parsing';
import { PromQueryPattern, PromQueryPatternType } from './types';
type Props = {
isOpen: boolean;
query: PromQuery;
queries: DataQuery[] | undefined;
app?: CoreApp;
onClose: () => void;
onChange: (query: PromQuery) => void;
onAddQuery?: (query: PromQuery) => 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(() => {
const visualQuery = buildVisualQueryFromString(query.expr);
// has anything entered in the query, metric, labels, operations, or binary queries
const hasOperations = visualQuery.query.operations.length > 0,
hasMetric = visualQuery.query.metric,
hasLabels = visualQuery.query.labels.length > 0,
hasBinaryQueries = visualQuery.query.binaryQueries ? visualQuery.query.binaryQueries.length > 0 : false;
return hasOperations || hasMetric || hasLabels || hasBinaryQueries;
}, [query.expr]);
const onPatternSelect = (pattern: PromQueryPattern, selectAsNewQuery = false) => {
const visualQuery = buildVisualQueryFromString(selectAsNewQuery ? '' : query.expr);
reportInteraction('grafana_prom_kickstart_your_query_selected', {
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;
visualQuery.query.binaryQueries = pattern.binaryQueries;
if (hasNewQueryOption && selectAsNewQuery) {
onAddQuery({
...query,
refId: getNextRefIdChar(queries ?? [query]),
expr: promQueryModeller.renderQuery(visualQuery.query),
});
} else {
onChange({
...query,
expr: promQueryModeller.renderQuery(visualQuery.query),
});
}
setSelectedPatternName(null);
onClose();
};
return (
<Modal aria-label="Kick start your query 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(PromQueryPatternType).map((patternType) => {
return (
<Collapse
aria-label={`open and close ${patternType} query starter card`}
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}>
{promQueryModeller
.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 aria-label="close kick start your query modal" 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

@ -2,13 +2,14 @@ import { map } from 'lodash';
import React, { SyntheticEvent, useCallback, useEffect, useState } from 'react';
import { CoreApp, LoadingState, SelectableValue } from '@grafana/data';
import { EditorHeader, EditorRows, FlexItem, InlineSelect, Space } from '@grafana/experimental';
import { selectors } from '@grafana/e2e-selectors';
import { EditorHeader, EditorRows, FlexItem, Space } from '@grafana/experimental';
import { reportInteraction } from '@grafana/runtime';
import { Button, ConfirmModal } from '@grafana/ui';
import { PromQueryEditorProps } from '../../components/types';
import { PromQuery } from '../../types';
import { promQueryModeller } from '../PromQueryModeller';
import { QueryPatternsModal } from '../QueryPatternsModal';
import { buildVisualQueryFromString } from '../parsing';
import { QueryEditorModeToggle } from '../shared/QueryEditorModeToggle';
import { QueryHeaderSwitch } from '../shared/QueryHeaderSwitch';
@ -39,9 +40,13 @@ export const PromQueryEditorSelector = React.memo<Props>((props) => {
onRunQuery,
data,
app,
onAddQuery,
datasource: { defaultEditor },
queries,
} = props;
const [parseModalOpen, setParseModalOpen] = useState(false);
const [queryPatternsModalOpen, setQueryPatternsModalOpen] = useState(false);
const [dataIsStale, setDataIsStale] = useState(false);
const { flag: explain, setFlag: setExplain } = useFlag(promQueryEditorExplainKey);
@ -97,23 +102,24 @@ export const PromQueryEditorSelector = React.memo<Props>((props) => {
}}
onDismiss={() => setParseModalOpen(false)}
/>
<QueryPatternsModal
isOpen={queryPatternsModalOpen}
onClose={() => setQueryPatternsModalOpen(false)}
query={query}
queries={queries}
app={app}
onChange={onChange}
onAddQuery={onAddQuery}
/>
<EditorHeader>
<InlineSelect
value={null}
placeholder="Query patterns"
allowCustomValue
onChange={({ value }) => {
// TODO: Bit convoluted as we don't have access to visualQuery model here. Maybe would make sense to
// move it inside the editor?
const result = buildVisualQueryFromString(query.expr || '');
result.query.operations = value?.operations!;
onChange({
...query,
expr: promQueryModeller.renderQuery(result.query),
});
}}
options={promQueryModeller.getQueryPatterns().map((x) => ({ label: x.name, value: x }))}
/>
<Button
aria-label={selectors.components.QueryBuilder.queryPatterns}
variant="secondary"
size="sm"
onClick={() => setQueryPatternsModalOpen((prevValue) => !prevValue)}
>
Kick start your query
</Button>
<QueryHeaderSwitch label="Explain" value={explain} onChange={onShowExplainChange} />
<FlexItem grow={1} />
{app !== CoreApp.Explore && app !== CoreApp.Correlations && (

View File

@ -119,7 +119,15 @@ export enum PromOperationId {
LessOrEqual = '__less_or_equal',
}
export enum PromQueryPatternType {
Rate = 'rate',
Histogram = 'histogram',
Binary = 'binary',
}
export interface PromQueryPattern {
name: string;
operations: QueryBuilderOperation[];
type: PromQueryPatternType;
binaryQueries?: PromVisualQueryBinary[];
}