From 4df6c9085a3357ed08d7b06d937aed6cf9826709 Mon Sep 17 00:00:00 2001 From: Andreas Christou Date: Wed, 9 Apr 2025 01:07:02 +0100 Subject: [PATCH] [release-11.5.4] Azure: Variable editor and resource picker improvements (#103657) Azure: Variable editor and resource picker improvements (#101695) * Update namespace endpoint to filter out only relevant namespaces * Update tests * Fix url builder tests * Add todo comments * Update func to use ARG to retrieve namespaces with metrics * Refactor getMetricNamespaces for readability * Lint * Remove comments * Remove type assertion * Refactor ARG query * Update tests and refactor class to use ARG * Update resource group query - Updates the resource groups query to support users/apps with restricted permissions * Update resources request to be paginated - Also order by name - Add tests * Start refactoring azure monitor util functions to resource graph * Minor lint * Add getMetricNamespaces resource graph function * Modify getMetricNamespaces call - Use resource graph function for variable queries * Return names for getResourceNames values * Use getMetricNamespaces variable specific req in editor * Substantial refactor - Update Azure Resource Graph data source with a method for making paged requests and a method for retrieving metric namespaces (and add tests) - Extract helpers from azure_monitor_datasource to utils and generalise them (also revert previous changes to datasource and test file) - Update mock with Azure Resource Graph data source - Revert response parser changes - Revert url builder changes - Update get metric namespaces query to use the resource graph method for variable queries - Update docs * Lint * Oops * Fix type * Lint and betterer * Simplify imports * Improve type * Simplify resource picker class * Start updating tests * Fix naming and include missing error * Update resource graph data source mock * Update tests * Remove unneeded parser * Add get subscriptions to resource graph * Generalise resource groups request * Fix resource names request to ensure no clashing * Update types * Use resource graph queries for resource picker * Correctly map resource group names * Update mocks * Update tests * Fix mapping * Refactor getResourceNames - Remove most of the logic from resourcePickerData - Add helper for parsing resource names as template vars - Some renames for clarity - Update types - Update tests * Ensure namespaces are lowercase * Update docs/sources/datasources/azure-monitor/template-variables/index.md Co-authored-by: Larissa Wandzura <126723338+lwandz13@users.noreply.github.com> * Prettier write * Ensure we return all namespaces if resourceGroup isn't specified --------- Co-authored-by: alyssabull Co-authored-by: Larissa Wandzura <126723338+lwandz13@users.noreply.github.com> (cherry picked from commit c7b0bbd262458a21fa81981b945097f1b017cad1) --- .../__mocks__/argResourcePickerResponse.ts | 18 ++ .../azuremonitor/__mocks__/datasource.ts | 5 +- .../azure_monitor_datasource.test.ts | 173 ++++++------------ .../azure_monitor/azure_monitor_datasource.ts | 66 +------ .../azure_monitor/response_parser.ts | 26 --- .../azure_resource_graph_datasource.ts | 96 +++++++++- .../MetricsQueryEditor.test.tsx | 3 +- .../ResourcePicker/ResourcePicker.test.tsx | 16 +- .../VariableEditor/VariableEditor.test.tsx | 20 +- .../VariableEditor/VariableEditor.tsx | 6 +- .../datasource/azuremonitor/datasource.ts | 32 ++-- .../resourcePicker/resourcePickerData.test.ts | 47 ++--- .../resourcePicker/resourcePickerData.ts | 167 +++-------------- .../datasource/azuremonitor/types/types.ts | 6 +- .../datasource/azuremonitor/variables.test.ts | 8 +- .../datasource/azuremonitor/variables.ts | 48 ++++- 16 files changed, 313 insertions(+), 424 deletions(-) diff --git a/public/app/plugins/datasource/azuremonitor/__mocks__/argResourcePickerResponse.ts b/public/app/plugins/datasource/azuremonitor/__mocks__/argResourcePickerResponse.ts index e18bef8ea6d..da06ff8236d 100644 --- a/public/app/plugins/datasource/azuremonitor/__mocks__/argResourcePickerResponse.ts +++ b/public/app/plugins/datasource/azuremonitor/__mocks__/argResourcePickerResponse.ts @@ -10,26 +10,38 @@ export const createMockARGSubscriptionResponse = (): AzureGraphResponse getResourceURIDisplayProperties: jest.fn().mockResolvedValue({}), }, - azureResourceGraphDatasource: {}, + azureResourceGraphDatasource: { + pagedResourceGraphRequest: jest.fn().mockResolvedValue([]), + ...overrides?.azureResourceGraphDatasource, + }, getVariablesRaw: jest.fn().mockReturnValue([]), currentUserAuth: false, ...overrides, diff --git a/public/app/plugins/datasource/azuremonitor/azure_monitor/azure_monitor_datasource.test.ts b/public/app/plugins/datasource/azuremonitor/azure_monitor/azure_monitor_datasource.test.ts index 9df9d831650..d20428269dd 100644 --- a/public/app/plugins/datasource/azuremonitor/azure_monitor/azure_monitor_datasource.test.ts +++ b/public/app/plugins/datasource/azuremonitor/azure_monitor/azure_monitor_datasource.test.ts @@ -7,7 +7,13 @@ import createMockQuery from '../__mocks__/query'; import { createTemplateVariables } from '../__mocks__/utils'; import { multiVariable } from '../__mocks__/variables'; import AzureMonitorDatasource from '../datasource'; -import { AzureAPIResponse, AzureMonitorDataSourceInstanceSettings, Location } from '../types'; +import { + AzureAPIResponse, + AzureMonitorDataSourceInstanceSettings, + Location, + RawAzureResourceGroupItem, + RawAzureResourceItem, +} from '../types'; // We want replace to just return the value as is in general/ // We declare this as a function so that we can overwrite it in each test @@ -754,20 +760,20 @@ describe('AzureMonitorDatasource', () => { describe('When performing getResourceGroups', () => { const response = { - value: [{ name: 'grp1' }, { name: 'grp2' }], + data: [{ resourceGroupName: 'grp1' }, { resourceGroupName: 'grp2' }], }; beforeEach(() => { - ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockResolvedValue(response); + ctx.ds.azureResourceGraphDatasource.postResource = jest.fn().mockResolvedValue(response); }); it('should return list of Resource Groups', () => { - return ctx.ds.getResourceGroups('subscriptionId').then((results: Array<{ text: string; value: string }>) => { + return ctx.ds.getResourceGroups('subscriptionId').then((results: RawAzureResourceGroupItem[]) => { 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'); + expect(results[0].resourceGroupName).toEqual('grp1'); + expect(results[0].resourceGroupName).toEqual('grp1'); + expect(results[1].resourceGroupName).toEqual('grp2'); + expect(results[1].resourceGroupName).toEqual('grp2'); }); }); }); @@ -786,11 +792,7 @@ describe('AzureMonitorDatasource', () => { describe('and there are no special cases', () => { const response = { - value: [ - { - name: 'Failure Anomalies - nodeapp', - type: 'microsoft.insights/alertrules', - }, + data: [ { name: resourceGroup, type: metricNamespace, @@ -799,13 +801,7 @@ describe('AzureMonitorDatasource', () => { }; beforeEach(() => { - ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => { - const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`; - expect(path).toBe( - `${basePath}/${resourceGroup}/resources?api-version=2021-04-01&$filter=resourceType eq '${metricNamespace}'${ - region ? ` and location eq '${region}'` : '' - }` - ); + ctx.ds.azureResourceGraphDatasource.postResource = jest.fn().mockImplementation((path: string) => { return Promise.resolve(response); }); }); @@ -813,10 +809,10 @@ describe('AzureMonitorDatasource', () => { it('should return list of Resource Names', () => { return ctx.ds .getResourceNames(subscription, resourceGroup, metricNamespace) - .then((results: Array<{ text: string; value: string }>) => { + .then((results: RawAzureResourceItem[]) => { expect(results.length).toEqual(1); - expect(results[0].text).toEqual('nodeapp'); - expect(results[0].value).toEqual('nodeapp'); + expect(results[0].name).toEqual('nodeapp'); + expect(results[0].name).toEqual('nodeapp'); }); }); @@ -824,10 +820,10 @@ describe('AzureMonitorDatasource', () => { metricNamespace = 'microsoft.insights/Components'; return ctx.ds .getResourceNames(subscription, resourceGroup, metricNamespace) - .then((results: Array<{ text: string; value: string }>) => { + .then((results: RawAzureResourceItem[]) => { expect(results.length).toEqual(1); - expect(results[0].text).toEqual('nodeapp'); - expect(results[0].value).toEqual('nodeapp'); + expect(results[0].name).toEqual('nodeapp'); + expect(results[0].name).toEqual('nodeapp'); }); }); @@ -835,10 +831,10 @@ describe('AzureMonitorDatasource', () => { region = 'eastus'; return ctx.ds .getResourceNames(subscription, resourceGroup, metricNamespace, region) - .then((results: Array<{ text: string; value: string }>) => { + .then((results: RawAzureResourceItem[]) => { expect(results.length).toEqual(1); - expect(results[0].text).toEqual('nodeapp'); - expect(results[0].value).toEqual('nodeapp'); + expect(results[0].name).toEqual('nodeapp'); + expect(results[0].name).toEqual('nodeapp'); }); }); @@ -869,43 +865,34 @@ describe('AzureMonitorDatasource', () => { return target === `$${multiVariable.id}` ? 'foo,bar' : (target ?? ''); }; const ds = new AzureMonitorDatasource(ctx.instanceSettings); - //ds.azureMonitorDatasource.templateSrv = tsrv; - ds.azureMonitorDatasource.getResource = jest + ds.azureResourceGraphDatasource.postResource = jest .fn() - .mockImplementationOnce((path: string) => { - expect(path).toMatch('foo'); - return Promise.resolve(response); - }) - .mockImplementationOnce((path: string) => { - expect(path).toMatch('bar'); - return Promise.resolve({ - value: [ + .mockImplementationOnce(() => Promise.resolve(response)) + .mockImplementationOnce(() => + Promise.resolve({ + data: [ { name: resourceGroup + '2', type: metricNamespace, }, ], - }); - }); + }) + ); return ds .getResourceNames(subscription, `$${multiVariable.id}`, metricNamespace) - .then((results: Array<{ text: string; value: string }>) => { + .then((results: RawAzureResourceItem[]) => { expect(results.length).toEqual(2); - expect(results[0].text).toEqual('nodeapp'); - expect(results[0].value).toEqual('nodeapp'); - expect(results[1].text).toEqual('nodeapp2'); - expect(results[1].value).toEqual('nodeapp2'); + expect(results[0].name).toEqual('nodeapp'); + expect(results[0].name).toEqual('nodeapp'); + expect(results[1].name).toEqual('nodeapp2'); + expect(results[1].name).toEqual('nodeapp2'); }); }); }); describe('and the metric definition is blobServices', () => { const response = { - value: [ - { - name: 'Failure Anomalies - nodeapp', - type: 'microsoft.insights/alertrules', - }, + data: [ { name: 'storagetest', type: 'microsoft.storage/storageaccounts', @@ -916,78 +903,32 @@ describe('AzureMonitorDatasource', () => { it('should return list of Resource Names', () => { metricNamespace = 'microsoft.storage/storageaccounts/blobservices'; const validMetricNamespace = 'microsoft.storage/storageaccounts'; - ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => { - const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`; - expect(path).toBe( - basePath + - `/${resourceGroup}/resources?api-version=2021-04-01&$filter=resourceType eq '${validMetricNamespace}'` - ); - return Promise.resolve(response); - }); + ctx.ds.azureResourceGraphDatasource.postResource = jest + .fn() + .mockImplementation(() => Promise.resolve(response)); return ctx.ds .getResourceNames(subscription, resourceGroup, metricNamespace) - .then((results: Array<{ text: string; value: string }>) => { + .then((results: RawAzureResourceItem[]) => { expect(results.length).toEqual(1); - expect(results[0].text).toEqual('storagetest/default'); - expect(results[0].value).toEqual('storagetest/default'); - expect(ctx.ds.azureMonitorDatasource.getResource).toHaveBeenCalledWith( - `azuremonitor/subscriptions/${subscription}/resourceGroups/${resourceGroup}/resources?api-version=2021-04-01&$filter=resourceType eq '${validMetricNamespace}'` + expect(results[0].name).toEqual('storagetest'); + expect(results[0].name).toEqual('storagetest'); + expect(ctx.ds.azureResourceGraphDatasource.postResource).toHaveBeenCalledWith( + 'resourcegraph/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01', + { + options: { resultFormat: 'objectArray' }, + query: `resources + | where id hasprefix \"/subscriptions/${subscription}/resourceGroups/${resourceGroup}/\" + | where type == '${validMetricNamespace}' + | order by tolower(name) asc`, + } ); }); }); }); - describe('and there are several pages', () => { - const skipToken = 'token'; - const response1 = { - value: [ - { - name: `${resourceGroup}1`, - type: metricNamespace, - }, - ], - nextLink: `https://management.azure.com/resourceuri?$skiptoken=${skipToken}`, - }; - const response2 = { - value: [ - { - name: `${resourceGroup}2`, - type: metricNamespace, - }, - ], - }; - - beforeEach(() => { - const fn = jest.fn(); - ctx.ds.azureMonitorDatasource.getResource = fn; - const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`; - const expectedPath = `${basePath}/${resourceGroup}/resources?api-version=2021-04-01&$filter=resourceType eq '${metricNamespace}'`; - // 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, metricNamespace) - .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('without a resource group or a metric definition', () => { const response = { - value: [ + data: [ { name: 'Failure Anomalies - nodeapp', type: 'microsoft.insights/alertrules', @@ -1000,15 +941,13 @@ describe('AzureMonitorDatasource', () => { }; beforeEach(() => { - ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => { - const basePath = `azuremonitor/subscriptions/${subscription}/resources?api-version=2021-04-01`; - expect(path).toBe(basePath); + ctx.ds.azureResourceGraphDatasource.postResource = jest.fn().mockImplementation((path: string) => { return Promise.resolve(response); }); }); it('should return list of Resource Names', () => { - return ctx.ds.getResourceNames(subscription).then((results: Array<{ text: string; value: string }>) => { + return ctx.ds.getResourceNames(subscription).then((results: RawAzureResourceItem[]) => { expect(results.length).toEqual(2); }); }); diff --git a/public/app/plugins/datasource/azuremonitor/azure_monitor/azure_monitor_datasource.ts b/public/app/plugins/datasource/azuremonitor/azure_monitor/azure_monitor_datasource.ts index 38a10e79a3a..4f0bbb9fd8e 100644 --- a/public/app/plugins/datasource/azuremonitor/azure_monitor/azure_monitor_datasource.ts +++ b/public/app/plugins/datasource/azuremonitor/azure_monitor/azure_monitor_datasource.ts @@ -1,4 +1,4 @@ -import { find, startsWith } from 'lodash'; +import { find } from 'lodash'; import { AzureCredentials } from '@grafana/azure-sdk'; import { ScopedVars } from '@grafana/data'; @@ -20,10 +20,8 @@ import { AzureMonitorLocations, AzureMonitorProvidersResponse, AzureAPIResponse, - AzureGetResourceNamesQuery, Subscription, Location, - ResourceGroup, Metric, MetricNamespace, } from '../types'; @@ -178,68 +176,6 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend< }); } - getResourceGroups(subscriptionId: string) { - return this.getResource( - `${this.resourcePath}/subscriptions/${subscriptionId}/resourceGroups?api-version=${this.listByResourceGroupApiVersion}` - ).then((result: AzureAPIResponse) => { - return ResponseParser.parseResponseValues(result, 'name', 'name'); - }); - } - - async getResourceNames(query: AzureGetResourceNamesQuery, skipToken?: string) { - const promises = replaceTemplateVariables(this.templateSrv, query).map( - ({ metricNamespace, subscriptionId, resourceGroup, region }) => { - const validMetricNamespace = startsWith(metricNamespace?.toLowerCase(), 'microsoft.storage/storageaccounts/') - ? 'microsoft.storage/storageaccounts' - : metricNamespace; - let url = `${this.resourcePath}/subscriptions/${subscriptionId}`; - if (resourceGroup) { - url += `/resourceGroups/${resourceGroup}`; - } - url += `/resources?api-version=${this.listByResourceGroupApiVersion}`; - const filters: string[] = []; - if (validMetricNamespace) { - filters.push(`resourceType eq '${validMetricNamespace}'`); - } - if (region) { - filters.push(`location eq '${region}'`); - } - if (filters.length > 0) { - url += `&$filter=${filters.join(' and ')}`; - } - if (skipToken) { - url += `&$skiptoken=${skipToken}`; - } - return this.getResource(url).then(async (result) => { - let list: Array<{ text: string; value: string }> = []; - if (startsWith(metricNamespace?.toLowerCase(), 'microsoft.storage/storageaccounts/')) { - list = ResponseParser.parseResourceNames(result, 'microsoft.storage/storageaccounts'); - for (let i = 0; i < list.length; i++) { - list[i].text += '/default'; - list[i].value += '/default'; - } - } else { - list = ResponseParser.parseResourceNames(result, metricNamespace); - } - - if (result.nextLink) { - // If there is a nextLink, we should request more pages - const nextURL = new URL(result.nextLink); - const nextToken = nextURL.searchParams.get('$skiptoken'); - if (!nextToken) { - throw Error('unable to request the next page of resources'); - } - const nextPage = await this.getResourceNames({ metricNamespace, subscriptionId, resourceGroup }, nextToken); - list = list.concat(nextPage); - } - - return list; - }); - } - ); - return (await Promise.all(promises)).flat(); - } - // Note globalRegion should be false when querying custom metric namespaces getMetricNamespaces(query: GetMetricNamespacesQuery, globalRegion: boolean, region?: string, custom?: boolean) { const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl( diff --git a/public/app/plugins/datasource/azuremonitor/azure_monitor/response_parser.ts b/public/app/plugins/datasource/azuremonitor/azure_monitor/response_parser.ts index 226af005510..42dacec1042 100644 --- a/public/app/plugins/datasource/azuremonitor/azure_monitor/response_parser.ts +++ b/public/app/plugins/datasource/azuremonitor/azure_monitor/response_parser.ts @@ -12,7 +12,6 @@ import { AzureAPIResponse, Location, Subscription, - Resource, } from '../types'; export default class ResponseParser { static parseResponseValues( @@ -40,31 +39,6 @@ export default class ResponseParser { return list; } - static parseResourceNames( - result: AzureAPIResponse, - metricNamespace?: string - ): Array<{ text: string; value: string }> { - const list: Array<{ text: string; value: string }> = []; - - if (!result) { - return list; - } - - for (let i = 0; i < result.value.length; i++) { - if ( - typeof result.value[i].type === 'string' && - (!metricNamespace || result.value[i].type.toLocaleLowerCase() === metricNamespace.toLocaleLowerCase()) - ) { - list.push({ - text: result.value[i].name, - value: result.value[i].name, - }); - } - } - - return list; - } - static parseMetadata(result: AzureMonitorMetricsMetadataResponse, metricName: string) { const defaultAggTypes = ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count']; const metricData = result?.value.find((v) => v.name.value === metricName); diff --git a/public/app/plugins/datasource/azuremonitor/azure_resource_graph/azure_resource_graph_datasource.ts b/public/app/plugins/datasource/azuremonitor/azure_resource_graph/azure_resource_graph_datasource.ts index 7c0edc53294..b192b1c1ed0 100644 --- a/public/app/plugins/datasource/azuremonitor/azure_resource_graph/azure_resource_graph_datasource.ts +++ b/public/app/plugins/datasource/azuremonitor/azure_resource_graph/azure_resource_graph_datasource.ts @@ -1,5 +1,4 @@ -// eslint-disable-next-line lodash/import-scope -import _ from 'lodash'; +import { startsWith, includes, find, filter } from 'lodash'; import { ScopedVars } from '@grafana/data'; import { getTemplateSrv, DataSourceWithBackend, TemplateSrv } from '@grafana/runtime'; @@ -9,10 +8,13 @@ import { AzureMonitorQuery, AzureMonitorDataSourceJsonData, AzureQueryType, + RawAzureResourceGroupItem, + AzureGetResourceNamesQuery, AzureMonitorDataSourceInstanceSettings, RawAzureResourceItem, AzureGraphResponse, AzureResourceGraphOptions, + RawAzureSubscriptionItem, } from '../types'; import { interpolateVariable, replaceTemplateVariables, routeNames } from '../utils/common'; @@ -41,14 +43,14 @@ export default class AzureResourceGraphDatasource extends DataSourceWithBackend< return target; } const variableNames = ts.getVariables().map((v) => `$${v.name}`); - const subscriptionVar = _.find(target.subscriptions, (sub) => _.includes(variableNames, sub)); + const subscriptionVar = find(target.subscriptions, (sub) => includes(variableNames, sub)); const interpolatedSubscriptions = ts .replace(subscriptionVar, scopedVars, (v: string[] | string) => v) .split(',') .filter((v) => v.length > 0); const subscriptions = [ ...interpolatedSubscriptions, - ..._.filter(target.subscriptions, (sub) => !_.includes(variableNames, sub)), + ...filter(target.subscriptions, (sub) => !includes(variableNames, sub)), ]; const query = ts.replace(item.query, scopedVars, interpolateVariable); @@ -63,7 +65,7 @@ export default class AzureResourceGraphDatasource extends DataSourceWithBackend< }; } - async pagedResourceGraphRequest(query: string, maxRetries = 1): Promise { + async pagedResourceGraphRequest(query: string, maxRetries = 1): Promise { try { let allFetched = false; let $skipToken = undefined; @@ -101,6 +103,90 @@ export default class AzureResourceGraphDatasource extends DataSourceWithBackend< } } + async getSubscriptions() { + const query = ` + resources + | join kind=inner ( + ResourceContainers + | where type == 'microsoft.resources/subscriptions' + | project subscriptionName=name, subscriptionURI=id, subscriptionId + ) on subscriptionId + | summarize count=count() by subscriptionName, subscriptionURI, subscriptionId + | order by subscriptionName desc + `; + + const subscriptions = await this.pagedResourceGraphRequest(query, 1); + + return subscriptions; + } + + async getResourceGroups(subscriptionId: string, metricNamespacesFilter?: string) { + // We can use subscription ID for the filtering here as they're unique + // The logic of this query is: + // Retrieve _all_ resources a user/app registration/identity has access to + // Filter by the namespaces that support metrics (if this request is from the resource picker) + // Filter to resources contained within the subscription + // Conduct a left-outer join on the resourcecontainers table to allow us to get the case-sensitive resource group name + // Return the count of resources in a group, the URI, and name of the group in ascending order + const query = `resources + ${metricNamespacesFilter || ''} + | where subscriptionId == '${subscriptionId}' + | extend resourceGroupURI = strcat("/subscriptions/", subscriptionId, "/resourcegroups/", resourceGroup) + | join kind=leftouter (resourcecontainers + | where type =~ 'microsoft.resources/subscriptions/resourcegroups' + | project resourceGroupName=name, resourceGroupURI=tolower(id)) on resourceGroupURI + | project resourceGroupName=iff(resourceGroupName != "", resourceGroupName, resourceGroup), resourceGroupURI + | summarize count=count() by resourceGroupName, resourceGroupURI + | order by tolower(resourceGroupName) asc `; + + const resourceGroups = await this.pagedResourceGraphRequest(query); + + return resourceGroups; + } + + async getResourceNames(query: AzureGetResourceNamesQuery, metricNamespacesFilter?: string) { + const promises = replaceTemplateVariables(this.templateSrv, query).map( + async ({ metricNamespace, subscriptionId, resourceGroup, region, uri }) => { + const validMetricNamespace = startsWith(metricNamespace?.toLowerCase(), 'microsoft.storage/storageaccounts/') + ? 'microsoft.storage/storageaccounts' + : metricNamespace; + + // URI takes precedence over subscription ID and resource group + let prefix = uri; + if (!prefix) { + if (subscriptionId) { + prefix = `/subscriptions/${subscriptionId}`; + } + if (resourceGroup) { + prefix += `/resourceGroups/${resourceGroup}`; + } + } + + const filters: string[] = []; + if (validMetricNamespace) { + // Ensure the namespace is always lowercase as that's how it's stored in Resource Graph + filters.push(`type == '${validMetricNamespace.toLowerCase()}'`); + } + if (region) { + filters.push(`location == '${region}'`); + } + + // We use URIs for the filtering here because resource group names are not unique across subscriptions + // We also add a slash at the end of the URI to ensure we do not pull resources from a resource group + // that has a similar naming prefix e.g. resourceGroup1 and resourceGroup10 + const query = `resources${metricNamespacesFilter ? '\n' + metricNamespacesFilter : ''} + | where id hasprefix "${prefix}/" + ${filters.length > 0 ? `| where ${filters.join(' and ')}` : ''} + | order by tolower(name) asc`; + + const resources = await this.pagedResourceGraphRequest(query); + + return resources; + } + ); + return (await Promise.all(promises)).flat(); + } + // Retrieve metric namespaces relevant to a subscription/resource group/resource async getMetricNamespaces(resourceUri: string) { const promises = replaceTemplateVariables(this.templateSrv, { resourceUri }).map(async ({ resourceUri }) => { diff --git a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx index 49dc9f2a99c..3a129c12a70 100644 --- a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx @@ -34,7 +34,8 @@ export function createMockResourcePickerData() { const mockDatasource = createMockDatasource(); const mockResourcePicker = new ResourcePickerData( createMockInstanceSetttings(), - mockDatasource.azureMonitorDatasource + mockDatasource.azureMonitorDatasource, + mockDatasource.azureResourceGraphDatasource ); mockResourcePicker.getSubscriptions = jest.fn().mockResolvedValue(createMockSubscriptions()); diff --git a/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/ResourcePicker.test.tsx b/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/ResourcePicker.test.tsx index 40d5d3137eb..44095e1d20c 100644 --- a/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/ResourcePicker.test.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/ResourcePicker.test.tsx @@ -10,6 +10,8 @@ import { mockResourcesByResourceGroup, mockSearchResults, } from '../../__mocks__/resourcePickerRows'; +import { DeepPartial } from '../../__mocks__/utils'; +import Datasource from '../../datasource'; import ResourcePickerData, { ResourcePickerQueryType } from '../../resourcePicker/resourcePickerData'; import { ResourceRowType } from './types'; @@ -32,11 +34,15 @@ const singleResourceSelectionURI = '/subscriptions/def-456/resourceGroups/dev-3/providers/Microsoft.Compute/virtualMachines/db-server'; const noop = () => {}; -function createMockResourcePickerData(preserveImplementation?: string[]) { - const mockDatasource = createMockDatasource(); +function createMockResourcePickerData( + preserveImplementation?: string[], + datasourceOverrides?: DeepPartial +) { + const mockDatasource = createMockDatasource(datasourceOverrides); const mockResourcePicker = new ResourcePickerData( createMockInstanceSetttings(), - mockDatasource.azureMonitorDatasource + mockDatasource.azureMonitorDatasource, + mockDatasource.azureResourceGraphDatasource ); const mockFunctions = omit( @@ -332,7 +338,9 @@ describe('AzureMonitor ResourcePicker', () => { }); it('should not throw an error if no namespaces are found - fallback used', async () => { - const resourcePickerData = createMockResourcePickerData(['getResourceGroupsBySubscriptionId']); + const resourcePickerData = createMockResourcePickerData(['getResourceGroupsBySubscriptionId'], { + azureResourceGraphDatasource: { getResourceGroups: jest.fn().mockResolvedValue([]) }, + }); resourcePickerData.postResource = jest.fn().mockResolvedValueOnce({ data: [] }); render( ({ }, }), })); - +const getResourceGroups = jest.fn().mockResolvedValue([{ resourceGroupURI: 'rg', resourceGroupName: 'rg', count: 1 }]); +const getResourceNames = jest.fn().mockResolvedValue([ + { + id: 'foobarID', + name: 'foobar', + subscriptionId: 'subID', + resourceGroup: 'resourceGroup', + type: 'foobarType', + location: 'london', + }, +]); const defaultProps = { query: { refId: 'A', @@ -39,7 +49,6 @@ const defaultProps = { onChange: jest.fn(), datasource: createMockDatasource({ getSubscriptions: jest.fn().mockResolvedValue([{ text: 'Primary Subscription', value: 'sub' }]), - getResourceGroups: jest.fn().mockResolvedValue([{ text: 'rg', value: 'rg' }]), getMetricNamespaces: jest .fn() .mockImplementation( @@ -50,11 +59,16 @@ const defaultProps = { return [{ text: 'foo/custom', value: 'foo/custom' }]; } ), - getResourceNames: jest.fn().mockResolvedValue([{ text: 'foobar', value: 'foobar' }]), getVariablesRaw: jest.fn().mockReturnValue([ { label: 'query0', name: 'sub0' }, { label: 'query1', name: 'rg', query: { queryType: AzureQueryType.ResourceGroupsQuery } }, ]), + azureResourceGraphDatasource: { + getResourceGroups, + getResourceNames, + }, + getResourceGroups, + getResourceNames, }), }; diff --git a/public/app/plugins/datasource/azuremonitor/components/VariableEditor/VariableEditor.tsx b/public/app/plugins/datasource/azuremonitor/components/VariableEditor/VariableEditor.tsx index e238f4468f4..d14fbbe0f83 100644 --- a/public/app/plugins/datasource/azuremonitor/components/VariableEditor/VariableEditor.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/VariableEditor/VariableEditor.tsx @@ -149,7 +149,7 @@ const VariableEditor = (props: Props) => { useEffect(() => { if (subscription) { datasource.getResourceGroups(subscription).then((rgs) => { - setResourceGroups(rgs.map((s) => ({ label: s.text, value: s.value }))); + setResourceGroups(rgs.map((s) => ({ label: s.resourceGroupName, value: s.resourceGroupName }))); }); } }, [datasource, subscription]); @@ -179,8 +179,8 @@ const VariableEditor = (props: Props) => { // When subscription, resource group, and namespace are all set, retrieve resource names useEffect(() => { if (subscription && resourceGroup && namespace) { - datasource.getResourceNames(subscription, resourceGroup, namespace).then((rgs) => { - setResources(rgs.map((s) => ({ label: s.text, value: s.value }))); + datasource.getResourceNames(subscription, resourceGroup, namespace).then((resources) => { + setResources(resources.map((s) => ({ label: s.name, value: s.name }))); }); } }, [datasource, subscription, resourceGroup, namespace]); diff --git a/public/app/plugins/datasource/azuremonitor/datasource.ts b/public/app/plugins/datasource/azuremonitor/datasource.ts index 0735d1a891f..7b405ed94dd 100644 --- a/public/app/plugins/datasource/azuremonitor/datasource.ts +++ b/public/app/plugins/datasource/azuremonitor/datasource.ts @@ -49,7 +49,11 @@ export default class Datasource extends DataSourceWithBackend ({ const createResourcePickerData = (responses: AzureGraphResponse[], noNamespaces?: boolean) => { const instanceSettings = createMockInstanceSetttings(); - const mockDatasource = createMockDatasource(); + const azureResourceGraphDatasource = new AzureResourceGraphDatasource(instanceSettings); + const postResource = jest.fn(); + responses.forEach((res) => { + postResource.mockResolvedValueOnce(res); + }); + azureResourceGraphDatasource.postResource = postResource; + const mockDatasource = createMockDatasource({ azureResourceGraphDatasource: azureResourceGraphDatasource }); mockDatasource.azureMonitorDatasource.getMetricNamespaces = jest .fn() .mockResolvedValueOnce( noNamespaces ? [] : [{ text: 'Microsoft.Storage/storageAccounts', value: 'Microsoft.Storage/storageAccounts' }] ); - const resourcePickerData = new ResourcePickerData(instanceSettings, mockDatasource.azureMonitorDatasource); - const postResource = jest.fn(); - responses.forEach((res) => { - postResource.mockResolvedValueOnce(res); - }); - resourcePickerData.postResource = postResource; + + const resourcePickerData = new ResourcePickerData( + instanceSettings, + mockDatasource.azureMonitorDatasource, + mockDatasource.azureResourceGraphDatasource + ); return { resourcePickerData, postResource, mockDatasource }; }; @@ -301,32 +308,6 @@ describe('AzureMonitor resourcePickerData', () => { }); }); - it('throws an error if it recieves data with a malformed uri', async () => { - const mockResponse = { - data: [ - { - id: '/a-differently-formatted/uri/than/the/type/we/planned/to/parse', - name: 'web-server', - type: 'Microsoft.Compute/virtualMachines', - resourceGroup: 'dev', - subscriptionId: 'def-456', - location: 'northeurope', - }, - ], - }; - const { resourcePickerData } = createResourcePickerData([mockResponse]); - try { - await resourcePickerData.getResourcesForResourceGroup('dev', 'logs'); - throw Error('expected getResourcesForResourceGroup to fail but it succeeded'); - } catch (err) { - if (err instanceof Error) { - expect(err.message).toEqual('unable to fetch resource details'); - } else { - throw err; - } - } - }); - it('should filter metrics resources', async () => { const mockSubscriptionsResponse = createMockARGSubscriptionResponse(); const mockResourcesResponse = createARGResourcesResponse(); diff --git a/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.ts b/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.ts index 135dc332572..b3cd9e7a498 100644 --- a/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.ts +++ b/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.ts @@ -2,6 +2,7 @@ import { DataSourceWithBackend, reportInteraction } from '@grafana/runtime'; import { logsResourceTypes, resourceTypeDisplayNames, resourceTypes } from '../azureMetadata'; import AzureMonitorDatasource from '../azure_monitor/azure_monitor_datasource'; +import AzureResourceGraphDatasource from '../azure_resource_graph/azure_resource_graph_datasource'; import { ResourceRow, ResourceRowGroup, ResourceRowType } from '../components/ResourcePicker/types'; import { addResources, @@ -14,18 +15,11 @@ import { import { AzureMonitorDataSourceInstanceSettings, AzureMonitorDataSourceJsonData, - AzureGraphResponse, AzureMonitorResource, AzureMonitorQuery, - AzureResourceGraphOptions, AzureResourceSummaryItem, - RawAzureResourceGroupItem, RawAzureResourceItem, - RawAzureSubscriptionItem, } from '../types'; -import { routeNames } from '../utils/common'; - -const RESOURCE_GRAPH_URL = '/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01'; const logsSupportedResourceTypesKusto = logsResourceTypes.map((v) => `"${v}"`).join(','); @@ -35,18 +29,19 @@ export default class ResourcePickerData extends DataSourceWithBackend< AzureMonitorQuery, AzureMonitorDataSourceJsonData > { - private resourcePath: string; resultLimit = 200; azureMonitorDatasource; + azureResourceGraphDatasource; supportedMetricNamespaces = ''; constructor( instanceSettings: AzureMonitorDataSourceInstanceSettings, - azureMonitorDatasource: AzureMonitorDatasource + azureMonitorDatasource: AzureMonitorDatasource, + azureResourceGraphDatasource: AzureResourceGraphDatasource ) { super(instanceSettings); - this.resourcePath = `${routeNames.resourceGraph}`; this.azureMonitorDatasource = azureMonitorDatasource; + this.azureResourceGraphDatasource = azureResourceGraphDatasource; } async fetchInitialRows( @@ -111,7 +106,8 @@ export default class ResourcePickerData extends DataSourceWithBackend< | order by tolower(name) asc | limit ${this.resultLimit} `; - const { data: response } = await this.makeResourceGraphRequest(searchQuery); + const response = + await this.azureResourceGraphDatasource.pagedResourceGraphRequest(searchQuery); return response.map((item) => { const parsedUri = parseResourceURI(item.id); if (!parsedUri || !(parsedUri.resourceName || parsedUri.resourceGroup || parsedUri.subscription)) { @@ -138,41 +134,14 @@ export default class ResourcePickerData extends DataSourceWithBackend< }); }; - // private async getSubscriptions(): Promise { - const query = ` - resources - | join kind=inner ( - ResourceContainers - | where type == 'microsoft.resources/subscriptions' - | project subscriptionName=name, subscriptionURI=id, subscriptionId - ) on subscriptionId - | summarize count() by subscriptionName, subscriptionURI, subscriptionId - | order by subscriptionName desc - `; + const subscriptions = await this.azureResourceGraphDatasource.getSubscriptions(); - let resources: RawAzureSubscriptionItem[] = []; - - let allFetched = false; - let $skipToken = undefined; - while (!allFetched) { - // The response may include several pages - let options: Partial = {}; - if ($skipToken) { - options = { - $skipToken, - }; - } - const resourceResponse = await this.makeResourceGraphRequest(query, 1, options); - if (!resourceResponse.data.length) { - throw new Error('No subscriptions were found'); - } - resources = resources.concat(resourceResponse.data); - $skipToken = resourceResponse.$skipToken; - allFetched = !$skipToken; + if (!subscriptions.length) { + throw new Error('No subscriptions were found'); } - return resources.map((subscription) => ({ + return subscriptions.map((subscription) => ({ name: subscription.subscriptionName, id: subscription.subscriptionId, uri: `/subscriptions/${subscription.subscriptionId}`, @@ -186,41 +155,9 @@ export default class ResourcePickerData extends DataSourceWithBackend< subscriptionId: string, type: ResourcePickerQueryType ): Promise { - // We can use subscription ID for the filtering here as they're unique - // The logic of this query is: - // Retrieve _all_ resources a user/app registration/identity has access to - // Filter by the namespaces that support metrics - // Filter to resources contained within the subscription - // Conduct a left-outer join on the resourcecontainers table to allow us to get the case-sensitive resource group name - // Return the count of resources in a group, the URI, and name of the group in ascending order - const query = ` - resources - ${await this.filterByType(type)} - | where subscriptionId == '${subscriptionId}' - | extend resourceGroupURI = strcat("/subscriptions/", subscriptionId, "/resourcegroups/", resourceGroup) - | join kind=leftouter (resourcecontainers - | where type =~ 'microsoft.resources/subscriptions/resourcegroups' - | project resourceGroupName=name, resourceGroupURI=tolower(id)) on resourceGroupURI - | project resourceGroupName=iff(resourceGroupName != "", resourceGroupName, resourceGroup), resourceGroupURI - | summarize count() by resourceGroupName, resourceGroupURI - | order by tolower(resourceGroupName) asc `; + const filter = await this.filterByType(type); - let resourceGroups: RawAzureResourceGroupItem[] = []; - let allFetched = false; - let $skipToken = undefined; - while (!allFetched) { - // The response may include several pages - let options: Partial = {}; - if ($skipToken) { - options = { - $skipToken, - }; - } - const resourceResponse = await this.makeResourceGraphRequest(query, 1, options); - resourceGroups = resourceGroups.concat(resourceResponse.data); - $skipToken = resourceResponse.$skipToken; - allFetched = !$skipToken; - } + const resourceGroups = await this.azureResourceGraphDatasource.getResourceGroups(subscriptionId, filter); return resourceGroups.map((r) => { const parsedUri = parseResourceURI(r.resourceGroupURI); @@ -238,50 +175,20 @@ export default class ResourcePickerData extends DataSourceWithBackend< }); } - async getResourcesForResourceGroup( - resourceGroupUri: string, - type: ResourcePickerQueryType - ): Promise { - // We use resource group URI for the filtering here because resource group names are not unique across subscriptions - // We also add a slash at the end of the resource group URI to ensure we do not pull resources from a resource group - // that has a similar naming prefix e.g. resourceGroup1 and resourceGroup10 - const query = ` - resources - | where id hasprefix "${resourceGroupUri}/" - ${await this.filterByType(type)} - | order by tolower(name) asc`; + // Refactor this one out at a later date + async getResourcesForResourceGroup(uri: string, type: ResourcePickerQueryType): Promise { + const resources = await this.azureResourceGraphDatasource.getResourceNames({ uri }, await this.filterByType(type)); - let resources: RawAzureResourceItem[] = []; - let allFetched = false; - let $skipToken = undefined; - while (!allFetched) { - // The response may include several pages - let options: Partial = {}; - if ($skipToken) { - options = { - $skipToken, - }; - } - const resourceResponse = await this.makeResourceGraphRequest(query, 1, options); - resources = resources.concat(resourceResponse.data); - $skipToken = resourceResponse.$skipToken; - allFetched = !$skipToken; - } - - return resources.map((item) => { - const parsedUri = parseResourceURI(item.id); - if (!parsedUri || !parsedUri.resourceName) { - throw new Error('unable to fetch resource details'); - } + return resources.map((resource) => { return { - name: item.name, - id: parsedUri.resourceName, - uri: item.id, - resourceGroupName: item.resourceGroup, + name: resource.name, + id: resource.name, + uri: resource.id, + resourceGroupName: resource.resourceGroup, type: ResourceRowType.Resource, - typeLabel: resourceTypeDisplayNames[item.type] || item.type, - locationDisplayName: item.location, - location: item.location, + typeLabel: resourceTypeDisplayNames[resource.type] || resource.type, + locationDisplayName: resource.location, + location: resource.location, }; }); } @@ -321,7 +228,7 @@ export default class ResourcePickerData extends DataSourceWithBackend< | project subscriptionName, resourceGroupName, resourceName `; - const { data: response } = await this.makeResourceGraphRequest(query); + const response = await this.azureResourceGraphDatasource.pagedResourceGraphRequest(query); if (!response.length) { throw new Error('unable to fetch resource details'); @@ -339,7 +246,7 @@ export default class ResourcePickerData extends DataSourceWithBackend< } async getResourceURIFromWorkspace(workspace: string) { - const { data: response } = await this.makeResourceGraphRequest(` + const response = await this.azureResourceGraphDatasource.pagedResourceGraphRequest(` resources | where properties['customerId'] == "${workspace}" | project id @@ -352,28 +259,6 @@ export default class ResourcePickerData extends DataSourceWithBackend< return response[0].id; } - async makeResourceGraphRequest( - query: string, - maxRetries = 1, - reqOptions?: Partial - ): Promise> { - try { - return await this.postResource(this.resourcePath + RESOURCE_GRAPH_URL, { - query: query, - options: { - resultFormat: 'objectArray', - ...reqOptions, - }, - }); - } catch (error) { - if (maxRetries > 0) { - return this.makeResourceGraphRequest(query, maxRetries - 1); - } - - throw error; - } - } - private filterByType = async (t: ResourcePickerQueryType) => { if (this.supportedMetricNamespaces === '' && t !== 'logs') { await this.fetchAllNamespaces(); diff --git a/public/app/plugins/datasource/azuremonitor/types/types.ts b/public/app/plugins/datasource/azuremonitor/types/types.ts index 73c99db79ab..a1551965bda 100644 --- a/public/app/plugins/datasource/azuremonitor/types/types.ts +++ b/public/app/plugins/datasource/azuremonitor/types/types.ts @@ -145,11 +145,14 @@ export interface AzureResourceSummaryItem { export interface RawAzureSubscriptionItem { subscriptionName: string; subscriptionId: string; + subscriptionURI: string; + count: number; } export interface RawAzureResourceGroupItem { resourceGroupURI: string; resourceGroupName: string; + count: number; } export interface RawAzureResourceItem { @@ -226,10 +229,11 @@ export interface LegacyAzureGetMetricMetadataQuery { } export interface AzureGetResourceNamesQuery { - subscriptionId: string; + subscriptionId?: string; resourceGroup?: string; metricNamespace?: string; region?: string; + uri?: string; } export interface AzureMonitorLocations { diff --git a/public/app/plugins/datasource/azuremonitor/variables.test.ts b/public/app/plugins/datasource/azuremonitor/variables.test.ts index be9a0ae2191..84d4babcaf0 100644 --- a/public/app/plugins/datasource/azuremonitor/variables.test.ts +++ b/public/app/plugins/datasource/azuremonitor/variables.test.ts @@ -91,7 +91,7 @@ describe('VariableSupport', () => { }); it('can fetch resourceNames with a subscriptionId', async () => { - const expectedResults = ['test']; + const expectedResults = [{ name: 'test' }]; const variableSupport = new VariableSupport( createMockDatasource({ getResourceNames: jest.fn().mockResolvedValueOnce(expectedResults), @@ -113,7 +113,7 @@ describe('VariableSupport', () => { ], } as DataQueryRequest; const result = await lastValueFrom(variableSupport.query(mockRequest)); - expect(result.data[0].fields[0].values).toEqual(expectedResults); + expect(result.data[0].fields[0].values).toEqual([expectedResults[0].name]); }); it('can fetch a metricNamespace with a subscriptionId', async () => { @@ -448,7 +448,7 @@ describe('VariableSupport', () => { }); it('can fetch resource names', async () => { - const expectedResults = ['test']; + const expectedResults = [{ name: 'test' }]; const variableSupport = new VariableSupport( createMockDatasource({ getResourceNames: jest.fn().mockResolvedValueOnce(expectedResults), @@ -464,7 +464,7 @@ describe('VariableSupport', () => { ], } as DataQueryRequest; const result = await lastValueFrom(variableSupport.query(mockRequest)); - expect(result.data[0].fields[0].values).toEqual(expectedResults); + expect(result.data[0].fields[0].values).toEqual([expectedResults[0].name]); }); it('returns no data if calling resourceNames but the subscription is a template variable with no value', async () => { diff --git a/public/app/plugins/datasource/azuremonitor/variables.ts b/public/app/plugins/datasource/azuremonitor/variables.ts index 49486e077fa..9780c606c37 100644 --- a/public/app/plugins/datasource/azuremonitor/variables.ts +++ b/public/app/plugins/datasource/azuremonitor/variables.ts @@ -1,3 +1,4 @@ +import { startsWith } from 'lodash'; import { from, lastValueFrom, Observable } from 'rxjs'; import { @@ -13,10 +14,26 @@ import UrlBuilder from './azure_monitor/url_builder'; import VariableEditor from './components/VariableEditor/VariableEditor'; import DataSource from './datasource'; import { migrateQuery } from './grafanaTemplateVariableFns'; -import { AzureMonitorQuery, AzureQueryType } from './types'; +import { AzureMonitorQuery, AzureQueryType, RawAzureResourceItem } from './types'; import { GrafanaTemplateVariableQuery } from './types/templateVariables'; import messageFromError from './utils/messageFromError'; +export function parseResourceNamesAsTemplateVariable(resources: RawAzureResourceItem[], metricNamespace?: string) { + return resources.map((r) => { + if (startsWith(metricNamespace?.toLowerCase(), 'microsoft.storage/storageaccounts/')) { + return { + text: r.name + '/default', + value: r.name + '/default', + }; + } + + return { + text: r.name, + value: r.name, + }; + }); +} + export class VariableSupport extends CustomVariableSupport { constructor( private readonly datasource: DataSource, @@ -67,14 +84,14 @@ export class VariableSupport extends CustomVariableSupport { + if (rgs.length > 0) { + return rgs.map((rg) => ({ text: rg.resourceGroupName, value: rg.resourceGroupName })); + } + + return []; + }); } if (query.kind === 'ResourceNamesQuery') { - return this.datasource.getResourceNames( - this.replaceVariable(query.subscription), - this.replaceVariable(query.resourceGroup), - this.replaceVariable(query.metricNamespace) - ); + return this.datasource + .getResourceNames( + this.replaceVariable(query.subscription), + this.replaceVariable(query.resourceGroup), + this.replaceVariable(query.metricNamespace) + ) + .then((resources) => { + if (resources.length > 0) { + return parseResourceNamesAsTemplateVariable(resources, query.metricNamespace); + } + return []; + }); } if (query.kind === 'MetricNamespaceQuery') {