Azure Monitor: Add support for multiple template variables in resource picker (#46215)

This commit is contained in:
Sarah Zinger 2022-03-25 14:12:52 -04:00 committed by GitHub
parent e6726681a9
commit d5f40b63bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 90 additions and 71 deletions

View File

@ -27,9 +27,11 @@ describe('AzureLogAnalyticsDatasource', () => {
const ctx: any = {};
beforeEach(() => {
templateSrv.init([singleVariable]);
ctx.instanceSettings = {
jsonData: { subscriptionId: 'xxx' },
url: 'http://azureloganalyticsapi',
templateSrv: templateSrv,
};
ctx.ds = new AzureMonitorDatasource(ctx.instanceSettings);
@ -76,10 +78,11 @@ describe('AzureLogAnalyticsDatasource', () => {
describe('When performing getSchema', () => {
beforeEach(() => {
ctx.ds.azureLogAnalyticsDatasource.getResource = jest.fn().mockImplementation((path: string) => {
ctx.mockGetResource = jest.fn().mockImplementation((path: string) => {
expect(path).toContain('metadata');
return Promise.resolve(FakeSchemaData.getlogAnalyticsFakeMetadata());
});
ctx.ds.azureLogAnalyticsDatasource.getResource = ctx.mockGetResource;
});
it('should return a schema to use with monaco-kusto', async () => {
@ -112,6 +115,11 @@ describe('AzureLogAnalyticsDatasource', () => {
},
]);
});
it('should interpolate variables when making a request for a schema with a uri that contains template variables', async () => {
await ctx.ds.azureLogAnalyticsDatasource.getKustoSchema('myWorkspace/$var1');
expect(ctx.mockGetResource).lastCalledWith('loganalytics/v1myWorkspace/var1-foo/metadata');
});
});
describe('When performing annotationQuery', () => {

View File

@ -109,8 +109,10 @@ export default class AzureLogAnalyticsDatasource extends DataSourceWithBackend<
}
async getKustoSchema(resourceUri: string) {
const metadata = await this.getMetadata(resourceUri);
return transformMetadataToKustoSchema(metadata, resourceUri);
const templateSrv = getTemplateSrv();
const interpolatedUri = templateSrv.replace(resourceUri, {}, interpolateVariable);
const metadata = await this.getMetadata(interpolatedUri);
return transformMetadataToKustoSchema(metadata, interpolatedUri);
}
applyTemplateVariables(target: AzureMonitorQuery, scopedVars: ScopedVars): AzureMonitorQuery {

View File

@ -47,8 +47,6 @@ const ResourceField: React.FC<AzureQueryEditorFieldProps> = ({ query, datasource
[closePicker, onQueryChange, query]
);
const templateVariables = datasource.getVariables();
return (
<>
<Modal
@ -63,7 +61,6 @@ const ResourceField: React.FC<AzureQueryEditorFieldProps> = ({ query, datasource
<ResourcePicker
resourcePickerData={datasource.resourcePickerData}
resourceURI={resource}
templateVariables={templateVariables}
onApply={handleApply}
onCancel={closePicker}
selectableEntryTypes={[

View File

@ -1,4 +1,5 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import ResourcePicker from '.';
@ -87,21 +88,25 @@ describe('AzureMonitor ResourcePicker', () => {
expect(onApply).toBeCalledTimes(1);
expect(onApply).toBeCalledWith('/subscriptions/def-123');
});
it('should call onApply with a template variable when a user selects it', async () => {
it('should call onApply with a new subscription uri when a user types it', async () => {
const onApply = jest.fn();
render(<ResourcePicker {...defaultProps} templateVariables={['$workspace']} onApply={onApply} />);
render(<ResourcePicker {...defaultProps} onApply={onApply} />);
const subscriptionCheckbox = await screen.findByLabelText('Primary Subscription');
expect(subscriptionCheckbox).toBeInTheDocument();
expect(subscriptionCheckbox).not.toBeChecked();
const expandButton = await screen.findByLabelText('Expand Template variables');
expandButton.click();
const advancedSection = screen.getByText('Advanced');
advancedSection.click();
const workSpaceCheckbox = await screen.findByLabelText('$workspace');
workSpaceCheckbox.click();
const advancedInput = await screen.findByLabelText('Resource URI');
userEvent.type(advancedInput, '/subscriptions/def-123');
const applyButton = screen.getByRole('button', { name: 'Apply' });
applyButton.click();
expect(onApply).toBeCalledTimes(1);
expect(onApply).toBeCalledWith('$workspace');
expect(onApply).toBeCalledWith('/subscriptions/def-123');
});
describe('when rendering resource picker without any selectable entry types', () => {

View File

@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, Button, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
import { Alert, Button, Icon, Input, LoadingPlaceholder, Tooltip, useStyles2, Collapse, Label } from '@grafana/ui';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import ResourcePickerData from '../../resourcePicker/resourcePickerData';
@ -10,11 +10,9 @@ import NestedResourceTable from './NestedResourceTable';
import { ResourceRow, ResourceRowGroup, ResourceRowType } from './types';
import { addResources, findRow, parseResourceURI } from './utils';
const TEMPLATE_VARIABLE_GROUP_ID = '$$grafana-templateVariables$$';
interface ResourcePickerProps {
resourcePickerData: ResourcePickerData;
resourceURI: string | undefined;
templateVariables: string[];
selectableEntryTypes: ResourceRowType[];
onApply: (resourceURI: string | undefined) => void;
@ -24,7 +22,6 @@ interface ResourcePickerProps {
const ResourcePicker = ({
resourcePickerData,
resourceURI,
templateVariables,
onApply,
onCancel,
selectableEntryTypes,
@ -36,7 +33,7 @@ const ResourcePicker = ({
const [azureRows, setAzureRows] = useState<ResourceRowGroup>([]);
const [internalSelectedURI, setInternalSelectedURI] = useState<string | undefined>(resourceURI);
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(resourceURI?.includes('$'));
// Sync the resourceURI prop to internal state
useEffect(() => {
setInternalSelectedURI(resourceURI);
@ -85,14 +82,10 @@ const ResourcePicker = ({
}
}, [resourcePickerData, internalSelectedURI, azureRows, loadingStatus]);
const rows = useMemo(() => {
const templateVariableRow = transformVariablesToRow(templateVariables);
return templateVariables.length ? [...azureRows, templateVariableRow] : azureRows;
}, [azureRows, templateVariables]);
// Map the selected item into an array of rows
const selectedResourceRows = useMemo(() => {
const found = internalSelectedURI && findRow(rows, internalSelectedURI);
const found = internalSelectedURI && findRow(azureRows, internalSelectedURI);
return found
? [
{
@ -101,7 +94,7 @@ const ResourcePicker = ({
},
]
: [];
}, [internalSelectedURI, rows]);
}, [internalSelectedURI, azureRows]);
// Request resources for a expanded resource group
const requestNestedRows = useCallback(
@ -109,12 +102,8 @@ const ResourcePicker = ({
// clear error message (also when loading cached resources)
setErrorMessage(undefined);
// If we already have children, we don't need to re-fetch them. Also abort if we're expanding the special
// template variable group, though that shouldn't happen in practice
if (
resourceGroupOrSubscription.children?.length ||
resourceGroupOrSubscription.uri === TEMPLATE_VARIABLE_GROUP_ID
) {
// If we already have children, we don't need to re-fetch them.
if (resourceGroupOrSubscription.children?.length) {
return;
}
@ -152,7 +141,7 @@ const ResourcePicker = ({
) : (
<>
<NestedResourceTable
rows={rows}
rows={azureRows}
requestNestedRows={requestNestedRows}
onRowSelectedChange={handleSelectionChanged}
selectedRows={selectedResourceRows}
@ -162,7 +151,6 @@ const ResourcePicker = ({
<div className={styles.selectionFooter}>
{selectedResourceRows.length > 0 && (
<>
<Space v={2} />
<h5>Selection</h5>
<NestedResourceTable
rows={selectedResourceRows}
@ -172,15 +160,54 @@ const ResourcePicker = ({
noHeader={true}
selectableEntryTypes={selectableEntryTypes}
/>
<Space v={2} />
</>
)}
<Collapse
collapsible
label="Advanced"
isOpen={isAdvancedOpen}
onToggle={() => setIsAdvancedOpen(!isAdvancedOpen)}
>
<Label htmlFor={`input-${internalSelectedURI}`}>
<h6>
Resource URI{' '}
<Tooltip
content={
<>
Manually edit the{' '}
<a
href="https://docs.microsoft.com/en-us/azure/azure-monitor/logs/log-standard-columns#_resourceid"
rel="noopener noreferrer"
target="_blank"
>
resource uri.{' '}
</a>
Supports the use of multiple template variables (ex: /subscriptions/$subId/resourceGroups/$rg)
</>
}
placement="right"
interactive={true}
>
<Icon name="info-circle" />
</Tooltip>
</h6>
</Label>
<Input
id={`input-${internalSelectedURI}`}
value={internalSelectedURI}
onChange={(event) => setInternalSelectedURI(event.currentTarget.value)}
placeholder="ex: /subscriptions/$subId"
/>
</Collapse>
<Space v={2} />
<Button disabled={!!errorMessage} onClick={handleApply}>
Apply
</Button>
<Space layout="inline" h={1} />
<Button onClick={onCancel} variant="secondary">
Cancel
</Button>
@ -215,20 +242,3 @@ const getStyles = (theme: GrafanaTheme2) => ({
color: theme.colors.text.secondary,
}),
});
function transformVariablesToRow(templateVariables: string[]): ResourceRow {
return {
id: TEMPLATE_VARIABLE_GROUP_ID,
uri: TEMPLATE_VARIABLE_GROUP_ID,
name: 'Template variables',
type: ResourceRowType.VariableGroup,
typeLabel: 'Variables',
children: templateVariables.map((v) => ({
id: v,
uri: v,
name: v,
type: ResourceRowType.Variable,
typeLabel: 'Variable',
})),
};
}

View File

@ -48,9 +48,12 @@ export function addResources(rows: ResourceRowGroup, targetParentId: string, new
return produce(rows, (draftState) => {
const draftRow = findRow(draftState, targetParentId);
// we can't find the selected resource in our list of resources,
// probably means user has either mistyped in the input field
// or is using template variables.
// either way no need to throw, just show that none of the resources are checked
if (!draftRow) {
// This case shouldn't happen often because we're usually coming here from a resource we already have
throw new Error('Unable to find resource');
return;
}
draftRow.children = newResources;

View File

@ -92,7 +92,7 @@ describe('AzureMonitor resourcePickerData', () => {
await resourcePickerData.getSubscriptions();
throw Error('expected getSubscriptions to fail but it succeeded');
} catch (err) {
expect(err.message).toEqual('unable to fetch subscriptions');
expect(err.message).toEqual('No subscriptions were found');
}
});
});
@ -163,17 +163,6 @@ describe('AzureMonitor resourcePickerData', () => {
});
});
it('throws an error if it does not receive data', async () => {
const mockResponse = { data: [] };
const { resourcePickerData } = createResourcePickerData([mockResponse]);
try {
await resourcePickerData.getResourceGroupsBySubscriptionId('123');
throw Error('expected getSubscriptions to fail but it succeeded');
} catch (err) {
expect(err.message).toEqual('unable to fetch resource groups');
}
});
it('throws an error if it recieves data with a malformed uri', async () => {
const mockResponse = {
data: [

View File

@ -57,7 +57,7 @@ export default class ResourcePickerData extends DataSourceWithBackend<AzureMonit
}
const resourceResponse = await this.makeResourceGraphRequest<RawAzureSubscriptionItem[]>(query, 1, options);
if (!resourceResponse.data.length) {
throw new Error('unable to fetch subscriptions');
throw new Error('No subscriptions were found');
}
resources = resources.concat(resourceResponse.data);
$skipToken = resourceResponse.$skipToken;
@ -100,9 +100,6 @@ export default class ResourcePickerData extends DataSourceWithBackend<AzureMonit
};
}
const resourceResponse = await this.makeResourceGraphRequest<RawAzureResourceGroupItem[]>(query, 1, options);
if (!resourceResponse.data.length) {
throw new Error('unable to fetch resource groups');
}
resourceGroups = resourceGroups.concat(resourceResponse.data);
$skipToken = resourceResponse.$skipToken;
allFetched = !$skipToken;
@ -150,7 +147,7 @@ export default class ResourcePickerData extends DataSourceWithBackend<AzureMonit
// used to make the select resource button that launches the resource picker show a nicer file path to users
async getResourceURIDisplayProperties(resourceURI: string): Promise<AzureResourceSummaryItem> {
const { subscriptionID, resourceGroup } = parseResourceURI(resourceURI) ?? {};
const { subscriptionID, resourceGroup, resource } = parseResourceURI(resourceURI) ?? {};
if (!subscriptionID) {
throw new Error('Invalid resource URI passed');
@ -189,7 +186,15 @@ export default class ResourcePickerData extends DataSourceWithBackend<AzureMonit
throw new Error('unable to fetch resource details');
}
return response[0];
const { subscriptionName, resourceGroupName, resourceName } = response[0];
// if the name is undefined it could be because the id is undefined or because we are using a template variable.
// Either way we can use it as a fallback. We don't really want to interpolate these variables because we want
// to show the user when they are using template variables `$sub/$rg/$resource`
return {
subscriptionName: subscriptionName || subscriptionID,
resourceGroupName: resourceGroupName || resourceGroup,
resourceName: resourceName || resource,
};
}
async getResourceURIFromWorkspace(workspace: string) {