Azure Monitor: add search feature to resource picker. (#48234)

Co-authored-by: Andres Martinez Gotor <andres.mgotor@gmail.com>
This commit is contained in:
Sarah Zinger 2022-05-02 21:57:56 -04:00 committed by GitHub
parent 2e9c38c951
commit a8354a0319
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 475 additions and 331 deletions

View File

@ -106,3 +106,14 @@ export const mockResourcesByResourceGroup = (): ResourceRowGroup => [
location: 'northeurope',
},
];
export const mockSearchResults = (): ResourceRowGroup => [
{
id: 'search-result',
uri: '/subscriptions/def-456/resourceGroups/dev-3/providers/Microsoft.Compute/disks/search-result',
name: 'search-result',
typeLabel: 'Microsoft.Compute/disks',
type: ResourceRowType.Resource,
location: 'northeurope',
},
];

View File

@ -552,6 +552,11 @@ export const resourceTypeMetadata = [
displayName: 'WorkerPools',
supportsLogs: true,
},
{
resourceType: 'microsoft.resources/subscriptions/resourcegroups',
displayName: 'Resource Group',
supportsLogs: true,
},
];
export const logsSupportedResourceTypesKusto = resourceTypeMetadata

View File

@ -11,6 +11,7 @@ const defaultProps = {
isSelectable: false,
isOpen: false,
isDisabled: false,
scrollIntoView: false,
onToggleCollapse: jest.fn(),
onSelectedChange: jest.fn(),
};

View File

@ -27,8 +27,8 @@ export const NestedEntry: React.FC<NestedEntryProps> = ({
isDisabled,
isOpen,
isSelectable,
scrollIntoView,
level,
scrollIntoView,
onToggleCollapse,
onSelectedChange,
}) => {

View File

@ -17,6 +17,7 @@ const defaultProps = {
requestNestedRows: jest.fn(),
onRowSelectedChange: jest.fn(),
selectableEntryTypes: [],
scrollIntoView: false,
};
describe('NestedRow', () => {

View File

@ -7,6 +7,7 @@ import {
createMockResourceGroupsBySubscription,
createMockSubscriptions,
mockResourcesByResourceGroup,
mockSearchResults,
} from '../../__mocks__/resourcePickerRows';
import ResourcePickerData from '../../resourcePicker/resourcePickerData';
@ -22,17 +23,18 @@ const singleResourceSelectionURI =
const noop: any = () => {};
function createMockResourcePickerData() {
const mockDatasource = new ResourcePickerData(createMockInstanceSetttings());
const mockResourcePicker = new ResourcePickerData(createMockInstanceSetttings());
mockDatasource.getSubscriptions = jest.fn().mockResolvedValue(createMockSubscriptions());
mockDatasource.getResourceGroupsBySubscriptionId = jest
mockResourcePicker.getSubscriptions = jest.fn().mockResolvedValue(createMockSubscriptions());
mockResourcePicker.getResourceGroupsBySubscriptionId = jest
.fn()
.mockResolvedValue(createMockResourceGroupsBySubscription());
mockDatasource.getResourcesForResourceGroup = jest.fn().mockResolvedValue(mockResourcesByResourceGroup());
mockDatasource.getResourceURIFromWorkspace = jest.fn().mockReturnValue('');
mockDatasource.getResourceURIDisplayProperties = jest.fn().mockResolvedValue({});
mockResourcePicker.getResourcesForResourceGroup = jest.fn().mockResolvedValue(mockResourcesByResourceGroup());
mockResourcePicker.getResourceURIFromWorkspace = jest.fn().mockReturnValue('');
mockResourcePicker.getResourceURIDisplayProperties = jest.fn().mockResolvedValue({});
mockResourcePicker.search = jest.fn().mockResolvedValue(mockSearchResults());
return mockDatasource;
return mockResourcePicker;
}
const defaultProps = {
@ -78,7 +80,7 @@ describe('AzureMonitor ResourcePicker', () => {
expect(resourceGroupCheckboxes[1]).toBeChecked();
});
it('should show a resource 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} resourceURI={singleResourceSelectionURI} />);
const resourceCheckboxes = await screen.findAllByLabelText('db-server');
expect(resourceCheckboxes.length).toBe(2);
@ -109,7 +111,7 @@ describe('AzureMonitor ResourcePicker', () => {
expect(await screen.findByLabelText('A Great Resource Group')).toBeInTheDocument();
});
it('should call onApply with a new subscription uri when a user selects it', async () => {
it('should call onApply with a new subscription uri when a user clicks on the checkbox in the row', async () => {
const onApply = jest.fn();
render(<ResourcePicker {...defaultProps} onApply={onApply} />);
const subscriptionCheckbox = await screen.findByLabelText('Primary Subscription');
@ -122,7 +124,7 @@ describe('AzureMonitor ResourcePicker', () => {
expect(onApply).toBeCalledWith('/subscriptions/def-123');
});
it('should call onApply with a new subscription uri when a user types it', async () => {
it('should call onApply with a new subscription uri when a user types it in the selection box', async () => {
const onApply = jest.fn();
render(<ResourcePicker {...defaultProps} onApply={onApply} />);
const subscriptionCheckbox = await screen.findByLabelText('Primary Subscription');
@ -142,6 +144,87 @@ describe('AzureMonitor ResourcePicker', () => {
expect(onApply).toBeCalledWith('/subscriptions/def-123');
});
it('renders a search field which show search results when there are results', async () => {
render(<ResourcePicker {...defaultProps} />);
const searchRow1 = screen.queryByLabelText('search-result');
expect(searchRow1).not.toBeInTheDocument();
const searchField = await screen.findByLabelText('resource search');
expect(searchField).toBeInTheDocument();
await userEvent.type(searchField, 'sea');
const searchRow2 = await screen.findByLabelText('search-result');
expect(searchRow2).toBeInTheDocument();
});
it('renders no results if there are no search results', async () => {
const rpd = createMockResourcePickerData();
rpd.search = jest.fn().mockResolvedValue([]);
render(<ResourcePicker {...defaultProps} resourcePickerData={rpd} />);
const searchField = await screen.findByLabelText('resource search');
expect(searchField).toBeInTheDocument();
await userEvent.type(searchField, 'some search that has no results');
const noResults = await screen.findByText('No resources found');
expect(noResults).toBeInTheDocument();
});
it('renders a loading state while waiting for search results', async () => {
const rpd = createMockResourcePickerData();
rpd.search = jest.fn().mockImplementation(() => {
return new Promise((resolve) => {
setTimeout(() => {
return resolve(mockSearchResults());
}, 1); // purposely slow down call by a tick so as to force a loading state
});
});
render(<ResourcePicker {...defaultProps} resourcePickerData={rpd} />);
const searchField = await screen.findByLabelText('resource search');
expect(searchField).toBeInTheDocument();
await userEvent.type(searchField, 'sear');
const loading = await screen.findByText('Loading...');
expect(loading).toBeInTheDocument();
const searchResult = await screen.findByLabelText('search-result');
expect(searchResult).toBeInTheDocument();
const loadingAfterResults = screen.queryByText('Loading...');
expect(loadingAfterResults).not.toBeInTheDocument();
});
it('resets result when the user clears their search', async () => {
render(<ResourcePicker {...defaultProps} resourceURI={noResourceURI} />);
const subscriptionCheckboxBeforeSearch = await screen.findByLabelText('Primary Subscription');
expect(subscriptionCheckboxBeforeSearch).toBeInTheDocument();
const searchRow1 = screen.queryByLabelText('search-result');
expect(searchRow1).not.toBeInTheDocument();
const searchField = await screen.findByLabelText('resource search');
expect(searchField).toBeInTheDocument();
await userEvent.type(searchField, 'sea');
const searchRow2 = await screen.findByLabelText('search-result');
expect(searchRow2).toBeInTheDocument();
const subscriptionCheckboxAfterSearch = screen.queryByLabelText('Primary Subscription');
expect(subscriptionCheckboxAfterSearch).not.toBeInTheDocument();
await userEvent.clear(searchField);
const subscriptionCheckboxAfterClear = await screen.findByLabelText('Primary Subscription');
expect(subscriptionCheckboxAfterClear).toBeInTheDocument();
});
describe('when rendering resource picker without any selectable entry types', () => {
it('renders no checkboxes', async () => {
await act(async () => {

View File

@ -1,5 +1,6 @@
import { cx } from '@emotion/css';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useEffectOnce } from 'react-use';
import { Alert, Button, Icon, Input, LoadingPlaceholder, Tooltip, useStyles2, Collapse, Label } from '@grafana/ui';
@ -8,6 +9,7 @@ import messageFromError from '../../utils/messageFromError';
import { Space } from '../Space';
import NestedRow from './NestedRow';
import Search from './Search';
import getStyles from './styles';
import { ResourceRow, ResourceRowGroup, ResourceRowType } from './types';
import { findRow } from './utils';
@ -30,51 +32,54 @@ const ResourcePicker = ({
}: ResourcePickerProps) => {
const styles = useStyles2(getStyles);
type LoadingStatus = 'NotStarted' | 'Started' | 'Done';
const [loadingStatus, setLoadingStatus] = useState<LoadingStatus>('NotStarted');
const [isLoading, setIsLoading] = useState(false);
const [rows, setRows] = useState<ResourceRowGroup>([]);
const [selectedRows, setSelectedRows] = useState<ResourceRowGroup>([]);
const [internalSelectedURI, setInternalSelectedURI] = useState<string | undefined>(resourceURI);
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(resourceURI?.includes('$'));
const [shouldShowLimitFlag, setShouldShowLimitFlag] = useState(false);
// Sync the resourceURI prop to internal state
useEffect(() => {
setInternalSelectedURI(resourceURI);
}, [resourceURI]);
// Request initial data on first mount
useEffect(() => {
if (loadingStatus === 'NotStarted') {
const loadInitialData = async () => {
try {
setLoadingStatus('Started');
const resources = await resourcePickerData.fetchInitialRows(internalSelectedURI || '');
setRows(resources);
setLoadingStatus('Done');
} catch (error) {
setLoadingStatus('Done');
setErrorMessage(messageFromError(error));
}
};
loadInitialData();
const loadInitialData = useCallback(async () => {
if (!isLoading) {
try {
setIsLoading(true);
const resources = await resourcePickerData.fetchInitialRows(internalSelectedURI || '');
setRows(resources);
} catch (error) {
setErrorMessage(messageFromError(error));
}
setIsLoading(false);
}
}, [internalSelectedURI, isLoading, resourcePickerData]);
useEffectOnce(() => {
loadInitialData();
});
// set selected row data whenever row or selection changes
useEffect(() => {
if (!internalSelectedURI) {
setSelectedRows([]);
}
}, [resourcePickerData, internalSelectedURI, rows, loadingStatus]);
// Map the selected item into an array of rows
const selectedResourceRows = useMemo(() => {
const found = internalSelectedURI && findRow(rows, internalSelectedURI);
return found
? [
{
...found,
children: undefined,
},
]
: [];
if (found) {
return setSelectedRows([
{
...found,
children: undefined,
},
]);
}
}, [internalSelectedURI, rows]);
// Request resources for a expanded resource group
// Request resources for an expanded resource group
const requestNestedRows = useCallback(
async (parentRow: ResourceRow) => {
// clear error message (also when loading cached resources)
@ -104,120 +109,163 @@ const ResourcePicker = ({
onApply(internalSelectedURI);
}, [internalSelectedURI, onApply]);
const handleSearch = useCallback(
async (searchWord: string) => {
// clear errors and warnings
setErrorMessage(undefined);
setShouldShowLimitFlag(false);
if (!searchWord) {
loadInitialData();
return;
}
try {
setIsLoading(true);
const searchType = selectableEntryTypes.length > 1 ? 'logs' : 'metrics';
const searchResults = await resourcePickerData.search(searchWord, searchType);
setRows(searchResults);
if (searchResults.length >= resourcePickerData.resultLimit) {
setShouldShowLimitFlag(true);
}
} catch (err) {
setErrorMessage(messageFromError(err));
}
setIsLoading(false);
},
[loadInitialData, selectableEntryTypes.length, resourcePickerData]
);
return (
<div>
{loadingStatus === 'Started' ? (
<div className={styles.loadingWrapper}>
<LoadingPlaceholder text={'Loading...'} />
</div>
<Search searchFn={handleSearch} />
{shouldShowLimitFlag ? (
<p className={styles.resultLimit}>Showing first {resourcePickerData.resultLimit} results</p>
) : (
<>
<table className={styles.table}>
<thead>
<tr className={cx(styles.row, styles.header)}>
<td className={styles.cell}>Scope</td>
<td className={styles.cell}>Type</td>
<td className={styles.cell}>Location</td>
</tr>
</thead>
</table>
<div className={styles.tableScroller}>
<table className={styles.table}>
<tbody>
{rows.map((row) => (
<NestedRow
key={row.uri}
row={row}
selectedRows={selectedResourceRows}
level={0}
requestNestedRows={requestNestedRows}
onRowSelectedChange={handleSelectionChanged}
selectableEntryTypes={selectableEntryTypes}
scrollIntoView={true}
/>
))}
</tbody>
</table>
</div>
<div className={styles.selectionFooter}>
{selectedResourceRows.length > 0 && (
<>
<h5>Selection</h5>
<div className={styles.tableScroller}>
<table className={styles.table}>
<tbody>
{selectedResourceRows.map((row) => (
<NestedRow
key={row.uri}
row={row}
selectedRows={selectedResourceRows}
level={0}
requestNestedRows={requestNestedRows}
onRowSelectedChange={handleSelectionChanged}
selectableEntryTypes={selectableEntryTypes}
/>
))}
</tbody>
</table>
</div>
<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>
</div>
</>
<Space v={2} />
)}
<table className={styles.table}>
<thead>
<tr className={cx(styles.row, styles.header)}>
<td className={styles.cell}>Scope</td>
<td className={styles.cell}>Type</td>
<td className={styles.cell}>Location</td>
</tr>
</thead>
</table>
<div className={styles.tableScroller}>
<table className={styles.table}>
<tbody>
{isLoading && (
<tr className={cx(styles.row)}>
<td className={styles.cell}>
<LoadingPlaceholder text={'Loading...'} />
</td>
</tr>
)}
{!isLoading && rows.length === 0 && (
<tr className={cx(styles.row)}>
<td className={styles.cell} aria-live="polite">
No resources found
</td>
</tr>
)}
{!isLoading &&
rows.map((row) => (
<NestedRow
key={row.uri}
row={row}
selectedRows={selectedRows}
level={0}
requestNestedRows={requestNestedRows}
onRowSelectedChange={handleSelectionChanged}
selectableEntryTypes={selectableEntryTypes}
scrollIntoView={true}
/>
))}
</tbody>
</table>
</div>
<div className={styles.selectionFooter}>
{selectedRows.length > 0 && (
<>
<h5>Selection</h5>
<div className={styles.tableScroller}>
<table className={styles.table}>
<tbody>
{selectedRows.map((row) => (
<NestedRow
key={row.uri}
row={row}
selectedRows={selectedRows}
level={0}
requestNestedRows={requestNestedRows}
onRowSelectedChange={handleSelectionChanged}
selectableEntryTypes={selectableEntryTypes}
/>
))}
</tbody>
</table>
</div>
<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"
/>
<Space v={2} />
</Collapse>
<Space v={2} />
<Button disabled={!!errorMessage} onClick={handleApply}>
Apply
</Button>
<Space layout="inline" h={1} />
<Button onClick={onCancel} variant="secondary">
Cancel
</Button>
</div>
{errorMessage && (
<>
<Space v={2} />

View File

@ -0,0 +1,32 @@
import { debounce } from 'lodash';
import React, { useEffect, useMemo, useState } from 'react';
import { Icon, Input } from '@grafana/ui';
const Search = ({ searchFn }: { searchFn: (searchPhrase: string) => void }) => {
const [searchFilter, setSearchFilter] = useState('');
const debouncedSearch = useMemo(() => debounce(searchFn, 600), [searchFn]);
useEffect(() => {
return () => {
// Stop the invocation of the debounced function after unmounting
debouncedSearch.cancel();
};
}, [debouncedSearch]);
return (
<Input
aria-label="resource search"
prefix={<Icon name="search" />}
value={searchFilter}
onChange={(event) => {
const searchPhrase = event.currentTarget.value;
setSearchFilter(searchPhrase);
debouncedSearch(searchPhrase);
}}
placeholder="search for a resource"
/>
);
};
export default Search;

View File

@ -83,6 +83,11 @@ const getStyles = (theme: GrafanaTheme2) => ({
paddingBottom: theme.spacing(2),
color: theme.colors.text.secondary,
}),
resultLimit: css({
margin: '4px 0',
fontStyle: 'italic',
}),
});
export default getStyles;

View File

@ -1,65 +0,0 @@
export const LOCATION_DISPLAY_NAMES = {
eastus: 'East US',
eastus2: 'East US 2',
southcentralus: 'South Central US',
westus2: 'West US 2',
westus3: 'West US 3',
australiaeast: 'Australia East',
southeastasia: 'Southeast Asia',
northeurope: 'North Europe',
uksouth: 'UK South',
westeurope: 'West Europe',
centralus: 'Central US',
northcentralus: 'North Central US',
westus: 'West US',
southafricanorth: 'South Africa North',
centralindia: 'Central India',
eastasia: 'East Asia',
japaneast: 'Japan East',
jioindiawest: 'Jio India West',
koreacentral: 'Korea Central',
canadacentral: 'Canada Central',
francecentral: 'France Central',
germanywestcentral: 'Germany West Central',
norwayeast: 'Norway East',
switzerlandnorth: 'Switzerland North',
uaenorth: 'UAE North',
brazilsouth: 'Brazil South',
centralusstage: 'Central US (Stage)',
eastusstage: 'East US (Stage)',
eastus2stage: 'East US 2 (Stage)',
northcentralusstage: 'North Central US (Stage)',
southcentralusstage: 'South Central US (Stage)',
westusstage: 'West US (Stage)',
westus2stage: 'West US 2 (Stage)',
asia: 'Asia',
asiapacific: 'Asia Pacific',
australia: 'Australia',
brazil: 'Brazil',
canada: 'Canada',
europe: 'Europe',
global: 'Global',
india: 'India',
japan: 'Japan',
uk: 'United Kingdom',
unitedstates: 'United States',
eastasiastage: 'East Asia (Stage)',
southeastasiastage: 'Southeast Asia (Stage)',
westcentralus: 'West Central US',
southafricawest: 'South Africa West',
australiacentral: 'Australia Central',
australiacentral2: 'Australia Central 2',
australiasoutheast: 'Australia Southeast',
japanwest: 'Japan West',
koreasouth: 'Korea South',
southindia: 'South India',
westindia: 'West India',
canadaeast: 'Canada East',
francesouth: 'France South',
germanynorth: 'Germany North',
norwaywest: 'Norway West',
switzerlandwest: 'Switzerland West',
ukwest: 'UK West',
uaecentral: 'UAE Central',
brazilsoutheast: 'Brazil Southeast',
};

View File

@ -1,112 +0,0 @@
export const RESOURCE_TYPE_NAMES: Record<string, string> = {
'microsoft.analysisservices/servers': 'Analysis Services',
'microsoft.synapse/workspaces/bigdatapools': 'Apache Spark pools',
'microsoft.apimanagement/service': 'API Management services',
'microsoft.appconfiguration/configurationstores': 'App Configuration',
'microsoft.web/sites/slots': 'App Service (Slots)',
'microsoft.web/hostingenvironments': 'App Service Environments',
'microsoft.web/serverfarms': 'App Service plans',
'microsoft.web/sites': 'App Services',
'microsoft.network/applicationgateways': 'Application gateways',
'microsoft.insights/components': 'Application Insights',
'microsoft.automation/automationaccounts': 'Automation Accounts',
'microsoft.insights/autoscalesettings': 'Autoscale Settings',
'microsoft.aadiam/azureadmetrics': 'Azure AD Metrics',
'microsoft.cache/redis': 'Azure Cache for Redis',
'microsoft.documentdb/databaseaccounts': 'Azure Cosmos DB accounts',
'microsoft.kusto/clusters': 'Azure Data Explorer Clusters',
'microsoft.dbformariadb/servers': 'Azure Database for MariaDB servers',
'microsoft.dbformysql/servers': 'Azure Database for MySQL servers',
'microsoft.dbforpostgresql/flexibleservers': 'Azure Database for PostgreSQL flexible servers',
'microsoft.dbforpostgresql/servergroupsv2': 'Azure Database for PostgreSQL server groups',
'microsoft.dbforpostgresql/servers': 'Azure Database for PostgreSQL servers',
'microsoft.dbforpostgresql/serversv2': 'Azure Database for PostgreSQL servers v2',
'microsoft.resources/subscriptions': 'Azure Resource Manager',
'microsoft.appplatform/spring': 'Azure Spring Cloud',
'microsoft.databoxedge/databoxedgedevices': 'Azure Stack Edge / Data Box Gateway',
'microsoft.azurestackresourcemonitor/storageaccountmonitor': 'Azure Stack Resource Monitor',
'microsoft.synapse/workspaces': 'Azure Synapse Analytics',
'microsoft.network/bastionhosts': 'Bastions',
'microsoft.batch/batchaccounts': 'Batch accounts',
'microsoft.botservice/botservices': 'Bot Services',
'microsoft.netapp/netappaccounts/capacitypools': 'Capacity pools',
'microsoft.classiccompute/domainnames': 'Cloud services (classic)',
'microsoft.vmwarecloudsimple/virtualmachines': 'CloudSimple Virtual Machines',
'microsoft.cognitiveservices/accounts': 'Cognitive Services',
'microsoft.network/networkwatchers/connectionmonitors': 'Connection Monitors',
'microsoft.network/connections': 'Connections',
'microsoft.containerinstance/containergroups': 'Container instances',
'microsoft.containerregistry/registries': 'Container registries',
'microsoft.insights/qos': 'Custom Metric Usage',
'microsoft.customerinsights/hubs': 'CustomerInsights',
'microsoft.datafactory/datafactories': 'Data factories',
'microsoft.datafactory/factories': 'Data factories (V2)',
'microsoft.datalakeanalytics/accounts': 'Data Lake Analytics',
'microsoft.datalakestore/accounts': 'Data Lake Storage Gen1',
'microsoft.datashare/accounts': 'Data Shares',
'microsoft.synapse/workspaces/sqlpools': 'Dedicated SQL pools',
'microsoft.devices/provisioningservices': 'Device Provisioning Services',
'microsoft.compute/disks': 'Disks',
'microsoft.network/dnszones': 'DNS zones',
'microsoft.eventgrid/domains': 'Event Grid Domains',
'microsoft.eventgrid/systemtopics': 'Event Grid System Topics',
'microsoft.eventgrid/topics': 'Event Grid Topics',
'microsoft.eventhub/clusters': 'Event Hubs Clusters',
'microsoft.eventhub/namespaces': 'Event Hubs Namespaces',
'microsoft.network/expressroutecircuits': 'ExpressRoute circuits',
'microsoft.network/expressrouteports': 'ExpressRoute Direct',
'microsoft.network/expressroutegateways': 'ExpressRoute Gateways',
'microsoft.fabric.admin/fabriclocations': 'Fabric Locations',
'microsoft.network/azurefirewalls': 'Firewalls',
'microsoft.network/frontdoors': 'Front Doors',
'microsoft.hdinsight/clusters': 'HDInsight clusters',
'microsoft.storagecache/caches': 'HPC caches',
'microsoft.logic/integrationserviceenvironments': 'Integration Service Environments',
'microsoft.iotcentral/iotapps': 'IoT Central Applications',
'microsoft.devices/iothubs': 'IoT Hub',
'microsoft.keyvault/vaults': 'Key vaults',
'microsoft.kubernetes/connectedclusters': 'Kubernetes - Azure Arc',
'microsoft.containerservice/managedclusters': 'Kubernetes services',
'microsoft.media/mediaservices/liveevents': 'Live events',
'microsoft.network/loadbalancers': 'Load balancers',
'microsoft.operationalinsights/workspaces': 'Log Analytics workspaces',
'microsoft.logic/workflows': 'Logic apps',
'microsoft.machinelearningservices/workspaces': 'Machine learning',
'microsoft.media/mediaservices': 'Media Services',
'microsoft.network/natgateways': 'NAT gateways',
'microsoft.network/networkinterfaces': 'Network interfaces',
'microsoft.network/networkvirtualappliances': 'Network Virtual Appliances',
'microsoft.network/networkwatchers': 'Network Watchers',
'microsoft.notificationhubs/namespaces/notificationhubs': 'Notification Hubs',
'microsoft.network/p2svpngateways': 'P2S VPN Gateways',
'microsoft.peering/peeringservices': 'Peering Services',
'microsoft.powerbidedicated/capacities': 'Power BI Embedded',
'microsoft.network/privateendpoints': 'Private endpoints',
'microsoft.network/privatelinkservices': 'Private link services',
'microsoft.network/publicipaddresses': 'Public IP addresses',
'microsoft.cache/redisenterprise': 'Redis Enterprise',
'microsoft.relay/namespaces': 'Relays',
'microsoft.synapse/workspaces/scopepools': 'Scope pools',
'microsoft.search/searchservices': 'Search services',
'microsoft.servicebus/namespaces': 'Service Bus Namespaces',
'microsoft.signalrservice/signalr': 'SignalR',
'microsoft.operationsmanagement/solutions': 'Solutions',
'microsoft.sql/servers/databases': 'SQL databases',
'microsoft.sql/servers/elasticpools': 'SQL elastic pools',
'microsoft.sql/managedinstances': 'SQL managed instances',
'microsoft.storage/storageaccounts': 'Storage accounts',
'microsoft.classicstorage/storageaccounts': 'Storage accounts (classic)',
'microsoft.storagesync/storagesyncservices': 'Storage Sync Services',
'microsoft.streamanalytics/streamingjobs': 'Stream Analytics jobs',
'microsoft.media/mediaservices/streamingendpoints': 'Streaming Endpoints',
'microsoft.timeseriesinsights/environments': 'Time Series Insights environments',
'microsoft.network/trafficmanagerprofiles': 'Traffic Manager profiles',
'microsoft.compute/virtualmachinescalesets': 'Virtual machine scale sets',
'microsoft.compute/virtualmachines': 'Virtual machines',
'microsoft.classiccompute/virtualmachines': 'Virtual machines (classic)',
'microsoft.network/virtualnetworkgateways': 'Virtual network gateways',
'microsoft.netapp/netappaccounts/capacitypools/volumes': 'Volumes',
'microsoft.network/vpngateways': 'VPN Gateways',
'microsoft.cdn/cdnwebapplicationfirewallpolicies': 'Web application firewall policies (WAF)',
'microsoft.web/hostingenvironments/workerpools': 'WorkerPools',
};

View File

@ -236,4 +236,89 @@ describe('AzureMonitor resourcePickerData', () => {
}
});
});
describe('search', () => {
it('makes requests for metrics searches', async () => {
const mockResponse = {
data: [
{
id: '/subscriptions/subId/resourceGroups/rgName/providers/Microsoft.Compute/virtualMachines/vmname',
name: 'vmName',
type: 'microsoft.compute/virtualmachines',
resourceGroup: 'rgName',
subscriptionId: 'subId',
location: 'northeurope',
},
],
};
const { resourcePickerData, postResource } = createResourcePickerData([mockResponse]);
const formattedResults = await resourcePickerData.search('vmname', 'metrics');
expect(postResource).toBeCalledTimes(1);
const firstCall = postResource.mock.calls[0];
const [_, postBody] = firstCall;
expect(postBody.query).not.toContain('union resourcecontainers');
expect(postBody.query).toContain('where id contains "vmname"');
expect(formattedResults[0]).toEqual({
id: 'vmname',
name: 'vmName',
type: 'Resource',
location: 'North Europe',
resourceGroupName: 'rgName',
typeLabel: 'Virtual machine',
uri: '/subscriptions/subId/resourceGroups/rgName/providers/Microsoft.Compute/virtualMachines/vmname',
});
});
it('makes requests for logs searches', async () => {
const mockResponse = {
data: [
{
id: '/subscriptions/subId/resourceGroups/rgName',
name: 'rgName',
type: 'microsoft.resources/subscriptions/resourcegroups',
resourceGroup: 'rgName',
subscriptionId: 'subId',
location: 'northeurope',
},
],
};
const { resourcePickerData, postResource } = createResourcePickerData([mockResponse]);
const formattedResults = await resourcePickerData.search('rgName', 'logs');
expect(postResource).toBeCalledTimes(1);
const firstCall = postResource.mock.calls[0];
const [_, postBody] = firstCall;
expect(postBody.query).toContain('union resourcecontainers');
expect(formattedResults[0]).toEqual({
id: 'rgName',
name: 'rgName',
type: 'ResourceGroup',
location: 'North Europe',
resourceGroupName: 'rgName',
typeLabel: 'Resource Group',
uri: '/subscriptions/subId/resourceGroups/rgName',
});
});
it('throws an error if it receives data it can not parse', async () => {
const mockResponse = {
data: [
{
id: '/a-differently-formatted/uri/than/the/type/we/planned/to/parse',
name: 'web-server',
type: 'Microsoft.Compute/virtualMachines',
resourceGroup: 'dev',
subscriptionId: 'def-456',
location: 'northeurope',
},
],
};
const { resourcePickerData } = createResourcePickerData([mockResponse]);
try {
await resourcePickerData.search('dev', 'logs');
throw Error('expected search test to fail but it succeeded');
} catch (err) {
expect(err.message).toEqual('unable to fetch resource details');
}
});
});
});

View File

@ -7,8 +7,10 @@ import {
logsSupportedResourceTypesKusto,
resourceTypeDisplayNames,
} from '../azureMetadata';
import SupportedNamespaces from '../azure_monitor/supported_namespaces';
import { ResourceRow, ResourceRowGroup, ResourceRowType } from '../components/ResourcePicker/types';
import { addResources, parseResourceURI } from '../components/ResourcePicker/utils';
import { getAzureCloud } from '../credentials';
import {
AzureDataSourceJsonData,
AzureGraphResponse,
@ -22,13 +24,16 @@ import {
import { routeNames } from '../utils/common';
const RESOURCE_GRAPH_URL = '/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01';
export default class ResourcePickerData extends DataSourceWithBackend<AzureMonitorQuery, AzureDataSourceJsonData> {
private resourcePath: string;
private supportedMetricNamespaces: string[];
resultLimit = 200;
constructor(instanceSettings: DataSourceInstanceSettings<AzureDataSourceJsonData>) {
super(instanceSettings);
this.resourcePath = `${routeNames.resourceGraph}`;
const cloud = getAzureCloud(instanceSettings);
this.supportedMetricNamespaces = new SupportedNamespaces(cloud).get();
}
async fetchInitialRows(currentSelection?: string): Promise<ResourceRowGroup> {
@ -64,6 +69,51 @@ export default class ResourcePickerData extends DataSourceWithBackend<AzureMonit
return addResources(rows, parentRow.uri, nestedRows);
}
search = async (searchPhrase: string, searchType: 'logs' | 'metrics'): Promise<ResourceRowGroup> => {
const searchQuery = {
metrics: `
resources
| where id contains "${searchPhrase}"
| where type in (${this.supportedMetricNamespaces.map((ns) => `"${ns.toLowerCase()}"`).join(',')})
| order by tolower(name) asc
| limit ${this.resultLimit}
`,
logs: `
resources
| union resourcecontainers
| where id contains "${searchPhrase}"
| where type in (${logsSupportedResourceTypesKusto})
| order by tolower(name) asc
| limit ${this.resultLimit}
`,
};
const { data: response } = await this.makeResourceGraphRequest<RawAzureResourceItem[]>(searchQuery[searchType]);
return response.map((item) => {
const parsedUri = parseResourceURI(item.id);
if (!parsedUri || !(parsedUri.resource || parsedUri.resourceGroup || parsedUri.subscriptionID)) {
throw new Error('unable to fetch resource details');
}
let id = parsedUri.subscriptionID;
let type = ResourceRowType.Subscription;
if (parsedUri.resource) {
id = parsedUri.resource;
type = ResourceRowType.Resource;
} else if (parsedUri.resourceGroup) {
id = parsedUri.resourceGroup;
type = ResourceRowType.ResourceGroup;
}
return {
name: item.name,
id,
uri: item.id,
resourceGroupName: item.resourceGroup,
type,
typeLabel: resourceTypeDisplayNames[item.type] || item.type,
location: locationDisplayNames[item.location] || item.location,
};
});
};
// private
async getSubscriptions(): Promise<ResourceRowGroup> {
const query = `