diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index cc9297ded83..57d9a1c2f48 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -104,6 +104,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `azureMonitorLogsBuilderEditor` | Enables the logs builder mode for the Azure Monitor data source | | `localeFormatPreference` | Specifies the locale so the correct format for numbers and dates can be shown | | `logsPanelControls` | Enables a control component for the logs panel in Explore | +| `azureResourcePickerUpdates` | Enables the updated Azure Monitor resource picker | ## Development feature toggles diff --git a/e2e-playwright/cloud-plugins-suite/azure-monitor.spec.ts b/e2e-playwright/cloud-plugins-suite/azure-monitor.spec.ts index 4e6ec84b169..b9dd1e520d3 100644 --- a/e2e-playwright/cloud-plugins-suite/azure-monitor.spec.ts +++ b/e2e-playwright/cloud-plugins-suite/azure-monitor.spec.ts @@ -333,7 +333,7 @@ test.describe( .getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels('region')) .locator('..') .locator('input'); - await regionVariable.fill('uk south'); + await regionVariable.fill('uk west'); await regionVariable.press('ArrowDown'); await regionVariable.press('Enter'); diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 5fb4c6828e1..729b3e638ad 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1116,4 +1116,9 @@ export interface FeatureToggles { * @default false */ graphiteBackendMode?: boolean; + /** + * Enables the updated Azure Monitor resource picker + * @default false + */ + azureResourcePickerUpdates?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 7cbea353e39..b06e407e36b 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1938,6 +1938,14 @@ var ( Owner: grafanaPartnerPluginsSquad, Expression: "false", }, + { + Name: "azureResourcePickerUpdates", + Description: "Enables the updated Azure Monitor resource picker", + Stage: FeatureStagePublicPreview, + FrontendOnly: true, + Owner: grafanaPartnerPluginsSquad, + Expression: "false", + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 04f51e8bfa0..b60869854be 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -249,3 +249,4 @@ unifiedStorageSearchAfterWriteExperimentalAPI,experimental,@grafana/search-and-s teamFolders,experimental,@grafana/grafana-search-navigate-organise,false,false,false alertingTriage,experimental,@grafana/alerting-squad,false,false,true graphiteBackendMode,privatePreview,@grafana/partner-datasources,false,false,false +azureResourcePickerUpdates,preview,@grafana/partner-datasources,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index a3c76750d63..bf75a8204af 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -1006,4 +1006,8 @@ const ( // FlagGraphiteBackendMode // Enables the Graphite data source full backend mode FlagGraphiteBackendMode = "graphiteBackendMode" + + // FlagAzureResourcePickerUpdates + // Enables the updated Azure Monitor resource picker + FlagAzureResourcePickerUpdates = "azureResourcePickerUpdates" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 739a60e2e98..2839d3fbc1b 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -718,6 +718,20 @@ "expression": "true" } }, + { + "metadata": { + "name": "azureResourcePickerUpdates", + "resourceVersion": "1754910058337", + "creationTimestamp": "2025-08-11T11:00:58Z" + }, + "spec": { + "description": "Enables the updated Azure Monitor resource picker", + "stage": "preview", + "codeowner": "@grafana/partner-datasources", + "frontend": true, + "expression": "false" + } + }, { "metadata": { "name": "cachingOptimizeSerializationMemoryUsage", diff --git a/public/app/plugins/datasource/azuremonitor/azureMetadata/resourceTypes.ts b/public/app/plugins/datasource/azuremonitor/azureMetadata/resourceTypes.ts index dd3e32b88b4..d8cf9c9fca1 100644 --- a/public/app/plugins/datasource/azuremonitor/azureMetadata/resourceTypes.ts +++ b/public/app/plugins/datasource/azuremonitor/azureMetadata/resourceTypes.ts @@ -57,7 +57,7 @@ export const resourceTypeDisplayNames: { [k: string]: string } = { 'microsoft.cache/redis': 'Azure Cache for Redis', 'microsoft.cache/redisenterprise': 'Redis Enterprise', 'microsoft.cdn/cdnwebapplicationfirewallpolicies': 'Content Delivery Network WAF policies', - 'microsoft.cdn/profiles': '(front doors standard/premium Preview)', + 'microsoft.cdn/profiles': '(Front Doors Standard/Premium Preview)', 'microsoft.cdn/profiles/afdendpoints': 'Endpoints', 'microsoft.cdn/profiles/endpoints': 'Endpoints', 'microsoft.certificateregistration/certificateorders': 'App Service Certificates', diff --git a/public/app/plugins/datasource/azuremonitor/azure_resource_graph/azure_resource_graph_datasource.test.ts b/public/app/plugins/datasource/azuremonitor/azure_resource_graph/azure_resource_graph_datasource.test.ts index c6c920f062d..8fbb4cbe3e6 100644 --- a/public/app/plugins/datasource/azuremonitor/azure_resource_graph/azure_resource_graph_datasource.test.ts +++ b/public/app/plugins/datasource/azuremonitor/azure_resource_graph/azure_resource_graph_datasource.test.ts @@ -1,4 +1,4 @@ -import { set, get } from 'lodash'; +import { get, set } from 'lodash'; import { CustomVariableModel } from '@grafana/data'; @@ -12,7 +12,7 @@ import { AzureQueryType } from '../types/query'; import AzureResourceGraphDatasource from './azure_resource_graph_datasource'; let getTempVars = () => [] as CustomVariableModel[]; -let replace = () => ''; +let replace = (value?: string) => value || ''; jest.mock('@grafana/runtime', () => { return { @@ -176,4 +176,397 @@ describe('AzureResourceGraphDatasource', () => { expect(postBody.options.$skipToken).toEqual('skipToken'); }); }); + + describe('getSubscriptions', () => { + let datasource: AzureResourceGraphDatasource; + let pagedResourceGraphRequest: jest.SpyInstance; + + beforeEach(() => { + const instanceSettings = createMockInstanceSetttings(); + datasource = new AzureResourceGraphDatasource(instanceSettings); + pagedResourceGraphRequest = jest.spyOn(datasource, 'pagedResourceGraphRequest'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should return subscriptions without filters', async () => { + const mockSubscriptions = [ + { + subscriptionId: '1', + subscriptionName: 'Primary Subscription', + subscriptionURI: '/subscriptions/1', + count: 5, + }, + { + subscriptionId: '2', + subscriptionName: 'Dev Subscription', + subscriptionURI: '/subscriptions/2', + count: 3, + }, + ]; + + pagedResourceGraphRequest.mockResolvedValue(mockSubscriptions); + + const result = await datasource.getSubscriptions(); + + expect(result).toEqual(mockSubscriptions); + expect(pagedResourceGraphRequest).toHaveBeenCalledWith(expect.stringContaining('resources'), 1); + expect(pagedResourceGraphRequest).toHaveBeenCalledWith( + expect.not.stringContaining('| where subscriptionId in'), + 1 + ); + expect(pagedResourceGraphRequest).toHaveBeenCalledWith(expect.not.stringContaining('| where type in'), 1); + expect(pagedResourceGraphRequest).toHaveBeenCalledWith(expect.not.stringContaining('| where location in'), 1); + }); + + it('should generate correct query structure', async () => { + pagedResourceGraphRequest.mockResolvedValue([]); + + await datasource.getSubscriptions(); + + const query = pagedResourceGraphRequest.mock.calls[0][0]; + + expect(query).toContain('resources'); + expect(query).toContain('join kind=inner'); + expect(query).toContain('ResourceContainers'); + expect(query).toContain("type == 'microsoft.resources/subscriptions'"); + expect(query).toContain('project subscriptionName=name, subscriptionURI=id, subscriptionId'); + expect(query).toContain('summarize count=count() by subscriptionName, subscriptionURI, subscriptionId'); + expect(query).toContain('order by subscriptionName desc'); + }); + + it('should apply filters when provided', async () => { + const filters = { + subscriptions: ['sub1', 'sub2'], + types: ['microsoft.compute/virtualmachines', 'microsoft.storage/storageaccounts'], + locations: ['eastus', 'westus'], + }; + + pagedResourceGraphRequest.mockResolvedValue([]); + + await datasource.getSubscriptions(filters); + + const query = pagedResourceGraphRequest.mock.calls[0][0]; + + expect(query).toContain('| where subscriptionId in ("sub1","sub2")'); + expect(query).toContain( + '| where type in ("microsoft.compute/virtualmachines","microsoft.storage/storageaccounts")' + ); + expect(query).toContain('| where location in ("eastus","westus")'); + }); + + it('should apply partial filters', async () => { + const filters = { + subscriptions: ['sub1'], + types: [], + locations: ['eastus'], + }; + + pagedResourceGraphRequest.mockResolvedValue([]); + + await datasource.getSubscriptions(filters); + + const query = pagedResourceGraphRequest.mock.calls[0][0]; + + expect(query).toContain('| where subscriptionId in ("sub1")'); + expect(query).not.toContain('| where type in'); + expect(query).toContain('| where location in ("eastus")'); + }); + + it('should handle empty filters gracefully', async () => { + const filters = { + subscriptions: [], + types: [], + locations: [], + }; + + pagedResourceGraphRequest.mockResolvedValue([]); + + await datasource.getSubscriptions(filters); + + const query = pagedResourceGraphRequest.mock.calls[0][0]; + + expect(query).not.toContain('| where subscriptionId in'); + expect(query).not.toContain('| where type in'); + expect(query).not.toContain('| where location in'); + }); + + it('should return empty array when no subscriptions found', async () => { + pagedResourceGraphRequest.mockResolvedValue([]); + + const result = await datasource.getSubscriptions(); + + expect(result).toEqual([]); + }); + + it('should lowercase filter values', async () => { + const filters = { + subscriptions: ['SUB1', 'Sub2'], + types: ['Microsoft.Compute/VirtualMachines'], + locations: ['EastUS'], + }; + + pagedResourceGraphRequest.mockResolvedValue([]); + + await datasource.getSubscriptions(filters); + + const query = pagedResourceGraphRequest.mock.calls[0][0]; + + expect(query).toContain('"sub1","sub2"'); + expect(query).toContain('"microsoft.compute/virtualmachines"'); + expect(query).toContain('"eastus"'); + }); + }); + + describe('getResourceGroups', () => { + let datasource: AzureResourceGraphDatasource; + let pagedResourceGraphRequest: jest.SpyInstance; + + beforeEach(() => { + const instanceSettings = createMockInstanceSetttings(); + datasource = new AzureResourceGraphDatasource(instanceSettings); + pagedResourceGraphRequest = jest.spyOn(datasource, 'pagedResourceGraphRequest'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should return resource groups without filters', async () => { + const mockResourceGroups = [ + { + resourceGroup: 'rg1', + resourceGroupName: 'Resource Group 1', + resourceGroupURI: '/subscriptions/1/resourceGroups/rg1', + count: 2, + }, + { + resourceGroup: 'rg2', + resourceGroupName: 'Resource Group 2', + resourceGroupURI: '/subscriptions/1/resourceGroups/rg2', + count: 1, + }, + ]; + pagedResourceGraphRequest.mockResolvedValue(mockResourceGroups); + const result = await datasource.getResourceGroups('1'); + expect(result).toEqual(mockResourceGroups); + expect(pagedResourceGraphRequest).toHaveBeenCalledWith(expect.not.stringContaining('| where type in')); + expect(pagedResourceGraphRequest).toHaveBeenCalledWith(expect.not.stringContaining('| where location in')); + }); + + it('should generate correct query structure', async () => { + pagedResourceGraphRequest.mockResolvedValue([]); + await datasource.getResourceGroups('1'); + const query = pagedResourceGraphRequest.mock.calls[0][0]; + expect(query).toContain('resources'); + expect(query).toContain("| where subscriptionId == '1'"); + expect(query).toContain( + '| extend resourceGroupURI = strcat(\"/subscriptions/\", subscriptionId, \"/resourcegroups/\", resourceGroup)' + ); + expect(query).toContain('join kind=leftouter'); + expect(query).toContain('resourcecontainers'); + expect(query).toContain("| where type =~ 'microsoft.resources/subscriptions/resourcegroups'"); + expect(query).toContain( + '| project resourceGroupName=iff(resourceGroupName != \"\", resourceGroupName, resourceGroup), resourceGroupURI' + ); + expect(query).toContain('summarize count=count() by resourceGroupName, resourceGroupURI'); + expect(query).toContain('| order by tolower(resourceGroupName) asc'); + }); + + it('should apply filters when provided', async () => { + const filters = { + subscriptions: [], + types: ['microsoft.compute/virtualmachines', 'microsoft.storage/storageaccounts'], + locations: ['eastus', 'westus'], + }; + pagedResourceGraphRequest.mockResolvedValue([]); + await datasource.getResourceGroups('1', undefined, filters); + const query = pagedResourceGraphRequest.mock.calls[0][0]; + expect(query).toContain( + '| where type in ("microsoft.compute/virtualmachines","microsoft.storage/storageaccounts")' + ); + expect(query).toContain('| where location in ("eastus","westus")'); + }); + + it('should apply partial filters', async () => { + const filters = { + subscriptions: [], + types: [], + locations: ['eastus'], + }; + pagedResourceGraphRequest.mockResolvedValue([]); + await datasource.getResourceGroups('1', undefined, filters); + const query = pagedResourceGraphRequest.mock.calls[0][0]; + expect(query).not.toContain('| where type in'); + expect(query).toContain('| where location in ("eastus")'); + }); + + it('should handle empty filters gracefully', async () => { + const filters = { + subscriptions: [], + types: [], + locations: [], + }; + pagedResourceGraphRequest.mockResolvedValue([]); + await datasource.getResourceGroups('1', undefined, filters); + const query = pagedResourceGraphRequest.mock.calls[0][0]; + expect(query).not.toContain('| where type in'); + expect(query).not.toContain('| where location in'); + }); + + it('should return empty array when no resource groups found', async () => { + pagedResourceGraphRequest.mockResolvedValue([]); + const result = await datasource.getResourceGroups('1'); + expect(result).toEqual([]); + }); + + it('should lowercase filter values', async () => { + const filters = { + subscriptions: [], + types: ['Microsoft.Compute/VirtualMachines'], + locations: ['EastUS'], + }; + pagedResourceGraphRequest.mockResolvedValue([]); + await datasource.getResourceGroups('1', undefined, filters); + const query = pagedResourceGraphRequest.mock.calls[0][0]; + expect(query).toContain('"microsoft.compute/virtualmachines"'); + expect(query).toContain('"eastus"'); + }); + + it('will ignore subscription filters', async () => { + const filters = { + subscriptions: ['1234'], + types: [], + locations: [], + }; + pagedResourceGraphRequest.mockResolvedValue([]); + await datasource.getResourceGroups('1', undefined, filters); + const query = pagedResourceGraphRequest.mock.calls[0][0]; + expect(query).not.toContain('| where subscriptionId in (1234)'); + expect(query).toContain("| where subscriptionId == '1'"); + }); + }); + + describe('getResourceNames', () => { + let datasource: AzureResourceGraphDatasource; + let pagedResourceGraphRequest: jest.SpyInstance; + + beforeEach(() => { + const instanceSettings = createMockInstanceSetttings(); + datasource = new AzureResourceGraphDatasource(instanceSettings); + pagedResourceGraphRequest = jest.spyOn(datasource, 'pagedResourceGraphRequest'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should return resource names without filters', async () => { + const mockResources = [ + { + id: '/subscriptions/1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm1', + name: 'vm1', + type: 'microsoft.compute/virtualmachines', + location: 'eastus', + }, + { + id: '/subscriptions/1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm2', + name: 'vm2', + type: 'microsoft.compute/virtualmachines', + location: 'westus', + }, + ]; + pagedResourceGraphRequest.mockResolvedValue(mockResources); + const query = { + subscriptionId: '1', + resourceGroup: 'rg1', + }; + const result = await datasource.getResourceNames(query); + expect(result).toEqual(mockResources); + expect(pagedResourceGraphRequest).toHaveBeenCalledWith(expect.stringContaining('resources')); + }); + + it('should generate correct query structure', async () => { + pagedResourceGraphRequest.mockResolvedValue([]); + const query = { + subscriptionId: '1', + resourceGroup: 'rg1', + }; + + await datasource.getResourceNames(query); + const builtQuery = pagedResourceGraphRequest.mock.calls[0][0]; + expect(builtQuery).toContain('resources'); + expect(builtQuery).toContain('| where id hasprefix "/subscriptions/1/resourceGroups/rg1/"'); + expect(builtQuery).toContain('| order by tolower(name) asc'); + }); + + it('should apply metric namespace and region filters based on query parameters', async () => { + pagedResourceGraphRequest.mockResolvedValue([]); + const query = { + subscriptionId: '1', + resourceGroup: 'rg1', + metricNamespace: 'Microsoft.Compute/virtualMachines', + region: 'eastus', + }; + await datasource.getResourceNames(query); + const builtQuery = pagedResourceGraphRequest.mock.calls[0][0]; + expect(builtQuery).toContain("type == 'microsoft.compute/virtualmachines'"); + expect(builtQuery).toContain("location == 'eastus'"); + }); + + it('should apply resourceFilters if provided', async () => { + pagedResourceGraphRequest.mockResolvedValue([]); + const query = { + subscriptionId: '1', + resourceGroup: 'rg1', + }; + const resourceFilters = { + subscriptions: [], + types: ['microsoft.storage/storageaccounts'], + locations: ['westeurope'], + }; + await datasource.getResourceNames(query, undefined, resourceFilters); + const builtQuery = pagedResourceGraphRequest.mock.calls[0][0]; + expect(builtQuery).toContain('| where type in ("microsoft.storage/storageaccounts")'); + expect(builtQuery).toContain('| where location in ("westeurope")'); + }); + + it('should handle empty resourceFilters gracefully', async () => { + pagedResourceGraphRequest.mockResolvedValue([]); + const query = { + subscriptionId: '1', + resourceGroup: 'rg1', + }; + const resourceFilters = { subscriptions: [], types: [], locations: [] }; + await datasource.getResourceNames(query, undefined, resourceFilters); + const builtQuery = pagedResourceGraphRequest.mock.calls[0][0]; + expect(builtQuery).not.toContain('| where type in'); + expect(builtQuery).not.toContain('| where location in'); + }); + + it('should return empty array when no resources found', async () => { + pagedResourceGraphRequest.mockResolvedValue([]); + const query = { + subscriptionId: '1', + resourceGroup: 'rg1', + }; + const result = await datasource.getResourceNames(query); + expect(result).toEqual([]); + }); + + it('should lowercase metricNamespace', async () => { + pagedResourceGraphRequest.mockResolvedValue([]); + const query = { + subscriptionId: '1', + resourceGroup: 'rg1', + metricNamespace: 'Microsoft.Compute/VirtualMachines', + region: 'EastUS', + }; + await datasource.getResourceNames(query); + const builtQuery = pagedResourceGraphRequest.mock.calls[0][0]; + expect(builtQuery).toContain("type == 'microsoft.compute/virtualmachines'"); + }); + }); }); 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 67a6532deeb..c7b3877f429 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 @@ -5,6 +5,7 @@ import { DataSourceWithBackend, getTemplateSrv, TemplateSrv } from '@grafana/run import { resourceTypes } from '../azureMetadata/resourceTypes'; import { ARGScope } from '../dataquery.gen'; +import { createFilter } from '../resourcePicker/resourcePickerData'; import { AzureMonitorQuery, AzureQueryType } from '../types/query'; import { AzureGetResourceNamesQuery, @@ -15,6 +16,7 @@ import { RawAzureResourceGroupItem, RawAzureResourceItem, RawAzureSubscriptionItem, + ResourceGraphFilters, } from '../types/types'; import { interpolateVariable, replaceTemplateVariables, routeNames } from '../utils/common'; @@ -107,7 +109,8 @@ export default class AzureResourceGraphDatasource extends DataSourceWithBackend< } } - async getSubscriptions() { + async getSubscriptions(filters?: ResourceGraphFilters) { + const filtersQuery = filters ? createFilter(filters) : ''; const query = ` resources | join kind=inner ( @@ -115,6 +118,7 @@ export default class AzureResourceGraphDatasource extends DataSourceWithBackend< | where type == 'microsoft.resources/subscriptions' | project subscriptionName=name, subscriptionURI=id, subscriptionId ) on subscriptionId + ${filtersQuery} | summarize count=count() by subscriptionName, subscriptionURI, subscriptionId | order by subscriptionName desc `; @@ -124,7 +128,9 @@ export default class AzureResourceGraphDatasource extends DataSourceWithBackend< return subscriptions; } - async getResourceGroups(subscriptionId: string, metricNamespacesFilter?: string) { + async getResourceGroups(subscriptionId: string, metricNamespacesFilter?: string, filters?: ResourceGraphFilters) { + // When retrieving resource groups we only need to filter by the input subscription ID + const filtersQuery = filters ? createFilter({ ...filters, subscriptions: [subscriptionId] }) : ''; // 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 @@ -135,6 +141,7 @@ export default class AzureResourceGraphDatasource extends DataSourceWithBackend< const query = `resources ${metricNamespacesFilter || ''} | where subscriptionId == '${subscriptionId}' + ${filtersQuery} | extend resourceGroupURI = strcat("/subscriptions/", subscriptionId, "/resourcegroups/", resourceGroup) | join kind=leftouter (resourcecontainers | where type =~ 'microsoft.resources/subscriptions/resourcegroups' @@ -148,7 +155,11 @@ export default class AzureResourceGraphDatasource extends DataSourceWithBackend< return resourceGroups; } - async getResourceNames(query: AzureGetResourceNamesQuery, metricNamespacesFilter?: string) { + async getResourceNames( + query: AzureGetResourceNamesQuery, + metricNamespacesFilter?: string, + resourceFilters?: ResourceGraphFilters + ) { const promises = replaceTemplateVariables(this.templateSrv, query).map( async ({ metricNamespace, subscriptionId, resourceGroup, region, uri }) => { const validMetricNamespace = startsWith(metricNamespace?.toLowerCase(), 'microsoft.storage/storageaccounts/') @@ -175,11 +186,12 @@ export default class AzureResourceGraphDatasource extends DataSourceWithBackend< filters.push(`location == '${region}'`); } + const filtersQuery = resourceFilters ? createFilter(resourceFilters) : ''; // 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}/" + | where id hasprefix "${prefix}/"${filtersQuery !== '' ? `\n${filtersQuery}` : ''} ${filters.length > 0 ? `| where ${filters.join(' and ')}` : ''} | order by tolower(name) asc`; diff --git a/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/LogsQueryEditor.test.tsx b/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/LogsQueryEditor.test.tsx index 7553942d04d..5a5734b670b 100644 --- a/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/LogsQueryEditor.test.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/LogsQueryEditor.test.tsx @@ -457,8 +457,8 @@ describe('LogsQueryEditor', () => { const resourcePickerButton = await screen.findByRole('button', { name: 'la-workspace' }); await userEvent.click(resourcePickerButton); - const checkbox = await screen.findByLabelText('la-workspace'); - expect(checkbox).toBeChecked(); + const checkbox = await screen.queryAllByLabelText('la-workspace'); + expect(checkbox[0]).toBeChecked(); expect(await screen.findByLabelText('la-workspace-1')).toBeDisabled(); expect( diff --git a/public/app/plugins/datasource/azuremonitor/components/ResourceField/ResourceField.tsx b/public/app/plugins/datasource/azuremonitor/components/ResourceField/ResourceField.tsx index ddf88888f07..80849e09619 100644 --- a/public/app/plugins/datasource/azuremonitor/components/ResourceField/ResourceField.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/ResourceField/ResourceField.tsx @@ -82,6 +82,7 @@ const ResourceField = ({ disableRow={disableRow} renderAdvanced={renderAdvanced} selectionNotice={selectionNotice} + datasource={datasource} /> ({ return val; }, }), + config: { + featureToggles: { + azureResourcePickerUpdates: true, + }, + }, })); const noResourceURI = ''; @@ -61,11 +71,19 @@ function createMockResourcePickerData( } const queryType: ResourcePickerQueryType = 'logs'; - +const resourcePickerData = createMockResourcePickerData(); const defaultProps = { templateVariables: [], resources: [], - resourcePickerData: createMockResourcePickerData(), + resourcePickerData, + datasource: createMockDatasource({ + resourcePickerData, + getSubscriptions: jest + .fn() + .mockResolvedValue(createMockSubscriptions().map((sub) => ({ label: sub.name, value: sub.id }))), + getLocations: jest.fn().mockResolvedValue(createMockLocations()), + getMetricNamespaces: jest.fn().mockResolvedValue(createMockMetricsNamespaces()), + }), onCancel: noop, onApply: noop, selectableEntryTypes: [ @@ -82,6 +100,7 @@ const defaultProps = { describe('AzureMonitor ResourcePicker', () => { beforeEach(() => { window.HTMLElement.prototype.scrollIntoView = jest.fn(); + config.featureToggles.azureResourcePickerUpdates = false; }); it('should pre-load subscriptions when there is no existing selection', async () => { render(); @@ -388,4 +407,447 @@ describe('AzureMonitor ResourcePicker', () => { expect(checkboxes.length).toBe(0); }); }); + + describe('filters', () => { + beforeEach(() => { + config.featureToggles.azureResourcePickerUpdates = true; + }); + it('should not render filters if feature toggle disabled', async () => { + config.featureToggles.azureResourcePickerUpdates = false; + await act(async () => render()); + + expect( + screen.queryByTestId(selectors.components.queryEditor.resourcePicker.filters.subscription.input) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(selectors.components.queryEditor.resourcePicker.filters.type.input) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(selectors.components.queryEditor.resourcePicker.filters.location.input) + ).not.toBeInTheDocument(); + }); + + it('should render subscription filter and load subscription options', async () => { + await act(async () => render()); + + await waitFor(() => { + expect(defaultProps.datasource.getSubscriptions).toHaveBeenCalled(); + }); + + const subscriptionFilter = screen.getByTestId( + selectors.components.queryEditor.resourcePicker.filters.subscription.input + ); + expect(subscriptionFilter).toBeInTheDocument(); + }); + + it('should render resource type filter for metrics query type', async () => { + await act(async () => render()); + + await waitFor(() => { + expect(defaultProps.datasource.getMetricNamespaces).toHaveBeenCalled(); + }); + + const resourceTypeFilter = screen.getByTestId(selectors.components.queryEditor.resourcePicker.filters.type.input); + expect(resourceTypeFilter).toBeInTheDocument(); + }); + + it('should not render resource type filter for logs query type', async () => { + await act(async () => render()); + + const resourceTypeFilter = screen.queryByTestId( + selectors.components.queryEditor.resourcePicker.filters.type.input + ); + expect(resourceTypeFilter).not.toBeInTheDocument(); + }); + + it('should render location filter and load location options', async () => { + await act(async () => render()); + + await waitFor(() => { + expect(defaultProps.datasource.getLocations).toHaveBeenCalled(); + }); + + const locationFilter = screen.getByTestId(selectors.components.queryEditor.resourcePicker.filters.location.input); + expect(locationFilter).toBeInTheDocument(); + }); + + // Combobox tests seem to be quite finnicky when it comes to selecting options + // I've had to add multiple {ArrowDown} key-presses as sometimes the expected option isn't + // at the top of the list + it('should call fetchInitialRows when subscription filter changes', async () => { + const user = userEvent.setup(); + const mockFetchInitialRows = jest.spyOn(resourcePickerData, 'fetchInitialRows'); + + await act(async () => render()); + + const subscriptionFilter = await screen.getByTestId( + selectors.components.queryEditor.resourcePicker.filters.subscription.input + ); + await act(async () => { + await user.click(subscriptionFilter); + await user.type(subscriptionFilter, 'Primary Subscription {ArrowDown}{ArrowDown}{ArrowDown}{Enter}'); + }); + + await waitFor(() => { + expect(mockFetchInitialRows).toHaveBeenCalledWith( + 'logs', + undefined, + expect.objectContaining({ + subscriptions: ['def-456'], + types: [], + locations: [], + }) + ); + }); + }); + + it('should call fetchInitialRows when location filter changes', async () => { + const user = userEvent.setup(); + const mockFetchInitialRows = jest.spyOn(resourcePickerData, 'fetchInitialRows'); + + await act(async () => render()); + + const locationFilter = await screen.getByTestId( + selectors.components.queryEditor.resourcePicker.filters.location.input + ); + await act(async () => { + await user.click(locationFilter); + }); + await user.type(locationFilter, 'North Europe{ArrowDown}{Enter}'); + + await waitFor(() => { + expect(mockFetchInitialRows).toHaveBeenCalledWith( + 'logs', + undefined, + expect.objectContaining({ + subscriptions: [], + types: [], + locations: ['northeurope'], + }) + ); + }); + }); + + it('should call fetchInitialRows when resource type filter changes for metrics', async () => { + const user = userEvent.setup(); + const mockFetchInitialRows = jest.spyOn(resourcePickerData, 'fetchInitialRows'); + + await act(async () => render()); + + const typeFilter = await screen.getByTestId(selectors.components.queryEditor.resourcePicker.filters.type.input); + await act(async () => { + await user.click(typeFilter); + }); + + await user.type(typeFilter, 'Kubernetes services {ArrowDown}{Enter}'); + await waitFor(() => { + expect(mockFetchInitialRows).toHaveBeenCalledWith( + 'metrics', + undefined, + expect.objectContaining({ + subscriptions: [], + types: ['microsoft.containerservice/managedclusters'], + locations: [], + }) + ); + }); + }); + }); + + describe('recent resources', () => { + beforeEach(() => { + config.featureToggles.azureResourcePickerUpdates = true; + window.localStorage.clear(); + }); + it('should not render tabbed view if feature toggle disabled', async () => { + config.featureToggles.azureResourcePickerUpdates = false; + await act(async () => render()); + + expect(screen.queryByTestId(e2eSelectors.components.Tab.title('Browse'))).not.toBeInTheDocument(); + expect(screen.queryByTestId(e2eSelectors.components.Tab.title('Recent'))).not.toBeInTheDocument(); + }); + + it('should render tabbed view', async () => { + await act(async () => render()); + + expect(screen.queryByTestId(e2eSelectors.components.Tab.title('Browse'))).toBeInTheDocument(); + expect(screen.queryByTestId(e2eSelectors.components.Tab.title('Recent'))).toBeInTheDocument(); + }); + + it('should render tabbed view with no recent resources', async () => { + await act(async () => render()); + + const recent = await screen.getByTestId(e2eSelectors.components.Tab.title('Recent')); + await userEvent.click(recent); + + expect(screen.getByText('No recent resources found')).toBeInTheDocument(); + }); + + it('should render tabbed view with recent resources', async () => { + const recentResources = [ + { + id: 'aks-agentpool', + name: 'aks-agentpool', + type: 'Resource', + uri: '/subscriptions/def-123/resourceGroups/main-rg/providers/Microsoft.Compute/virtualMachineScaleSets/aks-agentpool', + typeLabel: 'Virtual machine scale sets', + location: 'eastus2', + }, + { + id: 'aks-systempool', + name: 'aks-systempool', + type: 'Resource', + uri: '/subscriptions/def-123/resourceGroups/main-rg/providers/Microsoft.Compute/virtualMachineScaleSets/aks-systempool', + typeLabel: 'Virtual machine scale sets', + location: 'eastus2', + }, + { + name: 'grafanadb', + id: 'datasources-sqlserver/grafanadb', + uri: '/subscriptions/def-123/resourceGroups/main-rg/providers/Microsoft.Sql/servers/datasources-sqlserver/databases/grafanadb', + resourceGroupName: 'main-rg', + type: 'Resource', + typeLabel: 'SQL databases', + location: 'eastus2', + }, + ]; + window.localStorage.setItem(RECENT_RESOURCES_KEY(defaultProps.queryType), JSON.stringify(recentResources)); + await act(async () => render()); + + const recent = await screen.getByTestId(e2eSelectors.components.Tab.title('Recent')); + await userEvent.click(recent); + + expect(screen.getByText(recentResources[0].name)).toBeInTheDocument(); + expect(screen.getByText(recentResources[1].name)).toBeInTheDocument(); + expect(screen.getByText(recentResources[2].name)).toBeInTheDocument(); + }); + + it('should call onApply when recent resource is selected (metrics)', async () => { + const recentResources = [ + { + id: 'aks-agentpool', + name: 'aks-agentpool', + type: 'Resource', + uri: '/subscriptions/def-123/resourceGroups/main-rg/providers/Microsoft.Compute/virtualMachineScaleSets/aks-agentpool', + typeLabel: 'Virtual machine scale sets', + location: 'eastus2', + }, + { + id: 'aks-systempool', + name: 'aks-systempool', + type: 'Resource', + uri: '/subscriptions/def-123/resourceGroups/main-rg/providers/Microsoft.Compute/virtualMachineScaleSets/aks-systempool', + typeLabel: 'Virtual machine scale sets', + location: 'eastus2', + }, + { + name: 'grafanadb', + id: 'datasources-sqlserver/grafanadb', + uri: '/subscriptions/def-123/resourceGroups/main-rg/providers/Microsoft.Sql/servers/datasources-sqlserver/databases/grafanadb', + resourceGroupName: 'main-rg', + type: 'Resource', + typeLabel: 'SQL databases', + location: 'eastus2', + }, + ]; + const queryType = 'metrics'; + window.localStorage.setItem(RECENT_RESOURCES_KEY(queryType), JSON.stringify(recentResources)); + const onApply = jest.fn(); + await act(async () => render()); + + const recent = await screen.getByTestId(e2eSelectors.components.Tab.title('Recent')); + await userEvent.click(recent); + + const checkbox = await screen.findByLabelText(recentResources[0].name); + await userEvent.click(checkbox); + expect(checkbox).toBeChecked(); + const applyButton = screen.getByRole('button', { name: 'Apply' }); + await userEvent.click(applyButton); + + expect(onApply).toHaveBeenCalledTimes(1); + expect(onApply).toHaveBeenCalledWith([ + { + metricNamespace: 'Microsoft.Compute/virtualMachineScaleSets', + region: 'eastus2', + resourceGroup: 'main-rg', + resourceName: 'aks-agentpool', + subscription: 'def-123', + }, + ]); + }); + + it('should call onApply when multiple recent resources are selected (metrics)', async () => { + const recentResources = [ + { + id: 'aks-agentpool', + name: 'aks-agentpool', + type: 'Resource', + uri: '/subscriptions/def-123/resourceGroups/main-rg/providers/Microsoft.Compute/virtualMachineScaleSets/aks-agentpool', + typeLabel: 'Virtual machine scale sets', + location: 'eastus2', + }, + { + id: 'aks-systempool', + name: 'aks-systempool', + type: 'Resource', + uri: '/subscriptions/def-123/resourceGroups/main-rg/providers/Microsoft.Compute/virtualMachineScaleSets/aks-systempool', + typeLabel: 'Virtual machine scale sets', + location: 'eastus2', + }, + { + name: 'grafanadb', + id: 'datasources-sqlserver/grafanadb', + uri: '/subscriptions/def-123/resourceGroups/main-rg/providers/Microsoft.Sql/servers/datasources-sqlserver/databases/grafanadb', + resourceGroupName: 'main-rg', + type: 'Resource', + typeLabel: 'SQL databases', + location: 'eastus2', + }, + ]; + const queryType = 'metrics'; + window.localStorage.setItem(RECENT_RESOURCES_KEY(queryType), JSON.stringify(recentResources)); + const onApply = jest.fn(); + await act(async () => render()); + + const recent = await screen.getByTestId(e2eSelectors.components.Tab.title('Recent')); + await userEvent.click(recent); + + const checkbox = await screen.findByLabelText(recentResources[0].name); + await userEvent.click(checkbox); + expect(checkbox).toBeChecked(); + const checkbox2 = await screen.findByLabelText(recentResources[1].name); + await userEvent.click(checkbox2); + expect(checkbox2).toBeChecked(); + const applyButton = screen.getByRole('button', { name: 'Apply' }); + await userEvent.click(applyButton); + + expect(onApply).toHaveBeenCalledTimes(1); + expect(onApply).toHaveBeenCalledWith([ + { + metricNamespace: 'Microsoft.Compute/virtualMachineScaleSets', + region: 'eastus2', + resourceGroup: 'main-rg', + resourceName: 'aks-agentpool', + subscription: 'def-123', + }, + { + metricNamespace: 'Microsoft.Compute/virtualMachineScaleSets', + region: 'eastus2', + resourceGroup: 'main-rg', + resourceName: 'aks-systempool', + subscription: 'def-123', + }, + ]); + }); + + it('should not duplicate recent resources', async () => { + const recentResources = [ + { + id: 'aks-agentpool', + name: 'aks-agentpool', + type: 'Resource', + uri: '/subscriptions/def-123/resourceGroups/main-rg/providers/Microsoft.Compute/virtualMachineScaleSets/aks-agentpool', + typeLabel: 'Virtual machine scale sets', + location: 'eastus2', + }, + { + id: 'aks-systempool', + name: 'aks-systempool', + type: 'Resource', + uri: '/subscriptions/def-123/resourceGroups/main-rg/providers/Microsoft.Compute/virtualMachineScaleSets/aks-systempool', + typeLabel: 'Virtual machine scale sets', + location: 'eastus2', + }, + { + name: 'grafanadb', + id: 'datasources-sqlserver/grafanadb', + uri: '/subscriptions/def-123/resourceGroups/main-rg/providers/Microsoft.Sql/servers/datasources-sqlserver/databases/grafanadb', + resourceGroupName: 'main-rg', + type: 'Resource', + typeLabel: 'SQL databases', + location: 'eastus2', + }, + ]; + const queryType = 'metrics'; + window.localStorage.setItem(RECENT_RESOURCES_KEY(queryType), JSON.stringify(recentResources)); + const onApply = jest.fn(); + await act(async () => render()); + + const recent = await screen.getByTestId(e2eSelectors.components.Tab.title('Recent')); + await userEvent.click(recent); + + const checkbox = await screen.findByLabelText(recentResources[0].name); + await userEvent.click(checkbox); + expect(checkbox).toBeChecked(); + const applyButton = screen.getByRole('button', { name: 'Apply' }); + await userEvent.click(applyButton); + + expect(onApply).toHaveBeenCalledTimes(1); + expect(onApply).toHaveBeenCalledWith([ + { + metricNamespace: 'Microsoft.Compute/virtualMachineScaleSets', + region: 'eastus2', + resourceGroup: 'main-rg', + resourceName: 'aks-agentpool', + subscription: 'def-123', + }, + ]); + expect(window.localStorage.getItem(RECENT_RESOURCES_KEY(queryType))).not.toBeNull(); + const recentResourcesFromStorage = JSON.parse( + window.localStorage.getItem(RECENT_RESOURCES_KEY(queryType)) || '[]' + ); + expect(recentResourcesFromStorage.length).toBe(3); + }); + + it('should not exceed 30 recent resources', async () => { + const recentResources = []; + for (let i = 0; i < 30; i++) { + recentResources.push({ + id: `aks-agentpool-${i}`, + name: `aks-agentpool-${i}`, + type: 'Resource', + uri: `/subscriptions/def-123/resourceGroups/main-rg/providers/Microsoft.Compute/virtualMachineScaleSets/aks-agentpool-${i}`, + typeLabel: 'Virtual machine scale sets', + location: 'eastus2', + }); + } + const queryType = 'metrics'; + window.localStorage.setItem(RECENT_RESOURCES_KEY(queryType), JSON.stringify(recentResources)); + expect(JSON.parse(window.localStorage.getItem(RECENT_RESOURCES_KEY(queryType)) || '[]')).toHaveLength(30); + + const onApply = jest.fn(); + await act(async () => render()); + + 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(); + await userEvent.click(subscriptionButton); + + const resourceGroupButton = await screen.findByRole('button', { name: 'Expand A Great Resource Group' }); + await userEvent.click(resourceGroupButton); + const checkbox = await screen.findByLabelText('web-server'); + await userEvent.click(checkbox); + expect(checkbox).toBeChecked(); + const applyButton = screen.getByRole('button', { name: 'Apply' }); + await userEvent.click(applyButton); + + expect(onApply).toHaveBeenCalledTimes(1); + expect(onApply).toHaveBeenCalledWith([ + { + metricNamespace: 'Microsoft.Compute/virtualMachines', + region: 'northeurope', + resourceGroup: 'dev-3', + resourceName: 'web-server', + subscription: 'def-456', + }, + ]); + expect(window.localStorage.getItem(RECENT_RESOURCES_KEY(queryType))).not.toBeNull(); + const recentResourcesFromStorage: ResourceRowGroup = JSON.parse( + window.localStorage.getItem(RECENT_RESOURCES_KEY(queryType)) || '[]' + ); + expect(recentResourcesFromStorage.length).toBe(30); + expect(recentResourcesFromStorage.find((resource) => resource.id === 'web-server')).toBeDefined(); + expect(recentResourcesFromStorage.find((resource) => resource.id === 'aks-agentpool-29')).not.toBeDefined(); + }); + }); }); diff --git a/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/ResourcePicker.tsx b/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/ResourcePicker.tsx index 0f47899d98d..6ff5bc11137 100644 --- a/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/ResourcePicker.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/ResourcePicker.tsx @@ -1,14 +1,34 @@ import { cx } from '@emotion/css'; +import { uniqBy } from 'lodash'; import { useCallback, useEffect, useState } from 'react'; import * as React from 'react'; import { useEffectOnce } from 'react-use'; +import { LocalStorageValueProvider } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { Alert, Button, LoadingPlaceholder, Modal, useStyles2, Space } from '@grafana/ui'; +import { config, reportInteraction } from '@grafana/runtime'; +import { + Alert, + Button, + LoadingPlaceholder, + Modal, + useStyles2, + Space, + Stack, + Field, + ComboboxOption, + MultiCombobox, + TabsBar, + TabContent, + Tab, +} from '@grafana/ui'; +import { resourceTypeDisplayNames } from '../../azureMetadata/resourceTypes'; +import Datasource from '../../datasource'; import { selectors } from '../../e2e/selectors'; import ResourcePickerData, { ResourcePickerQueryType } from '../../resourcePicker/resourcePickerData'; import { AzureMonitorResource } from '../../types/query'; +import { ResourceGraphFilters } from '../../types/types'; import messageFromError from '../../utils/messageFromError'; import AdvancedMulti from './AdvancedMulti'; @@ -23,6 +43,7 @@ interface ResourcePickerProps { resources: T[]; selectableEntryTypes: ResourceRowType[]; queryType: ResourcePickerQueryType; + datasource: Datasource; onApply: (resources: T[]) => void; onCancel: () => void; @@ -31,9 +52,13 @@ interface ResourcePickerProps { selectionNotice?: (selectedRows: ResourceRowGroup) => string; } +export const RECENT_RESOURCES_KEY = (queryType: ResourcePickerQueryType) => + `grafana.datasources.azuremonitor.recent-resources.${queryType}`; + const ResourcePicker = ({ resourcePickerData, resources, + datasource, onApply, onCancel, selectableEntryTypes, @@ -51,16 +76,61 @@ const ResourcePicker = ({ const [errorMessage, setErrorMessage] = useState(undefined); const [shouldShowLimitFlag, setShouldShowLimitFlag] = useState(false); const selectionNoticeText = selectionNotice?.(selectedRows); + const [subscriptions, setSubscriptions] = useState>>([]); + const [isLoadingSubscriptions, setIsLoadingSubscriptions] = useState(false); + const [namespaces, setNamespaces] = useState>>([]); + const [isLoadingNamespaces, setIsLoadingNamespaces] = useState(false); + const [locations, setLocations] = useState>>([]); + const [isLoadingLocations, setIsLoadingLocations] = useState(false); + const [filters, setFilters] = useState({ + subscriptions: [], + types: [], + locations: [], + }); + const [view, setView] = useState<'picker' | 'recent'>('picker'); // Sync the resourceURI prop to internal state useEffect(() => { setInternalSelected(resources); }, [resources]); + const loadFilterOptions = useCallback(async () => { + setIsLoadingSubscriptions(true); + const subscriptions = await datasource.getSubscriptions(); + setSubscriptions(subscriptions.map((sub) => ({ label: sub.text, value: sub.value }))); + setIsLoadingSubscriptions(false); + + if (queryType === 'metrics') { + setIsLoadingNamespaces(true); + const initialNamespaces = await datasource.getMetricNamespaces( + subscriptions[0]?.value || datasource.getDefaultSubscriptionId() + ); + setNamespaces( + initialNamespaces?.map((ns) => ({ + label: resourceTypeDisplayNames[ns.value.toLowerCase()] || ns.value, + value: ns.value, + })) + ); + setIsLoadingNamespaces(false); + } + + setIsLoadingLocations(true); + // We only retrieve locations from the first 3 subscriptions to avoid performance issues. + const initialLocations = await datasource.getLocations(subscriptions.map((s) => s.value).slice(0, 3)); + setLocations( + Array.from(initialLocations.values()).map((location) => ({ + label: location.displayName, + value: location.name, + })) + ); + setIsLoadingLocations(false); + }, [datasource, queryType]); + const loadInitialData = useCallback(async () => { if (!isLoading) { try { setIsLoading(true); + const resources = await resourcePickerData.fetchInitialRows( queryType, parseMultipleResourceDetails(internalSelected ?? {}) @@ -75,6 +145,9 @@ const ResourcePicker = ({ useEffectOnce(() => { loadInitialData(); + if (config.featureToggles.azureResourcePickerUpdates) { + loadFilterOptions(); + } }); // Avoid using empty resources @@ -112,14 +185,14 @@ const ResourcePicker = ({ } try { - const nestedRows = await resourcePickerData.fetchAndAppendNestedRow(rows, parentRow, queryType); + const nestedRows = await resourcePickerData.fetchAndAppendNestedRow(rows, parentRow, queryType, filters); setRows(nestedRows); } catch (error) { setErrorMessage(messageFromError(error)); throw error; } }, - [resourcePickerData, rows, queryType] + [resourcePickerData, rows, queryType, filters] ); const handleSelectionChanged = useCallback( @@ -144,6 +217,19 @@ const ResourcePicker = ({ } }, [queryType, internalSelected, onApply]); + // Once the azureResourcePickerUpdates feature toggle is removed this will replace handleApply above + const handleApplyWithLocalStorage = useCallback( + (recentResources: ResourceRowGroup, onRecentResourcesSave: (value: ResourceRowGroup) => void) => { + if (internalSelected) { + const resourcesToSave = uniqBy([...selectedRows, ...recentResources], 'id'); + + onRecentResourcesSave(resourcesToSave.slice(0, 30)); + onApply(queryType === 'logs' ? internalSelected : parseMultipleResourceDetails(internalSelected)); + } + }, + [queryType, internalSelected, selectedRows, onApply] + ); + const handleSearch = useCallback( async (searchWord: string) => { // clear errors and warnings @@ -157,7 +243,7 @@ const ResourcePicker = ({ try { setIsLoading(true); - const searchResults = await resourcePickerData.search(searchWord, queryType); + const searchResults = await resourcePickerData.search(searchWord, queryType, filters); setRows(searchResults); if (searchResults.length >= resourcePickerData.resultLimit) { setShouldShowLimitFlag(true); @@ -167,130 +253,252 @@ const ResourcePicker = ({ } setIsLoading(false); }, - [loadInitialData, resourcePickerData, queryType] + [loadInitialData, resourcePickerData, queryType, filters] ); - return ( - <> - - {shouldShowLimitFlag ? ( -

- - Showing first {'{{numResults}}'} results - -

- ) : ( - - )} + const loadFilteredRows = useCallback( + async (filters: ResourceGraphFilters) => { + try { + setIsLoading(true); + const filteredRows = await resourcePickerData.fetchInitialRows(queryType, undefined, filters); + setRows(filteredRows); + } catch (error) { + setErrorMessage(messageFromError(error)); + } + setIsLoading(false); + }, + [resourcePickerData, queryType] + ); - - - - - - - - -
- Scope - - Type - - Location -
+ const updateFilters = (value: Array>, filterType: 'subscriptions' | 'types' | 'locations') => { + const updatedFilters = { ...filters }; + const values = value.map((v) => v.value); + switch (filterType) { + case 'subscriptions': + updatedFilters.subscriptions = values; + break; + case 'types': + updatedFilters.types = values; + break; + case 'locations': + updatedFilters.locations = values; + break; + } + setFilters(updatedFilters); + reportInteraction('grafana_ds_azuremonitor_resource_picker_filters', { + subscriptionsFilters: updatedFilters.subscriptions.length, + typesFilters: updatedFilters.types.length, + locationsFilters: updatedFilters.locations.length, + }); + if ( + updatedFilters.subscriptions.length === 0 && + updatedFilters.types.length === 0 && + updatedFilters.locations.length === 0 + ) { + loadInitialData(); + return; + } + loadFilteredRows(updatedFilters); + }; -
+ const resourceTable = (resourceRows: ResourceRowGroup) => { + return ( + <> - - {isLoading && ( - - - - )} - {!isLoading && rows.length === 0 && ( - - - - )} - {!isLoading && - rows.map((row) => ( - - ))} - + + + + + + +
- -
- No resources found -
+ Scope + + Type + + Location +
-
-
- {selectedRows.length > 0 && ( - <> -
- Selection -
- -
- - - {selectedRows.map((row) => ( - false} - /> - ))} - -
-
- - {selectionNoticeText?.length ? ( - - {selectionNoticeText} - - ) : null} - - )} - - setInternalSelected(r)} - renderAdvanced={renderAdvanced} - /> - - {errorMessage && ( - <> - - + + + {isLoading && ( + + + )} + {!isLoading && resourceRows?.length === 0 && ( + + + + )} + {!isLoading && + resourceRows?.map((row) => ( + + ))} + +
+ +
+ {view === 'picker' ? ( + No resources found + ) : ( + + No recent resources found + + )} +
+ + +
+ {selectedRows.length > 0 && ( + <> +
+ Selection +
+ +
+ + + {selectedRows.map((row) => ( + false} + /> + ))} + +
+
+ + {selectionNoticeText?.length ? ( + + {selectionNoticeText} + + ) : null} + + )} + + {view === 'picker' && ( + setInternalSelected(r)} + renderAdvanced={renderAdvanced} + /> + )} + {errorMessage && ( + <> + + + {errorMessage} + + + )} +
+ + ); + }; + + const baseResourcePicker = ( + recentResources?: ResourceRowGroup, + localStorageSave?: (value: ResourceRowGroup) => void + ) => { + return ( + <> + + {config.featureToggles.azureResourcePickerUpdates && ( + + - {errorMessage} -
- + updateFilters(value, 'subscriptions')} + isClearable + enableAllOption + loading={isLoadingSubscriptions} + data-testid={selectors.components.queryEditor.resourcePicker.filters.subscription.input} + placeholder={t('components.resource-picker.subscriptions-filter-placeholder', 'Select a subscription')} + /> + + {queryType === 'metrics' && ( + + updateFilters(value, 'types')} + isClearable + enableAllOption + loading={isLoadingNamespaces} + data-testid={selectors.components.queryEditor.resourcePicker.filters.type.input} + placeholder={t('components.resource-picker.types-filter-placeholder', 'Select a resource type')} + /> + + )} + + updateFilters(value, 'locations')} + isClearable + enableAllOption + loading={isLoadingLocations} + data-testid={selectors.components.queryEditor.resourcePicker.filters.location.input} + placeholder={t('components.resource-picker.locations-filter-placeholder', 'Select a location')} + /> + + )} + {shouldShowLimitFlag ? ( +

+ + Showing first {'{{numResults}}'} results + +

+ ) : ( + + )} + + {resourceTable(rows)} -
- - ); + + ); + }; + + // Once the azureResourcePickerUpdates feature toggle is removed, baseResourcePicker can be merged into this function + const tabbedResourcePicker = () => { + return ( + storageKey={RECENT_RESOURCES_KEY(queryType)} defaultValue={[]}> + {(recentResources, onRecentResourcesSave) => { + return ( + <> + + setView('picker')} + /> + { + reportInteraction('grafana_ds_azuremonitor_resource_picker_recent_used', { + recentResourcesCount: recentResources.length, + }); + setView('recent'); + }} + /> + + + {view === 'picker' && baseResourcePicker(recentResources, onRecentResourcesSave)} + {view === 'recent' && ( + <> + {resourceTable(recentResources)} + + + + + + + )} + + + ); + }} + + ); + }; + + return config.featureToggles.azureResourcePickerUpdates ? tabbedResourcePicker() : baseResourcePicker(); }; export default ResourcePicker; diff --git a/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/Search.tsx b/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/Search.tsx index 44836695234..bf81d6d99bd 100644 --- a/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/Search.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/Search.tsx @@ -29,6 +29,7 @@ const Search = ({ searchFn }: { searchFn: (searchPhrase: string) => void }) => { }} placeholder={t('components.search.placeholder-resource-search', 'Search for a resource')} data-testid={selectors.components.queryEditor.resourcePicker.search.input} + style={{ marginBottom: '10px' }} /> ); }; diff --git a/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/styles.ts b/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/styles.ts index c3d88c9849b..b39100edee9 100644 --- a/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/styles.ts +++ b/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/styles.ts @@ -2,6 +2,8 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; +import { ResourcePickerQueryType } from '../../resourcePicker/resourcePickerData'; + const getStyles = (theme: GrafanaTheme2) => ({ table: css({ width: '100%', @@ -14,11 +16,11 @@ const getStyles = (theme: GrafanaTheme2) => ({ }), tableScroller: css({ - maxHeight: '16vh', + maxHeight: '35vh', }), selectedTableScroller: css({ - maxHeight: '13vh', + maxHeight: '35vh', }), header: css({ @@ -107,7 +109,14 @@ const getStyles = (theme: GrafanaTheme2) => ({ modal: css({ width: theme.breakpoints.values.lg, + maxHeight: '80vh', }), + + filterInput: (queryType: ResourcePickerQueryType) => + css({ + width: queryType === 'metrics' ? '30%' : '50%', + marginTop: '10px', + }), }); export default getStyles; diff --git a/public/app/plugins/datasource/azuremonitor/datasource.ts b/public/app/plugins/datasource/azuremonitor/datasource.ts index db8acd9a103..104d4c2de86 100644 --- a/public/app/plugins/datasource/azuremonitor/datasource.ts +++ b/public/app/plugins/datasource/azuremonitor/datasource.ts @@ -35,6 +35,7 @@ export default class Datasource extends DataSourceWithBackend { if (!query.queryType) { @@ -285,6 +292,10 @@ export default class Datasource extends DataSourceWithBackend new Map([['northeurope', { displayName: 'North Europe', name: 'northeurope', supportsLogs: false }]]) ), }, - - getAzureLogAnalyticsWorkspaces: jest.fn().mockResolvedValueOnce([]), - - getSubscriptions: jest.fn().mockResolvedValue([]), - getResourceGroups: jest.fn().mockResolvedValueOnce([]), - getResourceNames: jest.fn().mockResolvedValueOnce([]), - azureLogAnalyticsDatasource: { getKustoSchema: () => Promise.resolve(), getDeprecatedDefaultWorkSpace: () => 'defaultWorkspaceId', @@ -74,12 +68,18 @@ export default function createMockDatasource(overrides?: DeepPartial getResourceURIFromWorkspace: jest.fn().mockReturnValue(''), getResourceURIDisplayProperties: jest.fn().mockResolvedValue({}), }, - azureResourceGraphDatasource: { pagedResourceGraphRequest: jest.fn().mockResolvedValue([]), ...overrides?.azureResourceGraphDatasource, }, getVariablesRaw: jest.fn().mockReturnValue([]), + getDefaultSubscriptionId: jest.fn().mockReturnValue('defaultSubscriptionId'), + getMetricNamespaces: jest.fn().mockResolvedValueOnce([]), + getLocations: jest.fn().mockResolvedValueOnce([]), + getAzureLogAnalyticsWorkspaces: jest.fn().mockResolvedValueOnce([]), + getSubscriptions: jest.fn().mockResolvedValue([]), + getResourceGroups: jest.fn().mockResolvedValueOnce([]), + getResourceNames: jest.fn().mockResolvedValueOnce([]), currentUserAuth: false, ...overrides, }; @@ -88,3 +88,20 @@ export default function createMockDatasource(overrides?: DeepPartial return jest.mocked(mockDatasource); } + +export const createMockLocations = (): Promise> => { + return Promise.resolve( + new Map([ + ['northeurope', { displayName: 'North Europe', name: 'northeurope', supportsLogs: true }], + ['eastus', { displayName: 'East US', name: 'eastus', supportsLogs: true }], + ]) + ); +}; +export const createMockMetricsNamespaces = (): Promise< + Array<{ + text: string; + value: string; + }> +> => { + return Promise.resolve(resourceTypes.map((type) => ({ text: resourceTypeDisplayNames[type], value: type }))); +}; diff --git a/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.test.ts b/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.test.ts index 9bdf757a5c3..37eb493fa0d 100644 --- a/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.test.ts +++ b/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.test.ts @@ -44,6 +44,12 @@ const createResourcePickerData = (responses: AzureGraphResponse[], noNamespaces? return { resourcePickerData, postResource, mockDatasource }; }; +const emptyFilters = { + subscriptions: [], + types: [], + locations: [], +}; + describe('AzureMonitor resourcePickerData', () => { describe('getSubscriptions', () => { it('makes 1 call to ARG with the correct path and query arguments', async () => { @@ -166,6 +172,52 @@ describe('AzureMonitor resourcePickerData', () => { } } }); + + it('applies subscription filters in the query', async () => { + const mockResponse = createMockARGSubscriptionResponse(); + const { resourcePickerData, postResource } = createResourcePickerData([mockResponse]); + const filters = { subscriptions: ['sub1', 'sub2'], types: [], locations: [] }; + await resourcePickerData.getSubscriptions(filters); + const firstCall = postResource.mock.calls[0]; + const postBody = firstCall[1]; + expect(postBody.query).toContain('| where subscriptionId in ("sub1","sub2")'); + }); + + it('applies type filters in the query', async () => { + const mockResponse = createMockARGSubscriptionResponse(); + const { resourcePickerData, postResource } = createResourcePickerData([mockResponse]); + const filters = { subscriptions: [], types: ['microsoft.compute/virtualmachines'], locations: [] }; + await resourcePickerData.getSubscriptions(filters); + const firstCall = postResource.mock.calls[0]; + const postBody = firstCall[1]; + expect(postBody.query).toContain('| where type in ("microsoft.compute/virtualmachines")'); + }); + + it('applies location filters in the query', async () => { + const mockResponse = createMockARGSubscriptionResponse(); + const { resourcePickerData, postResource } = createResourcePickerData([mockResponse]); + const filters = { subscriptions: [], types: [], locations: ['eastus', 'westeurope'] }; + await resourcePickerData.getSubscriptions(filters); + const firstCall = postResource.mock.calls[0]; + const postBody = firstCall[1]; + expect(postBody.query).toContain('| where location in ("eastus","westeurope")'); + }); + + it('applies all filters together in the query', async () => { + const mockResponse = createMockARGSubscriptionResponse(); + const { resourcePickerData, postResource } = createResourcePickerData([mockResponse]); + const filters = { + subscriptions: ['sub1'], + types: ['microsoft.compute/virtualmachines'], + locations: ['eastus'], + }; + await resourcePickerData.getSubscriptions(filters); + const firstCall = postResource.mock.calls[0]; + const postBody = firstCall[1]; + expect(postBody.query).toContain('| where subscriptionId in ("sub1")'); + expect(postBody.query).toContain('| where type in ("microsoft.compute/virtualmachines")'); + expect(postBody.query).toContain('| where location in ("eastus")'); + }); }); describe('getResourceGroupsBySubscriptionId', () => { @@ -184,6 +236,36 @@ describe('AzureMonitor resourcePickerData', () => { expect(postBody.query).toContain("where subscriptionId == '123'"); }); + it('does not apply subscription filters in the query - only the supplied subscription is used', async () => { + const mockResponse = createMockARGResourceGroupsResponse(); + const { resourcePickerData, postResource } = createResourcePickerData([mockResponse]); + const filters = { subscriptions: ['sub1', 'sub2'], types: [], locations: [] }; + await resourcePickerData.getResourceGroupsBySubscriptionId('123', 'logs', filters); + const firstCall = postResource.mock.calls[0]; + const postBody = firstCall[1]; + expect(postBody.query).toContain('| where subscriptionId in ("123")'); + }); + + it('applies type filters in the query', async () => { + const mockResponse = createMockARGResourceGroupsResponse(); + const { resourcePickerData, postResource } = createResourcePickerData([mockResponse]); + const filters = { subscriptions: [], types: ['microsoft.compute/virtualmachines'], locations: [] }; + await resourcePickerData.getResourceGroupsBySubscriptionId('123', 'logs', filters); + const firstCall = postResource.mock.calls[0]; + const postBody = firstCall[1]; + expect(postBody.query).toContain('| where type in ("microsoft.compute/virtualmachines")'); + }); + + it('applies location filters in the query', async () => { + const mockResponse = createMockARGResourceGroupsResponse(); + const { resourcePickerData, postResource } = createResourcePickerData([mockResponse]); + const filters = { subscriptions: [], types: [], locations: ['eastus', 'westeurope'] }; + await resourcePickerData.getResourceGroupsBySubscriptionId('123', 'logs', filters); + const firstCall = postResource.mock.calls[0]; + const postBody = firstCall[1]; + expect(postBody.query).toContain('| where location in ("eastus","westeurope")'); + }); + it('returns formatted resourceGroups', async () => { const mockResponse = createMockARGResourceGroupsResponse(); const { resourcePickerData } = createResourcePickerData([mockResponse]); @@ -289,6 +371,36 @@ describe('AzureMonitor resourcePickerData', () => { expect(postBody.query).toContain('where id hasprefix "/subscription/sub1/resourceGroups/dev/"'); }); + it('applies subscription filters in the query', async () => { + const mockResponse = createARGResourcesResponse(); + const { resourcePickerData, postResource } = createResourcePickerData([mockResponse]); + const filters = { subscriptions: ['sub1', 'sub2'], types: [], locations: [] }; + await resourcePickerData.getResourcesForResourceGroup('/subscription/sub1/resourceGroups/dev', 'logs', filters); + const firstCall = postResource.mock.calls[0]; + const postBody = firstCall[1]; + expect(postBody.query).toContain('| where subscriptionId in ("sub1","sub2")'); + }); + + it('applies type filters in the query', async () => { + const mockResponse = createARGResourcesResponse(); + const { resourcePickerData, postResource } = createResourcePickerData([mockResponse]); + const filters = { subscriptions: [], types: ['microsoft.compute/virtualmachines'], locations: [] }; + await resourcePickerData.getResourcesForResourceGroup('/subscription/sub1/resourceGroups/dev', 'logs', filters); + const firstCall = postResource.mock.calls[0]; + const postBody = firstCall[1]; + expect(postBody.query).toContain('| where type in ("microsoft.compute/virtualmachines")'); + }); + + it('applies location filters in the query', async () => { + const mockResponse = createARGResourcesResponse(); + const { resourcePickerData, postResource } = createResourcePickerData([mockResponse]); + const filters = { subscriptions: [], types: [], locations: ['eastus', 'westeurope'] }; + await resourcePickerData.getResourcesForResourceGroup('/subscription/sub1/resourceGroups/dev', 'logs', filters); + const firstCall = postResource.mock.calls[0]; + const postBody = firstCall[1]; + expect(postBody.query).toContain('| where location in ("eastus","westeurope")'); + }); + it('returns formatted resources', async () => { const mockResponse = createARGResourcesResponse(); const { resourcePickerData } = createResourcePickerData([mockResponse]); @@ -344,7 +456,7 @@ describe('AzureMonitor resourcePickerData', () => { mockSubscriptionsResponse, mockResponse, ]); - const formattedResults = await resourcePickerData.search('vmname', 'metrics'); + const formattedResults = await resourcePickerData.search('vmname', 'metrics', emptyFilters); expect(postResource).toHaveBeenCalledTimes(2); expect(mockDatasource.azureMonitorDatasource.getMetricNamespaces).toHaveBeenCalledWith( { @@ -382,6 +494,69 @@ describe('AzureMonitor resourcePickerData', () => { uri: '/subscriptions/subId/resourceGroups/rgName/providers/Microsoft.Compute/virtualMachines/vmname', }); }); + + it('applies subscription filters in the query', async () => { + const mockResponse = { + data: [ + { + id: '/subscriptions/subId/resourceGroups/rgName', + name: 'rgName', + type: 'microsoft.resources/subscriptions/resourcegroups', + resourceGroup: 'rgName', + subscriptionId: 'subId', + location: 'northeurope', + }, + ], + }; + const { resourcePickerData, postResource } = createResourcePickerData([mockResponse]); + const filters = { subscriptions: ['sub1', 'sub2'], types: [], locations: [] }; + await resourcePickerData.search('rgName', 'logs', filters); + const firstCall = postResource.mock.calls[0]; + const postBody = firstCall[1]; + expect(postBody.query).toContain('| where subscriptionId in ("sub1","sub2")'); + }); + + it('applies type filters in the query', async () => { + const mockResponse = { + data: [ + { + id: '/subscriptions/subId/resourceGroups/rgName', + name: 'rgName', + type: 'microsoft.resources/subscriptions/resourcegroups', + resourceGroup: 'rgName', + subscriptionId: 'subId', + location: 'northeurope', + }, + ], + }; + const { resourcePickerData, postResource } = createResourcePickerData([mockResponse]); + const filters = { subscriptions: [], types: ['microsoft.compute/virtualmachines'], locations: [] }; + await resourcePickerData.search('rgName', 'logs', filters); + const firstCall = postResource.mock.calls[0]; + const postBody = firstCall[1]; + expect(postBody.query).toContain('| where type in ("microsoft.compute/virtualmachines")'); + }); + + it('applies location filters in the query', async () => { + const mockResponse = { + data: [ + { + id: '/subscriptions/subId/resourceGroups/rgName', + name: 'rgName', + type: 'microsoft.resources/subscriptions/resourcegroups', + resourceGroup: 'rgName', + subscriptionId: 'subId', + location: 'northeurope', + }, + ], + }; + const { resourcePickerData, postResource } = createResourcePickerData([mockResponse]); + const filters = { subscriptions: [], types: [], locations: ['eastus', 'westeurope'] }; + await resourcePickerData.search('rgName', 'logs', filters); + const firstCall = postResource.mock.calls[0]; + const postBody = firstCall[1]; + expect(postBody.query).toContain('| where location in ("eastus","westeurope")'); + }); it('metrics searches - fallback namespaces', async () => { const mockSubscriptionsResponse = createMockARGSubscriptionResponse(); @@ -401,7 +576,7 @@ describe('AzureMonitor resourcePickerData', () => { [mockSubscriptionsResponse, mockResponse], true ); - await resourcePickerData.search('vmname', 'metrics'); + await resourcePickerData.search('vmname', 'metrics', emptyFilters); expect(postResource).toHaveBeenCalledTimes(2); expect(mockDatasource.azureMonitorDatasource.getMetricNamespaces).toHaveBeenCalledWith( { @@ -443,7 +618,7 @@ describe('AzureMonitor resourcePickerData', () => { ], }; const { resourcePickerData, postResource } = createResourcePickerData([mockResponse]); - const formattedResults = await resourcePickerData.search('rgName', 'logs'); + const formattedResults = await resourcePickerData.search('rgName', 'logs', emptyFilters); expect(postResource).toBeCalledTimes(1); const firstCall = postResource.mock.calls[0]; const [_, postBody] = firstCall; @@ -474,7 +649,7 @@ describe('AzureMonitor resourcePickerData', () => { }; const { resourcePickerData } = createResourcePickerData([mockResponse]); try { - await resourcePickerData.search('dev', 'logs'); + await resourcePickerData.search('dev', 'logs', emptyFilters); throw Error('expected search test to fail but it succeeded'); } catch (err) { if (err instanceof Error) { @@ -532,6 +707,47 @@ describe('AzureMonitor resourcePickerData', () => { // of both resources is the same expect(resourcePickerData.getResourcesForResourceGroup).toBeCalledTimes(1); }); + + it('fetches filtered resource groups and resources', async () => { + const { resourcePickerData } = createResourcePickerData([createMockARGSubscriptionResponse()]); + resourcePickerData.getResourceGroupsBySubscriptionId = jest + .fn() + .mockResolvedValue([{ id: 'rg1', uri: '/subscriptions/1/resourceGroups/rg1' }]); + resourcePickerData.getResourcesForResourceGroup = jest.fn().mockResolvedValue([ + { id: 'vm1', uri: '/subscriptions/1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm1' }, + { id: 'vm2', uri: '/subscriptions/1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm2' }, + ]); + const filters = { subscriptions: ['1'], types: [], locations: [] }; + const rows = await resourcePickerData.fetchInitialRows( + 'logs', + [ + { + subscription: '1', + resourceGroup: 'rg1', + resourceName: 'vm1', + metricNamespace: 'Microsoft.Compute/virtualMachines', + }, + { + subscription: '1', + resourceGroup: 'rg1', + resourceName: 'vm2', + metricNamespace: 'Microsoft.Compute/virtualMachines', + }, + ], + filters + ); + expect(rows[0]).toMatchObject({ + id: '1', + children: [ + { + id: 'rg1', + children: [{ id: 'vm1' }, { id: 'vm2' }], + }, + ], + }); + expect(resourcePickerData.getResourceGroupsBySubscriptionId).toBeCalledTimes(1); + expect(resourcePickerData.getResourcesForResourceGroup).toBeCalledTimes(1); + }); }); describe('parseRows', () => { diff --git a/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.ts b/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.ts index 35bf3933f1b..64215cc2f59 100644 --- a/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.ts +++ b/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.ts @@ -19,6 +19,7 @@ import { AzureMonitorDataSourceJsonData, AzureResourceSummaryItem, RawAzureResourceItem, + ResourceGraphFilters, } from '../types/types'; const logsSupportedResourceTypesKusto = logsResourceTypes.map((v) => `"${v}"`).join(','); @@ -46,63 +47,84 @@ export default class ResourcePickerData extends DataSourceWithBackend< async fetchInitialRows( type: ResourcePickerQueryType, - currentSelection?: AzureMonitorResource[] + currentSelection?: AzureMonitorResource[], + filters?: ResourceGraphFilters ): Promise { - const subscriptions = await this.getSubscriptions(); + try { + const subscriptions = await this.getSubscriptions(filters); - if (!currentSelection) { - return subscriptions; - } + if (!currentSelection) { + return subscriptions; + } - let resources = subscriptions; - const promises = currentSelection.map((selection) => async () => { - if (selection.subscription) { - const resourceGroupURI = `/subscriptions/${selection.subscription}/resourceGroups/${selection.resourceGroup}`; + let resources = subscriptions; + const promises = currentSelection.map((selection) => async () => { + if (selection.subscription) { + const resourceGroupURI = `/subscriptions/${selection.subscription}/resourceGroups/${selection.resourceGroup}`; - if (selection.resourceGroup && !findRow(resources, resourceGroupURI)) { - const resourceGroups = await this.getResourceGroupsBySubscriptionId(selection.subscription, type); - resources = addResources(resources, `/subscriptions/${selection.subscription}`, resourceGroups); + if (selection.resourceGroup && !findRow(resources, resourceGroupURI)) { + const resourceGroups = await this.getResourceGroupsBySubscriptionId(selection.subscription, type); + resources = addResources(resources, `/subscriptions/${selection.subscription}`, resourceGroups); + } + + const resourceURI = resourceToString(selection); + if (selection.resourceName && !findRow(resources, resourceURI)) { + const resourcesForResourceGroup = await this.getResourcesForResourceGroup(resourceGroupURI, type); + resources = addResources(resources, resourceGroupURI, resourcesForResourceGroup); + } } + }); - const resourceURI = resourceToString(selection); - if (selection.resourceName && !findRow(resources, resourceURI)) { - const resourcesForResourceGroup = await this.getResourcesForResourceGroup(resourceGroupURI, type); - resources = addResources(resources, resourceGroupURI, resourcesForResourceGroup); + for (const promise of promises) { + // Fetch resources one by one, avoiding re-fetching the same resource + // and race conditions updating the resources array + await promise(); + } + + return resources; + } catch (err) { + if (err instanceof Error) { + if (err.message !== 'No subscriptions were found') { + throw err; + } + if (filters) { + return []; } } - }); - - for (const promise of promises) { - // Fetch resources one by one, avoiding re-fetching the same resource - // and race conditions updating the resources array - await promise(); + throw err; } - - return resources; } async fetchAndAppendNestedRow( rows: ResourceRowGroup, parentRow: ResourceRow, - type: ResourcePickerQueryType + type: ResourcePickerQueryType, + filters?: ResourceGraphFilters ): Promise { const nestedRows = parentRow.type === ResourceRowType.Subscription - ? await this.getResourceGroupsBySubscriptionId(parentRow.id, type) - : await this.getResourcesForResourceGroup(parentRow.uri, type); + ? await this.getResourceGroupsBySubscriptionId(parentRow.id, type, filters) + : await this.getResourcesForResourceGroup(parentRow.uri, type, filters); return addResources(rows, parentRow.uri, nestedRows); } - search = async (searchPhrase: string, searchType: ResourcePickerQueryType): Promise => { + search = async ( + searchPhrase: string, + searchType: ResourcePickerQueryType, + filters: ResourceGraphFilters + ): Promise => { let searchQuery = 'resources'; if (searchType === 'logs') { searchQuery += ` | union resourcecontainers`; } + + const filtersQuery = createFilter(filters); searchQuery += ` | where id contains "${searchPhrase}" ${await this.filterByType(searchType)} + ${filtersQuery} | order by tolower(name) asc | limit ${this.resultLimit} `; @@ -134,8 +156,8 @@ export default class ResourcePickerData extends DataSourceWithBackend< }); }; - async getSubscriptions(): Promise { - const subscriptions = await this.azureResourceGraphDatasource.getSubscriptions(); + async getSubscriptions(filters?: ResourceGraphFilters): Promise { + const subscriptions = await this.azureResourceGraphDatasource.getSubscriptions(filters); if (!subscriptions.length) { throw new Error('No subscriptions were found'); @@ -153,11 +175,12 @@ export default class ResourcePickerData extends DataSourceWithBackend< async getResourceGroupsBySubscriptionId( subscriptionId: string, - type: ResourcePickerQueryType + type: ResourcePickerQueryType, + filters?: ResourceGraphFilters ): Promise { const filter = await this.filterByType(type); - const resourceGroups = await this.azureResourceGraphDatasource.getResourceGroups(subscriptionId, filter); + const resourceGroups = await this.azureResourceGraphDatasource.getResourceGroups(subscriptionId, filter, filters); return resourceGroups.map((r) => { const parsedUri = parseResourceURI(r.resourceGroupURI); @@ -176,8 +199,16 @@ export default class ResourcePickerData extends DataSourceWithBackend< } // 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)); + async getResourcesForResourceGroup( + uri: string, + type: ResourcePickerQueryType, + filters?: ResourceGraphFilters + ): Promise { + const resources = await this.azureResourceGraphDatasource.getResourceNames( + { uri }, + await this.filterByType(type), + filters + ); return resources.map((resource) => { return { @@ -338,3 +369,19 @@ export default class ResourcePickerData extends DataSourceWithBackend< return newSelectedRows; } } +export const createFilter = (filters: ResourceGraphFilters) => { + let filtersQuery = ''; + if (filters) { + if (filters.subscriptions && filters.subscriptions.length > 0) { + filtersQuery += `| where subscriptionId in (${filters.subscriptions.map((s) => `"${s.toLowerCase()}"`).join(',')})\n`; + } + if (filters.types && filters.types.length > 0) { + filtersQuery += `| where type in (${filters.types.map((t) => `"${t.toLowerCase()}"`).join(',')})\n`; + } + if (filters.locations && filters.locations.length > 0) { + filtersQuery += `| where location in (${filters.locations.map((l) => `"${l.toLowerCase()}"`).join(',')})\n`; + } + } + + return filtersQuery; +}; diff --git a/public/app/plugins/datasource/azuremonitor/types/types.ts b/public/app/plugins/datasource/azuremonitor/types/types.ts index 103c59a5535..99ab54f5b54 100644 --- a/public/app/plugins/datasource/azuremonitor/types/types.ts +++ b/public/app/plugins/datasource/azuremonitor/types/types.ts @@ -528,3 +528,9 @@ export function instanceOfLogAnalyticsTableError( } return response.hasOwnProperty('error'); } + +export interface ResourceGraphFilters { + subscriptions: string[]; + types: string[]; + locations: string[]; +}