mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
da91c93f4a
commit
cfa24a3cfb
@ -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}
|
||||
|
@ -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))
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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 { 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`);
|
||||
|
@ -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;
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
@ -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,
|
||||
});
|
||||
|
@ -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 { 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;
|
||||
|
@ -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',
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user