mirror of
https://github.com/grafana/grafana.git
synced 2026-02-03 20:49:50 -05:00
* 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:
parent
b56b7add01
commit
1a8d25375a
23 changed files with 1682 additions and 186 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -1116,4 +1116,9 @@ export interface FeatureToggles {
|
|||
* @default false
|
||||
*/
|
||||
graphiteBackendMode?: boolean;
|
||||
/**
|
||||
* Enables the updated Azure Monitor resource picker
|
||||
* @default false
|
||||
*/
|
||||
azureResourcePickerUpdates?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ const ResourceField = ({
|
|||
disableRow={disableRow}
|
||||
renderAdvanced={renderAdvanced}
|
||||
selectionNotice={selectionNotice}
|
||||
datasource={datasource}
|
||||
/>
|
||||
</Modal>
|
||||
<Field
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 })));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -528,3 +528,9 @@ export function instanceOfLogAnalyticsTableError(
|
|||
}
|
||||
return response.hasOwnProperty('error');
|
||||
}
|
||||
|
||||
export interface ResourceGraphFilters {
|
||||
subscriptions: string[];
|
||||
types: string[];
|
||||
locations: string[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue