AzureMonitor: Adapt ResourcePicker and Advanced components to multiple resources (#61605)

This commit is contained in:
Andres Martinez Gotor 2023-01-17 14:11:11 +01:00 committed by GitHub
parent 004705a10b
commit 1b86a49622
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 429 additions and 161 deletions

View File

@ -63,8 +63,9 @@ const ResourceField: React.FC<ResourceFieldProps<string | AzureMetricResource>>
>
<ResourcePicker
resourcePickerData={datasource.resourcePickerData}
resource={resource}
onApply={handleApply}
// TODO: This should be a list of resources, not a single resource
resources={[resource]}
onApply={(resources) => resources && handleApply(resources[0])}
onCancel={closePicker}
selectableEntryTypes={selectableEntryTypes}
queryType={queryType}

View File

@ -7,29 +7,38 @@ import Advanced from './Advanced';
describe('AzureMonitor ResourcePicker', () => {
it('should set a parameter as an object', async () => {
const onChange = jest.fn();
const { rerender } = render(<Advanced onChange={onChange} resource={{}} />);
const { rerender } = render(<Advanced onChange={onChange} resources={[{}]} />);
const advancedSection = screen.getByText('Advanced');
advancedSection.click();
const subsInput = await screen.findByLabelText('Subscription');
await userEvent.type(subsInput, 'd');
expect(onChange).toHaveBeenCalledWith({ subscription: 'd' });
expect(onChange).toHaveBeenCalledWith([{ subscription: 'd' }]);
rerender(<Advanced onChange={onChange} resource={{ subscription: 'def-123' }} />);
rerender(<Advanced onChange={onChange} resources={[{ subscription: 'def-123' }]} />);
expect(screen.getByLabelText('Subscription').outerHTML).toMatch('value="def-123"');
});
it('should set a parameter as uri', async () => {
const onChange = jest.fn();
const { rerender } = render(<Advanced onChange={onChange} resource={''} />);
const { rerender } = render(<Advanced onChange={onChange} resources={['']} />);
const advancedSection = screen.getByText('Advanced');
advancedSection.click();
const subsInput = await screen.findByLabelText('Resource URI');
await userEvent.type(subsInput, '/');
expect(onChange).toHaveBeenCalledWith('/');
expect(onChange).toHaveBeenCalledWith(['/']);
rerender(<Advanced onChange={onChange} resource={'/subscriptions/sub'} />);
rerender(<Advanced onChange={onChange} resources={['/subscriptions/sub']} />);
expect(screen.getByLabelText('Resource URI').outerHTML).toMatch('value="/subscriptions/sub"');
});
it('should render multiple resources', async () => {
render(<Advanced onChange={jest.fn()} resources={['/subscriptions/sub1', '/subscriptions/sub2']} />);
const advancedSection = screen.getByText('Advanced');
advancedSection.click();
expect(screen.getByDisplayValue('/subscriptions/sub1')).toBeInTheDocument();
expect(screen.getByDisplayValue('/subscriptions/sub2')).toBeInTheDocument();
});
});

View File

@ -7,12 +7,18 @@ import { AzureMetricResource } from '../../types';
import { Space } from '../Space';
interface ResourcePickerProps<T> {
resource: T;
onChange: (resource: T) => void;
resources: T[];
onChange: (resources: T[]) => void;
}
const Advanced = ({ resource, onChange }: ResourcePickerProps<string | AzureMetricResource>) => {
const [isAdvancedOpen, setIsAdvancedOpen] = useState(!!resource && JSON.stringify(resource).includes('$'));
const Advanced = ({ resources, onChange }: ResourcePickerProps<string | AzureMetricResource>) => {
const [isAdvancedOpen, setIsAdvancedOpen] = useState(!!resources.length && JSON.stringify(resources).includes('$'));
const onResourceChange = (resource: string | AzureMetricResource, index: number) => {
const newResources = [...resources];
newResources[index] = resource;
onChange(newResources);
};
return (
<div data-testid={selectors.components.queryEditor.resourcePicker.advanced.collapse}>
@ -22,104 +28,115 @@ const Advanced = ({ resource, onChange }: ResourcePickerProps<string | AzureMetr
isOpen={isAdvancedOpen}
onToggle={() => setIsAdvancedOpen(!isAdvancedOpen)}
>
{typeof resource === 'string' ? (
<>
{' '}
<Label htmlFor="input-advanced-resource-picker">
<h6>
Resource URI{' '}
<Tooltip
content={
<>
Manually edit the{' '}
<a
href="https://docs.microsoft.com/en-us/azure/azure-monitor/logs/log-standard-columns#_resourceid"
rel="noopener noreferrer"
target="_blank"
>
resource uri.{' '}
</a>
Supports the use of multiple template variables (ex: /subscriptions/$subId/resourceGroups/$rg)
</>
}
placement="right"
interactive={true}
{resources.map((resource, index) => (
<div key={`resource-${index + 1}`}>
{typeof resource === 'string' ? (
<>
<Label htmlFor={`input-advanced-resource-picker-${index + 1}`}>
<h6>
Resource URI{' '}
<Tooltip
content={
<>
Manually edit the{' '}
<a
href="https://docs.microsoft.com/en-us/azure/azure-monitor/logs/log-standard-columns#_resourceid"
rel="noopener noreferrer"
target="_blank"
>
resource uri.{' '}
</a>
Supports the use of multiple template variables (ex: /subscriptions/$subId/resourceGroups/$rg)
</>
}
placement="right"
interactive={true}
>
<Icon name="info-circle" />
</Tooltip>
</h6>
</Label>
<Input
id={`input-advanced-resource-picker-${index + 1}`}
value={resource}
onChange={(event) => onResourceChange(event.currentTarget.value, index)}
placeholder="ex: /subscriptions/$subId"
/>
</>
) : (
<>
<InlineField
label="Subscription"
grow
transparent
htmlFor={`input-advanced-resource-picker-subscription-${index + 1}`}
labelWidth={15}
data-testid={selectors.components.queryEditor.resourcePicker.advanced.subscription.input}
>
<Icon name="info-circle" />
</Tooltip>
</h6>
</Label>
<Input
id="input-advanced-resource-picker"
value={resource}
onChange={(event) => onChange(event.currentTarget.value)}
placeholder="ex: /subscriptions/$subId"
/>
</>
) : (
<>
<InlineField
label="Subscription"
grow
transparent
htmlFor="input-advanced-resource-picker-subscription"
labelWidth={15}
data-testid={selectors.components.queryEditor.resourcePicker.advanced.subscription.input}
>
<Input
id="input-advanced-resource-picker-subscription"
value={resource?.subscription ?? ''}
onChange={(event) => onChange({ ...resource, subscription: event.currentTarget.value })}
placeholder="aaaaaaaa-bbbb-cccc-dddd-eeeeeeee"
/>
</InlineField>
<InlineField
label="Resource Group"
grow
transparent
htmlFor="input-advanced-resource-picker-resourceGroup"
labelWidth={15}
data-testid={selectors.components.queryEditor.resourcePicker.advanced.resourceGroup.input}
>
<Input
id="input-advanced-resource-picker-resourceGroup"
value={resource?.resourceGroup ?? ''}
onChange={(event) => onChange({ ...resource, resourceGroup: event.currentTarget.value })}
placeholder="resource-group"
/>
</InlineField>
<InlineField
label="Namespace"
grow
transparent
htmlFor="input-advanced-resource-picker-metricNamespace"
labelWidth={15}
data-testid={selectors.components.queryEditor.resourcePicker.advanced.namespace.input}
>
<Input
id="input-advanced-resource-picker-metricNamespace"
value={resource?.metricNamespace ?? ''}
onChange={(event) => onChange({ ...resource, metricNamespace: event.currentTarget.value })}
placeholder="Microsoft.Insights/metricNamespaces"
/>
</InlineField>
<InlineField
label="Resource Name"
grow
transparent
htmlFor="input-advanced-resource-picker-resourceName"
labelWidth={15}
data-testid={selectors.components.queryEditor.resourcePicker.advanced.resource.input}
>
<Input
id="input-advanced-resource-picker-resourceName"
value={resource?.resourceName ?? ''}
onChange={(event) => onChange({ ...resource, resourceName: event.currentTarget.value })}
placeholder="name"
/>
</InlineField>
</>
)}
<Input
id={`input-advanced-resource-picker-subscription-${index + 1}`}
value={resource?.subscription ?? ''}
onChange={(event) =>
onResourceChange({ ...resource, subscription: event.currentTarget.value }, index)
}
placeholder="aaaaaaaa-bbbb-cccc-dddd-eeeeeeee"
/>
</InlineField>
<InlineField
label="Resource Group"
grow
transparent
htmlFor={`input-advanced-resource-picker-resourceGroup-${index + 1}`}
labelWidth={15}
data-testid={selectors.components.queryEditor.resourcePicker.advanced.resourceGroup.input}
>
<Input
id={`input-advanced-resource-picker-resourceGroup-${index + 1}`}
value={resource?.resourceGroup ?? ''}
onChange={(event) =>
onResourceChange({ ...resource, resourceGroup: event.currentTarget.value }, index)
}
placeholder="resource-group"
/>
</InlineField>
<InlineField
label="Namespace"
grow
transparent
htmlFor={`input-advanced-resource-picker-metricNamespace-${index + 1}`}
labelWidth={15}
data-testid={selectors.components.queryEditor.resourcePicker.advanced.namespace.input}
>
<Input
id={`input-advanced-resource-picker-metricNamespace-${index + 1}`}
value={resource?.metricNamespace ?? ''}
onChange={(event) =>
onResourceChange({ ...resource, metricNamespace: event.currentTarget.value }, index)
}
placeholder="Microsoft.Insights/metricNamespaces"
/>
</InlineField>
<InlineField
label="Resource Name"
grow
transparent
htmlFor={`input-advanced-resource-picker-resourceName-${index + 1}`}
labelWidth={15}
data-testid={selectors.components.queryEditor.resourcePicker.advanced.resource.input}
>
<Input
id={`input-advanced-resource-picker-resourceName-${index + 1}`}
value={resource?.resourceName ?? ''}
onChange={(event) =>
onResourceChange({ ...resource, resourceName: event.currentTarget.value }, index)
}
placeholder="name"
/>
</InlineField>
</>
)}
</div>
))}
<Space v={2} />
</Collapse>
</div>

View File

@ -17,6 +17,15 @@ import { ResourceRowType } from './types';
import ResourcePicker from '.';
jest.mock('@grafana/runtime', () => ({
...(jest.requireActual('@grafana/runtime') as unknown as object),
getTemplateSrv: () => ({
replace: (val: string) => {
return val;
},
}),
}));
const noResourceURI = '';
const singleSubscriptionSelectionURI = '/subscriptions/def-456';
const singleResourceGroupSelectionURI = '/subscriptions/def-456/resourceGroups/dev-3';
@ -50,7 +59,7 @@ const queryType: ResourcePickerQueryType = 'logs';
const defaultProps = {
templateVariables: [],
resource: noResourceURI,
resources: [noResourceURI],
resourcePickerData: createMockResourcePickerData(),
onCancel: noop,
onApply: noop,
@ -68,7 +77,7 @@ describe('AzureMonitor ResourcePicker', () => {
window.HTMLElement.prototype.scrollIntoView = jest.fn();
});
it('should pre-load subscriptions when there is no existing selection', async () => {
render(<ResourcePicker {...defaultProps} resource={noResourceURI} />);
render(<ResourcePicker {...defaultProps} resources={[noResourceURI]} />);
const subscriptionCheckbox = await screen.findByLabelText('Primary Subscription');
expect(subscriptionCheckbox).toBeInTheDocument();
expect(subscriptionCheckbox).not.toBeChecked();
@ -77,7 +86,7 @@ describe('AzureMonitor ResourcePicker', () => {
});
it('should show a subscription as selected if there is one saved', async () => {
render(<ResourcePicker {...defaultProps} resource={singleSubscriptionSelectionURI} />);
render(<ResourcePicker {...defaultProps} resources={[singleSubscriptionSelectionURI]} />);
const subscriptionCheckboxes = await screen.findAllByLabelText('Dev Subscription');
expect(subscriptionCheckboxes.length).toBe(2);
expect(subscriptionCheckboxes[0]).toBeChecked();
@ -85,7 +94,7 @@ describe('AzureMonitor ResourcePicker', () => {
});
it('should show a resourceGroup as selected if there is one saved', async () => {
render(<ResourcePicker {...defaultProps} resource={singleResourceGroupSelectionURI} />);
render(<ResourcePicker {...defaultProps} resources={[singleResourceGroupSelectionURI]} />);
const resourceGroupCheckboxes = await screen.findAllByLabelText('A Great Resource Group');
expect(resourceGroupCheckboxes.length).toBe(2);
expect(resourceGroupCheckboxes[0]).toBeChecked();
@ -93,7 +102,7 @@ describe('AzureMonitor ResourcePicker', () => {
});
it('should show scroll down to a resource and mark it as selected if there is one saved', async () => {
render(<ResourcePicker {...defaultProps} resource={singleResourceSelectionURI} />);
render(<ResourcePicker {...defaultProps} resources={[singleResourceSelectionURI]} />);
const resourceCheckboxes = await screen.findAllByLabelText('db-server');
expect(resourceCheckboxes.length).toBe(2);
expect(resourceCheckboxes[0]).toBeChecked();
@ -101,7 +110,7 @@ describe('AzureMonitor ResourcePicker', () => {
});
it('opens the selected nested resources', async () => {
render(<ResourcePicker {...defaultProps} resource={singleResourceSelectionURI} />);
render(<ResourcePicker {...defaultProps} resources={[singleResourceSelectionURI]} />);
const collapseSubscriptionBtn = await screen.findByLabelText('Collapse Dev Subscription');
expect(collapseSubscriptionBtn).toBeInTheDocument();
const collapseResourceGroupBtn = await screen.findByLabelText('Collapse A Great Resource Group');
@ -109,7 +118,7 @@ describe('AzureMonitor ResourcePicker', () => {
});
it('scrolls down to the selected resource', async () => {
render(<ResourcePicker {...defaultProps} resource={singleResourceSelectionURI} />);
render(<ResourcePicker {...defaultProps} resources={[singleResourceSelectionURI]} />);
await screen.findByLabelText('Collapse A Great Resource Group');
expect(window.HTMLElement.prototype.scrollIntoView).toBeCalledTimes(1);
});
@ -133,12 +142,40 @@ describe('AzureMonitor ResourcePicker', () => {
const applyButton = screen.getByRole('button', { name: 'Apply' });
applyButton.click();
expect(onApply).toBeCalledTimes(1);
expect(onApply).toBeCalledWith('/subscriptions/def-123');
expect(onApply).toBeCalledWith(['/subscriptions/def-123']);
});
it('should call onApply removing an element', async () => {
const onApply = jest.fn();
render(<ResourcePicker {...defaultProps} resources={['/subscriptions/def-123']} onApply={onApply} />);
const subscriptionCheckbox = await screen.findAllByLabelText('Primary Subscription');
expect(subscriptionCheckbox).toHaveLength(2);
expect(subscriptionCheckbox.at(0)).toBeChecked();
subscriptionCheckbox.at(0)?.click();
const applyButton = screen.getByRole('button', { name: 'Apply' });
applyButton.click();
expect(onApply).toBeCalledTimes(1);
expect(onApply).toBeCalledWith([]);
});
it('should call onApply removing an element ignoring the case', async () => {
const onApply = jest.fn();
render(
<ResourcePicker {...defaultProps} resources={['/subscriptions/def-456/resourceGroups/DEV-3']} onApply={onApply} />
);
const subscriptionCheckbox = await screen.findAllByLabelText('A Great Resource Group');
expect(subscriptionCheckbox).toHaveLength(2);
expect(subscriptionCheckbox.at(0)).toBeChecked();
subscriptionCheckbox.at(0)?.click();
const applyButton = screen.getByRole('button', { name: 'Apply' });
applyButton.click();
expect(onApply).toBeCalledTimes(1);
expect(onApply).toBeCalledWith([]);
});
it('should call onApply with a new subscription when a user clicks on the checkbox in the row', async () => {
const onApply = jest.fn();
render(<ResourcePicker {...defaultProps} onApply={onApply} resource={{}} />);
render(<ResourcePicker {...defaultProps} onApply={onApply} resources={[]} />);
const subscriptionCheckbox = await screen.findByLabelText('Primary Subscription');
expect(subscriptionCheckbox).toBeInTheDocument();
expect(subscriptionCheckbox).not.toBeChecked();
@ -146,7 +183,20 @@ describe('AzureMonitor ResourcePicker', () => {
const applyButton = screen.getByRole('button', { name: 'Apply' });
applyButton.click();
expect(onApply).toBeCalledTimes(1);
expect(onApply).toBeCalledWith({ subscription: 'def-123' });
expect(onApply).toBeCalledWith([{ subscription: 'def-123' }]);
});
it('should call onApply removing a resource element', async () => {
const onApply = jest.fn();
render(<ResourcePicker {...defaultProps} onApply={onApply} resources={[{ subscription: 'def-123' }]} />);
const subscriptionCheckbox = await screen.findAllByLabelText('Primary Subscription');
expect(subscriptionCheckbox).toHaveLength(2);
expect(subscriptionCheckbox.at(0)).toBeChecked();
subscriptionCheckbox.at(0)?.click();
const applyButton = screen.getByRole('button', { name: 'Apply' });
applyButton.click();
expect(onApply).toBeCalledTimes(1);
expect(onApply).toBeCalledWith([]);
});
it('should call onApply with a new subscription uri when a user types it in the selection box', async () => {
@ -166,12 +216,12 @@ describe('AzureMonitor ResourcePicker', () => {
applyButton.click();
expect(onApply).toBeCalledTimes(1);
expect(onApply).toBeCalledWith('/subscriptions/def-123');
expect(onApply).toBeCalledWith(['/subscriptions/def-123']);
});
it('should call onApply with a new subscription when a user types it in the selection box', async () => {
const onApply = jest.fn();
render(<ResourcePicker {...defaultProps} onApply={onApply} resource={{}} />);
render(<ResourcePicker {...defaultProps} onApply={onApply} resources={[{}]} />);
const subscriptionCheckbox = await screen.findByLabelText('Primary Subscription');
expect(subscriptionCheckbox).toBeInTheDocument();
expect(subscriptionCheckbox).not.toBeChecked();
@ -186,11 +236,11 @@ describe('AzureMonitor ResourcePicker', () => {
applyButton.click();
expect(onApply).toBeCalledTimes(1);
expect(onApply).toBeCalledWith({ subscription: 'def-123' });
expect(onApply).toBeCalledWith([{ subscription: 'def-123' }]);
});
it('should show unselect a subscription if the value is manually edited', async () => {
render(<ResourcePicker {...defaultProps} resource={{ subscription: 'def-456' }} />);
render(<ResourcePicker {...defaultProps} resources={[{ subscription: 'def-456' }]} />);
const subscriptionCheckboxes = await screen.findAllByLabelText('Dev Subscription');
expect(subscriptionCheckboxes.length).toBe(2);
expect(subscriptionCheckboxes[0]).toBeChecked();
@ -264,7 +314,7 @@ describe('AzureMonitor ResourcePicker', () => {
});
it('resets result when the user clears their search', async () => {
render(<ResourcePicker {...defaultProps} resource={noResourceURI} />);
render(<ResourcePicker {...defaultProps} resources={[noResourceURI]} />);
const subscriptionCheckboxBeforeSearch = await screen.findByLabelText('Primary Subscription');
expect(subscriptionCheckboxBeforeSearch).toBeInTheDocument();
@ -295,7 +345,7 @@ describe('AzureMonitor ResourcePicker', () => {
{...defaultProps}
queryType={'metrics'}
resourcePickerData={resourcePickerData}
resource={noResourceURI}
resources={[noResourceURI]}
/>
);
const subscriptionExpand = await screen.findByLabelText('Expand Primary Subscription');

View File

@ -15,21 +15,21 @@ import NestedRow from './NestedRow';
import Search from './Search';
import getStyles from './styles';
import { ResourceRow, ResourceRowGroup, ResourceRowType } from './types';
import { findRow, parseResourceDetails, resourceToString } from './utils';
import { findRows, parseMultipleResourceDetails, resourcesToStrings, matchURI, resourceToString } from './utils';
interface ResourcePickerProps<T> {
resourcePickerData: ResourcePickerData;
resource: T;
resources: T[];
selectableEntryTypes: ResourceRowType[];
queryType: ResourcePickerQueryType;
onApply: (resource?: T) => void;
onApply: (resources: T[]) => void;
onCancel: () => void;
}
const ResourcePicker = ({
resourcePickerData,
resource,
resources,
onApply,
onCancel,
selectableEntryTypes,
@ -40,14 +40,14 @@ const ResourcePicker = ({
const [isLoading, setIsLoading] = useState(false);
const [rows, setRows] = useState<ResourceRowGroup>([]);
const [selectedRows, setSelectedRows] = useState<ResourceRowGroup>([]);
const [internalSelected, setInternalSelected] = useState(resource);
const [internalSelected, setInternalSelected] = useState(resources);
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const [shouldShowLimitFlag, setShouldShowLimitFlag] = useState(false);
// Sync the resourceURI prop to internal state
useEffect(() => {
setInternalSelected(resource);
}, [resource]);
setInternalSelected(resources);
}, [resources]);
const loadInitialData = useCallback(async () => {
if (!isLoading) {
@ -55,7 +55,7 @@ const ResourcePicker = ({
setIsLoading(true);
const resources = await resourcePickerData.fetchInitialRows(
queryType,
parseResourceDetails(internalSelected ?? {})
parseMultipleResourceDetails(internalSelected ?? {})
);
setRows(resources);
} catch (error) {
@ -75,14 +75,9 @@ const ResourcePicker = ({
setSelectedRows([]);
}
const found = internalSelected && findRow(rows, resourceToString(internalSelected));
if (found) {
return setSelectedRows([
{
...found,
children: undefined,
},
]);
const found = internalSelected && findRows(rows, resourcesToStrings(internalSelected));
if (found && found.length) {
return setSelectedRows(found);
}
return setSelectedRows([]);
}, [internalSelected, rows]);
@ -109,19 +104,29 @@ const ResourcePicker = ({
[resourcePickerData, rows, queryType]
);
const resourceIsString = typeof resource === 'string';
const resourceIsString = resources?.length && typeof resources[0] === 'string';
const handleSelectionChanged = useCallback(
(row: ResourceRow, isSelected: boolean) => {
isSelected
? setInternalSelected(resourceIsString ? row.uri : parseResourceDetails(row.uri, row.location))
: setInternalSelected(resourceIsString ? '' : {});
if (isSelected) {
const newRes = resourceIsString ? row.uri : parseMultipleResourceDetails([row.uri], row.location)[0];
const newSelected = (internalSelected ? internalSelected.concat(newRes) : [newRes]).filter((r) => {
// avoid setting empty resources
return typeof r === 'string' ? r !== '' : r.subscription;
});
setInternalSelected(newSelected);
} else {
const newInternalSelected = internalSelected?.filter((r) => {
return !matchURI(resourceToString(r), row.uri);
});
setInternalSelected(newInternalSelected);
}
},
[resourceIsString]
[resourceIsString, internalSelected, setInternalSelected]
);
const handleApply = useCallback(() => {
if (internalSelected) {
onApply(resourceIsString ? internalSelected : parseResourceDetails(internalSelected));
onApply(resourceIsString ? internalSelected : parseMultipleResourceDetails(internalSelected));
}
}, [resourceIsString, internalSelected, onApply]);
@ -230,7 +235,7 @@ const ResourcePicker = ({
</>
)}
<Advanced resource={internalSelected} onChange={(r) => setInternalSelected(r)} />
<Advanced resources={internalSelected} onChange={(r) => setInternalSelected(r)} />
<Space v={2} />
<Button

View File

@ -1,7 +1,24 @@
import createMockQuery from '../../__mocks__/query';
import { ResourceRowGroup, ResourceRowType } from './types';
import { findRow, parseResourceURI, setResource } from './utils';
import {
findRow,
findRows,
parseMultipleResourceDetails,
parseResourceDetails,
parseResourceURI,
resourcesToStrings,
setResource,
} from './utils';
jest.mock('@grafana/runtime', () => ({
...(jest.requireActual('@grafana/runtime') as unknown as object),
getTemplateSrv: () => ({
replace: (val: string) => {
return val;
},
}),
}));
describe('AzureMonitor ResourcePicker utils', () => {
describe('parseResourceURI', () => {
@ -163,6 +180,17 @@ describe('AzureMonitor ResourcePicker utils', () => {
});
});
describe('findRows', () => {
it('should find multiple rows', () => {
const rows: ResourceRowGroup = [
{ id: 'sub1', uri: '/subscriptions/sub1', name: '', type: ResourceRowType.Subscription, typeLabel: '' },
{ id: 'sub2', uri: '/subscriptions/sub2', name: '', type: ResourceRowType.Subscription, typeLabel: '' },
{ id: 'sub3', uri: '/subscriptions/sub3', name: '', type: ResourceRowType.Subscription, typeLabel: '' },
];
expect(findRows(rows, ['/subscriptions/sub1', '/subscriptions/sub2'])).toEqual([rows[0], rows[1]]);
});
});
describe('setResource', () => {
it('updates a resource with a resource URI for Log Analytics', () => {
expect(setResource(createMockQuery(), '/subscription/sub')).toMatchObject({
@ -196,4 +224,68 @@ describe('AzureMonitor ResourcePicker utils', () => {
});
});
});
describe('parseResourceDetails', () => {
it('parses a string resource', () => {
expect(
parseResourceDetails(
'/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/resourceGroups/cloud-datasources/providers/Microsoft.Sql/servers/foo/databases/bar',
'useast'
)
).toEqual({
metricNamespace: 'Microsoft.Sql/servers/databases',
resourceGroup: 'cloud-datasources',
resourceName: 'foo/bar',
subscription: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
region: 'useast',
});
});
});
describe('parseMultipleResourceDetails', () => {
it('parses multiple string resources', () => {
expect(
parseMultipleResourceDetails(
[
'/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/resourceGroups/cloud-datasources/providers/Microsoft.Sql/servers/foo/databases/bar',
'/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/resourceGroups/cloud-datasources/providers/Microsoft.Sql/servers/other/databases/resource',
],
'useast'
)
).toEqual([
{
metricNamespace: 'Microsoft.Sql/servers/databases',
resourceGroup: 'cloud-datasources',
resourceName: 'foo/bar',
subscription: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
region: 'useast',
},
{
metricNamespace: 'Microsoft.Sql/servers/databases',
resourceGroup: 'cloud-datasources',
resourceName: 'other/resource',
subscription: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
region: 'useast',
},
]);
});
});
describe('resourcesToStrings', () => {
it('converts a resource to a string', () => {
expect(
resourcesToStrings([
{
metricNamespace: 'Microsoft.Sql/servers/databases',
resourceGroup: 'cloud-datasources',
resourceName: 'foo/bar',
subscription: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
region: 'useast',
},
])
).toEqual([
'/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/resourceGroups/cloud-datasources/providers/Microsoft.Sql/servers/foo/databases/bar',
]);
});
});
});

View File

@ -43,6 +43,12 @@ export function parseResourceURI(resourceURI: string): AzureMetricResource {
return { subscription, resourceGroup, metricNamespace, resourceName };
}
export function parseMultipleResourceDetails(resources: Array<string | AzureMetricResource>, location?: string) {
return resources.map((resource) => {
return parseResourceDetails(resource, location);
});
}
export function parseResourceDetails(resource: string | AzureMetricResource, location?: string) {
if (typeof resource === 'string') {
const res = parseResourceURI(resource);
@ -54,6 +60,10 @@ export function parseResourceDetails(resource: string | AzureMetricResource, loc
return resource;
}
export function resourcesToStrings(resources: Array<string | AzureMetricResource>) {
return resources.map((resource) => resourceToString(resource));
}
export function resourceToString(resource?: string | AzureMetricResource) {
return resource
? typeof resource === 'string'
@ -82,7 +92,7 @@ function compareNamespaceAndName(
return rowNamespace === resourceNamespace && rowName === resourceName;
}
function matchURI(rowURI: string, resourceURI: string) {
export function matchURI(rowURI: string, resourceURI: string) {
const targetParams = parseResourceDetails(resourceURI);
const rowParams = parseResourceDetails(rowURI);
@ -98,6 +108,17 @@ function matchURI(rowURI: string, resourceURI: string) {
);
}
export function findRows(rows: ResourceRowGroup, uris: string[]): ResourceRow[] {
const result: ResourceRow[] = [];
uris.forEach((uri) => {
const row = findRow(rows, uri);
if (row) {
result.push(row);
}
});
return result;
}
export function findRow(rows: ResourceRowGroup, uri: string): ResourceRow | undefined {
for (const row of rows) {
if (matchURI(row.uri, uri)) {

View File

@ -10,6 +10,15 @@ import { AzureGraphResponse } from '../types';
import ResourcePickerData from './resourcePickerData';
jest.mock('@grafana/runtime', () => ({
...(jest.requireActual('@grafana/runtime') as unknown as object),
getTemplateSrv: () => ({
replace: (val: string) => {
return val;
},
}),
}));
const createResourcePickerData = (responses: AzureGraphResponse[]) => {
const instanceSettings = createMockInstanceSetttings();
const mockDatasource = createMockDatasource();
@ -412,4 +421,52 @@ describe('AzureMonitor resourcePickerData', () => {
expect(locations.get('northeurope')?.supportsLogs).toBe(false);
});
});
describe('fetchInitialRows', () => {
it('returns a list of subscriptions', async () => {
const { resourcePickerData } = createResourcePickerData([createMockARGSubscriptionResponse()]);
const rows = await resourcePickerData.fetchInitialRows('logs');
expect(rows.length).toEqual(createMockARGSubscriptionResponse().data.length);
});
it('fetches resource groups and resources', async () => {
const { resourcePickerData } = createResourcePickerData([createMockARGSubscriptionResponse()]);
resourcePickerData.getResourceGroupsBySubscriptionId = jest
.fn()
.mockResolvedValue([{ id: 'rg1', uri: '/subscriptions/1/resourceGroups/rg1' }]);
resourcePickerData.getResourcesForResourceGroup = jest.fn().mockResolvedValue([
{ id: 'vm1', uri: '/subscriptions/1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm1' },
{ id: 'vm2', uri: '/subscriptions/1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm2' },
]);
const rows = await resourcePickerData.fetchInitialRows('logs', [
{
subscription: '1',
resourceGroup: 'rg1',
resourceName: 'vm1',
metricNamespace: 'Microsoft.Compute/virtualMachines',
},
{
subscription: '1',
resourceGroup: 'rg1',
resourceName: 'vm2',
metricNamespace: 'Microsoft.Compute/virtualMachines',
},
]);
expect(rows[0]).toMatchObject({
id: '1',
children: [
{
id: 'rg1',
children: [{ id: 'vm1' }, { id: 'vm2' }],
},
],
});
// getResourceGroupsBySubscriptionId should only be called once because the subscription
// of both resources is the same
expect(resourcePickerData.getResourceGroupsBySubscriptionId).toBeCalledTimes(1);
// getResourcesForResourceGroup should only be called once because the resource group
// of both resources is the same
expect(resourcePickerData.getResourcesForResourceGroup).toBeCalledTimes(1);
});
});
});

View File

@ -6,7 +6,13 @@ import { DataSourceWithBackend } from '@grafana/runtime';
import { logsResourceTypes, resourceTypeDisplayNames } from '../azureMetadata';
import AzureMonitorDatasource from '../azure_monitor/azure_monitor_datasource';
import { ResourceRow, ResourceRowGroup, ResourceRowType } from '../components/ResourcePicker/types';
import { addResources, parseResourceDetails, parseResourceURI } from '../components/ResourcePicker/utils';
import {
addResources,
findRow,
parseResourceDetails,
parseResourceURI,
resourceToString,
} from '../components/ResourcePicker/utils';
import {
AzureDataSourceJsonData,
AzureGraphResponse,
@ -46,7 +52,7 @@ export default class ResourcePickerData extends DataSourceWithBackend<AzureMonit
async fetchInitialRows(
type: ResourcePickerQueryType,
currentSelection?: AzureMetricResource
currentSelection?: AzureMetricResource[]
): Promise<ResourceRowGroup> {
const subscriptions = await this.getSubscriptions();
@ -60,19 +66,29 @@ export default class ResourcePickerData extends DataSourceWithBackend<AzureMonit
}
let resources = subscriptions;
if (currentSelection.subscription) {
const resourceGroupURI = `/subscriptions/${currentSelection.subscription}/resourceGroups/${currentSelection.resourceGroup}`;
const promises = currentSelection.map((selection) => async () => {
if (selection.subscription) {
const resourceGroupURI = `/subscriptions/${selection.subscription}/resourceGroups/${selection.resourceGroup}`;
if (currentSelection.resourceGroup) {
const resourceGroups = await this.getResourceGroupsBySubscriptionId(currentSelection.subscription, type);
resources = addResources(resources, `/subscriptions/${currentSelection.subscription}`, resourceGroups);
}
if (selection.resourceGroup && !findRow(resources, resourceGroupURI)) {
const resourceGroups = await this.getResourceGroupsBySubscriptionId(selection.subscription, type);
resources = addResources(resources, `/subscriptions/${selection.subscription}`, resourceGroups);
}
if (currentSelection.resourceName) {
const resourcesForResourceGroup = await this.getResourcesForResourceGroup(resourceGroupURI, type);
resources = addResources(resources, resourceGroupURI, resourcesForResourceGroup);
const resourceURI = resourceToString(selection);
if (selection.resourceName && !findRow(resources, resourceURI)) {
const resourcesForResourceGroup = await this.getResourcesForResourceGroup(resourceGroupURI, type);
resources = addResources(resources, resourceGroupURI, resourcesForResourceGroup);
}
}
});
for (const promise of promises) {
// Fetch resources one by one, avoiding re-fetching the same resource
// and race conditions updating the resources array
await promise();
}
return resources;
}