From 64e1e91403c88ce98d9a6477fccc779d3d2930eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 31 Jan 2022 07:57:14 +0100 Subject: [PATCH] PromQueryBuilder: Query builder and components that can be shared with a loki query builder and others (#42854) --- .../ButtonCascader/ButtonCascader.tsx | 27 +- .../ButtonCascader/_ButtonCascader.scss | 11 +- .../components/Forms/Legacy/Select/Select.tsx | 2 +- .../grafana-ui/src/components/Select/types.ts | 2 + .../src/themes/_variables.dark.scss.tmpl.ts | 3 + .../src/themes/_variables.light.scss.tmpl.ts | 3 + public/app/angular/components/query_part.ts | 6 +- .../query/components/QueryEditorRow.tsx | 2 +- .../loki/components/LokiQueryEditorByApp.tsx | 5 + public/app/plugins/datasource/loki/module.ts | 3 +- .../querybuilder/LokiQueryModeller.test.ts | 188 +++++++++++ .../loki/querybuilder/LokiQueryModeller.ts | 61 ++++ .../components/LokiQueryBuilder.tsx | 80 +++++ .../components/LokiQueryBuilderExplaind.tsx | 24 ++ .../components/LokiQueryEditorSelector.tsx | 105 ++++++ .../querybuilder/components/QueryPreview.tsx | 41 +++ .../loki/querybuilder/operations.ts | 300 ++++++++++++++++++ .../datasource/loki/querybuilder/types.ts | 58 ++++ public/app/plugins/datasource/loki/syntax.ts | 7 +- public/app/plugins/datasource/loki/types.ts | 10 +- .../components/PromQueryEditorByApp.tsx | 5 + .../datasource/prometheus/datasource.ts | 5 +- .../prometheus/language_provider.mock.ts | 15 + .../plugins/datasource/prometheus/module.ts | 3 +- .../plugins/datasource/prometheus/promql.ts | 4 +- .../querybuilder/PromQueryModeller.test.ts | 200 ++++++++++++ .../querybuilder/PromQueryModeller.ts | 72 +++++ .../prometheus/querybuilder/aggregations.ts | 177 +++++++++++ .../components/LabelParamEditor.tsx | 49 +++ .../querybuilder/components/MetricSelect.tsx | 58 ++++ .../querybuilder/components/NestedQuery.tsx | 104 ++++++ .../components/NestedQueryList.tsx | 73 +++++ .../components/PromQueryBuilder.test.tsx | 153 +++++++++ .../components/PromQueryBuilder.tsx | 105 ++++++ .../components/PromQueryBuilderContext.tsx | 12 + .../components/PromQueryBuilderExplained.tsx | 24 ++ .../PromQueryEditorSelector.test.tsx | 150 +++++++++ .../components/PromQueryEditorSelector.tsx | 124 ++++++++ .../querybuilder/components/QueryPreview.tsx | 41 +++ .../prometheus/querybuilder/operations.ts | 176 ++++++++++ .../querybuilder/shared/LabelFilterItem.tsx | 123 +++++++ .../querybuilder/shared/LabelFilters.test.tsx | 65 ++++ .../querybuilder/shared/LabelFilters.tsx | 50 +++ .../shared/LokiAndPromQueryModellerBase.ts | 88 +++++ .../querybuilder/shared/OperationEditor.tsx | 260 +++++++++++++++ .../shared/OperationExplainedBox.tsx | 75 +++++ .../shared/OperationInfoButton.tsx | 102 ++++++ .../shared/OperationList.test.tsx | 95 ++++++ .../querybuilder/shared/OperationList.tsx | 128 ++++++++ .../shared/OperationListExplained.tsx | 24 ++ .../querybuilder/shared/OperationName.tsx | 92 ++++++ .../shared/OperationParamEditor.tsx | 53 ++++ .../shared/OperationsEditorRow.tsx | 29 ++ .../shared/QueryEditorModeToggle.tsx | 18 ++ .../querybuilder/shared/operationUtils.ts | 51 +++ .../prometheus/querybuilder/shared/types.ts | 97 ++++++ .../prometheus/querybuilder/testUtils.ts | 10 + .../prometheus/querybuilder/types.ts | 36 +++ .../plugins/datasource/prometheus/types.ts | 5 + .../plugins/datasource/zipkin/QueryField.tsx | 21 +- public/sass/_variables.dark.generated.scss | 3 + public/sass/_variables.light.generated.scss | 3 + public/sass/components/_gf-form.scss | 2 +- public/sass/components/_query_editor.scss | 4 +- public/sass/components/_slate_editor.scss | 3 +- 65 files changed, 3884 insertions(+), 41 deletions(-) create mode 100644 public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.test.ts create mode 100644 public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.ts create mode 100644 public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx create mode 100644 public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderExplaind.tsx create mode 100644 public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.tsx create mode 100644 public/app/plugins/datasource/loki/querybuilder/components/QueryPreview.tsx create mode 100644 public/app/plugins/datasource/loki/querybuilder/operations.ts create mode 100644 public/app/plugins/datasource/loki/querybuilder/types.ts create mode 100644 public/app/plugins/datasource/prometheus/language_provider.mock.ts create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.test.ts create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.ts create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/aggregations.ts create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/components/LabelParamEditor.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/components/NestedQuery.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/components/NestedQueryList.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.test.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContext.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderExplained.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.test.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/components/QueryPreview.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/operations.ts create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilterItem.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.test.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/shared/LokiAndPromQueryModellerBase.ts create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/shared/OperationEditor.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/shared/OperationExplainedBox.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/shared/OperationInfoButton.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/shared/OperationList.test.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/shared/OperationList.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/shared/OperationListExplained.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/shared/OperationName.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/shared/OperationParamEditor.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/shared/OperationsEditorRow.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/shared/QueryEditorModeToggle.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/shared/operationUtils.ts create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/shared/types.ts create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/testUtils.ts create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/types.ts diff --git a/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.tsx b/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.tsx index 7efa5adeae7..0349f93c989 100644 --- a/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.tsx +++ b/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.tsx @@ -1,17 +1,18 @@ import React from 'react'; -import { Icon } from '../Icon/Icon'; import { IconName } from '../../types/icon'; -import { css, cx } from '@emotion/css'; +import { css } from '@emotion/css'; import RCCascader from 'rc-cascader'; import { CascaderOption } from '../Cascader/Cascader'; import { onChangeCascader, onLoadDataCascader } from '../Cascader/optionMappings'; import { stylesFactory, useTheme2 } from '../../themes'; import { GrafanaTheme2 } from '@grafana/data'; +import { Button, ButtonProps } from '../Button'; +import { Icon } from '../Icon/Icon'; export interface ButtonCascaderProps { options: CascaderOption[]; - children: string; + children?: string; icon?: IconName; disabled?: boolean; value?: string[]; @@ -20,6 +21,9 @@ export interface ButtonCascaderProps { onChange?: (value: string[], selectedOptions: CascaderOption[]) => void; onPopupVisibleChange?: (visible: boolean) => void; className?: string; + variant?: ButtonProps['variant']; + buttonProps?: ButtonProps; + hideDownIcon?: boolean; } const getStyles = stylesFactory((theme: GrafanaTheme2) => { @@ -40,10 +44,17 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => { }); export const ButtonCascader: React.FC = (props) => { - const { onChange, className, loadData, icon, ...rest } = props; + const { onChange, className, loadData, icon, buttonProps, hideDownIcon, variant, disabled, ...rest } = props; const theme = useTheme2(); const styles = getStyles(theme); + // Weird way to do this bit it goes around a styling issue in Button where even null/undefined child triggers + // styling change which messes up the look if there is only single icon content. + let content: any = props.children; + if (!hideDownIcon) { + content = [props.children, ]; + } + return ( = (props) => { {...rest} expandIcon={null} > - + ); }; diff --git a/packages/grafana-ui/src/components/ButtonCascader/_ButtonCascader.scss b/packages/grafana-ui/src/components/ButtonCascader/_ButtonCascader.scss index ea9ec6154c9..63ba29bdd31 100644 --- a/packages/grafana-ui/src/components/ButtonCascader/_ButtonCascader.scss +++ b/packages/grafana-ui/src/components/ButtonCascader/_ButtonCascader.scss @@ -12,7 +12,7 @@ } &-menus { - font-size: 12px; + //font-size: 12px; overflow: hidden; background: $page-bg; border: $panel-border; @@ -92,7 +92,7 @@ position: relative; &:hover { - background: $typeahead-selected-bg; + background: $colors-action-hover; } &-disabled { @@ -113,12 +113,11 @@ } &-active { - color: $typeahead-selected-color; - background: $typeahead-selected-bg; + color: $text-color-strong; + background: $colors-action-selected; &:hover { - color: $typeahead-selected-color; - background: $typeahead-selected-bg; + background: $colors-action-hover; } } diff --git a/packages/grafana-ui/src/components/Forms/Legacy/Select/Select.tsx b/packages/grafana-ui/src/components/Forms/Legacy/Select/Select.tsx index b80e61ffb30..b8df2c4d405 100644 --- a/packages/grafana-ui/src/components/Forms/Legacy/Select/Select.tsx +++ b/packages/grafana-ui/src/components/Forms/Legacy/Select/Select.tsx @@ -23,7 +23,7 @@ import { ThemeContext } from '../../../../themes'; * - noOptionsMessage & loadingMessage is of string type * - isDisabled is renamed to disabled */ -type LegacyCommonProps = Omit, 'noOptionsMessage' | 'disabled' | 'value'>; +type LegacyCommonProps = Omit, 'noOptionsMessage' | 'disabled' | 'value' | 'loadingMessage'>; interface AsyncProps extends LegacyCommonProps, Omit, 'loadingMessage'> { loadingMessage?: () => string; diff --git a/packages/grafana-ui/src/components/Select/types.ts b/packages/grafana-ui/src/components/Select/types.ts index c7493a69866..989d293bfdd 100644 --- a/packages/grafana-ui/src/components/Select/types.ts +++ b/packages/grafana-ui/src/components/Select/types.ts @@ -79,6 +79,8 @@ export interface SelectCommonProps { value: SelectableValue | null, options: OptionsOrGroups> ) => boolean; + /** Message to display isLoading=true*/ + loadingMessage?: string; } export interface SelectAsyncProps { diff --git a/packages/grafana-ui/src/themes/_variables.dark.scss.tmpl.ts b/packages/grafana-ui/src/themes/_variables.dark.scss.tmpl.ts index 3a807f5e695..e4ee63b4af2 100644 --- a/packages/grafana-ui/src/themes/_variables.dark.scss.tmpl.ts +++ b/packages/grafana-ui/src/themes/_variables.dark.scss.tmpl.ts @@ -10,6 +10,9 @@ export const darkThemeVarsTemplate = (theme: GrafanaTheme2) => $theme-name: dark; +$colors-action-hover: ${theme.colors.action.hover}; +$colors-action-selected: ${theme.colors.action.selected}; + // New Colors // ------------------------- $blue-light: ${theme.colors.primary.text}; diff --git a/packages/grafana-ui/src/themes/_variables.light.scss.tmpl.ts b/packages/grafana-ui/src/themes/_variables.light.scss.tmpl.ts index 57392812dce..196faabe1df 100644 --- a/packages/grafana-ui/src/themes/_variables.light.scss.tmpl.ts +++ b/packages/grafana-ui/src/themes/_variables.light.scss.tmpl.ts @@ -11,6 +11,9 @@ export const lightThemeVarsTemplate = (theme: GrafanaTheme2) => $theme-name: light; +$colors-action-hover: ${theme.colors.action.hover}; +$colors-action-selected: ${theme.colors.action.selected}; + // New Colors // ------------------------- $blue-light: ${theme.colors.primary.text}; diff --git a/public/app/angular/components/query_part.ts b/public/app/angular/components/query_part.ts index 26f68057272..79fc58ce3bb 100644 --- a/public/app/angular/components/query_part.ts +++ b/public/app/angular/components/query_part.ts @@ -106,14 +106,14 @@ export function functionRenderer(part: any, innerExpr: string) { return str + parameters.join(', ') + ')'; } -export function suffixRenderer(part: QueryPartDef, innerExpr: string) { +export function suffixRenderer(part: QueryPart, innerExpr: string) { return innerExpr + ' ' + part.params[0]; } -export function identityRenderer(part: QueryPartDef, innerExpr: string) { +export function identityRenderer(part: QueryPart, innerExpr: string) { return part.params[0]; } -export function quotedIdentityRenderer(part: QueryPartDef, innerExpr: string) { +export function quotedIdentityRenderer(part: QueryPart, innerExpr: string) { return '"' + part.params[0] + '"'; } diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index 278dd1225ee..e01e955a1ad 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -476,7 +476,7 @@ export function filterPanelDataToQuery(data: PanelData, refId: string): PanelDat } // Only say this is an error if the error links to the query - let state = LoadingState.Done; + let state = data.state; const error = data.error && data.error.refId === refId ? data.error : undefined; if (error) { state = LoadingState.Error; diff --git a/public/app/plugins/datasource/loki/components/LokiQueryEditorByApp.tsx b/public/app/plugins/datasource/loki/components/LokiQueryEditorByApp.tsx index c1a1efb499b..7dffe3c21d0 100644 --- a/public/app/plugins/datasource/loki/components/LokiQueryEditorByApp.tsx +++ b/public/app/plugins/datasource/loki/components/LokiQueryEditorByApp.tsx @@ -3,6 +3,8 @@ import { CoreApp } from '@grafana/data'; import { LokiQueryEditorProps } from './types'; import { LokiQueryEditor } from './LokiQueryEditor'; import { LokiQueryEditorForAlerting } from './LokiQueryEditorForAlerting'; +import { LokiQueryEditorSelector } from '../querybuilder/components/LokiQueryEditorSelector'; +import { config } from '@grafana/runtime'; export function LokiQueryEditorByApp(props: LokiQueryEditorProps) { const { app } = props; @@ -11,6 +13,9 @@ export function LokiQueryEditorByApp(props: LokiQueryEditorProps) { case CoreApp.CloudAlerting: return ; default: + if (config.featureToggles.lokiQueryBuilder) { + return ; + } return ; } } diff --git a/public/app/plugins/datasource/loki/module.ts b/public/app/plugins/datasource/loki/module.ts index 225a12097ca..524838580c7 100644 --- a/public/app/plugins/datasource/loki/module.ts +++ b/public/app/plugins/datasource/loki/module.ts @@ -2,7 +2,6 @@ import { DataSourcePlugin } from '@grafana/data'; import Datasource from './datasource'; import LokiCheatSheet from './components/LokiCheatSheet'; -import LokiExploreQueryEditor from './components/LokiExploreQueryEditor'; import LokiQueryEditorByApp from './components/LokiQueryEditorByApp'; import { LokiAnnotationsQueryCtrl } from './LokiAnnotationsQueryCtrl'; import { ConfigEditor } from './configuration/ConfigEditor'; @@ -10,6 +9,6 @@ import { ConfigEditor } from './configuration/ConfigEditor'; export const plugin = new DataSourcePlugin(Datasource) .setQueryEditor(LokiQueryEditorByApp) .setConfigEditor(ConfigEditor) - .setExploreQueryField(LokiExploreQueryEditor) + .setExploreQueryField(LokiQueryEditorByApp) .setQueryEditorHelp(LokiCheatSheet) .setAnnotationQueryCtrl(LokiAnnotationsQueryCtrl); diff --git a/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.test.ts b/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.test.ts new file mode 100644 index 00000000000..dfbf360cf08 --- /dev/null +++ b/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.test.ts @@ -0,0 +1,188 @@ +import { LokiQueryModeller } from './LokiQueryModeller'; +import { LokiOperationId } from './types'; + +describe('LokiQueryModeller', () => { + const modeller = new LokiQueryModeller(); + + it('Can query with labels only', () => { + expect( + modeller.renderQuery({ + labels: [{ label: 'app', op: '=', value: 'grafana' }], + operations: [], + }) + ).toBe('{app="grafana"}'); + }); + + it('Can query with pipeline operation json', () => { + expect( + modeller.renderQuery({ + labels: [{ label: 'app', op: '=', value: 'grafana' }], + operations: [{ id: LokiOperationId.Json, params: [] }], + }) + ).toBe('{app="grafana"} | json'); + }); + + it('Can query with pipeline operation logfmt', () => { + expect( + modeller.renderQuery({ + labels: [{ label: 'app', op: '=', value: 'grafana' }], + operations: [{ id: LokiOperationId.Logfmt, params: [] }], + }) + ).toBe('{app="grafana"} | logfmt'); + }); + + it('Can query with line filter contains operation', () => { + expect( + modeller.renderQuery({ + labels: [{ label: 'app', op: '=', value: 'grafana' }], + operations: [{ id: LokiOperationId.LineContains, params: ['error'] }], + }) + ).toBe('{app="grafana"} |= `error`'); + }); + + it('Can query with line filter contains operation with empty params', () => { + expect( + modeller.renderQuery({ + labels: [{ label: 'app', op: '=', value: 'grafana' }], + operations: [{ id: LokiOperationId.LineContains, params: [''] }], + }) + ).toBe('{app="grafana"}'); + }); + + it('Can query with line filter contains not operation', () => { + expect( + modeller.renderQuery({ + labels: [{ label: 'app', op: '=', value: 'grafana' }], + operations: [{ id: LokiOperationId.LineContainsNot, params: ['error'] }], + }) + ).toBe('{app="grafana"} != `error`'); + }); + + it('Can query with line regex filter', () => { + expect( + modeller.renderQuery({ + labels: [{ label: 'app', op: '=', value: 'grafana' }], + operations: [{ id: LokiOperationId.LineMatchesRegex, params: ['error'] }], + }) + ).toBe('{app="grafana"} |~ `error`'); + }); + + it('Can query with line not matching regex', () => { + expect( + modeller.renderQuery({ + labels: [{ label: 'app', op: '=', value: 'grafana' }], + operations: [{ id: LokiOperationId.LineMatchesRegexNot, params: ['error'] }], + }) + ).toBe('{app="grafana"} !~ `error`'); + }); + + it('Can query with label filter expression', () => { + expect( + modeller.renderQuery({ + labels: [{ label: 'app', op: '=', value: 'grafana' }], + operations: [{ id: LokiOperationId.LabelFilter, params: ['__error__', '=', 'value'] }], + }) + ).toBe('{app="grafana"} | __error__="value"'); + }); + + it('Can query with label filter expression using greater than operator', () => { + expect( + modeller.renderQuery({ + labels: [{ label: 'app', op: '=', value: 'grafana' }], + operations: [{ id: LokiOperationId.LabelFilter, params: ['count', '>', 'value'] }], + }) + ).toBe('{app="grafana"} | count > value'); + }); + + it('Can query no formatting errors operation', () => { + expect( + modeller.renderQuery({ + labels: [{ label: 'app', op: '=', value: 'grafana' }], + operations: [{ id: LokiOperationId.LabelFilterNoErrors, params: [] }], + }) + ).toBe('{app="grafana"} | __error__=""'); + }); + + it('Can query with unwrap operation', () => { + expect( + modeller.renderQuery({ + labels: [{ label: 'app', op: '=', value: 'grafana' }], + operations: [{ id: LokiOperationId.Unwrap, params: ['count'] }], + }) + ).toBe('{app="grafana"} | unwrap count'); + }); + + describe('On add operation handlers', () => { + it('When adding function without range vector param should automatically add rate', () => { + const query = { + labels: [], + operations: [], + }; + + const def = modeller.getOperationDef('sum'); + const result = def.addOperationHandler(def, query, modeller); + expect(result.operations[0].id).toBe('rate'); + expect(result.operations[1].id).toBe('sum'); + }); + + it('When adding function without range vector param should automatically add rate after existing pipe operation', () => { + const query = { + labels: [], + operations: [{ id: 'json', params: [] }], + }; + + const def = modeller.getOperationDef('sum'); + const result = def.addOperationHandler(def, query, modeller); + expect(result.operations[0].id).toBe('json'); + expect(result.operations[1].id).toBe('rate'); + expect(result.operations[2].id).toBe('sum'); + }); + + it('When adding a pipe operation after a function operation should add pipe operation first', () => { + const query = { + labels: [], + operations: [{ id: 'rate', params: [] }], + }; + + const def = modeller.getOperationDef('json'); + const result = def.addOperationHandler(def, query, modeller); + expect(result.operations[0].id).toBe('json'); + expect(result.operations[1].id).toBe('rate'); + }); + + it('When adding a pipe operation after a line filter operation', () => { + const query = { + labels: [], + operations: [{ id: '__line_contains', params: ['error'] }], + }; + + const def = modeller.getOperationDef('json'); + const result = def.addOperationHandler(def, query, modeller); + expect(result.operations[0].id).toBe('__line_contains'); + expect(result.operations[1].id).toBe('json'); + }); + + it('When adding a line filter operation after format operation', () => { + const query = { + labels: [], + operations: [{ id: 'json', params: [] }], + }; + + const def = modeller.getOperationDef('__line_contains'); + const result = def.addOperationHandler(def, query, modeller); + expect(result.operations[0].id).toBe('__line_contains'); + expect(result.operations[1].id).toBe('json'); + }); + + it('When adding a rate it should not add another rate', () => { + const query = { + labels: [], + operations: [], + }; + + const def = modeller.getOperationDef('rate'); + const result = def.addOperationHandler(def, query, modeller); + expect(result.operations.length).toBe(1); + }); + }); +}); diff --git a/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.ts b/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.ts new file mode 100644 index 00000000000..6c15918d74a --- /dev/null +++ b/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.ts @@ -0,0 +1,61 @@ +import { LokiAndPromQueryModellerBase } from '../../prometheus/querybuilder/shared/LokiAndPromQueryModellerBase'; +import { QueryBuilderLabelFilter } from '../../prometheus/querybuilder/shared/types'; +import { getOperationDefintions } from './operations'; +import { LokiOperationId, LokiQueryPattern, LokiVisualQuery, LokiVisualQueryOperationCategory } from './types'; + +export class LokiQueryModeller extends LokiAndPromQueryModellerBase { + constructor() { + super(getOperationDefintions); + + this.setOperationCategories([ + LokiVisualQueryOperationCategory.Aggregations, + LokiVisualQueryOperationCategory.RangeFunctions, + LokiVisualQueryOperationCategory.Formats, + //LokiVisualQueryOperationCategory.Functions, + LokiVisualQueryOperationCategory.LabelFilters, + LokiVisualQueryOperationCategory.LineFilters, + ]); + } + + renderLabels(labels: QueryBuilderLabelFilter[]) { + if (labels.length === 0) { + return '{}'; + } + + return super.renderLabels(labels); + } + + renderQuery(query: LokiVisualQuery) { + let queryString = `${this.renderLabels(query.labels)}`; + queryString = this.renderOperations(queryString, query.operations); + queryString = this.renderBinaryQueries(queryString, query.binaryQueries); + return queryString; + } + + getQueryPatterns(): LokiQueryPattern[] { + return [ + { + name: 'Log query and label filter', + operations: [ + { id: LokiOperationId.LineMatchesRegex, params: [''] }, + { id: LokiOperationId.Logfmt, params: [] }, + { id: LokiOperationId.LabelFilterNoErrors, params: [] }, + { id: LokiOperationId.LabelFilter, params: ['', '=', ''] }, + ], + }, + { + name: 'Time series query on value inside log line', + operations: [ + { id: LokiOperationId.LineMatchesRegex, params: [''] }, + { id: LokiOperationId.Logfmt, params: [] }, + { id: LokiOperationId.LabelFilterNoErrors, params: [] }, + { id: LokiOperationId.Unwrap, params: [''] }, + { id: LokiOperationId.SumOverTime, params: ['auto'] }, + { id: LokiOperationId.Sum, params: [] }, + ], + }, + ]; + } +} + +export const lokiQueryModeller = new LokiQueryModeller(); diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx new file mode 100644 index 00000000000..104c84847e4 --- /dev/null +++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { LokiVisualQuery } from '../types'; +import { LokiDatasource } from '../../datasource'; +import { LabelFilters } from 'app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters'; +import { OperationList } from 'app/plugins/datasource/prometheus/querybuilder/shared/OperationList'; +import { QueryBuilderLabelFilter } from 'app/plugins/datasource/prometheus/querybuilder/shared/types'; +import { lokiQueryModeller } from '../LokiQueryModeller'; +import { DataSourceApi } from '@grafana/data'; +import { EditorRow, EditorRows } from '@grafana/experimental'; +import { QueryPreview } from './QueryPreview'; + +export interface Props { + query: LokiVisualQuery; + datasource: LokiDatasource; + onChange: (update: LokiVisualQuery) => void; + onRunQuery: () => void; + nested?: boolean; +} + +export const LokiQueryBuilder = React.memo(({ datasource, query, nested, onChange, onRunQuery }) => { + const onChangeLabels = (labels: QueryBuilderLabelFilter[]) => { + onChange({ ...query, labels }); + }; + + const onGetLabelNames = async (forLabel: Partial): Promise => { + const labelsToConsider = query.labels.filter((x) => x !== forLabel); + + if (labelsToConsider.length === 0) { + await datasource.languageProvider.refreshLogLabels(); + return datasource.languageProvider.getLabelKeys(); + } + + const expr = lokiQueryModeller.renderLabels(labelsToConsider); + return await datasource.languageProvider.fetchSeriesLabels(expr); + }; + + const onGetLabelValues = async (forLabel: Partial) => { + if (!forLabel.label) { + return []; + } + + const labelsToConsider = query.labels.filter((x) => x !== forLabel); + if (labelsToConsider.length === 0) { + return await datasource.languageProvider.fetchLabelValues(forLabel.label); + } + + const expr = lokiQueryModeller.renderLabels(labelsToConsider); + const result = await datasource.languageProvider.fetchSeriesLabels(expr); + return result[forLabel.label] ?? []; + }; + + return ( + + + + + + + + {!nested && ( + + + + )} + + ); +}); + +LokiQueryBuilder.displayName = 'LokiQueryBuilder'; diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderExplaind.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderExplaind.tsx new file mode 100644 index 00000000000..69e6115a8e6 --- /dev/null +++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderExplaind.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { LokiVisualQuery } from '../types'; +import { Stack } from '@grafana/experimental'; +import { lokiQueryModeller } from '../LokiQueryModeller'; +import { OperationListExplained } from 'app/plugins/datasource/prometheus/querybuilder/shared/OperationListExplained'; +import { OperationExplainedBox } from 'app/plugins/datasource/prometheus/querybuilder/shared/OperationExplainedBox'; + +export interface Props { + query: LokiVisualQuery; + nested?: boolean; +} + +export const LokiQueryBuilderExplained = React.memo(({ query, nested }) => { + return ( + + + Fetch all log lines matching label filters. + + stepNumber={2} queryModeller={lokiQueryModeller} query={query} /> + + ); +}); + +LokiQueryBuilderExplained.displayName = 'LokiQueryBuilderExplained'; diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.tsx new file mode 100644 index 00000000000..498ec229885 --- /dev/null +++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.tsx @@ -0,0 +1,105 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2, LoadingState } from '@grafana/data'; +import { EditorHeader, FlexItem, InlineSelect, Space, Stack } from '@grafana/experimental'; +import { Button, Switch, useStyles2 } from '@grafana/ui'; +import { QueryEditorModeToggle } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryEditorModeToggle'; +import { QueryEditorMode } from 'app/plugins/datasource/prometheus/querybuilder/shared/types'; +import React, { useCallback, useState } from 'react'; +import { LokiQueryEditor } from '../../components/LokiQueryEditor'; +import { LokiQueryEditorProps } from '../../components/types'; +import { lokiQueryModeller } from '../LokiQueryModeller'; +import { getDefaultEmptyQuery, LokiVisualQuery } from '../types'; +import { LokiQueryBuilder } from './LokiQueryBuilder'; +import { LokiQueryBuilderExplained } from './LokiQueryBuilderExplaind'; + +export const LokiQueryEditorSelector = React.memo((props) => { + const { query, onChange, onRunQuery, data } = props; + const styles = useStyles2(getStyles); + const [visualQuery, setVisualQuery] = useState(query.visualQuery ?? getDefaultEmptyQuery()); + + const onEditorModeChange = useCallback( + (newMetricEditorMode: QueryEditorMode) => { + onChange({ ...query, editorMode: newMetricEditorMode }); + }, + [onChange, query] + ); + + const onChangeViewModel = (updatedQuery: LokiVisualQuery) => { + setVisualQuery(updatedQuery); + + onChange({ + ...query, + expr: lokiQueryModeller.renderQuery(updatedQuery), + visualQuery: updatedQuery, + editorMode: QueryEditorMode.Builder, + }); + }; + + // If no expr (ie new query) then default to builder + const editorMode = query.editorMode ?? (query.expr ? QueryEditorMode.Code : QueryEditorMode.Builder); + + return ( + <> + + + + + + + + + + + + { + onChangeViewModel({ + ...visualQuery, + operations: value?.operations!, + }); + }} + options={lokiQueryModeller.getQueryPatterns().map((x) => ({ label: x.name, value: x }))} + /> + + + + {editorMode === QueryEditorMode.Code && } + {editorMode === QueryEditorMode.Builder && ( + + )} + {editorMode === QueryEditorMode.Explain && } + + ); +}); + +LokiQueryEditorSelector.displayName = 'LokiQueryEditorSelector'; + +const getStyles = (theme: GrafanaTheme2) => { + return { + runQuery: css({ + color: theme.colors.text.secondary, + }), + switchLabel: css({ + color: theme.colors.text.secondary, + fontSize: theme.typography.bodySmall.fontSize, + }), + }; +}; diff --git a/public/app/plugins/datasource/loki/querybuilder/components/QueryPreview.tsx b/public/app/plugins/datasource/loki/querybuilder/components/QueryPreview.tsx new file mode 100644 index 00000000000..55ef0adc1c6 --- /dev/null +++ b/public/app/plugins/datasource/loki/querybuilder/components/QueryPreview.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { LokiVisualQuery } from '../types'; +import { useTheme2 } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { css, cx } from '@emotion/css'; +import { EditorField, EditorFieldGroup } from '@grafana/experimental'; +import Prism from 'prismjs'; +import { lokiGrammar } from '../../syntax'; +import { lokiQueryModeller } from '../LokiQueryModeller'; + +export interface Props { + query: LokiVisualQuery; +} + +export function QueryPreview({ query }: Props) { + const theme = useTheme2(); + const styles = getStyles(theme); + const hightlighted = Prism.highlight(lokiQueryModeller.renderQuery(query), lokiGrammar, 'lokiql'); + + return ( + + +
+ + + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + editorField: css({ + padding: theme.spacing(0.25, 1), + fontFamily: theme.typography.fontFamilyMonospace, + fontSize: theme.typography.bodySmall.fontSize, + }), + }; +}; diff --git a/public/app/plugins/datasource/loki/querybuilder/operations.ts b/public/app/plugins/datasource/loki/querybuilder/operations.ts new file mode 100644 index 00000000000..06e6c7cd499 --- /dev/null +++ b/public/app/plugins/datasource/loki/querybuilder/operations.ts @@ -0,0 +1,300 @@ +import { + functionRendererLeft, + getPromAndLokiOperationDisplayName, +} from '../../prometheus/querybuilder/shared/operationUtils'; +import { + QueryBuilderOperation, + QueryBuilderOperationDef, + QueryBuilderOperationParamDef, + VisualQueryModeller, +} from '../../prometheus/querybuilder/shared/types'; +import { FUNCTIONS } from '../syntax'; +import { LokiOperationId, LokiVisualQuery, LokiVisualQueryOperationCategory } from './types'; + +export function getOperationDefintions(): QueryBuilderOperationDef[] { + const list: QueryBuilderOperationDef[] = [ + createRangeOperation(LokiOperationId.Rate), + createRangeOperation(LokiOperationId.CountOverTime), + createRangeOperation(LokiOperationId.SumOverTime), + createRangeOperation(LokiOperationId.BytesRate), + createRangeOperation(LokiOperationId.BytesOverTime), + createRangeOperation(LokiOperationId.AbsentOverTime), + createAggregationOperation(LokiOperationId.Sum), + createAggregationOperation(LokiOperationId.Avg), + createAggregationOperation(LokiOperationId.Min), + createAggregationOperation(LokiOperationId.Max), + { + id: LokiOperationId.Json, + name: 'Json', + params: [], + defaultParams: [], + alternativesKey: 'format', + category: LokiVisualQueryOperationCategory.Formats, + renderer: pipelineRenderer, + addOperationHandler: addLokiOperation, + }, + { + id: LokiOperationId.Logfmt, + name: 'Logfmt', + params: [], + defaultParams: [], + alternativesKey: 'format', + category: LokiVisualQueryOperationCategory.Formats, + renderer: pipelineRenderer, + addOperationHandler: addLokiOperation, + explainHandler: () => + `This will extract all keys and values from a [logfmt](https://grafana.com/docs/loki/latest/logql/log_queries/#logfmt) formatted log line as labels. The extracted lables can be used in label filter expressions and used as values for a range aggregation via the unwrap operation. `, + }, + { + id: LokiOperationId.LineContains, + name: 'Line contains', + params: [{ name: 'String', type: 'string' }], + defaultParams: [''], + alternativesKey: 'line filter', + category: LokiVisualQueryOperationCategory.LineFilters, + renderer: getLineFilterRenderer('|='), + addOperationHandler: addLokiOperation, + explainHandler: (op) => `Return log lines that contain string \`${op.params[0]}\`.`, + }, + { + id: LokiOperationId.LineContainsNot, + name: 'Line does not contain', + params: [{ name: 'String', type: 'string' }], + defaultParams: [''], + alternativesKey: 'line filter', + category: LokiVisualQueryOperationCategory.LineFilters, + renderer: getLineFilterRenderer('!='), + addOperationHandler: addLokiOperation, + explainHandler: (op) => `Return log lines that does not contain string \`${op.params[0]}\`.`, + }, + { + id: LokiOperationId.LineMatchesRegex, + name: 'Line contains regex match', + params: [{ name: 'Regex', type: 'string' }], + defaultParams: [''], + alternativesKey: 'line filter', + category: LokiVisualQueryOperationCategory.LineFilters, + renderer: getLineFilterRenderer('|~'), + addOperationHandler: addLokiOperation, + explainHandler: (op) => `Return log lines that match regex \`${op.params[0]}\`.`, + }, + { + id: LokiOperationId.LineMatchesRegexNot, + name: 'Line does not match regex', + params: [{ name: 'Regex', type: 'string' }], + defaultParams: [''], + alternativesKey: 'line filter', + category: LokiVisualQueryOperationCategory.LineFilters, + renderer: getLineFilterRenderer('!~'), + addOperationHandler: addLokiOperation, + explainHandler: (op) => `Return log lines that does not match regex \`${op.params[0]}\`.`, + }, + { + id: LokiOperationId.LabelFilter, + name: 'Label filter expression', + params: [ + { name: 'Label', type: 'string' }, + { name: 'Operator', type: 'string', options: ['=', '!=', '>', '<', '>=', '<='] }, + { name: 'Value', type: 'string' }, + ], + defaultParams: ['', '=', ''], + category: LokiVisualQueryOperationCategory.LabelFilters, + renderer: labelFilterRenderer, + addOperationHandler: addLokiOperation, + explainHandler: () => `Label expression filter allows filtering using original and extracted labels.`, + }, + { + id: LokiOperationId.LabelFilterNoErrors, + name: 'No pipeline errors', + params: [], + defaultParams: [], + category: LokiVisualQueryOperationCategory.LabelFilters, + renderer: (model, def, innerExpr) => `${innerExpr} | __error__=""`, + addOperationHandler: addLokiOperation, + explainHandler: () => `Filter out all formatting and parsing errors.`, + }, + { + id: LokiOperationId.Unwrap, + name: 'Unwrap', + params: [{ name: 'Identifier', type: 'string' }], + defaultParams: [''], + category: LokiVisualQueryOperationCategory.Formats, + renderer: (op, def, innerExpr) => `${innerExpr} | unwrap ${op.params[0]}`, + addOperationHandler: addLokiOperation, + explainHandler: (op) => + `Use the extracted label \`${op.params[0]}\` as sample values instead of log lines for the subsequent range aggregation.`, + }, + ]; + + return list; +} + +function createRangeOperation(name: string): QueryBuilderOperationDef { + return { + id: name, + name: getPromAndLokiOperationDisplayName(name), + params: [getRangeVectorParamDef()], + defaultParams: ['auto'], + alternativesKey: 'range function', + category: LokiVisualQueryOperationCategory.RangeFunctions, + renderer: operationWithRangeVectorRenderer, + addOperationHandler: addLokiOperation, + explainHandler: (op, def) => { + let opDocs = FUNCTIONS.find((x) => x.insertText === op.id)?.documentation ?? ''; + + if (op.params[0] === 'auto' || op.params[0] === '$__interval') { + return `${opDocs} \`$__interval\` is variable that will be replaced with a calculated interval based on **Max data points**, **Min interval** and query time range. You find these options you find under **Query options** at the right of the data source select dropdown.`; + } else { + return `${opDocs} The [range vector](https://grafana.com/docs/loki/latest/logql/metric_queries/#range-vector-aggregation) is set to \`${op.params[0]}\`.`; + } + }, + }; +} + +function createAggregationOperation(name: string): QueryBuilderOperationDef { + return { + id: name, + name: getPromAndLokiOperationDisplayName(name), + params: [], + defaultParams: [], + alternativesKey: 'plain aggregation', + category: LokiVisualQueryOperationCategory.Aggregations, + renderer: functionRendererLeft, + addOperationHandler: addLokiOperation, + explainHandler: (op, def) => { + const opDocs = FUNCTIONS.find((x) => x.insertText === op.id); + return `${opDocs?.documentation}.`; + }, + }; +} + +function getRangeVectorParamDef(): QueryBuilderOperationParamDef { + return { + name: 'Range vector', + type: 'string', + options: ['auto', '$__interval', '$__range', '1m', '5m', '10m', '1h', '24h'], + }; +} + +function operationWithRangeVectorRenderer( + model: QueryBuilderOperation, + def: QueryBuilderOperationDef, + innerExpr: string +) { + let rangeVector = (model.params ?? [])[0] ?? 'auto'; + + if (rangeVector === 'auto') { + rangeVector = '$__interval'; + } + + return `${def.id}(${innerExpr} [${rangeVector}])`; +} + +function getLineFilterRenderer(operation: string) { + return function lineFilterRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { + if (model.params[0] === '') { + return innerExpr; + } + return `${innerExpr} ${operation} \`${model.params[0]}\``; + }; +} + +function labelFilterRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { + if (model.params[0] === '') { + return innerExpr; + } + + if (model.params[1] === '<' || model.params[1] === '>') { + return `${innerExpr} | ${model.params[0]} ${model.params[1]} ${model.params[2]}`; + } + + return `${innerExpr} | ${model.params[0]}${model.params[1]}"${model.params[2]}"`; +} + +function pipelineRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { + return `${innerExpr} | ${model.id}`; +} + +function isRangeVectorFunction(def: QueryBuilderOperationDef) { + return def.category === LokiVisualQueryOperationCategory.RangeFunctions; +} + +function getIndexOfOrLast( + operations: QueryBuilderOperation[], + queryModeller: VisualQueryModeller, + condition: (def: QueryBuilderOperationDef) => boolean +) { + const index = operations.findIndex((x) => { + return condition(queryModeller.getOperationDef(x.id)); + }); + + return index === -1 ? operations.length : index; +} + +export function addLokiOperation( + def: QueryBuilderOperationDef, + query: LokiVisualQuery, + modeller: VisualQueryModeller +): LokiVisualQuery { + const newOperation: QueryBuilderOperation = { + id: def.id, + params: def.defaultParams, + }; + + const operations = [...query.operations]; + + switch (def.category) { + case LokiVisualQueryOperationCategory.Aggregations: + case LokiVisualQueryOperationCategory.Functions: { + const rangeVectorFunction = operations.find((x) => { + return isRangeVectorFunction(modeller.getOperationDef(x.id)); + }); + + // If we are adding a function but we have not range vector function yet add one + if (!rangeVectorFunction) { + const placeToInsert = getIndexOfOrLast( + operations, + modeller, + (def) => def.category === LokiVisualQueryOperationCategory.Functions + ); + operations.splice(placeToInsert, 0, { id: 'rate', params: ['auto'] }); + } + + operations.push(newOperation); + break; + } + case LokiVisualQueryOperationCategory.RangeFunctions: + // Add range functions after any formats, line filters and label filters + const placeToInsert = getIndexOfOrLast(operations, modeller, (x) => { + return ( + x.category !== LokiVisualQueryOperationCategory.Formats && + x.category !== LokiVisualQueryOperationCategory.LineFilters && + x.category !== LokiVisualQueryOperationCategory.LabelFilters + ); + }); + operations.splice(placeToInsert, 0, newOperation); + break; + case LokiVisualQueryOperationCategory.Formats: + case LokiVisualQueryOperationCategory.LineFilters: { + const placeToInsert = getIndexOfOrLast(operations, modeller, (x) => { + return x.category !== LokiVisualQueryOperationCategory.LineFilters; + }); + operations.splice(placeToInsert, 0, newOperation); + break; + } + case LokiVisualQueryOperationCategory.LabelFilters: { + const placeToInsert = getIndexOfOrLast(operations, modeller, (x) => { + return ( + x.category !== LokiVisualQueryOperationCategory.LineFilters && + x.category !== LokiVisualQueryOperationCategory.Formats + ); + }); + operations.splice(placeToInsert, 0, newOperation); + } + } + + return { + ...query, + operations, + }; +} diff --git a/public/app/plugins/datasource/loki/querybuilder/types.ts b/public/app/plugins/datasource/loki/querybuilder/types.ts new file mode 100644 index 00000000000..fb9e9e3e62c --- /dev/null +++ b/public/app/plugins/datasource/loki/querybuilder/types.ts @@ -0,0 +1,58 @@ +import { QueryBuilderLabelFilter, QueryBuilderOperation } from '../../prometheus/querybuilder/shared/types'; + +/** + * Visual query model + */ +export interface LokiVisualQuery { + labels: QueryBuilderLabelFilter[]; + operations: QueryBuilderOperation[]; + binaryQueries?: LokiVisualQueryBinary[]; +} + +export interface LokiVisualQueryBinary { + operator: string; + vectorMatches?: string; + query: LokiVisualQuery; +} +export interface LokiQueryPattern { + name: string; + operations: QueryBuilderOperation[]; +} + +export enum LokiVisualQueryOperationCategory { + Aggregations = 'Aggregations', + RangeFunctions = 'Range functions', + Functions = 'Functions', + Formats = 'Formats', + LineFilters = 'Line filters', + LabelFilters = 'Label filters', +} + +export enum LokiOperationId { + Json = 'json', + Logfmt = 'logfmt', + Rate = 'rate', + CountOverTime = 'count_over_time', + SumOverTime = 'sum_over_time', + BytesRate = 'bytes_rate', + BytesOverTime = 'bytes_over_time', + AbsentOverTime = 'absent_over_time', + Sum = 'sum', + Avg = 'avg', + Min = 'min', + Max = 'max', + LineContains = '__line_contains', + LineContainsNot = '__line_contains_not', + LineMatchesRegex = '__line_matches_regex', + LineMatchesRegexNot = '__line_matches_regex_not', + LabelFilter = '__label_filter', + LabelFilterNoErrors = '__label_filter_no_errors', + Unwrap = 'unwrap', +} + +export function getDefaultEmptyQuery(): LokiVisualQuery { + return { + labels: [], + operations: [{ id: '__line_contains', params: [''] }], + }; +} diff --git a/public/app/plugins/datasource/loki/syntax.ts b/public/app/plugins/datasource/loki/syntax.ts index e0fac42708a..cac467b365e 100644 --- a/public/app/plugins/datasource/loki/syntax.ts +++ b/public/app/plugins/datasource/loki/syntax.ts @@ -162,15 +162,14 @@ export const RANGE_VEC_FUNCTIONS = [ insertText: 'rate', label: 'rate', detail: 'rate(v range-vector)', - documentation: - "Calculates the per-second average rate of increase of the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. Also, the calculation extrapolates to the ends of the time range, allowing for missed scrapes or imperfect alignment of scrape cycles with the range's time period.", + documentation: 'Calculates the number of entries per second.', }, ]; export const FUNCTIONS = [...AGGREGATION_OPERATORS, ...RANGE_VEC_FUNCTIONS]; export const LOKI_KEYWORDS = [...FUNCTIONS, ...PIPE_OPERATORS, ...PIPE_PARSERS].map((keyword) => keyword.label); -const tokenizer: Grammar = { +export const lokiGrammar: Grammar = { comment: { pattern: /#.*/, }, @@ -245,4 +244,4 @@ const tokenizer: Grammar = { punctuation: /[{}()`,.]/, }; -export default tokenizer; +export default lokiGrammar; diff --git a/public/app/plugins/datasource/loki/types.ts b/public/app/plugins/datasource/loki/types.ts index c654c78dc8e..35e37bdad8f 100644 --- a/public/app/plugins/datasource/loki/types.ts +++ b/public/app/plugins/datasource/loki/types.ts @@ -1,4 +1,6 @@ import { DataQuery, DataSourceJsonData, QueryResultMeta, ScopedVars } from '@grafana/data'; +import { QueryEditorMode } from '../prometheus/querybuilder/shared/types'; +import { LokiVisualQuery } from './querybuilder/types'; export interface LokiInstantQueryRequest { query: string; @@ -38,13 +40,15 @@ export interface LokiQuery extends DataQuery { valueWithRefId?: boolean; maxLines?: number; resolution?: number; - volumeQuery?: boolean; // Used in range queries - + /** Used in range queries */ + volumeQuery?: boolean; /* @deprecated now use queryType */ range?: boolean; - /* @deprecated now use queryType */ instant?: boolean; + editorMode?: QueryEditorMode; + /** Temporary until we have a parser */ + visualQuery?: LokiVisualQuery; } export interface LokiOptions extends DataSourceJsonData { diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryEditorByApp.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryEditorByApp.tsx index f3c5c56373b..74300916fa5 100644 --- a/public/app/plugins/datasource/prometheus/components/PromQueryEditorByApp.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromQueryEditorByApp.tsx @@ -3,6 +3,8 @@ import { CoreApp } from '@grafana/data'; import { PromQueryEditorProps } from './types'; import { PromQueryEditor } from './PromQueryEditor'; import { PromQueryEditorForAlerting } from './PromQueryEditorForAlerting'; +import { config } from '@grafana/runtime'; +import { PromQueryEditorSelector } from '../querybuilder/components/PromQueryEditorSelector'; export function PromQueryEditorByApp(props: PromQueryEditorProps) { const { app } = props; @@ -11,6 +13,9 @@ export function PromQueryEditorByApp(props: PromQueryEditorProps) { case CoreApp.CloudAlerting: return ; default: + if (config.featureToggles.promQueryBuilder) { + return ; + } return ; } } diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index fd173e8cd30..38f9697f955 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -84,7 +84,8 @@ export class PrometheusDatasource constructor( instanceSettings: DataSourceInstanceSettings, private readonly templateSrv: TemplateSrv = getTemplateSrv(), - private readonly timeSrv: TimeSrv = getTimeSrv() + private readonly timeSrv: TimeSrv = getTimeSrv(), + languageProvider?: PrometheusLanguageProvider ) { super(instanceSettings); @@ -103,7 +104,7 @@ export class PrometheusDatasource this.directUrl = instanceSettings.jsonData.directUrl ?? this.url; this.exemplarTraceIdDestinations = instanceSettings.jsonData.exemplarTraceIdDestinations; this.ruleMappings = {}; - this.languageProvider = new PrometheusLanguageProvider(this); + this.languageProvider = languageProvider ?? new PrometheusLanguageProvider(this); this.lookupsDisabled = instanceSettings.jsonData.disableMetricsLookup ?? false; this.customQueryParameters = new URLSearchParams(instanceSettings.jsonData.customQueryParameters); this.variables = new PrometheusVariableSupport(this, this.templateSrv, this.timeSrv); diff --git a/public/app/plugins/datasource/prometheus/language_provider.mock.ts b/public/app/plugins/datasource/prometheus/language_provider.mock.ts new file mode 100644 index 00000000000..24ac3d403d9 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/language_provider.mock.ts @@ -0,0 +1,15 @@ +export class EmptyLanguageProviderMock { + metrics = []; + constructor() {} + start() { + return new Promise((resolve) => { + resolve(''); + }); + } + getLabelKeys = jest.fn().mockReturnValue([]); + getLabelValues = jest.fn().mockReturnValue([]); + getSeries = jest.fn().mockReturnValue({ __name__: [] }); + fetchSeries = jest.fn().mockReturnValue([]); + fetchSeriesLabels = jest.fn().mockReturnValue([]); + fetchLabels = jest.fn(); +} diff --git a/public/app/plugins/datasource/prometheus/module.ts b/public/app/plugins/datasource/prometheus/module.ts index 34daca87835..f4711890cba 100644 --- a/public/app/plugins/datasource/prometheus/module.ts +++ b/public/app/plugins/datasource/prometheus/module.ts @@ -3,7 +3,6 @@ import { ANNOTATION_QUERY_STEP_DEFAULT, PrometheusDatasource } from './datasourc import PromQueryEditorByApp from './components/PromQueryEditorByApp'; import PromCheatSheet from './components/PromCheatSheet'; -import PromExploreQueryEditor from './components/PromExploreQueryEditor'; import { ConfigEditor } from './configuration/ConfigEditor'; @@ -15,6 +14,6 @@ class PrometheusAnnotationsQueryCtrl { export const plugin = new DataSourcePlugin(PrometheusDatasource) .setQueryEditor(PromQueryEditorByApp) .setConfigEditor(ConfigEditor) - .setExploreMetricsQueryField(PromExploreQueryEditor) + .setExploreMetricsQueryField(PromQueryEditorByApp) .setAnnotationQueryCtrl(PrometheusAnnotationsQueryCtrl) .setQueryEditorHelp(PromCheatSheet); diff --git a/public/app/plugins/datasource/prometheus/promql.ts b/public/app/plugins/datasource/prometheus/promql.ts index 5128ffa2ff0..ae817cca80c 100644 --- a/public/app/plugins/datasource/prometheus/promql.ts +++ b/public/app/plugins/datasource/prometheus/promql.ts @@ -430,7 +430,7 @@ export const FUNCTIONS = [ export const PROM_KEYWORDS = FUNCTIONS.map((keyword) => keyword.label); -const tokenizer: Grammar = { +export const promqlGrammar: Grammar = { comment: { pattern: /#.*/, }, @@ -496,4 +496,4 @@ const tokenizer: Grammar = { punctuation: /[{};()`,.]/, }; -export default tokenizer; +export default promqlGrammar; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.test.ts b/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.test.ts new file mode 100644 index 00000000000..f4fffe81d2e --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.test.ts @@ -0,0 +1,200 @@ +import { PromQueryModeller } from './PromQueryModeller'; + +describe('PromQueryModeller', () => { + const modeller = new PromQueryModeller(); + + it('Can render query with metric only', () => { + expect( + modeller.renderQuery({ + metric: 'my_totals', + labels: [], + operations: [], + }) + ).toBe('my_totals'); + }); + + it('Can render query with label filters', () => { + expect( + modeller.renderQuery({ + metric: 'my_totals', + labels: [ + { label: 'cluster', op: '=', value: 'us-east' }, + { label: 'job', op: '=~', value: 'abc' }, + ], + operations: [], + }) + ).toBe('my_totals{cluster="us-east", job=~"abc"}'); + }); + + it('Can render query with function', () => { + expect( + modeller.renderQuery({ + metric: 'my_totals', + labels: [], + operations: [{ id: 'sum', params: [] }], + }) + ).toBe('sum(my_totals)'); + }); + + it('Can render query with function with parameter to left of inner expression', () => { + expect( + modeller.renderQuery({ + metric: 'metric', + labels: [], + operations: [{ id: 'histogram_quantile', params: [0.86] }], + }) + ).toBe('histogram_quantile(0.86, metric)'); + }); + + it('Can render query with function with function parameters to the right of inner expression', () => { + expect( + modeller.renderQuery({ + metric: 'metric', + labels: [], + operations: [{ id: 'label_replace', params: ['server', '$1', 'instance', 'as(.*)d'] }], + }) + ).toBe('label_replace(metric, "server", "$1", "instance", "as(.*)d")'); + }); + + it('Can group by expressions', () => { + expect( + modeller.renderQuery({ + metric: 'metric', + labels: [], + operations: [{ id: '__sum_by', params: ['server', 'job'] }], + }) + ).toBe('sum by(server, job) (metric)'); + }); + + it('Can render avg around a group by', () => { + expect( + modeller.renderQuery({ + metric: 'metric', + labels: [], + operations: [ + { id: '__sum_by', params: ['server', 'job'] }, + { id: 'avg', params: [] }, + ], + }) + ).toBe('avg(sum by(server, job) (metric))'); + }); + + it('Can render aggregations with parameters', () => { + expect( + modeller.renderQuery({ + metric: 'metric', + labels: [], + operations: [{ id: 'topk', params: [5] }], + }) + ).toBe('topk(5, metric)'); + }); + + it('Can render rate', () => { + expect( + modeller.renderQuery({ + metric: 'metric', + labels: [{ label: 'pod', op: '=', value: 'A' }], + operations: [{ id: 'rate', params: ['auto'] }], + }) + ).toBe('rate(metric{pod="A"}[$__rate_interval])'); + }); + + it('Can render increase', () => { + expect( + modeller.renderQuery({ + metric: 'metric', + labels: [{ label: 'pod', op: '=', value: 'A' }], + operations: [{ id: 'increase', params: ['auto'] }], + }) + ).toBe('increase(metric{pod="A"}[$__rate_interval])'); + }); + + it('Can render rate with custom range-vector', () => { + expect( + modeller.renderQuery({ + metric: 'metric', + labels: [{ label: 'pod', op: '=', value: 'A' }], + operations: [{ id: 'rate', params: ['10m'] }], + }) + ).toBe('rate(metric{pod="A"}[10m])'); + }); + + it('Can render multiply operation', () => { + expect( + modeller.renderQuery({ + metric: 'metric', + labels: [], + operations: [{ id: '__multiply_by', params: [1000] }], + }) + ).toBe('metric * 1000'); + }); + + it('Can render query with simple binary query', () => { + expect( + modeller.renderQuery({ + metric: 'metric_a', + labels: [], + operations: [], + binaryQueries: [ + { + operator: '/', + query: { + metric: 'metric_b', + labels: [], + operations: [], + }, + }, + ], + }) + ).toBe('metric_a / metric_b'); + }); + + it('Can render query with multiple binary queries and nesting', () => { + expect( + modeller.renderQuery({ + metric: 'metric_a', + labels: [], + operations: [], + binaryQueries: [ + { + operator: '+', + query: { + metric: 'metric_b', + labels: [], + operations: [], + }, + }, + { + operator: '+', + query: { + metric: 'metric_c', + labels: [], + operations: [], + }, + }, + ], + }) + ).toBe('metric_a + metric_b + metric_c'); + }); + + it('Can render with binary queries with vectorMatches expression', () => { + expect( + modeller.renderQuery({ + metric: 'metric_a', + labels: [], + operations: [], + binaryQueries: [ + { + operator: '/', + vectorMatches: 'on(le)', + query: { + metric: 'metric_b', + labels: [], + operations: [], + }, + }, + ], + }) + ).toBe('metric_a / on(le) metric_b'); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.ts b/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.ts new file mode 100644 index 00000000000..73cd0128d54 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.ts @@ -0,0 +1,72 @@ +import { FUNCTIONS } from '../promql'; +import { getAggregationOperations } from './aggregations'; +import { getOperationDefinitions } from './operations'; +import { LokiAndPromQueryModellerBase } from './shared/LokiAndPromQueryModellerBase'; +import { PromQueryPattern, PromVisualQuery, PromVisualQueryOperationCategory } from './types'; + +export class PromQueryModeller extends LokiAndPromQueryModellerBase { + constructor() { + super(() => { + const allOperations = [...getOperationDefinitions(), ...getAggregationOperations()]; + for (const op of allOperations) { + const func = FUNCTIONS.find((x) => x.insertText === op.id); + if (func) { + op.documentation = func.documentation; + } + } + return allOperations; + }); + + this.setOperationCategories([ + PromVisualQueryOperationCategory.Aggregations, + PromVisualQueryOperationCategory.RangeFunctions, + PromVisualQueryOperationCategory.Functions, + PromVisualQueryOperationCategory.BinaryOps, + ]); + } + + renderQuery(query: PromVisualQuery) { + let queryString = `${query.metric}${this.renderLabels(query.labels)}`; + queryString = this.renderOperations(queryString, query.operations); + queryString = this.renderBinaryQueries(queryString, query.binaryQueries); + return queryString; + } + + getQueryPatterns(): PromQueryPattern[] { + return [ + { + name: 'Rate then sum', + operations: [ + { id: 'rate', params: ['auto'] }, + { id: 'sum', params: [] }, + ], + }, + { + name: 'Rate then sum by(label) then avg', + operations: [ + { id: 'rate', params: ['auto'] }, + { id: '__sum_by', params: [''] }, + { id: 'avg', params: [] }, + ], + }, + { + name: 'Histogram quantile on rate', + operations: [ + { id: 'rate', params: ['auto'] }, + { id: '__sum_by', params: ['le'] }, + { id: 'histogram_quantile', params: [0.95] }, + ], + }, + { + name: 'Histogram quantile on increase ', + operations: [ + { id: 'increase', params: ['auto'] }, + { id: '__max_by', params: ['le'] }, + { id: 'histogram_quantile', params: [0.95] }, + ], + }, + ]; + } +} + +export const promQueryModeller = new PromQueryModeller(); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/aggregations.ts b/public/app/plugins/datasource/prometheus/querybuilder/aggregations.ts new file mode 100644 index 00000000000..ce34bee9faf --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/aggregations.ts @@ -0,0 +1,177 @@ +import pluralize from 'pluralize'; +import { LabelParamEditor } from './components/LabelParamEditor'; +import { addOperationWithRangeVector } from './operations'; +import { + defaultAddOperationHandler, + functionRendererLeft, + getPromAndLokiOperationDisplayName, +} from './shared/operationUtils'; +import { QueryBuilderOperation, QueryBuilderOperationDef, QueryBuilderOperationParamDef } from './shared/types'; +import { PromVisualQueryOperationCategory } from './types'; + +export function getAggregationOperations(): QueryBuilderOperationDef[] { + return [ + ...createAggregationOperation('sum'), + ...createAggregationOperation('avg'), + ...createAggregationOperation('min'), + ...createAggregationOperation('max'), + ...createAggregationOperation('count'), + ...createAggregationOperation('topk'), + createAggregationOverTime('sum'), + createAggregationOverTime('avg'), + createAggregationOverTime('min'), + createAggregationOverTime('max'), + createAggregationOverTime('count'), + createAggregationOverTime('last'), + createAggregationOverTime('present'), + createAggregationOverTime('stddev'), + createAggregationOverTime('stdvar'), + ]; +} + +function createAggregationOperation(name: string): QueryBuilderOperationDef[] { + const operations: QueryBuilderOperationDef[] = [ + { + id: name, + name: getPromAndLokiOperationDisplayName(name), + params: [ + { + name: 'By label', + type: 'string', + restParam: true, + optional: true, + }, + ], + defaultParams: [], + alternativesKey: 'plain aggregations', + category: PromVisualQueryOperationCategory.Aggregations, + renderer: functionRendererLeft, + addOperationHandler: defaultAddOperationHandler, + paramChangedHandler: getOnLabelAdddedHandler(`__${name}_by`), + }, + { + id: `__${name}_by`, + name: `${getPromAndLokiOperationDisplayName(name)} by`, + params: [ + { + name: 'Label', + type: 'string', + restParam: true, + optional: true, + editor: LabelParamEditor, + }, + ], + defaultParams: [''], + alternativesKey: 'aggregations by', + category: PromVisualQueryOperationCategory.Aggregations, + renderer: getAggregationByRenderer(name), + addOperationHandler: defaultAddOperationHandler, + paramChangedHandler: getLastLabelRemovedHandler(name), + explainHandler: getAggregationExplainer(name), + hideFromList: true, + }, + ]; + + // Handle some special aggregations that have parameters + if (name === 'topk') { + const param: QueryBuilderOperationParamDef = { + name: 'K-value', + type: 'number', + }; + operations[0].params.unshift(param); + operations[1].params.unshift(param); + operations[0].defaultParams = [5]; + operations[1].defaultParams = [5, '']; + operations[1].renderer = getAggregationByRendererWithParameter(name); + } + + return operations; +} + +function getAggregationByRenderer(aggregation: string) { + return function aggregationRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { + return `${aggregation} by(${model.params.join(', ')}) (${innerExpr})`; + }; +} + +/** + * Very simple poc implementation, needs to be modified to support all aggregation operators + */ +function getAggregationExplainer(aggregationName: string) { + return function aggregationExplainer(model: QueryBuilderOperation) { + const labels = model.params.map((label) => `\`${label}\``).join(' and '); + const labelWord = pluralize('label', model.params.length); + return `Calculates ${aggregationName} over dimensions while preserving ${labelWord} ${labels}.`; + }; +} + +function getAggregationByRendererWithParameter(aggregation: string) { + return function aggregationRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { + const firstParam = model.params[0]; + const restParams = model.params.slice(1); + return `${aggregation} by(${restParams.join(', ')}) (${firstParam}, ${innerExpr})`; + }; +} + +/** + * This function will transform operations without labels to their plan aggregation operation + */ +function getLastLabelRemovedHandler(changeToOperartionId: string) { + return function onParamChanged(index: number, op: QueryBuilderOperation, def: QueryBuilderOperationDef) { + // If definition has more params then is defined there are no optional rest params anymore + // We then transform this operation into a different one + if (op.params.length < def.params.length) { + return { + ...op, + id: changeToOperartionId, + }; + } + + return op; + }; +} + +function getOnLabelAdddedHandler(changeToOperartionId: string) { + return function onParamChanged(index: number, op: QueryBuilderOperation) { + return { + ...op, + id: changeToOperartionId, + }; + }; +} + +function createAggregationOverTime(name: string): QueryBuilderOperationDef { + const functionName = `${name}_over_time`; + return { + id: functionName, + name: getPromAndLokiOperationDisplayName(functionName), + params: [getAggregationOverTimeRangeVector()], + defaultParams: ['auto'], + alternativesKey: 'overtime function', + category: PromVisualQueryOperationCategory.RangeFunctions, + renderer: operationWithRangeVectorRenderer, + addOperationHandler: addOperationWithRangeVector, + }; +} + +function getAggregationOverTimeRangeVector(): QueryBuilderOperationParamDef { + return { + name: 'Range vector', + type: 'string', + options: ['auto', '$__interval', '$__range', '1m', '5m', '10m', '1h', '24h'], + }; +} + +function operationWithRangeVectorRenderer( + model: QueryBuilderOperation, + def: QueryBuilderOperationDef, + innerExpr: string +) { + let rangeVector = (model.params ?? [])[0] ?? 'auto'; + + if (rangeVector === 'auto') { + rangeVector = '$__interval'; + } + + return `${def.id}(${innerExpr}[${rangeVector}])`; +} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/LabelParamEditor.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/LabelParamEditor.tsx new file mode 100644 index 00000000000..51a3bde9d59 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/LabelParamEditor.tsx @@ -0,0 +1,49 @@ +import { SelectableValue, toOption } from '@grafana/data'; +import { Select } from '@grafana/ui'; +import React, { useState } from 'react'; +import { PrometheusDatasource } from '../../datasource'; +import { promQueryModeller } from '../PromQueryModeller'; +import { QueryBuilderOperationParamEditorProps } from '../shared/types'; +import { PromVisualQuery } from '../types'; + +export function LabelParamEditor({ onChange, index, value, query, datasource }: QueryBuilderOperationParamEditorProps) { + const [state, setState] = useState<{ + options?: Array>; + isLoading?: boolean; + }>({}); + + return ( + { + setState({ isLoading: true }); + const metrics = await loadMetrics(); + setState({ metrics, isLoading: undefined }); + }} + isLoading={state.isLoading} + options={state.metrics} + onChange={({ value }) => { + if (value) { + onChange({ ...query, metric: value, labels: [] }); + } + }} + /> + + + ); +} + +const getStyles = () => ({ + select: css` + min-width: 125px; + `, +}); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/NestedQuery.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/NestedQuery.tsx new file mode 100644 index 00000000000..e4f442b6a62 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/NestedQuery.tsx @@ -0,0 +1,104 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2, toOption } from '@grafana/data'; +import { FlexItem } from '@grafana/experimental'; +import { IconButton, Input, Select, useStyles2 } from '@grafana/ui'; +import React from 'react'; +import { PrometheusDatasource } from '../../datasource'; +import { PromVisualQueryBinary } from '../types'; +import { PromQueryBuilder } from './PromQueryBuilder'; + +export interface Props { + nestedQuery: PromVisualQueryBinary; + datasource: PrometheusDatasource; + index: number; + onChange: (index: number, update: PromVisualQueryBinary) => void; + onRemove: (index: number) => void; + onRunQuery: () => void; +} + +export const NestedQuery = React.memo(({ nestedQuery, index, datasource, onChange, onRemove, onRunQuery }) => { + const styles = useStyles2(getStyles); + + return ( +
+
+
Operator
+ { + onChange(index, { + ...nestedQuery, + vectorMatches: evt.currentTarget.value, + }); + }} + /> + + + onRemove(index)} /> +
+
+ { + onChange(index, { ...nestedQuery, query: update }); + }} + /> +
+
+ ); +}); + +const operators = [ + { label: '/', value: '/' }, + { label: '*', value: '*' }, + { label: '+', value: '+' }, + { label: '==', value: '==' }, + { label: '>', value: '>' }, + { label: '<', value: '<' }, +]; + +NestedQuery.displayName = 'NestedQuery'; + +const getStyles = (theme: GrafanaTheme2) => { + return { + card: css({ + background: theme.colors.background.primary, + border: `1px solid ${theme.colors.border.medium}`, + display: 'flex', + flexDirection: 'column', + cursor: 'grab', + borderRadius: theme.shape.borderRadius(1), + }), + header: css({ + borderBottom: `1px solid ${theme.colors.border.medium}`, + padding: theme.spacing(0.5, 0.5, 0.5, 1), + gap: theme.spacing(1), + display: 'flex', + alignItems: 'center', + }), + name: css({ + whiteSpace: 'nowrap', + }), + body: css({ + margin: theme.spacing(1, 1, 0.5, 1), + display: 'table', + }), + }; +}; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/NestedQueryList.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/NestedQueryList.tsx new file mode 100644 index 00000000000..e1aaa0bf943 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/NestedQueryList.tsx @@ -0,0 +1,73 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; +import { Stack } from '@grafana/experimental'; +import React from 'react'; +import { PrometheusDatasource } from '../../datasource'; +import { PromVisualQuery, PromVisualQueryBinary } from '../types'; +import { NestedQuery } from './NestedQuery'; + +export interface Props { + query: PromVisualQuery; + datasource: PrometheusDatasource; + onChange: (query: PromVisualQuery) => void; + onRunQuery: () => void; +} + +export function NestedQueryList({ query, datasource, onChange, onRunQuery }: Props) { + const styles = useStyles2(getStyles); + const nestedQueries = query.binaryQueries ?? []; + + const onNestedQueryUpdate = (index: number, update: PromVisualQueryBinary) => { + const updatedList = [...nestedQueries]; + updatedList.splice(index, 1, update); + onChange({ ...query, binaryQueries: updatedList }); + }; + + const onRemove = (index: number) => { + const updatedList = [...nestedQueries.slice(0, index), ...nestedQueries.slice(index + 1)]; + onChange({ ...query, binaryQueries: updatedList }); + }; + + return ( +
+ +
Binary operations
+ + {nestedQueries.map((nestedQuery, index) => ( + + ))} + +
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + heading: css({ + fontSize: 12, + fontWeight: theme.typography.fontWeightMedium, + }), + body: css({ + width: '100%', + }), + connectingLine: css({ + height: '2px', + width: '16px', + backgroundColor: theme.colors.border.strong, + alignSelf: 'center', + }), + addOperation: css({ + paddingLeft: theme.spacing(2), + }), + }; +}; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.test.tsx new file mode 100644 index 00000000000..00044ce2e85 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.test.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { render, screen, getByRole, getByText } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { PromQueryBuilder } from './PromQueryBuilder'; +import { PrometheusDatasource } from '../../datasource'; +import { EmptyLanguageProviderMock } from '../../language_provider.mock'; +import PromQlLanguageProvider from '../../language_provider'; +import { PromVisualQuery } from '../types'; +import { getLabelSelects } from '../testUtils'; + +const defaultQuery: PromVisualQuery = { + metric: 'random_metric', + labels: [], + operations: [], +}; + +const bugQuery: PromVisualQuery = { + metric: 'random_metric', + labels: [{ label: 'instance', op: '=', value: 'localhost:9090' }], + operations: [ + { + id: 'rate', + params: ['auto'], + }, + { + id: '__sum_by', + params: ['instance', 'job'], + }, + ], + binaryQueries: [ + { + operator: '/', + query: { + metric: 'metric2', + labels: [{ label: 'foo', op: '=', value: 'bar' }], + operations: [ + { + id: '__sum_by', + params: ['app'], + }, + ], + }, + }, + ], +}; + +describe('PromQueryBuilder', () => { + it('shows empty just with metric selected', async () => { + setup(); + // One should be select another query preview + expect(screen.getAllByText('random_metric').length).toBe(2); + // Add label + expect(screen.getByLabelText('Add')).toBeInTheDocument(); + expect(screen.getByLabelText('Add operation')).toBeInTheDocument(); + }); + + it('renders all the query sections', async () => { + setup(bugQuery); + expect(screen.getByText('random_metric')).toBeInTheDocument(); + expect(screen.getByText('localhost:9090')).toBeInTheDocument(); + expect(screen.getByText('Rate')).toBeInTheDocument(); + const sumBys = screen.getAllByTestId('operation-wrapper-for-__sum_by'); + expect(getByText(sumBys[0], 'instance')).toBeInTheDocument(); + expect(getByText(sumBys[0], 'job')).toBeInTheDocument(); + + expect(getByText(sumBys[1], 'app')).toBeInTheDocument(); + expect(screen.getByText('Binary operations')).toBeInTheDocument(); + expect(screen.getByText('Operator')).toBeInTheDocument(); + expect(screen.getByText('Vector matches')).toBeInTheDocument(); + expect(screen.getByLabelText('selector').textContent).toBe( + 'sum by(instance, job) (rate(random_metric{instance="localhost:9090"}[$__rate_interval])) / sum by(app) (metric2{foo="bar"})' + ); + }); + + it('tries to load metrics without labels', async () => { + const { languageProvider } = setup(); + openMetricSelect(); + expect(languageProvider.getLabelValues).toBeCalledWith('__name__'); + }); + + it('tries to load metrics with labels', async () => { + const { languageProvider } = setup({ + ...defaultQuery, + labels: [{ label: 'label_name', op: '=', value: 'label_value' }], + }); + openMetricSelect(); + expect(languageProvider.getSeries).toBeCalledWith('{label_name="label_value"}', true); + }); + + it('tries to load labels when metric selected', async () => { + const { languageProvider } = setup(); + openLabelNameSelect(); + expect(languageProvider.fetchSeriesLabels).toBeCalledWith('{__name__="random_metric"}'); + }); + + it('tries to load labels when metric selected and other labels are already present', async () => { + const { languageProvider } = setup({ + ...defaultQuery, + labels: [ + { label: 'label_name', op: '=', value: 'label_value' }, + { label: 'foo', op: '=', value: 'bar' }, + ], + }); + openLabelNameSelect(1); + expect(languageProvider.fetchSeriesLabels).toBeCalledWith('{label_name="label_value", __name__="random_metric"}'); + }); + + it('tries to load labels when metric is not selected', async () => { + const { languageProvider } = setup({ + ...defaultQuery, + metric: '', + }); + openLabelNameSelect(); + expect(languageProvider.fetchLabels).toBeCalled(); + }); +}); + +function setup(query: PromVisualQuery = defaultQuery) { + const languageProvider = (new EmptyLanguageProviderMock() as unknown) as PromQlLanguageProvider; + const props = { + datasource: new PrometheusDatasource( + { + url: '', + jsonData: {}, + meta: {} as any, + } as any, + undefined, + undefined, + languageProvider + ), + onRunQuery: () => {}, + onChange: () => {}, + }; + + render(); + return { languageProvider }; +} + +function getMetricSelect() { + const metricSelect = screen.getAllByText('random_metric')[0].parentElement!; + // We need to return specifically input element otherwise clicks don't seem to work + return getByRole(metricSelect, 'combobox'); +} + +function openMetricSelect() { + const select = getMetricSelect(); + userEvent.click(select); +} + +function openLabelNameSelect(index = 0) { + const { name } = getLabelSelects(index); + userEvent.click(name); +} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx new file mode 100644 index 00000000000..ee77f15392b --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { MetricSelect } from './MetricSelect'; +import { PromVisualQuery } from '../types'; +import { LabelFilters } from '../shared/LabelFilters'; +import { OperationList } from '../shared/OperationList'; +import { EditorRows, EditorRow } from '@grafana/experimental'; +import { PrometheusDatasource } from '../../datasource'; +import { NestedQueryList } from './NestedQueryList'; +import { promQueryModeller } from '../PromQueryModeller'; +import { QueryBuilderLabelFilter } from '../shared/types'; +import { QueryPreview } from './QueryPreview'; +import { DataSourceApi } from '@grafana/data'; +import { OperationsEditorRow } from '../shared/OperationsEditorRow'; + +export interface Props { + query: PromVisualQuery; + datasource: PrometheusDatasource; + onChange: (update: PromVisualQuery) => void; + onRunQuery: () => void; + nested?: boolean; +} + +export const PromQueryBuilder = React.memo(({ datasource, query, onChange, onRunQuery, nested }) => { + const onChangeLabels = (labels: QueryBuilderLabelFilter[]) => { + onChange({ ...query, labels }); + }; + + const onGetLabelNames = async (forLabel: Partial): Promise => { + // If no metric we need to use a different method + if (!query.metric) { + // Todo add caching but inside language provider! + await datasource.languageProvider.fetchLabels(); + return datasource.languageProvider.getLabelKeys(); + } + + const labelsToConsider = query.labels.filter((x) => x !== forLabel); + labelsToConsider.push({ label: '__name__', op: '=', value: query.metric }); + const expr = promQueryModeller.renderLabels(labelsToConsider); + const labelsIndex = await datasource.languageProvider.fetchSeriesLabels(expr); + + // filter out already used labels + return Object.keys(labelsIndex).filter( + (labelName) => !labelsToConsider.find((filter) => filter.label === labelName) + ); + }; + + const onGetLabelValues = async (forLabel: Partial) => { + if (!forLabel.label) { + return []; + } + + // If no metric we need to use a different method + if (!query.metric) { + return await datasource.languageProvider.getLabelValues(forLabel.label); + } + + const labelsToConsider = query.labels.filter((x) => x !== forLabel); + labelsToConsider.push({ label: '__name__', op: '=', value: query.metric }); + const expr = promQueryModeller.renderLabels(labelsToConsider); + const result = await datasource.languageProvider.fetchSeriesLabels(expr); + return result[forLabel.label] ?? []; + }; + + const onGetMetrics = async () => { + if (query.labels.length > 0) { + const expr = promQueryModeller.renderLabels(query.labels); + return (await datasource.languageProvider.getSeries(expr, true))['__name__'] ?? []; + } else { + return (await datasource.languageProvider.getLabelValues('__name__')) ?? []; + } + }; + + return ( + + + + + + + + queryModeller={promQueryModeller} + datasource={datasource as DataSourceApi} + query={query} + onChange={onChange} + onRunQuery={onRunQuery} + /> + {query.binaryQueries && query.binaryQueries.length > 0 && ( + + )} + + {!nested && ( + + + + )} + + ); +}); + +PromQueryBuilder.displayName = 'PromQueryBuilder'; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContext.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContext.tsx new file mode 100644 index 00000000000..528dc9beb7c --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContext.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { PrometheusDatasource } from '../../datasource'; +import { PromVisualQuery } from '../types'; + +export interface PromQueryBuilderContextType { + query: PromVisualQuery; + datasource: PrometheusDatasource; +} + +export const PromQueryBuilderContext = React.createContext( + ({} as any) as PromQueryBuilderContextType +); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderExplained.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderExplained.tsx new file mode 100644 index 00000000000..d24b93bd05b --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderExplained.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { PromVisualQuery } from '../types'; +import { Stack } from '@grafana/experimental'; +import { promQueryModeller } from '../PromQueryModeller'; +import { OperationListExplained } from '../shared/OperationListExplained'; +import { OperationExplainedBox } from '../shared/OperationExplainedBox'; + +export interface Props { + query: PromVisualQuery; + nested?: boolean; +} + +export const PromQueryBuilderExplained = React.memo(({ query, nested }) => { + return ( + + + Fetch all series matching metric name and label filters. + + stepNumber={2} queryModeller={promQueryModeller} query={query} /> + + ); +}); + +PromQueryBuilderExplained.displayName = 'PromQueryBuilderExplained'; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.test.tsx new file mode 100644 index 00000000000..3dbd0f7de29 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.test.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { PromQueryEditorSelector } from './PromQueryEditorSelector'; +import { PrometheusDatasource } from '../../datasource'; +import { QueryEditorMode } from '../shared/types'; +import { EmptyLanguageProviderMock } from '../../language_provider.mock'; +import PromQlLanguageProvider from '../../language_provider'; + +// We need to mock this because it seems jest has problem importing monaco in tests +jest.mock('../../components/monaco-query-field/MonacoQueryFieldWrapper', () => { + return { + MonacoQueryFieldWrapper: () => { + return 'MonacoQueryFieldWrapper'; + }, + }; +}); + +const defaultQuery = { + refId: 'A', + expr: 'metric{label1="foo", label2="bar"}', +}; + +const defaultProps = { + datasource: new PrometheusDatasource( + { + id: 1, + uid: '', + type: 'prometheus', + name: 'prom-test', + access: 'proxy', + url: '', + jsonData: {}, + meta: {} as any, + }, + undefined, + undefined, + (new EmptyLanguageProviderMock() as unknown) as PromQlLanguageProvider + ), + query: defaultQuery, + onRunQuery: () => {}, + onChange: () => {}, +}; + +describe('PromQueryEditorSelector', () => { + it('shows code editor if expr and nothing else', async () => { + // We opt for showing code editor for queries created before this feature was added + render(); + expectCodeEditor(); + }); + + it('shows builder if new query', async () => { + render( + + ); + expectBuilder(); + }); + + it('shows code editor when code mode is set', async () => { + renderWithMode(QueryEditorMode.Code); + expectCodeEditor(); + }); + + it('shows builder when builder mode is set', async () => { + renderWithMode(QueryEditorMode.Builder); + expectBuilder(); + }); + + it('shows explain when explain mode is set', async () => { + renderWithMode(QueryEditorMode.Explain); + expectExplain(); + }); + + it('changes to builder mode', async () => { + const { onChange } = renderWithMode(QueryEditorMode.Code); + switchToMode(QueryEditorMode.Builder); + expect(onChange).toBeCalledWith({ + refId: 'A', + expr: '', + editorMode: QueryEditorMode.Builder, + }); + }); + + it('changes to code mode', async () => { + const { onChange } = renderWithMode(QueryEditorMode.Builder); + switchToMode(QueryEditorMode.Code); + expect(onChange).toBeCalledWith({ + refId: 'A', + expr: '', + editorMode: QueryEditorMode.Code, + }); + }); + + it('changes to explain mode', async () => { + const { onChange } = renderWithMode(QueryEditorMode.Code); + switchToMode(QueryEditorMode.Explain); + expect(onChange).toBeCalledWith({ + refId: 'A', + expr: '', + editorMode: QueryEditorMode.Explain, + }); + }); +}); + +function renderWithMode(mode: QueryEditorMode) { + const onChange = jest.fn(); + render( + + ); + return { onChange }; +} + +function expectCodeEditor() { + // Metric browser shows this until metrics are loaded. + expect(screen.getByText('Loading metrics...')).toBeInTheDocument(); +} + +function expectBuilder() { + expect(screen.getByText('Select metric')).toBeInTheDocument(); +} + +function expectExplain() { + // Base message when there is no query + expect(screen.getByText(/Fetch all series/)).toBeInTheDocument(); +} + +function switchToMode(mode: QueryEditorMode) { + const label = { + [QueryEditorMode.Code]: 'Code', + [QueryEditorMode.Explain]: 'Explain', + [QueryEditorMode.Builder]: 'Builder', + }[mode]; + + const switchEl = screen.getByLabelText(label); + userEvent.click(switchEl); +} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx new file mode 100644 index 00000000000..3af4c13a98c --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx @@ -0,0 +1,124 @@ +import { css } from '@emotion/css'; +import { CoreApp, GrafanaTheme2, LoadingState } from '@grafana/data'; +import { EditorHeader, FlexItem, InlineSelect, Space, Stack } from '@grafana/experimental'; +import { Button, Switch, useStyles2 } from '@grafana/ui'; +import React, { SyntheticEvent, useCallback, useState } from 'react'; +import { PromQueryEditor } from '../../components/PromQueryEditor'; +import { PromQueryEditorProps } from '../../components/types'; +import { promQueryModeller } from '../PromQueryModeller'; +import { QueryEditorModeToggle } from '../shared/QueryEditorModeToggle'; +import { QueryEditorMode } from '../shared/types'; +import { getDefaultEmptyQuery, PromVisualQuery } from '../types'; +import { PromQueryBuilder } from './PromQueryBuilder'; +import { PromQueryBuilderExplained } from './PromQueryBuilderExplained'; + +export const PromQueryEditorSelector = React.memo((props) => { + const { query, onChange, onRunQuery, data } = props; + const styles = useStyles2(getStyles); + const [visualQuery, setVisualQuery] = useState(query.visualQuery ?? getDefaultEmptyQuery()); + + const onEditorModeChange = useCallback( + (newMetricEditorMode: QueryEditorMode) => { + onChange({ ...query, editorMode: newMetricEditorMode }); + }, + [onChange, query] + ); + + const onChangeViewModel = (updatedQuery: PromVisualQuery) => { + setVisualQuery(updatedQuery); + + onChange({ + ...query, + expr: promQueryModeller.renderQuery(updatedQuery), + visualQuery: updatedQuery, + editorMode: QueryEditorMode.Builder, + }); + }; + + const onInstantChange = (event: SyntheticEvent) => { + const isEnabled = event.currentTarget.checked; + onChange({ ...query, instant: isEnabled, exemplar: false }); + onRunQuery(); + }; + + const onExemplarChange = (event: SyntheticEvent) => { + const isEnabled = event.currentTarget.checked; + onChange({ ...query, exemplar: isEnabled }); + onRunQuery(); + }; + + // If no expr (ie new query) then default to builder + const editorMode = query.editorMode ?? (query.expr ? QueryEditorMode.Code : QueryEditorMode.Builder); + const showExemplarSwitch = props.app !== CoreApp.UnifiedAlerting && !query.instant; + + return ( + <> + + + + + + + + {showExemplarSwitch && ( + + + + + )} + {editorMode === QueryEditorMode.Builder && ( + <> + { + onChangeViewModel({ + ...visualQuery, + operations: value?.operations!, + }); + }} + options={promQueryModeller.getQueryPatterns().map((x) => ({ label: x.name, value: x }))} + /> + + )} + + + + {editorMode === QueryEditorMode.Code && } + {editorMode === QueryEditorMode.Builder && ( + + )} + {editorMode === QueryEditorMode.Explain && } + + ); +}); + +PromQueryEditorSelector.displayName = 'PromQueryEditorSelector'; + +const getStyles = (theme: GrafanaTheme2) => { + return { + runQuery: css({ + color: theme.colors.text.secondary, + }), + switchLabel: css({ + color: theme.colors.text.secondary, + fontSize: theme.typography.bodySmall.fontSize, + }), + }; +}; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/QueryPreview.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/QueryPreview.tsx new file mode 100644 index 00000000000..3d797f664f9 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/QueryPreview.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { PromVisualQuery } from '../types'; +import { useTheme2 } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { promQueryModeller } from '../PromQueryModeller'; +import { css, cx } from '@emotion/css'; +import { EditorField, EditorFieldGroup } from '@grafana/experimental'; +import Prism from 'prismjs'; +import { promqlGrammar } from '../../promql'; + +export interface Props { + query: PromVisualQuery; +} + +export function QueryPreview({ query }: Props) { + const theme = useTheme2(); + const styles = getStyles(theme); + const hightlighted = Prism.highlight(promQueryModeller.renderQuery(query), promqlGrammar, 'promql'); + + return ( + + +
+ + + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + editorField: css({ + padding: theme.spacing(0.25, 1), + fontFamily: theme.typography.fontFamilyMonospace, + fontSize: theme.typography.bodySmall.fontSize, + }), + }; +}; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/operations.ts b/public/app/plugins/datasource/prometheus/querybuilder/operations.ts new file mode 100644 index 00000000000..7521867fb6a --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/operations.ts @@ -0,0 +1,176 @@ +import { + defaultAddOperationHandler, + functionRendererLeft, + functionRendererRight, + getPromAndLokiOperationDisplayName, +} from './shared/operationUtils'; +import { + QueryBuilderOperation, + QueryBuilderOperationDef, + QueryBuilderOperationParamDef, + VisualQueryModeller, +} from './shared/types'; +import { PromVisualQuery, PromVisualQueryOperationCategory } from './types'; + +export function getOperationDefinitions(): QueryBuilderOperationDef[] { + const list: QueryBuilderOperationDef[] = [ + { + id: 'histogram_quantile', + name: 'Histogram quantile', + params: [{ name: 'Quantile', type: 'number', options: [0.99, 0.95, 0.9, 0.75, 0.5, 0.25] }], + defaultParams: [0.9], + category: PromVisualQueryOperationCategory.Functions, + renderer: functionRendererLeft, + addOperationHandler: defaultAddOperationHandler, + }, + { + id: 'label_replace', + name: 'Label replace', + params: [ + { name: 'Destination label', type: 'string' }, + { name: 'Replacement', type: 'string' }, + { name: 'Source label', type: 'string' }, + { name: 'Regex', type: 'string' }, + ], + category: PromVisualQueryOperationCategory.Functions, + defaultParams: ['', '$1', '', '(.*)'], + renderer: functionRendererRight, + addOperationHandler: defaultAddOperationHandler, + }, + { + id: 'ln', + name: 'Ln', + params: [], + defaultParams: [], + category: PromVisualQueryOperationCategory.Functions, + renderer: functionRendererLeft, + addOperationHandler: defaultAddOperationHandler, + }, + createRangeFunction('changes'), + createRangeFunction('rate'), + createRangeFunction('irate'), + createRangeFunction('increase'), + createRangeFunction('delta'), + // Not sure about this one. It could also be a more generic "Simple math operation" where user specifies + // both the operator and the operand in a single input + { + id: '__multiply_by', + name: 'Multiply by scalar', + params: [{ name: 'Factor', type: 'number' }], + defaultParams: [2], + category: PromVisualQueryOperationCategory.BinaryOps, + renderer: getSimpleBinaryRenderer('*'), + addOperationHandler: defaultAddOperationHandler, + }, + { + id: '__divide_by', + name: 'Divide by scalar', + params: [{ name: 'Factor', type: 'number' }], + defaultParams: [2], + category: PromVisualQueryOperationCategory.BinaryOps, + renderer: getSimpleBinaryRenderer('/'), + addOperationHandler: defaultAddOperationHandler, + }, + { + id: '__nested_query', + name: 'Binary operation with query', + params: [], + defaultParams: [], + category: PromVisualQueryOperationCategory.BinaryOps, + renderer: (model, def, innerExpr) => innerExpr, + addOperationHandler: addNestedQueryHandler, + }, + ]; + + return list; +} + +function createRangeFunction(name: string): QueryBuilderOperationDef { + return { + id: name, + name: getPromAndLokiOperationDisplayName(name), + params: [getRangeVectorParamDef()], + defaultParams: ['auto'], + alternativesKey: 'range function', + category: PromVisualQueryOperationCategory.RangeFunctions, + renderer: operationWithRangeVectorRenderer, + addOperationHandler: addOperationWithRangeVector, + }; +} + +function operationWithRangeVectorRenderer( + model: QueryBuilderOperation, + def: QueryBuilderOperationDef, + innerExpr: string +) { + let rangeVector = (model.params ?? [])[0] ?? 'auto'; + + if (rangeVector === 'auto') { + rangeVector = '$__rate_interval'; + } + + return `${def.id}(${innerExpr}[${rangeVector}])`; +} + +function getSimpleBinaryRenderer(operator: string) { + return function binaryRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { + return `${innerExpr} ${operator} ${model.params[0]}`; + }; +} + +function getRangeVectorParamDef(): QueryBuilderOperationParamDef { + return { + name: 'Range vector', + type: 'string', + options: ['auto', '$__rate_interval', '$__interval', '$__range', '1m', '5m', '10m', '1h', '24h'], + }; +} + +/** + * Since there can only be one operation with range vector this will replace the current one (if one was added ) + */ +export function addOperationWithRangeVector( + def: QueryBuilderOperationDef, + query: PromVisualQuery, + modeller: VisualQueryModeller +) { + if (query.operations.length > 0) { + const firstOp = modeller.getOperationDef(query.operations[0].id); + + if (firstOp.addOperationHandler === addOperationWithRangeVector) { + return { + ...query, + operations: [ + { + ...query.operations[0], + id: def.id, + }, + ...query.operations.slice(1), + ], + }; + } + } + + const newOperation: QueryBuilderOperation = { + id: def.id, + params: def.defaultParams, + }; + + return { + ...query, + operations: [newOperation, ...query.operations], + }; +} + +function addNestedQueryHandler(def: QueryBuilderOperationDef, query: PromVisualQuery): PromVisualQuery { + return { + ...query, + binaryQueries: [ + ...(query.binaryQueries ?? []), + { + operator: '/', + query, + }, + ], + }; +} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilterItem.tsx b/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilterItem.tsx new file mode 100644 index 00000000000..be5ecb82d4a --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilterItem.tsx @@ -0,0 +1,123 @@ +import React, { useState } from 'react'; +import { Select } from '@grafana/ui'; +import { SelectableValue, toOption } from '@grafana/data'; +import { QueryBuilderLabelFilter } from './types'; +import { AccessoryButton, InputGroup } from '@grafana/experimental'; + +export interface Props { + defaultOp: string; + item: Partial; + onChange: (value: QueryBuilderLabelFilter) => void; + onGetLabelNames: (forLabel: Partial) => Promise; + onGetLabelValues: (forLabel: Partial) => Promise; + onDelete: () => void; +} + +export function LabelFilterItem({ item, defaultOp, onChange, onDelete, onGetLabelNames, onGetLabelValues }: Props) { + const [state, setState] = useState<{ + labelNames?: Array>; + labelValues?: Array>; + isLoadingLabelNames?: boolean; + isLoadingLabelValues?: boolean; + }>({}); + + const isMultiSelect = () => { + return item.op === operators[0].label; + }; + + const getValue = (item: any) => { + if (item && item.value) { + if (item.value.indexOf('|') > 0) { + return item.value.split('|').map((x: any) => ({ label: x, value: x })); + } + return toOption(item.value); + } + return null; + }; + + const getOptions = () => { + if (!state.labelValues && item && item.value && item.value.indexOf('|') > 0) { + return getValue(item); + } + + return state.labelValues; + }; + + return ( +
+ + { + if (change.value != null) { + onChange(({ ...item, op: change.value } as any) as QueryBuilderLabelFilter); + } + }} + /> + + { + if (value.value) { + onChange(index, { + ...operation, + id: value.value.id, + }); + } + }} + /> + )} + + ); +}); + +OperationName.displayName = 'OperationName'; + +const getStyles = (theme: GrafanaTheme2) => { + return { + wrapper: css({ + display: 'inline-block', + background: 'transparent', + padding: 0, + border: 'none', + boxShadow: 'none', + cursor: 'pointer', + }), + dropdown: css({ + opacity: 0, + color: theme.colors.text.secondary, + }), + }; +}; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationParamEditor.tsx b/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationParamEditor.tsx new file mode 100644 index 00000000000..d89ef0f34b5 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationParamEditor.tsx @@ -0,0 +1,53 @@ +import { toOption } from '@grafana/data'; +import { Input, Select } from '@grafana/ui'; +import React, { ComponentType } from 'react'; +import { QueryBuilderOperationParamDef, QueryBuilderOperationParamEditorProps } from '../shared/types'; + +export function getOperationParamEditor( + paramDef: QueryBuilderOperationParamDef +): ComponentType { + if (paramDef.editor) { + return paramDef.editor; + } + + if (paramDef.options) { + return SelectInputParamEditor; + } + + return SimpleInputParamEditor; +} + +function SimpleInputParamEditor(props: QueryBuilderOperationParamEditorProps) { + return ( + { + if (evt.key === 'Enter') { + if (evt.currentTarget.value !== props.value) { + props.onChange(props.index, evt.currentTarget.value); + } + props.onRunQuery(); + } + }} + onBlur={(evt) => { + props.onChange(props.index, evt.currentTarget.value); + }} + /> + ); +} + +function SelectInputParamEditor({ paramDef, value, index, onChange }: QueryBuilderOperationParamEditorProps) { + const selectOptions = paramDef.options!.map((option) => ({ + label: option as string, + value: option as string, + })); + + return ( +