diff --git a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go index 3def772d76b..0fcd93f7fca 100644 --- a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go +++ b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go @@ -217,7 +217,6 @@ func (e *AzureMonitorDatasource) createRequest(ctx context.Context, dsInfo types azlog.Debug("Failed to create request", "error", err) return nil, errutil.Wrap("Failed to create request", err) } - req.URL.Path = "/subscriptions" req.Header.Set("Content-Type", "application/json") return req, nil diff --git a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go index 41453545713..89faac286c0 100644 --- a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go +++ b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go @@ -163,7 +163,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) { } azureMonitorQuery := &types.AzureMonitorQuery{ - URL: "12345678-aaaa-bbbb-cccc-123456789abc/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana/providers/microsoft.insights/metrics", + URL: "/subscriptions/12345678-aaaa-bbbb-cccc-123456789abc/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana/providers/microsoft.insights/metrics", UrlComponents: map[string]string{ "metricDefinition": "Microsoft.Compute/virtualMachines", "resourceGroup": "grafanastaging", @@ -587,7 +587,7 @@ func TestAzureMonitorCreateRequest(t *testing.T) { }{ { name: "creates a request", - expectedURL: "http://ds/subscriptions", + expectedURL: "http://ds/", expectedHeaders: http.Header{ "Content-Type": []string{"application/json"}, }, diff --git a/pkg/tsdb/azuremonitor/metrics/url-builder.go b/pkg/tsdb/azuremonitor/metrics/url-builder.go index 4745f3bcdf0..8c3fad8fd37 100644 --- a/pkg/tsdb/azuremonitor/metrics/url-builder.go +++ b/pkg/tsdb/azuremonitor/metrics/url-builder.go @@ -18,6 +18,35 @@ type urlBuilder struct { ResourceName string } +func (params *urlBuilder) buildMetricsURLFromLegacyQuery() string { + subscription := params.Subscription + + if params.Subscription == "" { + subscription = params.DefaultSubscription + } + + metricDefinitionArray := strings.Split(params.MetricDefinition, "/") + resourceNameArray := strings.Split(params.ResourceName, "/") + provider := metricDefinitionArray[0] + metricDefinitionArray = metricDefinitionArray[1:] + + urlArray := []string{ + "/subscriptions", + subscription, + "resourceGroups", + params.ResourceGroup, + "providers", + provider, + } + + for i, metricDefinition := range metricDefinitionArray { + urlArray = append(urlArray, metricDefinition, resourceNameArray[i]) + } + + resourceURI := strings.Join(urlArray, "/") + return resourceURI +} + // BuildMetricsURL checks the metric definition property to see which form of the url // should be returned func (params *urlBuilder) BuildMetricsURL() string { @@ -25,31 +54,7 @@ func (params *urlBuilder) BuildMetricsURL() string { // Prior to Grafana 9, we had a legacy query object rather than a resourceURI, so we manually create the resource URI if resourceURI == "" { - subscription := params.Subscription - - if params.Subscription == "" { - subscription = params.DefaultSubscription - } - - metricDefinitionArray := strings.Split(params.MetricDefinition, "/") - resourceNameArray := strings.Split(params.ResourceName, "/") - provider := metricDefinitionArray[0] - metricDefinitionArray = metricDefinitionArray[1:] - - urlArray := []string{ - subscription, - "resourceGroups", - params.ResourceGroup, - "providers", - provider, - } - - for i := range metricDefinitionArray { - urlArray = append(urlArray, metricDefinitionArray[i]) - urlArray = append(urlArray, resourceNameArray[i]) - } - - resourceURI = strings.Join(urlArray[:], "/") + resourceURI = params.buildMetricsURLFromLegacyQuery() } return fmt.Sprintf("%s/providers/microsoft.insights/metrics", resourceURI) diff --git a/pkg/tsdb/azuremonitor/metrics/url-builder_test.go b/pkg/tsdb/azuremonitor/metrics/url-builder_test.go index 458cf7b491c..1abde71f094 100644 --- a/pkg/tsdb/azuremonitor/metrics/url-builder_test.go +++ b/pkg/tsdb/azuremonitor/metrics/url-builder_test.go @@ -3,23 +3,23 @@ package metrics import ( "testing" - "github.com/stretchr/testify/require" + "github.com/stretchr/testify/assert" ) func TestURLBuilder(t *testing.T) { t.Run("AzureMonitor URL Builder", func(t *testing.T) { t.Run("when only resource uri is provided it returns resource/uri/providers/microsoft.insights/metrics", func(t *testing.T) { ub := &urlBuilder{ - ResourceURI: "resource/uri", + ResourceURI: "/subscriptions/sub/resource/uri", } url := ub.BuildMetricsURL() - require.Equal(t, url, "resource/uri/providers/microsoft.insights/metrics") + assert.Equal(t, "/subscriptions/sub/resource/uri/providers/microsoft.insights/metrics", url) }) t.Run("when resource uri and legacy fields are provided the legacy fields are ignored", func(t *testing.T) { ub := &urlBuilder{ - ResourceURI: "resource/uri", + ResourceURI: "/subscriptions/sub/resource/uri", DefaultSubscription: "default-sub", ResourceGroup: "rg", MetricDefinition: "Microsoft.NetApp/netAppAccounts/capacityPools/volumes", @@ -27,7 +27,7 @@ func TestURLBuilder(t *testing.T) { } url := ub.BuildMetricsURL() - require.Equal(t, url, "resource/uri/providers/microsoft.insights/metrics") + assert.Equal(t, "/subscriptions/sub/resource/uri/providers/microsoft.insights/metrics", url) }) t.Run("Legacy URL Builder params", func(t *testing.T) { @@ -40,7 +40,7 @@ func TestURLBuilder(t *testing.T) { } url := ub.BuildMetricsURL() - require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics") + assert.Equal(t, "/subscriptions/default-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics", url) }) t.Run("when metric definition is in the short form and a subscription is defined", func(t *testing.T) { @@ -53,7 +53,7 @@ func TestURLBuilder(t *testing.T) { } url := ub.BuildMetricsURL() - require.Equal(t, url, "specified-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics") + assert.Equal(t, "/subscriptions/specified-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics", url) }) t.Run("when metric definition is Microsoft.Storage/storageAccounts/blobServices", func(t *testing.T) { @@ -65,7 +65,7 @@ func TestURLBuilder(t *testing.T) { } url := ub.BuildMetricsURL() - require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/providers/microsoft.insights/metrics") + assert.Equal(t, "/subscriptions/default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/providers/microsoft.insights/metrics", url) }) t.Run("when metric definition is Microsoft.Storage/storageAccounts/fileServices", func(t *testing.T) { @@ -77,7 +77,7 @@ func TestURLBuilder(t *testing.T) { } url := ub.BuildMetricsURL() - require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/providers/microsoft.insights/metrics") + assert.Equal(t, "/subscriptions/default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/providers/microsoft.insights/metrics", url) }) t.Run("when metric definition is Microsoft.NetApp/netAppAccounts/capacityPools/volumes", func(t *testing.T) { @@ -89,7 +89,7 @@ func TestURLBuilder(t *testing.T) { } url := ub.BuildMetricsURL() - require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/providers/microsoft.insights/metrics") + assert.Equal(t, "/subscriptions/default-sub/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/providers/microsoft.insights/metrics", url) }) }) }) diff --git a/pkg/tsdb/azuremonitor/types/types.go b/pkg/tsdb/azuremonitor/types/types.go index f0d40823c7b..1a9b4d60271 100644 --- a/pkg/tsdb/azuremonitor/types/types.go +++ b/pkg/tsdb/azuremonitor/types/types.go @@ -121,9 +121,12 @@ type AzureMonitorJSONQuery struct { // Legecy "resource" fields from before the resource picker provided just a single ResourceURI // These are used for pre-resource picker queries to reconstruct a resource URI + // Deprecated MetricDefinition string `json:"metricDefinition"` - ResourceGroup string `json:"resourceGroup"` - ResourceName string `json:"resourceName"` + // Deprecated + ResourceGroup string `json:"resourceGroup"` + // Deprecated + ResourceName string `json:"resourceName"` AllowedTimeGrainsMs []int64 `json:"allowedTimeGrainsMs"` Dimension string `json:"dimension"` // old model diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/datasource.ts index 23492d50d3f..1793aa8a397 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/datasource.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/datasource.ts @@ -16,6 +16,14 @@ export default function createMockDatasource(overrides?: DeepPartial }, getSubscriptions: jest.fn().mockResolvedValueOnce([]), defaultSubscriptionId: 'subscriptionId', + newGetMetricNamespaces: jest.fn().mockResolvedValueOnce([]), + newGetMetricNames: jest.fn().mockResolvedValueOnce([]), + newGetMetricMetadata: jest.fn().mockResolvedValueOnce({ + primaryAggType: 'Average', + supportedAggTypes: ['Average', 'Maximum', 'Minimum'], + supportedTimeGrains: [], + dimensions: [], + }), }, getAzureLogAnalyticsWorkspaces: jest.fn().mockResolvedValueOnce([]), @@ -41,6 +49,7 @@ export default function createMockDatasource(overrides?: DeepPartial getResourceGroupsBySubscriptionId: jest.fn().mockResolvedValue([]), getResourcesForResourceGroup: jest.fn().mockResolvedValue([]), getResourceURIFromWorkspace: jest.fn().mockReturnValue(''), + getResourceURIDisplayProperties: jest.fn().mockResolvedValue({}), }, ...overrides, }; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/query.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/query.ts index 883c4452e58..8ed51200cea 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/query.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/query.ts @@ -17,6 +17,8 @@ export default function createMockQuery(): AzureMonitorQuery { azureMonitor: { // aggOptions: [], + resourceUri: + '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana', aggregation: 'Average', allowedTimeGrainsMs: [60000, 300000, 900000, 1800000, 3600000, 21600000, 43200000, 86400000], // dimensionFilter: '*', diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/resourcePickerData.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/resourcePickerData.ts index 9d963262bd7..e4659041b64 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/resourcePickerData.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/resourcePickerData.ts @@ -10,6 +10,7 @@ export default function createMockResourcePickerData(overrides?: DeepPartial [ { id: 'dev-1', uri: '/subscriptions/def-456/resourceGroups/dev-1', - name: 'Development', + name: 'Development 1', type: ResourceRowType.ResourceGroup, typeLabel: 'Resource Group', children: [], @@ -39,7 +39,7 @@ export const createMockResourceGroupsBySubscription = (): ResourceRowGroup => [ { id: 'dev-2', uri: '/subscriptions/def-456/resourceGroups/dev-2', - name: 'Development', + name: 'Development 2', type: ResourceRowType.ResourceGroup, typeLabel: 'Resource Group', children: [], @@ -55,7 +55,7 @@ export const createMockResourceGroupsBySubscription = (): ResourceRowGroup => [ { id: 'dev-4', uri: '/subscriptions/def-456/resourceGroups/dev-4', - name: 'Development', + name: 'Development 3', type: ResourceRowType.ResourceGroup, typeLabel: 'Resource Group', children: [], @@ -63,7 +63,7 @@ export const createMockResourceGroupsBySubscription = (): ResourceRowGroup => [ { id: 'dev-5', uri: '/subscriptions/def-456/resourceGroups/dev-5', - name: 'Development', + name: 'Development 4', type: ResourceRowType.ResourceGroup, typeLabel: 'Resource Group', children: [], diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts index 8236bb81437..a6cbb496067 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts @@ -75,266 +75,59 @@ describe('AzureMonitorDatasource', () => { }); }); }); - describe('When performing getSubscriptions', () => { + + describe('When performing newGetMetricNamespaces', () => { const response = { value: [ { - id: '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572', - subscriptionId: '99999999-cccc-bbbb-aaaa-9106972f9572', - tenantId: '99999999-aaaa-bbbb-cccc-51c4f982ec48', - displayName: 'Primary Subscription', - state: 'Enabled', - subscriptionPolicies: { - locationPlacementId: 'Public_2014-09-01', - quotaId: 'PayAsYouGo_2014-09-01', - spendingLimit: 'Off', + id: '/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp/providers/microsoft.insights/components/resource1/providers/microsoft.insights/metricNamespaces/Azure.ApplicationInsights', + name: 'Azure.ApplicationInsights', + type: 'Microsoft.Insights/metricNamespaces', + classification: 'Custom', + properties: { + metricNamespaceName: 'Azure.ApplicationInsights', }, - authorizationSource: 'RoleBased', - }, - ], - count: { - type: 'Total', - value: 1, - }, - }; - - beforeEach(() => { - ctx.instanceSettings.jsonData.azureAuthType = 'msi'; - ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockResolvedValue(response); - }); - - it('should return list of subscriptions', () => { - return ctx.ds.getSubscriptions().then((results: Array<{ text: string; value: string }>) => { - expect(results.length).toEqual(1); - expect(results[0].text).toEqual('Primary Subscription'); - expect(results[0].value).toEqual('99999999-cccc-bbbb-aaaa-9106972f9572'); - }); - }); - }); - - describe('When performing getResourceGroups', () => { - const response = { - value: [{ name: 'grp1' }, { name: 'grp2' }], - }; - - beforeEach(() => { - ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockResolvedValue(response); - }); - - it('should return list of Resource Groups', () => { - return ctx.ds.getResourceGroups('subscriptionId').then((results: Array<{ text: string; value: string }>) => { - expect(results.length).toEqual(2); - expect(results[0].text).toEqual('grp1'); - expect(results[0].value).toEqual('grp1'); - expect(results[1].text).toEqual('grp2'); - expect(results[1].value).toEqual('grp2'); - }); - }); - }); - - describe('When performing getMetricDefinitions', () => { - const response = { - value: [ - { - name: 'test', - type: 'Microsoft.Network/networkInterfaces', }, { - location: 'northeurope', - name: 'northeur', - type: 'Microsoft.Compute/virtualMachines', - }, - { - location: 'westcentralus', - name: 'us', - type: 'Microsoft.Compute/virtualMachines', - }, - { - name: 'IHaveNoMetrics', - type: 'IShouldBeFilteredOut', - }, - { - name: 'storageTest', - type: 'Microsoft.Storage/storageAccounts', + id: '/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp/providers/microsoft.insights/components/resource1/providers/microsoft.insights/metricNamespaces/microsoft.insights-components', + name: 'microsoft.insights-components', + type: 'Microsoft.Insights/metricNamespaces', + classification: 'Platform', + properties: { + metricNamespaceName: 'microsoft.insights/components', + }, }, ], }; beforeEach(() => { ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => { - const basePath = 'azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups'; - expect(path).toBe(basePath + '/nodesapp/resources?api-version=2021-04-01'); + const basePath = 'azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp'; + const expected = + basePath + + '/providers/microsoft.insights/components/resource1' + + '/providers/microsoft.insights/metricNamespaces?api-version=2017-12-01-preview'; + expect(path).toBe(expected); return Promise.resolve(response); }); }); - it('should return list of Metric Definitions with no duplicates and no unsupported namespaces', () => { - return ctx.ds - .getMetricDefinitions('9935389e-9122-4ef9-95f9-1513dd24753f', 'nodesapp') + it('should return list of Metric Namspaces', () => { + return ctx.ds.azureMonitorDatasource + .newGetMetricNamespaces( + '/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp/providers/microsoft.insights/components/resource1' + ) .then((results: Array<{ text: string; value: string }>) => { - expect(results.length).toEqual(7); - expect(results[0].text).toEqual('Network interface'); - expect(results[0].value).toEqual('Microsoft.Network/networkInterfaces'); - expect(results[1].text).toEqual('Virtual machine'); - expect(results[1].value).toEqual('Microsoft.Compute/virtualMachines'); - expect(results[2].text).toEqual('Storage account'); - expect(results[2].value).toEqual('Microsoft.Storage/storageAccounts'); - expect(results[3].text).toEqual('Microsoft.Storage/storageAccounts/blobServices'); - expect(results[3].value).toEqual('Microsoft.Storage/storageAccounts/blobServices'); - expect(results[4].text).toEqual('Microsoft.Storage/storageAccounts/fileServices'); - expect(results[4].value).toEqual('Microsoft.Storage/storageAccounts/fileServices'); - expect(results[5].text).toEqual('Microsoft.Storage/storageAccounts/tableServices'); - expect(results[5].value).toEqual('Microsoft.Storage/storageAccounts/tableServices'); - expect(results[6].text).toEqual('Microsoft.Storage/storageAccounts/queueServices'); - expect(results[6].value).toEqual('Microsoft.Storage/storageAccounts/queueServices'); + expect(results.length).toEqual(2); + expect(results[0].text).toEqual('Azure.ApplicationInsights'); + expect(results[0].value).toEqual('Azure.ApplicationInsights'); + expect(results[1].text).toEqual('microsoft.insights-components'); + expect(results[1].value).toEqual('microsoft.insights/components'); }); }); }); - describe('When performing getResourceNames', () => { - let subscription = '9935389e-9122-4ef9-95f9-1513dd24753f'; - let resourceGroup = 'nodeapp'; - let metricDefinition = 'microsoft.insights/components'; - - beforeEach(() => { - subscription = '9935389e-9122-4ef9-95f9-1513dd24753f'; - resourceGroup = 'nodeapp'; - metricDefinition = 'microsoft.insights/components'; - }); - - describe('and there are no special cases', () => { - const response = { - value: [ - { - name: 'Failure Anomalies - nodeapp', - type: 'microsoft.insights/alertrules', - }, - { - name: resourceGroup, - type: metricDefinition, - }, - ], - }; - - beforeEach(() => { - ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => { - const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`; - expect(path).toBe( - `${basePath}/${resourceGroup}/resources?$filter=resourceType eq '${metricDefinition}'&api-version=2021-04-01` - ); - return Promise.resolve(response); - }); - }); - - it('should return list of Resource Names', () => { - return ctx.ds - .getResourceNames(subscription, resourceGroup, metricDefinition) - .then((results: Array<{ text: string; value: string }>) => { - expect(results.length).toEqual(1); - expect(results[0].text).toEqual('nodeapp'); - expect(results[0].value).toEqual('nodeapp'); - }); - }); - - it('should return ignore letter case', () => { - metricDefinition = 'microsoft.insights/Components'; - return ctx.ds - .getResourceNames(subscription, resourceGroup, metricDefinition) - .then((results: Array<{ text: string; value: string }>) => { - expect(results.length).toEqual(1); - expect(results[0].text).toEqual('nodeapp'); - expect(results[0].value).toEqual('nodeapp'); - }); - }); - }); - - describe('and the metric definition is blobServices', () => { - const response = { - value: [ - { - name: 'Failure Anomalies - nodeapp', - type: 'microsoft.insights/alertrules', - }, - { - name: 'storagetest', - type: 'Microsoft.Storage/storageAccounts', - }, - ], - }; - - beforeEach(() => { - ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => { - const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`; - expect(path).toBe( - basePath + - `/${resourceGroup}/resources?$filter=resourceType eq '${metricDefinition}'&api-version=2021-04-01` - ); - return Promise.resolve(response); - }); - }); - - it('should return list of Resource Names', () => { - metricDefinition = 'Microsoft.Storage/storageAccounts/blobServices'; - return ctx.ds - .getResourceNames(subscription, resourceGroup, metricDefinition) - .then((results: Array<{ text: string; value: string }>) => { - expect(results.length).toEqual(1); - expect(results[0].text).toEqual('storagetest/default'); - expect(results[0].value).toEqual('storagetest/default'); - }); - }); - }); - - describe('and there are several pages', () => { - const skipToken = 'token'; - const response1 = { - value: [ - { - name: `${resourceGroup}1`, - type: metricDefinition, - }, - ], - nextLink: `https://management.azure.com/resourceuri?$skiptoken=${skipToken}`, - }; - const response2 = { - value: [ - { - name: `${resourceGroup}2`, - type: metricDefinition, - }, - ], - }; - - beforeEach(() => { - const fn = jest.fn(); - ctx.ds.azureMonitorDatasource.getResource = fn; - const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`; - const expectedPath = `${basePath}/${resourceGroup}/resources?$filter=resourceType eq '${metricDefinition}'&api-version=2021-04-01`; - // first page - fn.mockImplementationOnce((path: string) => { - expect(path).toBe(expectedPath); - return Promise.resolve(response1); - }); - // second page - fn.mockImplementationOnce((path: string) => { - expect(path).toBe(`${expectedPath}&$skiptoken=${skipToken}`); - return Promise.resolve(response2); - }); - }); - - it('should return list of Resource Names', () => { - return ctx.ds - .getResourceNames(subscription, resourceGroup, metricDefinition) - .then((results: Array<{ text: string; value: string }>) => { - expect(results.length).toEqual(2); - expect(results[0].value).toEqual(`${resourceGroup}1`); - expect(results[1].value).toEqual(`${resourceGroup}2`); - }); - }); - }); - }); - - describe('When performing getMetricNames', () => { + describe('When performing newGetMetricNames', () => { const response = { value: [ { @@ -383,12 +176,9 @@ describe('AzureMonitorDatasource', () => { }); it('should return list of Metric Definitions', () => { - return ctx.ds - .getMetricNames( - '9935389e-9122-4ef9-95f9-1513dd24753f', - 'nodeapp', - 'microsoft.insights/components', - 'resource1', + return ctx.ds.azureMonitorDatasource + .newGetMetricNames( + '/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp/providers/microsoft.insights/components/resource1', 'default' ) .then((results: Array<{ text: string; value: string }>) => { @@ -401,7 +191,7 @@ describe('AzureMonitorDatasource', () => { }); }); - describe('When performing getMetricMetadata', () => { + describe('When performing newGetMetricMetadata', () => { const response = { value: [ { @@ -450,12 +240,9 @@ describe('AzureMonitorDatasource', () => { }); it('should return Aggregation metadata for a Metric', () => { - return ctx.ds - .getMetricMetadata( - '9935389e-9122-4ef9-95f9-1513dd24753f', - 'nodeapp', - 'microsoft.insights/components', - 'resource1', + return ctx.ds.azureMonitorDatasource + .newGetMetricMetadata( + '/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp/providers/microsoft.insights/components/resource1', 'default', 'UsedCapacity' ) @@ -467,130 +254,524 @@ describe('AzureMonitorDatasource', () => { }); }); - describe('When performing getMetricMetadata on metrics with dimensions', () => { - const response = { - value: [ - { - name: { - value: 'Transactions', - localizedValue: 'Transactions', + describe('Legacy Azure Monitor Query Object data fetchers', () => { + describe('When performing getSubscriptions', () => { + const response = { + value: [ + { + id: '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572', + subscriptionId: '99999999-cccc-bbbb-aaaa-9106972f9572', + tenantId: '99999999-aaaa-bbbb-cccc-51c4f982ec48', + displayName: 'Primary Subscription', + state: 'Enabled', + subscriptionPolicies: { + locationPlacementId: 'Public_2014-09-01', + quotaId: 'PayAsYouGo_2014-09-01', + spendingLimit: 'Off', + }, + authorizationSource: 'RoleBased', }, - unit: 'Count', - primaryAggregationType: 'Total', - supportedAggregationTypes: ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count'], - isDimensionRequired: false, - dimensions: [ + ], + count: { + type: 'Total', + value: 1, + }, + }; + + beforeEach(() => { + ctx.instanceSettings.jsonData.azureAuthType = 'msi'; + ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockResolvedValue(response); + }); + + it('should return list of subscriptions', () => { + return ctx.ds.getSubscriptions().then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toEqual(1); + expect(results[0].text).toEqual('Primary Subscription'); + expect(results[0].value).toEqual('99999999-cccc-bbbb-aaaa-9106972f9572'); + }); + }); + }); + + describe('When performing getResourceGroups', () => { + const response = { + value: [{ name: 'grp1' }, { name: 'grp2' }], + }; + + beforeEach(() => { + ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockResolvedValue(response); + }); + + it('should return list of Resource Groups', () => { + return ctx.ds.getResourceGroups('subscriptionId').then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toEqual(2); + expect(results[0].text).toEqual('grp1'); + expect(results[0].value).toEqual('grp1'); + expect(results[1].text).toEqual('grp2'); + expect(results[1].value).toEqual('grp2'); + }); + }); + }); + + describe('When performing getMetricDefinitions', () => { + const response = { + value: [ + { + name: 'test', + type: 'Microsoft.Network/networkInterfaces', + }, + { + location: 'northeurope', + name: 'northeur', + type: 'Microsoft.Compute/virtualMachines', + }, + { + location: 'westcentralus', + name: 'us', + type: 'Microsoft.Compute/virtualMachines', + }, + { + name: 'IHaveNoMetrics', + type: 'IShouldBeFilteredOut', + }, + { + name: 'storageTest', + type: 'Microsoft.Storage/storageAccounts', + }, + ], + }; + + beforeEach(() => { + ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => { + const basePath = 'azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups'; + expect(path).toBe(basePath + '/nodesapp/resources?api-version=2021-04-01'); + return Promise.resolve(response); + }); + }); + + it('should return list of Metric Definitions with no duplicates and no unsupported namespaces', () => { + return ctx.ds + .getMetricDefinitions('9935389e-9122-4ef9-95f9-1513dd24753f', 'nodesapp') + .then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toEqual(7); + expect(results[0].text).toEqual('Network interface'); + expect(results[0].value).toEqual('Microsoft.Network/networkInterfaces'); + expect(results[1].text).toEqual('Virtual machine'); + expect(results[1].value).toEqual('Microsoft.Compute/virtualMachines'); + expect(results[2].text).toEqual('Storage account'); + expect(results[2].value).toEqual('Microsoft.Storage/storageAccounts'); + expect(results[3].text).toEqual('Microsoft.Storage/storageAccounts/blobServices'); + expect(results[3].value).toEqual('Microsoft.Storage/storageAccounts/blobServices'); + expect(results[4].text).toEqual('Microsoft.Storage/storageAccounts/fileServices'); + expect(results[4].value).toEqual('Microsoft.Storage/storageAccounts/fileServices'); + expect(results[5].text).toEqual('Microsoft.Storage/storageAccounts/tableServices'); + expect(results[5].value).toEqual('Microsoft.Storage/storageAccounts/tableServices'); + expect(results[6].text).toEqual('Microsoft.Storage/storageAccounts/queueServices'); + expect(results[6].value).toEqual('Microsoft.Storage/storageAccounts/queueServices'); + }); + }); + }); + + describe('When performing getResourceNames', () => { + let subscription = '9935389e-9122-4ef9-95f9-1513dd24753f'; + let resourceGroup = 'nodeapp'; + let metricDefinition = 'microsoft.insights/components'; + + beforeEach(() => { + subscription = '9935389e-9122-4ef9-95f9-1513dd24753f'; + resourceGroup = 'nodeapp'; + metricDefinition = 'microsoft.insights/components'; + }); + + describe('and there are no special cases', () => { + const response = { + value: [ { - value: 'ResponseType', - localizedValue: 'Response type', + name: 'Failure Anomalies - nodeapp', + type: 'microsoft.insights/alertrules', }, { - value: 'GeoType', - localizedValue: 'Geo type', - }, - { - value: 'ApiName', - localizedValue: 'API name', + name: resourceGroup, + type: metricDefinition, }, ], - }, - { - name: { - value: 'FreeCapacity', - localizedValue: 'Free capacity', + }; + + beforeEach(() => { + ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => { + const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`; + expect(path).toBe( + `${basePath}/${resourceGroup}/resources?$filter=resourceType eq '${metricDefinition}'&api-version=2021-04-01` + ); + return Promise.resolve(response); + }); + }); + + it('should return list of Resource Names', () => { + return ctx.ds + .getResourceNames(subscription, resourceGroup, metricDefinition) + .then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toEqual(1); + expect(results[0].text).toEqual('nodeapp'); + expect(results[0].value).toEqual('nodeapp'); + }); + }); + + it('should return ignore letter case', () => { + metricDefinition = 'microsoft.insights/Components'; + return ctx.ds + .getResourceNames(subscription, resourceGroup, metricDefinition) + .then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toEqual(1); + expect(results[0].text).toEqual('nodeapp'); + expect(results[0].value).toEqual('nodeapp'); + }); + }); + }); + + describe('and the metric definition is blobServices', () => { + const response = { + value: [ + { + name: 'Failure Anomalies - nodeapp', + type: 'microsoft.insights/alertrules', + }, + { + name: 'storagetest', + type: 'Microsoft.Storage/storageAccounts', + }, + ], + }; + + beforeEach(() => { + ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => { + const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`; + expect(path).toBe( + basePath + + `/${resourceGroup}/resources?$filter=resourceType eq '${metricDefinition}'&api-version=2021-04-01` + ); + return Promise.resolve(response); + }); + }); + + it('should return list of Resource Names', () => { + metricDefinition = 'Microsoft.Storage/storageAccounts/blobServices'; + return ctx.ds + .getResourceNames(subscription, resourceGroup, metricDefinition) + .then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toEqual(1); + expect(results[0].text).toEqual('storagetest/default'); + expect(results[0].value).toEqual('storagetest/default'); + }); + }); + }); + + describe('and there are several pages', () => { + const skipToken = 'token'; + const response1 = { + value: [ + { + name: `${resourceGroup}1`, + type: metricDefinition, + }, + ], + nextLink: `https://management.azure.com/resourceuri?$skiptoken=${skipToken}`, + }; + const response2 = { + value: [ + { + name: `${resourceGroup}2`, + type: metricDefinition, + }, + ], + }; + + beforeEach(() => { + const fn = jest.fn(); + ctx.ds.azureMonitorDatasource.getResource = fn; + const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`; + const expectedPath = `${basePath}/${resourceGroup}/resources?$filter=resourceType eq '${metricDefinition}'&api-version=2021-04-01`; + // first page + fn.mockImplementationOnce((path: string) => { + expect(path).toBe(expectedPath); + return Promise.resolve(response1); + }); + // second page + fn.mockImplementationOnce((path: string) => { + expect(path).toBe(`${expectedPath}&$skiptoken=${skipToken}`); + return Promise.resolve(response2); + }); + }); + + it('should return list of Resource Names', () => { + return ctx.ds + .getResourceNames(subscription, resourceGroup, metricDefinition) + .then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toEqual(2); + expect(results[0].value).toEqual(`${resourceGroup}1`); + expect(results[1].value).toEqual(`${resourceGroup}2`); + }); + }); + }); + }); + + describe('When performing getMetricNames', () => { + const response = { + value: [ + { + name: { + value: 'UsedCapacity', + localizedValue: 'Used capacity', + }, + unit: 'CountPerSecond', + primaryAggregationType: 'Total', + supportedAggregationTypes: ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count'], + metricAvailabilities: [ + { timeGrain: 'PT1H', retention: 'P93D' }, + { timeGrain: 'PT6H', retention: 'P93D' }, + { timeGrain: 'PT12H', retention: 'P93D' }, + { timeGrain: 'P1D', retention: 'P93D' }, + ], }, - unit: 'CountPerSecond', - primaryAggregationType: 'Average', - supportedAggregationTypes: ['None', 'Average'], - }, - ], - }; + { + name: { + value: 'FreeCapacity', + localizedValue: 'Free capacity', + }, + unit: 'CountPerSecond', + primaryAggregationType: 'Average', + supportedAggregationTypes: ['None', 'Average'], + metricAvailabilities: [ + { timeGrain: 'PT1H', retention: 'P93D' }, + { timeGrain: 'PT6H', retention: 'P93D' }, + { timeGrain: 'PT12H', retention: 'P93D' }, + { timeGrain: 'P1D', retention: 'P93D' }, + ], + }, + ], + }; - beforeEach(() => { - ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => { - const basePath = 'azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp'; - const expected = - basePath + - '/providers/microsoft.insights/components/resource1' + - '/providers/microsoft.insights/metricdefinitions?api-version=2018-01-01&metricnamespace=default'; - expect(path).toBe(expected); - return Promise.resolve(response); - }); - }); - - it('should return dimensions for a Metric that has dimensions', () => { - return ctx.ds - .getMetricMetadata( - '9935389e-9122-4ef9-95f9-1513dd24753f', - 'nodeapp', - 'microsoft.insights/components', - 'resource1', - 'default', - 'Transactions' - ) - .then((results: any) => { - expect(results.dimensions).toMatchInlineSnapshot(` - Array [ - Object { - "label": "Response type", - "value": "ResponseType", - }, - Object { - "label": "Geo type", - "value": "GeoType", - }, - Object { - "label": "API name", - "value": "ApiName", - }, - ] - `); + beforeEach(() => { + ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => { + const basePath = 'azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp'; + const expected = + basePath + + '/providers/microsoft.insights/components/resource1' + + '/providers/microsoft.insights/metricdefinitions?api-version=2018-01-01&metricnamespace=default'; + expect(path).toBe(expected); + return Promise.resolve(response); }); - }); - - describe('When performing targetContainsTemplate', () => { - it('should return false when no variable is being used', () => { - const query = createMockQuery(); - query.queryType = AzureQueryType.AzureMonitor; - expect(ctx.ds.targetContainsTemplate(query)).toEqual(false); }); - it('should return true when subscriptions field is using a variable', () => { - const query = createMockQuery(); - const templateSrv = new TemplateSrv(); - templateSrv.init([subscriptionsVariable]); - - const ds = new AzureMonitorDatasource(ctx.instanceSettings, templateSrv); - query.queryType = AzureQueryType.AzureMonitor; - query.subscription = `$${subscriptionsVariable.name}`; - expect(ds.targetContainsTemplate(query)).toEqual(true); - }); - - it('should return false when a variable is used in a different part of the query', () => { - const query = createMockQuery(); - const templateSrv = new TemplateSrv(); - templateSrv.init([singleVariable]); - - const ds = new AzureMonitorDatasource(ctx.instanceSettings, templateSrv); - query.queryType = AzureQueryType.AzureMonitor; - query.azureLogAnalytics = { resource: `$${singleVariable.name}` }; - expect(ds.targetContainsTemplate(query)).toEqual(false); + it('should return list of Metric Definitions', () => { + return ctx.ds + .getMetricNames( + '9935389e-9122-4ef9-95f9-1513dd24753f', + 'nodeapp', + 'microsoft.insights/components', + 'resource1', + 'default' + ) + .then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toEqual(2); + expect(results[0].text).toEqual('Used capacity'); + expect(results[0].value).toEqual('UsedCapacity'); + expect(results[1].text).toEqual('Free capacity'); + expect(results[1].value).toEqual('FreeCapacity'); + }); }); }); - it('should return an empty array for a Metric that does not have dimensions', () => { - return ctx.ds - .getMetricMetadata( - '9935389e-9122-4ef9-95f9-1513dd24753f', - 'nodeapp', - 'microsoft.insights/components', - 'resource1', - 'default', - 'FreeCapacity' - ) - .then((results: any) => { - expect(results.dimensions.length).toEqual(0); + describe('When performing getMetricMetadata', () => { + const response = { + value: [ + { + name: { + value: 'UsedCapacity', + localizedValue: 'Used capacity', + }, + unit: 'CountPerSecond', + primaryAggregationType: 'Total', + supportedAggregationTypes: ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count'], + metricAvailabilities: [ + { timeGrain: 'PT1H', retention: 'P93D' }, + { timeGrain: 'PT6H', retention: 'P93D' }, + { timeGrain: 'PT12H', retention: 'P93D' }, + { timeGrain: 'P1D', retention: 'P93D' }, + ], + }, + { + name: { + value: 'FreeCapacity', + localizedValue: 'Free capacity', + }, + unit: 'CountPerSecond', + primaryAggregationType: 'Average', + supportedAggregationTypes: ['None', 'Average'], + metricAvailabilities: [ + { timeGrain: 'PT1H', retention: 'P93D' }, + { timeGrain: 'PT6H', retention: 'P93D' }, + { timeGrain: 'PT12H', retention: 'P93D' }, + { timeGrain: 'P1D', retention: 'P93D' }, + ], + }, + ], + }; + + beforeEach(() => { + ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => { + const basePath = 'azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp'; + const expected = + basePath + + '/providers/microsoft.insights/components/resource1' + + '/providers/microsoft.insights/metricdefinitions?api-version=2018-01-01&metricnamespace=default'; + expect(path).toBe(expected); + return Promise.resolve(response); }); + }); + + it('should return Aggregation metadata for a Metric', () => { + return ctx.ds + .getMetricMetadata( + '9935389e-9122-4ef9-95f9-1513dd24753f', + 'nodeapp', + 'microsoft.insights/components', + 'resource1', + 'default', + 'UsedCapacity' + ) + .then((results) => { + expect(results.primaryAggType).toEqual('Total'); + expect(results.supportedAggTypes.length).toEqual(6); + expect(results.supportedTimeGrains.length).toEqual(5); // 4 time grains from the API + auto + }); + }); + }); + + describe('When performing getMetricMetadata on metrics with dimensions', () => { + const response = { + value: [ + { + name: { + value: 'Transactions', + localizedValue: 'Transactions', + }, + unit: 'Count', + primaryAggregationType: 'Total', + supportedAggregationTypes: ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count'], + isDimensionRequired: false, + dimensions: [ + { + value: 'ResponseType', + localizedValue: 'Response type', + }, + { + value: 'GeoType', + localizedValue: 'Geo type', + }, + { + value: 'ApiName', + localizedValue: 'API name', + }, + ], + }, + { + name: { + value: 'FreeCapacity', + localizedValue: 'Free capacity', + }, + unit: 'CountPerSecond', + primaryAggregationType: 'Average', + supportedAggregationTypes: ['None', 'Average'], + }, + ], + }; + + beforeEach(() => { + ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => { + const basePath = 'azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp'; + const expected = + basePath + + '/providers/microsoft.insights/components/resource1' + + '/providers/microsoft.insights/metricdefinitions?api-version=2018-01-01&metricnamespace=default'; + expect(path).toBe(expected); + return Promise.resolve(response); + }); + }); + + it('should return dimensions for a Metric that has dimensions', () => { + return ctx.ds + .getMetricMetadata( + '9935389e-9122-4ef9-95f9-1513dd24753f', + 'nodeapp', + 'microsoft.insights/components', + 'resource1', + 'default', + 'Transactions' + ) + .then((results: any) => { + expect(results.dimensions).toMatchInlineSnapshot(` + Array [ + Object { + "label": "Response type", + "value": "ResponseType", + }, + Object { + "label": "Geo type", + "value": "GeoType", + }, + Object { + "label": "API name", + "value": "ApiName", + }, + ] + `); + }); + }); + + describe('When performing targetContainsTemplate', () => { + it('should return false when no variable is being used', () => { + const query = createMockQuery(); + query.queryType = AzureQueryType.AzureMonitor; + expect(ctx.ds.targetContainsTemplate(query)).toEqual(false); + }); + + it('should return true when subscriptions field is using a variable', () => { + const query = createMockQuery(); + const templateSrv = new TemplateSrv(); + templateSrv.init([subscriptionsVariable]); + + const ds = new AzureMonitorDatasource(ctx.instanceSettings, templateSrv); + query.queryType = AzureQueryType.AzureMonitor; + query.subscription = `$${subscriptionsVariable.name}`; + expect(ds.targetContainsTemplate(query)).toEqual(true); + }); + + it('should return false when a variable is used in a different part of the query', () => { + const query = createMockQuery(); + const templateSrv = new TemplateSrv(); + templateSrv.init([singleVariable]); + + const ds = new AzureMonitorDatasource(ctx.instanceSettings, templateSrv); + query.queryType = AzureQueryType.AzureMonitor; + query.azureLogAnalytics = { resource: `$${singleVariable.name}` }; + expect(ds.targetContainsTemplate(query)).toEqual(false); + }); + }); + + it('should return an empty array for a Metric that does not have dimensions', () => { + return ctx.ds + .getMetricMetadata( + '9935389e-9122-4ef9-95f9-1513dd24753f', + 'nodeapp', + 'microsoft.insights/components', + 'resource1', + 'default', + 'FreeCapacity' + ) + .then((results: any) => { + expect(results.dimensions.length).toEqual(0); + }); + }); }); }); }); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts index 9f876557e0f..b302061d483 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts @@ -9,6 +9,9 @@ import TimegrainConverter from '../time_grain_converter'; import { AzureDataSourceJsonData, AzureMonitorMetricDefinitionsResponse, + AzureMonitorMetricNamespacesResponse, + AzureMonitorMetricNamesResponse, + AzureMonitorMetricsMetadataResponse, AzureMonitorQuery, AzureMonitorResourceGroupsResponse, AzureQueryType, @@ -21,6 +24,10 @@ import UrlBuilder from './url_builder'; const defaultDropdownValue = 'select'; +function hasValue(item?: string) { + return !!(item && item !== defaultDropdownValue); +} + export default class AzureMonitorDatasource extends DataSourceWithBackend { apiVersion = '2018-01-01'; apiPreviewVersion = '2017-12-01-preview'; @@ -40,7 +47,7 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend { + return this.getResource(`${this.resourcePath}/subscriptions?api-version=2019-03-01`).then((result: any) => { return ResponseParser.parseSubscriptions(result); }); } getResourceGroups(subscriptionId: string) { return this.getResource( - `${this.resourcePath}/${subscriptionId}/resourceGroups?api-version=${this.listByResourceGroupApiVersion}` + `${this.resourcePath}/subscriptions/${subscriptionId}/resourceGroups?api-version=${this.listByResourceGroupApiVersion}` ).then((result: AzureMonitorResourceGroupsResponse) => { return ResponseParser.parseResponseValues(result, 'name', 'name'); }); @@ -143,7 +150,7 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend { return ResponseParser.parseResponseValues(result, 'type', 'type'); @@ -196,7 +203,7 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend { + return ResponseParser.parseResponseValues(result, 'name', 'properties.metricNamespaceName'); + }); + } + getMetricNames( subscriptionId: string, resourceGroup: string, @@ -266,6 +286,20 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend { + return ResponseParser.parseResponseValues(result, 'name.localizedValue', 'name.value'); + }); + } + getMetricMetadata( subscriptionId: string, resourceGroup: string, @@ -289,6 +323,20 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend { + return ResponseParser.parseMetadata(result, metricName); + }); + } + async testDatasource(): Promise { const validationError = this.validateDatasource(); if (validationError) { @@ -296,7 +344,7 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend((response: any) => { return { diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.test.ts index 9ed28795078..4aa8b984548 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.test.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.test.ts @@ -1,180 +1,209 @@ import UrlBuilder from './url_builder'; describe('AzureMonitorUrlBuilder', () => { - describe('when metric definition is Microsoft.NetApp/netAppAccounts/capacityPools/volumes', () => { - it('should build the getMetricNamespaces url in the even longer format', () => { - const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl( + describe('when a resource uri is provided', () => { + it('builds a getMetricNamesnamespace url', () => { + const url = UrlBuilder.newBuildAzureMonitorGetMetricNamespacesUrl( '', - 'sub1', - 'rg', - 'Microsoft.NetApp/netAppAccounts/capacityPools/volumes', - 'rn1/rn2/rn3', + '/subscriptions/sub/resource-uri/resource', '2017-05-01-preview' ); expect(url).toBe( - '/sub1/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/' + - 'providers/microsoft.insights/metricNamespaces?api-version=2017-05-01-preview' + '/subscriptions/sub/resource-uri/resource/providers/microsoft.insights/metricNamespaces?api-version=2017-05-01-preview' ); }); }); - describe('when metric definition is Microsoft.Sql/servers/databases', () => { - it('should build the getMetricNamespaces url in the longer format', () => { - const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl( + describe('when a resource uri and metric namespace is provided', () => { + it('builds a getMetricNames url', () => { + const url = UrlBuilder.newBuildAzureMonitorGetMetricNamesUrl( '', - 'sub1', - 'rg', - 'Microsoft.Sql/servers/databases', - 'rn1/rn2', - '2017-05-01-preview' - ); - expect(url).toBe( - '/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn1/databases/rn2/' + - 'providers/microsoft.insights/metricNamespaces?api-version=2017-05-01-preview' - ); - }); - }); - - describe('when metric definition is Microsoft.Sql/servers', () => { - it('should build the getMetricNamespaces url in the shorter format', () => { - const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl( - '', - 'sub1', - 'rg', + '/subscriptions/sub/resource-uri/resource', 'Microsoft.Sql/servers', - 'rn', '2017-05-01-preview' ); expect(url).toBe( - '/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn/' + - 'providers/microsoft.insights/metricNamespaces?api-version=2017-05-01-preview' + '/subscriptions/sub/resource-uri/resource/providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=Microsoft.Sql%2Fservers' ); }); }); - describe('when metric definition is Microsoft.NetApp/netAppAccounts/capacityPools/volumes and the metricNamespace is default', () => { - it('should build the getMetricNames url in the even longer format', () => { - const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( - '', - 'sub1', - 'rg', - 'Microsoft.NetApp/netAppAccounts/capacityPools/volumes', - 'rn1/rn2/rn3', - 'default', - '2017-05-01-preview' - ); - expect(url).toBe( - '/sub1/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/' + - 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default' - ); + describe('Legacy query object', () => { + describe('when metric definition is Microsoft.NetApp/netAppAccounts/capacityPools/volumes', () => { + it('should build the getMetricNamespaces url in the even longer format', () => { + const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl( + '', + 'sub1', + 'rg', + 'Microsoft.NetApp/netAppAccounts/capacityPools/volumes', + 'rn1/rn2/rn3', + '2017-05-01-preview' + ); + expect(url).toBe( + '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/' + + 'providers/microsoft.insights/metricNamespaces?api-version=2017-05-01-preview' + ); + }); }); - }); - describe('when metric definition is Microsoft.Sql/servers/databases and the metricNamespace is default', () => { - it('should build the getMetricNames url in the longer format', () => { - const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( - '', - 'sub1', - 'rg', - 'Microsoft.Sql/servers/databases', - 'rn1/rn2', - 'default', - '2017-05-01-preview' - ); - expect(url).toBe( - '/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn1/databases/rn2/' + - 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default' - ); + describe('when metric definition is Microsoft.Sql/servers/databases', () => { + it('should build the getMetricNamespaces url in the longer format', () => { + const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl( + '', + 'sub1', + 'rg', + 'Microsoft.Sql/servers/databases', + 'rn1/rn2', + '2017-05-01-preview' + ); + expect(url).toBe( + '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn1/databases/rn2/' + + 'providers/microsoft.insights/metricNamespaces?api-version=2017-05-01-preview' + ); + }); }); - }); - describe('when metric definition is Microsoft.Sql/servers and the metricNamespace is default', () => { - it('should build the getMetricNames url in the shorter format', () => { - const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( - '', - 'sub1', - 'rg', - 'Microsoft.Sql/servers', - 'rn', - 'default', - '2017-05-01-preview' - ); - expect(url).toBe( - '/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn/' + - 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default' - ); + describe('when metric definition is Microsoft.Sql/servers', () => { + it('should build the getMetricNamespaces url in the shorter format', () => { + const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl( + '', + 'sub1', + 'rg', + 'Microsoft.Sql/servers', + 'rn', + '2017-05-01-preview' + ); + expect(url).toBe( + '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn/' + + 'providers/microsoft.insights/metricNamespaces?api-version=2017-05-01-preview' + ); + }); }); - }); - describe('when metric definition is Microsoft.Storage/storageAccounts/blobServices and the metricNamespace is default', () => { - it('should build the getMetricNames url in the longer format', () => { - const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( - '', - 'sub1', - 'rg', - 'Microsoft.Storage/storageAccounts/blobServices', - 'rn1/default', - 'default', - '2017-05-01-preview' - ); - expect(url).toBe( - '/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/' + - 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default' - ); + describe('when metric definition is Microsoft.NetApp/netAppAccounts/capacityPools/volumes and the metricNamespace is default', () => { + it('should build the getMetricNames url in the even longer format', () => { + const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( + '', + 'sub1', + 'rg', + 'Microsoft.NetApp/netAppAccounts/capacityPools/volumes', + 'rn1/rn2/rn3', + 'default', + '2017-05-01-preview' + ); + expect(url).toBe( + '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/' + + 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default' + ); + }); }); - }); - describe('when metric definition is Microsoft.Storage/storageAccounts/fileServices and the metricNamespace is default', () => { - it('should build the getMetricNames url in the longer format', () => { - const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( - '', - 'sub1', - 'rg', - 'Microsoft.Storage/storageAccounts/fileServices', - 'rn1/default', - 'default', - '2017-05-01-preview' - ); - expect(url).toBe( - '/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/' + - 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default' - ); + describe('when metric definition is Microsoft.Sql/servers/databases and the metricNamespace is default', () => { + it('should build the getMetricNames url in the longer format', () => { + const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( + '', + 'sub1', + 'rg', + 'Microsoft.Sql/servers/databases', + 'rn1/rn2', + 'default', + '2017-05-01-preview' + ); + expect(url).toBe( + '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn1/databases/rn2/' + + 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default' + ); + }); }); - }); - describe('when metric definition is Microsoft.Storage/storageAccounts/tableServices and the metricNamespace is default', () => { - it('should build the getMetricNames url in the longer format', () => { - const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( - '', - 'sub1', - 'rg', - 'Microsoft.Storage/storageAccounts/tableServices', - 'rn1/default', - 'default', - '2017-05-01-preview' - ); - expect(url).toBe( - '/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/tableServices/default/' + - 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default' - ); + describe('when metric definition is Microsoft.Sql/servers and the metricNamespace is default', () => { + it('should build the getMetricNames url in the shorter format', () => { + const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( + '', + 'sub1', + 'rg', + 'Microsoft.Sql/servers', + 'rn', + 'default', + '2017-05-01-preview' + ); + expect(url).toBe( + '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn/' + + 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default' + ); + }); }); - }); - describe('when metric definition is Microsoft.Storage/storageAccounts/queueServices and the metricNamespace is default', () => { - it('should build the getMetricNames url in the longer format', () => { - const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( - '', - 'sub1', - 'rg', - 'Microsoft.Storage/storageAccounts/queueServices', - 'rn1/default', - 'default', - '2017-05-01-preview' - ); - expect(url).toBe( - '/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/queueServices/default/' + - 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default' - ); + describe('when metric definition is Microsoft.Storage/storageAccounts/blobServices and the metricNamespace is default', () => { + it('should build the getMetricNames url in the longer format', () => { + const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( + '', + 'sub1', + 'rg', + 'Microsoft.Storage/storageAccounts/blobServices', + 'rn1/default', + 'default', + '2017-05-01-preview' + ); + expect(url).toBe( + '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/' + + 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default' + ); + }); + }); + + describe('when metric definition is Microsoft.Storage/storageAccounts/fileServices and the metricNamespace is default', () => { + it('should build the getMetricNames url in the longer format', () => { + const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( + '', + 'sub1', + 'rg', + 'Microsoft.Storage/storageAccounts/fileServices', + 'rn1/default', + 'default', + '2017-05-01-preview' + ); + expect(url).toBe( + '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/' + + 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default' + ); + }); + }); + + describe('when metric definition is Microsoft.Storage/storageAccounts/tableServices and the metricNamespace is default', () => { + it('should build the getMetricNames url in the longer format', () => { + const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( + '', + 'sub1', + 'rg', + 'Microsoft.Storage/storageAccounts/tableServices', + 'rn1/default', + 'default', + '2017-05-01-preview' + ); + expect(url).toBe( + '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/tableServices/default/' + + 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default' + ); + }); + }); + + describe('when metric definition is Microsoft.Storage/storageAccounts/queueServices and the metricNamespace is default', () => { + it('should build the getMetricNames url in the longer format', () => { + const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( + '', + 'sub1', + 'rg', + 'Microsoft.Storage/storageAccounts/queueServices', + 'rn1/default', + 'default', + '2017-05-01-preview' + ); + expect(url).toBe( + '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/queueServices/default/' + + 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default' + ); + }); }); }); }); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.ts index a477dd23ef4..1d18c89a09d 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.ts @@ -10,7 +10,7 @@ export default class UrlBuilder { const metricDefinitionArray = metricDefinition.split('/'); const resourceNameArray = resourceName.split('/'); const provider = metricDefinitionArray.shift(); - const urlArray = [baseUrl, subscriptionId, 'resourceGroups', resourceGroup, 'providers', provider]; + const urlArray = [baseUrl, 'subscriptions', subscriptionId, 'resourceGroups', resourceGroup, 'providers', provider]; for (const i in metricDefinitionArray) { urlArray.push(metricDefinitionArray[i]); urlArray.push(resourceNameArray[i]); @@ -31,7 +31,7 @@ export default class UrlBuilder { const metricDefinitionArray = metricDefinition.split('/'); const resourceNameArray = resourceName.split('/'); const provider = metricDefinitionArray.shift(); - const urlArray = [baseUrl, subscriptionId, 'resourceGroups', resourceGroup, 'providers', provider]; + const urlArray = [baseUrl, 'subscriptions', subscriptionId, 'resourceGroups', resourceGroup, 'providers', provider]; for (const i in metricDefinitionArray) { urlArray.push(metricDefinitionArray[i]); urlArray.push(resourceNameArray[i]); @@ -42,4 +42,20 @@ export default class UrlBuilder { `&metricnamespace=${encodeURIComponent(metricNamespace)}` ); } + + static newBuildAzureMonitorGetMetricNamespacesUrl(baseUrl: string, resourceUri: string, apiVersion: string) { + return `${baseUrl}${resourceUri}/providers/microsoft.insights/metricNamespaces?api-version=${apiVersion}`; + } + + static newBuildAzureMonitorGetMetricNamesUrl( + baseUrl: string, + resourceUri: string, + metricNamespace: string, + apiVersion: string + ) { + return ( + `${baseUrl}${resourceUri}/providers/microsoft.insights/metricdefinitions?api-version=${apiVersion}` + + `&metricnamespace=${encodeURIComponent(metricNamespace)}` + ); + } } diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/LogsQueryEditor/LogsQueryEditor.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/LogsQueryEditor/LogsQueryEditor.tsx index d6ae263793d..4e63ac97ed5 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/LogsQueryEditor/LogsQueryEditor.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/LogsQueryEditor/LogsQueryEditor.tsx @@ -2,10 +2,12 @@ import React from 'react'; import { AzureMonitorErrorish, AzureMonitorOption, AzureMonitorQuery } from '../../types'; import Datasource from '../../datasource'; import { Alert, InlineFieldRow } from '@grafana/ui'; +import { ResourceRowType } from '../ResourcePicker/types'; +import ResourceField from '../ResourceField'; import QueryField from './QueryField'; import FormatAsField from './FormatAsField'; -import ResourceField from './ResourceField'; import useMigrations from './useMigrations'; +import { setResource } from './setQueryValue'; interface LogsQueryEditorProps { query: AzureMonitorQuery; @@ -38,6 +40,14 @@ const LogsQueryEditor: React.FC = ({ variableOptionGroup={variableOptionGroup} onQueryChange={onChange} setError={setError} + selectableEntryTypes={[ + ResourceRowType.Subscription, + ResourceRowType.ResourceGroup, + ResourceRowType.Resource, + ResourceRowType.Variable, + ]} + setResource={setResource} + resourceUri={query.azureLogAnalytics?.resource} /> diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.test.ts index 6de964895cd..2b25f47ad2c 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.test.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.test.ts @@ -6,7 +6,6 @@ import { AzureMetricQuery, AzureMonitorOption, AzureMonitorQuery, AzureQueryType import { DataHook, updateSubscriptions, - useAsyncState, useMetricNames, useMetricNamespaces, useResourceGroups, @@ -15,75 +14,12 @@ import { useSubscriptions, } from './dataHooks'; -interface WaitableMock extends jest.Mock { - waitToBeCalled(): Promise; -} - const WAIT_OPTIONS = { timeout: 1000, }; -function createWaitableMock() { - let resolve: Function; - - const mock = jest.fn() as WaitableMock; - mock.mockImplementation(() => { - resolve && resolve(); - }); - - mock.waitToBeCalled = () => { - return new Promise((_resolve) => (resolve = _resolve)); - }; - - return mock; -} - const opt = (text: string, value: string) => ({ text, value }); -describe('AzureMonitor: useAsyncState', () => { - const MOCKED_RANDOM_VALUE = 0.42069; - - beforeEach(() => { - jest.spyOn(global.Math, 'random').mockReturnValue(MOCKED_RANDOM_VALUE); - }); - - afterEach(() => { - jest.spyOn(global.Math, 'random').mockRestore(); - }); - - it('should return data from an async function', async () => { - const apiCall = () => Promise.resolve(['a', 'b', 'c']); - const setError = jest.fn(); - - const { result, waitForNextUpdate } = renderHook(() => useAsyncState(apiCall, setError, [])); - await waitForNextUpdate(); - - expect(result.current).toEqual(['a', 'b', 'c']); - }); - - it('should report errors through setError', async () => { - const error = new Error(); - const apiCall = () => Promise.reject(error); - const setError = createWaitableMock(); - - const { result, waitForNextUpdate } = renderHook(() => useAsyncState(apiCall, setError, [])); - await Promise.race([waitForNextUpdate(), setError.waitToBeCalled()]); - - expect(result.current).toEqual([]); - expect(setError).toHaveBeenCalledWith(MOCKED_RANDOM_VALUE, error); - }); - - it('should clear the error once the request is successful', async () => { - const apiCall = () => Promise.resolve(['a', 'b', 'c']); - const setError = createWaitableMock(); - - const { waitForNextUpdate } = renderHook(() => useAsyncState(apiCall, setError, [])); - await Promise.race([waitForNextUpdate(), setError.waitToBeCalled()]); - - expect(setError).toHaveBeenCalledWith(MOCKED_RANDOM_VALUE, undefined); - }); -}); - interface TestScenario { name: string; hook: DataHook; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.ts index 6ffecfec1ea..c340e9929bb 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.ts @@ -1,8 +1,9 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import Datasource from '../../datasource'; import { AzureMonitorErrorish, AzureMonitorOption, AzureMonitorQuery } from '../../types'; import { hasOption, toOption } from '../../utils/common'; +import { useAsyncState } from '../../utils/useAsyncState'; import { setMetricNamespace, setSubscriptionID } from './setQueryValue'; export interface MetricMetadata { @@ -26,29 +27,6 @@ export type DataHook = ( setError: SetErrorFn ) => AzureMonitorOption[]; -export function useAsyncState(asyncFn: () => Promise, setError: Function, dependencies: unknown[]) { - // Use the lazy initial state functionality of useState to assign a random ID to the API call - // to track where errors come from. See useLastError. - const [errorSource] = useState(() => Math.random()); - const [value, setValue] = useState(); - - const finalValue = useMemo(() => value ?? [], [value]); - - useEffect(() => { - asyncFn() - .then((results) => { - setValue(results); - setError(errorSource, undefined); - }) - .catch((err) => { - setError(errorSource, err); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, dependencies); - - return finalValue; -} - export const updateSubscriptions = ( query: AzureMonitorQuery, subscriptionOptions: AzureMonitorOption[], diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/setQueryValue.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/setQueryValue.ts index 108fe67cb1b..19c49b3d3dd 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/setQueryValue.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/setQueryValue.ts @@ -1,5 +1,20 @@ import { AzureMetricDimension, AzureMonitorQuery } from '../../types'; +export function setResource(query: AzureMonitorQuery, resourceURI: string | undefined): AzureMonitorQuery { + return { + ...query, + azureMonitor: { + ...query.azureMonitor, + resourceUri: resourceURI, + metricNamespace: undefined, + metricName: undefined, + aggregation: undefined, + timeGrain: '', + dimensionFilters: [], + }, + }; +} + export function setSubscriptionID(query: AzureMonitorQuery, subscriptionID: string): AzureMonitorQuery { if (query.subscription === subscriptionID) { return query; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/MetricsQueryEditor.test.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/MetricsQueryEditor.test.tsx new file mode 100644 index 00000000000..792d3b76123 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/MetricsQueryEditor.test.tsx @@ -0,0 +1,226 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { selectOptionInTest } from '@grafana/ui'; + +import MetricsQueryEditor from './MetricsQueryEditor'; +import createMockQuery from '../../__mocks__/query'; +import createMockDatasource from '../../__mocks__/datasource'; +import createMockResourcePickerData from '../../__mocks__/resourcePickerData'; +import { + createMockResourceGroupsBySubscription, + createMockSubscriptions, + mockResourcesByResourceGroup, +} from '../../__mocks__/resourcePickerRows'; + +const variableOptionGroup = { + label: 'Template variables', + options: [], +}; + +const resourcePickerData = createMockResourcePickerData({ + getSubscriptions: jest.fn().mockResolvedValue(createMockSubscriptions()), + getResourceGroupsBySubscriptionId: jest.fn().mockResolvedValue(createMockResourceGroupsBySubscription()), + getResourcesForResourceGroup: jest.fn().mockResolvedValue(mockResourcesByResourceGroup()), +}); + +describe('MetricsQueryEditor', () => { + const originalScrollIntoView = window.HTMLElement.prototype.scrollIntoView; + beforeEach(() => { + window.HTMLElement.prototype.scrollIntoView = function () {}; + }); + afterEach(() => { + window.HTMLElement.prototype.scrollIntoView = originalScrollIntoView; + }); + + it('should render', async () => { + const mockDatasource = createMockDatasource({ resourcePickerData }); + + render( + {}} + setError={() => {}} + /> + ); + + expect(await screen.findByTestId('azure-monitor-metrics-query-editor-with-resource-picker')).toBeInTheDocument(); + }); + + it('should change resource when a resource is selected in the ResourcePicker', async () => { + const mockDatasource = createMockDatasource({ resourcePickerData }); + const query = createMockQuery(); + delete query?.azureMonitor?.resourceUri; + const onChange = jest.fn(); + + render( + {}} + /> + ); + + const resourcePickerButton = await screen.findByRole('button', { name: 'Select a resource' }); + expect(resourcePickerButton).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Expand Primary Subscription' })).not.toBeInTheDocument(); + resourcePickerButton.click(); + + const subscriptionButton = await screen.findByRole('button', { name: 'Expand Primary Subscription' }); + expect(subscriptionButton).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Expand A Great Resource Group' })).not.toBeInTheDocument(); + subscriptionButton.click(); + + const resourceGroupButton = await screen.findByRole('button', { name: 'Expand A Great Resource Group' }); + expect(resourceGroupButton).toBeInTheDocument(); + expect(screen.queryByLabelText('web-server')).not.toBeInTheDocument(); + resourceGroupButton.click(); + + const checkbox = await screen.findByLabelText('web-server'); + expect(checkbox).toBeInTheDocument(); + expect(checkbox).not.toBeChecked(); + userEvent.click(checkbox); + expect(checkbox).toBeChecked(); + userEvent.click(await screen.findByRole('button', { name: 'Apply' })); + + expect(onChange).toBeCalledTimes(1); + expect(onChange).toBeCalledWith( + expect.objectContaining({ + azureMonitor: expect.objectContaining({ + resourceUri: + '/subscriptions/def-456/resourceGroups/dev-3/providers/Microsoft.Compute/virtualMachines/web-server', + }), + }) + ); + }); + + it('should reset metric namespace, metric name, and aggregation fields after selecting a new resource when a valid query has already been set', async () => { + const mockDatasource = createMockDatasource({ resourcePickerData }); + const query = createMockQuery(); + const onChange = jest.fn(); + + render( + {}} + /> + ); + + const resourcePickerButton = await screen.findByRole('button', { name: /grafanastaging/ }); + + expect(screen.getByText('Microsoft.Compute/virtualMachines')).toBeInTheDocument(); + expect(screen.getByText('Metric A')).toBeInTheDocument(); + expect(screen.getByText('Average')).toBeInTheDocument(); + + expect(resourcePickerButton).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Expand Primary Subscription' })).not.toBeInTheDocument(); + resourcePickerButton.click(); + + const subscriptionButton = await screen.findByRole('button', { name: 'Expand Dev Subscription' }); + expect(subscriptionButton).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Expand Development 3' })).not.toBeInTheDocument(); + subscriptionButton.click(); + + const resourceGroupButton = await screen.findByRole('button', { name: 'Expand Development 3' }); + expect(resourceGroupButton).toBeInTheDocument(); + expect(screen.queryByLabelText('db-server')).not.toBeInTheDocument(); + resourceGroupButton.click(); + + const checkbox = await screen.findByLabelText('db-server'); + expect(checkbox).toBeInTheDocument(); + expect(checkbox).not.toBeChecked(); + userEvent.click(checkbox); + expect(checkbox).toBeChecked(); + userEvent.click(await screen.findByRole('button', { name: 'Apply' })); + + expect(onChange).toBeCalledTimes(1); + expect(onChange).toBeCalledWith( + expect.objectContaining({ + azureMonitor: expect.objectContaining({ + resourceUri: + '/subscriptions/def-456/resourceGroups/dev-3/providers/Microsoft.Compute/virtualMachines/db-server', + metricNamespace: undefined, + metricName: undefined, + aggregation: undefined, + timeGrain: '', + dimensionFilters: [], + }), + }) + ); + }); + + it('should change the metric name when selected', async () => { + const mockDatasource = createMockDatasource({ resourcePickerData }); + const onChange = jest.fn(); + const mockQuery = createMockQuery(); + mockDatasource.azureMonitorDatasource.newGetMetricNames = jest.fn().mockResolvedValue([ + { + value: 'metric-a', + text: 'Metric A', + }, + { + value: 'metric-b', + text: 'Metric B', + }, + ]); + + render( + {}} + /> + ); + + const metrics = await screen.findByLabelText('Metric'); + expect(metrics).toBeInTheDocument(); + await selectOptionInTest(metrics, 'Metric B'); + + expect(onChange).toHaveBeenLastCalledWith({ + ...mockQuery, + azureMonitor: { + ...mockQuery.azureMonitor, + metricName: 'metric-b', + aggregation: undefined, + timeGrain: '', + }, + }); + }); + + it('should change the aggregation type when selected', async () => { + const mockDatasource = createMockDatasource({ resourcePickerData }); + const onChange = jest.fn(); + const mockQuery = createMockQuery(); + + render( + {}} + /> + ); + + const aggregation = await screen.findByLabelText('Aggregation'); + expect(aggregation).toBeInTheDocument(); + await selectOptionInTest(aggregation, 'Maximum'); + + expect(onChange).toHaveBeenLastCalledWith({ + ...mockQuery, + azureMonitor: { + ...mockQuery.azureMonitor, + aggregation: 'Maximum', + }, + }); + }); +}); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/MetricsQueryEditor.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/MetricsQueryEditor.tsx index 595d3203fd7..97e6bb3b0e1 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/MetricsQueryEditor.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/MetricsQueryEditor.tsx @@ -1,9 +1,113 @@ import React from 'react'; +import { InlineFieldRow } from '@grafana/ui'; +import AggregationField from '../MetricsQueryEditor/AggregationField'; +import MetricNameField from '../MetricsQueryEditor/MetricNameField'; +import MetricNamespaceField from '../MetricsQueryEditor/MetricNamespaceField'; +import TimeGrainField from '../MetricsQueryEditor/TimeGrainField'; +import DimensionFields from '../MetricsQueryEditor/DimensionFields'; +import TopField from '../MetricsQueryEditor/TopField'; +import LegendFormatField from '../MetricsQueryEditor/LegendFormatField'; +import ResourceField from '../ResourceField'; +import { ResourceRowType } from '../ResourcePicker/types'; +import type Datasource from '../../datasource'; +import type { AzureMonitorQuery, AzureMonitorOption, AzureMonitorErrorish } from '../../types'; +import { useMetricNames, useMetricNamespaces, useMetricMetadata } from './dataHooks'; +import { setResource } from '../MetricsQueryEditor/setQueryValue'; -interface MetricsQueryEditorProps {} +interface MetricsQueryEditorProps { + query: AzureMonitorQuery; + datasource: Datasource; + onChange: (newQuery: AzureMonitorQuery) => void; + variableOptionGroup: { label: string; options: AzureMonitorOption[] }; + setError: (source: string, error: AzureMonitorErrorish | undefined) => void; +} -const MetricsQueryEditor: React.FC = ({}) => { - return
New Query Editor
; +const MetricsQueryEditor: React.FC = ({ + query, + datasource, + variableOptionGroup, + onChange, + setError, +}) => { + const metricsMetadata = useMetricMetadata(query, datasource, onChange); + const metricNamespaces = useMetricNamespaces(query, datasource, onChange, setError); + const metricNames = useMetricNames(query, datasource, onChange, setError); + return ( +
+ + + + + + + + + + + + + + + +
+ ); }; export default MetricsQueryEditor; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/dataHooks.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/dataHooks.test.ts new file mode 100644 index 00000000000..7a784885c91 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/dataHooks.test.ts @@ -0,0 +1,165 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import createMockDatasource from '../../__mocks__/datasource'; +import Datasource from '../../datasource'; +import { AzureMetricQuery, AzureMonitorOption, AzureMonitorQuery, AzureQueryType } from '../../types'; +import { DataHook } from '../MetricsQueryEditor/dataHooks'; +import { useMetricNames, useMetricNamespaces } from './dataHooks'; + +const WAIT_OPTIONS = { + timeout: 1000, +}; + +const opt = (text: string, value: string) => ({ text, value }); + +interface TestScenario { + name: string; + hook: DataHook; + + // For convenience, only need to define the azureMonitor part of the query for some tests + emptyQueryPartial: AzureMetricQuery; + customProperties: AzureMetricQuery; + topLevelCustomProperties?: Partial; + + expectedCustomPropertyResults?: Array>; + expectedOptions: AzureMonitorOption[]; +} + +describe('AzureMonitor: metrics dataHooks', () => { + const bareQuery = { + refId: 'A', + queryType: AzureQueryType.AzureMonitor, + subscription: 'sub-abc-123', + }; + + const testTable: TestScenario[] = [ + { + name: 'useMetricNames', + hook: useMetricNames, + emptyQueryPartial: { + resourceUri: + '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana', + metricNamespace: 'azure/vm', + }, + customProperties: { + resourceUri: + '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana', + metricNamespace: 'azure/vm', + metricName: 'metric-$ENVIRONMENT', + }, + expectedOptions: [ + { + label: 'Percentage CPU', + value: 'percentage-cpu', + }, + { + label: 'Free memory', + value: 'free-memory', + }, + ], + expectedCustomPropertyResults: [ + { label: 'Percentage CPU', value: 'percentage-cpu' }, + { label: 'Free memory', value: 'free-memory' }, + { label: 'metric-$ENVIRONMENT', value: 'metric-$ENVIRONMENT' }, + ], + }, + + { + name: 'useMetricNamespaces', + hook: useMetricNamespaces, + emptyQueryPartial: { + resourceUri: + '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana', + metricNamespace: 'azure/vm', + }, + customProperties: { + resourceUri: + '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana', + metricNamespace: 'azure/vm-$ENVIRONMENT', + metricName: 'metric-name', + }, + expectedOptions: [ + { + label: 'Compute Virtual Machine', + value: 'azure/vmc', + }, + { + label: 'Database NS', + value: 'azure/dbns', + }, + { + label: 'azure/vm', + value: 'azure/vm', + }, + ], + expectedCustomPropertyResults: [ + { label: 'Compute Virtual Machine', value: 'azure/vmc' }, + { label: 'Database NS', value: 'azure/dbns' }, + { label: 'azure/vm-$ENVIRONMENT', value: 'azure/vm-$ENVIRONMENT' }, + ], + }, + ]; + + let datasource: Datasource; + let onChange: jest.Mock; + let setError: jest.Mock; + + beforeEach(() => { + onChange = jest.fn(); + setError = jest.fn(); + + datasource = createMockDatasource(); + datasource.getVariables = jest.fn().mockReturnValue(['$sub', '$rg', '$rt', '$variable']); + + datasource.azureMonitorDatasource.getSubscriptions = jest + .fn() + .mockResolvedValue([opt('sub-abc-123', 'sub-abc-123')]); + + datasource.getResourceGroups = jest + .fn() + .mockResolvedValue([ + opt('Web App - Production', 'web-app-production'), + opt('Web App - Development', 'web-app-development'), + ]); + + datasource.getMetricDefinitions = jest + .fn() + .mockResolvedValue([opt('Virtual Machine', 'azure/vm'), opt('Database', 'azure/db')]); + + datasource.getResourceNames = jest + .fn() + .mockResolvedValue([opt('Web server', 'web-server'), opt('Job server', 'job-server')]); + + datasource.azureMonitorDatasource.newGetMetricNames = jest + .fn() + .mockResolvedValue([opt('Percentage CPU', 'percentage-cpu'), opt('Free memory', 'free-memory')]); + + datasource.azureMonitorDatasource.newGetMetricNamespaces = jest + .fn() + .mockResolvedValue([opt('Compute Virtual Machine', 'azure/vmc'), opt('Database NS', 'azure/dbns')]); + }); + describe.each(testTable)('scenario %#: $name', (scenario) => { + it('returns values', async () => { + const query = { + ...bareQuery, + azureMonitor: scenario.emptyQueryPartial, + }; + const { result, waitForNextUpdate } = renderHook(() => scenario.hook(query, datasource, onChange, setError)); + await waitForNextUpdate(WAIT_OPTIONS); + + expect(result.current).toEqual(scenario.expectedOptions); + }); + + it('adds custom properties as a valid option', async () => { + const query = { + ...bareQuery, + azureMonitor: scenario.customProperties, + ...scenario.topLevelCustomProperties, + }; + const { result, waitForNextUpdate } = renderHook(() => scenario.hook(query, datasource, onChange, setError)); + await waitForNextUpdate(WAIT_OPTIONS); + + expect(result.current).toEqual(scenario.expectedCustomPropertyResults); + }); + }); +}); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/dataHooks.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/dataHooks.ts new file mode 100644 index 00000000000..a8c11ebd38b --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/dataHooks.ts @@ -0,0 +1,144 @@ +import { useEffect, useState } from 'react'; + +import Datasource from '../../datasource'; +import { AzureMonitorOption, AzureMonitorQuery } from '../../types'; +import { toOption } from '../../utils/common'; +import { useAsyncState } from '../../utils/useAsyncState'; +import { DataHook } from '../MetricsQueryEditor/dataHooks'; +import { setMetricNamespace } from '../MetricsQueryEditor/setQueryValue'; + +export interface MetricMetadata { + aggOptions: AzureMonitorOption[]; + timeGrains: AzureMonitorOption[]; + dimensions: AzureMonitorOption[]; + isLoading: boolean; + + // These two properties are only used within the hook, and not elsewhere + supportedAggTypes: string[]; + primaryAggType: string | undefined; +} + +type OnChangeFn = (newQuery: AzureMonitorQuery) => void; + +export const useMetricNamespaces: DataHook = (query, datasource, onChange, setError) => { + const { metricNamespace, resourceUri } = query.azureMonitor ?? {}; + + const metricNamespaces = useAsyncState( + async () => { + if (!resourceUri) { + return; + } + + const results = await datasource.azureMonitorDatasource.newGetMetricNamespaces(resourceUri); + const options = formatOptions(results, metricNamespace); + + // Do some cleanup of the query state if need be + if (!metricNamespace && options.length) { + onChange(setMetricNamespace(query, options[0].value)); + } + + return options; + }, + setError, + [resourceUri] + ); + + return metricNamespaces; +}; + +export const useMetricNames: DataHook = (query, datasource, onChange, setError) => { + const { metricNamespace, metricName, resourceUri } = query.azureMonitor ?? {}; + + return useAsyncState( + async () => { + if (!(metricNamespace && resourceUri)) { + return; + } + + const results = await datasource.azureMonitorDatasource.newGetMetricNames(resourceUri, metricNamespace); + const options = formatOptions(results, metricName); + + return options; + }, + setError, + [resourceUri, metricNamespace] + ); +}; + +const defaultMetricMetadata: MetricMetadata = { + aggOptions: [], + timeGrains: [], + dimensions: [], + isLoading: false, + supportedAggTypes: [], + primaryAggType: undefined, +}; + +export const useMetricMetadata = (query: AzureMonitorQuery, datasource: Datasource, onChange: OnChangeFn) => { + const [metricMetadata, setMetricMetadata] = useState(defaultMetricMetadata); + + const { resourceUri, metricNamespace, metricName, aggregation, timeGrain } = query.azureMonitor ?? {}; + + // Fetch new metric metadata when the fields change + useEffect(() => { + if (!(resourceUri && metricNamespace && metricName)) { + setMetricMetadata(defaultMetricMetadata); + return; + } + + datasource.azureMonitorDatasource + .newGetMetricMetadata(resourceUri, metricNamespace, metricName) + .then((metadata) => { + // TODO: Move the aggregationTypes and timeGrain defaults into `getMetricMetadata` + const aggregations = (metadata.supportedAggTypes || [metadata.primaryAggType]).map((v) => ({ + label: v, + value: v, + })); + + setMetricMetadata({ + aggOptions: aggregations, + timeGrains: metadata.supportedTimeGrains, + dimensions: metadata.dimensions, + isLoading: false, + supportedAggTypes: metadata.supportedAggTypes ?? [], + primaryAggType: metadata.primaryAggType, + }); + }); + }, [datasource, resourceUri, metricNamespace, metricName]); + + // Update the query state in response to the meta data changing + useEffect(() => { + const newAggregation = aggregation || metricMetadata.primaryAggType; + const newTimeGrain = timeGrain || 'auto'; + + if (newAggregation !== aggregation || newTimeGrain !== timeGrain) { + onChange({ + ...query, + azureMonitor: { + ...query.azureMonitor, + aggregation: newAggregation, + timeGrain: newTimeGrain, + }, + }); + } + }, [onChange, metricMetadata, aggregation, timeGrain, query]); + + return metricMetadata; +}; + +function formatOptions( + rawResults: Array<{ + text: string; + value: string; + }>, + selectedValue?: string +) { + const options = rawResults.map(toOption); + + // account for custom values that might have been set in json file like ones crafted with a template variable (ex: "cloud-datasource-resource-$Environment") + if (selectedValue && !options.find((option) => option.value === selectedValue)) { + options.push({ label: selectedValue, value: selectedValue }); + } + + return options; +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/QueryEditor.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/QueryEditor.tsx index 85382626a38..dbc618bc6bf 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/QueryEditor.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/QueryEditor.tsx @@ -99,7 +99,15 @@ const EditorForQueryType: React.FC = ({ switch (query.queryType) { case AzureQueryType.AzureMonitor: if (config.featureToggles.azureMonitorResourcePickerForMetrics) { - return ; + return ( + + ); } return ( = ({ query, datasource, onQueryChange }) => { +interface ResourceFieldProps extends AzureQueryEditorFieldProps { + setResource: (query: AzureMonitorQuery, resourceURI?: string) => AzureMonitorQuery; + selectableEntryTypes: ResourceRowType[]; + resourceUri?: string; +} + +const ResourceField: React.FC = ({ + query, + datasource, + onQueryChange, + setResource, + selectableEntryTypes, + resourceUri, +}) => { const styles = useStyles2(getStyles); - const { resource } = query.azureLogAnalytics ?? {}; const [pickerIsOpen, setPickerIsOpen] = useState(false); const handleOpenPicker = useCallback(() => { @@ -44,7 +55,7 @@ const ResourceField: React.FC = ({ query, datasource onQueryChange(setResource(query, resourceURI)); closePicker(); }, - [closePicker, onQueryChange, query] + [closePicker, onQueryChange, query, setResource] ); return ( @@ -60,21 +71,16 @@ const ResourceField: React.FC = ({ query, datasource > diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourceField/index.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourceField/index.tsx new file mode 100644 index 00000000000..5e5d95b4814 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourceField/index.tsx @@ -0,0 +1 @@ +export { default } from './ResourceField'; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/types/query.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/types/query.ts index 22c208d3cea..86c9a2df88c 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/types/query.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/types/query.ts @@ -36,6 +36,7 @@ export interface AzureMonitorQuery extends DeprecatedAzureMonitorQuery { * Azure Monitor Metrics sub-query properties */ export interface AzureMetricQuery { + resourceUri?: string; resourceGroup?: string; /** Resource type */ diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/types/types.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/types/types.ts index 4e91064b7e0..9a6666fcf77 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/types/types.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/types/types.ts @@ -95,6 +95,23 @@ export interface AzureMonitorMetricMetadataItem { metricAvailabilities?: AzureMonitorMetricAvailabilityMetadata[]; } +export interface AzureMonitorMetricNamespacesResponse { + value: AzureMonitorMetricNamespaceItem[]; +} + +export interface AzureMonitorMetricNamespaceItem { + name: string; + properties: { metricNamespacename: string }; +} + +export interface AzureMonitorMetricNamesResponse { + value: AzureMonitorMetricNameItem[]; +} + +export interface AzureMonitorMetricNameItem { + name: { value: string; localizedValue: string }; +} + export interface AzureMonitorMetricAvailabilityMetadata { timeGrain: string; retention: string; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/utils/useAsyncState.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/utils/useAsyncState.test.ts new file mode 100644 index 00000000000..ddbfb2b5cc0 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/utils/useAsyncState.test.ts @@ -0,0 +1,65 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useAsyncState } from './useAsyncState'; + +interface WaitableMock extends jest.Mock { + waitToBeCalled(): Promise; +} + +function createWaitableMock() { + let resolve: Function; + + const mock = jest.fn() as WaitableMock; + mock.mockImplementation(() => { + resolve && resolve(); + }); + + mock.waitToBeCalled = () => { + return new Promise((_resolve) => (resolve = _resolve)); + }; + + return mock; +} + +describe('useAsyncState', () => { + const MOCKED_RANDOM_VALUE = 0.42069; + + beforeEach(() => { + jest.spyOn(global.Math, 'random').mockReturnValue(MOCKED_RANDOM_VALUE); + }); + + afterEach(() => { + jest.spyOn(global.Math, 'random').mockRestore(); + }); + + it('should return data from an async function', async () => { + const apiCall = () => Promise.resolve(['a', 'b', 'c']); + const setError = jest.fn(); + + const { result, waitForNextUpdate } = renderHook(() => useAsyncState(apiCall, setError, [])); + await waitForNextUpdate(); + + expect(result.current).toEqual(['a', 'b', 'c']); + }); + + it('should report errors through setError', async () => { + const error = new Error(); + const apiCall = () => Promise.reject(error); + const setError = createWaitableMock(); + + const { result, waitForNextUpdate } = renderHook(() => useAsyncState(apiCall, setError, [])); + await Promise.race([waitForNextUpdate(), setError.waitToBeCalled()]); + + expect(result.current).toEqual([]); + expect(setError).toHaveBeenCalledWith(MOCKED_RANDOM_VALUE, error); + }); + + it('should clear the error once the request is successful', async () => { + const apiCall = () => Promise.resolve(['a', 'b', 'c']); + const setError = createWaitableMock(); + + const { waitForNextUpdate } = renderHook(() => useAsyncState(apiCall, setError, [])); + await Promise.race([waitForNextUpdate(), setError.waitToBeCalled()]); + + expect(setError).toHaveBeenCalledWith(MOCKED_RANDOM_VALUE, undefined); + }); +}); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/utils/useAsyncState.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/utils/useAsyncState.ts new file mode 100644 index 00000000000..fa84157bfc6 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/utils/useAsyncState.ts @@ -0,0 +1,24 @@ +import { useEffect, useMemo, useState } from 'react'; + +export function useAsyncState(asyncFn: () => Promise, setError: Function, dependencies: unknown[]) { + // Use the lazy initial state functionality of useState to assign a random ID to the API call + // to track where errors come from. See useLastError. + const [errorSource] = useState(() => Math.random()); + const [value, setValue] = useState(); + + const finalValue = useMemo(() => value ?? [], [value]); + + useEffect(() => { + asyncFn() + .then((results) => { + setValue(results); + setError(errorSource, undefined); + }) + .catch((err) => { + setError(errorSource, err); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, dependencies); + + return finalValue; +}