mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Azure Monitor: add search feature to resource picker. (#48234)
Co-authored-by: Andres Martinez Gotor <andres.mgotor@gmail.com>
This commit is contained in:
parent
2e9c38c951
commit
a8354a0319
@ -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',
|
||||
},
|
||||
];
|
||||
|
@ -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
|
||||
|
@ -11,6 +11,7 @@ const defaultProps = {
|
||||
isSelectable: false,
|
||||
isOpen: false,
|
||||
isDisabled: false,
|
||||
scrollIntoView: false,
|
||||
onToggleCollapse: jest.fn(),
|
||||
onSelectedChange: jest.fn(),
|
||||
};
|
||||
|
@ -27,8 +27,8 @@ export const NestedEntry: React.FC<NestedEntryProps> = ({
|
||||
isDisabled,
|
||||
isOpen,
|
||||
isSelectable,
|
||||
scrollIntoView,
|
||||
level,
|
||||
scrollIntoView,
|
||||
onToggleCollapse,
|
||||
onSelectedChange,
|
||||
}) => {
|
||||
|
@ -17,6 +17,7 @@ const defaultProps = {
|
||||
requestNestedRows: jest.fn(),
|
||||
onRowSelectedChange: jest.fn(),
|
||||
selectableEntryTypes: [],
|
||||
scrollIntoView: false,
|
||||
};
|
||||
|
||||
describe('NestedRow', () => {
|
||||
|
@ -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 () => {
|
||||
|
@ -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} />
|
||||
|
@ -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;
|
@ -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;
|
||||
|
@ -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',
|
||||
};
|
@ -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',
|
||||
};
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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 = `
|
||||
|
Loading…
Reference in New Issue
Block a user