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