mirror of
https://github.com/grafana/grafana.git
synced 2025-02-11 08:05:43 -06:00
Azure Monitor: Add support for multiple template variables in resource picker (#46215)
This commit is contained in:
parent
e6726681a9
commit
d5f40b63bb
@ -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', () => {
|
||||
|
@ -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 {
|
||||
|
@ -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={[
|
||||
|
@ -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', () => {
|
||||
|
@ -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',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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: [
|
||||
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user