AzureMonitor: Use Resource Picker in Metrics Query Editor (#47164)

* wip: new metrics query editor

* prepend subscriptions to url path

* remove duplicated setQueryValue file

* add tests for new metrics query editor

* wip start extracting resource field into a shared component

* fix query editor test

* fix up backend tests

* move azure monitor specific getters to azure_monitor_datasource

* use existing useAsyncState hook

* extract useAsyncState into separate file

* add datahooks tests

* extract resource field component

* cleanup

* clarify variable names

* remove logs query specific resource field component

* add url_builder test

* add azure_monitor_datasources tests

* address comments

* add types

* pass resourceUri to resource field component

* add test to check we reset the query fields when changing resources
This commit is contained in:
Kevin Yu 2022-04-08 08:49:46 -07:00 committed by GitHub
parent e3b123c3fc
commit ebe5f3646f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1668 additions and 675 deletions

View File

@ -217,7 +217,6 @@ func (e *AzureMonitorDatasource) createRequest(ctx context.Context, dsInfo types
azlog.Debug("Failed to create request", "error", err)
return nil, errutil.Wrap("Failed to create request", err)
}
req.URL.Path = "/subscriptions"
req.Header.Set("Content-Type", "application/json")
return req, nil

View File

@ -163,7 +163,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) {
}
azureMonitorQuery := &types.AzureMonitorQuery{
URL: "12345678-aaaa-bbbb-cccc-123456789abc/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana/providers/microsoft.insights/metrics",
URL: "/subscriptions/12345678-aaaa-bbbb-cccc-123456789abc/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana/providers/microsoft.insights/metrics",
UrlComponents: map[string]string{
"metricDefinition": "Microsoft.Compute/virtualMachines",
"resourceGroup": "grafanastaging",
@ -587,7 +587,7 @@ func TestAzureMonitorCreateRequest(t *testing.T) {
}{
{
name: "creates a request",
expectedURL: "http://ds/subscriptions",
expectedURL: "http://ds/",
expectedHeaders: http.Header{
"Content-Type": []string{"application/json"},
},

View File

@ -18,6 +18,35 @@ type urlBuilder struct {
ResourceName string
}
func (params *urlBuilder) buildMetricsURLFromLegacyQuery() string {
subscription := params.Subscription
if params.Subscription == "" {
subscription = params.DefaultSubscription
}
metricDefinitionArray := strings.Split(params.MetricDefinition, "/")
resourceNameArray := strings.Split(params.ResourceName, "/")
provider := metricDefinitionArray[0]
metricDefinitionArray = metricDefinitionArray[1:]
urlArray := []string{
"/subscriptions",
subscription,
"resourceGroups",
params.ResourceGroup,
"providers",
provider,
}
for i, metricDefinition := range metricDefinitionArray {
urlArray = append(urlArray, metricDefinition, resourceNameArray[i])
}
resourceURI := strings.Join(urlArray, "/")
return resourceURI
}
// BuildMetricsURL checks the metric definition property to see which form of the url
// should be returned
func (params *urlBuilder) BuildMetricsURL() string {
@ -25,31 +54,7 @@ func (params *urlBuilder) BuildMetricsURL() string {
// Prior to Grafana 9, we had a legacy query object rather than a resourceURI, so we manually create the resource URI
if resourceURI == "" {
subscription := params.Subscription
if params.Subscription == "" {
subscription = params.DefaultSubscription
}
metricDefinitionArray := strings.Split(params.MetricDefinition, "/")
resourceNameArray := strings.Split(params.ResourceName, "/")
provider := metricDefinitionArray[0]
metricDefinitionArray = metricDefinitionArray[1:]
urlArray := []string{
subscription,
"resourceGroups",
params.ResourceGroup,
"providers",
provider,
}
for i := range metricDefinitionArray {
urlArray = append(urlArray, metricDefinitionArray[i])
urlArray = append(urlArray, resourceNameArray[i])
}
resourceURI = strings.Join(urlArray[:], "/")
resourceURI = params.buildMetricsURLFromLegacyQuery()
}
return fmt.Sprintf("%s/providers/microsoft.insights/metrics", resourceURI)

View File

@ -3,23 +3,23 @@ package metrics
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/assert"
)
func TestURLBuilder(t *testing.T) {
t.Run("AzureMonitor URL Builder", func(t *testing.T) {
t.Run("when only resource uri is provided it returns resource/uri/providers/microsoft.insights/metrics", func(t *testing.T) {
ub := &urlBuilder{
ResourceURI: "resource/uri",
ResourceURI: "/subscriptions/sub/resource/uri",
}
url := ub.BuildMetricsURL()
require.Equal(t, url, "resource/uri/providers/microsoft.insights/metrics")
assert.Equal(t, "/subscriptions/sub/resource/uri/providers/microsoft.insights/metrics", url)
})
t.Run("when resource uri and legacy fields are provided the legacy fields are ignored", func(t *testing.T) {
ub := &urlBuilder{
ResourceURI: "resource/uri",
ResourceURI: "/subscriptions/sub/resource/uri",
DefaultSubscription: "default-sub",
ResourceGroup: "rg",
MetricDefinition: "Microsoft.NetApp/netAppAccounts/capacityPools/volumes",
@ -27,7 +27,7 @@ func TestURLBuilder(t *testing.T) {
}
url := ub.BuildMetricsURL()
require.Equal(t, url, "resource/uri/providers/microsoft.insights/metrics")
assert.Equal(t, "/subscriptions/sub/resource/uri/providers/microsoft.insights/metrics", url)
})
t.Run("Legacy URL Builder params", func(t *testing.T) {
@ -40,7 +40,7 @@ func TestURLBuilder(t *testing.T) {
}
url := ub.BuildMetricsURL()
require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics")
assert.Equal(t, "/subscriptions/default-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics", url)
})
t.Run("when metric definition is in the short form and a subscription is defined", func(t *testing.T) {
@ -53,7 +53,7 @@ func TestURLBuilder(t *testing.T) {
}
url := ub.BuildMetricsURL()
require.Equal(t, url, "specified-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics")
assert.Equal(t, "/subscriptions/specified-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics", url)
})
t.Run("when metric definition is Microsoft.Storage/storageAccounts/blobServices", func(t *testing.T) {
@ -65,7 +65,7 @@ func TestURLBuilder(t *testing.T) {
}
url := ub.BuildMetricsURL()
require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/providers/microsoft.insights/metrics")
assert.Equal(t, "/subscriptions/default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/providers/microsoft.insights/metrics", url)
})
t.Run("when metric definition is Microsoft.Storage/storageAccounts/fileServices", func(t *testing.T) {
@ -77,7 +77,7 @@ func TestURLBuilder(t *testing.T) {
}
url := ub.BuildMetricsURL()
require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/providers/microsoft.insights/metrics")
assert.Equal(t, "/subscriptions/default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/providers/microsoft.insights/metrics", url)
})
t.Run("when metric definition is Microsoft.NetApp/netAppAccounts/capacityPools/volumes", func(t *testing.T) {
@ -89,7 +89,7 @@ func TestURLBuilder(t *testing.T) {
}
url := ub.BuildMetricsURL()
require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/providers/microsoft.insights/metrics")
assert.Equal(t, "/subscriptions/default-sub/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/providers/microsoft.insights/metrics", url)
})
})
})

View File

@ -121,9 +121,12 @@ type AzureMonitorJSONQuery struct {
// Legecy "resource" fields from before the resource picker provided just a single ResourceURI
// These are used for pre-resource picker queries to reconstruct a resource URI
// Deprecated
MetricDefinition string `json:"metricDefinition"`
ResourceGroup string `json:"resourceGroup"`
ResourceName string `json:"resourceName"`
// Deprecated
ResourceGroup string `json:"resourceGroup"`
// Deprecated
ResourceName string `json:"resourceName"`
AllowedTimeGrainsMs []int64 `json:"allowedTimeGrainsMs"`
Dimension string `json:"dimension"` // old model

View File

@ -16,6 +16,14 @@ export default function createMockDatasource(overrides?: DeepPartial<Datasource>
},
getSubscriptions: jest.fn().mockResolvedValueOnce([]),
defaultSubscriptionId: 'subscriptionId',
newGetMetricNamespaces: jest.fn().mockResolvedValueOnce([]),
newGetMetricNames: jest.fn().mockResolvedValueOnce([]),
newGetMetricMetadata: jest.fn().mockResolvedValueOnce({
primaryAggType: 'Average',
supportedAggTypes: ['Average', 'Maximum', 'Minimum'],
supportedTimeGrains: [],
dimensions: [],
}),
},
getAzureLogAnalyticsWorkspaces: jest.fn().mockResolvedValueOnce([]),
@ -41,6 +49,7 @@ export default function createMockDatasource(overrides?: DeepPartial<Datasource>
getResourceGroupsBySubscriptionId: jest.fn().mockResolvedValue([]),
getResourcesForResourceGroup: jest.fn().mockResolvedValue([]),
getResourceURIFromWorkspace: jest.fn().mockReturnValue(''),
getResourceURIDisplayProperties: jest.fn().mockResolvedValue({}),
},
...overrides,
};

View File

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

View File

@ -10,6 +10,7 @@ export default function createMockResourcePickerData(overrides?: DeepPartial<Res
getResourceGroupsBySubscriptionId: jest.fn().mockResolvedValue([]),
getResourcesForResourceGroup: jest.fn().mockResolvedValue([]),
getResourceURIFromWorkspace: jest.fn().mockReturnValue(''),
getResourceURIDisplayProperties: jest.fn().mockResolvedValue({}),
...overrides,
};

View File

@ -31,7 +31,7 @@ export const createMockResourceGroupsBySubscription = (): ResourceRowGroup => [
{
id: 'dev-1',
uri: '/subscriptions/def-456/resourceGroups/dev-1',
name: 'Development',
name: 'Development 1',
type: ResourceRowType.ResourceGroup,
typeLabel: 'Resource Group',
children: [],
@ -39,7 +39,7 @@ export const createMockResourceGroupsBySubscription = (): ResourceRowGroup => [
{
id: 'dev-2',
uri: '/subscriptions/def-456/resourceGroups/dev-2',
name: 'Development',
name: 'Development 2',
type: ResourceRowType.ResourceGroup,
typeLabel: 'Resource Group',
children: [],
@ -55,7 +55,7 @@ export const createMockResourceGroupsBySubscription = (): ResourceRowGroup => [
{
id: 'dev-4',
uri: '/subscriptions/def-456/resourceGroups/dev-4',
name: 'Development',
name: 'Development 3',
type: ResourceRowType.ResourceGroup,
typeLabel: 'Resource Group',
children: [],
@ -63,7 +63,7 @@ export const createMockResourceGroupsBySubscription = (): ResourceRowGroup => [
{
id: 'dev-5',
uri: '/subscriptions/def-456/resourceGroups/dev-5',
name: 'Development',
name: 'Development 4',
type: ResourceRowType.ResourceGroup,
typeLabel: 'Resource Group',
children: [],

View File

@ -75,266 +75,59 @@ describe('AzureMonitorDatasource', () => {
});
});
});
describe('When performing getSubscriptions', () => {
describe('When performing newGetMetricNamespaces', () => {
const response = {
value: [
{
id: '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572',
subscriptionId: '99999999-cccc-bbbb-aaaa-9106972f9572',
tenantId: '99999999-aaaa-bbbb-cccc-51c4f982ec48',
displayName: 'Primary Subscription',
state: 'Enabled',
subscriptionPolicies: {
locationPlacementId: 'Public_2014-09-01',
quotaId: 'PayAsYouGo_2014-09-01',
spendingLimit: 'Off',
id: '/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp/providers/microsoft.insights/components/resource1/providers/microsoft.insights/metricNamespaces/Azure.ApplicationInsights',
name: 'Azure.ApplicationInsights',
type: 'Microsoft.Insights/metricNamespaces',
classification: 'Custom',
properties: {
metricNamespaceName: 'Azure.ApplicationInsights',
},
authorizationSource: 'RoleBased',
},
],
count: {
type: 'Total',
value: 1,
},
};
beforeEach(() => {
ctx.instanceSettings.jsonData.azureAuthType = 'msi';
ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockResolvedValue(response);
});
it('should return list of subscriptions', () => {
return ctx.ds.getSubscriptions().then((results: Array<{ text: string; value: string }>) => {
expect(results.length).toEqual(1);
expect(results[0].text).toEqual('Primary Subscription');
expect(results[0].value).toEqual('99999999-cccc-bbbb-aaaa-9106972f9572');
});
});
});
describe('When performing getResourceGroups', () => {
const response = {
value: [{ name: 'grp1' }, { name: 'grp2' }],
};
beforeEach(() => {
ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockResolvedValue(response);
});
it('should return list of Resource Groups', () => {
return ctx.ds.getResourceGroups('subscriptionId').then((results: Array<{ text: string; value: string }>) => {
expect(results.length).toEqual(2);
expect(results[0].text).toEqual('grp1');
expect(results[0].value).toEqual('grp1');
expect(results[1].text).toEqual('grp2');
expect(results[1].value).toEqual('grp2');
});
});
});
describe('When performing getMetricDefinitions', () => {
const response = {
value: [
{
name: 'test',
type: 'Microsoft.Network/networkInterfaces',
},
{
location: 'northeurope',
name: 'northeur',
type: 'Microsoft.Compute/virtualMachines',
},
{
location: 'westcentralus',
name: 'us',
type: 'Microsoft.Compute/virtualMachines',
},
{
name: 'IHaveNoMetrics',
type: 'IShouldBeFilteredOut',
},
{
name: 'storageTest',
type: 'Microsoft.Storage/storageAccounts',
id: '/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp/providers/microsoft.insights/components/resource1/providers/microsoft.insights/metricNamespaces/microsoft.insights-components',
name: 'microsoft.insights-components',
type: 'Microsoft.Insights/metricNamespaces',
classification: 'Platform',
properties: {
metricNamespaceName: 'microsoft.insights/components',
},
},
],
};
beforeEach(() => {
ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => {
const basePath = 'azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups';
expect(path).toBe(basePath + '/nodesapp/resources?api-version=2021-04-01');
const basePath = 'azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp';
const expected =
basePath +
'/providers/microsoft.insights/components/resource1' +
'/providers/microsoft.insights/metricNamespaces?api-version=2017-12-01-preview';
expect(path).toBe(expected);
return Promise.resolve(response);
});
});
it('should return list of Metric Definitions with no duplicates and no unsupported namespaces', () => {
return ctx.ds
.getMetricDefinitions('9935389e-9122-4ef9-95f9-1513dd24753f', 'nodesapp')
it('should return list of Metric Namspaces', () => {
return ctx.ds.azureMonitorDatasource
.newGetMetricNamespaces(
'/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp/providers/microsoft.insights/components/resource1'
)
.then((results: Array<{ text: string; value: string }>) => {
expect(results.length).toEqual(7);
expect(results[0].text).toEqual('Network interface');
expect(results[0].value).toEqual('Microsoft.Network/networkInterfaces');
expect(results[1].text).toEqual('Virtual machine');
expect(results[1].value).toEqual('Microsoft.Compute/virtualMachines');
expect(results[2].text).toEqual('Storage account');
expect(results[2].value).toEqual('Microsoft.Storage/storageAccounts');
expect(results[3].text).toEqual('Microsoft.Storage/storageAccounts/blobServices');
expect(results[3].value).toEqual('Microsoft.Storage/storageAccounts/blobServices');
expect(results[4].text).toEqual('Microsoft.Storage/storageAccounts/fileServices');
expect(results[4].value).toEqual('Microsoft.Storage/storageAccounts/fileServices');
expect(results[5].text).toEqual('Microsoft.Storage/storageAccounts/tableServices');
expect(results[5].value).toEqual('Microsoft.Storage/storageAccounts/tableServices');
expect(results[6].text).toEqual('Microsoft.Storage/storageAccounts/queueServices');
expect(results[6].value).toEqual('Microsoft.Storage/storageAccounts/queueServices');
expect(results.length).toEqual(2);
expect(results[0].text).toEqual('Azure.ApplicationInsights');
expect(results[0].value).toEqual('Azure.ApplicationInsights');
expect(results[1].text).toEqual('microsoft.insights-components');
expect(results[1].value).toEqual('microsoft.insights/components');
});
});
});
describe('When performing getResourceNames', () => {
let subscription = '9935389e-9122-4ef9-95f9-1513dd24753f';
let resourceGroup = 'nodeapp';
let metricDefinition = 'microsoft.insights/components';
beforeEach(() => {
subscription = '9935389e-9122-4ef9-95f9-1513dd24753f';
resourceGroup = 'nodeapp';
metricDefinition = 'microsoft.insights/components';
});
describe('and there are no special cases', () => {
const response = {
value: [
{
name: 'Failure Anomalies - nodeapp',
type: 'microsoft.insights/alertrules',
},
{
name: resourceGroup,
type: metricDefinition,
},
],
};
beforeEach(() => {
ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => {
const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`;
expect(path).toBe(
`${basePath}/${resourceGroup}/resources?$filter=resourceType eq '${metricDefinition}'&api-version=2021-04-01`
);
return Promise.resolve(response);
});
});
it('should return list of Resource Names', () => {
return ctx.ds
.getResourceNames(subscription, resourceGroup, metricDefinition)
.then((results: Array<{ text: string; value: string }>) => {
expect(results.length).toEqual(1);
expect(results[0].text).toEqual('nodeapp');
expect(results[0].value).toEqual('nodeapp');
});
});
it('should return ignore letter case', () => {
metricDefinition = 'microsoft.insights/Components';
return ctx.ds
.getResourceNames(subscription, resourceGroup, metricDefinition)
.then((results: Array<{ text: string; value: string }>) => {
expect(results.length).toEqual(1);
expect(results[0].text).toEqual('nodeapp');
expect(results[0].value).toEqual('nodeapp');
});
});
});
describe('and the metric definition is blobServices', () => {
const response = {
value: [
{
name: 'Failure Anomalies - nodeapp',
type: 'microsoft.insights/alertrules',
},
{
name: 'storagetest',
type: 'Microsoft.Storage/storageAccounts',
},
],
};
beforeEach(() => {
ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => {
const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`;
expect(path).toBe(
basePath +
`/${resourceGroup}/resources?$filter=resourceType eq '${metricDefinition}'&api-version=2021-04-01`
);
return Promise.resolve(response);
});
});
it('should return list of Resource Names', () => {
metricDefinition = 'Microsoft.Storage/storageAccounts/blobServices';
return ctx.ds
.getResourceNames(subscription, resourceGroup, metricDefinition)
.then((results: Array<{ text: string; value: string }>) => {
expect(results.length).toEqual(1);
expect(results[0].text).toEqual('storagetest/default');
expect(results[0].value).toEqual('storagetest/default');
});
});
});
describe('and there are several pages', () => {
const skipToken = 'token';
const response1 = {
value: [
{
name: `${resourceGroup}1`,
type: metricDefinition,
},
],
nextLink: `https://management.azure.com/resourceuri?$skiptoken=${skipToken}`,
};
const response2 = {
value: [
{
name: `${resourceGroup}2`,
type: metricDefinition,
},
],
};
beforeEach(() => {
const fn = jest.fn();
ctx.ds.azureMonitorDatasource.getResource = fn;
const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`;
const expectedPath = `${basePath}/${resourceGroup}/resources?$filter=resourceType eq '${metricDefinition}'&api-version=2021-04-01`;
// first page
fn.mockImplementationOnce((path: string) => {
expect(path).toBe(expectedPath);
return Promise.resolve(response1);
});
// second page
fn.mockImplementationOnce((path: string) => {
expect(path).toBe(`${expectedPath}&$skiptoken=${skipToken}`);
return Promise.resolve(response2);
});
});
it('should return list of Resource Names', () => {
return ctx.ds
.getResourceNames(subscription, resourceGroup, metricDefinition)
.then((results: Array<{ text: string; value: string }>) => {
expect(results.length).toEqual(2);
expect(results[0].value).toEqual(`${resourceGroup}1`);
expect(results[1].value).toEqual(`${resourceGroup}2`);
});
});
});
});
describe('When performing getMetricNames', () => {
describe('When performing newGetMetricNames', () => {
const response = {
value: [
{
@ -383,12 +176,9 @@ describe('AzureMonitorDatasource', () => {
});
it('should return list of Metric Definitions', () => {
return ctx.ds
.getMetricNames(
'9935389e-9122-4ef9-95f9-1513dd24753f',
'nodeapp',
'microsoft.insights/components',
'resource1',
return ctx.ds.azureMonitorDatasource
.newGetMetricNames(
'/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp/providers/microsoft.insights/components/resource1',
'default'
)
.then((results: Array<{ text: string; value: string }>) => {
@ -401,7 +191,7 @@ describe('AzureMonitorDatasource', () => {
});
});
describe('When performing getMetricMetadata', () => {
describe('When performing newGetMetricMetadata', () => {
const response = {
value: [
{
@ -450,12 +240,9 @@ describe('AzureMonitorDatasource', () => {
});
it('should return Aggregation metadata for a Metric', () => {
return ctx.ds
.getMetricMetadata(
'9935389e-9122-4ef9-95f9-1513dd24753f',
'nodeapp',
'microsoft.insights/components',
'resource1',
return ctx.ds.azureMonitorDatasource
.newGetMetricMetadata(
'/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp/providers/microsoft.insights/components/resource1',
'default',
'UsedCapacity'
)
@ -467,130 +254,524 @@ describe('AzureMonitorDatasource', () => {
});
});
describe('When performing getMetricMetadata on metrics with dimensions', () => {
const response = {
value: [
{
name: {
value: 'Transactions',
localizedValue: 'Transactions',
describe('Legacy Azure Monitor Query Object data fetchers', () => {
describe('When performing getSubscriptions', () => {
const response = {
value: [
{
id: '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572',
subscriptionId: '99999999-cccc-bbbb-aaaa-9106972f9572',
tenantId: '99999999-aaaa-bbbb-cccc-51c4f982ec48',
displayName: 'Primary Subscription',
state: 'Enabled',
subscriptionPolicies: {
locationPlacementId: 'Public_2014-09-01',
quotaId: 'PayAsYouGo_2014-09-01',
spendingLimit: 'Off',
},
authorizationSource: 'RoleBased',
},
unit: 'Count',
primaryAggregationType: 'Total',
supportedAggregationTypes: ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count'],
isDimensionRequired: false,
dimensions: [
],
count: {
type: 'Total',
value: 1,
},
};
beforeEach(() => {
ctx.instanceSettings.jsonData.azureAuthType = 'msi';
ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockResolvedValue(response);
});
it('should return list of subscriptions', () => {
return ctx.ds.getSubscriptions().then((results: Array<{ text: string; value: string }>) => {
expect(results.length).toEqual(1);
expect(results[0].text).toEqual('Primary Subscription');
expect(results[0].value).toEqual('99999999-cccc-bbbb-aaaa-9106972f9572');
});
});
});
describe('When performing getResourceGroups', () => {
const response = {
value: [{ name: 'grp1' }, { name: 'grp2' }],
};
beforeEach(() => {
ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockResolvedValue(response);
});
it('should return list of Resource Groups', () => {
return ctx.ds.getResourceGroups('subscriptionId').then((results: Array<{ text: string; value: string }>) => {
expect(results.length).toEqual(2);
expect(results[0].text).toEqual('grp1');
expect(results[0].value).toEqual('grp1');
expect(results[1].text).toEqual('grp2');
expect(results[1].value).toEqual('grp2');
});
});
});
describe('When performing getMetricDefinitions', () => {
const response = {
value: [
{
name: 'test',
type: 'Microsoft.Network/networkInterfaces',
},
{
location: 'northeurope',
name: 'northeur',
type: 'Microsoft.Compute/virtualMachines',
},
{
location: 'westcentralus',
name: 'us',
type: 'Microsoft.Compute/virtualMachines',
},
{
name: 'IHaveNoMetrics',
type: 'IShouldBeFilteredOut',
},
{
name: 'storageTest',
type: 'Microsoft.Storage/storageAccounts',
},
],
};
beforeEach(() => {
ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => {
const basePath = 'azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups';
expect(path).toBe(basePath + '/nodesapp/resources?api-version=2021-04-01');
return Promise.resolve(response);
});
});
it('should return list of Metric Definitions with no duplicates and no unsupported namespaces', () => {
return ctx.ds
.getMetricDefinitions('9935389e-9122-4ef9-95f9-1513dd24753f', 'nodesapp')
.then((results: Array<{ text: string; value: string }>) => {
expect(results.length).toEqual(7);
expect(results[0].text).toEqual('Network interface');
expect(results[0].value).toEqual('Microsoft.Network/networkInterfaces');
expect(results[1].text).toEqual('Virtual machine');
expect(results[1].value).toEqual('Microsoft.Compute/virtualMachines');
expect(results[2].text).toEqual('Storage account');
expect(results[2].value).toEqual('Microsoft.Storage/storageAccounts');
expect(results[3].text).toEqual('Microsoft.Storage/storageAccounts/blobServices');
expect(results[3].value).toEqual('Microsoft.Storage/storageAccounts/blobServices');
expect(results[4].text).toEqual('Microsoft.Storage/storageAccounts/fileServices');
expect(results[4].value).toEqual('Microsoft.Storage/storageAccounts/fileServices');
expect(results[5].text).toEqual('Microsoft.Storage/storageAccounts/tableServices');
expect(results[5].value).toEqual('Microsoft.Storage/storageAccounts/tableServices');
expect(results[6].text).toEqual('Microsoft.Storage/storageAccounts/queueServices');
expect(results[6].value).toEqual('Microsoft.Storage/storageAccounts/queueServices');
});
});
});
describe('When performing getResourceNames', () => {
let subscription = '9935389e-9122-4ef9-95f9-1513dd24753f';
let resourceGroup = 'nodeapp';
let metricDefinition = 'microsoft.insights/components';
beforeEach(() => {
subscription = '9935389e-9122-4ef9-95f9-1513dd24753f';
resourceGroup = 'nodeapp';
metricDefinition = 'microsoft.insights/components';
});
describe('and there are no special cases', () => {
const response = {
value: [
{
value: 'ResponseType',
localizedValue: 'Response type',
name: 'Failure Anomalies - nodeapp',
type: 'microsoft.insights/alertrules',
},
{
value: 'GeoType',
localizedValue: 'Geo type',
},
{
value: 'ApiName',
localizedValue: 'API name',
name: resourceGroup,
type: metricDefinition,
},
],
},
{
name: {
value: 'FreeCapacity',
localizedValue: 'Free capacity',
};
beforeEach(() => {
ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => {
const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`;
expect(path).toBe(
`${basePath}/${resourceGroup}/resources?$filter=resourceType eq '${metricDefinition}'&api-version=2021-04-01`
);
return Promise.resolve(response);
});
});
it('should return list of Resource Names', () => {
return ctx.ds
.getResourceNames(subscription, resourceGroup, metricDefinition)
.then((results: Array<{ text: string; value: string }>) => {
expect(results.length).toEqual(1);
expect(results[0].text).toEqual('nodeapp');
expect(results[0].value).toEqual('nodeapp');
});
});
it('should return ignore letter case', () => {
metricDefinition = 'microsoft.insights/Components';
return ctx.ds
.getResourceNames(subscription, resourceGroup, metricDefinition)
.then((results: Array<{ text: string; value: string }>) => {
expect(results.length).toEqual(1);
expect(results[0].text).toEqual('nodeapp');
expect(results[0].value).toEqual('nodeapp');
});
});
});
describe('and the metric definition is blobServices', () => {
const response = {
value: [
{
name: 'Failure Anomalies - nodeapp',
type: 'microsoft.insights/alertrules',
},
{
name: 'storagetest',
type: 'Microsoft.Storage/storageAccounts',
},
],
};
beforeEach(() => {
ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => {
const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`;
expect(path).toBe(
basePath +
`/${resourceGroup}/resources?$filter=resourceType eq '${metricDefinition}'&api-version=2021-04-01`
);
return Promise.resolve(response);
});
});
it('should return list of Resource Names', () => {
metricDefinition = 'Microsoft.Storage/storageAccounts/blobServices';
return ctx.ds
.getResourceNames(subscription, resourceGroup, metricDefinition)
.then((results: Array<{ text: string; value: string }>) => {
expect(results.length).toEqual(1);
expect(results[0].text).toEqual('storagetest/default');
expect(results[0].value).toEqual('storagetest/default');
});
});
});
describe('and there are several pages', () => {
const skipToken = 'token';
const response1 = {
value: [
{
name: `${resourceGroup}1`,
type: metricDefinition,
},
],
nextLink: `https://management.azure.com/resourceuri?$skiptoken=${skipToken}`,
};
const response2 = {
value: [
{
name: `${resourceGroup}2`,
type: metricDefinition,
},
],
};
beforeEach(() => {
const fn = jest.fn();
ctx.ds.azureMonitorDatasource.getResource = fn;
const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`;
const expectedPath = `${basePath}/${resourceGroup}/resources?$filter=resourceType eq '${metricDefinition}'&api-version=2021-04-01`;
// first page
fn.mockImplementationOnce((path: string) => {
expect(path).toBe(expectedPath);
return Promise.resolve(response1);
});
// second page
fn.mockImplementationOnce((path: string) => {
expect(path).toBe(`${expectedPath}&$skiptoken=${skipToken}`);
return Promise.resolve(response2);
});
});
it('should return list of Resource Names', () => {
return ctx.ds
.getResourceNames(subscription, resourceGroup, metricDefinition)
.then((results: Array<{ text: string; value: string }>) => {
expect(results.length).toEqual(2);
expect(results[0].value).toEqual(`${resourceGroup}1`);
expect(results[1].value).toEqual(`${resourceGroup}2`);
});
});
});
});
describe('When performing getMetricNames', () => {
const response = {
value: [
{
name: {
value: 'UsedCapacity',
localizedValue: 'Used capacity',
},
unit: 'CountPerSecond',
primaryAggregationType: 'Total',
supportedAggregationTypes: ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count'],
metricAvailabilities: [
{ timeGrain: 'PT1H', retention: 'P93D' },
{ timeGrain: 'PT6H', retention: 'P93D' },
{ timeGrain: 'PT12H', retention: 'P93D' },
{ timeGrain: 'P1D', retention: 'P93D' },
],
},
unit: 'CountPerSecond',
primaryAggregationType: 'Average',
supportedAggregationTypes: ['None', 'Average'],
},
],
};
{
name: {
value: 'FreeCapacity',
localizedValue: 'Free capacity',
},
unit: 'CountPerSecond',
primaryAggregationType: 'Average',
supportedAggregationTypes: ['None', 'Average'],
metricAvailabilities: [
{ timeGrain: 'PT1H', retention: 'P93D' },
{ timeGrain: 'PT6H', retention: 'P93D' },
{ timeGrain: 'PT12H', retention: 'P93D' },
{ timeGrain: 'P1D', retention: 'P93D' },
],
},
],
};
beforeEach(() => {
ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => {
const basePath = 'azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp';
const expected =
basePath +
'/providers/microsoft.insights/components/resource1' +
'/providers/microsoft.insights/metricdefinitions?api-version=2018-01-01&metricnamespace=default';
expect(path).toBe(expected);
return Promise.resolve(response);
});
});
it('should return dimensions for a Metric that has dimensions', () => {
return ctx.ds
.getMetricMetadata(
'9935389e-9122-4ef9-95f9-1513dd24753f',
'nodeapp',
'microsoft.insights/components',
'resource1',
'default',
'Transactions'
)
.then((results: any) => {
expect(results.dimensions).toMatchInlineSnapshot(`
Array [
Object {
"label": "Response type",
"value": "ResponseType",
},
Object {
"label": "Geo type",
"value": "GeoType",
},
Object {
"label": "API name",
"value": "ApiName",
},
]
`);
beforeEach(() => {
ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => {
const basePath = 'azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp';
const expected =
basePath +
'/providers/microsoft.insights/components/resource1' +
'/providers/microsoft.insights/metricdefinitions?api-version=2018-01-01&metricnamespace=default';
expect(path).toBe(expected);
return Promise.resolve(response);
});
});
describe('When performing targetContainsTemplate', () => {
it('should return false when no variable is being used', () => {
const query = createMockQuery();
query.queryType = AzureQueryType.AzureMonitor;
expect(ctx.ds.targetContainsTemplate(query)).toEqual(false);
});
it('should return true when subscriptions field is using a variable', () => {
const query = createMockQuery();
const templateSrv = new TemplateSrv();
templateSrv.init([subscriptionsVariable]);
const ds = new AzureMonitorDatasource(ctx.instanceSettings, templateSrv);
query.queryType = AzureQueryType.AzureMonitor;
query.subscription = `$${subscriptionsVariable.name}`;
expect(ds.targetContainsTemplate(query)).toEqual(true);
});
it('should return false when a variable is used in a different part of the query', () => {
const query = createMockQuery();
const templateSrv = new TemplateSrv();
templateSrv.init([singleVariable]);
const ds = new AzureMonitorDatasource(ctx.instanceSettings, templateSrv);
query.queryType = AzureQueryType.AzureMonitor;
query.azureLogAnalytics = { resource: `$${singleVariable.name}` };
expect(ds.targetContainsTemplate(query)).toEqual(false);
it('should return list of Metric Definitions', () => {
return ctx.ds
.getMetricNames(
'9935389e-9122-4ef9-95f9-1513dd24753f',
'nodeapp',
'microsoft.insights/components',
'resource1',
'default'
)
.then((results: Array<{ text: string; value: string }>) => {
expect(results.length).toEqual(2);
expect(results[0].text).toEqual('Used capacity');
expect(results[0].value).toEqual('UsedCapacity');
expect(results[1].text).toEqual('Free capacity');
expect(results[1].value).toEqual('FreeCapacity');
});
});
});
it('should return an empty array for a Metric that does not have dimensions', () => {
return ctx.ds
.getMetricMetadata(
'9935389e-9122-4ef9-95f9-1513dd24753f',
'nodeapp',
'microsoft.insights/components',
'resource1',
'default',
'FreeCapacity'
)
.then((results: any) => {
expect(results.dimensions.length).toEqual(0);
describe('When performing getMetricMetadata', () => {
const response = {
value: [
{
name: {
value: 'UsedCapacity',
localizedValue: 'Used capacity',
},
unit: 'CountPerSecond',
primaryAggregationType: 'Total',
supportedAggregationTypes: ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count'],
metricAvailabilities: [
{ timeGrain: 'PT1H', retention: 'P93D' },
{ timeGrain: 'PT6H', retention: 'P93D' },
{ timeGrain: 'PT12H', retention: 'P93D' },
{ timeGrain: 'P1D', retention: 'P93D' },
],
},
{
name: {
value: 'FreeCapacity',
localizedValue: 'Free capacity',
},
unit: 'CountPerSecond',
primaryAggregationType: 'Average',
supportedAggregationTypes: ['None', 'Average'],
metricAvailabilities: [
{ timeGrain: 'PT1H', retention: 'P93D' },
{ timeGrain: 'PT6H', retention: 'P93D' },
{ timeGrain: 'PT12H', retention: 'P93D' },
{ timeGrain: 'P1D', retention: 'P93D' },
],
},
],
};
beforeEach(() => {
ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => {
const basePath = 'azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp';
const expected =
basePath +
'/providers/microsoft.insights/components/resource1' +
'/providers/microsoft.insights/metricdefinitions?api-version=2018-01-01&metricnamespace=default';
expect(path).toBe(expected);
return Promise.resolve(response);
});
});
it('should return Aggregation metadata for a Metric', () => {
return ctx.ds
.getMetricMetadata(
'9935389e-9122-4ef9-95f9-1513dd24753f',
'nodeapp',
'microsoft.insights/components',
'resource1',
'default',
'UsedCapacity'
)
.then((results) => {
expect(results.primaryAggType).toEqual('Total');
expect(results.supportedAggTypes.length).toEqual(6);
expect(results.supportedTimeGrains.length).toEqual(5); // 4 time grains from the API + auto
});
});
});
describe('When performing getMetricMetadata on metrics with dimensions', () => {
const response = {
value: [
{
name: {
value: 'Transactions',
localizedValue: 'Transactions',
},
unit: 'Count',
primaryAggregationType: 'Total',
supportedAggregationTypes: ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count'],
isDimensionRequired: false,
dimensions: [
{
value: 'ResponseType',
localizedValue: 'Response type',
},
{
value: 'GeoType',
localizedValue: 'Geo type',
},
{
value: 'ApiName',
localizedValue: 'API name',
},
],
},
{
name: {
value: 'FreeCapacity',
localizedValue: 'Free capacity',
},
unit: 'CountPerSecond',
primaryAggregationType: 'Average',
supportedAggregationTypes: ['None', 'Average'],
},
],
};
beforeEach(() => {
ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => {
const basePath = 'azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp';
const expected =
basePath +
'/providers/microsoft.insights/components/resource1' +
'/providers/microsoft.insights/metricdefinitions?api-version=2018-01-01&metricnamespace=default';
expect(path).toBe(expected);
return Promise.resolve(response);
});
});
it('should return dimensions for a Metric that has dimensions', () => {
return ctx.ds
.getMetricMetadata(
'9935389e-9122-4ef9-95f9-1513dd24753f',
'nodeapp',
'microsoft.insights/components',
'resource1',
'default',
'Transactions'
)
.then((results: any) => {
expect(results.dimensions).toMatchInlineSnapshot(`
Array [
Object {
"label": "Response type",
"value": "ResponseType",
},
Object {
"label": "Geo type",
"value": "GeoType",
},
Object {
"label": "API name",
"value": "ApiName",
},
]
`);
});
});
describe('When performing targetContainsTemplate', () => {
it('should return false when no variable is being used', () => {
const query = createMockQuery();
query.queryType = AzureQueryType.AzureMonitor;
expect(ctx.ds.targetContainsTemplate(query)).toEqual(false);
});
it('should return true when subscriptions field is using a variable', () => {
const query = createMockQuery();
const templateSrv = new TemplateSrv();
templateSrv.init([subscriptionsVariable]);
const ds = new AzureMonitorDatasource(ctx.instanceSettings, templateSrv);
query.queryType = AzureQueryType.AzureMonitor;
query.subscription = `$${subscriptionsVariable.name}`;
expect(ds.targetContainsTemplate(query)).toEqual(true);
});
it('should return false when a variable is used in a different part of the query', () => {
const query = createMockQuery();
const templateSrv = new TemplateSrv();
templateSrv.init([singleVariable]);
const ds = new AzureMonitorDatasource(ctx.instanceSettings, templateSrv);
query.queryType = AzureQueryType.AzureMonitor;
query.azureLogAnalytics = { resource: `$${singleVariable.name}` };
expect(ds.targetContainsTemplate(query)).toEqual(false);
});
});
it('should return an empty array for a Metric that does not have dimensions', () => {
return ctx.ds
.getMetricMetadata(
'9935389e-9122-4ef9-95f9-1513dd24753f',
'nodeapp',
'microsoft.insights/components',
'resource1',
'default',
'FreeCapacity'
)
.then((results: any) => {
expect(results.dimensions.length).toEqual(0);
});
});
});
});
});

View File

@ -9,6 +9,9 @@ import TimegrainConverter from '../time_grain_converter';
import {
AzureDataSourceJsonData,
AzureMonitorMetricDefinitionsResponse,
AzureMonitorMetricNamespacesResponse,
AzureMonitorMetricNamesResponse,
AzureMonitorMetricsMetadataResponse,
AzureMonitorQuery,
AzureMonitorResourceGroupsResponse,
AzureQueryType,
@ -21,6 +24,10 @@ import UrlBuilder from './url_builder';
const defaultDropdownValue = 'select';
function hasValue(item?: string) {
return !!(item && item !== defaultDropdownValue);
}
export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureMonitorQuery, AzureDataSourceJsonData> {
apiVersion = '2018-01-01';
apiPreviewVersion = '2017-12-01-preview';
@ -40,7 +47,7 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
this.defaultSubscriptionId = instanceSettings.jsonData.subscriptionId;
const cloud = getAzureCloud(instanceSettings);
this.resourcePath = `${routeNames.azureMonitor}/subscriptions`;
this.resourcePath = routeNames.azureMonitor;
this.supportedMetricNamespaces = new SupportedNamespaces(cloud).get();
this.azurePortalUrl = getAzurePortalUrl(cloud);
}
@ -51,19 +58,17 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
}
filterQuery(item: AzureMonitorQuery): boolean {
const hasResourceUri = !!item?.azureMonitor?.resourceUri;
const hasLegacyQuery =
hasValue(item?.azureMonitor?.resourceGroup) &&
hasValue(item?.azureMonitor?.resourceName) &&
hasValue(item?.azureMonitor?.metricDefinition);
return !!(
item.hide !== true &&
item.azureMonitor &&
item.azureMonitor.resourceGroup &&
item.azureMonitor.resourceGroup !== defaultDropdownValue &&
item.azureMonitor.resourceName &&
item.azureMonitor.resourceName !== defaultDropdownValue &&
item.azureMonitor.metricDefinition &&
item.azureMonitor.metricDefinition !== defaultDropdownValue &&
item.azureMonitor.metricName &&
item.azureMonitor.metricName !== defaultDropdownValue &&
item.azureMonitor.aggregation &&
item.azureMonitor.aggregation !== defaultDropdownValue
(hasResourceUri || hasLegacyQuery) &&
hasValue(item?.azureMonitor?.metricName) &&
hasValue(item?.azureMonitor?.aggregation)
);
}
@ -82,6 +87,7 @@ 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);
@ -107,6 +113,7 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
subscription: subscriptionId,
queryType: AzureQueryType.AzureMonitor,
azureMonitor: {
resourceUri,
resourceGroup,
resourceName,
metricDefinition,
@ -128,14 +135,14 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
return [];
}
return this.getResource(`${this.resourcePath}?api-version=2019-03-01`).then((result: any) => {
return this.getResource(`${this.resourcePath}/subscriptions?api-version=2019-03-01`).then((result: any) => {
return ResponseParser.parseSubscriptions(result);
});
}
getResourceGroups(subscriptionId: string) {
return this.getResource(
`${this.resourcePath}/${subscriptionId}/resourceGroups?api-version=${this.listByResourceGroupApiVersion}`
`${this.resourcePath}/subscriptions/${subscriptionId}/resourceGroups?api-version=${this.listByResourceGroupApiVersion}`
).then((result: AzureMonitorResourceGroupsResponse) => {
return ResponseParser.parseResponseValues(result, 'name', 'name');
});
@ -143,7 +150,7 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
getMetricDefinitions(subscriptionId: string, resourceGroup: string) {
return this.getResource(
`${this.resourcePath}/${subscriptionId}/resourceGroups/${resourceGroup}/resources?api-version=${this.listByResourceGroupApiVersion}`
`${this.resourcePath}/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/resources?api-version=${this.listByResourceGroupApiVersion}`
)
.then((result: AzureMonitorMetricDefinitionsResponse) => {
return ResponseParser.parseResponseValues(result, 'type', 'type');
@ -196,7 +203,7 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
getResourceNames(subscriptionId: string, resourceGroup: string, metricDefinition: string, skipToken?: string) {
let url =
`${this.resourcePath}/${subscriptionId}/resourceGroups/${resourceGroup}/resources?` +
`${this.resourcePath}/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/resources?` +
`$filter=resourceType eq '${metricDefinition}'&` +
`api-version=${this.listByResourceGroupApiVersion}`;
if (skipToken) {
@ -244,6 +251,19 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
});
}
newGetMetricNamespaces(resourceUri: string) {
const templateSrv = getTemplateSrv();
const url = UrlBuilder.newBuildAzureMonitorGetMetricNamespacesUrl(
this.resourcePath,
templateSrv.replace(resourceUri),
this.apiPreviewVersion
);
return this.getResource(url).then((result: AzureMonitorMetricNamespacesResponse) => {
return ResponseParser.parseResponseValues(result, 'name', 'properties.metricNamespaceName');
});
}
getMetricNames(
subscriptionId: string,
resourceGroup: string,
@ -266,6 +286,20 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
});
}
newGetMetricNames(resourceUri: string, metricNamespace: string) {
const templateSrv = getTemplateSrv();
const url = UrlBuilder.newBuildAzureMonitorGetMetricNamesUrl(
this.resourcePath,
templateSrv.replace(resourceUri),
templateSrv.replace(metricNamespace),
this.apiVersion
);
return this.getResource(url).then((result: AzureMonitorMetricNamesResponse) => {
return ResponseParser.parseResponseValues(result, 'name.localizedValue', 'name.value');
});
}
getMetricMetadata(
subscriptionId: string,
resourceGroup: string,
@ -289,6 +323,20 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
});
}
newGetMetricMetadata(resourceUri: string, metricNamespace: string, metricName: string) {
const templateSrv = getTemplateSrv();
const url = UrlBuilder.newBuildAzureMonitorGetMetricNamesUrl(
this.resourcePath,
templateSrv.replace(resourceUri),
templateSrv.replace(metricNamespace),
this.apiVersion
);
return this.getResource(url).then((result: AzureMonitorMetricsMetadataResponse) => {
return ResponseParser.parseMetadata(result, metricName);
});
}
async testDatasource(): Promise<DatasourceValidationResult> {
const validationError = this.validateDatasource();
if (validationError) {
@ -296,7 +344,7 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
}
try {
const url = `${this.resourcePath}?api-version=2019-03-01`;
const url = `${this.resourcePath}/subscriptions?api-version=2019-03-01`;
return await this.getResource(url).then<DatasourceValidationResult>((response: any) => {
return {

View File

@ -1,180 +1,209 @@
import UrlBuilder from './url_builder';
describe('AzureMonitorUrlBuilder', () => {
describe('when metric definition is Microsoft.NetApp/netAppAccounts/capacityPools/volumes', () => {
it('should build the getMetricNamespaces url in the even longer format', () => {
const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl(
describe('when a resource uri is provided', () => {
it('builds a getMetricNamesnamespace url', () => {
const url = UrlBuilder.newBuildAzureMonitorGetMetricNamespacesUrl(
'',
'sub1',
'rg',
'Microsoft.NetApp/netAppAccounts/capacityPools/volumes',
'rn1/rn2/rn3',
'/subscriptions/sub/resource-uri/resource',
'2017-05-01-preview'
);
expect(url).toBe(
'/sub1/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/' +
'providers/microsoft.insights/metricNamespaces?api-version=2017-05-01-preview'
'/subscriptions/sub/resource-uri/resource/providers/microsoft.insights/metricNamespaces?api-version=2017-05-01-preview'
);
});
});
describe('when metric definition is Microsoft.Sql/servers/databases', () => {
it('should build the getMetricNamespaces url in the longer format', () => {
const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl(
describe('when a resource uri and metric namespace is provided', () => {
it('builds a getMetricNames url', () => {
const url = UrlBuilder.newBuildAzureMonitorGetMetricNamesUrl(
'',
'sub1',
'rg',
'Microsoft.Sql/servers/databases',
'rn1/rn2',
'2017-05-01-preview'
);
expect(url).toBe(
'/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn1/databases/rn2/' +
'providers/microsoft.insights/metricNamespaces?api-version=2017-05-01-preview'
);
});
});
describe('when metric definition is Microsoft.Sql/servers', () => {
it('should build the getMetricNamespaces url in the shorter format', () => {
const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl(
'',
'sub1',
'rg',
'/subscriptions/sub/resource-uri/resource',
'Microsoft.Sql/servers',
'rn',
'2017-05-01-preview'
);
expect(url).toBe(
'/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn/' +
'providers/microsoft.insights/metricNamespaces?api-version=2017-05-01-preview'
'/subscriptions/sub/resource-uri/resource/providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=Microsoft.Sql%2Fservers'
);
});
});
describe('when metric definition is Microsoft.NetApp/netAppAccounts/capacityPools/volumes and the metricNamespace is default', () => {
it('should build the getMetricNames url in the even longer format', () => {
const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
'',
'sub1',
'rg',
'Microsoft.NetApp/netAppAccounts/capacityPools/volumes',
'rn1/rn2/rn3',
'default',
'2017-05-01-preview'
);
expect(url).toBe(
'/sub1/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/' +
'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
);
describe('Legacy query object', () => {
describe('when metric definition is Microsoft.NetApp/netAppAccounts/capacityPools/volumes', () => {
it('should build the getMetricNamespaces url in the even longer format', () => {
const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl(
'',
'sub1',
'rg',
'Microsoft.NetApp/netAppAccounts/capacityPools/volumes',
'rn1/rn2/rn3',
'2017-05-01-preview'
);
expect(url).toBe(
'/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/' +
'providers/microsoft.insights/metricNamespaces?api-version=2017-05-01-preview'
);
});
});
});
describe('when metric definition is Microsoft.Sql/servers/databases and the metricNamespace is default', () => {
it('should build the getMetricNames url in the longer format', () => {
const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
'',
'sub1',
'rg',
'Microsoft.Sql/servers/databases',
'rn1/rn2',
'default',
'2017-05-01-preview'
);
expect(url).toBe(
'/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn1/databases/rn2/' +
'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
);
describe('when metric definition is Microsoft.Sql/servers/databases', () => {
it('should build the getMetricNamespaces url in the longer format', () => {
const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl(
'',
'sub1',
'rg',
'Microsoft.Sql/servers/databases',
'rn1/rn2',
'2017-05-01-preview'
);
expect(url).toBe(
'/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn1/databases/rn2/' +
'providers/microsoft.insights/metricNamespaces?api-version=2017-05-01-preview'
);
});
});
});
describe('when metric definition is Microsoft.Sql/servers and the metricNamespace is default', () => {
it('should build the getMetricNames url in the shorter format', () => {
const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
'',
'sub1',
'rg',
'Microsoft.Sql/servers',
'rn',
'default',
'2017-05-01-preview'
);
expect(url).toBe(
'/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn/' +
'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
);
describe('when metric definition is Microsoft.Sql/servers', () => {
it('should build the getMetricNamespaces url in the shorter format', () => {
const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl(
'',
'sub1',
'rg',
'Microsoft.Sql/servers',
'rn',
'2017-05-01-preview'
);
expect(url).toBe(
'/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn/' +
'providers/microsoft.insights/metricNamespaces?api-version=2017-05-01-preview'
);
});
});
});
describe('when metric definition is Microsoft.Storage/storageAccounts/blobServices and the metricNamespace is default', () => {
it('should build the getMetricNames url in the longer format', () => {
const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
'',
'sub1',
'rg',
'Microsoft.Storage/storageAccounts/blobServices',
'rn1/default',
'default',
'2017-05-01-preview'
);
expect(url).toBe(
'/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/' +
'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
);
describe('when metric definition is Microsoft.NetApp/netAppAccounts/capacityPools/volumes and the metricNamespace is default', () => {
it('should build the getMetricNames url in the even longer format', () => {
const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
'',
'sub1',
'rg',
'Microsoft.NetApp/netAppAccounts/capacityPools/volumes',
'rn1/rn2/rn3',
'default',
'2017-05-01-preview'
);
expect(url).toBe(
'/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/' +
'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
);
});
});
});
describe('when metric definition is Microsoft.Storage/storageAccounts/fileServices and the metricNamespace is default', () => {
it('should build the getMetricNames url in the longer format', () => {
const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
'',
'sub1',
'rg',
'Microsoft.Storage/storageAccounts/fileServices',
'rn1/default',
'default',
'2017-05-01-preview'
);
expect(url).toBe(
'/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/' +
'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
);
describe('when metric definition is Microsoft.Sql/servers/databases and the metricNamespace is default', () => {
it('should build the getMetricNames url in the longer format', () => {
const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
'',
'sub1',
'rg',
'Microsoft.Sql/servers/databases',
'rn1/rn2',
'default',
'2017-05-01-preview'
);
expect(url).toBe(
'/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn1/databases/rn2/' +
'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
);
});
});
});
describe('when metric definition is Microsoft.Storage/storageAccounts/tableServices and the metricNamespace is default', () => {
it('should build the getMetricNames url in the longer format', () => {
const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
'',
'sub1',
'rg',
'Microsoft.Storage/storageAccounts/tableServices',
'rn1/default',
'default',
'2017-05-01-preview'
);
expect(url).toBe(
'/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/tableServices/default/' +
'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
);
describe('when metric definition is Microsoft.Sql/servers and the metricNamespace is default', () => {
it('should build the getMetricNames url in the shorter format', () => {
const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
'',
'sub1',
'rg',
'Microsoft.Sql/servers',
'rn',
'default',
'2017-05-01-preview'
);
expect(url).toBe(
'/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn/' +
'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
);
});
});
});
describe('when metric definition is Microsoft.Storage/storageAccounts/queueServices and the metricNamespace is default', () => {
it('should build the getMetricNames url in the longer format', () => {
const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
'',
'sub1',
'rg',
'Microsoft.Storage/storageAccounts/queueServices',
'rn1/default',
'default',
'2017-05-01-preview'
);
expect(url).toBe(
'/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/queueServices/default/' +
'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
);
describe('when metric definition is Microsoft.Storage/storageAccounts/blobServices and the metricNamespace is default', () => {
it('should build the getMetricNames url in the longer format', () => {
const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
'',
'sub1',
'rg',
'Microsoft.Storage/storageAccounts/blobServices',
'rn1/default',
'default',
'2017-05-01-preview'
);
expect(url).toBe(
'/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/' +
'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
);
});
});
describe('when metric definition is Microsoft.Storage/storageAccounts/fileServices and the metricNamespace is default', () => {
it('should build the getMetricNames url in the longer format', () => {
const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
'',
'sub1',
'rg',
'Microsoft.Storage/storageAccounts/fileServices',
'rn1/default',
'default',
'2017-05-01-preview'
);
expect(url).toBe(
'/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/' +
'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
);
});
});
describe('when metric definition is Microsoft.Storage/storageAccounts/tableServices and the metricNamespace is default', () => {
it('should build the getMetricNames url in the longer format', () => {
const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
'',
'sub1',
'rg',
'Microsoft.Storage/storageAccounts/tableServices',
'rn1/default',
'default',
'2017-05-01-preview'
);
expect(url).toBe(
'/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/tableServices/default/' +
'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
);
});
});
describe('when metric definition is Microsoft.Storage/storageAccounts/queueServices and the metricNamespace is default', () => {
it('should build the getMetricNames url in the longer format', () => {
const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
'',
'sub1',
'rg',
'Microsoft.Storage/storageAccounts/queueServices',
'rn1/default',
'default',
'2017-05-01-preview'
);
expect(url).toBe(
'/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/queueServices/default/' +
'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
);
});
});
});
});

View File

@ -10,7 +10,7 @@ export default class UrlBuilder {
const metricDefinitionArray = metricDefinition.split('/');
const resourceNameArray = resourceName.split('/');
const provider = metricDefinitionArray.shift();
const urlArray = [baseUrl, subscriptionId, 'resourceGroups', resourceGroup, 'providers', provider];
const urlArray = [baseUrl, 'subscriptions', subscriptionId, 'resourceGroups', resourceGroup, 'providers', provider];
for (const i in metricDefinitionArray) {
urlArray.push(metricDefinitionArray[i]);
urlArray.push(resourceNameArray[i]);
@ -31,7 +31,7 @@ export default class UrlBuilder {
const metricDefinitionArray = metricDefinition.split('/');
const resourceNameArray = resourceName.split('/');
const provider = metricDefinitionArray.shift();
const urlArray = [baseUrl, subscriptionId, 'resourceGroups', resourceGroup, 'providers', provider];
const urlArray = [baseUrl, 'subscriptions', subscriptionId, 'resourceGroups', resourceGroup, 'providers', provider];
for (const i in metricDefinitionArray) {
urlArray.push(metricDefinitionArray[i]);
urlArray.push(resourceNameArray[i]);
@ -42,4 +42,20 @@ export default class UrlBuilder {
`&metricnamespace=${encodeURIComponent(metricNamespace)}`
);
}
static newBuildAzureMonitorGetMetricNamespacesUrl(baseUrl: string, resourceUri: string, apiVersion: string) {
return `${baseUrl}${resourceUri}/providers/microsoft.insights/metricNamespaces?api-version=${apiVersion}`;
}
static newBuildAzureMonitorGetMetricNamesUrl(
baseUrl: string,
resourceUri: string,
metricNamespace: string,
apiVersion: string
) {
return (
`${baseUrl}${resourceUri}/providers/microsoft.insights/metricdefinitions?api-version=${apiVersion}` +
`&metricnamespace=${encodeURIComponent(metricNamespace)}`
);
}
}

View File

@ -2,10 +2,12 @@ import React from 'react';
import { AzureMonitorErrorish, AzureMonitorOption, AzureMonitorQuery } from '../../types';
import Datasource from '../../datasource';
import { Alert, InlineFieldRow } from '@grafana/ui';
import { ResourceRowType } from '../ResourcePicker/types';
import ResourceField from '../ResourceField';
import QueryField from './QueryField';
import FormatAsField from './FormatAsField';
import ResourceField from './ResourceField';
import useMigrations from './useMigrations';
import { setResource } from './setQueryValue';
interface LogsQueryEditorProps {
query: AzureMonitorQuery;
@ -38,6 +40,14 @@ const LogsQueryEditor: React.FC<LogsQueryEditorProps> = ({
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
setError={setError}
selectableEntryTypes={[
ResourceRowType.Subscription,
ResourceRowType.ResourceGroup,
ResourceRowType.Resource,
ResourceRowType.Variable,
]}
setResource={setResource}
resourceUri={query.azureLogAnalytics?.resource}
/>
</InlineFieldRow>

View File

@ -6,7 +6,6 @@ import { AzureMetricQuery, AzureMonitorOption, AzureMonitorQuery, AzureQueryType
import {
DataHook,
updateSubscriptions,
useAsyncState,
useMetricNames,
useMetricNamespaces,
useResourceGroups,
@ -15,75 +14,12 @@ import {
useSubscriptions,
} from './dataHooks';
interface WaitableMock extends jest.Mock<any, any> {
waitToBeCalled(): Promise<unknown>;
}
const WAIT_OPTIONS = {
timeout: 1000,
};
function createWaitableMock() {
let resolve: Function;
const mock = jest.fn() as WaitableMock;
mock.mockImplementation(() => {
resolve && resolve();
});
mock.waitToBeCalled = () => {
return new Promise((_resolve) => (resolve = _resolve));
};
return mock;
}
const opt = (text: string, value: string) => ({ text, value });
describe('AzureMonitor: useAsyncState', () => {
const MOCKED_RANDOM_VALUE = 0.42069;
beforeEach(() => {
jest.spyOn(global.Math, 'random').mockReturnValue(MOCKED_RANDOM_VALUE);
});
afterEach(() => {
jest.spyOn(global.Math, 'random').mockRestore();
});
it('should return data from an async function', async () => {
const apiCall = () => Promise.resolve(['a', 'b', 'c']);
const setError = jest.fn();
const { result, waitForNextUpdate } = renderHook(() => useAsyncState(apiCall, setError, []));
await waitForNextUpdate();
expect(result.current).toEqual(['a', 'b', 'c']);
});
it('should report errors through setError', async () => {
const error = new Error();
const apiCall = () => Promise.reject(error);
const setError = createWaitableMock();
const { result, waitForNextUpdate } = renderHook(() => useAsyncState(apiCall, setError, []));
await Promise.race([waitForNextUpdate(), setError.waitToBeCalled()]);
expect(result.current).toEqual([]);
expect(setError).toHaveBeenCalledWith(MOCKED_RANDOM_VALUE, error);
});
it('should clear the error once the request is successful', async () => {
const apiCall = () => Promise.resolve(['a', 'b', 'c']);
const setError = createWaitableMock();
const { waitForNextUpdate } = renderHook(() => useAsyncState(apiCall, setError, []));
await Promise.race([waitForNextUpdate(), setError.waitToBeCalled()]);
expect(setError).toHaveBeenCalledWith(MOCKED_RANDOM_VALUE, undefined);
});
});
interface TestScenario {
name: string;
hook: DataHook;

View File

@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import Datasource from '../../datasource';
import { AzureMonitorErrorish, AzureMonitorOption, AzureMonitorQuery } from '../../types';
import { hasOption, toOption } from '../../utils/common';
import { useAsyncState } from '../../utils/useAsyncState';
import { setMetricNamespace, setSubscriptionID } from './setQueryValue';
export interface MetricMetadata {
@ -26,29 +27,6 @@ export type DataHook = (
setError: SetErrorFn
) => AzureMonitorOption[];
export function useAsyncState<T>(asyncFn: () => Promise<T>, setError: Function, dependencies: unknown[]) {
// Use the lazy initial state functionality of useState to assign a random ID to the API call
// to track where errors come from. See useLastError.
const [errorSource] = useState(() => Math.random());
const [value, setValue] = useState<T>();
const finalValue = useMemo(() => value ?? [], [value]);
useEffect(() => {
asyncFn()
.then((results) => {
setValue(results);
setError(errorSource, undefined);
})
.catch((err) => {
setError(errorSource, err);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies);
return finalValue;
}
export const updateSubscriptions = (
query: AzureMonitorQuery,
subscriptionOptions: AzureMonitorOption[],

View File

@ -1,5 +1,20 @@
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 setSubscriptionID(query: AzureMonitorQuery, subscriptionID: string): AzureMonitorQuery {
if (query.subscription === subscriptionID) {
return query;

View File

@ -0,0 +1,226 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { selectOptionInTest } from '@grafana/ui';
import MetricsQueryEditor from './MetricsQueryEditor';
import createMockQuery from '../../__mocks__/query';
import createMockDatasource from '../../__mocks__/datasource';
import createMockResourcePickerData from '../../__mocks__/resourcePickerData';
import {
createMockResourceGroupsBySubscription,
createMockSubscriptions,
mockResourcesByResourceGroup,
} from '../../__mocks__/resourcePickerRows';
const variableOptionGroup = {
label: 'Template variables',
options: [],
};
const resourcePickerData = createMockResourcePickerData({
getSubscriptions: jest.fn().mockResolvedValue(createMockSubscriptions()),
getResourceGroupsBySubscriptionId: jest.fn().mockResolvedValue(createMockResourceGroupsBySubscription()),
getResourcesForResourceGroup: jest.fn().mockResolvedValue(mockResourcesByResourceGroup()),
});
describe('MetricsQueryEditor', () => {
const originalScrollIntoView = window.HTMLElement.prototype.scrollIntoView;
beforeEach(() => {
window.HTMLElement.prototype.scrollIntoView = function () {};
});
afterEach(() => {
window.HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
});
it('should render', async () => {
const mockDatasource = createMockDatasource({ resourcePickerData });
render(
<MetricsQueryEditor
query={createMockQuery()}
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={() => {}}
setError={() => {}}
/>
);
expect(await screen.findByTestId('azure-monitor-metrics-query-editor-with-resource-picker')).toBeInTheDocument();
});
it('should change resource when a resource is selected in the ResourcePicker', async () => {
const mockDatasource = createMockDatasource({ resourcePickerData });
const query = createMockQuery();
delete query?.azureMonitor?.resourceUri;
const onChange = jest.fn();
render(
<MetricsQueryEditor
query={query}
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
setError={() => {}}
/>
);
const resourcePickerButton = await screen.findByRole('button', { name: 'Select a resource' });
expect(resourcePickerButton).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Expand Primary Subscription' })).not.toBeInTheDocument();
resourcePickerButton.click();
const subscriptionButton = await screen.findByRole('button', { name: 'Expand Primary Subscription' });
expect(subscriptionButton).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Expand A Great Resource Group' })).not.toBeInTheDocument();
subscriptionButton.click();
const resourceGroupButton = await screen.findByRole('button', { name: 'Expand A Great Resource Group' });
expect(resourceGroupButton).toBeInTheDocument();
expect(screen.queryByLabelText('web-server')).not.toBeInTheDocument();
resourceGroupButton.click();
const checkbox = await screen.findByLabelText('web-server');
expect(checkbox).toBeInTheDocument();
expect(checkbox).not.toBeChecked();
userEvent.click(checkbox);
expect(checkbox).toBeChecked();
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/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 });
const query = createMockQuery();
const onChange = jest.fn();
render(
<MetricsQueryEditor
query={query}
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
setError={() => {}}
/>
);
const resourcePickerButton = await screen.findByRole('button', { name: /grafanastaging/ });
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();
userEvent.click(checkbox);
expect(checkbox).toBeChecked();
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: [],
}),
})
);
});
it('should change the metric name when selected', async () => {
const mockDatasource = createMockDatasource({ resourcePickerData });
const onChange = jest.fn();
const mockQuery = createMockQuery();
mockDatasource.azureMonitorDatasource.newGetMetricNames = jest.fn().mockResolvedValue([
{
value: 'metric-a',
text: 'Metric A',
},
{
value: 'metric-b',
text: 'Metric B',
},
]);
render(
<MetricsQueryEditor
query={createMockQuery()}
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
setError={() => {}}
/>
);
const metrics = await screen.findByLabelText('Metric');
expect(metrics).toBeInTheDocument();
await selectOptionInTest(metrics, 'Metric B');
expect(onChange).toHaveBeenLastCalledWith({
...mockQuery,
azureMonitor: {
...mockQuery.azureMonitor,
metricName: 'metric-b',
aggregation: undefined,
timeGrain: '',
},
});
});
it('should change the aggregation type when selected', async () => {
const mockDatasource = createMockDatasource({ resourcePickerData });
const onChange = jest.fn();
const mockQuery = createMockQuery();
render(
<MetricsQueryEditor
query={createMockQuery()}
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
setError={() => {}}
/>
);
const aggregation = await screen.findByLabelText('Aggregation');
expect(aggregation).toBeInTheDocument();
await selectOptionInTest(aggregation, 'Maximum');
expect(onChange).toHaveBeenLastCalledWith({
...mockQuery,
azureMonitor: {
...mockQuery.azureMonitor,
aggregation: 'Maximum',
},
});
});
});

View File

@ -1,9 +1,113 @@
import React from 'react';
import { InlineFieldRow } from '@grafana/ui';
import AggregationField from '../MetricsQueryEditor/AggregationField';
import MetricNameField from '../MetricsQueryEditor/MetricNameField';
import MetricNamespaceField from '../MetricsQueryEditor/MetricNamespaceField';
import TimeGrainField from '../MetricsQueryEditor/TimeGrainField';
import DimensionFields from '../MetricsQueryEditor/DimensionFields';
import TopField from '../MetricsQueryEditor/TopField';
import LegendFormatField from '../MetricsQueryEditor/LegendFormatField';
import ResourceField from '../ResourceField';
import { ResourceRowType } from '../ResourcePicker/types';
import type Datasource from '../../datasource';
import type { AzureMonitorQuery, AzureMonitorOption, AzureMonitorErrorish } from '../../types';
import { useMetricNames, useMetricNamespaces, useMetricMetadata } from './dataHooks';
import { setResource } from '../MetricsQueryEditor/setQueryValue';
interface MetricsQueryEditorProps {}
interface MetricsQueryEditorProps {
query: AzureMonitorQuery;
datasource: Datasource;
onChange: (newQuery: AzureMonitorQuery) => void;
variableOptionGroup: { label: string; options: AzureMonitorOption[] };
setError: (source: string, error: AzureMonitorErrorish | undefined) => void;
}
const MetricsQueryEditor: React.FC<MetricsQueryEditorProps> = ({}) => {
return <div data-testid="azure-monitor-metrics-query-editor-with-resource-picker">New Query Editor</div>;
const MetricsQueryEditor: React.FC<MetricsQueryEditorProps> = ({
query,
datasource,
variableOptionGroup,
onChange,
setError,
}) => {
const metricsMetadata = useMetricMetadata(query, datasource, onChange);
const metricNamespaces = useMetricNamespaces(query, datasource, onChange, setError);
const metricNames = useMetricNames(query, datasource, onChange, setError);
return (
<div data-testid="azure-monitor-metrics-query-editor-with-resource-picker">
<InlineFieldRow>
<ResourceField
query={query}
datasource={datasource}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
setError={setError}
selectableEntryTypes={[ResourceRowType.Resource]}
setResource={setResource}
resourceUri={query.azureMonitor?.resourceUri}
/>
</InlineFieldRow>
<InlineFieldRow>
<MetricNamespaceField
metricNamespaces={metricNamespaces}
query={query}
datasource={datasource}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
setError={setError}
/>
<MetricNameField
metricNames={metricNames}
query={query}
datasource={datasource}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
setError={setError}
/>
</InlineFieldRow>
<InlineFieldRow>
<AggregationField
query={query}
datasource={datasource}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
setError={setError}
aggregationOptions={metricsMetadata?.aggOptions ?? []}
isLoading={metricsMetadata.isLoading}
/>
<TimeGrainField
query={query}
datasource={datasource}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
setError={setError}
timeGrainOptions={metricsMetadata?.timeGrains ?? []}
/>
</InlineFieldRow>
<DimensionFields
query={query}
datasource={datasource}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
setError={setError}
dimensionOptions={metricsMetadata?.dimensions ?? []}
/>
<TopField
query={query}
datasource={datasource}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
setError={setError}
/>
<LegendFormatField
query={query}
datasource={datasource}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
setError={setError}
/>
</div>
);
};
export default MetricsQueryEditor;

View File

@ -0,0 +1,165 @@
import { renderHook } from '@testing-library/react-hooks';
import createMockDatasource from '../../__mocks__/datasource';
import Datasource from '../../datasource';
import { AzureMetricQuery, AzureMonitorOption, AzureMonitorQuery, AzureQueryType } from '../../types';
import { DataHook } from '../MetricsQueryEditor/dataHooks';
import { useMetricNames, useMetricNamespaces } from './dataHooks';
const WAIT_OPTIONS = {
timeout: 1000,
};
const opt = (text: string, value: string) => ({ text, value });
interface TestScenario {
name: string;
hook: DataHook;
// For convenience, only need to define the azureMonitor part of the query for some tests
emptyQueryPartial: AzureMetricQuery;
customProperties: AzureMetricQuery;
topLevelCustomProperties?: Partial<AzureMonitorQuery>;
expectedCustomPropertyResults?: Array<AzureMonitorOption<string>>;
expectedOptions: AzureMonitorOption[];
}
describe('AzureMonitor: metrics dataHooks', () => {
const bareQuery = {
refId: 'A',
queryType: AzureQueryType.AzureMonitor,
subscription: 'sub-abc-123',
};
const testTable: TestScenario[] = [
{
name: 'useMetricNames',
hook: useMetricNames,
emptyQueryPartial: {
resourceUri:
'/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
metricNamespace: 'azure/vm',
},
customProperties: {
resourceUri:
'/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
metricNamespace: 'azure/vm',
metricName: 'metric-$ENVIRONMENT',
},
expectedOptions: [
{
label: 'Percentage CPU',
value: 'percentage-cpu',
},
{
label: 'Free memory',
value: 'free-memory',
},
],
expectedCustomPropertyResults: [
{ label: 'Percentage CPU', value: 'percentage-cpu' },
{ label: 'Free memory', value: 'free-memory' },
{ label: 'metric-$ENVIRONMENT', value: 'metric-$ENVIRONMENT' },
],
},
{
name: 'useMetricNamespaces',
hook: useMetricNamespaces,
emptyQueryPartial: {
resourceUri:
'/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
metricNamespace: 'azure/vm',
},
customProperties: {
resourceUri:
'/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
metricNamespace: 'azure/vm-$ENVIRONMENT',
metricName: 'metric-name',
},
expectedOptions: [
{
label: 'Compute Virtual Machine',
value: 'azure/vmc',
},
{
label: 'Database NS',
value: 'azure/dbns',
},
{
label: 'azure/vm',
value: 'azure/vm',
},
],
expectedCustomPropertyResults: [
{ label: 'Compute Virtual Machine', value: 'azure/vmc' },
{ label: 'Database NS', value: 'azure/dbns' },
{ label: 'azure/vm-$ENVIRONMENT', value: 'azure/vm-$ENVIRONMENT' },
],
},
];
let datasource: Datasource;
let onChange: jest.Mock<any, any>;
let setError: jest.Mock<any, any>;
beforeEach(() => {
onChange = jest.fn();
setError = jest.fn();
datasource = createMockDatasource();
datasource.getVariables = jest.fn().mockReturnValue(['$sub', '$rg', '$rt', '$variable']);
datasource.azureMonitorDatasource.getSubscriptions = jest
.fn()
.mockResolvedValue([opt('sub-abc-123', 'sub-abc-123')]);
datasource.getResourceGroups = jest
.fn()
.mockResolvedValue([
opt('Web App - Production', 'web-app-production'),
opt('Web App - Development', 'web-app-development'),
]);
datasource.getMetricDefinitions = jest
.fn()
.mockResolvedValue([opt('Virtual Machine', 'azure/vm'), opt('Database', 'azure/db')]);
datasource.getResourceNames = jest
.fn()
.mockResolvedValue([opt('Web server', 'web-server'), opt('Job server', 'job-server')]);
datasource.azureMonitorDatasource.newGetMetricNames = jest
.fn()
.mockResolvedValue([opt('Percentage CPU', 'percentage-cpu'), opt('Free memory', 'free-memory')]);
datasource.azureMonitorDatasource.newGetMetricNamespaces = jest
.fn()
.mockResolvedValue([opt('Compute Virtual Machine', 'azure/vmc'), opt('Database NS', 'azure/dbns')]);
});
describe.each(testTable)('scenario %#: $name', (scenario) => {
it('returns values', async () => {
const query = {
...bareQuery,
azureMonitor: scenario.emptyQueryPartial,
};
const { result, waitForNextUpdate } = renderHook(() => scenario.hook(query, datasource, onChange, setError));
await waitForNextUpdate(WAIT_OPTIONS);
expect(result.current).toEqual(scenario.expectedOptions);
});
it('adds custom properties as a valid option', async () => {
const query = {
...bareQuery,
azureMonitor: scenario.customProperties,
...scenario.topLevelCustomProperties,
};
const { result, waitForNextUpdate } = renderHook(() => scenario.hook(query, datasource, onChange, setError));
await waitForNextUpdate(WAIT_OPTIONS);
expect(result.current).toEqual(scenario.expectedCustomPropertyResults);
});
});
});

View File

@ -0,0 +1,144 @@
import { useEffect, useState } from 'react';
import Datasource from '../../datasource';
import { AzureMonitorOption, AzureMonitorQuery } from '../../types';
import { toOption } from '../../utils/common';
import { useAsyncState } from '../../utils/useAsyncState';
import { DataHook } from '../MetricsQueryEditor/dataHooks';
import { setMetricNamespace } from '../MetricsQueryEditor/setQueryValue';
export interface MetricMetadata {
aggOptions: AzureMonitorOption[];
timeGrains: AzureMonitorOption[];
dimensions: AzureMonitorOption[];
isLoading: boolean;
// These two properties are only used within the hook, and not elsewhere
supportedAggTypes: string[];
primaryAggType: string | undefined;
}
type OnChangeFn = (newQuery: AzureMonitorQuery) => void;
export const useMetricNamespaces: DataHook = (query, datasource, onChange, setError) => {
const { metricNamespace, resourceUri } = query.azureMonitor ?? {};
const metricNamespaces = useAsyncState(
async () => {
if (!resourceUri) {
return;
}
const results = await datasource.azureMonitorDatasource.newGetMetricNamespaces(resourceUri);
const options = formatOptions(results, metricNamespace);
// Do some cleanup of the query state if need be
if (!metricNamespace && options.length) {
onChange(setMetricNamespace(query, options[0].value));
}
return options;
},
setError,
[resourceUri]
);
return metricNamespaces;
};
export const useMetricNames: DataHook = (query, datasource, onChange, setError) => {
const { metricNamespace, metricName, resourceUri } = query.azureMonitor ?? {};
return useAsyncState(
async () => {
if (!(metricNamespace && resourceUri)) {
return;
}
const results = await datasource.azureMonitorDatasource.newGetMetricNames(resourceUri, metricNamespace);
const options = formatOptions(results, metricName);
return options;
},
setError,
[resourceUri, metricNamespace]
);
};
const defaultMetricMetadata: MetricMetadata = {
aggOptions: [],
timeGrains: [],
dimensions: [],
isLoading: false,
supportedAggTypes: [],
primaryAggType: undefined,
};
export const useMetricMetadata = (query: AzureMonitorQuery, datasource: Datasource, onChange: OnChangeFn) => {
const [metricMetadata, setMetricMetadata] = useState<MetricMetadata>(defaultMetricMetadata);
const { resourceUri, metricNamespace, metricName, aggregation, timeGrain } = query.azureMonitor ?? {};
// Fetch new metric metadata when the fields change
useEffect(() => {
if (!(resourceUri && metricNamespace && metricName)) {
setMetricMetadata(defaultMetricMetadata);
return;
}
datasource.azureMonitorDatasource
.newGetMetricMetadata(resourceUri, metricNamespace, metricName)
.then((metadata) => {
// TODO: Move the aggregationTypes and timeGrain defaults into `getMetricMetadata`
const aggregations = (metadata.supportedAggTypes || [metadata.primaryAggType]).map((v) => ({
label: v,
value: v,
}));
setMetricMetadata({
aggOptions: aggregations,
timeGrains: metadata.supportedTimeGrains,
dimensions: metadata.dimensions,
isLoading: false,
supportedAggTypes: metadata.supportedAggTypes ?? [],
primaryAggType: metadata.primaryAggType,
});
});
}, [datasource, resourceUri, metricNamespace, metricName]);
// Update the query state in response to the meta data changing
useEffect(() => {
const newAggregation = aggregation || metricMetadata.primaryAggType;
const newTimeGrain = timeGrain || 'auto';
if (newAggregation !== aggregation || newTimeGrain !== timeGrain) {
onChange({
...query,
azureMonitor: {
...query.azureMonitor,
aggregation: newAggregation,
timeGrain: newTimeGrain,
},
});
}
}, [onChange, metricMetadata, aggregation, timeGrain, query]);
return metricMetadata;
};
function formatOptions(
rawResults: Array<{
text: string;
value: string;
}>,
selectedValue?: string
) {
const options = rawResults.map(toOption);
// account for custom values that might have been set in json file like ones crafted with a template variable (ex: "cloud-datasource-resource-$Environment")
if (selectedValue && !options.find((option) => option.value === selectedValue)) {
options.push({ label: selectedValue, value: selectedValue });
}
return options;
}

View File

@ -99,7 +99,15 @@ const EditorForQueryType: React.FC<EditorForQueryTypeProps> = ({
switch (query.queryType) {
case AzureQueryType.AzureMonitor:
if (config.featureToggles.azureMonitorResourcePickerForMetrics) {
return <NewMetricsQueryEditor />;
return (
<NewMetricsQueryEditor
query={query}
datasource={datasource}
onChange={onChange}
variableOptionGroup={variableOptionGroup}
setError={setError}
/>
);
}
return (
<MetricsQueryEditor

View File

@ -4,13 +4,12 @@ import { Button, Icon, Modal, useStyles2 } from '@grafana/ui';
import React, { useCallback, useEffect, useState } from 'react';
import Datasource from '../../datasource';
import { AzureQueryEditorFieldProps, AzureResourceSummaryItem } from '../../types';
import { AzureQueryEditorFieldProps, AzureMonitorQuery, AzureResourceSummaryItem } from '../../types';
import { Field } from '../Field';
import ResourcePicker from '../ResourcePicker';
import { ResourceRowType } from '../ResourcePicker/types';
import { parseResourceURI } from '../ResourcePicker/utils';
import { Space } from '../Space';
import { setResource } from './setQueryValue';
function parseResourceDetails(resourceURI: string) {
const parsed = parseResourceURI(resourceURI);
@ -26,9 +25,21 @@ function parseResourceDetails(resourceURI: string) {
};
}
const ResourceField: React.FC<AzureQueryEditorFieldProps> = ({ query, datasource, onQueryChange }) => {
interface ResourceFieldProps extends AzureQueryEditorFieldProps {
setResource: (query: AzureMonitorQuery, resourceURI?: string) => AzureMonitorQuery;
selectableEntryTypes: ResourceRowType[];
resourceUri?: string;
}
const ResourceField: React.FC<ResourceFieldProps> = ({
query,
datasource,
onQueryChange,
setResource,
selectableEntryTypes,
resourceUri,
}) => {
const styles = useStyles2(getStyles);
const { resource } = query.azureLogAnalytics ?? {};
const [pickerIsOpen, setPickerIsOpen] = useState(false);
const handleOpenPicker = useCallback(() => {
@ -44,7 +55,7 @@ const ResourceField: React.FC<AzureQueryEditorFieldProps> = ({ query, datasource
onQueryChange(setResource(query, resourceURI));
closePicker();
},
[closePicker, onQueryChange, query]
[closePicker, onQueryChange, query, setResource]
);
return (
@ -60,21 +71,16 @@ const ResourceField: React.FC<AzureQueryEditorFieldProps> = ({ query, datasource
>
<ResourcePicker
resourcePickerData={datasource.resourcePickerData}
resourceURI={resource}
resourceURI={resourceUri}
onApply={handleApply}
onCancel={closePicker}
selectableEntryTypes={[
ResourceRowType.Subscription,
ResourceRowType.ResourceGroup,
ResourceRowType.Resource,
ResourceRowType.Variable,
]}
selectableEntryTypes={selectableEntryTypes}
/>
</Modal>
<Field label="Resource">
<Button variant="secondary" onClick={handleOpenPicker} type="button">
<ResourceLabel resource={resource} datasource={datasource} />
<ResourceLabel resource={resourceUri} datasource={datasource} />
</Button>
</Field>
</>

View File

@ -0,0 +1 @@
export { default } from './ResourceField';

View File

@ -36,6 +36,7 @@ export interface AzureMonitorQuery extends DeprecatedAzureMonitorQuery {
* Azure Monitor Metrics sub-query properties
*/
export interface AzureMetricQuery {
resourceUri?: string;
resourceGroup?: string;
/** Resource type */

View File

@ -95,6 +95,23 @@ export interface AzureMonitorMetricMetadataItem {
metricAvailabilities?: AzureMonitorMetricAvailabilityMetadata[];
}
export interface AzureMonitorMetricNamespacesResponse {
value: AzureMonitorMetricNamespaceItem[];
}
export interface AzureMonitorMetricNamespaceItem {
name: string;
properties: { metricNamespacename: string };
}
export interface AzureMonitorMetricNamesResponse {
value: AzureMonitorMetricNameItem[];
}
export interface AzureMonitorMetricNameItem {
name: { value: string; localizedValue: string };
}
export interface AzureMonitorMetricAvailabilityMetadata {
timeGrain: string;
retention: string;

View File

@ -0,0 +1,65 @@
import { renderHook } from '@testing-library/react-hooks';
import { useAsyncState } from './useAsyncState';
interface WaitableMock extends jest.Mock<any, any> {
waitToBeCalled(): Promise<unknown>;
}
function createWaitableMock() {
let resolve: Function;
const mock = jest.fn() as WaitableMock;
mock.mockImplementation(() => {
resolve && resolve();
});
mock.waitToBeCalled = () => {
return new Promise((_resolve) => (resolve = _resolve));
};
return mock;
}
describe('useAsyncState', () => {
const MOCKED_RANDOM_VALUE = 0.42069;
beforeEach(() => {
jest.spyOn(global.Math, 'random').mockReturnValue(MOCKED_RANDOM_VALUE);
});
afterEach(() => {
jest.spyOn(global.Math, 'random').mockRestore();
});
it('should return data from an async function', async () => {
const apiCall = () => Promise.resolve(['a', 'b', 'c']);
const setError = jest.fn();
const { result, waitForNextUpdate } = renderHook(() => useAsyncState(apiCall, setError, []));
await waitForNextUpdate();
expect(result.current).toEqual(['a', 'b', 'c']);
});
it('should report errors through setError', async () => {
const error = new Error();
const apiCall = () => Promise.reject(error);
const setError = createWaitableMock();
const { result, waitForNextUpdate } = renderHook(() => useAsyncState(apiCall, setError, []));
await Promise.race([waitForNextUpdate(), setError.waitToBeCalled()]);
expect(result.current).toEqual([]);
expect(setError).toHaveBeenCalledWith(MOCKED_RANDOM_VALUE, error);
});
it('should clear the error once the request is successful', async () => {
const apiCall = () => Promise.resolve(['a', 'b', 'c']);
const setError = createWaitableMock();
const { waitForNextUpdate } = renderHook(() => useAsyncState(apiCall, setError, []));
await Promise.race([waitForNextUpdate(), setError.waitToBeCalled()]);
expect(setError).toHaveBeenCalledWith(MOCKED_RANDOM_VALUE, undefined);
});
});

View File

@ -0,0 +1,24 @@
import { useEffect, useMemo, useState } from 'react';
export function useAsyncState<T>(asyncFn: () => Promise<T>, setError: Function, dependencies: unknown[]) {
// Use the lazy initial state functionality of useState to assign a random ID to the API call
// to track where errors come from. See useLastError.
const [errorSource] = useState(() => Math.random());
const [value, setValue] = useState<T>();
const finalValue = useMemo(() => value ?? [], [value]);
useEffect(() => {
asyncFn()
.then((results) => {
setValue(results);
setError(errorSource, undefined);
})
.catch((err) => {
setError(errorSource, err);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies);
return finalValue;
}