PromQueryBuilder: Query builder and components that can be shared with a loki query builder and others (#42854)

pull/44632/head
Torkel Ödegaard 3 years ago committed by GitHub
parent a660ccc6e4
commit 64e1e91403
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 27
      packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.tsx
  2. 11
      packages/grafana-ui/src/components/ButtonCascader/_ButtonCascader.scss
  3. 2
      packages/grafana-ui/src/components/Forms/Legacy/Select/Select.tsx
  4. 2
      packages/grafana-ui/src/components/Select/types.ts
  5. 3
      packages/grafana-ui/src/themes/_variables.dark.scss.tmpl.ts
  6. 3
      packages/grafana-ui/src/themes/_variables.light.scss.tmpl.ts
  7. 6
      public/app/angular/components/query_part.ts
  8. 2
      public/app/features/query/components/QueryEditorRow.tsx
  9. 5
      public/app/plugins/datasource/loki/components/LokiQueryEditorByApp.tsx
  10. 3
      public/app/plugins/datasource/loki/module.ts
  11. 188
      public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.test.ts
  12. 61
      public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.ts
  13. 80
      public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx
  14. 24
      public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderExplaind.tsx
  15. 105
      public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.tsx
  16. 41
      public/app/plugins/datasource/loki/querybuilder/components/QueryPreview.tsx
  17. 300
      public/app/plugins/datasource/loki/querybuilder/operations.ts
  18. 58
      public/app/plugins/datasource/loki/querybuilder/types.ts
  19. 7
      public/app/plugins/datasource/loki/syntax.ts
  20. 10
      public/app/plugins/datasource/loki/types.ts
  21. 5
      public/app/plugins/datasource/prometheus/components/PromQueryEditorByApp.tsx
  22. 5
      public/app/plugins/datasource/prometheus/datasource.ts
  23. 15
      public/app/plugins/datasource/prometheus/language_provider.mock.ts
  24. 3
      public/app/plugins/datasource/prometheus/module.ts
  25. 4
      public/app/plugins/datasource/prometheus/promql.ts
  26. 200
      public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.test.ts
  27. 72
      public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.ts
  28. 177
      public/app/plugins/datasource/prometheus/querybuilder/aggregations.ts
  29. 49
      public/app/plugins/datasource/prometheus/querybuilder/components/LabelParamEditor.tsx
  30. 58
      public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx
  31. 104
      public/app/plugins/datasource/prometheus/querybuilder/components/NestedQuery.tsx
  32. 73
      public/app/plugins/datasource/prometheus/querybuilder/components/NestedQueryList.tsx
  33. 153
      public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.test.tsx
  34. 105
      public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx
  35. 12
      public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContext.tsx
  36. 24
      public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderExplained.tsx
  37. 150
      public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.test.tsx
  38. 124
      public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx
  39. 41
      public/app/plugins/datasource/prometheus/querybuilder/components/QueryPreview.tsx
  40. 176
      public/app/plugins/datasource/prometheus/querybuilder/operations.ts
  41. 123
      public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilterItem.tsx
  42. 65
      public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.test.tsx
  43. 50
      public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.tsx
  44. 88
      public/app/plugins/datasource/prometheus/querybuilder/shared/LokiAndPromQueryModellerBase.ts
  45. 260
      public/app/plugins/datasource/prometheus/querybuilder/shared/OperationEditor.tsx
  46. 75
      public/app/plugins/datasource/prometheus/querybuilder/shared/OperationExplainedBox.tsx
  47. 102
      public/app/plugins/datasource/prometheus/querybuilder/shared/OperationInfoButton.tsx
  48. 95
      public/app/plugins/datasource/prometheus/querybuilder/shared/OperationList.test.tsx
  49. 128
      public/app/plugins/datasource/prometheus/querybuilder/shared/OperationList.tsx
  50. 24
      public/app/plugins/datasource/prometheus/querybuilder/shared/OperationListExplained.tsx
  51. 92
      public/app/plugins/datasource/prometheus/querybuilder/shared/OperationName.tsx
  52. 53
      public/app/plugins/datasource/prometheus/querybuilder/shared/OperationParamEditor.tsx
  53. 29
      public/app/plugins/datasource/prometheus/querybuilder/shared/OperationsEditorRow.tsx
  54. 18
      public/app/plugins/datasource/prometheus/querybuilder/shared/QueryEditorModeToggle.tsx
  55. 51
      public/app/plugins/datasource/prometheus/querybuilder/shared/operationUtils.ts
  56. 97
      public/app/plugins/datasource/prometheus/querybuilder/shared/types.ts
  57. 10
      public/app/plugins/datasource/prometheus/querybuilder/testUtils.ts
  58. 36
      public/app/plugins/datasource/prometheus/querybuilder/types.ts
  59. 5
      public/app/plugins/datasource/prometheus/types.ts
  60. 21
      public/app/plugins/datasource/zipkin/QueryField.tsx
  61. 3
      public/sass/_variables.dark.generated.scss
  62. 3
      public/sass/_variables.light.generated.scss
  63. 2
      public/sass/components/_gf-form.scss
  64. 4
      public/sass/components/_query_editor.scss
  65. 3
      public/sass/components/_slate_editor.scss

@ -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<ButtonCascaderProps> = (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, <Icon key={'down-icon'} name="angle-down" className={styles.icons.right} />];
}
return (
<RCCascader
onChange={onChangeCascader(onChange)}
@ -52,11 +63,9 @@ export const ButtonCascader: React.FC<ButtonCascaderProps> = (props) => {
{...rest}
expandIcon={null}
>
<button className={cx('gf-form-label', className)} disabled={props.disabled}>
{icon && <Icon name={icon} className={styles.icons.left} />}
{props.children}
<Icon name="angle-down" className={styles.icons.right} />
</button>
<Button icon={icon} disabled={disabled} variant={variant} {...(buttonProps ?? {})}>
{content}
</Button>
</RCCascader>
);
};

@ -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;
}
}

@ -23,7 +23,7 @@ import { ThemeContext } from '../../../../themes';
* - noOptionsMessage & loadingMessage is of string type
* - isDisabled is renamed to disabled
*/
type LegacyCommonProps<T> = Omit<SelectCommonProps<T>, 'noOptionsMessage' | 'disabled' | 'value'>;
type LegacyCommonProps<T> = Omit<SelectCommonProps<T>, 'noOptionsMessage' | 'disabled' | 'value' | 'loadingMessage'>;
interface AsyncProps<T> extends LegacyCommonProps<T>, Omit<SelectAsyncProps<T>, 'loadingMessage'> {
loadingMessage?: () => string;

@ -79,6 +79,8 @@ export interface SelectCommonProps<T> {
value: SelectableValue<T> | null,
options: OptionsOrGroups<unknown, GroupBase<unknown>>
) => boolean;
/** Message to display isLoading=true*/
loadingMessage?: string;
}
export interface SelectAsyncProps<T> {

@ -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};

@ -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};

@ -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] + '"';
}

@ -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;

@ -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 <LokiQueryEditorForAlerting {...props} />;
default:
if (config.featureToggles.lokiQueryBuilder) {
return <LokiQueryEditorSelector {...props} />;
}
return <LokiQueryEditor {...props} />;
}
}

@ -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);

@ -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);
});
});
});

@ -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<LokiVisualQuery> {
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();

@ -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<Props>(({ datasource, query, nested, onChange, onRunQuery }) => {
const onChangeLabels = (labels: QueryBuilderLabelFilter[]) => {
onChange({ ...query, labels });
};
const onGetLabelNames = async (forLabel: Partial<QueryBuilderLabelFilter>): Promise<any> => {
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<QueryBuilderLabelFilter>) => {
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 (
<EditorRows>
<EditorRow>
<LabelFilters
onGetLabelNames={onGetLabelNames}
onGetLabelValues={onGetLabelValues}
labelsFilters={query.labels}
onChange={onChangeLabels}
/>
</EditorRow>
<EditorRow>
<OperationList
queryModeller={lokiQueryModeller}
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
datasource={datasource as DataSourceApi}
/>
</EditorRow>
{!nested && (
<EditorRow>
<QueryPreview query={query} />
</EditorRow>
)}
</EditorRows>
);
});
LokiQueryBuilder.displayName = 'LokiQueryBuilder';

@ -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<Props>(({ query, nested }) => {
return (
<Stack gap={0} direction="column">
<OperationExplainedBox stepNumber={1} title={`${lokiQueryModeller.renderLabels(query.labels)}`}>
Fetch all log lines matching label filters.
</OperationExplainedBox>
<OperationListExplained<LokiVisualQuery> stepNumber={2} queryModeller={lokiQueryModeller} query={query} />
</Stack>
);
});
LokiQueryBuilderExplained.displayName = 'LokiQueryBuilderExplained';

@ -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<LokiQueryEditorProps>((props) => {
const { query, onChange, onRunQuery, data } = props;
const styles = useStyles2(getStyles);
const [visualQuery, setVisualQuery] = useState<LokiVisualQuery>(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 (
<>
<EditorHeader>
<FlexItem grow={1} />
<Button
className={styles.runQuery}
variant="secondary"
size="sm"
fill="outline"
onClick={onRunQuery}
icon={data?.state === LoadingState.Loading ? 'fa fa-spinner' : undefined}
disabled={data?.state === LoadingState.Loading}
>
Run query
</Button>
<Stack gap={1}>
<label className={styles.switchLabel}>Instant</label>
<Switch />
</Stack>
<Stack gap={1}>
<label className={styles.switchLabel}>Exemplars</label>
<Switch />
</Stack>
<InlineSelect
value={null}
placeholder="Query patterns"
allowCustomValue
onChange={({ value }) => {
onChangeViewModel({
...visualQuery,
operations: value?.operations!,
});
}}
options={lokiQueryModeller.getQueryPatterns().map((x) => ({ label: x.name, value: x }))}
/>
<QueryEditorModeToggle mode={editorMode} onChange={onEditorModeChange} />
</EditorHeader>
<Space v={0.5} />
{editorMode === QueryEditorMode.Code && <LokiQueryEditor {...props} />}
{editorMode === QueryEditorMode.Builder && (
<LokiQueryBuilder
datasource={props.datasource}
query={visualQuery}
onChange={onChangeViewModel}
onRunQuery={props.onRunQuery}
/>
)}
{editorMode === QueryEditorMode.Explain && <LokiQueryBuilderExplained query={visualQuery} />}
</>
);
});
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,
}),
};
};

@ -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 (
<EditorFieldGroup>
<EditorField label="Query text">
<div
className={cx(styles.editorField, 'prism-syntax-highlight')}
aria-label="selector"
dangerouslySetInnerHTML={{ __html: hightlighted }}
/>
</EditorField>
</EditorFieldGroup>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
editorField: css({
padding: theme.spacing(0.25, 1),
fontFamily: theme.typography.fontFamilyMonospace,
fontSize: theme.typography.bodySmall.fontSize,
}),
};
};

@ -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,
};
}

@ -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: [''] }],
};
}

@ -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;

@ -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 {

@ -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 <PromQueryEditorForAlerting {...props} />;
default:
if (config.featureToggles.promQueryBuilder) {
return <PromQueryEditorSelector {...props} />;
}
return <PromQueryEditor {...props} />;
}
}

@ -84,7 +84,8 @@ export class PrometheusDatasource
constructor(
instanceSettings: DataSourceInstanceSettings<PromOptions>,
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);

@ -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();
}

@ -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);

@ -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;

@ -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');
});
});

@ -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<PromVisualQuery> {
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();

@ -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}])`;
}

@ -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<SelectableValue<any>>;
isLoading?: boolean;
}>({});
return (
<Select
menuShouldPortal
autoFocus={value === '' ? true : undefined}
openMenuOnFocus
onOpenMenu={async () => {
setState({ isLoading: true });
const options = await loadGroupByLabels(query as PromVisualQuery, datasource as PrometheusDatasource);
setState({ options, isLoading: undefined });
}}
isLoading={state.isLoading}
allowCustomValue
noOptionsMessage="No labels found"
loadingMessage="Loading labels"
options={state.options}
value={toOption(value as string)}
onChange={(value) => onChange(index, value.value!)}
/>
);
}
async function loadGroupByLabels(
query: PromVisualQuery,
datasource: PrometheusDatasource
): Promise<Array<SelectableValue<any>>> {
const labels = [{ label: '__name__', op: '=', value: query.metric }, ...query.labels];
const expr = promQueryModeller.renderLabels(labels);
const result = await datasource.languageProvider.fetchSeriesLabels(expr);
return Object.keys(result).map((x) => ({
label: x,
value: x,
}));
}

@ -0,0 +1,58 @@
import { Select } from '@grafana/ui';
import React, { useState } from 'react';
import { PromVisualQuery } from '../types';
import { SelectableValue, toOption } from '@grafana/data';
import { EditorField, EditorFieldGroup } from '@grafana/experimental';
import { css } from '@emotion/css';
export interface Props {
query: PromVisualQuery;
onChange: (query: PromVisualQuery) => void;
onGetMetrics: () => Promise<string[]>;
}
export function MetricSelect({ query, onChange, onGetMetrics }: Props) {
const styles = getStyles();
const [state, setState] = useState<{
metrics?: Array<SelectableValue<any>>;
isLoading?: boolean;
}>({});
const loadMetrics = async () => {
return await onGetMetrics().then((res) => {
return res.map((value) => ({ label: value, value }));
});
};
return (
<EditorFieldGroup>
<EditorField label="Metric">
<Select
inputId="prometheus-metric-select"
className={styles.select}
value={query.metric ? toOption(query.metric) : undefined}
placeholder="Select metric"
allowCustomValue
onOpenMenu={async () => {
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: [] });
}
}}
/>
</EditorField>
</EditorFieldGroup>
);
}
const getStyles = () => ({
select: css`
min-width: 125px;
`,
});

@ -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<Props>(({ nestedQuery, index, datasource, onChange, onRemove, onRunQuery }) => {
const styles = useStyles2(getStyles);
return (
<div className={styles.card}>
<div className={styles.header}>
<div className={styles.name}>Operator</div>
<Select
width="auto"
options={operators}
value={toOption(nestedQuery.operator)}
onChange={(value) => {
onChange(index, {
...nestedQuery,
operator: value.value!,
});
}}
/>
<div className={styles.name}>Vector matches</div>
<Input
width={20}
defaultValue={nestedQuery.vectorMatches}
onBlur={(evt) => {
onChange(index, {
...nestedQuery,
vectorMatches: evt.currentTarget.value,
});
}}
/>
<FlexItem grow={1} />
<IconButton name="times" size="sm" onClick={() => onRemove(index)} />
</div>
<div className={styles.body}>
<PromQueryBuilder
query={nestedQuery.query}
datasource={datasource}
nested={true}
onRunQuery={onRunQuery}
onChange={(update) => {
onChange(index, { ...nestedQuery, query: update });
}}
/>
</div>
</div>
);
});
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',
}),
};
};

@ -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 (
<div className={styles.body}>
<Stack gap={1} direction="column">
<h5 className={styles.heading}>Binary operations</h5>
<Stack gap={1} direction="column">
{nestedQueries.map((nestedQuery, index) => (
<NestedQuery
key={index.toString()}
nestedQuery={nestedQuery}
index={index}
onChange={onNestedQueryUpdate}
datasource={datasource}
onRemove={onRemove}
onRunQuery={onRunQuery}
/>
))}
</Stack>
</Stack>
</div>
);
}
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),
}),
};
};

@ -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(<PromQueryBuilder {...props} query={query} />);
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);
}

@ -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<Props>(({ datasource, query, onChange, onRunQuery, nested }) => {
const onChangeLabels = (labels: QueryBuilderLabelFilter[]) => {
onChange({ ...query, labels });
};
const onGetLabelNames = async (forLabel: Partial<QueryBuilderLabelFilter>): Promise<string[]> => {
// 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<QueryBuilderLabelFilter>) => {
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 (
<EditorRows>
<EditorRow>
<MetricSelect query={query} onChange={onChange} onGetMetrics={onGetMetrics} />
<LabelFilters
labelsFilters={query.labels}
onChange={onChangeLabels}
onGetLabelNames={onGetLabelNames}
onGetLabelValues={onGetLabelValues}
/>
</EditorRow>
<OperationsEditorRow>
<OperationList<PromVisualQuery>
queryModeller={promQueryModeller}
datasource={datasource as DataSourceApi}
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
/>
{query.binaryQueries && query.binaryQueries.length > 0 && (
<NestedQueryList query={query} datasource={datasource} onChange={onChange} onRunQuery={onRunQuery} />
)}
</OperationsEditorRow>
{!nested && (
<EditorRow>
<QueryPreview query={query} />
</EditorRow>
)}
</EditorRows>
);
});
PromQueryBuilder.displayName = 'PromQueryBuilder';

@ -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<PromQueryBuilderContextType>(
({} as any) as PromQueryBuilderContextType
);

@ -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<Props>(({ query, nested }) => {
return (
<Stack gap={0} direction="column">
<OperationExplainedBox stepNumber={1} title={`${query.metric} ${promQueryModeller.renderLabels(query.labels)}`}>
Fetch all series matching metric name and label filters.
</OperationExplainedBox>
<OperationListExplained<PromVisualQuery> stepNumber={2} queryModeller={promQueryModeller} query={query} />
</Stack>
);
});
PromQueryBuilderExplained.displayName = 'PromQueryBuilderExplained';

@ -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(<PromQueryEditorSelector {...defaultProps} />);
expectCodeEditor();
});
it('shows builder if new query', async () => {
render(
<PromQueryEditorSelector
{...defaultProps}
query={{
refId: 'A',
expr: '',
}}
/>
);
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(
<PromQueryEditorSelector
{...defaultProps}
onChange={onChange}
query={{
refId: 'A',
expr: '',
editorMode: mode,
}}
/>
);
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);
}

@ -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<PromQueryEditorProps>((props) => {
const { query, onChange, onRunQuery, data } = props;
const styles = useStyles2(getStyles);
const [visualQuery, setVisualQuery] = useState<PromVisualQuery>(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<HTMLInputElement>) => {
const isEnabled = event.currentTarget.checked;
onChange({ ...query, instant: isEnabled, exemplar: false });
onRunQuery();
};
const onExemplarChange = (event: SyntheticEvent<HTMLInputElement>) => {
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 (
<>
<EditorHeader>
<FlexItem grow={1} />
<Button
className={styles.runQuery}
variant="secondary"
size="sm"
fill="outline"
onClick={onRunQuery}
icon={data?.state === LoadingState.Loading ? 'fa fa-spinner' : undefined}
disabled={data?.state === LoadingState.Loading}
>
Run query
</Button>
<Stack gap={1}>
<label className={styles.switchLabel}>Instant</label>
<Switch value={query.instant} onChange={onInstantChange} />
</Stack>
{showExemplarSwitch && (
<Stack gap={1}>
<label className={styles.switchLabel}>Exemplars</label>
<Switch value={query.exemplar} onChange={onExemplarChange} />
</Stack>
)}
{editorMode === QueryEditorMode.Builder && (
<>
<InlineSelect
value={null}
placeholder="Query patterns"
allowCustomValue
onChange={({ value }) => {
onChangeViewModel({
...visualQuery,
operations: value?.operations!,
});
}}
options={promQueryModeller.getQueryPatterns().map((x) => ({ label: x.name, value: x }))}
/>
</>
)}
<QueryEditorModeToggle mode={editorMode} onChange={onEditorModeChange} />
</EditorHeader>
<Space v={0.5} />
{editorMode === QueryEditorMode.Code && <PromQueryEditor {...props} />}
{editorMode === QueryEditorMode.Builder && (
<PromQueryBuilder
query={visualQuery}
datasource={props.datasource}
onChange={onChangeViewModel}
onRunQuery={props.onRunQuery}
/>
)}
{editorMode === QueryEditorMode.Explain && <PromQueryBuilderExplained query={visualQuery} />}
</>
);
});
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,
}),
};
};

@ -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 (
<EditorFieldGroup>
<EditorField label="Query text">
<div
className={cx(styles.editorField, 'prism-syntax-highlight')}
aria-label="selector"
dangerouslySetInnerHTML={{ __html: hightlighted }}
/>
</EditorField>
</EditorFieldGroup>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
editorField: css({
padding: theme.spacing(0.25, 1),
fontFamily: theme.typography.fontFamilyMonospace,
fontSize: theme.typography.bodySmall.fontSize,
}),
};
};

@ -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,
},
],
};
}

@ -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<QueryBuilderLabelFilter>;
onChange: (value: QueryBuilderLabelFilter) => void;
onGetLabelNames: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<string[]>;
onGetLabelValues: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<string[]>;
onDelete: () => void;
}
export function LabelFilterItem({ item, defaultOp, onChange, onDelete, onGetLabelNames, onGetLabelValues }: Props) {
const [state, setState] = useState<{
labelNames?: Array<SelectableValue<any>>;
labelValues?: Array<SelectableValue<any>>;
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 (
<div data-testid="prometheus-dimensions-filter-item">
<InputGroup>
<Select
inputId="prometheus-dimensions-filter-item-key"
width="auto"
value={item.label ? toOption(item.label) : null}
allowCustomValue
onOpenMenu={async () => {
setState({ isLoadingLabelNames: true });
const labelNames = (await onGetLabelNames(item)).map((x) => ({ label: x, value: x }));
setState({ labelNames, isLoadingLabelNames: undefined });
}}
isLoading={state.isLoadingLabelNames}
options={state.labelNames}
onChange={(change) => {
if (change.label) {
onChange(({
...item,
op: item.op ?? defaultOp,
label: change.label,
} as any) as QueryBuilderLabelFilter);
}
}}
/>
<Select
value={toOption(item.op ?? defaultOp)}
options={operators}
width="auto"
onChange={(change) => {
if (change.value != null) {
onChange(({ ...item, op: change.value } as any) as QueryBuilderLabelFilter);
}
}}
/>
<Select
inputId="prometheus-dimensions-filter-item-value"
width="auto"
value={getValue(item)}
allowCustomValue
onOpenMenu={async () => {
setState({ isLoadingLabelValues: true });
const labelValues = await onGetLabelValues(item);
setState({
...state,
labelValues: labelValues.map((value) => ({ label: value, value })),
isLoadingLabelValues: undefined,
});
}}
isMulti={isMultiSelect()}
isLoading={state.isLoadingLabelValues}
options={getOptions()}
onChange={(change) => {
if (change.value) {
onChange(({ ...item, value: change.value, op: item.op ?? defaultOp } as any) as QueryBuilderLabelFilter);
} else {
const changes = change
.map((change: any) => {
return change.label;
})
.join('|');
onChange(({ ...item, value: changes, op: item.op ?? defaultOp } as any) as QueryBuilderLabelFilter);
}
}}
/>
<AccessoryButton aria-label="remove" icon="times" variant="secondary" onClick={onDelete} />
</InputGroup>
</div>
);
}
const operators = [
{ label: '=~', value: '=~' },
{ label: '=', value: '=' },
{ label: '!=', value: '!=' },
];

@ -0,0 +1,65 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LabelFilters } from './LabelFilters';
import { QueryBuilderLabelFilter } from './types';
import { getLabelSelects } from '../testUtils';
import { selectOptionInTest } from '../../../../../../../packages/grafana-ui';
describe('LabelFilters', () => {
it('renders empty input without labels', async () => {
setup();
expect(screen.getAllByText(/Choose/)).toHaveLength(2);
expect(screen.getByText(/=/)).toBeInTheDocument();
expect(getAddButton()).toBeInTheDocument();
});
it('renders multiple labels', async () => {
setup([
{ label: 'foo', op: '=', value: 'bar' },
{ label: 'baz', op: '!=', value: 'qux' },
{ label: 'quux', op: '=~', value: 'quuz' },
]);
expect(screen.getByText(/foo/)).toBeInTheDocument();
expect(screen.getByText(/bar/)).toBeInTheDocument();
expect(screen.getByText(/baz/)).toBeInTheDocument();
expect(screen.getByText(/qux/)).toBeInTheDocument();
expect(screen.getByText(/quux/)).toBeInTheDocument();
expect(screen.getByText(/quuz/)).toBeInTheDocument();
expect(getAddButton()).toBeInTheDocument();
});
it('adds new label', async () => {
const { onChange } = setup([{ label: 'foo', op: '=', value: 'bar' }]);
userEvent.click(getAddButton());
expect(screen.getAllByText(/Choose/)).toHaveLength(2);
const { name, value } = getLabelSelects(1);
await selectOptionInTest(name, 'baz');
await selectOptionInTest(value, 'qux');
expect(onChange).toBeCalledWith([
{ label: 'foo', op: '=', value: 'bar' },
{ label: 'baz', op: '=', value: 'qux' },
]);
});
it('removes label', async () => {
const { onChange } = setup([{ label: 'foo', op: '=', value: 'bar' }]);
userEvent.click(screen.getByLabelText(/remove/));
expect(onChange).toBeCalledWith([]);
});
});
function setup(labels: QueryBuilderLabelFilter[] = []) {
const props = {
onChange: jest.fn(),
onGetLabelNames: async () => ['foo', 'bar', 'baz'],
onGetLabelValues: async () => ['bar', 'qux', 'quux'],
};
render(<LabelFilters {...props} labelsFilters={labels} />);
return props;
}
function getAddButton() {
return screen.getByLabelText(/Add/);
}

@ -0,0 +1,50 @@
import { EditorField, EditorFieldGroup, EditorList } from '@grafana/experimental';
import { isEqual } from 'lodash';
import React, { useState } from 'react';
import { QueryBuilderLabelFilter } from '../shared/types';
import { LabelFilterItem } from './LabelFilterItem';
export interface Props {
labelsFilters: QueryBuilderLabelFilter[];
onChange: (labelFilters: QueryBuilderLabelFilter[]) => void;
onGetLabelNames: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<string[]>;
onGetLabelValues: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<string[]>;
}
export function LabelFilters({ labelsFilters, onChange, onGetLabelNames, onGetLabelValues }: Props) {
const defaultOp = '=';
const [items, setItems] = useState<Array<Partial<QueryBuilderLabelFilter>>>(
labelsFilters.length === 0 ? [{ op: defaultOp }] : labelsFilters
);
const onLabelsChange = (newItems: Array<Partial<QueryBuilderLabelFilter>>) => {
setItems(newItems);
// Extract full label filters with both label & value
const newLabels = newItems.filter((x) => x.label != null && x.value != null);
if (!isEqual(newLabels, labelsFilters)) {
onChange(newLabels as QueryBuilderLabelFilter[]);
}
};
return (
<EditorFieldGroup>
<EditorField label="Labels">
<EditorList
items={items}
onChange={onLabelsChange}
renderItem={(item, onChangeItem, onDelete) => (
<LabelFilterItem
item={item}
defaultOp={defaultOp}
onChange={onChangeItem}
onDelete={onDelete}
onGetLabelNames={onGetLabelNames}
onGetLabelValues={onGetLabelValues}
/>
)}
/>
</EditorField>
</EditorFieldGroup>
);
}

@ -0,0 +1,88 @@
import { Registry } from '@grafana/data';
import {
QueryBuilderLabelFilter,
QueryBuilderOperation,
QueryBuilderOperationDef,
QueryWithOperations,
VisualQueryModeller,
} from './types';
export interface VisualQueryBinary<T> {
operator: string;
vectorMatches?: string;
query: T;
}
export abstract class LokiAndPromQueryModellerBase<T extends QueryWithOperations> implements VisualQueryModeller {
protected operationsRegisty: Registry<QueryBuilderOperationDef>;
private categories: string[] = [];
constructor(getOperations: () => QueryBuilderOperationDef[]) {
this.operationsRegisty = new Registry<QueryBuilderOperationDef>(getOperations);
}
protected setOperationCategories(categories: string[]) {
this.categories = categories;
}
getOperationsForCategory(category: string) {
return this.operationsRegisty.list().filter((op) => op.category === category && !op.hideFromList);
}
getAlternativeOperations(key: string) {
return this.operationsRegisty.list().filter((op) => op.alternativesKey === key);
}
getCategories() {
return this.categories;
}
getOperationDef(id: string) {
return this.operationsRegisty.get(id);
}
renderOperations(queryString: string, operations: QueryBuilderOperation[]) {
for (const operation of operations) {
const def = this.operationsRegisty.get(operation.id);
queryString = def.renderer(operation, def, queryString);
}
return queryString;
}
renderBinaryQueries(queryString: string, binaryQueries?: Array<VisualQueryBinary<T>>) {
if (binaryQueries) {
for (const binQuery of binaryQueries) {
queryString = `${this.renderBinaryQuery(queryString, binQuery)}`;
}
}
return queryString;
}
private renderBinaryQuery(leftOperand: string, binaryQuery: VisualQueryBinary<T>) {
let result = leftOperand + ` ${binaryQuery.operator} `;
if (binaryQuery.vectorMatches) {
result += `${binaryQuery.vectorMatches} `;
}
return result + `${this.renderQuery(binaryQuery.query)}`;
}
renderLabels(labels: QueryBuilderLabelFilter[]) {
if (labels.length === 0) {
return '';
}
let expr = '{';
for (const filter of labels) {
if (expr !== '{') {
expr += ', ';
}
expr += `${filter.label}${filter.op}"${filter.value}"`;
}
return expr + `}`;
}
abstract renderQuery(query: T): string;
}

@ -0,0 +1,260 @@
import { css } from '@emotion/css';
import { DataSourceApi, GrafanaTheme2 } from '@grafana/data';
import { FlexItem, Stack } from '@grafana/experimental';
import { Button, useStyles2 } from '@grafana/ui';
import React from 'react';
import { Draggable } from 'react-beautiful-dnd';
import {
VisualQueryModeller,
QueryBuilderOperation,
QueryBuilderOperationParamValue,
QueryBuilderOperationDef,
QueryBuilderOperationParamDef,
} from '../shared/types';
import { OperationInfoButton } from './OperationInfoButton';
import { OperationName } from './OperationName';
import { getOperationParamEditor } from './OperationParamEditor';
export interface Props {
operation: QueryBuilderOperation;
index: number;
query: any;
datasource: DataSourceApi;
queryModeller: VisualQueryModeller;
onChange: (index: number, update: QueryBuilderOperation) => void;
onRemove: (index: number) => void;
onRunQuery: () => void;
}
export function OperationEditor({
operation,
index,
onRemove,
onChange,
onRunQuery,
queryModeller,
query,
datasource,
}: Props) {
const styles = useStyles2(getStyles);
const def = queryModeller.getOperationDef(operation.id);
const onParamValueChanged = (paramIdx: number, value: QueryBuilderOperationParamValue) => {
const update: QueryBuilderOperation = { ...operation, params: [...operation.params] };
update.params[paramIdx] = value;
callParamChangedThenOnChange(def, update, index, paramIdx, onChange);
};
const onAddRestParam = () => {
const update: QueryBuilderOperation = { ...operation, params: [...operation.params, ''] };
callParamChangedThenOnChange(def, update, index, operation.params.length, onChange);
};
const onRemoveRestParam = (paramIdx: number) => {
const update: QueryBuilderOperation = {
...operation,
params: [...operation.params.slice(0, paramIdx), ...operation.params.slice(paramIdx + 1)],
};
callParamChangedThenOnChange(def, update, index, paramIdx, onChange);
};
const operationElements: React.ReactNode[] = [];
for (let paramIndex = 0; paramIndex < operation.params.length; paramIndex++) {
const paramDef = def.params[Math.min(def.params.length - 1, paramIndex)];
const Editor = getOperationParamEditor(paramDef);
operationElements.push(
<div className={styles.paramRow} key={`${paramIndex}-1`}>
<div className={styles.paramName}>{paramDef.name}</div>
<div className={styles.paramValue}>
<Stack gap={0.5} direction="row" alignItems="center" wrap={false}>
<Editor
index={paramIndex}
paramDef={paramDef}
value={operation.params[paramIndex]}
operation={operation}
onChange={onParamValueChanged}
onRunQuery={onRunQuery}
query={query}
datasource={datasource}
/>
{paramDef.restParam && (operation.params.length > def.params.length || paramDef.optional) && (
<Button
size="sm"
fill="text"
icon="times"
variant="secondary"
title={`Remove ${paramDef.name}`}
onClick={() => onRemoveRestParam(paramIndex)}
/>
)}
</Stack>
</div>
</div>
);
}
// Handle adding button for rest params
let restParam: React.ReactNode | undefined;
if (def.params.length > 0) {
const lastParamDef = def.params[def.params.length - 1];
if (lastParamDef.restParam) {
restParam = renderAddRestParamButton(lastParamDef, onAddRestParam, operation.params.length, styles);
}
}
return (
<Draggable draggableId={`operation-${index}`} index={index}>
{(provided) => (
<div
className={styles.card}
ref={provided.innerRef}
{...provided.draggableProps}
data-testid={`operation-wrapper-for-${operation.id}`}
>
<div className={styles.header} {...provided.dragHandleProps}>
<OperationName
operation={operation}
def={def}
index={index}
onChange={onChange}
queryModeller={queryModeller}
/>
<FlexItem grow={1} />
<div className={`${styles.operationHeaderButtons} operation-header-show-on-hover`}>
<OperationInfoButton def={def} operation={operation} />
<Button
icon="times"
size="sm"
onClick={() => onRemove(index)}
fill="text"
variant="secondary"
title="Remove operation"
/>
</div>
</div>
<div className={styles.body}>{operationElements}</div>
{restParam}
{index < query.operations.length - 1 && (
<div className={styles.arrow}>
<div className={styles.arrowLine} />
<div className={styles.arrowArrow} />
</div>
)}
</div>
)}
</Draggable>
);
}
function renderAddRestParamButton(
paramDef: QueryBuilderOperationParamDef,
onAddRestParam: () => void,
paramIndex: number,
styles: OperationEditorStyles
) {
return (
<div className={styles.restParam} key={`${paramIndex}-2`}>
<Button size="sm" icon="plus" title={`Add ${paramDef.name}`} variant="secondary" onClick={onAddRestParam}>
{paramDef.name}
</Button>
</div>
);
}
function callParamChangedThenOnChange(
def: QueryBuilderOperationDef,
operation: QueryBuilderOperation,
operationIndex: number,
paramIndex: number,
onChange: (index: number, update: QueryBuilderOperation) => void
) {
if (def.paramChangedHandler) {
onChange(operationIndex, def.paramChangedHandler(paramIndex, operation, def));
} else {
onChange(operationIndex, operation);
}
}
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),
marginBottom: theme.spacing(1),
position: 'relative',
}),
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',
'&:hover .operation-header-show-on-hover': css({
opacity: 1,
}),
}),
infoIcon: css({
color: theme.colors.text.secondary,
}),
body: css({
margin: theme.spacing(1, 1, 0.5, 1),
display: 'table',
}),
paramRow: css({
display: 'table-row',
verticalAlign: 'middle',
}),
paramName: css({
display: 'table-cell',
padding: theme.spacing(0, 1, 0, 0),
fontSize: theme.typography.bodySmall.fontSize,
fontWeight: theme.typography.fontWeightMedium,
verticalAlign: 'middle',
height: '32px',
}),
operationHeaderButtons: css({
opacity: 0,
transition: theme.transitions.create(['opacity'], {
duration: theme.transitions.duration.short,
}),
}),
paramValue: css({
display: 'table-cell',
paddingBottom: theme.spacing(0.5),
verticalAlign: 'middle',
}),
restParam: css({
padding: theme.spacing(0, 1, 1, 1),
}),
arrow: css({
position: 'absolute',
top: '0',
right: '-18px',
display: 'flex',
}),
arrowLine: css({
height: '2px',
width: '8px',
backgroundColor: theme.colors.border.strong,
position: 'relative',
top: '14px',
}),
arrowArrow: css({
width: 0,
height: 0,
borderTop: `5px solid transparent`,
borderBottom: `5px solid transparent`,
borderLeft: `7px solid ${theme.colors.border.strong}`,
position: 'relative',
top: '10px',
}),
};
};
type OperationEditorStyles = ReturnType<typeof getStyles>;

@ -0,0 +1,75 @@
import { css } from '@emotion/css';
import { GrafanaTheme2, renderMarkdown } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import React from 'react';
export interface Props {
title: string;
children?: React.ReactNode;
markdown?: string;
stepNumber: number;
}
export function OperationExplainedBox({ title, stepNumber, markdown, children }: Props) {
const styles = useStyles2(getStyles);
return (
<div className={styles.box}>
<div className={styles.stepNumber}>{stepNumber}</div>
<div className={styles.boxInner}>
<div className={styles.header}>
<span>{title}</span>
</div>
<div className={styles.body}>
{markdown && <div dangerouslySetInnerHTML={{ __html: renderMarkdown(markdown) }}></div>}
{children}
</div>
</div>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
box: css({
background: theme.colors.background.secondary,
padding: theme.spacing(1),
borderRadius: theme.shape.borderRadius(),
position: 'relative',
marginBottom: theme.spacing(0.5),
}),
boxInner: css({
marginLeft: theme.spacing(4),
}),
stepNumber: css({
fontWeight: theme.typography.fontWeightMedium,
background: theme.colors.secondary.main,
width: '20px',
height: '20px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'absolute',
top: '10px',
left: '11px',
fontSize: theme.typography.bodySmall.fontSize,
}),
header: css({
paddingBottom: theme.spacing(0.5),
display: 'flex',
alignItems: 'center',
fontFamily: theme.typography.fontFamilyMonospace,
}),
body: css({
color: theme.colors.text.secondary,
'p:last-child': {
margin: 0,
},
a: {
color: theme.colors.text.link,
textDecoration: 'underline',
},
}),
};
};

@ -0,0 +1,102 @@
import { css } from '@emotion/css';
import { GrafanaTheme2, renderMarkdown } from '@grafana/data';
import { FlexItem } from '@grafana/experimental';
import { Button, Portal, useStyles2 } from '@grafana/ui';
import React, { useState } from 'react';
import { usePopper } from 'react-popper';
import { useToggle } from 'react-use';
import { QueryBuilderOperation, QueryBuilderOperationDef } from './types';
export interface Props {
operation: QueryBuilderOperation;
def: QueryBuilderOperationDef;
}
export const OperationInfoButton = React.memo<Props>(({ def, operation }) => {
const styles = useStyles2(getStyles);
const [popperTrigger, setPopperTrigger] = useState<HTMLButtonElement | null>(null);
const [popover, setPopover] = useState<HTMLDivElement | null>(null);
const [isOpen, toggleIsOpen] = useToggle(false);
const popper = usePopper(popperTrigger, popover, {
placement: 'top',
modifiers: [
{ name: 'arrow', enabled: true },
{
name: 'preventOverflow',
enabled: true,
options: {
rootBoundary: 'viewport',
},
},
],
});
return (
<>
<Button
ref={setPopperTrigger}
icon="info-circle"
size="sm"
variant="secondary"
fill="text"
onClick={toggleIsOpen}
/>
{isOpen && (
<Portal>
<div ref={setPopover} style={popper.styles.popper} {...popper.attributes.popper} className={styles.docBox}>
<div className={styles.docBoxHeader}>
<span>{def.renderer(operation, def, '<expr>')}</span>
<FlexItem grow={1} />
<Button icon="times" onClick={toggleIsOpen} fill="text" variant="secondary" title="Remove operation" />
</div>
<div
className={styles.docBoxBody}
dangerouslySetInnerHTML={{ __html: getOperationDocs(def, operation) }}
></div>
</div>
</Portal>
)}
</>
);
});
OperationInfoButton.displayName = 'OperationDocs';
const getStyles = (theme: GrafanaTheme2) => {
return {
docBox: css({
overflow: 'hidden',
background: theme.colors.background.canvas,
border: `1px solid ${theme.colors.border.strong}`,
boxShadow: theme.shadows.z2,
maxWidth: '600px',
padding: theme.spacing(1),
borderRadius: theme.shape.borderRadius(),
zIndex: theme.zIndex.tooltip,
}),
docBoxHeader: css({
fontSize: theme.typography.h5.fontSize,
fontFamily: theme.typography.fontFamilyMonospace,
paddingBottom: theme.spacing(1),
display: 'flex',
alignItems: 'center',
}),
docBoxBody: css({
// The markdown paragraph has a marginBottom this removes it
marginBottom: theme.spacing(-1),
color: theme.colors.text.secondary,
}),
signature: css({
fontSize: theme.typography.bodySmall.fontSize,
fontFamily: theme.typography.fontFamilyMonospace,
}),
dropdown: css({
opacity: 0,
color: theme.colors.text.secondary,
}),
};
};
function getOperationDocs(def: QueryBuilderOperationDef, op: QueryBuilderOperation): string {
return renderMarkdown(def.explainHandler ? def.explainHandler(op, def) : def.documentation ?? 'no docs');
}

@ -0,0 +1,95 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { OperationList } from './OperationList';
import { promQueryModeller } from '../PromQueryModeller';
import { EmptyLanguageProviderMock } from '../../language_provider.mock';
import PromQlLanguageProvider from '../../language_provider';
import { PromVisualQuery } from '../types';
import { PrometheusDatasource } from '../../datasource';
import { DataSourceApi } from '@grafana/data';
const defaultQuery: PromVisualQuery = {
metric: 'random_metric',
labels: [{ label: 'instance', op: '=', value: 'localhost:9090' }],
operations: [
{
id: 'rate',
params: ['auto'],
},
{
id: '__sum_by',
params: ['instance', 'job'],
},
],
};
describe('OperationList', () => {
it('renders operations', async () => {
setup();
expect(screen.getByText('Rate')).toBeInTheDocument();
expect(screen.getByText('Sum by')).toBeInTheDocument();
});
it('removes an operation', async () => {
const { onChange } = setup();
const removeOperationButtons = screen.getAllByTitle('Remove operation');
expect(removeOperationButtons).toHaveLength(2);
userEvent.click(removeOperationButtons[1]);
expect(onChange).toBeCalledWith({
labels: [{ label: 'instance', op: '=', value: 'localhost:9090' }],
metric: 'random_metric',
operations: [{ id: 'rate', params: ['auto'] }],
});
});
it('adds an operation', async () => {
const { onChange } = setup();
addOperation('Aggregations', 'Min');
expect(onChange).toBeCalledWith({
labels: [{ label: 'instance', op: '=', value: 'localhost:9090' }],
metric: 'random_metric',
operations: [
{ id: 'rate', params: ['auto'] },
{ id: '__sum_by', params: ['instance', 'job'] },
{ id: 'min', params: [] },
],
});
});
});
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
) as DataSourceApi,
onRunQuery: () => {},
onChange: jest.fn(),
queryModeller: promQueryModeller,
};
render(<OperationList {...props} query={query} />);
return props;
}
function addOperation(section: string, op: string) {
const addOperationButton = screen.getByTitle('Add operation');
expect(addOperationButton).toBeInTheDocument();
userEvent.click(addOperationButton);
const sectionItem = screen.getByTitle(section);
expect(sectionItem).toBeInTheDocument();
// Weirdly the userEvent.click doesn't work here, it reports the item has pointer-events: none. Don't see that
// anywhere when debugging so not sure what style is it picking up.
fireEvent.click(sectionItem.children[0]);
const opItem = screen.getByTitle(op);
expect(opItem).toBeInTheDocument();
fireEvent.click(opItem);
}

@ -0,0 +1,128 @@
import { css } from '@emotion/css';
import { DataSourceApi, GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { ButtonCascader, CascaderOption, useStyles2 } from '@grafana/ui';
import React from 'react';
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
import { QueryBuilderOperation, QueryWithOperations, VisualQueryModeller } from '../shared/types';
import { OperationEditor } from './OperationEditor';
export interface Props<T extends QueryWithOperations> {
query: T;
datasource: DataSourceApi;
onChange: (query: T) => void;
onRunQuery: () => void;
queryModeller: VisualQueryModeller;
explainMode?: boolean;
}
export function OperationList<T extends QueryWithOperations>({
query,
datasource,
queryModeller,
onChange,
onRunQuery,
}: Props<T>) {
const styles = useStyles2(getStyles);
const { operations } = query;
const onOperationChange = (index: number, update: QueryBuilderOperation) => {
const updatedList = [...operations];
updatedList.splice(index, 1, update);
onChange({ ...query, operations: updatedList });
};
const onRemove = (index: number) => {
const updatedList = [...operations.slice(0, index), ...operations.slice(index + 1)];
onChange({ ...query, operations: updatedList });
};
const addOptions: CascaderOption[] = queryModeller.getCategories().map((category) => {
return {
value: category,
label: category,
children: queryModeller.getOperationsForCategory(category).map((operation) => ({
value: operation.id,
label: operation.name,
isLeaf: true,
})),
};
});
const onAddOperation = (value: string[]) => {
const operationDef = queryModeller.getOperationDef(value[1]);
onChange(operationDef.addOperationHandler(operationDef, query, queryModeller));
};
const onDragEnd = (result: DropResult) => {
if (!result.destination) {
return;
}
const updatedList = [...operations];
const element = updatedList[result.source.index];
updatedList.splice(result.source.index, 1);
updatedList.splice(result.destination.index, 0, element);
onChange({ ...query, operations: updatedList });
};
return (
<Stack gap={1} direction="column">
<Stack gap={1}>
{operations.length > 0 && (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="sortable-field-mappings" direction="horizontal">
{(provided) => (
<div className={styles.operationList} ref={provided.innerRef} {...provided.droppableProps}>
{operations.map((op, index) => (
<OperationEditor
key={index}
queryModeller={queryModeller}
index={index}
operation={op}
query={query}
datasource={datasource}
onChange={onOperationChange}
onRemove={onRemove}
onRunQuery={onRunQuery}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
)}
<div className={styles.addButton}>
<ButtonCascader
key="cascader"
icon="plus"
options={addOptions}
onChange={onAddOperation}
variant="secondary"
hideDownIcon={true}
buttonProps={{ 'aria-label': 'Add operation', title: 'Add operation' }}
/>
</div>
</Stack>
</Stack>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
heading: css({
fontSize: 12,
fontWeight: theme.typography.fontWeightMedium,
marginBottom: 0,
}),
operationList: css({
display: 'flex',
flexWrap: 'wrap',
gap: theme.spacing(2),
}),
addButton: css({
paddingBottom: theme.spacing(1),
}),
};
};

@ -0,0 +1,24 @@
import React from 'react';
import { OperationExplainedBox } from './OperationExplainedBox';
import { QueryWithOperations, VisualQueryModeller } from './types';
export interface Props<T extends QueryWithOperations> {
query: T;
queryModeller: VisualQueryModeller;
explainMode?: boolean;
stepNumber: number;
}
export function OperationListExplained<T extends QueryWithOperations>({ query, queryModeller, stepNumber }: Props<T>) {
return (
<>
{query.operations.map((op, index) => {
const def = queryModeller.getOperationDef(op.id);
const title = def.renderer(op, def, '<expr>');
const body = def.explainHandler ? def.explainHandler(op, def) : def.documentation ?? 'no docs';
return <OperationExplainedBox stepNumber={index + stepNumber} key={index} title={title} markdown={body} />;
})}
</>
);
}

@ -0,0 +1,92 @@
import { css } from '@emotion/css';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Icon, Select, useStyles2 } from '@grafana/ui';
import React, { useState } from 'react';
import { VisualQueryModeller, QueryBuilderOperation, QueryBuilderOperationDef } from './types';
export interface Props {
operation: QueryBuilderOperation;
def: QueryBuilderOperationDef;
index: number;
queryModeller: VisualQueryModeller;
onChange: (index: number, update: QueryBuilderOperation) => void;
}
interface State {
isOpen?: boolean;
alternatives?: Array<SelectableValue<QueryBuilderOperationDef>>;
}
export const OperationName = React.memo<Props>(({ operation, def, index, onChange, queryModeller }) => {
const styles = useStyles2(getStyles);
const [state, setState] = useState<State>({});
const onToggleSwitcher = () => {
if (state.isOpen) {
setState({ ...state, isOpen: false });
} else {
const alternatives = queryModeller
.getAlternativeOperations(def.alternativesKey!)
.map((alt) => ({ label: alt.name, value: alt }));
setState({ isOpen: true, alternatives });
}
};
const nameElement = <span>{def.name ?? def.id}</span>;
if (!def.alternativesKey) {
return nameElement;
}
return (
<>
{!state.isOpen && (
<button
className={styles.wrapper}
onClick={onToggleSwitcher}
title={'Click to replace with alternative function'}
>
{nameElement}
<Icon className={`${styles.dropdown} operation-header-show-on-hover`} name="arrow-down" size="sm" />
</button>
)}
{state.isOpen && (
<Select
autoFocus
openMenuOnFocus
placeholder="Replace with"
options={state.alternatives}
isOpen={true}
onCloseMenu={onToggleSwitcher}
onChange={(value) => {
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,
}),
};
};

@ -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<QueryBuilderOperationParamEditorProps> {
if (paramDef.editor) {
return paramDef.editor;
}
if (paramDef.options) {
return SelectInputParamEditor;
}
return SimpleInputParamEditor;
}
function SimpleInputParamEditor(props: QueryBuilderOperationParamEditorProps) {
return (
<Input
defaultValue={props.value ?? ''}
onKeyDown={(evt) => {
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 (
<Select
menuShouldPortal
value={toOption(value as string)}
options={selectOptions}
onChange={(value) => onChange(index, value.value!)}
/>
);
}

@ -0,0 +1,29 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { useStyles2 } from '@grafana/ui';
import React from 'react';
interface Props {
children: React.ReactNode;
}
export function OperationsEditorRow({ children }: Props) {
const styles = useStyles2(getStyles);
return (
<div className={styles.root}>
<Stack gap={1}>{children}</Stack>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
root: css({
padding: theme.spacing(1, 1, 0, 1),
backgroundColor: theme.colors.background.secondary,
borderRadius: theme.shape.borderRadius(1),
}),
};
};

@ -0,0 +1,18 @@
import { RadioButtonGroup } from '@grafana/ui';
import React from 'react';
import { QueryEditorMode } from './types';
export interface Props {
mode: QueryEditorMode;
onChange: (mode: QueryEditorMode) => void;
}
const editorModes = [
{ label: 'Explain', value: QueryEditorMode.Explain },
{ label: 'Builder', value: QueryEditorMode.Builder },
{ label: 'Code', value: QueryEditorMode.Code },
];
export function QueryEditorModeToggle({ mode, onChange }: Props) {
return <RadioButtonGroup options={editorModes} size="sm" value={mode} onChange={onChange} />;
}

@ -0,0 +1,51 @@
import { capitalize } from 'lodash';
import { QueryBuilderOperation, QueryBuilderOperationDef, QueryWithOperations } from './types';
export function functionRendererLeft(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) {
const params = renderParams(model, def, innerExpr);
const str = model.id + '(';
if (innerExpr) {
params.push(innerExpr);
}
return str + params.join(', ') + ')';
}
export function functionRendererRight(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) {
const params = renderParams(model, def, innerExpr);
const str = model.id + '(';
if (innerExpr) {
params.unshift(innerExpr);
}
return str + params.join(', ') + ')';
}
function renderParams(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) {
return (model.params ?? []).map((value, index) => {
const paramDef = def.params[index];
if (paramDef.type === 'string') {
return '"' + value + '"';
}
return value;
});
}
export function defaultAddOperationHandler<T extends QueryWithOperations>(def: QueryBuilderOperationDef, query: T) {
const newOperation: QueryBuilderOperation = {
id: def.id,
params: def.defaultParams,
};
return {
...query,
operations: [...query.operations, newOperation],
};
}
export function getPromAndLokiOperationDisplayName(funcName: string) {
return capitalize(funcName.replace(/_/g, ' '));
}

@ -0,0 +1,97 @@
/**
* Shared types that can be reused by Loki and other data sources
*/
import { DataSourceApi, RegistryItem, SelectableValue } from '@grafana/data';
import { ComponentType } from 'react';
export interface QueryBuilderLabelFilter {
label: string;
op: string;
value: string;
}
export interface QueryBuilderOperation {
id: string;
params: QueryBuilderOperationParamValue[];
}
export interface QueryWithOperations {
operations: QueryBuilderOperation[];
}
export interface QueryBuilderOperationDef<T = any> extends RegistryItem {
documentation?: string;
params: QueryBuilderOperationParamDef[];
defaultParams: QueryBuilderOperationParamValue[];
category: string;
hideFromList?: boolean;
alternativesKey?: string;
renderer: QueryBuilderOperationRenderer;
addOperationHandler: QueryBuilderAddOperationHandler<T>;
paramChangedHandler?: QueryBuilderOnParamChangedHandler;
explainHandler?: (op: QueryBuilderOperation, def: QueryBuilderOperationDef<T>) => string;
}
export type QueryBuilderAddOperationHandler<T> = (
def: QueryBuilderOperationDef,
query: T,
modeller: VisualQueryModeller
) => T;
export type QueryBuilderOnParamChangedHandler = (
index: number,
operation: QueryBuilderOperation,
operationDef: QueryBuilderOperationDef
) => QueryBuilderOperation;
export type QueryBuilderOperationRenderer = (
model: QueryBuilderOperation,
def: QueryBuilderOperationDef,
innerExpr: string
) => string;
export type QueryBuilderOperationParamValue = string | number;
export interface QueryBuilderOperationParamDef {
name: string;
type: string;
options?: string[] | number[] | Array<SelectableValue<string>>;
restParam?: boolean;
optional?: boolean;
editor?: ComponentType<QueryBuilderOperationParamEditorProps>;
}
export interface QueryBuilderOperationEditorProps {
operation: QueryBuilderOperation;
index: number;
query: any;
datasource: DataSourceApi;
queryModeller: VisualQueryModeller;
onChange: (index: number, update: QueryBuilderOperation) => void;
onRemove: (index: number) => void;
}
export interface QueryBuilderOperationParamEditorProps {
value?: QueryBuilderOperationParamValue;
paramDef: QueryBuilderOperationParamDef;
index: number;
operation: QueryBuilderOperation;
query: any;
datasource: DataSourceApi;
onChange: (index: number, value: QueryBuilderOperationParamValue) => void;
onRunQuery: () => void;
}
export enum QueryEditorMode {
Builder,
Code,
Explain,
}
export interface VisualQueryModeller {
getOperationsForCategory(category: string): QueryBuilderOperationDef[];
getAlternativeOperations(key: string): QueryBuilderOperationDef[];
getCategories(): string[];
getOperationDef(id: string): QueryBuilderOperationDef;
}

@ -0,0 +1,10 @@
import { screen, getAllByRole } from '@testing-library/react';
export function getLabelSelects(index = 0) {
const labels = screen.getByText(/Labels/);
const selects = getAllByRole(labels.parentElement!, 'combobox');
return {
name: selects[3 * index],
value: selects[3 * index + 2],
};
}

@ -0,0 +1,36 @@
import { VisualQueryBinary } from './shared/LokiAndPromQueryModellerBase';
import { QueryBuilderLabelFilter, QueryBuilderOperation } from './shared/types';
/**
* Visual query model
*/
export interface PromVisualQuery {
metric: string;
labels: QueryBuilderLabelFilter[];
operations: QueryBuilderOperation[];
binaryQueries?: PromVisualQueryBinary[];
}
export type PromVisualQueryBinary = VisualQueryBinary<PromVisualQuery>;
export enum PromVisualQueryOperationCategory {
Aggregations = 'Aggregations',
RangeFunctions = 'Range functions',
Functions = 'Functions',
BinaryOps = 'Binary operations',
}
export interface PromQueryPattern {
name: string;
operations: QueryBuilderOperation[];
}
export function getDefaultEmptyQuery() {
const model: PromVisualQuery = {
metric: '',
labels: [],
operations: [],
};
return model;
}

@ -1,4 +1,6 @@
import { DataQuery, DataSourceJsonData, QueryResultMeta, ScopedVars } from '@grafana/data';
import { QueryEditorMode } from './querybuilder/shared/types';
import { PromVisualQuery } from './querybuilder/types';
export interface PromQuery extends DataQuery {
expr: string;
@ -16,6 +18,9 @@ export interface PromQuery extends DataQuery {
requestId?: string;
showingGraph?: boolean;
showingTable?: boolean;
editorMode?: QueryEditorMode;
/** Temporary until we have a parser */
visualQuery?: PromVisualQuery;
}
export interface PromOptions extends DataSourceJsonData {

@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { QueryEditorProps } from '@grafana/data';
import { GrafanaTheme2, QueryEditorProps } from '@grafana/data';
import {
ButtonCascader,
CascaderOption,
@ -9,6 +9,7 @@ import {
RadioButtonGroup,
useTheme2,
QueryField,
useStyles2,
} from '@grafana/ui';
import { notifyApp } from 'app/core/actions';
import { createErrorNotification } from 'app/core/copy/appNotification';
@ -23,9 +24,19 @@ import { ZipkinQuery, ZipkinQueryType, ZipkinSpan } from './types';
type Props = QueryEditorProps<ZipkinDatasource, ZipkinQuery>;
const getStyles = (theme: GrafanaTheme2) => {
return {
tracesCascader: css({
label: 'tracesCascader',
marginRight: theme.spacing(1),
}),
};
};
export const ZipkinQueryField = ({ query, onChange, onRunQuery, datasource }: Props) => {
const serviceOptions = useServices(datasource);
const theme = useTheme2();
const styles = useStyles2(getStyles);
const { onLoadOptions, allOptions } = useLoadOptions(datasource);
const onSelectTrace = useCallback(
@ -78,7 +89,13 @@ export const ZipkinQueryField = ({ query, onChange, onRunQuery, datasource }: Pr
</div>
) : (
<InlineFieldRow>
<ButtonCascader options={cascaderOptions} onChange={onSelectTrace} loadData={onLoadOptions}>
<ButtonCascader
options={cascaderOptions}
onChange={onSelectTrace}
loadData={onLoadOptions}
variant="secondary"
buttonProps={{ className: styles.tracesCascader }}
>
Traces
</ButtonCascader>
<div className="gf-form gf-form--grow flex-shrink-1 min-width-15">

@ -13,6 +13,9 @@
$theme-name: dark;
$colors-action-hover: rgba(204, 204, 220, 0.16);
$colors-action-selected: rgba(204, 204, 220, 0.12);
// New Colors
// -------------------------
$blue-light: #6E9FFF;

@ -13,6 +13,9 @@
$theme-name: light;
$colors-action-hover: rgba(36, 41, 46, 0.12);
$colors-action-selected: rgba(36, 41, 46, 0.08);
// New Colors
// -------------------------
$blue-light: #1F62E0;

@ -1,4 +1,4 @@
@use "sass:list";
@use 'sass:list';
$input-border: 1px solid $input-border-color;
.gf-form {

@ -1,10 +1,10 @@
.query-keyword {
font-weight: $font-weight-semi-bold;
color: $text-blue;
color: $text-blue !important;
}
.query-segment-operator {
color: $orange;
color: $orange !important;
}
.query-placeholder {

@ -86,7 +86,8 @@
/* SYNTAX */
.slate-query-field {
.slate-query-field,
.prism-syntax-highlight {
.token.comment,
.token.block-comment,
.token.prolog,

Loading…
Cancel
Save