Prometheus: Auto legend handling (#45367)

* Legend editor is working

* It's working

* Progress on auto legend mode

* Fixes

* added unit tests

* Added go tests

* Fixing tests

* Fix issue with timing and internal state

* Update public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditor.tsx

Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com>
pull/44642/head^2
Torkel Ödegaard 3 years ago committed by GitHub
parent da91c93f4a
commit cfa24a3cfb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx
  2. 19
      pkg/tsdb/prometheus/time_series_query.go
  3. 24
      pkg/tsdb/prometheus/time_series_query_test.go
  4. 76
      public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.test.tsx
  5. 40
      public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx
  6. 39
      public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditor.tsx
  7. 4
      public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.test.tsx
  8. 112
      public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryLegendEditor.tsx
  9. 12
      public/app/plugins/datasource/prometheus/querybuilder/types.ts
  10. 11
      public/app/plugins/datasource/prometheus/types.ts

@ -9,6 +9,7 @@ import { useStyles2 } from '../../../themes';
export interface RadioButtonGroupProps<T> { export interface RadioButtonGroupProps<T> {
value?: T; value?: T;
id?: string;
disabled?: boolean; disabled?: boolean;
disabledOptions?: T[]; disabledOptions?: T[];
options: Array<SelectableValue<T>>; options: Array<SelectableValue<T>>;
@ -28,6 +29,7 @@ export function RadioButtonGroup<T>({
disabled, disabled,
disabledOptions, disabledOptions,
size = 'md', size = 'md',
id,
className, className,
fullWidth = false, fullWidth = false,
autoFocus = false, autoFocus = false,
@ -52,8 +54,9 @@ export function RadioButtonGroup<T>({
}, },
[onClick] [onClick]
); );
const id = uniqueId('radiogroup-');
const groupName = useRef(id); const internalId = id ?? uniqueId('radiogroup-');
const groupName = useRef(internalId);
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const activeButtonRef = useRef<HTMLInputElement | null>(null); const activeButtonRef = useRef<HTMLInputElement | null>(null);
@ -76,7 +79,7 @@ export function RadioButtonGroup<T>({
aria-label={o.ariaLabel} aria-label={o.ariaLabel}
onChange={handleOnChange(o)} onChange={handleOnChange(o)}
onClick={handleOnClick(o)} onClick={handleOnClick(o)}
id={`option-${o.value}-${id}`} id={`option-${o.value}-${internalId}`}
name={groupName.current} name={groupName.current}
description={o.description} description={o.description}
fullWidth={fullWidth} fullWidth={fullWidth}

@ -39,6 +39,8 @@ const (
varRateIntervalAlt = "${__rate_interval}" varRateIntervalAlt = "${__rate_interval}"
) )
const legendFormatAuto = "__auto"
type TimeSeriesQueryType string type TimeSeriesQueryType string
const ( const (
@ -137,11 +139,14 @@ func (s *Service) executeTimeSeriesQuery(ctx context.Context, req *backend.Query
} }
func formatLegend(metric model.Metric, query *PrometheusQuery) string { func formatLegend(metric model.Metric, query *PrometheusQuery) string {
var legend string var legend = metric.String()
if query.LegendFormat == "" { if query.LegendFormat == legendFormatAuto {
legend = metric.String() // If we have labels set legend to empty string to utilize the auto naming system
} else { if len(metric) > 0 {
legend = ""
}
} else if query.LegendFormat != "" {
result := legendFormat.ReplaceAllFunc([]byte(query.LegendFormat), func(in []byte) []byte { result := legendFormat.ReplaceAllFunc([]byte(query.LegendFormat), func(in []byte) []byte {
labelName := strings.Replace(string(in), "{{", "", 1) labelName := strings.Replace(string(in), "{{", "", 1)
labelName = strings.Replace(labelName, "}}", "", 1) labelName = strings.Replace(labelName, "}}", "", 1)
@ -335,8 +340,12 @@ func matrixToDataFrames(matrix model.Matrix, query *PrometheusQuery, frames data
timeField.Name = data.TimeSeriesTimeFieldName timeField.Name = data.TimeSeriesTimeFieldName
timeField.Config = &data.FieldConfig{Interval: float64(query.Step.Milliseconds())} timeField.Config = &data.FieldConfig{Interval: float64(query.Step.Milliseconds())}
valueField.Name = data.TimeSeriesValueFieldName valueField.Name = data.TimeSeriesValueFieldName
valueField.Config = &data.FieldConfig{DisplayNameFromDS: name}
valueField.Labels = tags valueField.Labels = tags
if name != "" {
valueField.Config = &data.FieldConfig{DisplayNameFromDS: name}
}
frames = append(frames, newDataFrame(name, "matrix", timeField, valueField)) frames = append(frames, newDataFrame(name, "matrix", timeField, valueField))
} }

@ -52,6 +52,30 @@ func TestPrometheus_timeSeriesQuery_formatLeged(t *testing.T) {
require.Equal(t, `{job="grafana"}`, formatLegend(metric, query)) require.Equal(t, `{job="grafana"}`, formatLegend(metric, query))
}) })
t.Run("When legendFormat = __auto and no labels", func(t *testing.T) {
metric := map[p.LabelName]p.LabelValue{}
query := &PrometheusQuery{
LegendFormat: legendFormatAuto,
Expr: `{job="grafana"}`,
}
require.Equal(t, `{job="grafana"}`, formatLegend(metric, query))
})
t.Run("When legendFormat = __auto with labels", func(t *testing.T) {
metric := map[p.LabelName]p.LabelValue{
p.LabelName("app"): p.LabelValue("backend"),
}
query := &PrometheusQuery{
LegendFormat: legendFormatAuto,
Expr: `{job="grafana"}`,
}
require.Equal(t, "", formatLegend(metric, query))
})
} }
func TestPrometheus_timeSeriesQuery_parseTimeSeriesQuery(t *testing.T) { func TestPrometheus_timeSeriesQuery_parseTimeSeriesQuery(t *testing.T) {

@ -0,0 +1,76 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { PromQuery } from '../../types';
import { getQueryWithDefaults } from '../types';
import { CoreApp } from '@grafana/data';
import { PromQueryBuilderOptions } from './PromQueryBuilderOptions';
import { selectOptionInTest } from '@grafana/ui';
describe('PromQueryBuilderOptions', () => {
it('Can change query type', async () => {
const { props } = setup();
screen.getByTitle('Click to edit options').click();
expect(screen.getByLabelText('Range')).toBeChecked();
screen.getByLabelText('Instant').click();
expect(props.onChange).toHaveBeenCalledWith({
...props.query,
instant: true,
range: false,
exemplar: false,
});
});
it('Legend format default to Auto', async () => {
setup();
expect(screen.getByText('Legend: Auto')).toBeInTheDocument();
});
it('Can change legend format to verbose', async () => {
const { props } = setup();
screen.getByTitle('Click to edit options').click();
let legendModeSelect = screen.getByText('Auto').parentElement!;
legendModeSelect.click();
await selectOptionInTest(legendModeSelect as HTMLElement, 'Verbose');
expect(props.onChange).toHaveBeenCalledWith({
...props.query,
legendFormat: '',
});
});
it('Can change legend format to custom', async () => {
const { props } = setup();
screen.getByTitle('Click to edit options').click();
let legendModeSelect = screen.getByText('Auto').parentElement!;
legendModeSelect.click();
await selectOptionInTest(legendModeSelect as HTMLElement, 'Custom');
expect(props.onChange).toHaveBeenCalledWith({
...props.query,
legendFormat: '{{label_name}}',
});
});
});
function setup(queryOverrides: Partial<PromQuery> = {}) {
const props = {
query: {
...getQueryWithDefaults({ refId: 'A' } as PromQuery, CoreApp.PanelEditor),
queryOverrides,
},
onRunQuery: jest.fn(),
onChange: jest.fn(),
};
const { container } = render(<PromQueryBuilderOptions {...props} />);
return { container, props };
}

@ -6,6 +6,7 @@ import { QueryOptionGroup } from '../shared/QueryOptionGroup';
import { PromQuery } from '../../types'; import { PromQuery } from '../../types';
import { FORMAT_OPTIONS, INTERVAL_FACTOR_OPTIONS } from '../../components/PromQueryEditor'; import { FORMAT_OPTIONS, INTERVAL_FACTOR_OPTIONS } from '../../components/PromQueryEditor';
import { getQueryTypeChangeHandler, getQueryTypeOptions } from '../../components/PromExploreExtraField'; import { getQueryTypeChangeHandler, getQueryTypeOptions } from '../../components/PromExploreExtraField';
import { getLegendModeLabel, PromQueryLegendEditor } from './PromQueryLegendEditor';
export interface Props { export interface Props {
query: PromQuery; query: PromQuery;
@ -15,18 +16,11 @@ export interface Props {
} }
export const PromQueryBuilderOptions = React.memo<Props>(({ query, app, onChange, onRunQuery }) => { export const PromQueryBuilderOptions = React.memo<Props>(({ query, app, onChange, onRunQuery }) => {
const formatOption = FORMAT_OPTIONS.find((option) => option.value === query.format) || FORMAT_OPTIONS[0];
const onChangeFormat = (value: SelectableValue<string>) => { const onChangeFormat = (value: SelectableValue<string>) => {
onChange({ ...query, format: value.value }); onChange({ ...query, format: value.value });
onRunQuery(); onRunQuery();
}; };
const onLegendFormatChanged = (evt: React.FocusEvent<HTMLInputElement>) => {
onChange({ ...query, legendFormat: evt.currentTarget.value });
onRunQuery();
};
const onChangeStep = (evt: React.FocusEvent<HTMLInputElement>) => { const onChangeStep = (evt: React.FocusEvent<HTMLInputElement>) => {
onChange({ ...query, interval: evt.currentTarget.value }); onChange({ ...query, interval: evt.currentTarget.value });
onRunQuery(); onRunQuery();
@ -46,15 +40,14 @@ export const PromQueryBuilderOptions = React.memo<Props>(({ query, app, onChange
onRunQuery(); onRunQuery();
}; };
const formatOption = FORMAT_OPTIONS.find((option) => option.value === query.format) || FORMAT_OPTIONS[0];
const queryTypeValue = getQueryTypeValue(query);
const queryTypeLabel = queryTypeOptions.find((x) => x.value === queryTypeValue)!.label;
return ( return (
<EditorRow> <EditorRow>
<QueryOptionGroup title="Options" collapsedInfo={getCollapsedInfo(query, formatOption)}> <QueryOptionGroup title="Options" collapsedInfo={getCollapsedInfo(query, formatOption.label!, queryTypeLabel)}>
<EditorField <PromQueryLegendEditor query={query} onChange={onChange} onRunQuery={onRunQuery} />
label="Legend"
tooltip="Series name override or template. Ex. {{hostname}} will be replaced with label value for hostname."
>
<Input placeholder="auto" defaultValue={query.legendFormat} onBlur={onLegendFormatChanged} />
</EditorField>
<EditorField <EditorField
label="Min step" label="Min step"
tooltip={ tooltip={
@ -73,12 +66,16 @@ export const PromQueryBuilderOptions = React.memo<Props>(({ query, app, onChange
defaultValue={query.interval} defaultValue={query.interval}
/> />
</EditorField> </EditorField>
<EditorField label="Format"> <EditorField label="Format">
<Select value={formatOption} allowCustomValue onChange={onChangeFormat} options={FORMAT_OPTIONS} /> <Select value={formatOption} allowCustomValue onChange={onChangeFormat} options={FORMAT_OPTIONS} />
</EditorField> </EditorField>
<EditorField label="Type"> <EditorField label="Type">
<RadioButtonGroup options={queryTypeOptions} value={getQueryTypeValue(query)} onChange={onQueryTypeChange} /> <RadioButtonGroup
id="options.query.type"
options={queryTypeOptions}
value={queryTypeValue}
onChange={onQueryTypeChange}
/>
</EditorField> </EditorField>
{shouldShowExemplarSwitch(query, app) && ( {shouldShowExemplarSwitch(query, app) && (
<EditorField label="Exemplars"> <EditorField label="Exemplars">
@ -114,20 +111,17 @@ function getQueryTypeValue(query: PromQuery) {
return query.range && query.instant ? 'both' : query.instant ? 'instant' : 'range'; return query.range && query.instant ? 'both' : query.instant ? 'instant' : 'range';
} }
function getCollapsedInfo(query: PromQuery, formatOption: SelectableValue<string>): string[] { function getCollapsedInfo(query: PromQuery, formatOption: string, queryType: string): string[] {
const items: string[] = []; const items: string[] = [];
if (query.legendFormat) { items.push(`Legend: ${getLegendModeLabel(query.legendFormat)}`);
items.push(`Legend: ${query.legendFormat}`); items.push(`Format: ${formatOption}`);
}
items.push(`Format: ${formatOption.label}`);
if (query.interval) { if (query.interval) {
items.push(`Step ${query.interval}`); items.push(`Step ${query.interval}`);
} }
items.push(`Type: ${getQueryTypeValue(query)}`); items.push(`Type: ${queryType}`);
if (query.exemplar) { if (query.exemplar) {
items.push(`Exemplars: true`); items.push(`Exemplars: true`);

@ -2,18 +2,37 @@ import React from 'react';
import { PromQueryEditorProps } from '../../components/types'; import { PromQueryEditorProps } from '../../components/types';
import PromQueryField from '../../components/PromQueryField'; import PromQueryField from '../../components/PromQueryField';
import { testIds } from '../../components/PromQueryEditor'; import { testIds } from '../../components/PromQueryEditor';
import { useStyles2 } from '@grafana/ui';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
export function PromQueryCodeEditor({ query, datasource, range, onRunQuery, onChange, data }: PromQueryEditorProps) { export function PromQueryCodeEditor({ query, datasource, range, onRunQuery, onChange, data }: PromQueryEditorProps) {
const styles = useStyles2(getStyles);
return ( return (
<PromQueryField <div className={styles.wrapper}>
datasource={datasource} <PromQueryField
query={query} datasource={datasource}
range={range} query={query}
onRunQuery={onRunQuery} range={range}
onChange={onChange} onRunQuery={onRunQuery}
history={[]} onChange={onChange}
data={data} history={[]}
data-testid={testIds.editor} data={data}
/> data-testid={testIds.editor}
/>
</div>
); );
} }
const getStyles = (theme: GrafanaTheme2) => {
return {
// This wrapper styling can be removed after the old PromQueryEditor is removed.
// This is removing margin bottom on the old legacy inline form styles
wrapper: css`
.gf-form {
margin-bottom: 0;
}
`,
};
};

@ -85,7 +85,6 @@ describe('PromQueryEditorSelector', () => {
expect(onChange).toBeCalledWith({ expect(onChange).toBeCalledWith({
refId: 'A', refId: 'A',
expr: defaultQuery.expr, expr: defaultQuery.expr,
instant: false,
range: true, range: true,
editorMode: QueryEditorMode.Builder, editorMode: QueryEditorMode.Builder,
}); });
@ -100,7 +99,6 @@ describe('PromQueryEditorSelector', () => {
expect(onChange).toBeCalledWith({ expect(onChange).toBeCalledWith({
refId: 'A', refId: 'A',
expr: defaultQuery.expr, expr: defaultQuery.expr,
instant: false,
range: true, range: true,
editorMode: QueryEditorMode.Builder, editorMode: QueryEditorMode.Builder,
editorPreview: true, editorPreview: true,
@ -122,7 +120,6 @@ describe('PromQueryEditorSelector', () => {
expect(onChange).toBeCalledWith({ expect(onChange).toBeCalledWith({
refId: 'A', refId: 'A',
expr: defaultQuery.expr, expr: defaultQuery.expr,
instant: false,
range: true, range: true,
editorMode: QueryEditorMode.Code, editorMode: QueryEditorMode.Code,
}); });
@ -134,7 +131,6 @@ describe('PromQueryEditorSelector', () => {
expect(onChange).toBeCalledWith({ expect(onChange).toBeCalledWith({
refId: 'A', refId: 'A',
expr: defaultQuery.expr, expr: defaultQuery.expr,
instant: false,
range: true, range: true,
editorMode: QueryEditorMode.Explain, editorMode: QueryEditorMode.Explain,
}); });

@ -0,0 +1,112 @@
import React, { useRef } from 'react';
import { EditorField } from '@grafana/experimental';
import { SelectableValue } from '@grafana/data';
import { Input, Select } from '@grafana/ui';
import { LegendFormatMode, PromQuery } from '../../types';
export interface Props {
query: PromQuery;
onChange: (update: PromQuery) => void;
onRunQuery: () => void;
}
const legendModeOptions = [
{
label: 'Auto',
value: LegendFormatMode.Auto,
description: 'Only includes unique labels',
},
{ label: 'Verbose', value: LegendFormatMode.Verbose, description: 'All label names and values' },
{ label: 'Custom', value: LegendFormatMode.Custom, description: 'Provide a naming template' },
];
/**
* Tests for this component are on the parent level (PromQueryBuilderOptions).
*/
export const PromQueryLegendEditor = React.memo<Props>(({ query, onChange, onRunQuery }) => {
const mode = getLegendMode(query.legendFormat);
const inputRef = useRef<HTMLInputElement | null>(null);
const onLegendFormatChanged = (evt: React.FocusEvent<HTMLInputElement>) => {
let legendFormat = evt.currentTarget.value;
if (legendFormat.length === 0) {
legendFormat = LegendFormatMode.Auto;
}
onChange({ ...query, legendFormat });
onRunQuery();
};
const onLegendModeChanged = (value: SelectableValue<LegendFormatMode>) => {
switch (value.value!) {
case LegendFormatMode.Auto:
onChange({ ...query, legendFormat: LegendFormatMode.Auto });
break;
case LegendFormatMode.Custom:
onChange({ ...query, legendFormat: '{{label_name}}' });
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.setSelectionRange(2, 12, 'forward');
}, 10);
break;
case LegendFormatMode.Verbose:
onChange({ ...query, legendFormat: '' });
break;
}
onRunQuery();
};
return (
<EditorField
label="Legend"
tooltip="Series name override or template. Ex. {{hostname}} will be replaced with label value for hostname."
>
<>
{mode === LegendFormatMode.Custom && (
<Input
id="legendFormat"
width={22}
placeholder="auto"
defaultValue={query.legendFormat}
onBlur={onLegendFormatChanged}
ref={inputRef}
/>
)}
{mode !== LegendFormatMode.Custom && (
<Select
inputId="legend.mode"
isSearchable={false}
placeholder="Select legend mode"
options={legendModeOptions}
width={22}
onChange={onLegendModeChanged}
value={legendModeOptions.find((x) => x.value === mode)}
/>
)}
</>
</EditorField>
);
});
PromQueryLegendEditor.displayName = 'PromQueryLegendEditor';
function getLegendMode(legendFormat: string | undefined) {
// This special value means the new smart minimal series naming
if (legendFormat === LegendFormatMode.Auto) {
return LegendFormatMode.Auto;
}
// Missing or empty legend format is the old verbose behavior
if (legendFormat == null || legendFormat === '') {
return LegendFormatMode.Verbose;
}
return LegendFormatMode.Custom;
}
export function getLegendModeLabel(legendFormat: string | undefined) {
const mode = getLegendMode(legendFormat);
if (mode !== LegendFormatMode.Custom) {
return legendModeOptions.find((x) => x.value === mode)?.label;
}
return legendFormat;
}

@ -1,5 +1,5 @@
import { CoreApp } from '@grafana/data'; import { CoreApp } from '@grafana/data';
import { PromQuery } from '../types'; import { LegendFormatMode, PromQuery } from '../types';
import { VisualQueryBinary } from './shared/LokiAndPromQueryModellerBase'; import { VisualQueryBinary } from './shared/LokiAndPromQueryModellerBase';
import { QueryBuilderLabelFilter, QueryBuilderOperation, QueryEditorMode } from './shared/types'; import { QueryBuilderLabelFilter, QueryBuilderOperation, QueryEditorMode } from './shared/types';
@ -69,7 +69,7 @@ export function getQueryWithDefaults(query: PromQuery, app: CoreApp | undefined)
} }
if (query.expr == null) { if (query.expr == null) {
result = { ...result, expr: '' }; result = { ...result, expr: '', legendFormat: LegendFormatMode.Auto };
} }
// Default to range query // Default to range query
@ -78,12 +78,8 @@ export function getQueryWithDefaults(query: PromQuery, app: CoreApp | undefined)
} }
// In explore we default to both instant & range // In explore we default to both instant & range
if (query.instant == null && query.range == null) { if (query.instant == null && app === CoreApp.Explore) {
if (app === CoreApp.Explore) { result = { ...result, instant: true };
result = { ...result, instant: true };
} else {
result = { ...result, instant: false, range: true };
}
} }
return result; return result;

@ -153,3 +153,14 @@ export interface PromLabelQueryResponse {
}; };
cancelled?: boolean; cancelled?: boolean;
} }
/**
* Auto = query.legendFormat == '__auto'
* Verbose = query.legendFormat == null/undefined/''
* Custom query.legendFormat.length > 0 && query.legendFormat !== '__auto'
*/
export enum LegendFormatMode {
Auto = '__auto',
Verbose = '__verbose',
Custom = '__custom',
}

Loading…
Cancel
Save