mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Azure Monitor: Restore Metrics query parameters: subscription, resourceGroup, metricNamespace and resourceName (#52897)
* Azure Monitor: (Components) deprecate ResourceURI (#52982)
This commit is contained in:
committed by
GitHub
parent
2948bf01dc
commit
a4f56446ee
@@ -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: '*',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()])) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 chart’s 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,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user