Azure Monitor: Restore Metrics query parameters: subscription, resourceGroup, metricNamespace and resourceName (#52897)

* Azure Monitor: (Components) deprecate ResourceURI (#52982)
This commit is contained in:
Andres Martinez Gotor
2022-08-01 17:48:49 +02:00
committed by GitHub
parent 2948bf01dc
commit a4f56446ee
29 changed files with 859 additions and 720 deletions

View File

@@ -29,8 +29,6 @@ export default function createMockQuery(overrides?: Partial<AzureMonitorQuery>):
azureMonitor: {
// aggOptions: [],
resourceUri:
'/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
aggregation: 'Average',
allowedTimeGrainsMs: [60000, 300000, 900000, 1800000, 3600000, 21600000, 43200000, 86400000],
// dimensionFilter: '*',

View File

@@ -1,4 +1,4 @@
import { startsWith, get, set } from 'lodash';
import { get, set } from 'lodash';
import { DataSourceInstanceSettings } from '@grafana/data';
import { TemplateSrv } from 'app/features/templating/template_srv';
@@ -34,6 +34,50 @@ describe('AzureMonitorDatasource', () => {
ctx.ds = new AzureMonitorDatasource(ctx.instanceSettings);
});
describe('filterQuery', () => {
[
{
description: 'filter query all props',
query: createMockQuery(),
filtered: true,
},
{
description: 'filter query with no resourceGroup',
query: createMockQuery({ azureMonitor: { resourceGroup: undefined } }),
filtered: false,
},
{
description: 'filter query with no resourceName',
query: createMockQuery({ azureMonitor: { resourceName: undefined } }),
filtered: false,
},
{
description: 'filter query with no metricNamespace',
query: createMockQuery({ azureMonitor: { metricNamespace: undefined } }),
filtered: false,
},
{
description: 'filter query with no metricName',
query: createMockQuery({ azureMonitor: { metricName: undefined } }),
filtered: false,
},
{
description: 'filter query with no aggregation',
query: createMockQuery({ azureMonitor: { aggregation: undefined } }),
filtered: false,
},
{
description: 'filter hidden query',
query: createMockQuery({ hide: true }),
filtered: false,
},
].forEach((t) => {
it(t.description, () => {
expect(ctx.ds.filterQuery(t.query)).toEqual(t.filtered);
});
});
});
describe('applyTemplateVariables', () => {
it('should migrate metricDefinition to metricNamespace', () => {
const query = createMockQuery({
@@ -245,7 +289,6 @@ describe('AzureMonitorDatasource', () => {
it('should return a query with any template variables replaced', () => {
const templateableProps = [
'resourceUri',
'resourceGroup',
'resourceName',
'metricNamespace',
@@ -399,16 +442,14 @@ describe('AzureMonitorDatasource', () => {
},
{
name: 'storagetest',
type: 'Microsoft.Storage/storageAccounts',
type: 'microsoft.storage/storageaccounts',
},
],
};
it('should return list of Resource Names', () => {
metricNamespace = 'Microsoft.Storage/storageAccounts/blobServices';
const validMetricNamespace = startsWith(metricNamespace, 'Microsoft.Storage/storageAccounts/')
? 'Microsoft.Storage/storageAccounts'
: metricNamespace;
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(

View File

@@ -62,15 +62,14 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
}
filterQuery(item: AzureMonitorQuery): boolean {
const hasResourceUri = !!item?.azureMonitor?.resourceUri;
const hasLegacyQuery =
const hasResource =
hasValue(item?.azureMonitor?.resourceGroup) &&
hasValue(item?.azureMonitor?.resourceName) &&
hasValue(item?.azureMonitor?.metricDefinition || item?.azureMonitor?.metricNamespace);
return !!(
item.hide !== true &&
(hasResourceUri || hasLegacyQuery) &&
hasResource &&
hasValue(item?.azureMonitor?.metricName) &&
hasValue(item?.azureMonitor?.aggregation)
);
@@ -91,7 +90,6 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
const templateSrv = getTemplateSrv();
const resourceUri = templateSrv.replace(item.resourceUri, scopedVars);
const subscriptionId = templateSrv.replace(target.subscription || this.defaultSubscriptionId, scopedVars);
const resourceGroup = templateSrv.replace(item.resourceGroup, scopedVars);
const resourceName = templateSrv.replace(item.resourceName, scopedVars);
@@ -112,7 +110,6 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
});
const azMonitorQuery: AzureMetricQuery = {
resourceUri,
resourceGroup,
metricNamespace,
resourceName,
@@ -127,16 +124,16 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
if (item.metricDefinition) {
azMonitorQuery.metricDefinition = templateSrv.replace(item.metricDefinition, scopedVars);
}
if (item.resourceUri) {
azMonitorQuery.resourceUri = templateSrv.replace(item.resourceUri, scopedVars);
}
return migrateQuery(
{
...target,
subscription: subscriptionId,
queryType: AzureQueryType.AzureMonitor,
azureMonitor: azMonitorQuery,
},
templateSrv
);
return migrateQuery({
...target,
subscription: subscriptionId,
queryType: AzureQueryType.AzureMonitor,
azureMonitor: azMonitorQuery,
});
}
async getSubscriptions(): Promise<Array<{ text: string; value: string }>> {
@@ -158,8 +155,8 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
}
getResourceNames(subscriptionId: string, resourceGroup?: string, metricNamespace?: string, skipToken?: string) {
const validMetricNamespace = startsWith(metricNamespace, 'Microsoft.Storage/storageAccounts/')
? 'Microsoft.Storage/storageAccounts'
const validMetricNamespace = startsWith(metricNamespace?.toLowerCase(), 'microsoft.storage/storageaccounts/')
? 'microsoft.storage/storageaccounts'
: metricNamespace;
let url = `${this.resourcePath}/subscriptions/${subscriptionId}`;
if (resourceGroup) {
@@ -174,8 +171,8 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
}
return this.getResource(url).then(async (result: any) => {
let list: Array<{ text: string; value: string }> = [];
if (startsWith(metricNamespace, 'Microsoft.Storage/storageAccounts/')) {
list = ResponseParser.parseResourceNames(result, 'Microsoft.Storage/storageAccounts');
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';
@@ -215,13 +212,13 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
);
})
.then((result) => {
if (url.includes('Microsoft.Storage/storageAccounts')) {
if (url.toLowerCase().includes('microsoft.storage/storageaccounts')) {
const storageNamespaces = [
'Microsoft.Storage/storageAccounts',
'Microsoft.Storage/storageAccounts/blobServices',
'Microsoft.Storage/storageAccounts/fileServices',
'Microsoft.Storage/storageAccounts/tableServices',
'Microsoft.Storage/storageAccounts/queueServices',
'microsoft.storage/storageaccounts',
'microsoft.storage/storageaccounts/blobservices',
'microsoft.storage/storageaccounts/fileservices',
'microsoft.storage/storageaccounts/tableservices',
'microsoft.storage/storageaccounts/queueservices',
];
for (const namespace of storageNamespaces) {
if (!find(result, ['value', namespace.toLowerCase()])) {

View File

@@ -19,25 +19,34 @@ describe('AzureMonitorUrlBuilder', () => {
describe('buildResourceUri', () => {
it('builds a resource uri when the required properties are provided', () => {
expect(
UrlBuilder.buildResourceUri('sub', 'group', templateSrv, 'Microsoft.NetApp/netAppAccounts', 'name')
UrlBuilder.buildResourceUri(templateSrv, {
subscription: 'sub',
resourceGroup: 'group',
metricNamespace: 'Microsoft.NetApp/netAppAccounts',
resourceName: 'name',
})
).toEqual('/subscriptions/sub/resourceGroups/group/providers/Microsoft.NetApp/netAppAccounts/name');
});
it('builds a resource uri correctly when a template variable is used as namespace', () => {
expect(UrlBuilder.buildResourceUri('sub', 'group', templateSrv, '$ns', 'name')).toEqual(
'/subscriptions/sub/resourceGroups/group/providers/$ns/name'
);
expect(
UrlBuilder.buildResourceUri(templateSrv, {
subscription: 'sub',
resourceGroup: 'group',
metricNamespace: '$ns',
resourceName: 'name',
})
).toEqual('/subscriptions/sub/resourceGroups/group/providers/$ns/name');
});
it('builds a resource uri correctly when the namespace includes a storage sub-resource', () => {
expect(
UrlBuilder.buildResourceUri(
'sub',
'group',
templateSrv,
'Microsoft.Storage/storageAccounts/tableServices',
'name'
)
UrlBuilder.buildResourceUri(templateSrv, {
subscription: 'sub',
resourceGroup: 'group',
metricNamespace: 'Microsoft.Storage/storageAccounts/tableServices',
resourceName: 'name',
})
).toEqual(
'/subscriptions/sub/resourceGroups/group/providers/Microsoft.Storage/storageAccounts/name/tableServices/default'
);
@@ -56,27 +65,64 @@ describe('AzureMonitorUrlBuilder', () => {
templateSrv = getTemplateSrv();
it('builds a resource uri without specifying a subresource (default)', () => {
expect(UrlBuilder.buildResourceUri('sub', 'group', templateSrv, '$ns/tableServices', 'name')).toEqual(
'/subscriptions/sub/resourceGroups/group/providers/$ns/name/tableServices/default'
);
expect(
UrlBuilder.buildResourceUri(templateSrv, {
subscription: 'sub',
resourceGroup: 'group',
metricNamespace: '$ns/tableServices',
resourceName: 'name',
})
).toEqual('/subscriptions/sub/resourceGroups/group/providers/$ns/name/tableServices/default');
});
it('builds a resource uri specifying a subresource (default)', () => {
expect(UrlBuilder.buildResourceUri('sub', 'group', templateSrv, '$ns/tableServices', 'name/default')).toEqual(
'/subscriptions/sub/resourceGroups/group/providers/$ns/name/tableServices/default'
);
expect(
UrlBuilder.buildResourceUri(templateSrv, {
subscription: 'sub',
resourceGroup: 'group',
metricNamespace: '$ns/tableServices',
resourceName: 'name/default',
})
).toEqual('/subscriptions/sub/resourceGroups/group/providers/$ns/name/tableServices/default');
});
it('builds a resource uri specifying a resource template variable', () => {
expect(UrlBuilder.buildResourceUri('sub', 'group', templateSrv, '$ns/tableServices', '$rs/default')).toEqual(
'/subscriptions/sub/resourceGroups/group/providers/$ns/$rs/tableServices/default'
);
expect(
UrlBuilder.buildResourceUri(templateSrv, {
subscription: 'sub',
resourceGroup: 'group',
metricNamespace: '$ns/tableServices',
resourceName: '$rs/default',
})
).toEqual('/subscriptions/sub/resourceGroups/group/providers/$ns/$rs/tableServices/default');
});
it('builds a resource uri specifying multiple template variables', () => {
expect(UrlBuilder.buildResourceUri('sub', 'group', templateSrv, '$ns/$ns2', '$rs/$rs2')).toEqual(
'/subscriptions/sub/resourceGroups/group/providers/$ns/$rs/$ns2/$rs2'
);
expect(
UrlBuilder.buildResourceUri(templateSrv, {
subscription: 'sub',
resourceGroup: 'group',
metricNamespace: '$ns/$ns2',
resourceName: '$rs/$rs2',
})
).toEqual('/subscriptions/sub/resourceGroups/group/providers/$ns/$rs/$ns2/$rs2');
});
it('builds a resource uri with only a subscription', () => {
expect(
UrlBuilder.buildResourceUri(templateSrv, {
subscription: 'sub',
})
).toEqual('/subscriptions/sub');
});
it('builds a resource uri with a subscription and a resource group', () => {
expect(
UrlBuilder.buildResourceUri(templateSrv, {
subscription: 'sub',
resourceGroup: 'group',
})
).toEqual('/subscriptions/sub/resourceGroups/group');
});
});
});

View File

@@ -1,45 +1,48 @@
import { TemplateSrv } from '@grafana/runtime';
import { GetMetricNamespacesQuery, GetMetricNamesQuery } from '../types';
import { AzureMetricResource, GetMetricNamespacesQuery, GetMetricNamesQuery } from '../types';
export default class UrlBuilder {
static buildResourceUri(
subscriptionId: string,
resourceGroup: string,
templateSrv: TemplateSrv,
metricNamespace?: string,
resourceName?: string
) {
const urlArray = ['/subscriptions', subscriptionId, 'resourceGroups', resourceGroup];
static buildResourceUri(templateSrv: TemplateSrv, resource: AzureMetricResource) {
const urlArray = [];
const { subscription, resourceGroup, metricNamespace, resourceName } = resource;
if (metricNamespace && resourceName) {
const metricNamespaceProcessed = templateSrv.replace(metricNamespace);
const metricNamespaceArray = metricNamespace.split('/');
const resourceNameProcessed = templateSrv.replace(resourceName);
const resourceNameArray = resourceName.split('/');
const provider = metricNamespaceArray.shift();
if (provider) {
urlArray.push('providers', provider);
}
if (subscription) {
urlArray.push('/subscriptions', subscription);
if (
metricNamespaceProcessed.startsWith('Microsoft.Storage/storageAccounts/') &&
!resourceNameProcessed.endsWith('default')
) {
resourceNameArray.push('default');
}
if (resourceGroup) {
urlArray.push('resourceGroups', resourceGroup);
if (resourceNameArray.length > metricNamespaceArray.length) {
const parentResource = resourceNameArray.shift();
if (parentResource) {
urlArray.push(parentResource);
if (metricNamespace && resourceName) {
const metricNamespaceProcessed = templateSrv.replace(metricNamespace);
const metricNamespaceArray = metricNamespace.split('/');
const resourceNameProcessed = templateSrv.replace(resourceName);
const resourceNameArray = resourceName.split('/');
const provider = metricNamespaceArray.shift();
if (provider) {
urlArray.push('providers', provider);
}
if (
metricNamespaceProcessed.toLowerCase().startsWith('microsoft.storage/storageaccounts/') &&
!resourceNameProcessed.endsWith('default')
) {
resourceNameArray.push('default');
}
if (resourceNameArray.length > metricNamespaceArray.length) {
const parentResource = resourceNameArray.shift();
if (parentResource) {
urlArray.push(parentResource);
}
}
for (const i in metricNamespaceArray) {
urlArray.push(metricNamespaceArray[i]);
urlArray.push(resourceNameArray[i]);
}
}
}
for (const i in metricNamespaceArray) {
urlArray.push(metricNamespaceArray[i]);
urlArray.push(resourceNameArray[i]);
}
}
return urlArray.join('/');
@@ -57,13 +60,12 @@ export default class UrlBuilder {
resourceUri = query.resourceUri;
} else {
const { subscription, resourceGroup, metricNamespace, resourceName } = query;
resourceUri = UrlBuilder.buildResourceUri(
resourceUri = UrlBuilder.buildResourceUri(templateSrv, {
subscription,
resourceGroup,
templateSrv,
metricNamespace,
resourceName
);
resourceName,
});
}
return `${baseUrl}${resourceUri}/providers/microsoft.insights/metricNamespaces?region=global&api-version=${apiVersion}`;
@@ -82,13 +84,12 @@ export default class UrlBuilder {
resourceUri = query.resourceUri;
} else {
const { subscription, resourceGroup, metricNamespace, resourceName } = query;
resourceUri = UrlBuilder.buildResourceUri(
resourceUri = UrlBuilder.buildResourceUri(templateSrv, {
subscription,
resourceGroup,
templateSrv,
metricNamespace,
resourceName
);
resourceName,
});
}
let url = `${baseUrl}${resourceUri}/providers/microsoft.insights/metricdefinitions?api-version=${apiVersion}`;

View File

@@ -9,7 +9,6 @@ import { ResourceRowType } from '../ResourcePicker/types';
import FormatAsField from './FormatAsField';
import QueryField from './QueryField';
import { setResource } from './setQueryValue';
import useMigrations from './useMigrations';
interface LogsQueryEditorProps {
@@ -53,8 +52,7 @@ const LogsQueryEditor: React.FC<LogsQueryEditorProps> = ({
ResourceRowType.Resource,
ResourceRowType.Variable,
]}
setResource={setResource}
resourceUri={query.azureLogAnalytics?.resource}
resource={query.azureLogAnalytics?.resource ?? ''}
queryType="logs"
/>
</EditorFieldGroup>

View File

@@ -19,13 +19,3 @@ export function setFormatAs(query: AzureMonitorQuery, formatAs: string): AzureMo
},
};
}
export function setResource(query: AzureMonitorQuery, resourceURI: string | undefined): AzureMonitorQuery {
return {
...query,
azureLogAnalytics: {
...query.azureLogAnalytics,
resource: resourceURI,
},
};
}

View File

@@ -41,7 +41,7 @@ const AggregationField: React.FC<AggregationFieldProps> = ({
<Field label="Aggregation">
<Select
inputId="azure-monitor-metrics-aggregation-field"
value={query.azureMonitor?.aggregation}
value={query.azureMonitor?.aggregation || null}
onChange={handleChange}
options={options}
isLoading={isLoading}

View File

@@ -43,7 +43,7 @@ const MetricNamespaceField: React.FC<MetricNamespaceFieldProps> = ({
<Field label="Metric namespace">
<Select
inputId="azure-monitor-metrics-metric-namespace-field"
value={query.azureMonitor?.metricNamespace}
value={query.azureMonitor?.metricNamespace || null}
onChange={handleChange}
options={options}
allowCustomValue

View File

@@ -16,6 +16,15 @@ import ResourcePickerData from '../../resourcePicker/resourcePickerData';
import MetricsQueryEditor from './MetricsQueryEditor';
jest.mock('@grafana/runtime', () => ({
...(jest.requireActual('@grafana/runtime') as unknown as object),
getTemplateSrv: () => ({
replace: (val: string) => {
return val;
},
}),
}));
const variableOptionGroup = {
label: 'Template variables',
options: [],
@@ -66,7 +75,10 @@ describe('MetricsQueryEditor', () => {
it('should change resource when a resource is selected in the ResourcePicker', async () => {
const mockDatasource = createMockDatasource({ resourcePickerData: createMockResourcePickerData() });
const query = createMockQuery();
delete query?.azureMonitor?.resourceUri;
delete query?.subscription;
delete query?.azureMonitor?.resourceGroup;
delete query?.azureMonitor?.resourceName;
delete query?.azureMonitor?.metricNamespace;
const onChange = jest.fn();
render(
@@ -105,68 +117,11 @@ describe('MetricsQueryEditor', () => {
expect(onChange).toBeCalledTimes(1);
expect(onChange).toBeCalledWith(
expect.objectContaining({
subscription: 'def-456',
azureMonitor: expect.objectContaining({
resourceUri:
'/subscriptions/def-456/resourceGroups/dev-3/providers/Microsoft.Compute/virtualMachines/web-server',
}),
})
);
});
it('should reset metric namespace, metric name, and aggregation fields after selecting a new resource when a valid query has already been set', async () => {
const mockDatasource = createMockDatasource({ resourcePickerData: createMockResourcePickerData() });
const query = createMockQuery();
const onChange = jest.fn();
render(
<MetricsQueryEditor
data={mockPanelData}
query={query}
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
setError={() => {}}
/>
);
const resourcePickerButton = await screen.findByRole('button', { name: /grafana/ });
expect(screen.getByText('Microsoft.Compute/virtualMachines')).toBeInTheDocument();
expect(screen.getByText('Metric A')).toBeInTheDocument();
expect(screen.getByText('Average')).toBeInTheDocument();
expect(resourcePickerButton).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Expand Primary Subscription' })).not.toBeInTheDocument();
resourcePickerButton.click();
const subscriptionButton = await screen.findByRole('button', { name: 'Expand Dev Subscription' });
expect(subscriptionButton).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Expand Development 3' })).not.toBeInTheDocument();
subscriptionButton.click();
const resourceGroupButton = await screen.findByRole('button', { name: 'Expand Development 3' });
expect(resourceGroupButton).toBeInTheDocument();
expect(screen.queryByLabelText('db-server')).not.toBeInTheDocument();
resourceGroupButton.click();
const checkbox = await screen.findByLabelText('db-server');
expect(checkbox).toBeInTheDocument();
expect(checkbox).not.toBeChecked();
await userEvent.click(checkbox);
expect(checkbox).toBeChecked();
await userEvent.click(await screen.findByRole('button', { name: 'Apply' }));
expect(onChange).toBeCalledTimes(1);
expect(onChange).toBeCalledWith(
expect.objectContaining({
azureMonitor: expect.objectContaining({
resourceUri:
'/subscriptions/def-456/resourceGroups/dev-3/providers/Microsoft.Compute/virtualMachines/db-server',
metricNamespace: undefined,
metricName: undefined,
aggregation: undefined,
timeGrain: '',
dimensionFilters: [],
metricNamespace: 'microsoft.compute/virtualmachines',
resourceGroup: 'dev-3',
resourceName: 'web-server',
}),
})
);

View File

@@ -4,7 +4,7 @@ import { PanelData } from '@grafana/data/src/types';
import { EditorRows, EditorRow, EditorFieldGroup } from '@grafana/ui';
import type Datasource from '../../datasource';
import type { AzureMonitorQuery, AzureMonitorOption, AzureMonitorErrorish } from '../../types';
import type { AzureMonitorQuery, AzureMonitorOption, AzureMonitorErrorish, AzureMetricResource } from '../../types';
import ResourceField from '../ResourceField';
import { ResourceRowType } from '../ResourcePicker/types';
@@ -16,7 +16,6 @@ import MetricNamespaceField from './MetricNamespaceField';
import TimeGrainField from './TimeGrainField';
import TopField from './TopField';
import { useMetricNames, useMetricNamespaces, useMetricMetadata } from './dataHooks';
import { setResource } from './setQueryValue';
interface MetricsQueryEditorProps {
data: PanelData | undefined;
@@ -38,6 +37,12 @@ const MetricsQueryEditor: React.FC<MetricsQueryEditorProps> = ({
const metricsMetadata = useMetricMetadata(query, datasource, onChange);
const metricNamespaces = useMetricNamespaces(query, datasource, onChange, setError);
const metricNames = useMetricNames(query, datasource, onChange, setError);
const resource: AzureMetricResource = {
subscription: query.subscription,
resourceGroup: query.azureMonitor?.resourceGroup,
metricNamespace: query.azureMonitor?.metricNamespace,
resourceName: query.azureMonitor?.resourceName,
};
return (
<span data-testid="azure-monitor-metrics-query-editor-with-experimental-ui">
<EditorRows>
@@ -50,8 +55,7 @@ const MetricsQueryEditor: React.FC<MetricsQueryEditorProps> = ({
onQueryChange={onChange}
setError={setError}
selectableEntryTypes={[ResourceRowType.Resource]}
setResource={setResource}
resourceUri={query.azureMonitor?.resourceUri}
resource={resource}
queryType={'metrics'}
/>
<MetricNamespaceField

View File

@@ -44,14 +44,14 @@ describe('AzureMonitor: metrics dataHooks', () => {
name: 'useMetricNames',
hook: useMetricNames,
emptyQueryPartial: {
resourceUri:
'/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
metricNamespace: 'azure/vm',
resourceGroup: 'rg',
resourceName: 'rn',
},
customProperties: {
resourceUri:
'/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
metricNamespace: 'azure/vm',
resourceGroup: 'rg',
resourceName: 'rn',
metricName: 'metric-$ENVIRONMENT',
},
expectedOptions: [
@@ -74,14 +74,14 @@ describe('AzureMonitor: metrics dataHooks', () => {
name: 'useMetricNamespaces',
hook: useMetricNamespaces,
emptyQueryPartial: {
resourceUri:
'/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
metricNamespace: 'azure/vm',
resourceGroup: 'rg',
resourceName: 'rn',
},
customProperties: {
resourceUri:
'/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
metricNamespace: 'azure/vm-$ENVIRONMENT',
resourceGroup: 'rg',
resourceName: 'rn',
metricName: 'metric-name',
},
expectedOptions: [
@@ -188,8 +188,8 @@ describe('AzureMonitor: metrics dataHooks', () => {
name: 'useMetricMetadata',
hook: useMetricMetadata,
emptyQueryPartial: {
resourceUri:
'/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
resourceGroup: 'rg',
resourceName: 'rn',
metricNamespace: 'azure/vm',
metricName: 'Average CPU',
},

View File

@@ -39,15 +39,21 @@ export interface MetricMetadata {
type OnChangeFn = (newQuery: AzureMonitorQuery) => void;
export const useMetricNamespaces: DataHook = (query, datasource, onChange, setError) => {
const { metricNamespace, resourceUri } = query.azureMonitor ?? {};
const { subscription } = query;
const { metricNamespace, resourceGroup, resourceName } = query.azureMonitor ?? {};
const metricNamespaces = useAsyncState(
async () => {
if (!resourceUri) {
if (!subscription || !resourceGroup || !resourceName) {
return;
}
const results = await datasource.azureMonitorDatasource.getMetricNamespaces({ resourceUri });
const results = await datasource.azureMonitorDatasource.getMetricNamespaces({
subscription,
metricNamespace,
resourceGroup,
resourceName,
});
const options = formatOptions(results, metricNamespace);
// Do some cleanup of the query state if need be
@@ -58,28 +64,34 @@ export const useMetricNamespaces: DataHook = (query, datasource, onChange, setEr
return options;
},
setError,
[resourceUri]
[subscription, metricNamespace, resourceGroup, resourceName]
);
return metricNamespaces;
};
export const useMetricNames: DataHook = (query, datasource, onChange, setError) => {
const { metricNamespace, metricName, resourceUri } = query.azureMonitor ?? {};
const { subscription } = query;
const { metricNamespace, metricName, resourceGroup, resourceName } = query.azureMonitor ?? {};
return useAsyncState(
async () => {
if (!(metricNamespace && resourceUri)) {
if (!subscription || !metricNamespace || !resourceGroup || !resourceName) {
return;
}
const results = await datasource.azureMonitorDatasource.getMetricNames({ resourceUri, metricNamespace });
const results = await datasource.azureMonitorDatasource.getMetricNames({
subscription,
resourceGroup,
resourceName,
metricNamespace,
});
const options = formatOptions(results, metricName);
return options;
},
setError,
[resourceUri, metricNamespace]
[subscription, resourceGroup, resourceName, metricNamespace]
);
};
@@ -94,18 +106,18 @@ const defaultMetricMetadata: MetricMetadata = {
export const useMetricMetadata = (query: AzureMonitorQuery, datasource: Datasource, onChange: OnChangeFn) => {
const [metricMetadata, setMetricMetadata] = useState<MetricMetadata>(defaultMetricMetadata);
const { resourceUri, metricNamespace, metricName, aggregation, timeGrain } = query.azureMonitor ?? {};
const { subscription } = query;
const { resourceGroup, resourceName, metricNamespace, metricName, aggregation, timeGrain } = query.azureMonitor ?? {};
// Fetch new metric metadata when the fields change
useEffect(() => {
if (!(resourceUri && metricNamespace && metricName)) {
if (!subscription || !resourceGroup || !resourceName || !metricNamespace || !metricName) {
setMetricMetadata(defaultMetricMetadata);
return;
}
datasource.azureMonitorDatasource
.getMetricMetadata({ resourceUri, metricNamespace, metricName })
.getMetricMetadata({ subscription, resourceGroup, resourceName, metricNamespace, metricName })
.then((metadata) => {
// TODO: Move the aggregationTypes and timeGrain defaults into `getMetricMetadata`
const aggregations = (metadata.supportedAggTypes || [metadata.primaryAggType]).map((v) => ({
@@ -122,7 +134,7 @@ export const useMetricMetadata = (query: AzureMonitorQuery, datasource: Datasour
primaryAggType: metadata.primaryAggType,
});
});
}, [datasource, resourceUri, metricNamespace, metricName]);
}, [datasource, subscription, resourceGroup, resourceName, metricNamespace, metricName]);
// Update the query state in response to the meta data changing
useEffect(() => {

View File

@@ -1,27 +0,0 @@
import createMockQuery from '../../__mocks__/query';
import { setResource } from './setQueryValue';
describe('setResource', () => {
it('should set a resource URI', () => {
const q = setResource(createMockQuery(), '/new-uri');
expect(q.azureMonitor?.resourceUri).toEqual('/new-uri');
});
it('should remove clean up dependent fields', () => {
const q = createMockQuery();
expect(q.azureMonitor?.metricNamespace).not.toEqual(undefined);
expect(q.azureMonitor?.metricName).not.toEqual(undefined);
expect(q.azureMonitor?.aggregation).not.toEqual(undefined);
expect(q.azureMonitor?.timeGrain).not.toEqual('');
expect(q.azureMonitor?.timeGrain).not.toEqual([]);
const newQ = setResource(createMockQuery(), '/new-uri');
expect(newQ.azureMonitor).toMatchObject({
metricNamespace: undefined,
metricName: undefined,
aggregation: undefined,
timeGrain: '',
dimensionFilters: [],
});
});
});

View File

@@ -1,42 +1,10 @@
import { AzureMetricDimension, AzureMonitorQuery } from '../../types';
export function setResource(query: AzureMonitorQuery, resourceURI: string | undefined): AzureMonitorQuery {
return {
...query,
azureMonitor: {
...query.azureMonitor,
resourceUri: resourceURI,
metricNamespace: undefined,
metricName: undefined,
aggregation: undefined,
timeGrain: '',
dimensionFilters: [],
},
};
}
export function setMetricNamespace(query: AzureMonitorQuery, metricNamespace: string | undefined): AzureMonitorQuery {
if (query.azureMonitor?.metricNamespace === metricNamespace) {
return query;
}
let resourceUri = query.azureMonitor?.resourceUri;
// Storage Account URIs need to be handled differently due to the additional storage services (blob/queue/table/file).
// When one of these namespaces is selected it does not form a part of the URI for the storage account and so must be appended.
// The 'default' path must also be appended. Without these two paths any API call will fail.
if (resourceUri && metricNamespace?.includes('Microsoft.Storage/storageAccounts')) {
const splitUri = resourceUri.split('/');
const accountNameIndex = splitUri.findIndex((item) => item === 'storageAccounts') + 1;
const baseUri = splitUri.slice(0, accountNameIndex + 1).join('/');
if (metricNamespace === 'Microsoft.Storage/storageAccounts') {
resourceUri = baseUri;
} else {
const subNamespace = metricNamespace.split('/')[2];
resourceUri = `${baseUri}/${subNamespace}/default`;
}
}
return {
...query,
azureMonitor: {
@@ -46,7 +14,6 @@ export function setMetricNamespace(query: AzureMonitorQuery, metricNamespace: st
aggregation: undefined,
timeGrain: '',
dimensionFilters: [],
resourceUri,
},
};
}

View File

@@ -45,7 +45,7 @@ const QueryEditor: React.FC<AzureMonitorQueryEditorProps> = ({
[onChange, onRunQuery]
);
const query = usePreparedQuery(baseQuery, onQueryChange, setError);
const query = usePreparedQuery(baseQuery, onQueryChange);
const subscriptionId = query.subscription || datasource.azureMonitorDatasource.defaultSubscriptionId;
const variableOptionGroup = {

View File

@@ -2,22 +2,17 @@ import deepEqual from 'fast-deep-equal';
import { defaults } from 'lodash';
import { useEffect, useMemo } from 'react';
import { getTemplateSrv } from '@grafana/runtime';
import { AzureMonitorErrorish, AzureMonitorQuery, AzureQueryType } from '../../types';
import { AzureMonitorQuery, AzureQueryType } from '../../types';
import migrateQuery from '../../utils/migrateQuery';
const DEFAULT_QUERY = {
queryType: AzureQueryType.AzureMonitor,
};
const prepareQuery = (
query: AzureMonitorQuery,
setError: (errorSource: string, error: AzureMonitorErrorish) => void
) => {
const prepareQuery = (query: AzureMonitorQuery) => {
// Note: _.defaults does not apply default values deeply.
const withDefaults = defaults({}, query, DEFAULT_QUERY);
const migratedQuery = migrateQuery(withDefaults, getTemplateSrv(), setError);
const migratedQuery = migrateQuery(withDefaults);
// If we didn't make any changes to the object, then return the original object to keep the
// identity the same, and not trigger any other useEffects or anything.
@@ -27,12 +22,8 @@ const prepareQuery = (
/**
* Returns queries with some defaults + migrations, and calls onChange function to notify if it changes
*/
const usePreparedQuery = (
query: AzureMonitorQuery,
onChangeQuery: (newQuery: AzureMonitorQuery) => void,
setError: (errorSource: string, error: AzureMonitorErrorish) => void
) => {
const preparedQuery = useMemo(() => prepareQuery(query, setError), [query, setError]);
const usePreparedQuery = (query: AzureMonitorQuery, onChangeQuery: (newQuery: AzureMonitorQuery) => void) => {
const preparedQuery = useMemo(() => prepareQuery(query), [query]);
useEffect(() => {
if (preparedQuery !== query) {

View File

@@ -5,44 +5,28 @@ import { Button, Icon, Modal, useStyles2 } from '@grafana/ui';
import Datasource from '../../datasource';
import { ResourcePickerQueryType } from '../../resourcePicker/resourcePickerData';
import { AzureQueryEditorFieldProps, AzureMonitorQuery, AzureResourceSummaryItem } from '../../types';
import { AzureQueryEditorFieldProps, AzureMetricResource } from '../../types';
import { Field } from '../Field';
import ResourcePicker from '../ResourcePicker';
import getStyles from '../ResourcePicker/styles';
import { ResourceRowType } from '../ResourcePicker/types';
import { parseResourceURI } from '../ResourcePicker/utils';
import { parseResourceDetails, setResource } from '../ResourcePicker/utils';
function parseResourceDetails(resourceURI: string) {
const parsed = parseResourceURI(resourceURI);
if (!parsed) {
return undefined;
}
return {
subscriptionName: parsed.subscriptionID,
resourceGroupName: parsed.resourceGroup,
resourceName: parsed.resource,
};
}
interface ResourceFieldProps extends AzureQueryEditorFieldProps {
setResource: (query: AzureMonitorQuery, resourceURI?: string) => AzureMonitorQuery;
interface ResourceFieldProps<T> extends AzureQueryEditorFieldProps {
selectableEntryTypes: ResourceRowType[];
queryType: ResourcePickerQueryType;
resourceUri?: string;
resource: T;
inlineField?: boolean;
labelWidth?: number;
}
const ResourceField: React.FC<ResourceFieldProps> = ({
const ResourceField: React.FC<ResourceFieldProps<string | AzureMetricResource>> = ({
query,
datasource,
onQueryChange,
setResource,
selectableEntryTypes,
queryType,
resourceUri,
resource,
inlineField,
labelWidth,
}) => {
@@ -58,11 +42,11 @@ const ResourceField: React.FC<ResourceFieldProps> = ({
}, []);
const handleApply = useCallback(
(resourceURI: string | undefined) => {
onQueryChange(setResource(query, resourceURI));
(resource: string | AzureMetricResource | undefined) => {
onQueryChange(setResource(query, resource));
closePicker();
},
[closePicker, onQueryChange, query, setResource]
[closePicker, onQueryChange, query]
);
return (
@@ -78,7 +62,7 @@ const ResourceField: React.FC<ResourceFieldProps> = ({
>
<ResourcePicker
resourcePickerData={datasource.resourcePickerData}
resourceURI={resourceUri}
resource={resource}
onApply={handleApply}
onCancel={closePicker}
selectableEntryTypes={selectableEntryTypes}
@@ -87,30 +71,32 @@ const ResourceField: React.FC<ResourceFieldProps> = ({
</Modal>
<Field label="Resource" inlineField={inlineField} labelWidth={labelWidth}>
<Button className={styles.resourceFieldButton} variant="secondary" onClick={handleOpenPicker} type="button">
<ResourceLabel resource={resourceUri} datasource={datasource} />
<ResourceLabel resource={resource} datasource={datasource} />
</Button>
</Field>
</>
);
};
interface ResourceLabelProps {
resource: string | undefined;
interface ResourceLabelProps<T> {
resource: T;
datasource: Datasource;
}
const ResourceLabel = ({ resource, datasource }: ResourceLabelProps) => {
const ResourceLabel = ({ resource, datasource }: ResourceLabelProps<string | AzureMetricResource>) => {
const [resourceComponents, setResourceComponents] = useState(parseResourceDetails(resource ?? ''));
useEffect(() => {
if (resource && parseResourceDetails(resource)) {
datasource.resourcePickerData.getResourceURIDisplayProperties(resource).then(setResourceComponents);
typeof resource === 'string'
? datasource.resourcePickerData.getResourceURIDisplayProperties(resource).then(setResourceComponents)
: setResourceComponents(resource);
} else {
setResourceComponents(undefined);
setResourceComponents({});
}
}, [datasource.resourcePickerData, resource]);
if (!resource) {
if (!resource || (typeof resource === 'object' && !resource.subscription)) {
return <>Select a resource</>;
}
@@ -118,19 +104,11 @@ const ResourceLabel = ({ resource, datasource }: ResourceLabelProps) => {
return <FormattedResource resource={resourceComponents} />;
}
if (resource.startsWith('$')) {
return (
<span>
<Icon name="x" /> {resource}
</span>
);
}
return <>{resource}</>;
};
interface FormattedResourceProps {
resource: AzureResourceSummaryItem;
resource: AzureMetricResource;
}
const FormattedResource = ({ resource }: FormattedResourceProps) => {
@@ -139,20 +117,20 @@ const FormattedResource = ({ resource }: FormattedResourceProps) => {
if (resource.resourceName) {
return (
<span className={cx(styles.truncated, styles.resourceField)}>
<Icon name="cube" /> {resource.resourceName}
<Icon name="cube" /> {resource.resourceName.split('/')[0]}
</span>
);
}
if (resource.resourceGroupName) {
if (resource.resourceGroup) {
return (
<span>
<Icon name="folder" /> {resource.resourceGroupName}
<Icon name="folder" /> {resource.resourceGroup}
</span>
);
}
return (
<span>
<Icon name="layer-group" /> {resource.subscriptionName}
<Icon name="layer-group" /> {resource.subscription}
</span>
);
};

View File

@@ -0,0 +1,35 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import Advanced from './Advanced';
describe('AzureMonitor ResourcePicker', () => {
it('should set a parameter as an object', async () => {
const onChange = jest.fn();
const { rerender } = render(<Advanced onChange={onChange} resource={{}} />);
const advancedSection = screen.getByText('Advanced');
advancedSection.click();
const subsInput = await screen.findByLabelText('Subscription');
await userEvent.type(subsInput, 'd');
expect(onChange).toHaveBeenCalledWith({ subscription: 'd' });
rerender(<Advanced onChange={onChange} resource={{ subscription: 'def-123' }} />);
expect(screen.getByLabelText('Subscription').outerHTML).toMatch('value="def-123"');
});
it('should set a parameter as uri', async () => {
const onChange = jest.fn();
const { rerender } = render(<Advanced onChange={onChange} resource={''} />);
const advancedSection = screen.getByText('Advanced');
advancedSection.click();
const subsInput = await screen.findByLabelText('Resource URI');
await userEvent.type(subsInput, '/');
expect(onChange).toHaveBeenCalledWith('/');
rerender(<Advanced onChange={onChange} resource={'/subscriptions/sub'} />);
expect(screen.getByLabelText('Resource URI').outerHTML).toMatch('value="/subscriptions/sub"');
});
});

View File

@@ -0,0 +1,124 @@
import React, { useState } from 'react';
import { Icon, Input, Tooltip, Collapse, Label, InlineField } from '@grafana/ui';
import { AzureMetricResource } from '../../types';
import { Space } from '../Space';
interface ResourcePickerProps<T> {
resource: T;
onChange: (resource: T) => void;
}
const Advanced = ({ resource, onChange }: ResourcePickerProps<string | AzureMetricResource>) => {
const [isAdvancedOpen, setIsAdvancedOpen] = useState(!!resource && JSON.stringify(resource).includes('$'));
return (
<div>
<Collapse
collapsible
label="Advanced"
isOpen={isAdvancedOpen}
onToggle={() => setIsAdvancedOpen(!isAdvancedOpen)}
>
{typeof resource === 'string' ? (
<>
{' '}
<Label htmlFor="input-advanced-resource-picker">
<h6>
Resource URI{' '}
<Tooltip
content={
<>
Manually edit the{' '}
<a
href="https://docs.microsoft.com/en-us/azure/azure-monitor/logs/log-standard-columns#_resourceid"
rel="noopener noreferrer"
target="_blank"
>
resource uri.{' '}
</a>
Supports the use of multiple template variables (ex: /subscriptions/$subId/resourceGroups/$rg)
</>
}
placement="right"
interactive={true}
>
<Icon name="info-circle" />
</Tooltip>
</h6>
</Label>
<Input
id="input-advanced-resource-picker"
value={resource}
onChange={(event) => onChange(event.currentTarget.value)}
placeholder="ex: /subscriptions/$subId"
/>
</>
) : (
<>
<InlineField
label="Subscription"
grow
transparent
htmlFor="input-advanced-resource-picker-subscription"
labelWidth={15}
>
<Input
id="input-advanced-resource-picker-subscription"
value={resource?.subscription ?? ''}
onChange={(event) => onChange({ ...resource, subscription: event.currentTarget.value })}
placeholder="aaaaaaaa-bbbb-cccc-dddd-eeeeeeee"
/>
</InlineField>
<InlineField
label="Resource Group"
grow
transparent
htmlFor="input-advanced-resource-picker-resourceGroup"
labelWidth={15}
>
<Input
id="input-advanced-resource-picker-resourceGroup"
value={resource?.resourceGroup ?? ''}
onChange={(event) => onChange({ ...resource, resourceGroup: event.currentTarget.value })}
placeholder="resource-group"
/>
</InlineField>
<InlineField
label="Metric Namespace"
grow
transparent
htmlFor="input-advanced-resource-picker-metricNamespace"
labelWidth={15}
>
<Input
id="input-advanced-resource-picker-metricNamespace"
value={resource?.metricNamespace ?? ''}
onChange={(event) => onChange({ ...resource, metricNamespace: event.currentTarget.value })}
placeholder="Microsoft.Insights/metricNamespaces"
/>
</InlineField>
<InlineField
label="Resource Name"
grow
transparent
htmlFor="input-advanced-resource-picker-resourceName"
labelWidth={15}
>
<Input
id="input-advanced-resource-picker-resourceName"
value={resource?.resourceName ?? ''}
onChange={(event) => onChange({ ...resource, resourceName: event.currentTarget.value })}
placeholder="name"
/>
</InlineField>
</>
)}
<Space v={2} />
</Collapse>
</div>
);
};
export default Advanced;

View File

@@ -41,7 +41,7 @@ const queryType: ResourcePickerQueryType = 'logs';
const defaultProps = {
templateVariables: [],
resourceURI: noResourceURI,
resource: noResourceURI,
resourcePickerData: createMockResourcePickerData(),
onCancel: noop,
onApply: noop,
@@ -59,7 +59,7 @@ describe('AzureMonitor ResourcePicker', () => {
window.HTMLElement.prototype.scrollIntoView = jest.fn();
});
it('should pre-load subscriptions when there is no existing selection', async () => {
render(<ResourcePicker {...defaultProps} resourceURI={noResourceURI} />);
render(<ResourcePicker {...defaultProps} resource={noResourceURI} />);
const subscriptionCheckbox = await screen.findByLabelText('Primary Subscription');
expect(subscriptionCheckbox).toBeInTheDocument();
expect(subscriptionCheckbox).not.toBeChecked();
@@ -68,7 +68,7 @@ describe('AzureMonitor ResourcePicker', () => {
});
it('should show a subscription as selected if there is one saved', async () => {
render(<ResourcePicker {...defaultProps} resourceURI={singleSubscriptionSelectionURI} />);
render(<ResourcePicker {...defaultProps} resource={singleSubscriptionSelectionURI} />);
const subscriptionCheckboxes = await screen.findAllByLabelText('Dev Subscription');
expect(subscriptionCheckboxes.length).toBe(2);
expect(subscriptionCheckboxes[0]).toBeChecked();
@@ -76,7 +76,7 @@ describe('AzureMonitor ResourcePicker', () => {
});
it('should show a resourceGroup as selected if there is one saved', async () => {
render(<ResourcePicker {...defaultProps} resourceURI={singleResourceGroupSelectionURI} />);
render(<ResourcePicker {...defaultProps} resource={singleResourceGroupSelectionURI} />);
const resourceGroupCheckboxes = await screen.findAllByLabelText('A Great Resource Group');
expect(resourceGroupCheckboxes.length).toBe(2);
expect(resourceGroupCheckboxes[0]).toBeChecked();
@@ -84,7 +84,7 @@ describe('AzureMonitor ResourcePicker', () => {
});
it('should show scroll down to a resource and mark it as selected if there is one saved', async () => {
render(<ResourcePicker {...defaultProps} resourceURI={singleResourceSelectionURI} />);
render(<ResourcePicker {...defaultProps} resource={singleResourceSelectionURI} />);
const resourceCheckboxes = await screen.findAllByLabelText('db-server');
expect(resourceCheckboxes.length).toBe(2);
expect(resourceCheckboxes[0]).toBeChecked();
@@ -92,7 +92,7 @@ describe('AzureMonitor ResourcePicker', () => {
});
it('opens the selected nested resources', async () => {
render(<ResourcePicker {...defaultProps} resourceURI={singleResourceSelectionURI} />);
render(<ResourcePicker {...defaultProps} resource={singleResourceSelectionURI} />);
const collapseSubscriptionBtn = await screen.findByLabelText('Collapse Dev Subscription');
expect(collapseSubscriptionBtn).toBeInTheDocument();
const collapseResourceGroupBtn = await screen.findByLabelText('Collapse A Great Resource Group');
@@ -100,7 +100,7 @@ describe('AzureMonitor ResourcePicker', () => {
});
it('scrolls down to the selected resource', async () => {
render(<ResourcePicker {...defaultProps} resourceURI={singleResourceSelectionURI} />);
render(<ResourcePicker {...defaultProps} resource={singleResourceSelectionURI} />);
await screen.findByLabelText('Collapse A Great Resource Group');
expect(window.HTMLElement.prototype.scrollIntoView).toBeCalledTimes(1);
});
@@ -127,6 +127,19 @@ describe('AzureMonitor ResourcePicker', () => {
expect(onApply).toBeCalledWith('/subscriptions/def-123');
});
it('should call onApply with a new subscription when a user clicks on the checkbox in the row', async () => {
const onApply = jest.fn();
render(<ResourcePicker {...defaultProps} onApply={onApply} resource={{}} />);
const subscriptionCheckbox = await screen.findByLabelText('Primary Subscription');
expect(subscriptionCheckbox).toBeInTheDocument();
expect(subscriptionCheckbox).not.toBeChecked();
subscriptionCheckbox.click();
const applyButton = screen.getByRole('button', { name: 'Apply' });
applyButton.click();
expect(onApply).toBeCalledTimes(1);
expect(onApply).toBeCalledWith({ subscription: 'def-123' });
});
it('should call onApply with a new subscription uri when a user types it in the selection box', async () => {
const onApply = jest.fn();
render(<ResourcePicker {...defaultProps} onApply={onApply} />);
@@ -147,6 +160,44 @@ describe('AzureMonitor ResourcePicker', () => {
expect(onApply).toBeCalledWith('/subscriptions/def-123');
});
it('should call onApply with a new subscription when a user types it in the selection box', async () => {
const onApply = jest.fn();
render(<ResourcePicker {...defaultProps} onApply={onApply} resource={{}} />);
const subscriptionCheckbox = await screen.findByLabelText('Primary Subscription');
expect(subscriptionCheckbox).toBeInTheDocument();
expect(subscriptionCheckbox).not.toBeChecked();
const advancedSection = screen.getByText('Advanced');
advancedSection.click();
const advancedInput = await screen.findByLabelText('Subscription');
await userEvent.type(advancedInput, 'def-123');
const applyButton = screen.getByRole('button', { name: 'Apply' });
applyButton.click();
expect(onApply).toBeCalledTimes(1);
expect(onApply).toBeCalledWith({ subscription: 'def-123' });
});
it('should show unselect a subscription if the value is manually edited', async () => {
render(<ResourcePicker {...defaultProps} resource={{ subscription: 'def-456' }} />);
const subscriptionCheckboxes = await screen.findAllByLabelText('Dev Subscription');
expect(subscriptionCheckboxes.length).toBe(2);
expect(subscriptionCheckboxes[0]).toBeChecked();
expect(subscriptionCheckboxes[1]).toBeChecked();
const advancedSection = screen.getByText('Advanced');
advancedSection.click();
const advancedInput = await screen.findByLabelText('Subscription');
await userEvent.type(advancedInput, 'def-123');
const updatedCheckboxes = await screen.findAllByLabelText('Dev Subscription');
expect(updatedCheckboxes.length).toBe(1);
expect(updatedCheckboxes[0]).not.toBeChecked();
});
it('renders a search field which show search results when there are results', async () => {
render(<ResourcePicker {...defaultProps} />);
const searchRow1 = screen.queryByLabelText('search-result');
@@ -204,7 +255,7 @@ describe('AzureMonitor ResourcePicker', () => {
});
it('resets result when the user clears their search', async () => {
render(<ResourcePicker {...defaultProps} resourceURI={noResourceURI} />);
render(<ResourcePicker {...defaultProps} resource={noResourceURI} />);
const subscriptionCheckboxBeforeSearch = await screen.findByLabelText('Primary Subscription');
expect(subscriptionCheckboxBeforeSearch).toBeInTheDocument();

View File

@@ -2,63 +2,67 @@ import { cx } from '@emotion/css';
import React, { useCallback, useEffect, useState } from 'react';
import { useEffectOnce } from 'react-use';
import { Alert, Button, Icon, Input, LoadingPlaceholder, Tooltip, useStyles2, Collapse, Label } from '@grafana/ui';
import { Alert, Button, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
import ResourcePickerData, { ResourcePickerQueryType } from '../../resourcePicker/resourcePickerData';
import { AzureMetricResource } from '../../types';
import messageFromError from '../../utils/messageFromError';
import { Space } from '../Space';
import Advanced from './Advanced';
import NestedRow from './NestedRow';
import Search from './Search';
import getStyles from './styles';
import { ResourceRow, ResourceRowGroup, ResourceRowType } from './types';
import { findRow } from './utils';
import { findRow, parseResourceDetails, resourceToString } from './utils';
interface ResourcePickerProps {
interface ResourcePickerProps<T> {
resourcePickerData: ResourcePickerData;
resourceURI: string | undefined;
resource: T;
selectableEntryTypes: ResourceRowType[];
queryType: ResourcePickerQueryType;
onApply: (resourceURI: string | undefined) => void;
onApply: (resource?: T) => void;
onCancel: () => void;
}
const ResourcePicker = ({
resourcePickerData,
resourceURI,
resource,
onApply,
onCancel,
selectableEntryTypes,
queryType,
}: ResourcePickerProps) => {
}: ResourcePickerProps<string | AzureMetricResource>) => {
const styles = useStyles2(getStyles);
const [isLoading, setIsLoading] = useState(false);
const [rows, setRows] = useState<ResourceRowGroup>([]);
const [selectedRows, setSelectedRows] = useState<ResourceRowGroup>([]);
const [internalSelectedURI, setInternalSelectedURI] = useState<string | undefined>(resourceURI);
const [internalSelected, setInternalSelected] = useState(resource);
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(resourceURI?.includes('$'));
const [shouldShowLimitFlag, setShouldShowLimitFlag] = useState(false);
// Sync the resourceURI prop to internal state
useEffect(() => {
setInternalSelectedURI(resourceURI);
}, [resourceURI]);
setInternalSelected(resource);
}, [resource]);
const loadInitialData = useCallback(async () => {
if (!isLoading) {
try {
setIsLoading(true);
const resources = await resourcePickerData.fetchInitialRows(queryType, internalSelectedURI || '');
const resources = await resourcePickerData.fetchInitialRows(
queryType,
parseResourceDetails(internalSelected ?? {})
);
setRows(resources);
} catch (error) {
setErrorMessage(messageFromError(error));
}
setIsLoading(false);
}
}, [internalSelectedURI, isLoading, resourcePickerData, queryType]);
}, [internalSelected, isLoading, resourcePickerData, queryType]);
useEffectOnce(() => {
loadInitialData();
@@ -66,11 +70,11 @@ const ResourcePicker = ({
// set selected row data whenever row or selection changes
useEffect(() => {
if (!internalSelectedURI) {
if (!internalSelected) {
setSelectedRows([]);
}
const found = internalSelectedURI && findRow(rows, internalSelectedURI);
const found = internalSelected && findRow(rows, resourceToString(internalSelected));
if (found) {
return setSelectedRows([
{
@@ -79,7 +83,8 @@ const ResourcePicker = ({
},
]);
}
}, [internalSelectedURI, rows]);
return setSelectedRows([]);
}, [internalSelected, rows]);
// Request resources for an expanded resource group
const requestNestedRows = useCallback(
@@ -103,13 +108,21 @@ const ResourcePicker = ({
[resourcePickerData, rows, queryType]
);
const handleSelectionChanged = useCallback((row: ResourceRow, isSelected: boolean) => {
isSelected ? setInternalSelectedURI(row.uri) : setInternalSelectedURI(undefined);
}, []);
const resourceIsString = typeof resource === 'string';
const handleSelectionChanged = useCallback(
(row: ResourceRow, isSelected: boolean) => {
isSelected
? setInternalSelected(resourceIsString ? row.uri : parseResourceDetails(row.uri))
: setInternalSelected(resourceIsString ? '' : {});
},
[resourceIsString]
);
const handleApply = useCallback(() => {
onApply(internalSelectedURI);
}, [internalSelectedURI, onApply]);
if (internalSelected) {
onApply(resourceIsString ? internalSelected : parseResourceDetails(internalSelected));
}
}, [resourceIsString, internalSelected, onApply]);
const handleSearch = useCallback(
async (searchWord: string) => {
@@ -216,44 +229,7 @@ const ResourcePicker = ({
</>
)}
<Collapse
collapsible
label="Advanced"
isOpen={isAdvancedOpen}
onToggle={() => setIsAdvancedOpen(!isAdvancedOpen)}
>
<Label htmlFor={`input-${internalSelectedURI}`}>
<h6>
Resource URI{' '}
<Tooltip
content={
<>
Manually edit the{' '}
<a
href="https://docs.microsoft.com/en-us/azure/azure-monitor/logs/log-standard-columns#_resourceid"
rel="noopener noreferrer"
target="_blank"
>
resource uri.{' '}
</a>
Supports the use of multiple template variables (ex: /subscriptions/$subId/resourceGroups/$rg)
</>
}
placement="right"
interactive={true}
>
<Icon name="info-circle" />
</Tooltip>
</h6>
</Label>
<Input
id={`input-${internalSelectedURI}`}
value={internalSelectedURI}
onChange={(event) => setInternalSelectedURI(event.currentTarget.value)}
placeholder="ex: /subscriptions/$subId"
/>
<Space v={2} />
</Collapse>
<Advanced resource={internalSelected} onChange={(r) => setInternalSelected(r)} />
<Space v={2} />
<Button disabled={!!errorMessage} onClick={handleApply}>

View File

@@ -1,10 +1,13 @@
import { parseResourceURI } from './utils';
import createMockQuery from '../../__mocks__/query';
import { ResourceRowGroup, ResourceRowType } from './types';
import { findRow, parseResourceURI, setResource } from './utils';
describe('AzureMonitor ResourcePicker utils', () => {
describe('parseResourceURI', () => {
it('should parse subscription URIs', () => {
expect(parseResourceURI('/subscriptions/44693801-6ee6-49de-9b2d-9106972f9572')).toEqual({
subscriptionID: '44693801-6ee6-49de-9b2d-9106972f9572',
subscription: '44693801-6ee6-49de-9b2d-9106972f9572',
});
});
@@ -12,7 +15,7 @@ describe('AzureMonitor ResourcePicker utils', () => {
expect(
parseResourceURI('/subscriptions/44693801-6ee6-49de-9b2d-9106972f9572/resourceGroups/cloud-datasources')
).toEqual({
subscriptionID: '44693801-6ee6-49de-9b2d-9106972f9572',
subscription: '44693801-6ee6-49de-9b2d-9106972f9572',
resourceGroup: 'cloud-datasources',
});
});
@@ -23,14 +26,143 @@ describe('AzureMonitor ResourcePicker utils', () => {
'/subscriptions/44693801-6ee6-49de-9b2d-9106972f9572/resourceGroups/cloud-datasources/providers/Microsoft.Compute/virtualMachines/GithubTestDataVM'
)
).toEqual({
subscriptionID: '44693801-6ee6-49de-9b2d-9106972f9572',
subscription: '44693801-6ee6-49de-9b2d-9106972f9572',
resourceGroup: 'cloud-datasources',
resource: 'GithubTestDataVM',
metricNamespace: 'Microsoft.Compute/virtualMachines',
resourceName: 'GithubTestDataVM',
});
});
it('should parse resource URIs with a subresource', () => {
expect(
parseResourceURI(
'/subscriptions/44693801-6ee6-49de-9b2d-9106972f9572/resourceGroups/cloud-datasources/providers/Microsoft.Storage/storageAccounts/csb100320016c43d2d0/fileServices/default'
)
).toEqual({
subscription: '44693801-6ee6-49de-9b2d-9106972f9572',
resourceGroup: 'cloud-datasources',
metricNamespace: 'Microsoft.Storage/storageAccounts/fileServices',
resourceName: 'csb100320016c43d2d0/default',
});
});
it('returns undefined for invalid input', () => {
expect(parseResourceURI('44693801-6ee6-49de-9b2d-9106972f9572')).toBeUndefined();
expect(parseResourceURI('44693801-6ee6-49de-9b2d-9106972f9572')).toEqual({});
});
it('returns a valid response with a missing element in the metric namespace and name', () => {
expect(
parseResourceURI(
'/subscriptions/44693801-6ee6-49de-9b2d-9106972f9572/resourceGroups/cloud-datasources/providers/foo'
)
).toEqual({
metricNamespace: 'foo',
resourceGroup: 'cloud-datasources',
resourceName: '',
subscription: '44693801-6ee6-49de-9b2d-9106972f9572',
});
expect(
parseResourceURI(
'/subscriptions/44693801-6ee6-49de-9b2d-9106972f9572/resourceGroups/cloud-datasources/providers/foo/bar'
)
).toEqual({
metricNamespace: 'foo/bar',
resourceGroup: 'cloud-datasources',
resourceName: '',
subscription: '44693801-6ee6-49de-9b2d-9106972f9572',
});
});
});
describe('findRow', () => {
it('should find a row', () => {
const rows: ResourceRowGroup = [
{ id: '', uri: '/subscription/sub', name: '', type: ResourceRowType.Subscription, typeLabel: '' },
];
expect(findRow(rows, '/subscription/sub')).toEqual(rows[0]);
});
it('should find a row ignoring a subresource', () => {
const rows: ResourceRowGroup = [
{
id: '',
uri: '/subscriptions/44693801-6ee6-49de-9b2d-9106972f9572/resourceGroups/cloud-datasources/providers/Microsoft.Storage/storageAccounts/csb100320016c43d2d0',
name: '',
type: ResourceRowType.Resource,
typeLabel: '',
},
];
expect(
findRow(
rows,
'/subscriptions/44693801-6ee6-49de-9b2d-9106972f9572/resourceGroups/cloud-datasources/providers/Microsoft.Storage/storageAccounts/csb100320016c43d2d0/fileServices/default'
)
).toEqual(rows[0]);
});
it('should find a row ignoring a metric namespace case', () => {
const rows: ResourceRowGroup = [
{
id: '',
uri: '/subscriptions/44693801-6ee6-49de-9b2d-9106972f9572/resourceGroups/cloud-datasources/providers/microsoft.storage/storageaccounts/csb100320016c43d2d0',
name: '',
type: ResourceRowType.Resource,
typeLabel: '',
},
];
expect(
findRow(
rows,
'/subscriptions/44693801-6ee6-49de-9b2d-9106972f9572/resourceGroups/cloud-datasources/providers/Microsoft.Storage/storageAccounts/csb100320016c43d2d0'
)
).toEqual(rows[0]);
});
it('should find a row ignoring a resource group case', () => {
const rows: ResourceRowGroup = [
{
id: '',
uri: '/subscriptions/44693801-6ee6-49de-9b2d-9106972f9572/resourceGroups/CLOUD-DATASOURCES/providers/microsoft.storage/storageaccounts/csb100320016c43d2d0',
name: '',
type: ResourceRowType.Resource,
typeLabel: '',
},
];
expect(
findRow(
rows,
'/subscriptions/44693801-6ee6-49de-9b2d-9106972f9572/resourceGroups/cloud-datasources/providers/Microsoft.Storage/storageAccounts/csb100320016c43d2d0'
)
).toEqual(rows[0]);
});
});
describe('setResource', () => {
it('updates a resource with a resource URI for Log Analytics', () => {
expect(setResource(createMockQuery(), '/subscription/sub')).toMatchObject({
azureLogAnalytics: { resource: '/subscription/sub' },
});
});
it('updates a resource with a resource parameters for Metrics', () => {
expect(
setResource(createMockQuery(), {
subscription: 'sub',
resourceGroup: 'rg',
metricNamespace: 'Microsoft.Storage/storageAccounts',
resourceName: 'testacct',
})
).toMatchObject({
subscription: 'sub',
azureMonitor: {
aggregation: undefined,
metricName: undefined,
metricNamespace: 'microsoft.storage/storageaccounts',
resourceGroup: 'rg',
resourceName: 'testacct',
},
});
});
});
});

View File

@@ -1,5 +1,10 @@
import produce from 'immer';
import { getTemplateSrv } from '@grafana/runtime';
import UrlBuilder from '../../azure_monitor/url_builder';
import { AzureMetricResource, AzureMonitorQuery } from '../../types';
import { ResourceRow, ResourceRowGroup } from './types';
// This regex matches URIs representing:
@@ -7,29 +12,74 @@ import { ResourceRow, ResourceRowGroup } from './types';
// - resource groups: /subscriptions/44693801-6ee6-49de-9b2d-9106972f9572/resourceGroups/cloud-datasources
// - resources: /subscriptions/44693801-6ee6-49de-9b2d-9106972f9572/resourceGroups/cloud-datasources/providers/Microsoft.Compute/virtualMachines/GithubTestDataVM
const RESOURCE_URI_REGEX =
/\/subscriptions\/(?<subscriptionID>[^/]+)(?:\/resourceGroups\/(?<resourceGroup>[^/]+)(?:\/providers.+\/(?<resource>[^/]+))?)?/;
/\/subscriptions\/(?<subscription>[^/]+)(?:\/resourceGroups\/(?<resourceGroup>[^/]+)(?:\/providers\/(?<metricNamespaceAndResource>.+))?)?/;
type RegexGroups = Record<string, string | undefined>;
function parseNamespaceAndName(metricNamespaceAndName?: string) {
if (!metricNamespaceAndName) {
return {};
}
const stringArray = metricNamespaceAndName.split('/');
// The first two groups belong to the namespace (e.g. Microsoft.Storage/storageAccounts)
const namespaceArray = stringArray.splice(0, 2);
// The next element belong to the resource name (e.g. storageAcc1)
const resourceNameArray = stringArray.splice(0, 1);
// If there are more elements, keep adding them to the namespace and resource name, alternatively
// e.g (blobServices/default)
while (stringArray.length) {
const nextElem = stringArray.shift()!;
stringArray.length % 2 === 0 ? resourceNameArray.push(nextElem) : namespaceArray.push(nextElem);
}
return { metricNamespace: namespaceArray.join('/'), resourceName: resourceNameArray.join('/') };
}
export function parseResourceURI(resourceURI: string) {
const matches = RESOURCE_URI_REGEX.exec(resourceURI);
const groups: RegexGroups = matches?.groups ?? {};
const { subscriptionID, resourceGroup, resource } = groups;
const { subscription, resourceGroup, metricNamespaceAndResource } = groups;
const { metricNamespace, resourceName } = parseNamespaceAndName(metricNamespaceAndResource);
if (!subscriptionID) {
return undefined;
return { subscription, resourceGroup, metricNamespace, resourceName };
}
export function parseResourceDetails(resource: string | AzureMetricResource) {
if (typeof resource === 'string') {
return parseResourceURI(resource);
}
return resource;
}
return { subscriptionID, resourceGroup, resource };
export function resourceToString(resource?: string | AzureMetricResource) {
return resource
? typeof resource === 'string'
? resource
: UrlBuilder.buildResourceUri(getTemplateSrv(), resource)
: '';
}
export function isGUIDish(input: string) {
return !!input.match(/^[A-Z0-9]+/i);
}
function matchURI(rowURI: string, resourceURI: string) {
const targetParams = parseResourceDetails(resourceURI);
const rowParams = parseResourceDetails(rowURI);
return (
rowParams?.subscription === targetParams?.subscription &&
rowParams?.resourceGroup?.toLowerCase() === targetParams?.resourceGroup?.toLowerCase() &&
// metricNamespace may include a subresource that we don't need to compare
rowParams?.metricNamespace?.toLowerCase().split('/')[0] ===
targetParams?.metricNamespace?.toLowerCase().split('/')[0] &&
// resourceName may include a subresource that we don't need to compare
rowParams?.resourceName?.split('/')[0] === targetParams?.resourceName?.split('/')[0]
);
}
export function findRow(rows: ResourceRowGroup, uri: string): ResourceRow | undefined {
for (const row of rows) {
if (row.uri.toLowerCase() === uri.toLowerCase()) {
if (matchURI(row.uri, uri)) {
return row;
}
@@ -60,3 +110,31 @@ export function addResources(rows: ResourceRowGroup, targetParentId: string, new
draftRow.children = newResources;
});
}
export function setResource(query: AzureMonitorQuery, resource?: string | AzureMetricResource): AzureMonitorQuery {
if (typeof resource === 'string') {
// Resource URI for LogAnalytics
return {
...query,
azureLogAnalytics: {
...query.azureLogAnalytics,
resource,
},
};
}
// Resource object for metrics
return {
...query,
subscription: resource?.subscription,
azureMonitor: {
...query.azureMonitor,
resourceGroup: resource?.resourceGroup,
metricNamespace: resource?.metricNamespace?.toLocaleLowerCase(),
resourceName: resource?.resourceName,
metricName: undefined,
aggregation: undefined,
timeGrain: '',
dimensionFilters: [],
},
};
}

View File

@@ -19,7 +19,7 @@ import AzureResourceGraphDatasource from './azure_resource_graph/azure_resource_
import ResourcePickerData from './resourcePicker/resourcePickerData';
import { AzureDataSourceJsonData, AzureMonitorQuery, AzureQueryType } from './types';
import migrateAnnotation from './utils/migrateAnnotation';
import { datasourceMigrations } from './utils/migrateQuery';
import migrateQuery from './utils/migrateQuery';
import { VariableSupport } from './variables';
export default class Datasource extends DataSourceWithBackend<AzureMonitorQuery, AzureDataSourceJsonData> {
@@ -70,7 +70,7 @@ export default class Datasource extends DataSourceWithBackend<AzureMonitorQuery,
for (const baseTarget of options.targets) {
// Migrate old query structures
const target = datasourceMigrations(baseTarget, this.templateSrv);
const target = migrateQuery(baseTarget);
// Skip hidden or invalid queries or ones without properties
if (!target.queryType || target.hide || !hasQueryForType(target)) {

View File

@@ -9,10 +9,11 @@ import {
supportedMetricNamespaces,
} from '../azureMetadata';
import { ResourceRow, ResourceRowGroup, ResourceRowType } from '../components/ResourcePicker/types';
import { addResources, parseResourceURI } from '../components/ResourcePicker/utils';
import { addResources, parseResourceDetails, parseResourceURI } from '../components/ResourcePicker/utils';
import {
AzureDataSourceJsonData,
AzureGraphResponse,
AzureMetricResource,
AzureMonitorQuery,
AzureResourceGraphOptions,
AzureResourceSummaryItem,
@@ -38,23 +39,25 @@ export default class ResourcePickerData extends DataSourceWithBackend<AzureMonit
this.resourcePath = `${routeNames.resourceGraph}`;
}
async fetchInitialRows(type: ResourcePickerQueryType, currentSelection?: string): Promise<ResourceRowGroup> {
async fetchInitialRows(
type: ResourcePickerQueryType,
currentSelection?: AzureMetricResource
): Promise<ResourceRowGroup> {
const subscriptions = await this.getSubscriptions();
if (!currentSelection) {
return subscriptions;
}
let resources = subscriptions;
const parsedURI = parseResourceURI(currentSelection);
if (parsedURI) {
const resourceGroupURI = `/subscriptions/${parsedURI.subscriptionID}/resourceGroups/${parsedURI.resourceGroup}`;
if (currentSelection.subscription) {
const resourceGroupURI = `/subscriptions/${currentSelection.subscription}/resourceGroups/${currentSelection.resourceGroup}`;
if (parsedURI.resourceGroup) {
const resourceGroups = await this.getResourceGroupsBySubscriptionId(parsedURI.subscriptionID, type);
resources = addResources(resources, `/subscriptions/${parsedURI.subscriptionID}`, resourceGroups);
if (currentSelection.resourceGroup) {
const resourceGroups = await this.getResourceGroupsBySubscriptionId(currentSelection.subscription, type);
resources = addResources(resources, `/subscriptions/${currentSelection.subscription}`, resourceGroups);
}
if (parsedURI.resource) {
if (currentSelection.resourceName) {
const resourcesForResourceGroup = await this.getResourcesForResourceGroup(resourceGroupURI, type);
resources = addResources(resources, resourceGroupURI, resourcesForResourceGroup);
}
@@ -90,13 +93,13 @@ export default class ResourcePickerData extends DataSourceWithBackend<AzureMonit
const { data: response } = await this.makeResourceGraphRequest<RawAzureResourceItem[]>(searchQuery);
return response.map((item) => {
const parsedUri = parseResourceURI(item.id);
if (!parsedUri || !(parsedUri.resource || parsedUri.resourceGroup || parsedUri.subscriptionID)) {
if (!parsedUri || !(parsedUri.resourceName || parsedUri.resourceGroup || parsedUri.subscription)) {
throw new Error('unable to fetch resource details');
}
let id = parsedUri.subscriptionID;
let id = parsedUri.subscription ?? '';
let type = ResourceRowType.Subscription;
if (parsedUri.resource) {
id = parsedUri.resource;
if (parsedUri.resourceName) {
id = parsedUri.resourceName;
type = ResourceRowType.Resource;
} else if (parsedUri.resourceGroup) {
id = parsedUri.resourceGroup;
@@ -220,12 +223,12 @@ export default class ResourcePickerData extends DataSourceWithBackend<AzureMonit
return response.map((item) => {
const parsedUri = parseResourceURI(item.id);
if (!parsedUri || !parsedUri.resource) {
if (!parsedUri || !parsedUri.resourceName) {
throw new Error('unable to fetch resource details');
}
return {
name: item.name,
id: parsedUri.resource,
id: parsedUri.resourceName,
uri: item.id,
resourceGroupName: item.resourceGroup,
type: ResourceRowType.Resource,
@@ -236,16 +239,16 @@ export default class ResourcePickerData extends DataSourceWithBackend<AzureMonit
}
// used to make the select resource button that launches the resource picker show a nicer file path to users
async getResourceURIDisplayProperties(resourceURI: string): Promise<AzureResourceSummaryItem> {
const { subscriptionID, resourceGroup, resource } = parseResourceURI(resourceURI) ?? {};
async getResourceURIDisplayProperties(resourceURI: string): Promise<AzureMetricResource> {
const { subscription, resourceGroup, resourceName } = parseResourceDetails(resourceURI) ?? {};
if (!subscriptionID) {
if (!subscription) {
throw new Error('Invalid resource URI passed');
}
// resourceGroupURI and resourceURI could be invalid values, but that's okay because the join
// will just silently fail as expected
const subscriptionURI = `/subscriptions/${subscriptionID}`;
const subscriptionURI = `/subscriptions/${subscription}`;
const resourceGroupURI = `${subscriptionURI}/resourceGroups/${resourceGroup}`;
const query = `
@@ -276,14 +279,14 @@ export default class ResourcePickerData extends DataSourceWithBackend<AzureMonit
throw new Error('unable to fetch resource details');
}
const { subscriptionName, resourceGroupName, resourceName } = response[0];
const { subscriptionName, resourceGroupName, resourceName: responseResourceName } = response[0];
// if the name is undefined it could be because the id is undefined or because we are using a template variable.
// Either way we can use it as a fallback. We don't really want to interpolate these variables because we want
// to show the user when they are using template variables `$sub/$rg/$resource`
return {
subscriptionName: subscriptionName || subscriptionID,
resourceGroupName: resourceGroupName || resourceGroup,
resourceName: resourceName || resource,
subscription: subscriptionName || subscription,
resourceGroup: resourceGroupName || resourceGroup,
resourceName: responseResourceName || resourceName,
};
}

View File

@@ -43,9 +43,7 @@ export interface AzureMonitorQuery extends DataQuery {
* Azure Monitor Metrics sub-query properties
*/
export interface AzureMetricQuery {
resourceUri?: string;
resourceGroup?: string;
resourceName?: string;
/** Resource type */
metricNamespace?: string;
@@ -68,6 +66,9 @@ export interface AzureMetricQuery {
/** @deprecated Use metricNamespace instead */
metricDefinition?: string;
/** @deprecated Use resourceGroup, resourceName and metricNamespace instead */
resourceUri?: string;
}
/**
@@ -98,3 +99,10 @@ export interface AzureMetricDimension {
*/
filter?: string;
}
export interface AzureMetricResource {
subscription?: string;
resourceGroup?: string;
resourceName?: string;
metricNamespace?: string;
}

View File

@@ -1,54 +1,7 @@
import React from 'react';
import { getTemplateSrv } from '@grafana/runtime';
import { AzureMetricDimension, AzureMonitorErrorish, AzureMonitorQuery, AzureQueryType } from '../types';
import { AzureMetricDimension, AzureMonitorQuery, AzureQueryType } from '../types';
import migrateQuery from './migrateQuery';
let replaceMock = jest.fn().mockImplementation((s: string) => s);
jest.mock('@grafana/runtime', () => {
const original = jest.requireActual('@grafana/runtime');
return {
...original,
getTemplateSrv: () => ({
replace: replaceMock,
}),
};
});
let templateSrv = getTemplateSrv();
let setErrorMock = jest.fn();
const azureMonitorQueryV7 = {
appInsights: { dimension: [], metricName: 'select', timeGrain: 'auto' },
azureLogAnalytics: {
query:
'//change this example to create your own time series query\n<table name> //the table to query (e.g. Usage, Heartbeat, Perf)\n| where $__timeFilter(TimeGenerated) //this is a macro used to show the full charts time range, choose the datetime column here\n| summarize count() by <group by column>, bin(TimeGenerated, $__interval) //change “group by column” to a column in your table, such as “Computer”. The $__interval macro is used to auto-select the time grain. Can also use 1h, 5m etc.\n| order by TimeGenerated asc',
resultFormat: 'time_series',
workspace: 'mock-workspace-id',
},
azureMonitor: {
aggregation: 'Average',
allowedTimeGrainsMs: [60000, 300000, 900000, 1800000, 3600000, 21600000, 43200000, 86400000],
dimensionFilters: [{ dimension: 'dependency/success', filter: '', operator: 'eq' }],
metricName: 'dependencies/duration',
metricNamespace: 'microsoft.insights/components',
resourceGroup: 'cloud-datasources',
resourceName: 'AppInsightsTestData',
timeGrain: 'auto',
top: '10',
},
insightsAnalytics: {
query: '',
resultFormat: 'time_series',
},
queryType: AzureQueryType.AzureMonitor,
refId: 'A',
subscription: '44693801-6ee6-49de-9b2d-9106972f9572',
};
const azureMonitorQueryV8 = {
azureMonitor: {
aggregation: 'Average',
@@ -68,6 +21,24 @@ const azureMonitorQueryV8 = {
subscription: '44693801-6ee6-49de-9b2d-9106972f9572',
};
const azureMonitorQueryV9_0 = {
azureMonitor: {
aggregation: 'Average',
dimensionFilters: [],
metricName: 'dependencies/duration',
metricNamespace: 'microsoft.insights/components',
resourceUri:
'/subscriptions/44693801-6ee6-49de-9b2d-9106972f9572/resourceGroups/cloud-datasources/providers/microsoft.insights/components/AppInsightsTestData',
timeGrain: 'auto',
},
datasource: {
type: 'grafana-azure-monitor-datasource',
uid: 'sD-ZuB87k',
},
queryType: AzureQueryType.AzureMonitor,
refId: 'A',
};
const modernMetricsQuery: AzureMonitorQuery = {
azureLogAnalytics: {
query:
@@ -84,8 +55,6 @@ const modernMetricsQuery: AzureMonitorQuery = {
metricNamespace: 'microsoft.insights/components',
resourceGroup: 'cloud-datasources',
resourceName: 'AppInsightsTestData',
resourceUri:
'/subscriptions/44693801-6ee6-49de-9b2d-9106972f9572/resourceGroups/cloud-datasources/providers/microsoft.insights/components/AppInsightsTestData',
timeGrain: 'PT5M',
top: '10',
},
@@ -98,112 +67,18 @@ const modernMetricsQuery: AzureMonitorQuery = {
describe('AzureMonitor: migrateQuery', () => {
it('modern queries should not change', () => {
const result = migrateQuery(modernMetricsQuery, templateSrv, setErrorMock);
const result = migrateQuery(modernMetricsQuery);
// MUST use .toBe because we want to assert that the identity of unmigrated queries remains the same
expect(modernMetricsQuery).toBe(result);
});
describe('migrating from a v7 query to the latest query version', () => {
it('should build a resource uri', () => {
const result = migrateQuery(azureMonitorQueryV7, templateSrv, setErrorMock);
expect(result).toMatchObject(
expect.objectContaining({
azureMonitor: expect.objectContaining({
resourceUri:
'/subscriptions/44693801-6ee6-49de-9b2d-9106972f9572/resourceGroups/cloud-datasources/providers/microsoft.insights/components/AppInsightsTestData',
}),
})
);
});
});
describe('migrating from a v8 query to the latest query version', () => {
it('should build a resource uri', () => {
const result = migrateQuery(azureMonitorQueryV8, templateSrv, setErrorMock);
expect(result).toMatchObject(
expect.objectContaining({
azureMonitor: expect.objectContaining({
resourceUri:
'/subscriptions/44693801-6ee6-49de-9b2d-9106972f9572/resourceGroups/cloud-datasources/providers/microsoft.insights/components/AppInsightsTestData',
}),
})
);
});
it('should not build a resource uri with an unsupported namespace template variable', () => {
replaceMock = jest
.fn()
.mockImplementation((s: string) => s.replace('$ns', 'Microsoft.Storage/storageAccounts/tableServices'));
setErrorMock = jest
.fn()
.mockImplementation((errorSource: string, error: AzureMonitorErrorish) => 'Template Var error');
const errorElement = React.createElement(
'div',
null,
`Failed to create resource URI. Validate the metric definition template variable against supported cases `,
React.createElement(
'a',
{
href: 'https://grafana.com/docs/grafana/latest/datasources/azuremonitor/template-variables/',
},
'here.'
)
);
templateSrv = getTemplateSrv();
const query = {
...azureMonitorQueryV8,
azureMonitor: {
...azureMonitorQueryV8.azureMonitor,
metricNamespace: '$ns',
},
};
const result = migrateQuery(query, templateSrv, setErrorMock);
expect(result.azureMonitor?.resourceUri).toBeUndefined();
expect(setErrorMock).toHaveBeenCalledWith('Resource URI migration', errorElement);
});
it('should not build a resource uri with unsupported resource name template variable', () => {
replaceMock = jest.fn().mockImplementation((s: string) => s.replace('$resource', 'resource/default'));
setErrorMock = jest
.fn()
.mockImplementation((errorSource: string, error: AzureMonitorErrorish) => 'Template Var error');
const errorElement = React.createElement(
'div',
null,
`Failed to create resource URI. Validate the resource name template variable against supported cases `,
React.createElement(
'a',
{
href: 'https://grafana.com/docs/grafana/latest/datasources/azuremonitor/template-variables/',
},
'here.'
)
);
templateSrv = getTemplateSrv();
const query = {
...azureMonitorQueryV8,
azureMonitor: {
...azureMonitorQueryV8.azureMonitor,
resourceName: '$resource',
},
};
const result = migrateQuery(query, templateSrv, setErrorMock);
expect(result.azureMonitor?.resourceUri).toBeUndefined();
expect(setErrorMock).toHaveBeenCalledWith('Resource URI migration', errorElement);
});
});
describe('migrating from a v9 query to the latest query version', () => {
it('will not change valid dimension filters', () => {
const dimensionFilters: AzureMetricDimension[] = [
{ dimension: 'TestDimension', operator: 'eq', filters: ['testFilter'] },
];
const result = migrateQuery(
{ ...azureMonitorQueryV8, azureMonitor: { dimensionFilters } },
templateSrv,
setErrorMock
);
const result = migrateQuery({ ...azureMonitorQueryV8, azureMonitor: { dimensionFilters } });
expect(result).toMatchObject(
expect.objectContaining({
azureMonitor: expect.objectContaining({
@@ -214,11 +89,7 @@ describe('AzureMonitor: migrateQuery', () => {
});
it('correctly updates old filter containing wildcard', () => {
const dimensionFilters: AzureMetricDimension[] = [{ dimension: 'TestDimension', operator: 'eq', filter: '*' }];
const result = migrateQuery(
{ ...azureMonitorQueryV8, azureMonitor: { dimensionFilters } },
templateSrv,
setErrorMock
);
const result = migrateQuery({ ...azureMonitorQueryV8, azureMonitor: { dimensionFilters } });
expect(result).toMatchObject(
expect.objectContaining({
azureMonitor: expect.objectContaining({
@@ -231,11 +102,7 @@ describe('AzureMonitor: migrateQuery', () => {
});
it('correctly updates old filter containing value', () => {
const dimensionFilters: AzureMetricDimension[] = [{ dimension: 'TestDimension', operator: 'eq', filter: 'test' }];
const result = migrateQuery(
{ ...azureMonitorQueryV8, azureMonitor: { dimensionFilters } },
templateSrv,
setErrorMock
);
const result = migrateQuery({ ...azureMonitorQueryV8, azureMonitor: { dimensionFilters } });
expect(result).toMatchObject(
expect.objectContaining({
azureMonitor: expect.objectContaining({
@@ -250,11 +117,7 @@ describe('AzureMonitor: migrateQuery', () => {
const dimensionFilters: AzureMetricDimension[] = [
{ dimension: 'TestDimension', operator: 'eq', filter: '*', filters: ['testFilter'] },
];
const result = migrateQuery(
{ ...azureMonitorQueryV8, azureMonitor: { dimensionFilters } },
templateSrv,
setErrorMock
);
const result = migrateQuery({ ...azureMonitorQueryV8, azureMonitor: { dimensionFilters } });
expect(result).toMatchObject(
expect.objectContaining({
azureMonitor: expect.objectContaining({
@@ -273,11 +136,7 @@ describe('AzureMonitor: migrateQuery', () => {
const dimensionFilters: AzureMetricDimension[] = [
{ dimension: 'TestDimension', operator: 'eq', filter: 'testFilter', filters: ['testFilter'] },
];
const result = migrateQuery(
{ ...azureMonitorQueryV8, azureMonitor: { dimensionFilters } },
templateSrv,
setErrorMock
);
const result = migrateQuery({ ...azureMonitorQueryV8, azureMonitor: { dimensionFilters } });
expect(result).toMatchObject(
expect.objectContaining({
azureMonitor: expect.objectContaining({
@@ -294,10 +153,7 @@ describe('AzureMonitor: migrateQuery', () => {
});
it('correctly migrates a metric definition', () => {
const result = migrateQuery(
{ ...azureMonitorQueryV8, azureMonitor: { metricDefinition: 'ms.ns/mn' } },
templateSrv
);
const result = migrateQuery({ ...azureMonitorQueryV8, azureMonitor: { metricDefinition: 'ms.ns/mn' } });
expect(result).toMatchObject(
expect.objectContaining({
azureMonitor: expect.objectContaining({
@@ -308,4 +164,21 @@ describe('AzureMonitor: migrateQuery', () => {
);
});
});
describe('migrating from a v9.0 query to the latest query version', () => {
it('will parse the resource URI', () => {
const result = migrateQuery(azureMonitorQueryV9_0);
expect(result).toMatchObject(
expect.objectContaining({
subscription: modernMetricsQuery.subscription,
azureMonitor: expect.objectContaining({
metricNamespace: modernMetricsQuery.azureMonitor!.metricNamespace,
resourceGroup: modernMetricsQuery.azureMonitor!.resourceGroup,
resourceName: modernMetricsQuery.azureMonitor!.resourceName,
resourceUri: undefined,
}),
})
);
});
});
});

View File

@@ -1,36 +1,32 @@
import React from 'react';
import { TemplateSrv } from '@grafana/runtime';
import UrlBuilder from '../azure_monitor/url_builder';
import { setKustoQuery } from '../components/LogsQueryEditor/setQueryValue';
import {
appendDimensionFilter,
setTimeGrain as setMetricsTimeGrain,
} from '../components/MetricsQueryEditor/setQueryValue';
import { parseResourceDetails } from '../components/ResourcePicker/utils';
import TimegrainConverter from '../time_grain_converter';
import { AzureMetricDimension, AzureMonitorErrorish, AzureMonitorQuery, AzureQueryType } from '../types';
import { AzureMetricDimension, AzureMonitorQuery, AzureQueryType } from '../types';
const OLD_DEFAULT_DROPDOWN_VALUE = 'select';
export default function migrateQuery(
query: AzureMonitorQuery,
templateSrv: TemplateSrv,
setError?: (errorSource: string, error: AzureMonitorErrorish) => void
): AzureMonitorQuery {
export default function migrateQuery(query: AzureMonitorQuery): AzureMonitorQuery {
let workingQuery = query;
// The old angular controller also had a `migrateApplicationInsightsKeys` migraiton that
// migrated old properties to other properties that still do not appear to be used anymore, so
// we decided to not include that migration anymore
// See https://github.com/grafana/grafana/blob/a6a09add/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.ts#L269-L288
if (!workingQuery.queryType) {
workingQuery = {
...workingQuery,
queryType: AzureQueryType.AzureMonitor,
};
}
workingQuery = migrateTimeGrains(workingQuery);
workingQuery = migrateLogAnalyticsToFromTimes(workingQuery);
workingQuery = migrateToDefaultNamespace(workingQuery);
workingQuery = migrateDimensionToDimensionFilter(workingQuery);
workingQuery = migrateResourceUri(workingQuery, templateSrv, setError);
workingQuery = migrateDimensionFilterToArray(workingQuery);
if (workingQuery.queryType === AzureQueryType.AzureMonitor && workingQuery.azureMonitor) {
workingQuery = migrateTimeGrains(workingQuery);
workingQuery = migrateToDefaultNamespace(workingQuery);
workingQuery = migrateDimensionToDimensionFilter(workingQuery);
workingQuery = migrateDimensionFilterToArray(workingQuery);
workingQuery = migrateDimensionToResourceObj(workingQuery);
}
return workingQuery;
}
@@ -102,92 +98,6 @@ function migrateDimensionToDimensionFilter(query: AzureMonitorQuery): AzureMonit
return workingQuery;
}
// Azure Monitor metric queries prior to Grafana version 9 did not include a `resourceUri`.
// The resourceUri was previously constructed with the subscription id, resource group,
// metric definition (a.k.a. resource type), and the resource name.
function migrateResourceUri(
query: AzureMonitorQuery,
templateSrv: TemplateSrv,
setError?: (errorSource: string, error: AzureMonitorErrorish) => void
): AzureMonitorQuery {
const azureMonitorQuery = query.azureMonitor;
if (!azureMonitorQuery || azureMonitorQuery.resourceUri) {
return query;
}
const { subscription } = query;
const { resourceGroup, metricNamespace, resourceName } = azureMonitorQuery;
if (!(subscription && resourceGroup && metricNamespace && resourceName)) {
return query;
}
const metricNamespaceArray = metricNamespace.split('/');
if (metricNamespaceArray.some((p) => templateSrv.replace(p).split('/').length > 2)) {
// If a metric definition includes template variable with a subresource e.g.
// Microsoft.Storage/storageAccounts/libraries, it's not possible to generate a valid
// resource URI
if (setError) {
setError(
'Resource URI migration',
React.createElement(
'div',
null,
`Failed to create resource URI. Validate the metric definition template variable against supported cases `,
React.createElement(
'a',
{
href: 'https://grafana.com/docs/grafana/latest/datasources/azuremonitor/template-variables/',
},
'here.'
)
)
);
}
return query;
}
const resourceNameArray = resourceName.split('/');
if (resourceNameArray.some((p) => templateSrv.replace(p).split('/').length > 1)) {
// If a resource name includes template variable with a subresource e.g.
// abc123/def456, it's not possible to generate a valid resource URI
if (setError) {
setError(
'Resource URI migration',
React.createElement(
'div',
null,
`Failed to create resource URI. Validate the resource name template variable against supported cases `,
React.createElement(
'a',
{
href: 'https://grafana.com/docs/grafana/latest/datasources/azuremonitor/template-variables/',
},
'here.'
)
)
);
}
return query;
}
const resourceUri = UrlBuilder.buildResourceUri(
subscription,
resourceGroup,
templateSrv,
metricNamespace,
resourceName
);
return {
...query,
azureMonitor: {
...azureMonitorQuery,
resourceUri,
},
};
}
function migrateDimensionFilterToArray(query: AzureMonitorQuery): AzureMonitorQuery {
const azureMonitorQuery = query.azureMonitor;
@@ -225,23 +135,21 @@ function migrateDimensionFilterToArray(query: AzureMonitorQuery): AzureMonitorQu
return query;
}
// datasource.ts also contains some migrations, which have been moved to here. Unsure whether
// they should also do all the other migrations...
export function datasourceMigrations(query: AzureMonitorQuery, templateSrv: TemplateSrv): AzureMonitorQuery {
let workingQuery = query;
if (!workingQuery.queryType) {
workingQuery = {
...workingQuery,
queryType: AzureQueryType.AzureMonitor,
function migrateDimensionToResourceObj(query: AzureMonitorQuery): AzureMonitorQuery {
if (query.azureMonitor?.resourceUri) {
const details = parseResourceDetails(query.azureMonitor.resourceUri);
return {
...query,
subscription: details?.subscription,
azureMonitor: {
...query.azureMonitor,
resourceGroup: details?.resourceGroup,
metricNamespace: details?.metricNamespace,
resourceName: details?.resourceName,
resourceUri: undefined,
},
};
}
if (workingQuery.queryType === AzureQueryType.AzureMonitor && workingQuery.azureMonitor) {
workingQuery = migrateDimensionToDimensionFilter(workingQuery);
workingQuery = migrateResourceUri(workingQuery, templateSrv);
workingQuery = migrateDimensionFilterToArray(workingQuery);
}
return workingQuery;
return query;
}