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>
This commit is contained in:
Torkel Ödegaard 2022-02-16 14:06:33 +01:00 committed by GitHub
parent da91c93f4a
commit cfa24a3cfb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 293 additions and 53 deletions

View File

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

View File

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

View File

@ -52,6 +52,30 @@ func TestPrometheus_timeSeriesQuery_formatLeged(t *testing.T) {
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) {

View File

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

View File

@ -6,6 +6,7 @@ import { QueryOptionGroup } from '../shared/QueryOptionGroup';
import { PromQuery } from '../../types';
import { FORMAT_OPTIONS, INTERVAL_FACTOR_OPTIONS } from '../../components/PromQueryEditor';
import { getQueryTypeChangeHandler, getQueryTypeOptions } from '../../components/PromExploreExtraField';
import { getLegendModeLabel, PromQueryLegendEditor } from './PromQueryLegendEditor';
export interface Props {
query: PromQuery;
@ -15,18 +16,11 @@ export interface Props {
}
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>) => {
onChange({ ...query, format: value.value });
onRunQuery();
};
const onLegendFormatChanged = (evt: React.FocusEvent<HTMLInputElement>) => {
onChange({ ...query, legendFormat: evt.currentTarget.value });
onRunQuery();
};
const onChangeStep = (evt: React.FocusEvent<HTMLInputElement>) => {
onChange({ ...query, interval: evt.currentTarget.value });
onRunQuery();
@ -46,15 +40,14 @@ export const PromQueryBuilderOptions = React.memo<Props>(({ query, app, onChange
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 (
<EditorRow>
<QueryOptionGroup title="Options" collapsedInfo={getCollapsedInfo(query, formatOption)}>
<EditorField
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>
<QueryOptionGroup title="Options" collapsedInfo={getCollapsedInfo(query, formatOption.label!, queryTypeLabel)}>
<PromQueryLegendEditor query={query} onChange={onChange} onRunQuery={onRunQuery} />
<EditorField
label="Min step"
tooltip={
@ -73,12 +66,16 @@ export const PromQueryBuilderOptions = React.memo<Props>(({ query, app, onChange
defaultValue={query.interval}
/>
</EditorField>
<EditorField label="Format">
<Select value={formatOption} allowCustomValue onChange={onChangeFormat} options={FORMAT_OPTIONS} />
</EditorField>
<EditorField label="Type">
<RadioButtonGroup options={queryTypeOptions} value={getQueryTypeValue(query)} onChange={onQueryTypeChange} />
<RadioButtonGroup
id="options.query.type"
options={queryTypeOptions}
value={queryTypeValue}
onChange={onQueryTypeChange}
/>
</EditorField>
{shouldShowExemplarSwitch(query, app) && (
<EditorField label="Exemplars">
@ -114,20 +111,17 @@ function getQueryTypeValue(query: PromQuery) {
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[] = [];
if (query.legendFormat) {
items.push(`Legend: ${query.legendFormat}`);
}
items.push(`Format: ${formatOption.label}`);
items.push(`Legend: ${getLegendModeLabel(query.legendFormat)}`);
items.push(`Format: ${formatOption}`);
if (query.interval) {
items.push(`Step ${query.interval}`);
}
items.push(`Type: ${getQueryTypeValue(query)}`);
items.push(`Type: ${queryType}`);
if (query.exemplar) {
items.push(`Exemplars: true`);

View File

@ -2,18 +2,37 @@ import React from 'react';
import { PromQueryEditorProps } from '../../components/types';
import PromQueryField from '../../components/PromQueryField';
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) {
const styles = useStyles2(getStyles);
return (
<PromQueryField
datasource={datasource}
query={query}
range={range}
onRunQuery={onRunQuery}
onChange={onChange}
history={[]}
data={data}
data-testid={testIds.editor}
/>
<div className={styles.wrapper}>
<PromQueryField
datasource={datasource}
query={query}
range={range}
onRunQuery={onRunQuery}
onChange={onChange}
history={[]}
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;
}
`,
};
};

View File

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

View File

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

View File

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

View File

@ -153,3 +153,14 @@ export interface PromLabelQueryResponse {
};
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',
}