AzureMonitor: Add support for selecting multiple options when using the equals and not equals dimension filters (#48650)

* Add support for multiselect

- Add filters param to Dimensions
- Update existing tests
- Add MultiSelect component
- Add helper function to determine valid options
- Update labels hook to account for custom values
- Update go type
- Add function to build valid filters string

* Additional go tests

- Ensure query targets are built correctly

* Update DimensionFields frontend test

- Corrently rerender components
- Additional test for multiple labels selection
- Better selection of options in react-select components

* Fix lint issue

* Reset filters when operator or dimension changes

* Terminology

* Update test

* Add backend migration

- Update types (deprecate Filter field)
- Add migration logic
- Update tests
- Update dimension filters buliding

* Add migration test code

* Simplify some logic

* Add frontend deprecation notice

* Add frontend migration logic and migration tests

* Update setting of filter values

* Update DimensionFields test

* Fix linting issues

* PR comment updates

- Remove unnecessary if/else condition
- Don't set filter default value as queries should be migrated
- Add comment explaining why sw operator only accepts one value
- Remove unnecessary test for merging of old and new filters

* Nit on terminology

Co-authored-by: Andres Martinez Gotor <andres.martinez@grafana.com>

* Rename migrations for clarity

Co-authored-by: Andres Martinez Gotor <andres.martinez@grafana.com>
This commit is contained in:
Andreas Christou
2022-05-10 15:05:48 +01:00
committed by GitHub
parent 61772a66b6
commit 2bd9e9aca5
12 changed files with 482 additions and 63 deletions

View File

@ -85,6 +85,8 @@ func (e *AzureMonitorDatasource) buildQueries(queries []backend.DataQuery, dsInf
MetricDefinition: azJSONModel.MetricDefinition,
ResourceName: azJSONModel.ResourceName,
}
azJSONModel.DimensionFilters = MigrateDimensionFilters(azJSONModel.DimensionFilters)
azureURL := ub.BuildMetricsURL()
resourceName := azJSONModel.ResourceName
@ -129,10 +131,10 @@ func (e *AzureMonitorDatasource) buildQueries(queries []backend.DataQuery, dsInf
dimSB.WriteString(fmt.Sprintf("%s eq '%s'", dimension, dimensionFilter))
} else {
for i, filter := range azJSONModel.DimensionFilters {
if filter.Operator != "eq" && filter.Filter == "*" {
if len(filter.Filters) == 0 {
dimSB.WriteString(fmt.Sprintf("%s eq '*'", filter.Dimension))
} else {
dimSB.WriteString(filter.String())
dimSB.WriteString(filter.ConstructFiltersString())
}
if i != len(azJSONModel.DimensionFilters)-1 {
dimSB.WriteString(" and ")

View File

@ -32,7 +32,8 @@ func TestAzureMonitorBuildQueries(t *testing.T) {
fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local)
duration, _ := time.ParseDuration("400s")
wildcardFilter := "*"
testFilter := "test"
tests := []struct {
name string
azureMonitorVariedProperties map[string]interface{}
@ -101,7 +102,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) {
name: "legacy query without resourceURI and has dimensionFilter*s* property with one dimension",
azureMonitorVariedProperties: map[string]interface{}{
"timeGrain": "PT1M",
"dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "eq", Filter: "*"}},
"dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "eq", Filter: &wildcardFilter}},
"top": "30",
},
queryInterval: duration,
@ -112,7 +113,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) {
name: "legacy query without resourceURI and has dimensionFilter*s* property with two dimensions",
azureMonitorVariedProperties: map[string]interface{}{
"timeGrain": "PT1M",
"dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "eq", Filter: "*"}, {Dimension: "tier", Operator: "eq", Filter: "*"}},
"dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "eq", Filter: &wildcardFilter}, {Dimension: "tier", Operator: "eq", Filter: &wildcardFilter}},
"top": "30",
},
queryInterval: duration,
@ -134,7 +135,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) {
name: "has dimensionFilter*s* property with not equals operator",
azureMonitorVariedProperties: map[string]interface{}{
"timeGrain": "PT1M",
"dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "ne", Filter: "test"}},
"dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "ne", Filter: &wildcardFilter, Filters: []string{"test"}}},
"top": "30",
},
queryInterval: duration,
@ -145,7 +146,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) {
name: "has dimensionFilter*s* property with startsWith operator",
azureMonitorVariedProperties: map[string]interface{}{
"timeGrain": "PT1M",
"dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "sw", Filter: "test"}},
"dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "sw", Filter: &testFilter}},
"top": "30",
},
queryInterval: duration,
@ -156,13 +157,35 @@ func TestAzureMonitorBuildQueries(t *testing.T) {
name: "correctly sets dimension operator to eq (irrespective of operator) when filter value is '*'",
azureMonitorVariedProperties: map[string]interface{}{
"timeGrain": "PT1M",
"dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "sw", Filter: "*"}, {Dimension: "tier", Operator: "ne", Filter: "*"}},
"dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "sw", Filter: &wildcardFilter}, {Dimension: "tier", Operator: "ne", Filter: &wildcardFilter}},
"top": "30",
},
queryInterval: duration,
expectedInterval: "PT1M",
azureMonitorQueryTarget: "%24filter=blob+eq+%27%2A%27+and+tier+eq+%27%2A%27&aggregation=Average&api-version=2018-01-01&interval=PT1M&metricnames=Percentage+CPU&metricnamespace=Microsoft.Compute-virtualMachines&timespan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z&top=30",
},
{
name: "correctly constructs target when multiple filter values are provided for the 'eq' operator",
azureMonitorVariedProperties: map[string]interface{}{
"timeGrain": "PT1M",
"dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "eq", Filter: &wildcardFilter, Filters: []string{"test", "test2"}}},
"top": "30",
},
queryInterval: duration,
expectedInterval: "PT1M",
azureMonitorQueryTarget: "%24filter=blob+eq+%27test%27+or+blob+eq+%27test2%27&aggregation=Average&api-version=2018-01-01&interval=PT1M&metricnames=Percentage+CPU&metricnamespace=Microsoft.Compute-virtualMachines&timespan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z&top=30",
},
{
name: "correctly constructs target when multiple filter values are provided for ne 'eq' operator",
azureMonitorVariedProperties: map[string]interface{}{
"timeGrain": "PT1M",
"dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "ne", Filter: &wildcardFilter, Filters: []string{"test", "test2"}}},
"top": "30",
},
queryInterval: duration,
expectedInterval: "PT1M",
azureMonitorQueryTarget: "%24filter=blob+ne+%27test%27+and+blob+ne+%27test2%27&aggregation=Average&api-version=2018-01-01&interval=PT1M&metricnames=Percentage+CPU&metricnamespace=Microsoft.Compute-virtualMachines&timespan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z&top=30",
},
}
commonAzureModelProps := map[string]interface{}{

View File

@ -0,0 +1,43 @@
package metrics
import (
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/types"
)
func MigrateDimensionFilters(filters []types.AzureMonitorDimensionFilter) []types.AzureMonitorDimensionFilter {
var newFilters []types.AzureMonitorDimensionFilter
for _, filter := range filters {
newFilter := filter
// Ignore the deprecation check as this is a migration
// nolint:staticcheck
newFilter.Filter = nil
// If there is no old field and the new field is specified - append as this is valid
// nolint:staticcheck
if filter.Filter == nil && filter.Filters != nil {
newFilters = append(newFilters, newFilter)
} else {
// nolint:staticcheck
oldFilter := *filter.Filter
// If there is an old filter and no new ones then construct the new array and append
if filter.Filters == nil && oldFilter != "*" {
newFilter.Filters = []string{oldFilter}
// If both the new and old fields are specified (edge case) then construct the appropriate values
} else {
hasFilter := false
oldFilters := filter.Filters
for _, filterValue := range oldFilters {
if filterValue == oldFilter {
hasFilter = true
break
}
}
if !hasFilter && oldFilter != "*" {
oldFilters = append(oldFilters, oldFilter)
newFilter.Filters = oldFilters
}
}
newFilters = append(newFilters, newFilter)
}
}
return newFilters
}

View File

@ -0,0 +1,62 @@
package metrics
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/types"
)
func TestDimensionFiltersMigration(t *testing.T) {
wildcard := "*"
testFilter := "testFilter"
additionalTestFilter := "testFilter2"
tests := []struct {
name string
dimensionFilters []types.AzureMonitorDimensionFilter
expectedDimensionFilters []types.AzureMonitorDimensionFilter
}{
{
name: "will return new format unchanged",
dimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq", Filters: []string{"testFilter"}}},
expectedDimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq", Filters: []string{"testFilter"}}},
},
{
name: "correctly updates old format with wildcard",
dimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq", Filter: &wildcard}},
expectedDimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq"}},
},
{
name: "correctly updates old format with a value",
dimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq", Filter: &testFilter}},
expectedDimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq", Filters: []string{testFilter}}},
},
{
name: "correctly ignores wildcard if filters has a value",
dimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq", Filter: &wildcard, Filters: []string{testFilter}}},
expectedDimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq", Filters: []string{testFilter}}},
},
{
name: "correctly merges values if filters has a value (ignores duplicates)",
dimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq", Filter: &testFilter, Filters: []string{testFilter}}},
expectedDimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq", Filters: []string{testFilter}}},
},
{
name: "correctly merges values if filters has a value",
dimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq", Filter: &additionalTestFilter, Filters: []string{testFilter}}},
expectedDimensionFilters: []types.AzureMonitorDimensionFilter{{Dimension: "testDimension", Operator: "eq", Filters: []string{testFilter, additionalTestFilter}}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filters := MigrateDimensionFilters(tt.dimensionFilters)
if diff := cmp.Diff(tt.expectedDimensionFilters, filters, cmpopts.IgnoreUnexported(simplejson.Json{})); diff != "" {
t.Errorf("Result mismatch (-want +got):\n%s", diff)
}
})
}
}

View File

@ -5,6 +5,7 @@ import (
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/grafana/grafana-azure-sdk-go/azcredentials"
@ -139,17 +140,23 @@ type AzureMonitorJSONQuery struct {
// AzureMonitorDimensionFilter is the model for the frontend sent for azureMonitor metric
// queries like "BlobType", "eq", "*"
type AzureMonitorDimensionFilter struct {
Dimension string `json:"dimension"`
Operator string `json:"operator"`
Filter string `json:"filter"`
Dimension string `json:"dimension"`
Operator string `json:"operator"`
Filters []string `json:"filters,omitempty"`
// Deprecated: To support multiselection, filters are passed in a slice now. Also migrated in frontend.
Filter *string `json:"filter,omitempty"`
}
func (a AzureMonitorDimensionFilter) String() string {
filter := "*"
if a.Filter != "" {
filter = a.Filter
func (a AzureMonitorDimensionFilter) ConstructFiltersString() string {
var filterStrings []string
for _, filter := range a.Filters {
filterStrings = append(filterStrings, fmt.Sprintf("%v %v '%v'", a.Dimension, a.Operator, filter))
}
if a.Operator == "eq" {
return strings.Join(filterStrings, " or ")
} else {
return strings.Join(filterStrings, " and ")
}
return fmt.Sprintf("%v %v '%v'", a.Dimension, a.Operator, filter)
}
// LogJSONQuery is the frontend JSON query model for an Azure Log Analytics query.

View File

@ -105,11 +105,11 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
const dimensionFilters = (item.dimensionFilters ?? [])
.filter((f) => f.dimension && f.dimension !== 'None')
.map((f) => {
const filter = templateSrv.replace(f.filter ?? '', scopedVars);
const filters = f.filters?.map((filter) => templateSrv.replace(filter ?? '', scopedVars));
return {
dimension: templateSrv.replace(f.dimension, scopedVars),
operator: f.operator || 'eq',
filter: filter || '*', // send * when empty
filters: filters || [],
};
});

View File

@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { openMenu } from 'react-select-event';
import { selectOptionInTest } from '@grafana/ui';
@ -18,17 +19,17 @@ const variableOptionGroup = {
const user = userEvent.setup();
describe('Azure Monitor QueryEditor', () => {
const mockPanelData = createMockPanelData();
const mockDatasource = createMockDatasource();
it('should render a dimension filter', async () => {
let mockQuery = createMockQuery();
const mockPanelData = createMockPanelData();
const onQueryChange = jest.fn();
const dimensionOptions = [
{ label: 'Test Dimension 1', value: 'TestDimension1' },
{ label: 'Test Dimension 2', value: 'TestDimension2' },
];
render(
const { rerender } = render(
<DimensionFields
data={mockPanelData}
subscriptionId="123"
@ -45,9 +46,12 @@ describe('Azure Monitor QueryEditor', () => {
mockQuery = appendDimensionFilter(mockQuery);
expect(onQueryChange).toHaveBeenCalledWith({
...mockQuery,
azureMonitor: { ...mockQuery.azureMonitor, dimensionFilters: [{ dimension: '', operator: 'eq', filter: '*' }] },
azureMonitor: {
...mockQuery.azureMonitor,
dimensionFilters: [{ dimension: '', operator: 'eq', filters: [] }],
},
});
render(
rerender(
<DimensionFields
data={mockPanelData}
subscriptionId="123"
@ -65,7 +69,7 @@ describe('Azure Monitor QueryEditor', () => {
...mockQuery,
azureMonitor: {
...mockQuery.azureMonitor,
dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filter: '*' }],
dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filters: [] }],
},
});
expect(screen.queryByText('Test Dimension 1')).toBeInTheDocument();
@ -74,16 +78,17 @@ describe('Azure Monitor QueryEditor', () => {
it('correctly filters out dimensions when selected', async () => {
let mockQuery = createMockQuery();
const mockPanelData = createMockPanelData();
mockQuery.azureMonitor = {
...mockQuery.azureMonitor,
dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filter: '*' }],
dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filters: [] }],
};
const onQueryChange = jest.fn();
const dimensionOptions = [
{ label: 'Test Dimension 1', value: 'TestDimension1' },
{ label: 'Test Dimension 2', value: 'TestDimension2' },
];
render(
const { rerender } = render(
<DimensionFields
data={mockPanelData}
subscriptionId="123"
@ -98,7 +103,7 @@ describe('Azure Monitor QueryEditor', () => {
const addDimension = await screen.findByText('Add new dimension');
await user.click(addDimension);
mockQuery = appendDimensionFilter(mockQuery);
render(
rerender(
<DimensionFields
data={mockPanelData}
subscriptionId="123"
@ -119,9 +124,10 @@ describe('Azure Monitor QueryEditor', () => {
it('correctly displays dimension labels', async () => {
let mockQuery = createMockQuery();
const mockPanelData = createMockPanelData();
mockQuery.azureMonitor = {
...mockQuery.azureMonitor,
dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filter: '*' }],
dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filters: [] }],
};
mockPanelData.series = [
@ -150,7 +156,7 @@ describe('Azure Monitor QueryEditor', () => {
dimensionOptions={dimensionOptions}
/>
);
const labelSelect = await screen.findByText('Select value');
const labelSelect = await screen.findByText('Select value(s)');
await user.click(labelSelect);
const options = await screen.findAllByLabelText('Select option');
expect(options).toHaveLength(1);
@ -159,9 +165,10 @@ describe('Azure Monitor QueryEditor', () => {
it('correctly updates dimension labels', async () => {
let mockQuery = createMockQuery();
const mockPanelData = createMockPanelData();
mockQuery.azureMonitor = {
...mockQuery.azureMonitor,
dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filter: 'testlabel' }],
dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filters: ['testlabel'] }],
};
mockPanelData.series = [
@ -178,7 +185,7 @@ describe('Azure Monitor QueryEditor', () => {
];
const onQueryChange = jest.fn();
const dimensionOptions = [{ label: 'Test Dimension 1', value: 'TestDimension1' }];
render(
const { rerender } = render(
<DimensionFields
data={mockPanelData}
subscriptionId="123"
@ -191,14 +198,14 @@ describe('Azure Monitor QueryEditor', () => {
/>
);
await screen.findByText('testlabel');
const labelClear = await screen.findByLabelText('select-clear-value');
const labelClear = await screen.findByLabelText('Remove testlabel');
await user.click(labelClear);
mockQuery = setDimensionFilterValue(mockQuery, 0, 'filter', '');
mockQuery = setDimensionFilterValue(mockQuery, 0, 'filters', []);
expect(onQueryChange).toHaveBeenCalledWith({
...mockQuery,
azureMonitor: {
...mockQuery.azureMonitor,
dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filter: '' }],
dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filters: [] }],
},
});
mockPanelData.series = [
@ -214,7 +221,7 @@ describe('Azure Monitor QueryEditor', () => {
],
},
];
render(
rerender(
<DimensionFields
data={mockPanelData}
subscriptionId="123"
@ -226,11 +233,127 @@ describe('Azure Monitor QueryEditor', () => {
dimensionOptions={dimensionOptions}
/>
);
const labelSelect = await screen.findByText('Select value');
await user.click(labelSelect);
const labelSelect = await screen.getByLabelText('dimension-labels-select');
await openMenu(labelSelect);
const options = await screen.findAllByLabelText('Select option');
expect(options).toHaveLength(2);
expect(options[0]).toHaveTextContent('testlabel');
expect(options[1]).toHaveTextContent('testlabel2');
});
it('correctly selects multiple dimension labels', async () => {
let mockQuery = createMockQuery();
const mockPanelData = createMockPanelData();
mockPanelData.series = [
{
...mockPanelData.series[0],
fields: [
{
...mockPanelData.series[0].fields[0],
name: 'Test Dimension 1',
labels: { testdimension1: 'testlabel' },
},
],
},
{
...mockPanelData.series[0],
fields: [
{
...mockPanelData.series[0].fields[0],
name: 'Test Dimension 1',
labels: { testdimension1: 'testlabel2' },
},
],
},
];
const onQueryChange = jest.fn();
const dimensionOptions = [{ label: 'Test Dimension 1', value: 'TestDimension1' }];
mockQuery = appendDimensionFilter(mockQuery, 'TestDimension1');
const { rerender } = render(
<DimensionFields
data={mockPanelData}
subscriptionId="123"
query={mockQuery}
onQueryChange={onQueryChange}
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
setError={() => {}}
dimensionOptions={dimensionOptions}
/>
);
const labelSelect = await screen.getByLabelText('dimension-labels-select');
await user.click(labelSelect);
await openMenu(labelSelect);
await screen.getByText('testlabel');
await screen.getByText('testlabel2');
await selectOptionInTest(labelSelect, 'testlabel');
mockQuery = setDimensionFilterValue(mockQuery, 0, 'filters', ['testlabel']);
expect(onQueryChange).toHaveBeenCalledWith({
...mockQuery,
azureMonitor: {
...mockQuery.azureMonitor,
dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filters: ['testlabel'] }],
},
});
mockPanelData.series = [
{
...mockPanelData.series[0],
fields: [
{
...mockPanelData.series[0].fields[0],
name: 'Test Dimension 1',
labels: { testdimension1: 'testlabel' },
},
],
},
];
rerender(
<DimensionFields
data={mockPanelData}
subscriptionId="123"
query={mockQuery}
onQueryChange={onQueryChange}
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
setError={() => {}}
dimensionOptions={dimensionOptions}
/>
);
const labelSelect2 = await screen.getByLabelText('dimension-labels-select');
await openMenu(labelSelect2);
const refreshedOptions = await screen.findAllByLabelText('Select options menu');
expect(refreshedOptions).toHaveLength(1);
expect(refreshedOptions[0]).toHaveTextContent('testlabel2');
await selectOptionInTest(labelSelect2, 'testlabel2');
mockQuery = setDimensionFilterValue(mockQuery, 0, 'filters', ['testlabel', 'testlabel2']);
expect(onQueryChange).toHaveBeenCalledWith({
...mockQuery,
azureMonitor: {
...mockQuery.azureMonitor,
dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filters: ['testlabel', 'testlabel2'] }],
},
});
mockPanelData.series = [
{
...mockPanelData.series[0],
fields: [
{
...mockPanelData.series[0].fields[0],
name: 'Test Dimension 1',
labels: { testdimension1: 'testlabel' },
},
],
},
{
...mockPanelData.series[0],
fields: [
{
...mockPanelData.series[0].fields[0],
name: 'Test Dimension 1',
labels: { testdimension1: 'testlabel2' },
},
],
},
];
});
});

View File

@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { SelectableValue, DataFrame, PanelData } from '@grafana/data';
import { Button, Select, HorizontalGroup, VerticalGroup } from '@grafana/ui';
import { Button, Select, HorizontalGroup, VerticalGroup, MultiSelect } from '@grafana/ui';
import { AzureMetricDimension, AzureMonitorOption, AzureMonitorQuery, AzureQueryEditorFieldProps } from '../../types';
import { Field } from '../Field';
@ -44,7 +44,11 @@ const useDimensionLabels = (data: PanelData | undefined, query: AzureMonitorQuer
}
setDimensionLabels((prevLabels) => {
const newLabels: DimensionLabels = {};
for (const label of Object.keys(labelsObj)) {
const currentLabels = Object.keys(labelsObj);
if (currentLabels.length === 0) {
return prevLabels;
}
for (const label of currentLabels) {
if (prevLabels[label] && labelsObj[label].size < prevLabels[label].size) {
newLabels[label] = prevLabels[label];
} else {
@ -100,7 +104,7 @@ const DimensionFields: React.FC<DimensionFieldsProps> = ({ data, query, dimensio
};
const onFilterInputChange = (index: number, v: SelectableValue<string> | null) => {
onFieldChange(index, 'filter', v?.value ?? '');
onFieldChange(index, 'filters', [v?.value ?? '']);
};
const getValidDimensionOptions = (selectedDimension: string) => {
@ -118,6 +122,18 @@ const DimensionFields: React.FC<DimensionFieldsProps> = ({ data, query, dimensio
}));
};
const getValidMultiSelectOptions = (selectedFilters: string[] | undefined, dimension: string) => {
const labelOptions = getValidFilterOptions(undefined, dimension);
if (selectedFilters) {
for (const filter of selectedFilters) {
if (!labelOptions.find((label) => label.value === filter)) {
labelOptions.push({ value: filter, label: filter });
}
}
}
return labelOptions;
};
const getValidOperators = (selectedOperator: string) => {
if (dimensionOperators.find((operator: SelectableValue) => operator.value === selectedOperator)) {
return dimensionOperators;
@ -125,6 +141,14 @@ const DimensionFields: React.FC<DimensionFieldsProps> = ({ data, query, dimensio
return [...dimensionOperators, ...(selectedOperator ? [{ label: selectedOperator, value: selectedOperator }] : [])];
};
const onMultiSelectFilterChange = (index: number, v: Array<SelectableValue<string>>) => {
onFieldChange(
index,
'filters',
v.map((item) => item.value || '')
);
};
return (
<Field label="Dimension">
<VerticalGroup spacing="xs">
@ -145,16 +169,28 @@ const DimensionFields: React.FC<DimensionFieldsProps> = ({ data, query, dimensio
onChange={(v) => onFieldChange(index, 'operator', v.value ?? '')}
allowCustomValue
/>
<Select
menuShouldPortal
placeholder="Select value"
value={filter.filter ? filter.filter : ''}
allowCustomValue
options={getValidFilterOptions(filter.filter, filter.dimension)}
onChange={(v) => onFilterInputChange(index, v)}
isClearable
/>
{filter.operator === 'eq' || filter.operator === 'ne' ? (
<MultiSelect
menuShouldPortal
placeholder="Select value(s)"
value={filter.filters}
options={getValidMultiSelectOptions(filter.filters, filter.dimension)}
onChange={(v) => onMultiSelectFilterChange(index, v)}
aria-label={'dimension-labels-select'}
allowCustomValue
/>
) : (
// The API does not currently allow for multiple "starts with" clauses to be used.
<Select
menuShouldPortal
placeholder="Select value"
value={filter.filters ? filter.filters[0] : ''}
allowCustomValue
options={getValidFilterOptions(filter.filters ? filter.filters[0] : '', filter.dimension)}
onChange={(v) => onFilterInputChange(index, v)}
isClearable
/>
)}
<Button
variant="secondary"
size="md"

View File

@ -185,7 +185,7 @@ export function appendDimensionFilter(
query: AzureMonitorQuery,
dimension = '',
operator = 'eq',
filter = '*'
filters: string[] = []
): AzureMonitorQuery {
const existingFilters = query.azureMonitor?.dimensionFilters ?? [];
@ -194,7 +194,7 @@ export function appendDimensionFilter(
{
dimension,
operator,
filter,
filters,
},
]);
}
@ -216,6 +216,9 @@ export function setDimensionFilterValue<Key extends keyof AzureMetricDimension>(
const newFilters = [...existingFilters];
const newFilter = newFilters[index];
newFilter[fieldName] = value;
if (fieldName === 'dimension' || fieldName === 'operator') {
newFilter.filters = [];
}
return setDimensionFilters(query, newFilters);
}

View File

@ -81,5 +81,9 @@ export interface AzureResourceGraphQuery {
export interface AzureMetricDimension {
dimension: string;
operator: string;
filters?: string[];
/**
* @deprecated filter is deprecated in favour of filters to support multiselect
*/
filter?: string;
}

View File

@ -1,4 +1,4 @@
import { AzureMonitorQuery, AzureQueryType } from '../types';
import { AzureMetricDimension, AzureMonitorQuery, AzureQueryType } from '../types';
import migrateQuery from './migrateQuery';
@ -62,7 +62,7 @@ const modernMetricsQuery: AzureMonitorQuery = {
aggregation: 'Average',
alias: '{{ dimensionvalue }}',
allowedTimeGrainsMs: [60000, 300000, 900000, 1800000, 3600000, 21600000, 43200000, 86400000],
dimensionFilters: [{ dimension: 'dependency/success', filter: '', operator: 'eq' }],
dimensionFilters: [{ dimension: 'dependency/success', filters: ['*'], operator: 'eq' }],
metricDefinition: 'microsoft.insights/components',
metricName: 'dependencies/duration',
metricNamespace: 'microsoft.insights/components',
@ -115,4 +115,84 @@ describe('AzureMonitor: migrateQuery', () => {
);
});
});
describe('migrating from a v9 query to the latest query version', () => {
it('will not change valid dimension filters', () => {
const dimensionFilters: AzureMetricDimension[] = [
{ dimension: 'TestDimension', operator: 'eq', filters: ['testFilter'] },
];
const result = migrateQuery({ ...azureMonitorQueryV8, azureMonitor: { dimensionFilters } });
expect(result).toMatchObject(
expect.objectContaining({
azureMonitor: expect.objectContaining({
dimensionFilters,
}),
})
);
});
it('correctly updates old filter containing wildcard', () => {
const dimensionFilters: AzureMetricDimension[] = [{ dimension: 'TestDimension', operator: 'eq', filter: '*' }];
const result = migrateQuery({ ...azureMonitorQueryV8, azureMonitor: { dimensionFilters } });
expect(result).toMatchObject(
expect.objectContaining({
azureMonitor: expect.objectContaining({
dimensionFilters: [
{ dimension: dimensionFilters[0].dimension, operator: dimensionFilters[0].operator, filters: ['*'] },
],
}),
})
);
});
it('correctly updates old filter containing value', () => {
const dimensionFilters: AzureMetricDimension[] = [{ dimension: 'TestDimension', operator: 'eq', filter: 'test' }];
const result = migrateQuery({ ...azureMonitorQueryV8, azureMonitor: { dimensionFilters } });
expect(result).toMatchObject(
expect.objectContaining({
azureMonitor: expect.objectContaining({
dimensionFilters: [
{ dimension: dimensionFilters[0].dimension, operator: dimensionFilters[0].operator, filters: ['test'] },
],
}),
})
);
});
it('correctly ignores wildcard if filters has a value', () => {
const dimensionFilters: AzureMetricDimension[] = [
{ dimension: 'TestDimension', operator: 'eq', filter: '*', filters: ['testFilter'] },
];
const result = migrateQuery({ ...azureMonitorQueryV8, azureMonitor: { dimensionFilters } });
expect(result).toMatchObject(
expect.objectContaining({
azureMonitor: expect.objectContaining({
dimensionFilters: [
{
dimension: dimensionFilters[0].dimension,
operator: dimensionFilters[0].operator,
filters: ['testFilter'],
},
],
}),
})
);
});
it('correctly ignores duplicates', () => {
const dimensionFilters: AzureMetricDimension[] = [
{ dimension: 'TestDimension', operator: 'eq', filter: 'testFilter', filters: ['testFilter'] },
];
const result = migrateQuery({ ...azureMonitorQueryV8, azureMonitor: { dimensionFilters } });
expect(result).toMatchObject(
expect.objectContaining({
azureMonitor: expect.objectContaining({
dimensionFilters: [
{
dimension: dimensionFilters[0].dimension,
operator: dimensionFilters[0].operator,
filters: ['testFilter'],
},
],
}),
})
);
});
});
});

View File

@ -5,7 +5,7 @@ import {
setTimeGrain as setMetricsTimeGrain,
} from '../components/MetricsQueryEditor/setQueryValue';
import TimegrainConverter from '../time_grain_converter';
import { AzureMonitorQuery, AzureQueryType } from '../types';
import { AzureMetricDimension, AzureMonitorQuery, AzureQueryType } from '../types';
const OLD_DEFAULT_DROPDOWN_VALUE = 'select';
@ -20,8 +20,9 @@ export default function migrateQuery(query: AzureMonitorQuery): AzureMonitorQuer
workingQuery = migrateTimeGrains(workingQuery);
workingQuery = migrateLogAnalyticsToFromTimes(workingQuery);
workingQuery = migrateToDefaultNamespace(workingQuery);
workingQuery = migrateMetricsDimensionFilters(workingQuery);
workingQuery = migrateDimensionToDimensionFilter(workingQuery);
workingQuery = migrateResourceUri(workingQuery);
workingQuery = migrateDimensionFilterToArray(workingQuery);
return workingQuery;
}
@ -79,17 +80,14 @@ function migrateToDefaultNamespace(query: AzureMonitorQuery): AzureMonitorQuery
return query;
}
function migrateMetricsDimensionFilters(query: AzureMonitorQuery): AzureMonitorQuery {
function migrateDimensionToDimensionFilter(query: AzureMonitorQuery): AzureMonitorQuery {
let workingQuery = query;
const oldDimension = workingQuery.azureMonitor?.dimension;
if (oldDimension && oldDimension !== 'None') {
workingQuery = appendDimensionFilter(
workingQuery,
oldDimension,
'eq',
workingQuery.azureMonitor?.dimensionFilter || ''
);
workingQuery = appendDimensionFilter(workingQuery, oldDimension, 'eq', [
workingQuery.azureMonitor?.dimensionFilter || '',
]);
}
return workingQuery;
@ -122,6 +120,43 @@ function migrateResourceUri(query: AzureMonitorQuery): AzureMonitorQuery {
};
}
function migrateDimensionFilterToArray(query: AzureMonitorQuery): AzureMonitorQuery {
const azureMonitorQuery = query.azureMonitor;
if (!azureMonitorQuery) {
return query;
}
const newFilters: AzureMetricDimension[] = [];
const dimensionFilters = azureMonitorQuery.dimensionFilters;
if (dimensionFilters && dimensionFilters.length > 0) {
dimensionFilters.forEach((filter) => {
const staticProps = { dimension: filter.dimension, operator: filter.operator };
if (!filter.filters && filter.filter) {
newFilters.push({ ...staticProps, filters: [filter.filter] });
} else {
let hasFilter = false;
if (filter.filters && filter.filter) {
for (const oldFilter of filter.filters) {
if (filter.filter === oldFilter) {
hasFilter = true;
break;
}
}
if (!hasFilter && filter.filter !== '*') {
filter.filters.push(filter.filter);
}
newFilters.push({ ...staticProps, filters: filter.filters });
}
}
});
if (newFilters.length > 0) {
return { ...query, azureMonitor: { ...azureMonitorQuery, dimensionFilters: newFilters } };
}
}
return query;
}
// datasource.ts also contains some migrations, which have been moved to here. Unsure whether
// they should also do all the other migrations...
export function datasourceMigrations(query: AzureMonitorQuery): AzureMonitorQuery {
@ -135,8 +170,9 @@ export function datasourceMigrations(query: AzureMonitorQuery): AzureMonitorQuer
}
if (workingQuery.queryType === AzureQueryType.AzureMonitor && workingQuery.azureMonitor) {
workingQuery = migrateMetricsDimensionFilters(workingQuery);
workingQuery = migrateDimensionToDimensionFilter(workingQuery);
workingQuery = migrateResourceUri(workingQuery);
workingQuery = migrateDimensionFilterToArray(workingQuery);
}
return workingQuery;