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 <ResourcePicker
resourcePickerData={datasource.resourcePickerData} resourcePickerData={datasource.resourcePickerData}
resource={resource} // TODO: This should be a list of resources, not a single resource
onApply={handleApply} resources={[resource]}
onApply={(resources) => resources && handleApply(resources[0])}
onCancel={closePicker} onCancel={closePicker}
selectableEntryTypes={selectableEntryTypes} selectableEntryTypes={selectableEntryTypes}
queryType={queryType} queryType={queryType}

View File

@ -7,29 +7,38 @@ import Advanced from './Advanced';
describe('AzureMonitor ResourcePicker', () => { describe('AzureMonitor ResourcePicker', () => {
it('should set a parameter as an object', async () => { it('should set a parameter as an object', async () => {
const onChange = jest.fn(); const onChange = jest.fn();
const { rerender } = render(<Advanced onChange={onChange} resource={{}} />); const { rerender } = render(<Advanced onChange={onChange} resources={[{}]} />);
const advancedSection = screen.getByText('Advanced'); const advancedSection = screen.getByText('Advanced');
advancedSection.click(); advancedSection.click();
const subsInput = await screen.findByLabelText('Subscription'); const subsInput = await screen.findByLabelText('Subscription');
await userEvent.type(subsInput, 'd'); 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"'); expect(screen.getByLabelText('Subscription').outerHTML).toMatch('value="def-123"');
}); });
it('should set a parameter as uri', async () => { it('should set a parameter as uri', async () => {
const onChange = jest.fn(); const onChange = jest.fn();
const { rerender } = render(<Advanced onChange={onChange} resource={''} />); const { rerender } = render(<Advanced onChange={onChange} resources={['']} />);
const advancedSection = screen.getByText('Advanced'); const advancedSection = screen.getByText('Advanced');
advancedSection.click(); advancedSection.click();
const subsInput = await screen.findByLabelText('Resource URI'); const subsInput = await screen.findByLabelText('Resource URI');
await userEvent.type(subsInput, '/'); 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"'); 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'; import { Space } from '../Space';
interface ResourcePickerProps<T> { interface ResourcePickerProps<T> {
resource: T; resources: T[];
onChange: (resource: T) => void; onChange: (resources: T[]) => void;
} }
const Advanced = ({ resource, onChange }: ResourcePickerProps<string | AzureMetricResource>) => { const Advanced = ({ resources, onChange }: ResourcePickerProps<string | AzureMetricResource>) => {
const [isAdvancedOpen, setIsAdvancedOpen] = useState(!!resource && JSON.stringify(resource).includes('$')); 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 ( return (
<div data-testid={selectors.components.queryEditor.resourcePicker.advanced.collapse}> <div data-testid={selectors.components.queryEditor.resourcePicker.advanced.collapse}>
@ -22,104 +28,115 @@ const Advanced = ({ resource, onChange }: ResourcePickerProps<string | AzureMetr
isOpen={isAdvancedOpen} isOpen={isAdvancedOpen}
onToggle={() => setIsAdvancedOpen(!isAdvancedOpen)} onToggle={() => setIsAdvancedOpen(!isAdvancedOpen)}
> >
{typeof resource === 'string' ? ( {resources.map((resource, index) => (
<> <div key={`resource-${index + 1}`}>
{' '} {typeof resource === 'string' ? (
<Label htmlFor="input-advanced-resource-picker"> <>
<h6> <Label htmlFor={`input-advanced-resource-picker-${index + 1}`}>
Resource URI{' '} <h6>
<Tooltip Resource URI{' '}
content={ <Tooltip
<> content={
Manually edit the{' '} <>
<a Manually edit the{' '}
href="https://docs.microsoft.com/en-us/azure/azure-monitor/logs/log-standard-columns#_resourceid" <a
rel="noopener noreferrer" href="https://docs.microsoft.com/en-us/azure/azure-monitor/logs/log-standard-columns#_resourceid"
target="_blank" rel="noopener noreferrer"
> target="_blank"
resource uri.{' '} >
</a> resource uri.{' '}
Supports the use of multiple template variables (ex: /subscriptions/$subId/resourceGroups/$rg) </a>
</> Supports the use of multiple template variables (ex: /subscriptions/$subId/resourceGroups/$rg)
} </>
placement="right" }
interactive={true} 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" /> <Input
</Tooltip> id={`input-advanced-resource-picker-subscription-${index + 1}`}
</h6> value={resource?.subscription ?? ''}
</Label> onChange={(event) =>
<Input onResourceChange({ ...resource, subscription: event.currentTarget.value }, index)
id="input-advanced-resource-picker" }
value={resource} placeholder="aaaaaaaa-bbbb-cccc-dddd-eeeeeeee"
onChange={(event) => onChange(event.currentTarget.value)} />
placeholder="ex: /subscriptions/$subId" </InlineField>
/> <InlineField
</> label="Resource Group"
) : ( grow
<> transparent
<InlineField htmlFor={`input-advanced-resource-picker-resourceGroup-${index + 1}`}
label="Subscription" labelWidth={15}
grow data-testid={selectors.components.queryEditor.resourcePicker.advanced.resourceGroup.input}
transparent >
htmlFor="input-advanced-resource-picker-subscription" <Input
labelWidth={15} id={`input-advanced-resource-picker-resourceGroup-${index + 1}`}
data-testid={selectors.components.queryEditor.resourcePicker.advanced.subscription.input} value={resource?.resourceGroup ?? ''}
> onChange={(event) =>
<Input onResourceChange({ ...resource, resourceGroup: event.currentTarget.value }, index)
id="input-advanced-resource-picker-subscription" }
value={resource?.subscription ?? ''} placeholder="resource-group"
onChange={(event) => onChange({ ...resource, subscription: event.currentTarget.value })} />
placeholder="aaaaaaaa-bbbb-cccc-dddd-eeeeeeee" </InlineField>
/> <InlineField
</InlineField> label="Namespace"
<InlineField grow
label="Resource Group" transparent
grow htmlFor={`input-advanced-resource-picker-metricNamespace-${index + 1}`}
transparent labelWidth={15}
htmlFor="input-advanced-resource-picker-resourceGroup" data-testid={selectors.components.queryEditor.resourcePicker.advanced.namespace.input}
labelWidth={15} >
data-testid={selectors.components.queryEditor.resourcePicker.advanced.resourceGroup.input} <Input
> id={`input-advanced-resource-picker-metricNamespace-${index + 1}`}
<Input value={resource?.metricNamespace ?? ''}
id="input-advanced-resource-picker-resourceGroup" onChange={(event) =>
value={resource?.resourceGroup ?? ''} onResourceChange({ ...resource, metricNamespace: event.currentTarget.value }, index)
onChange={(event) => onChange({ ...resource, resourceGroup: event.currentTarget.value })} }
placeholder="resource-group" placeholder="Microsoft.Insights/metricNamespaces"
/> />
</InlineField> </InlineField>
<InlineField <InlineField
label="Namespace" label="Resource Name"
grow grow
transparent transparent
htmlFor="input-advanced-resource-picker-metricNamespace" htmlFor={`input-advanced-resource-picker-resourceName-${index + 1}`}
labelWidth={15} labelWidth={15}
data-testid={selectors.components.queryEditor.resourcePicker.advanced.namespace.input} data-testid={selectors.components.queryEditor.resourcePicker.advanced.resource.input}
> >
<Input <Input
id="input-advanced-resource-picker-metricNamespace" id={`input-advanced-resource-picker-resourceName-${index + 1}`}
value={resource?.metricNamespace ?? ''} value={resource?.resourceName ?? ''}
onChange={(event) => onChange({ ...resource, metricNamespace: event.currentTarget.value })} onChange={(event) =>
placeholder="Microsoft.Insights/metricNamespaces" onResourceChange({ ...resource, resourceName: event.currentTarget.value }, index)
/> }
</InlineField> placeholder="name"
<InlineField />
label="Resource Name" </InlineField>
grow </>
transparent )}
htmlFor="input-advanced-resource-picker-resourceName" </div>
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>
</>
)}
<Space v={2} /> <Space v={2} />
</Collapse> </Collapse>
</div> </div>

View File

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

View File

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

View File

@ -1,7 +1,24 @@
import createMockQuery from '../../__mocks__/query'; import createMockQuery from '../../__mocks__/query';
import { ResourceRowGroup, ResourceRowType } from './types'; 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('AzureMonitor ResourcePicker utils', () => {
describe('parseResourceURI', () => { 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', () => { describe('setResource', () => {
it('updates a resource with a resource URI for Log Analytics', () => { it('updates a resource with a resource URI for Log Analytics', () => {
expect(setResource(createMockQuery(), '/subscription/sub')).toMatchObject({ 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 }; 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) { export function parseResourceDetails(resource: string | AzureMetricResource, location?: string) {
if (typeof resource === 'string') { if (typeof resource === 'string') {
const res = parseResourceURI(resource); const res = parseResourceURI(resource);
@ -54,6 +60,10 @@ export function parseResourceDetails(resource: string | AzureMetricResource, loc
return resource; return resource;
} }
export function resourcesToStrings(resources: Array<string | AzureMetricResource>) {
return resources.map((resource) => resourceToString(resource));
}
export function resourceToString(resource?: string | AzureMetricResource) { export function resourceToString(resource?: string | AzureMetricResource) {
return resource return resource
? typeof resource === 'string' ? typeof resource === 'string'
@ -82,7 +92,7 @@ function compareNamespaceAndName(
return rowNamespace === resourceNamespace && rowName === resourceName; return rowNamespace === resourceNamespace && rowName === resourceName;
} }
function matchURI(rowURI: string, resourceURI: string) { export function matchURI(rowURI: string, resourceURI: string) {
const targetParams = parseResourceDetails(resourceURI); const targetParams = parseResourceDetails(resourceURI);
const rowParams = parseResourceDetails(rowURI); 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 { export function findRow(rows: ResourceRowGroup, uri: string): ResourceRow | undefined {
for (const row of rows) { for (const row of rows) {
if (matchURI(row.uri, uri)) { if (matchURI(row.uri, uri)) {

View File

@ -10,6 +10,15 @@ import { AzureGraphResponse } from '../types';
import ResourcePickerData from './resourcePickerData'; 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 createResourcePickerData = (responses: AzureGraphResponse[]) => {
const instanceSettings = createMockInstanceSetttings(); const instanceSettings = createMockInstanceSetttings();
const mockDatasource = createMockDatasource(); const mockDatasource = createMockDatasource();
@ -412,4 +421,52 @@ describe('AzureMonitor resourcePickerData', () => {
expect(locations.get('northeurope')?.supportsLogs).toBe(false); 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 { logsResourceTypes, resourceTypeDisplayNames } from '../azureMetadata';
import AzureMonitorDatasource from '../azure_monitor/azure_monitor_datasource'; import AzureMonitorDatasource from '../azure_monitor/azure_monitor_datasource';
import { ResourceRow, ResourceRowGroup, ResourceRowType } from '../components/ResourcePicker/types'; 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 { import {
AzureDataSourceJsonData, AzureDataSourceJsonData,
AzureGraphResponse, AzureGraphResponse,
@ -46,7 +52,7 @@ export default class ResourcePickerData extends DataSourceWithBackend<AzureMonit
async fetchInitialRows( async fetchInitialRows(
type: ResourcePickerQueryType, type: ResourcePickerQueryType,
currentSelection?: AzureMetricResource currentSelection?: AzureMetricResource[]
): Promise<ResourceRowGroup> { ): Promise<ResourceRowGroup> {
const subscriptions = await this.getSubscriptions(); const subscriptions = await this.getSubscriptions();
@ -60,19 +66,29 @@ export default class ResourcePickerData extends DataSourceWithBackend<AzureMonit
} }
let resources = subscriptions; let resources = subscriptions;
if (currentSelection.subscription) { const promises = currentSelection.map((selection) => async () => {
const resourceGroupURI = `/subscriptions/${currentSelection.subscription}/resourceGroups/${currentSelection.resourceGroup}`; if (selection.subscription) {
const resourceGroupURI = `/subscriptions/${selection.subscription}/resourceGroups/${selection.resourceGroup}`;
if (currentSelection.resourceGroup) { if (selection.resourceGroup && !findRow(resources, resourceGroupURI)) {
const resourceGroups = await this.getResourceGroupsBySubscriptionId(currentSelection.subscription, type); const resourceGroups = await this.getResourceGroupsBySubscriptionId(selection.subscription, type);
resources = addResources(resources, `/subscriptions/${currentSelection.subscription}`, resourceGroups); resources = addResources(resources, `/subscriptions/${selection.subscription}`, resourceGroups);
} }
if (currentSelection.resourceName) { const resourceURI = resourceToString(selection);
const resourcesForResourceGroup = await this.getResourcesForResourceGroup(resourceGroupURI, type); if (selection.resourceName && !findRow(resources, resourceURI)) {
resources = addResources(resources, resourceGroupURI, resourcesForResourceGroup); 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; return resources;
} }