diff --git a/docs/sources/datasources/azuremonitor/_index.md b/docs/sources/datasources/azuremonitor/_index.md index a613a9c7c2c..680b5794808 100644 --- a/docs/sources/datasources/azuremonitor/_index.md +++ b/docs/sources/datasources/azuremonitor/_index.md @@ -82,6 +82,14 @@ The legend label for Metrics can be changed using aliases. In the Legend Format | `{{ dimensionname }}` | _(Legacy for backwards compatibility)_ Replaced with the name of the first dimension | | `{{ dimensionvalue }}` | _(Legacy for backwards compatibility)_ Replaced with the value of the first dimension | +#### Dimensions + +Some metrics have additional metadata associated - dimensions. Dimensions are represented as key-value pairs assigned to each value of a metric. Grafana allows for the display and filtering of metrics based on dimension values. + +Multiple operators are supported (as detailed [here](https://docs.microsoft.com/en-us/rest/api/monitor/metrics/list)) - the `equals`, `not equals`, and `starts with` operators. + +Further documentation on multi-dimensional metrics is available [here](https://docs.microsoft.com/en-us/azure/azure-monitor/essentials/data-platform-metrics#multi-dimensional-metrics), and documentation on filtering [here](https://docs.microsoft.com/en-us/azure/azure-monitor/essentials/metrics-charts#filters). + #### Supported Azure Monitor metrics Not all metrics returned by the Azure Monitor Metrics API have values. To make it easier for you when building a query, the Grafana data source has a list of supported metrics and ignores metrics which will never have values. This list is updated regularly as new services and metrics are added to the Azure cloud. For more information about the list of metrics, refer to [current supported namespaces](https://github.com/grafana/grafana/blob/main/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/supported_namespaces.ts). diff --git a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go index 0b9c2f5f0d3..3a5ba27f2b2 100644 --- a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go +++ b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go @@ -129,7 +129,11 @@ func (e *AzureMonitorDatasource) buildQueries(queries []backend.DataQuery, dsInf dimSB.WriteString(fmt.Sprintf("%s eq '%s'", dimension, dimensionFilter)) } else { for i, filter := range azJSONModel.DimensionFilters { - dimSB.WriteString(filter.String()) + if filter.Operator != "eq" && filter.Filter == "*" { + dimSB.WriteString(fmt.Sprintf("%s eq '*'", filter.Dimension)) + } else { + dimSB.WriteString(filter.String()) + } if i != len(azJSONModel.DimensionFilters)-1 { dimSB.WriteString(" and ") } diff --git a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go index 912def85bb9..98a021aa429 100644 --- a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go +++ b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go @@ -130,6 +130,39 @@ func TestAzureMonitorBuildQueries(t *testing.T) { expectedInterval: "PT1M", azureMonitorQueryTarget: "%24filter=blob+eq+%27%2A%27&aggregation=Average&api-version=2018-01-01&interval=PT1M&metricnames=Percentage+CPU&metricnamespace=Microsoft.Compute-virtualMachines×pan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z", }, + { + name: "has dimensionFilter*s* property with not equals operator", + azureMonitorVariedProperties: map[string]interface{}{ + "timeGrain": "PT1M", + "dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "ne", Filter: "test"}}, + "top": "30", + }, + queryInterval: duration, + expectedInterval: "PT1M", + azureMonitorQueryTarget: "%24filter=blob+ne+%27test%27&aggregation=Average&api-version=2018-01-01&interval=PT1M&metricnames=Percentage+CPU&metricnamespace=Microsoft.Compute-virtualMachines×pan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z&top=30", + }, + { + name: "has dimensionFilter*s* property with startsWith operator", + azureMonitorVariedProperties: map[string]interface{}{ + "timeGrain": "PT1M", + "dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "sw", Filter: "test"}}, + "top": "30", + }, + queryInterval: duration, + expectedInterval: "PT1M", + azureMonitorQueryTarget: "%24filter=blob+sw+%27test%27&aggregation=Average&api-version=2018-01-01&interval=PT1M&metricnames=Percentage+CPU&metricnamespace=Microsoft.Compute-virtualMachines×pan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z&top=30", + }, + { + 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: "*"}}, + "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×pan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z&top=30", + }, } commonAzureModelProps := map[string]interface{}{ diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/panelData.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/panelData.ts new file mode 100644 index 00000000000..9ccdfe28a92 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/panelData.ts @@ -0,0 +1,50 @@ +import { FieldType, LoadingState, PanelData } from '@grafana/data'; + +type DeepPartial = { + [P in keyof T]?: DeepPartial; +}; + +export default function createMockPanelData(overrides?: DeepPartial) { + const _mockPanelData: DeepPartial = { + state: 'Loading' as LoadingState, + series: [ + { + refId: 'A', + fields: [ + { + name: 'Time', + type: 'time' as FieldType, + config: { links: Array(1) }, + values: [], + state: null, + }, + ], + length: 360, + }, + ], + annotations: [], + request: { + app: 'dashboard', + requestId: 'request', + timezone: 'browser', + panelId: 1, + dashboardId: 0, + timeInfo: '', + interval: '20s', + intervalMs: 20000, + targets: [], + maxDataPoints: 100, + rangeRaw: { + from: 'now-6h', + to: 'now', + }, + }, + + structureRev: 15, + ...overrides, + }; + + const mockPanelData = _mockPanelData as PanelData; + + return jest.mocked(mockPanelData, true); +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/DimensionFields.test.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/DimensionFields.test.tsx new file mode 100644 index 00000000000..b26f3cde2b9 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/DimensionFields.test.tsx @@ -0,0 +1,236 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { selectOptionInTest } from '@grafana/ui'; + +import createMockDatasource from '../../__mocks__/datasource'; +import createMockPanelData from '../../__mocks__/panelData'; +import createMockQuery from '../../__mocks__/query'; + +import DimensionFields from './DimensionFields'; +import { appendDimensionFilter, setDimensionFilterValue } from './setQueryValue'; + +const variableOptionGroup = { + label: 'Template variables', + options: [], +}; +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 onQueryChange = jest.fn(); + const dimensionOptions = [ + { label: 'Test Dimension 1', value: 'TestDimension1' }, + { label: 'Test Dimension 2', value: 'TestDimension2' }, + ]; + render( + {}} + dimensionOptions={dimensionOptions} + /> + ); + const addDimension = await screen.findByText('Add new dimension'); + await user.click(addDimension); + mockQuery = appendDimensionFilter(mockQuery); + expect(onQueryChange).toHaveBeenCalledWith({ + ...mockQuery, + azureMonitor: { ...mockQuery.azureMonitor, dimensionFilters: [{ dimension: '', operator: 'eq', filter: '*' }] }, + }); + render( + {}} + dimensionOptions={dimensionOptions} + /> + ); + const dimensionSelect = await screen.findByText('Field'); + await selectOptionInTest(dimensionSelect, 'Test Dimension 1'); + expect(onQueryChange).toHaveBeenCalledWith({ + ...mockQuery, + azureMonitor: { + ...mockQuery.azureMonitor, + dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filter: '*' }], + }, + }); + expect(screen.queryByText('Test Dimension 1')).toBeInTheDocument(); + expect(screen.queryByText('==')).toBeInTheDocument(); + }); + + it('correctly filters out dimensions when selected', async () => { + let mockQuery = createMockQuery(); + mockQuery.azureMonitor = { + ...mockQuery.azureMonitor, + dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filter: '*' }], + }; + const onQueryChange = jest.fn(); + const dimensionOptions = [ + { label: 'Test Dimension 1', value: 'TestDimension1' }, + { label: 'Test Dimension 2', value: 'TestDimension2' }, + ]; + render( + {}} + dimensionOptions={dimensionOptions} + /> + ); + const addDimension = await screen.findByText('Add new dimension'); + await user.click(addDimension); + mockQuery = appendDimensionFilter(mockQuery); + render( + {}} + dimensionOptions={dimensionOptions} + /> + ); + const dimensionSelect = await screen.findByText('Field'); + await user.click(dimensionSelect); + const options = await screen.findAllByLabelText('Select option'); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('Test Dimension 2'); + }); + + it('correctly displays dimension labels', async () => { + let mockQuery = createMockQuery(); + mockQuery.azureMonitor = { + ...mockQuery.azureMonitor, + dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filter: '*' }], + }; + + mockPanelData.series = [ + { + ...mockPanelData.series[0], + fields: [ + { + ...mockPanelData.series[0].fields[0], + name: 'Test Dimension 1', + labels: { testdimension1: 'testlabel' }, + }, + ], + }, + ]; + const onQueryChange = jest.fn(); + const dimensionOptions = [{ label: 'Test Dimension 1', value: 'TestDimension1' }]; + render( + {}} + dimensionOptions={dimensionOptions} + /> + ); + const labelSelect = await screen.findByText('Select value'); + await user.click(labelSelect); + const options = await screen.findAllByLabelText('Select option'); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('testlabel'); + }); + + it('correctly updates dimension labels', async () => { + let mockQuery = createMockQuery(); + mockQuery.azureMonitor = { + ...mockQuery.azureMonitor, + dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filter: 'testlabel' }], + }; + + mockPanelData.series = [ + { + ...mockPanelData.series[0], + fields: [ + { + ...mockPanelData.series[0].fields[0], + name: 'Test Dimension 1', + labels: { testdimension1: 'testlabel' }, + }, + ], + }, + ]; + const onQueryChange = jest.fn(); + const dimensionOptions = [{ label: 'Test Dimension 1', value: 'TestDimension1' }]; + render( + {}} + dimensionOptions={dimensionOptions} + /> + ); + await screen.findByText('testlabel'); + const labelClear = await screen.findByLabelText('select-clear-value'); + await user.click(labelClear); + mockQuery = setDimensionFilterValue(mockQuery, 0, 'filter', ''); + expect(onQueryChange).toHaveBeenCalledWith({ + ...mockQuery, + azureMonitor: { + ...mockQuery.azureMonitor, + dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filter: '' }], + }, + }); + mockPanelData.series = [ + ...mockPanelData.series, + { + ...mockPanelData.series[0], + fields: [ + { + ...mockPanelData.series[0].fields[0], + name: 'Test Dimension 1', + labels: { testdimension1: 'testlabel2' }, + }, + ], + }, + ]; + render( + {}} + dimensionOptions={dimensionOptions} + /> + ); + const labelSelect = await screen.findByText('Select value'); + await user.click(labelSelect); + const options = await screen.findAllByLabelText('Select option'); + expect(options).toHaveLength(2); + expect(options[0]).toHaveTextContent('testlabel'); + expect(options[1]).toHaveTextContent('testlabel2'); + }); +}); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/DimensionFields.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/DimensionFields.tsx index 664c9b31f61..4021493f7ee 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/DimensionFields.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/DimensionFields.tsx @@ -1,8 +1,9 @@ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; -import { Button, Select, Input, HorizontalGroup, VerticalGroup, InlineLabel } from '@grafana/ui'; +import { SelectableValue, DataFrame, PanelData } from '@grafana/data'; +import { Button, Select, HorizontalGroup, VerticalGroup } from '@grafana/ui'; -import { AzureMetricDimension, AzureMonitorOption, AzureQueryEditorFieldProps } from '../../types'; +import { AzureMetricDimension, AzureMonitorOption, AzureMonitorQuery, AzureQueryEditorFieldProps } from '../../types'; import { Field } from '../Field'; import { appendDimensionFilter, removeDimensionFilter, setDimensionFilterValue } from './setQueryValue'; @@ -11,12 +12,77 @@ interface DimensionFieldsProps extends AzureQueryEditorFieldProps { dimensionOptions: AzureMonitorOption[]; } -const DimensionFields: React.FC = ({ query, dimensionOptions, onQueryChange }) => { +interface DimensionLabels { + [key: string]: Set; +} + +const useDimensionLabels = (data: PanelData | undefined, query: AzureMonitorQuery) => { + const [dimensionLabels, setDimensionLabels] = useState({}); + useEffect(() => { + let labelsObj: DimensionLabels = {}; + if (data?.series?.length) { + // Identify which series' in the dataframe are relevant to the current query + const series: DataFrame[] = data.series.flat().filter((series) => series.refId === query.refId); + const fields = series.flatMap((series) => series.fields); + // Retrieve labels for series fields + const labels = fields + .map((fields) => fields.labels) + .flat() + .filter((item) => item!); + for (const label of labels) { + // Labels only exist for series that have a dimension selected + if (label) { + for (const [dimension, value] of Object.entries(label)) { + if (labelsObj[dimension]) { + labelsObj[dimension].add(value); + } else { + labelsObj[dimension] = new Set([value]); + } + } + } + } + } + setDimensionLabels((prevLabels) => { + const newLabels: DimensionLabels = {}; + for (const label of Object.keys(labelsObj)) { + if (prevLabels[label] && labelsObj[label].size < prevLabels[label].size) { + newLabels[label] = prevLabels[label]; + } else { + newLabels[label] = labelsObj[label]; + } + } + return newLabels; + }); + }, [data?.series, query.refId]); + return dimensionLabels; +}; + +const DimensionFields: React.FC = ({ data, query, dimensionOptions, onQueryChange }) => { const dimensionFilters = useMemo( () => query.azureMonitor?.dimensionFilters ?? [], [query.azureMonitor?.dimensionFilters] ); + const dimensionLabels = useDimensionLabels(data, query); + + const dimensionOperators: Array> = [ + { label: '==', value: 'eq' }, + { label: '!=', value: 'ne' }, + { label: 'starts with', value: 'sw' }, + ]; + + const validDimensionOptions = useMemo(() => { + // We filter out any dimensions that have already been used in a filter as the API doesn't support having multiple filters with the same dimension name. + // The Azure portal also doesn't support this feature so it makes sense for consistency. + let t = dimensionOptions; + if (dimensionFilters.length) { + t = dimensionOptions.filter( + (val) => !dimensionFilters.some((dimensionFilter) => dimensionFilter.dimension === val.value) + ); + } + return t; + }, [dimensionFilters, dimensionOptions]); + const addFilter = () => { onQueryChange(appendDimensionFilter(query)); }; @@ -33,10 +99,30 @@ const DimensionFields: React.FC = ({ query, dimensionOptio onQueryChange(setDimensionFilterValue(query, filterIndex, fieldName, value)); }; - const onFilterInputChange = (index: number, ev: React.FormEvent) => { - if (ev.target instanceof HTMLInputElement) { - onFieldChange(index, 'filter', ev.target.value); + const onFilterInputChange = (index: number, v: SelectableValue | null) => { + onFieldChange(index, 'filter', v?.value ?? ''); + }; + + const getValidDimensionOptions = (selectedDimension: string) => { + return validDimensionOptions.concat(dimensionOptions.filter((item) => item.value === selectedDimension)); + }; + + const getValidFilterOptions = (selectedFilter: string | undefined, dimension: string) => { + const dimensionFilters = Array.from(dimensionLabels[dimension.toLowerCase()] ?? []); + if (dimensionFilters.find((filter) => filter === selectedFilter)) { + return dimensionFilters.map((filter) => ({ value: filter, label: filter })); } + return [...dimensionFilters, ...(selectedFilter && selectedFilter !== '*' ? [selectedFilter] : [])].map((item) => ({ + value: item, + label: item, + })); + }; + + const getValidOperators = (selectedOperator: string) => { + if (dimensionOperators.find((operator: SelectableValue) => operator.value === selectedOperator)) { + return dimensionOperators; + } + return [...dimensionOperators, ...(selectedOperator ? [{ label: selectedOperator, value: selectedOperator }] : [])]; }; return ( @@ -48,12 +134,28 @@ const DimensionFields: React.FC = ({ query, dimensionOptio menuShouldPortal placeholder="Field" value={filter.dimension} - options={dimensionOptions} + options={getValidDimensionOptions(filter.dimension)} onChange={(v) => onFieldChange(index, 'dimension', v.value ?? '')} width={38} /> - == - onFilterInputChange(index, ev)} /> + onFilterInputChange(index, v)} + isClearable + /> +