From 872df59de5a93791cd6ccc4eab770055f987f9c6 Mon Sep 17 00:00:00 2001 From: Brendan O'Handley Date: Tue, 17 Jan 2023 20:04:50 -0500 Subject: [PATCH] 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 --- .../prometheus/query-editor/index.md | 16 +- .../querybuilder/PromQueryModeller.ts | 29 +++- .../prometheus/querybuilder/QueryPattern.tsx | 118 +++++++++++++++ .../querybuilder/QueryPatternsModal.test.tsx | 143 ++++++++++++++++++ .../querybuilder/QueryPatternsModal.tsx | 132 ++++++++++++++++ .../components/PromQueryEditorSelector.tsx | 42 ++--- .../prometheus/querybuilder/types.ts | 8 + 7 files changed, 456 insertions(+), 32 deletions(-) create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/QueryPattern.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/QueryPatternsModal.test.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/QueryPatternsModal.tsx diff --git a/docs/sources/datasources/prometheus/query-editor/index.md b/docs/sources/datasources/prometheus/query-editor/index.md index 770a1dc387c..6a9e6dc2d27 100644 --- a/docs/sources/datasources/prometheus/query-editor/index.md +++ b/docs/sources/datasources/prometheus/query-editor/index.md @@ -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. diff --git a/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.ts b/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.ts index b205620ea76..894ba907ed1 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.ts @@ -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: [] }, + ], + }, + }, + ], + }, ]; } } diff --git a/public/app/plugins/datasource/prometheus/querybuilder/QueryPattern.tsx b/public/app/plugins/datasource/prometheus/querybuilder/QueryPattern.tsx new file mode 100644 index 00000000000..3d3b002dbca --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/QueryPattern.tsx @@ -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 ( + + {pattern.name} +
+ +
+ + {selectedPatternName !== pattern.name ? ( + + ) : ( + <> +
+ {`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' + }.`} +
+ + + {hasNewQueryOption && ( + + )} + + )} +
+
+ ); +}; + +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)}; + `, + }; +}; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/QueryPatternsModal.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/QueryPatternsModal.test.tsx new file mode 100644 index 00000000000..a8a2b3c9e02 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/QueryPatternsModal.test.tsx @@ -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(); + expect(screen.getByText('Kick start your query')).toBeInTheDocument(); + }); + it('renders collapsible elements with all query pattern types', () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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', + }); + }); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/QueryPatternsModal.tsx b/public/app/plugins/datasource/prometheus/querybuilder/QueryPatternsModal.tsx new file mode 100644 index 00000000000..f604bc1b235 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/QueryPatternsModal.tsx @@ -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([]); + const [selectedPatternName, setSelectedPatternName] = useState(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 ( + +
+ Kick start your query by selecting one of these queries. You can then continue to complete your query. +
+ {Object.values(PromQueryPatternType).map((patternType) => { + return ( + + setOpenTabs((tabs) => + // close tab if it's already open, otherwise open it + tabs.includes(patternType) ? tabs.filter((t) => t !== patternType) : [...tabs, patternType] + ) + } + > +
+ {promQueryModeller + .getQueryPatterns() + .filter((pattern) => pattern.type === patternType) + .map((pattern) => ( + + ))} +
+
+ ); + })} + +
+ ); +}; + +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)}; + `, + }; +}; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx index 768621924c0..1057c9ebcd4 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx @@ -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) => { 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) => { }} onDismiss={() => setParseModalOpen(false)} /> + setQueryPatternsModalOpen(false)} + query={query} + queries={queries} + app={app} + onChange={onChange} + onAddQuery={onAddQuery} + /> - { - // 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 }))} - /> + {app !== CoreApp.Explore && app !== CoreApp.Correlations && ( diff --git a/public/app/plugins/datasource/prometheus/querybuilder/types.ts b/public/app/plugins/datasource/prometheus/querybuilder/types.ts index 07a9a74e937..5570e025a7e 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/types.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/types.ts @@ -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[]; }