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

This commit is contained in:
Torkel Ödegaard
2022-01-31 07:57:14 +01:00
committed by GitHub
parent a660ccc6e4
commit 64e1e91403
65 changed files with 3884 additions and 41 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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