Azure: Resource picker improvements (#109458) (#109520)

* Azure: Create feature toggle for resource picker improvements (#109458)

Create feature toggle

* Azure: Resource picker subscriptions filter (#109527)

* Create feature toggle

* Fix namespace typo

* Retrieving default subscription ID

* Style updates

- Filter input styling
- Improved modal styling

* Pass data source to resource field

* Search style updates

* Function to support fetching filtered rows

* Filtering nested rows

* Filtering search

* Support subscriptions filtering

- Support filtering in resource graph functions
- Subscriptions filter component

* getSubscriptions tests

* Fix logs query editor test

* Update data source mock

* Update resourcePickerData tests

* Update tests, lint, and i18n

* Lint and test

* Simplify type

* Azure: Resource picker types filter  (#109528)

* Create feature toggle

* Fix namespace typo

* Retrieving default subscription ID

* Style updates

- Filter input styling
- Improved modal styling

* Pass data source to resource field

* Search style updates

* Function to support fetching filtered rows

* Filtering nested rows

* Filtering search

* Support subscriptions filtering

- Support filtering in resource graph functions
- Subscriptions filter component

* getSubscriptions tests

* Fix logs query editor test

* Update data source mock

* Update resourcePickerData tests

* Add types filter

* Update tests, lint, and i18n

* Lint and test

* Simplify type

* Rename variable for clarity

* Azure: Resource picker locations filter  (#109530)

* Create feature toggle

* Fix namespace typo

* Retrieving default subscription ID

* Style updates

- Filter input styling
- Improved modal styling

* Pass data source to resource field

* Search style updates

* Function to support fetching filtered rows

* Filtering nested rows

* Filtering search

* Support subscriptions filtering

- Support filtering in resource graph functions
- Subscriptions filter component

* getSubscriptions tests

* Fix logs query editor test

* Update data source mock

* Update resourcePickerData tests

* Add types filter

* Locations filter

* Update tests, lint, and i18n

* Minor test updates

* Imports

* Lint and test

* Simplify type

* Rename variable for clarity

* Rename var

* Azure: Resource picker filters tests (#109590)

* Create feature toggle

* Fix namespace typo

* Retrieving default subscription ID

* Style updates

- Filter input styling
- Improved modal styling

* Pass data source to resource field

* Search style updates

* Function to support fetching filtered rows

* Filtering nested rows

* Filtering search

* Support subscriptions filtering

- Support filtering in resource graph functions
- Subscriptions filter component

* getSubscriptions tests

* Fix logs query editor test

* Update data source mock

* Update resourcePickerData tests

* Add types filter

* Locations filter

* Update tests, lint, and i18n

* Minor test updates

* Imports

* Lint and test

* Resource picker filter tests

* Update tests

* Simplify type

* Rename variable for clarity

* Rename var

* Azure: Resource picker - recent resources (#109596)

* Create feature toggle

* Fix namespace typo

* Retrieving default subscription ID

* Style updates

- Filter input styling
- Improved modal styling

* Pass data source to resource field

* Search style updates

* Function to support fetching filtered rows

* Filtering nested rows

* Filtering search

* Support subscriptions filtering

- Support filtering in resource graph functions
- Subscriptions filter component

* getSubscriptions tests

* Fix logs query editor test

* Update data source mock

* Update resourcePickerData tests

* Add types filter

* Locations filter

* Update tests, lint, and i18n

* Minor test updates

* Imports

* Lint and test

* Resource picker filter tests

* Update tests

* Event for filter usage

* Function to support local storage

* Recent resources view

- Add LocalStorageValueProvider to store recent resources
- Add tabbed view to support switching between recent resources and resource picker
- Extract the base resource picker out to a functional component for reusability
- Extract the base resource table out to a functional component for reusability

* Update i18n keys

* Export resource key

* Add no recent resources text

* Run legacy tests with feature toggle off

* Add filters test without feature toggle

* Don't use as type assertions

* Add tests for recent resources

* Store resources for each query type

* i18n-extract

* Simplify type

* Minor performance improvement

* Rename variable for clarity

* Rename var

* Add placeholders

* Azure: Resource picker tests (#110175)

* Minor simplifying refactor

* Add more tests

* Update E2E
This commit is contained in:
Andreas Christou 2025-09-02 12:02:01 +02:00 committed by GitHub
parent b56b7add01
commit 1a8d25375a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1682 additions and 186 deletions

View file

@ -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

View file

@ -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');

View file

@ -1116,4 +1116,9 @@ export interface FeatureToggles {
* @default false
*/
graphiteBackendMode?: boolean;
/**
* Enables the updated Azure Monitor resource picker
* @default false
*/
azureResourcePickerUpdates?: boolean;
}

View file

@ -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",
},
}
)

View file

@ -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

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
249 teamFolders experimental @grafana/grafana-search-navigate-organise false false false
250 alertingTriage experimental @grafana/alerting-squad false false true
251 graphiteBackendMode privatePreview @grafana/partner-datasources false false false
252 azureResourcePickerUpdates preview @grafana/partner-datasources false false true

View file

@ -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"
)

View file

@ -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",

View file

@ -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',

View file

@ -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'");
});
});
});

View file

@ -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`;

View file

@ -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(

View file

@ -82,6 +82,7 @@ const ResourceField = ({
disableRow={disableRow}
renderAdvanced={renderAdvanced}
selectionNotice={selectionNotice}
datasource={datasource}
/>
</Modal>
<Field

View file

@ -2,8 +2,12 @@ import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { omit } from 'lodash';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import Datasource from '../../datasource';
import createMockDatasource from '../../mocks/datasource';
import { selectors } from '../../e2e/selectors';
import createMockDatasource, { createMockLocations, createMockMetricsNamespaces } from '../../mocks/datasource';
import { createMockInstanceSetttings } from '../../mocks/instanceSettings';
import {
createMockResourceGroupsBySubscription,
@ -14,7 +18,8 @@ import {
import { DeepPartial } from '../../mocks/utils';
import ResourcePickerData, { ResourcePickerQueryType } from '../../resourcePicker/resourcePickerData';
import { ResourceRowType } from './types';
import { RECENT_RESOURCES_KEY } from './ResourcePicker';
import { ResourceRowGroup, ResourceRowType } from './types';
import ResourcePicker from '.';
@ -25,6 +30,11 @@ jest.mock('@grafana/runtime', () => ({
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(<ResourcePicker {...defaultProps} resources={[noResourceURI]} />);
@ -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(<ResourcePicker {...defaultProps} queryType="metrics" />));
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(<ResourcePicker {...defaultProps} />));
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(<ResourcePicker {...defaultProps} queryType="metrics" />));
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(<ResourcePicker {...defaultProps} />));
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(<ResourcePicker {...defaultProps} />));
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(<ResourcePicker {...defaultProps} />));
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(<ResourcePicker {...defaultProps} />));
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(<ResourcePicker {...defaultProps} queryType="metrics" />));
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(<ResourcePicker {...defaultProps} />));
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(<ResourcePicker {...defaultProps} />));
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(<ResourcePicker {...defaultProps} />));
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(<ResourcePicker {...defaultProps} />));
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(<ResourcePicker {...defaultProps} queryType={queryType} onApply={onApply} />));
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(<ResourcePicker {...defaultProps} queryType={queryType} onApply={onApply} />));
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(<ResourcePicker {...defaultProps} queryType={queryType} onApply={onApply} />));
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(<ResourcePicker {...defaultProps} queryType={queryType} onApply={onApply} />));
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();
});
});
});

View file

@ -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<T> {
resources: T[];
selectableEntryTypes: ResourceRowType[];
queryType: ResourcePickerQueryType;
datasource: Datasource;
onApply: (resources: T[]) => void;
onCancel: () => void;
@ -31,9 +52,13 @@ interface ResourcePickerProps<T> {
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<string | undefined>(undefined);
const [shouldShowLimitFlag, setShouldShowLimitFlag] = useState(false);
const selectionNoticeText = selectionNotice?.(selectedRows);
const [subscriptions, setSubscriptions] = useState<Array<ComboboxOption<string>>>([]);
const [isLoadingSubscriptions, setIsLoadingSubscriptions] = useState(false);
const [namespaces, setNamespaces] = useState<Array<ComboboxOption<string>>>([]);
const [isLoadingNamespaces, setIsLoadingNamespaces] = useState(false);
const [locations, setLocations] = useState<Array<ComboboxOption<string>>>([]);
const [isLoadingLocations, setIsLoadingLocations] = useState(false);
const [filters, setFilters] = useState<ResourceGraphFilters>({
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 (
<>
<Search searchFn={handleSearch} />
{shouldShowLimitFlag ? (
<p className={styles.resultLimit}>
<Trans
i18nKey="components.resource-picker.result-limit"
values={{ numResults: resourcePickerData.resultLimit }}
>
Showing first {'{{numResults}}'} results
</Trans>
</p>
) : (
<Space v={2} />
)}
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]
);
<table className={styles.table}>
<thead>
<tr className={cx(styles.row, styles.header)}>
<td className={styles.cell}>
<Trans i18nKey="components.resource-picker.header-scope">Scope</Trans>
</td>
<td className={styles.cell}>
<Trans i18nKey="components.resource-picker.header-type">Type</Trans>
</td>
<td className={styles.cell}>
<Trans i18nKey="components.resource-picker.header-location">Location</Trans>
</td>
</tr>
</thead>
</table>
const updateFilters = (value: Array<ComboboxOption<string>>, 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);
};
<div className={cx(styles.scrollableTable, styles.tableScroller)}>
const resourceTable = (resourceRows: ResourceRowGroup) => {
return (
<>
<table className={styles.table}>
<tbody>
{isLoading && (
<tr className={cx(styles.row)}>
<td className={styles.cell}>
<LoadingPlaceholder text={t('components.resource-picker.text-loading', 'Loading...')} />
</td>
</tr>
)}
{!isLoading && rows.length === 0 && (
<tr className={cx(styles.row)}>
<td className={styles.cell} aria-live="polite">
<Trans i18nKey="components.resource-picker.text-no-resources">No resources found</Trans>
</td>
</tr>
)}
{!isLoading &&
rows.map((row) => (
<NestedRow
key={row.uri}
row={row}
selectedRows={selectedRows}
level={0}
requestNestedRows={requestNestedRows}
onRowSelectedChange={handleSelectionChanged}
selectableEntryTypes={selectableEntryTypes}
scrollIntoView={true}
disableRow={disableRow}
/>
))}
</tbody>
<thead>
<tr className={cx(styles.row, styles.header)}>
<td className={styles.cell}>
<Trans i18nKey="components.resource-picker.header-scope">Scope</Trans>
</td>
<td className={styles.cell}>
<Trans i18nKey="components.resource-picker.header-type">Type</Trans>
</td>
<td className={styles.cell}>
<Trans i18nKey="components.resource-picker.header-location">Location</Trans>
</td>
</tr>
</thead>
</table>
</div>
<footer className={styles.selectionFooter}>
{selectedRows.length > 0 && (
<>
<h5>
<Trans i18nKey="components.resource-picker.heading-selection">Selection</Trans>
</h5>
<div className={cx(styles.scrollableTable, styles.selectedTableScroller)}>
<table className={styles.table}>
<tbody>
{selectedRows.map((row) => (
<NestedRow
key={row.uri}
row={row}
selectedRows={selectedRows}
level={0}
requestNestedRows={requestNestedRows}
onRowSelectedChange={handleSelectionChanged}
selectableEntryTypes={selectableEntryTypes}
disableRow={() => false}
/>
))}
</tbody>
</table>
</div>
<Space v={2} />
{selectionNoticeText?.length ? (
<Alert title="" severity="info">
{selectionNoticeText}
</Alert>
) : null}
</>
)}
<AdvancedMulti
resources={internalSelected}
onChange={(r) => setInternalSelected(r)}
renderAdvanced={renderAdvanced}
/>
{errorMessage && (
<>
<Space v={2} />
<Alert
severity="error"
title={t(
'components.resource-picker.title-error-occurred',
'An error occurred while requesting resources from Azure Monitor'
<div className={cx(styles.scrollableTable, styles.tableScroller)}>
<table className={styles.table}>
<tbody>
{isLoading && (
<tr className={cx(styles.row)}>
<td className={styles.cell}>
<LoadingPlaceholder text={t('components.resource-picker.text-loading', 'Loading...')} />
</td>
</tr>
)}
{!isLoading && resourceRows?.length === 0 && (
<tr className={cx(styles.row)}>
<td className={styles.cell} aria-live="polite">
{view === 'picker' ? (
<Trans i18nKey="components.resource-picker.text-no-resources">No resources found</Trans>
) : (
<Trans i18nKey="components.resource-picker.text-no-recent-resources">
No recent resources found
</Trans>
)}
</td>
</tr>
)}
{!isLoading &&
resourceRows?.map((row) => (
<NestedRow
key={row.uri}
row={row}
selectedRows={selectedRows}
level={0}
requestNestedRows={requestNestedRows}
onRowSelectedChange={handleSelectionChanged}
selectableEntryTypes={selectableEntryTypes}
scrollIntoView={true}
disableRow={disableRow}
/>
))}
</tbody>
</table>
</div>
<footer className={styles.selectionFooter}>
{selectedRows.length > 0 && (
<>
<h5>
<Trans i18nKey="components.resource-picker.heading-selection">Selection</Trans>
</h5>
<div className={cx(styles.scrollableTable, styles.selectedTableScroller)}>
<table className={styles.table}>
<tbody>
{selectedRows.map((row) => (
<NestedRow
key={row.uri}
row={row}
selectedRows={selectedRows}
level={0}
requestNestedRows={requestNestedRows}
onRowSelectedChange={handleSelectionChanged}
selectableEntryTypes={selectableEntryTypes}
disableRow={() => false}
/>
))}
</tbody>
</table>
</div>
<Space v={2} />
{selectionNoticeText?.length ? (
<Alert title="" severity="info">
{selectionNoticeText}
</Alert>
) : null}
</>
)}
{view === 'picker' && (
<AdvancedMulti
resources={internalSelected}
onChange={(r) => setInternalSelected(r)}
renderAdvanced={renderAdvanced}
/>
)}
{errorMessage && (
<>
<Space v={2} />
<Alert
severity="error"
title={t(
'components.resource-picker.title-error-occurred',
'An error occurred while requesting resources from Azure Monitor'
)}
>
{errorMessage}
</Alert>
</>
)}
</footer>
</>
);
};
const baseResourcePicker = (
recentResources?: ResourceRowGroup,
localStorageSave?: (value: ResourceRowGroup) => void
) => {
return (
<>
<Search searchFn={handleSearch} />
{config.featureToggles.azureResourcePickerUpdates && (
<Stack direction={'row'} alignItems="flex-start" justifyContent={'space-between'} gap={1}>
<Field
label={t('components.resource-picker.subscriptions-filter', 'Subscriptions')}
noMargin
className={styles.filterInput(queryType)}
>
{errorMessage}
</Alert>
</>
<MultiCombobox
aria-label={t('components.resource-picker.subscriptions-filter', 'Subscriptions')}
value={filters.subscriptions}
options={subscriptions}
onChange={(value) => 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')}
/>
</Field>
{queryType === 'metrics' && (
<Field
label={t('components.resource-picker.types-filter', 'Resource Types')}
noMargin
className={styles.filterInput(queryType)}
>
<MultiCombobox
aria-label={t('components.resource-picker.types-filter', 'Resource Types')}
value={filters.types}
options={namespaces}
onChange={(value) => 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')}
/>
</Field>
)}
<Field
label={t('components.resource-picker.locations-filter', 'Locations')}
noMargin
className={styles.filterInput(queryType)}
>
<MultiCombobox
aria-label={t('components.resource-picker.locations-filter', 'Locations')}
value={filters.locations}
options={locations}
onChange={(value) => 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')}
/>
</Field>
</Stack>
)}
{shouldShowLimitFlag ? (
<p className={styles.resultLimit}>
<Trans
i18nKey="components.resource-picker.result-limit"
values={{ numResults: resourcePickerData.resultLimit }}
>
Showing first {'{{numResults}}'} results
</Trans>
</p>
) : (
<Space v={2} />
)}
{resourceTable(rows)}
<Modal.ButtonRow>
<Button onClick={onCancel} variant="secondary" fill="outline">
@ -298,15 +506,75 @@ const ResourcePicker = ({
</Button>
<Button
disabled={!!errorMessage || !internalSelected.every(isValid)}
onClick={handleApply}
onClick={
localStorageSave && recentResources
? () => handleApplyWithLocalStorage(recentResources, localStorageSave)
: handleApply
}
data-testid={selectors.components.queryEditor.resourcePicker.apply.button}
>
<Trans i18nKey="components.resource-picker.button-apply">Apply</Trans>
</Button>
</Modal.ButtonRow>
</footer>
</>
);
</>
);
};
// Once the azureResourcePickerUpdates feature toggle is removed, baseResourcePicker can be merged into this function
const tabbedResourcePicker = () => {
return (
<LocalStorageValueProvider<ResourceRowGroup> storageKey={RECENT_RESOURCES_KEY(queryType)} defaultValue={[]}>
{(recentResources, onRecentResourcesSave) => {
return (
<>
<TabsBar>
<Tab
key={'picker'}
label={t('components.resource-picker.browse-tab', 'Browse')}
active={view === 'picker'}
onChangeTab={() => setView('picker')}
/>
<Tab
key={'recent'}
label={t('components.resource-picker.recent-tab', 'Recent')}
active={view === 'recent'}
onChangeTab={() => {
reportInteraction('grafana_ds_azuremonitor_resource_picker_recent_used', {
recentResourcesCount: recentResources.length,
});
setView('recent');
}}
/>
</TabsBar>
<TabContent style={{ margin: '10px' }}>
{view === 'picker' && baseResourcePicker(recentResources, onRecentResourcesSave)}
{view === 'recent' && (
<>
{resourceTable(recentResources)}
<Modal.ButtonRow>
<Button onClick={onCancel} variant="secondary" fill="outline">
<Trans i18nKey="components.resource-picker.button-cancel">Cancel</Trans>
</Button>
<Button
disabled={!!errorMessage || !internalSelected.every(isValid)}
onClick={handleApply}
data-testid={selectors.components.queryEditor.resourcePicker.apply.button}
>
<Trans i18nKey="components.resource-picker.button-apply">Apply</Trans>
</Button>
</Modal.ButtonRow>
</>
)}
</TabContent>
</>
);
}}
</LocalStorageValueProvider>
);
};
return config.featureToggles.azureResourcePickerUpdates ? tabbedResourcePicker() : baseResourcePicker();
};
export default ResourcePicker;

View file

@ -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' }}
/>
);
};

View file

@ -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;

View file

@ -35,6 +35,7 @@ export default class Datasource extends DataSourceWithBackend<AzureMonitorQuery,
azureResourceGraphDatasource: AzureResourceGraphDatasource;
currentUserAuth: boolean;
currentUserAuthFallbackAvailable: boolean;
defaultSubscriptionId?: string;
pseudoDatasource: {
[key in AzureQueryType]?: AzureMonitorDatasource | AzureLogAnalyticsDatasource | AzureResourceGraphDatasource;
@ -78,6 +79,8 @@ export default class Datasource extends DataSourceWithBackend<AzureMonitorQuery,
this.currentUserAuth = instanceSettings.jsonData.azureAuthType === 'currentuser';
this.currentUserAuthFallbackAvailable = false;
}
this.defaultSubscriptionId = instanceSettings.jsonData.subscriptionId;
}
filterQuery(item: AzureMonitorQuery): boolean {
@ -240,6 +243,10 @@ export default class Datasource extends DataSourceWithBackend<AzureMonitorQuery,
});
}
getLocations(subscriptions: string[]) {
return this.azureMonitorDatasource.getLocations(subscriptions);
}
interpolateVariablesInQueries(queries: AzureMonitorQuery[], scopedVars: ScopedVars): AzureMonitorQuery[] {
const mapped = queries.map((query) => {
if (!query.queryType) {
@ -285,6 +292,10 @@ export default class Datasource extends DataSourceWithBackend<AzureMonitorQuery,
}
return { ...query, azureLogAnalytics: { ...query.azureLogAnalytics, query: expression } };
}
getDefaultSubscriptionId() {
return this.defaultSubscriptionId || '';
}
}
function hasQueryForType(query: AzureMonitorQuery): boolean {

View file

@ -64,6 +64,17 @@ export const components = {
input: 'data-testid resource-picker-resource',
},
},
filters: {
subscription: {
input: 'data-testid resource-picker-filter-subscription',
},
type: {
input: 'data-testid resource-picker-filter-type',
},
location: {
input: 'data-testid resource-picker-filter-location',
},
},
},
metricsQueryEditor: {
container: { input: 'data-testid azure-monitor-metrics-query-editor-with-experimental-ui' },

View file

@ -227,16 +227,25 @@
"select-resource": "Select a resource"
},
"resource-picker": {
"browse-tab": "Browse",
"button-apply": "Apply",
"button-cancel": "Cancel",
"header-location": "Location",
"header-scope": "Scope",
"header-type": "Type",
"heading-selection": "Selection",
"locations-filter": "Locations",
"locations-filter-placeholder": "Select a location",
"recent-tab": "Recent",
"result-limit": "Showing first {{numResults}} results",
"subscriptions-filter": "Subscriptions",
"subscriptions-filter-placeholder": "Select a subscription",
"text-loading": "Loading...",
"text-no-recent-resources": "No recent resources found",
"text-no-resources": "No resources found",
"title-error-occurred": "An error occurred while requesting resources from Azure Monitor"
"title-error-occurred": "An error occurred while requesting resources from Azure Monitor",
"types-filter": "Resource Types",
"types-filter-placeholder": "Select a resource type"
},
"scope-selector": {
"label": "Scope"

View file

@ -1,7 +1,8 @@
import { getTemplateSrv, TemplateSrv } from '@grafana/runtime';
import { resourceTypeDisplayNames, resourceTypes } from '../azureMetadata/resourceTypes';
import Datasource from '../datasource';
import { AzureMonitorDataSourceInstanceSettings } from '../types/types';
import { AzureMonitorDataSourceInstanceSettings, AzureMonitorLocations } from '../types/types';
import { createMockInstanceSetttings } from './instanceSettings';
import { DeepPartial } from './utils';
@ -55,13 +56,6 @@ export default function createMockDatasource(overrides?: DeepPartial<Datasource>
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<Datasource>
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<Datasource>
return jest.mocked(mockDatasource);
}
export const createMockLocations = (): Promise<Map<string, AzureMonitorLocations>> => {
return Promise.resolve(
new Map<string, AzureMonitorLocations>([
['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 })));
};

View file

@ -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', () => {

View file

@ -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<ResourceRowGroup> {
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<ResourceRowGroup> {
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<ResourceRowGroup> => {
search = async (
searchPhrase: string,
searchType: ResourcePickerQueryType,
filters: ResourceGraphFilters
): Promise<ResourceRowGroup> => {
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<ResourceRowGroup> {
const subscriptions = await this.azureResourceGraphDatasource.getSubscriptions();
async getSubscriptions(filters?: ResourceGraphFilters): Promise<ResourceRowGroup> {
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<ResourceRowGroup> {
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<ResourceRowGroup> {
const resources = await this.azureResourceGraphDatasource.getResourceNames({ uri }, await this.filterByType(type));
async getResourcesForResourceGroup(
uri: string,
type: ResourcePickerQueryType,
filters?: ResourceGraphFilters
): Promise<ResourceRowGroup> {
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;
};

View file

@ -528,3 +528,9 @@ export function instanceOfLogAnalyticsTableError(
}
return response.hasOwnProperty('error');
}
export interface ResourceGraphFilters {
subscriptions: string[];
types: string[];
locations: string[];
}