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 && (
-
- |
- No resources found
- |
-
- )}
- {!isLoading &&
- rows.map((row) => (
-
- ))}
-
+
+
+ |
+ Scope
+ |
+
+ Type
+ |
+
+ Location
+ |
+
+
-
-
+ {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