Cloud Monitoring: Update GroupBy fields to use experimental UI components (#50541)

* Cloud Monitoring: Update GroupBy fields to use experimental UI components

* let group by field grow horizontally

* remove fixed width constants from inputs

* add test

* Cloud Monitoring: Update GraphPeriod to use experimental UI components (#50545)

* Cloud Monitoring: Update GraphPeriod to use experimental UI components

* Cloud Monitoring: Update Preprocessing to use experimental UI components (#50548)

* Cloud Monitoring: Update Preprocessing to use experimental UI components

* add tests

* make overrides optional

* move preprocessor back into its own row
This commit is contained in:
Kevin Yu 2022-06-20 06:28:29 -07:00 committed by GitHub
parent 902101c524
commit e889dfdc5c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 516 additions and 8 deletions

View File

@ -252,7 +252,7 @@
"@grafana/aws-sdk": "0.0.36",
"@grafana/data": "workspace:*",
"@grafana/e2e-selectors": "workspace:*",
"@grafana/experimental": "^0.0.2-canary.30",
"@grafana/experimental": "^0.0.2-canary.32",
"@grafana/google-sdk": "0.0.3",
"@grafana/lezer-logql": "^0.0.12",
"@grafana/runtime": "workspace:*",

View File

@ -0,0 +1,15 @@
import { MetricDescriptor, MetricKind, ValueTypes } from '../types';
export const createMockMetricDescriptor = (overrides?: Partial<MetricDescriptor>): MetricDescriptor => {
return {
metricKind: MetricKind.CUMULATIVE,
valueType: ValueTypes.DOUBLE,
type: 'type',
unit: 'unit',
service: 'service',
serviceShortName: 'srv',
displayName: 'displayName',
description: 'description',
...overrides,
};
};

View File

@ -0,0 +1,65 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { openMenu } from 'react-select-event';
import { TemplateSrvStub } from 'test/specs/helpers';
import { ValueTypes, MetricKind } from '../../types';
import { Aggregation, Props } from './Aggregation';
const props: Props = {
onChange: () => {},
// @ts-ignore
templateSrv: new TemplateSrvStub(),
metricDescriptor: {
valueType: '',
metricKind: '',
} as any,
crossSeriesReducer: '',
groupBys: [],
templateVariableOptions: [],
};
describe('Aggregation', () => {
it('renders correctly', () => {
render(<Aggregation {...props} />);
expect(screen.getByTestId('cloud-monitoring-aggregation')).toBeInTheDocument();
});
describe('options', () => {
describe('when DOUBLE and GAUGE is passed as props', () => {
const nextProps = {
...props,
metricDescriptor: {
valueType: ValueTypes.DOUBLE,
metricKind: MetricKind.GAUGE,
} as any,
};
it('should not have the reduce values', () => {
render(<Aggregation {...nextProps} />);
const label = screen.getByLabelText('Group by function');
openMenu(label);
expect(screen.queryByText('count true')).not.toBeInTheDocument();
expect(screen.queryByText('count false')).not.toBeInTheDocument();
});
});
describe('when MONEY and CUMULATIVE is passed as props', () => {
const nextProps = {
...props,
metricDescriptor: {
valueType: ValueTypes.MONEY,
metricKind: MetricKind.CUMULATIVE,
} as any,
};
it('should have the reduce values', () => {
render(<Aggregation {...nextProps} />);
const label = screen.getByLabelText('Group by function');
openMenu(label);
expect(screen.getByText('none')).toBeInTheDocument();
});
});
});
});

View File

@ -0,0 +1,68 @@
import React, { FC, useMemo } from 'react';
import { SelectableValue } from '@grafana/data';
import { EditorField } from '@grafana/experimental';
import { Select } from '@grafana/ui';
import { getAggregationOptionsByMetric } from '../../functions';
import { MetricDescriptor, MetricKind, ValueTypes } from '../../types';
export interface Props {
refId: string;
onChange: (metricDescriptor: string) => void;
metricDescriptor?: MetricDescriptor;
crossSeriesReducer: string;
groupBys: string[];
templateVariableOptions: Array<SelectableValue<string>>;
}
export const Aggregation: FC<Props> = (props) => {
const aggOptions = useAggregationOptionsByMetric(props);
const selected = useSelectedFromOptions(aggOptions, props);
return (
<EditorField label="Group by function" data-testid="cloud-monitoring-aggregation">
<Select
width="auto"
onChange={({ value }) => props.onChange(value!)}
value={selected}
options={[
{
label: 'Template Variables',
options: props.templateVariableOptions,
},
{
label: 'Aggregations',
expanded: true,
options: aggOptions,
},
]}
placeholder="Select Reducer"
inputId={`${props.refId}-group-by-function`}
/>
</EditorField>
);
};
const useAggregationOptionsByMetric = ({ metricDescriptor }: Props): Array<SelectableValue<string>> => {
const valueType = metricDescriptor?.valueType;
const metricKind = metricDescriptor?.metricKind;
return useMemo(() => {
if (!valueType || !metricKind) {
return [];
}
return getAggregationOptionsByMetric(valueType as ValueTypes, metricKind as MetricKind).map((a) => ({
...a,
label: a.text,
}));
}, [valueType, metricKind]);
};
const useSelectedFromOptions = (aggOptions: Array<SelectableValue<string>>, props: Props) => {
return useMemo(() => {
const allOptions = [...aggOptions, ...props.templateVariableOptions];
return allOptions.find((s) => s.value === props.crossSeriesReducer);
}, [aggOptions, props.crossSeriesReducer, props.templateVariableOptions]);
};

View File

@ -0,0 +1,39 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { select } from 'react-select-event';
import { GraphPeriod, Props } from './GraphPeriod';
const props: Props = {
onChange: jest.fn(),
refId: 'A',
variableOptionGroup: { options: [] },
};
describe('Graph Period', () => {
it('should enable graph_period by default', () => {
render(<GraphPeriod {...props} />);
expect(screen.getByLabelText('Graph period')).not.toBeDisabled();
});
it('should disable graph_period when toggled', async () => {
const onChange = jest.fn();
render(<GraphPeriod {...props} onChange={onChange} />);
const s = screen.getByTestId('A-switch-graph-period');
await userEvent.click(s);
expect(onChange).toHaveBeenCalledWith('disabled');
});
it('should set a different value when selected', async () => {
const onChange = jest.fn();
render(<GraphPeriod {...props} onChange={onChange} />);
const selectEl = screen.getByLabelText('Graph period');
expect(selectEl).toBeInTheDocument();
await select(selectEl, '1m', {
container: document.body,
});
expect(onChange).toHaveBeenCalledWith('1m');
});
});

View File

@ -0,0 +1,49 @@
import React, { FunctionComponent } from 'react';
import { SelectableValue } from '@grafana/data';
import { EditorField, EditorRow } from '@grafana/experimental';
import { HorizontalGroup, Switch } from '@grafana/ui';
import { GRAPH_PERIODS, SELECT_WIDTH } from '../../constants';
import { PeriodSelect } from '../index';
export interface Props {
refId: string;
onChange: (period: string) => void;
variableOptionGroup: SelectableValue<string>;
graphPeriod?: string;
}
export const GraphPeriod: FunctionComponent<Props> = ({ refId, onChange, graphPeriod, variableOptionGroup }) => {
return (
<EditorRow>
<EditorField
label="Graph period"
htmlFor={`${refId}-graph-period`}
tooltip={
<>
Set <code>graph_period</code> which forces a preferred period between points. Automatically set to the
current interval if left blank.
</>
}
>
<HorizontalGroup>
<Switch
data-testid={`${refId}-switch-graph-period`}
value={graphPeriod !== 'disabled'}
onChange={(e) => onChange(e.currentTarget.checked ? '' : 'disabled')}
/>
<PeriodSelect
inputId={`${refId}-graph-period`}
templateVariableOptions={variableOptionGroup.options}
current={graphPeriod}
onChange={onChange}
selectWidth={SELECT_WIDTH}
disabled={graphPeriod === 'disabled'}
aligmentPeriods={GRAPH_PERIODS}
/>
</HorizontalGroup>
</EditorField>
</EditorRow>
);
};

View File

@ -0,0 +1,42 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { openMenu, select } from 'react-select-event';
import { createMockMetricQuery } from '../../__mocks__/cloudMonitoringQuery';
import { GroupBy, Props } from './GroupBy';
const props: Props = {
onChange: jest.fn(),
refId: 'refId',
metricDescriptor: {
valueType: '',
metricKind: '',
} as any,
variableOptionGroup: { options: [] },
labels: [],
query: createMockMetricQuery(),
};
describe('GroupBy', () => {
it('renders group by fields', () => {
render(<GroupBy {...props} />);
expect(screen.getByLabelText('Group by')).toBeInTheDocument();
expect(screen.getByLabelText('Group by function')).toBeInTheDocument();
});
it('can select a group by', async () => {
const onChange = jest.fn();
render(<GroupBy {...props} onChange={onChange} />);
const groupBy = screen.getByLabelText('Group by');
const option = 'metadata.system_labels.cloud_account';
expect(screen.queryByText(option)).not.toBeInTheDocument();
await openMenu(groupBy);
expect(screen.getByText(option)).toBeInTheDocument();
await select(groupBy, option, { container: document.body });
expect(onChange).toBeCalledWith(expect.objectContaining({ groupBys: expect.arrayContaining([option]) }));
});
});

View File

@ -0,0 +1,64 @@
import React, { FunctionComponent, useMemo } from 'react';
import { SelectableValue } from '@grafana/data';
import { EditorField, EditorFieldGroup, EditorRow } from '@grafana/experimental';
import { MultiSelect } from '@grafana/ui';
import { SYSTEM_LABELS } from '../../constants';
import { labelsToGroupedOptions } from '../../functions';
import { MetricDescriptor, MetricQuery } from '../../types';
import { Aggregation } from './Aggregation';
export interface Props {
refId: string;
variableOptionGroup: SelectableValue<string>;
labels: string[];
metricDescriptor?: MetricDescriptor;
onChange: (query: MetricQuery) => void;
query: MetricQuery;
}
export const GroupBy: FunctionComponent<Props> = ({
refId,
labels: groupBys = [],
query,
onChange,
variableOptionGroup,
metricDescriptor,
}) => {
const options = useMemo(
() => [variableOptionGroup, ...labelsToGroupedOptions([...groupBys, ...SYSTEM_LABELS])],
[groupBys, variableOptionGroup]
);
return (
<EditorRow>
<EditorFieldGroup>
<EditorField
label="Group by"
tooltip="You can reduce the amount of data returned for a metric by combining different time series. To combine multiple time series, you can specify a grouping and a function. Grouping is done on the basis of labels. The grouping function is used to combine the time series in the group into a single time series."
>
<MultiSelect
inputId={`${refId}-group-by`}
width="auto"
placeholder="Choose label"
options={options}
value={query.groupBys ?? []}
onChange={(options) => {
onChange({ ...query, groupBys: options.map((o) => o.value!) });
}}
/>
</EditorField>
<Aggregation
metricDescriptor={metricDescriptor}
templateVariableOptions={variableOptionGroup.options}
crossSeriesReducer={query.crossSeriesReducer}
groupBys={query.groupBys ?? []}
onChange={(crossSeriesReducer) => onChange({ ...query, crossSeriesReducer })}
refId={refId}
/>
</EditorFieldGroup>
</EditorRow>
);
};

View File

@ -18,9 +18,9 @@ import {
} from '../../types';
import { Project } from '../index';
import { GraphPeriod } from './../GraphPeriod';
import { MQLQueryEditor } from './../MQLQueryEditor';
import { AliasBy } from './AliasBy';
import { GraphPeriod } from './GraphPeriod';
import { VisualMetricQueryEditor } from './VisualMetricQueryEditor';
export interface Props {

View File

@ -0,0 +1,95 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { TemplateSrvMock } from 'app/features/templating/template_srv.mock';
import { createMockMetricDescriptor } from '../../__mocks__/cloudMonitoringMetricDescriptor';
import { createMockMetricQuery } from '../../__mocks__/cloudMonitoringQuery';
import { MetricKind, ValueTypes } from '../../types';
import { Preprocessor } from './Preprocessor';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getTemplateSrv: () => new TemplateSrvMock({}),
}));
describe('Preprocessor', () => {
it('only provides "None" as an option if no metric descriptor is provided', () => {
const query = createMockMetricQuery();
const onChange = jest.fn();
render(<Preprocessor onChange={onChange} query={query} />);
expect(screen.getByText('Pre-processing')).toBeInTheDocument();
expect(screen.getByText('None')).toBeInTheDocument();
expect(screen.queryByText('Rate')).not.toBeInTheDocument();
expect(screen.queryByText('Delta')).not.toBeInTheDocument();
});
it('only provides "None" as an option if metric kind is "Gauge"', () => {
const query = createMockMetricQuery();
const onChange = jest.fn();
const metricDescriptor = createMockMetricDescriptor({ metricKind: MetricKind.GAUGE });
render(<Preprocessor onChange={onChange} query={query} metricDescriptor={metricDescriptor} />);
expect(screen.getByText('Pre-processing')).toBeInTheDocument();
expect(screen.getByText('None')).toBeInTheDocument();
expect(screen.queryByText('Rate')).not.toBeInTheDocument();
expect(screen.queryByText('Delta')).not.toBeInTheDocument();
});
it('only provides "None" as an option if value type is "Distribution"', () => {
const query = createMockMetricQuery();
const onChange = jest.fn();
const metricDescriptor = createMockMetricDescriptor({ valueType: ValueTypes.DISTRIBUTION });
render(<Preprocessor onChange={onChange} query={query} metricDescriptor={metricDescriptor} />);
expect(screen.getByText('Pre-processing')).toBeInTheDocument();
expect(screen.getByText('None')).toBeInTheDocument();
expect(screen.queryByText('Rate')).not.toBeInTheDocument();
expect(screen.queryByText('Delta')).not.toBeInTheDocument();
});
it('provides "None" and "Rate" as options if metric kind is not "Delta" or "Cumulative" and value type is not "Distribution"', () => {
const query = createMockMetricQuery();
const onChange = jest.fn();
const metricDescriptor = createMockMetricDescriptor({ metricKind: MetricKind.DELTA });
render(<Preprocessor onChange={onChange} query={query} metricDescriptor={metricDescriptor} />);
expect(screen.getByText('Pre-processing')).toBeInTheDocument();
expect(screen.getByText('None')).toBeInTheDocument();
expect(screen.queryByText('Rate')).toBeInTheDocument();
expect(screen.queryByText('Delta')).not.toBeInTheDocument();
});
it('provides all options if metric kind is "Cumulative" and value type is not "Distribution"', () => {
const query = createMockMetricQuery();
const onChange = jest.fn();
const metricDescriptor = createMockMetricDescriptor({ metricKind: MetricKind.CUMULATIVE });
render(<Preprocessor onChange={onChange} query={query} metricDescriptor={metricDescriptor} />);
expect(screen.getByText('Pre-processing')).toBeInTheDocument();
expect(screen.getByText('None')).toBeInTheDocument();
expect(screen.queryByText('Rate')).toBeInTheDocument();
expect(screen.queryByText('Delta')).toBeInTheDocument();
});
it('provides all options if metric kind is "Cumulative" and value type is not "Distribution"', async () => {
const query = createMockMetricQuery();
const onChange = jest.fn();
const metricDescriptor = createMockMetricDescriptor({ metricKind: MetricKind.CUMULATIVE });
render(<Preprocessor onChange={onChange} query={query} metricDescriptor={metricDescriptor} />);
const none = screen.getByLabelText('None');
const rate = screen.getByLabelText('Rate');
const delta = screen.getByLabelText('Delta');
expect(none).toBeChecked();
expect(rate).not.toBeChecked();
expect(delta).not.toBeChecked();
await userEvent.click(rate);
expect(onChange).toBeCalledWith(expect.objectContaining({ preprocessor: 'rate' }));
});
});

View File

@ -0,0 +1,69 @@
import React, { FunctionComponent, useMemo } from 'react';
import { SelectableValue } from '@grafana/data';
import { EditorField, EditorRow } from '@grafana/experimental';
import { RadioButtonGroup } from '@grafana/ui';
import { getAlignmentPickerData } from '../../functions';
import { MetricDescriptor, MetricKind, MetricQuery, PreprocessorType, ValueTypes } from '../../types';
const NONE_OPTION = { label: 'None', value: PreprocessorType.None };
export interface Props {
metricDescriptor?: MetricDescriptor;
onChange: (query: MetricQuery) => void;
query: MetricQuery;
}
export const Preprocessor: FunctionComponent<Props> = ({ query, metricDescriptor, onChange }) => {
const options = useOptions(metricDescriptor);
return (
<EditorRow>
<EditorField
label="Pre-processing"
tooltip="Preprocessing options are displayed when the selected metric has a metric kind of delta or cumulative. The specific options available are determined by the metic's value type. If you select 'Rate', data points are aligned and converted to a rate per time series. If you select 'Delta', data points are aligned by their delta (difference) per time series"
>
<RadioButtonGroup
onChange={(value: PreprocessorType) => {
const { valueType, metricKind, perSeriesAligner: psa } = query;
const { perSeriesAligner } = getAlignmentPickerData(valueType, metricKind, psa, value);
onChange({ ...query, preprocessor: value, perSeriesAligner });
}}
value={query.preprocessor ?? PreprocessorType.None}
options={options}
/>
</EditorField>
</EditorRow>
);
};
const useOptions = (metricDescriptor?: MetricDescriptor): Array<SelectableValue<PreprocessorType>> => {
const metricKind = metricDescriptor?.metricKind;
const valueType = metricDescriptor?.valueType;
return useMemo(() => {
if (!metricKind || metricKind === MetricKind.GAUGE || valueType === ValueTypes.DISTRIBUTION) {
return [NONE_OPTION];
}
const options = [
NONE_OPTION,
{
label: 'Rate',
value: PreprocessorType.Rate,
description: 'Data points are aligned and converted to a rate per time series',
},
];
return metricKind === MetricKind.CUMULATIVE
? [
...options,
{
label: 'Delta',
value: PreprocessorType.Delta,
description: 'Data points are aligned by their delta (difference) per time series',
},
]
: options;
}, [metricKind, valueType]);
};

View File

@ -4,9 +4,11 @@ import { SelectableValue } from '@grafana/data';
import CloudMonitoringDatasource from '../../datasource';
import { CustomMetaData, MetricDescriptor, MetricQuery, SLOQuery } from '../../types';
import { GroupBy, LabelFilter, Metrics, Preprocessor } from '../index';
import { LabelFilter, Metrics } from '../index';
import { Alignment } from './Alignment';
import { GroupBy } from './GroupBy';
import { Preprocessor } from './Preprocessor';
export interface Props {
refId: string;

View File

@ -4463,9 +4463,9 @@ __metadata:
languageName: node
linkType: hard
"@grafana/experimental@npm:^0.0.2-canary.30":
version: 0.0.2-canary.30
resolution: "@grafana/experimental@npm:0.0.2-canary.30"
"@grafana/experimental@npm:^0.0.2-canary.32":
version: 0.0.2-canary.32
resolution: "@grafana/experimental@npm:0.0.2-canary.32"
dependencies:
"@types/uuid": ^8.3.3
uuid: ^8.3.2
@ -4473,7 +4473,7 @@ __metadata:
"@emotion/css": 11.1.3
react: 17.0.1
react-select: 5.2.1
checksum: b5b453b9372cde8f89021c50ae1191a2506ebbb069ad6331d22a5c7267ecd8c0db7f9f5fdd9cfab49fafce4e0b6809609e67449b78fe3ccc63cedfeceb64b911
checksum: 71924e6d03335fbedf1553ce08c2bbe95819ad746af48728aa81923f3f8ca0f0e8cf3db979095da5048f2b256e96fbb88aa8efb480e84913d81dd66faefeadec
languageName: node
linkType: hard
@ -20646,7 +20646,7 @@ __metadata:
"@grafana/e2e": "workspace:*"
"@grafana/e2e-selectors": "workspace:*"
"@grafana/eslint-config": 3.0.0
"@grafana/experimental": ^0.0.2-canary.30
"@grafana/experimental": ^0.0.2-canary.32
"@grafana/google-sdk": 0.0.3
"@grafana/lezer-logql": ^0.0.12
"@grafana/runtime": "workspace:*"