mirror of https://github.com/grafana/grafana
AzureMonitor: Support querying any Resource for Logs queries (#33879)
* wip * wip: * ui work for resource picker * disable rows when others are selected * resource picker open button * Azure Monitor: Connect to backend with real data (#34024) * Connect to backend with real data * Fixes from code review. * WIP:Begin thinking about how to make queries with scope determined by the resource picker * More fixes post code review. * Fixes after rebasing * Remove outdated todo * AzureMonitor: Support any resource for Logs queries (#33762) * Apply button for resource picker * scroll table body * secondary cancel button * loading state for nested rows * Display resource components in picker button * fix tests * fix icons * move route function * Migrate from workspace to resource uri for log analytics (#34337) * reword backwards compat comment * remove base url suffix * fix lint error * move migrations to seperate file * cleanup * update regex * cleanup * update plugin routes to use new azure auth type * use AzureDataSourceInstanceSettings alias Co-authored-by: Sarah Zinger <sarahzinger@users.noreply.github.com>pull/34423/head^2
parent
c61dd82163
commit
5dca9fd4d8
@ -0,0 +1,107 @@ |
||||
import { Button, Icon, Modal } from '@grafana/ui'; |
||||
import React, { useCallback, useEffect, useState } from 'react'; |
||||
import { AzureQueryEditorFieldProps, AzureResourceSummaryItem } from '../../types'; |
||||
import { Field } from '../Field'; |
||||
import ResourcePicker from '../ResourcePicker'; |
||||
import { parseResourceURI } from '../ResourcePicker/utils'; |
||||
import { Space } from '../Space'; |
||||
|
||||
function parseResourceDetails(resourceURI: string) { |
||||
const parsed = parseResourceURI(resourceURI); |
||||
|
||||
if (!parsed) { |
||||
return undefined; |
||||
} |
||||
|
||||
return { |
||||
id: resourceURI, |
||||
subscriptionName: parsed.subscriptionID, |
||||
resourceGroupName: parsed.resourceGroup, |
||||
name: parsed.resource, |
||||
}; |
||||
} |
||||
|
||||
const ResourceField: React.FC<AzureQueryEditorFieldProps> = ({ query, datasource, onQueryChange }) => { |
||||
const { resource } = query.azureLogAnalytics; |
||||
|
||||
const [resourceComponents, setResourceComponents] = useState(parseResourceDetails(resource ?? '')); |
||||
const [pickerIsOpen, setPickerIsOpen] = useState(false); |
||||
|
||||
useEffect(() => { |
||||
if (resource) { |
||||
datasource.resourcePickerData.getResource(resource).then(setResourceComponents); |
||||
} else { |
||||
setResourceComponents(undefined); |
||||
} |
||||
}, [datasource.resourcePickerData, resource]); |
||||
|
||||
const handleOpenPicker = useCallback(() => { |
||||
setPickerIsOpen(true); |
||||
}, []); |
||||
|
||||
const closePicker = useCallback(() => { |
||||
setPickerIsOpen(false); |
||||
}, []); |
||||
|
||||
const handleApply = useCallback( |
||||
(resourceURI: string | undefined) => { |
||||
onQueryChange({ |
||||
...query, |
||||
azureLogAnalytics: { |
||||
...query.azureLogAnalytics, |
||||
resource: resourceURI, |
||||
}, |
||||
}); |
||||
closePicker(); |
||||
}, |
||||
[closePicker, onQueryChange, query] |
||||
); |
||||
|
||||
return ( |
||||
<> |
||||
<Modal title="Select a resource" isOpen={pickerIsOpen} onDismiss={closePicker}> |
||||
<ResourcePicker |
||||
resourcePickerData={datasource.resourcePickerData} |
||||
resourceURI={query.azureLogAnalytics.resource!} |
||||
onApply={handleApply} |
||||
onCancel={closePicker} |
||||
/> |
||||
</Modal> |
||||
|
||||
<Field label="Resource"> |
||||
<Button variant="secondary" onClick={handleOpenPicker}> |
||||
{/* Three mutually exclusive states */} |
||||
{!resource && 'Select a resource'} |
||||
{resource && resourceComponents && <FormattedResource resource={resourceComponents} />} |
||||
{resource && !resourceComponents && resource} |
||||
</Button> |
||||
</Field> |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
interface FormattedResourceProps { |
||||
resource: AzureResourceSummaryItem; |
||||
} |
||||
|
||||
const FormattedResource: React.FC<FormattedResourceProps> = ({ resource }) => { |
||||
return ( |
||||
<span> |
||||
<Icon name="layer-group" /> {resource.subscriptionName} |
||||
<Separator /> |
||||
<Icon name="folder" /> {resource.resourceGroupName} |
||||
<Separator /> |
||||
<Icon name="cube" /> {resource.name} |
||||
</span> |
||||
); |
||||
}; |
||||
|
||||
const Separator = () => ( |
||||
<> |
||||
<Space layout="inline" h={2} /> |
||||
{'/'} |
||||
<Space layout="inline" h={2} /> |
||||
</> |
||||
); |
||||
|
||||
export default ResourceField; |
||||
@ -0,0 +1,38 @@ |
||||
import { useEffect } from 'react'; |
||||
import { AzureMonitorQuery } from '../../types'; |
||||
import Datasource from '../../datasource'; |
||||
|
||||
async function migrateWorkspaceQueryToResourceQuery( |
||||
datasource: Datasource, |
||||
query: AzureMonitorQuery, |
||||
onChange: (newQuery: AzureMonitorQuery) => void |
||||
) { |
||||
if (query.azureLogAnalytics.workspace !== undefined && !query.azureLogAnalytics.resource) { |
||||
const resourceURI = await datasource.resourcePickerData.getResourceURIFromWorkspace( |
||||
query.azureLogAnalytics.workspace |
||||
); |
||||
|
||||
const newQuery = { |
||||
...query, |
||||
azureLogAnalytics: { |
||||
...query.azureLogAnalytics, |
||||
resource: resourceURI, |
||||
workspace: undefined, |
||||
}, |
||||
}; |
||||
|
||||
delete newQuery.azureLogAnalytics.workspace; |
||||
|
||||
onChange(newQuery); |
||||
} |
||||
} |
||||
|
||||
export default function useMigrations( |
||||
datasource: Datasource, |
||||
query: AzureMonitorQuery, |
||||
onChange: (newQuery: AzureMonitorQuery) => void |
||||
) { |
||||
useEffect(() => { |
||||
migrateWorkspaceQueryToResourceQuery(datasource, query, onChange); |
||||
}, [datasource, query, onChange]); |
||||
} |
||||
@ -0,0 +1,58 @@ |
||||
import React from 'react'; |
||||
import { cx } from '@emotion/css'; |
||||
|
||||
import { useStyles2 } from '@grafana/ui'; |
||||
|
||||
import NestedRows from './NestedRows'; |
||||
import getStyles from './styles'; |
||||
import { Row, RowGroup } from './types'; |
||||
|
||||
interface NestedResourceTableProps { |
||||
rows: RowGroup; |
||||
selectedRows: RowGroup; |
||||
noHeader?: boolean; |
||||
requestNestedRows: (row: Row) => Promise<void>; |
||||
onRowSelectedChange: (row: Row, selected: boolean) => void; |
||||
} |
||||
|
||||
const NestedResourceTable: React.FC<NestedResourceTableProps> = ({ |
||||
rows, |
||||
selectedRows, |
||||
noHeader, |
||||
requestNestedRows, |
||||
onRowSelectedChange, |
||||
}) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
return ( |
||||
<> |
||||
<table className={styles.table}> |
||||
{!noHeader && ( |
||||
<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> |
||||
<NestedRows |
||||
rows={rows} |
||||
selectedRows={selectedRows} |
||||
level={0} |
||||
requestNestedRows={requestNestedRows} |
||||
onRowSelectedChange={onRowSelectedChange} |
||||
/> |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
export default NestedResourceTable; |
||||
@ -0,0 +1,195 @@ |
||||
import { cx } from '@emotion/css'; |
||||
import { Checkbox, HorizontalGroup, Icon, IconButton, useStyles2, useTheme2 } from '@grafana/ui'; |
||||
import React, { useCallback, useEffect, useState } from 'react'; |
||||
import getStyles from './styles'; |
||||
import { EntryType, Row, RowGroup } from './types'; |
||||
|
||||
interface NestedRowsProps { |
||||
rows: RowGroup; |
||||
level: number; |
||||
selectedRows: RowGroup; |
||||
requestNestedRows: (row: Row) => Promise<void>; |
||||
onRowSelectedChange: (row: Row, selected: boolean) => void; |
||||
} |
||||
|
||||
const NestedRows: React.FC<NestedRowsProps> = ({ |
||||
rows, |
||||
selectedRows, |
||||
level, |
||||
requestNestedRows, |
||||
onRowSelectedChange, |
||||
}) => ( |
||||
<> |
||||
{Object.keys(rows).map((rowId) => ( |
||||
<NestedRow |
||||
key={rowId} |
||||
row={rows[rowId]} |
||||
selectedRows={selectedRows} |
||||
level={level} |
||||
requestNestedRows={requestNestedRows} |
||||
onRowSelectedChange={onRowSelectedChange} |
||||
/> |
||||
))} |
||||
</> |
||||
); |
||||
|
||||
interface NestedRowProps { |
||||
row: Row; |
||||
level: number; |
||||
selectedRows: RowGroup; |
||||
requestNestedRows: (row: Row) => Promise<void>; |
||||
onRowSelectedChange: (row: Row, selected: boolean) => void; |
||||
} |
||||
|
||||
const NestedRow: React.FC<NestedRowProps> = ({ row, selectedRows, level, requestNestedRows, onRowSelectedChange }) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const isSelected = !!selectedRows[row.id]; |
||||
const isDisabled = Object.keys(selectedRows).length > 0 && !isSelected; |
||||
const initialOpenStatus = row.type === EntryType.Collection ? 'open' : 'closed'; |
||||
const [openStatus, setOpenStatus] = useState<'open' | 'closed' | 'loading'>(initialOpenStatus); |
||||
const isOpen = openStatus === 'open'; |
||||
|
||||
const onRowToggleCollapse = async () => { |
||||
if (openStatus === 'open') { |
||||
setOpenStatus('closed'); |
||||
return; |
||||
} |
||||
setOpenStatus('loading'); |
||||
await requestNestedRows(row); |
||||
setOpenStatus('open'); |
||||
}; |
||||
|
||||
// opens the resource group on load of component if there was a previously saved selection
|
||||
useEffect(() => { |
||||
const selectedRow = Object.keys(selectedRows).map((rowId) => selectedRows[rowId])[0]; |
||||
const isSelectedResourceGroup = |
||||
selectedRow && selectedRow.resourceGroupName && row.name === selectedRow.resourceGroupName; |
||||
if (isSelectedResourceGroup) { |
||||
setOpenStatus('open'); |
||||
} |
||||
}, [selectedRows, row]); |
||||
|
||||
return ( |
||||
<> |
||||
<tr className={cx(styles.row, isDisabled && styles.disabledRow)} key={row.id}> |
||||
<td className={styles.cell}> |
||||
<NestedEntry |
||||
level={level} |
||||
isSelected={isSelected} |
||||
isDisabled={isDisabled} |
||||
isOpen={isOpen} |
||||
entry={row} |
||||
onToggleCollapse={onRowToggleCollapse} |
||||
onSelectedChange={onRowSelectedChange} |
||||
/> |
||||
</td> |
||||
|
||||
<td className={styles.cell}>{row.typeLabel}</td> |
||||
|
||||
<td className={styles.cell}>{row.location ?? '-'}</td> |
||||
</tr> |
||||
|
||||
{isOpen && row.children && Object.keys(row.children).length > 0 && ( |
||||
<NestedRows |
||||
rows={row.children} |
||||
selectedRows={selectedRows} |
||||
level={level + 1} |
||||
requestNestedRows={requestNestedRows} |
||||
onRowSelectedChange={onRowSelectedChange} |
||||
/> |
||||
)} |
||||
|
||||
{openStatus === 'loading' && ( |
||||
<tr> |
||||
<td className={cx(styles.cell, styles.loadingCell)} colSpan={3}> |
||||
Loading... |
||||
</td> |
||||
</tr> |
||||
)} |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
interface EntryIconProps { |
||||
entry: Row; |
||||
isOpen: boolean; |
||||
} |
||||
|
||||
const EntryIcon: React.FC<EntryIconProps> = ({ isOpen, entry: { type } }) => { |
||||
switch (type) { |
||||
case EntryType.Collection: |
||||
return <Icon name="layer-group" />; |
||||
|
||||
case EntryType.SubCollection: |
||||
return <Icon name={isOpen ? 'folder-open' : 'folder'} />; |
||||
|
||||
case EntryType.Resource: |
||||
return <Icon name="cube" />; |
||||
|
||||
default: |
||||
return null; |
||||
} |
||||
}; |
||||
|
||||
interface NestedEntryProps { |
||||
level: number; |
||||
entry: Row; |
||||
isSelected: boolean; |
||||
isOpen: boolean; |
||||
isDisabled: boolean; |
||||
onToggleCollapse: (row: Row) => void; |
||||
onSelectedChange: (row: Row, selected: boolean) => void; |
||||
} |
||||
|
||||
const NestedEntry: React.FC<NestedEntryProps> = ({ |
||||
entry, |
||||
isSelected, |
||||
isDisabled, |
||||
isOpen, |
||||
level, |
||||
onToggleCollapse, |
||||
onSelectedChange, |
||||
}) => { |
||||
const theme = useTheme2(); |
||||
const styles = useStyles2(getStyles); |
||||
const hasChildren = !!entry.children; |
||||
const isSelectable = entry.type === EntryType.Resource; |
||||
|
||||
const handleToggleCollapse = useCallback(() => { |
||||
onToggleCollapse(entry); |
||||
}, [onToggleCollapse, entry]); |
||||
|
||||
const handleSelectedChanged = useCallback( |
||||
(ev: React.ChangeEvent<HTMLInputElement>) => { |
||||
const isSelected = ev.target.checked; |
||||
onSelectedChange(entry, isSelected); |
||||
}, |
||||
[entry, onSelectedChange] |
||||
); |
||||
|
||||
return ( |
||||
<div style={{ marginLeft: level * (3 * theme.spacing.gridSize) }}> |
||||
<HorizontalGroup align="center" spacing="sm"> |
||||
{/* When groups are selectable, I *think* we will want to show a 2-wide space instead |
||||
of the collapse button for leaf rows that have no children to get them to align */} |
||||
{hasChildren && ( |
||||
<IconButton |
||||
className={styles.collapseButton} |
||||
name={isOpen ? 'angle-down' : 'angle-right'} |
||||
aria-label={isOpen ? 'Collapse' : 'Expand'} |
||||
onClick={handleToggleCollapse} |
||||
/> |
||||
)} |
||||
|
||||
{isSelectable && <Checkbox onChange={handleSelectedChanged} disabled={isDisabled} value={isSelected} />} |
||||
|
||||
<EntryIcon entry={entry} isOpen={isOpen} /> |
||||
|
||||
{entry.name} |
||||
</HorizontalGroup> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
export default NestedRows; |
||||
@ -0,0 +1,139 @@ |
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'; |
||||
import NestedResourceTable from './NestedResourceTable'; |
||||
import { Row, RowGroup } from './types'; |
||||
import { css } from '@emotion/css'; |
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { Button, useStyles2 } from '@grafana/ui'; |
||||
import ResourcePickerData from '../../resourcePicker/resourcePickerData'; |
||||
import { produce } from 'immer'; |
||||
import { Space } from '../Space'; |
||||
import { parseResourceURI } from './utils'; |
||||
|
||||
interface ResourcePickerProps { |
||||
resourcePickerData: Pick<ResourcePickerData, 'getResourcePickerData' | 'getResourcesForResourceGroup'>; |
||||
resourceURI: string | undefined; |
||||
|
||||
onApply: (resourceURI: string | undefined) => void; |
||||
onCancel: () => void; |
||||
} |
||||
|
||||
const ResourcePicker = ({ resourcePickerData, resourceURI, onApply, onCancel }: ResourcePickerProps) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const [rows, setRows] = useState<RowGroup>({}); |
||||
const [internalSelected, setInternalSelected] = useState<string | undefined>(resourceURI); |
||||
|
||||
useEffect(() => { |
||||
setInternalSelected(resourceURI); |
||||
}, [resourceURI]); |
||||
|
||||
const handleFetchInitialResources = useCallback(async () => { |
||||
const initalRows = await resourcePickerData.getResourcePickerData(); |
||||
setRows(initalRows); |
||||
}, [resourcePickerData]); |
||||
|
||||
useEffect(() => { |
||||
handleFetchInitialResources(); |
||||
}, [handleFetchInitialResources]); |
||||
|
||||
const requestNestedRows = useCallback( |
||||
async (resourceGroup: Row) => { |
||||
// if we've already fetched resources for a resource group we don't need to re-fetch them
|
||||
if (resourceGroup.children && Object.keys(resourceGroup.children).length > 0) { |
||||
return; |
||||
} |
||||
|
||||
// fetch and set nested resources for the resourcegroup into the bigger state object
|
||||
const resources = await resourcePickerData.getResourcesForResourceGroup(resourceGroup); |
||||
setRows( |
||||
produce(rows, (draftState: RowGroup) => { |
||||
const subscriptionChildren = draftState[resourceGroup.subscriptionId].children; |
||||
|
||||
if (subscriptionChildren) { |
||||
subscriptionChildren[resourceGroup.name].children = resources; |
||||
} |
||||
}) |
||||
); |
||||
}, |
||||
[resourcePickerData, rows] |
||||
); |
||||
|
||||
const handleSelectionChanged = useCallback((row: Row, isSelected: boolean) => { |
||||
isSelected ? setInternalSelected(row.id) : setInternalSelected(undefined); |
||||
}, []); |
||||
|
||||
const selectedResource = useMemo(() => { |
||||
if (internalSelected && Object.keys(rows).length) { |
||||
const parsed = parseResourceURI(internalSelected); |
||||
|
||||
if (parsed) { |
||||
const { subscriptionID, resourceGroup } = parsed; |
||||
const allResourceGroups = rows[subscriptionID].children || {}; |
||||
const selectedResourceGroup = allResourceGroups[resourceGroup.toLowerCase()]; |
||||
const allResourcesInResourceGroup = selectedResourceGroup.children; |
||||
|
||||
if (!allResourcesInResourceGroup || Object.keys(allResourcesInResourceGroup).length === 0) { |
||||
requestNestedRows(selectedResourceGroup); |
||||
return {}; |
||||
} |
||||
|
||||
const matchingResource = allResourcesInResourceGroup[internalSelected]; |
||||
|
||||
return { |
||||
[internalSelected]: matchingResource, |
||||
}; |
||||
} |
||||
} |
||||
return {}; |
||||
}, [internalSelected, rows, requestNestedRows]); |
||||
|
||||
const handleApply = useCallback(() => { |
||||
onApply(internalSelected); |
||||
}, [internalSelected, onApply]); |
||||
|
||||
return ( |
||||
<div> |
||||
<NestedResourceTable |
||||
rows={rows} |
||||
requestNestedRows={requestNestedRows} |
||||
onRowSelectedChange={handleSelectionChanged} |
||||
selectedRows={selectedResource} |
||||
/> |
||||
|
||||
<div className={styles.selectionFooter}> |
||||
{internalSelected && ( |
||||
<> |
||||
<Space v={2} /> |
||||
<h5>Selection</h5> |
||||
<NestedResourceTable |
||||
noHeader={true} |
||||
rows={selectedResource} |
||||
requestNestedRows={requestNestedRows} |
||||
onRowSelectedChange={handleSelectionChanged} |
||||
selectedRows={selectedResource} |
||||
/> |
||||
</> |
||||
)} |
||||
|
||||
<Space v={2} /> |
||||
|
||||
<Button onClick={handleApply}>Apply</Button> |
||||
<Space layout="inline" h={1} /> |
||||
<Button onClick={onCancel} variant="secondary"> |
||||
Cancel |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
export default ResourcePicker; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
selectionFooter: css({ |
||||
position: 'sticky', |
||||
bottom: 0, |
||||
background: theme.colors.background.primary, |
||||
paddingTop: theme.spacing(2), |
||||
}), |
||||
}); |
||||
@ -0,0 +1,47 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
table: css({ |
||||
width: '100%', |
||||
}), |
||||
|
||||
tableScroller: css({ |
||||
maxHeight: '50vh', |
||||
overflow: 'auto', |
||||
}), |
||||
|
||||
header: css({ |
||||
background: theme.colors.background.secondary, |
||||
}), |
||||
|
||||
row: css({ |
||||
borderBottom: `1px solid ${theme.colors.border.weak}`, |
||||
|
||||
'&:last-of-type': { |
||||
borderBottomColor: theme.colors.border.medium, |
||||
}, |
||||
}), |
||||
|
||||
disabledRow: css({ |
||||
opacity: 0.5, |
||||
}), |
||||
|
||||
cell: css({ |
||||
padding: theme.spacing(1, 0), |
||||
width: '25%', |
||||
|
||||
'&:first-of-type': { |
||||
width: '50%', |
||||
padding: theme.spacing(1, 0, 1, 2), |
||||
}, |
||||
}), |
||||
|
||||
collapseButton: css({ margin: 0 }), |
||||
|
||||
loadingCell: css({ |
||||
textAlign: 'center', |
||||
}), |
||||
}); |
||||
|
||||
export default getStyles; |
||||
@ -0,0 +1,19 @@ |
||||
export enum EntryType { |
||||
Collection, |
||||
SubCollection, |
||||
Resource, |
||||
} |
||||
export interface Row { |
||||
id: string; |
||||
name: string; |
||||
type: EntryType; |
||||
typeLabel: string; |
||||
subscriptionId: string; |
||||
location?: string; |
||||
children?: RowGroup; |
||||
resourceGroupName?: string; |
||||
} |
||||
|
||||
export interface RowGroup { |
||||
[subscriptionIdOrResourceGroupName: string]: Row; |
||||
} |
||||
@ -0,0 +1,12 @@ |
||||
const RESOURCE_URI_REGEX = /\/subscriptions\/(?<subscriptionID>.+)\/resourceGroups\/(?<resourceGroup>.+)\/providers.+\/(?<resource>[\w-_]+)/; |
||||
|
||||
export function parseResourceURI(resourceURI: string) { |
||||
const matches = RESOURCE_URI_REGEX.exec(resourceURI); |
||||
|
||||
if (!matches?.groups?.subscriptionID || !matches?.groups?.resourceGroup) { |
||||
return undefined; |
||||
} |
||||
|
||||
const { subscriptionID, resourceGroup, resource } = matches.groups; |
||||
return { subscriptionID, resourceGroup, resource }; |
||||
} |
||||
@ -0,0 +1,189 @@ |
||||
import { FetchResponse, getBackendSrv } from '@grafana/runtime'; |
||||
import { getLogAnalyticsResourcePickerApiRoute } from '../api/routes'; |
||||
import { EntryType, Row, RowGroup } from '../components/ResourcePicker/types'; |
||||
import { getAzureCloud } from '../credentials'; |
||||
import { AzureDataSourceInstanceSettings, AzureResourceSummaryItem } from '../types'; |
||||
import { SUPPORTED_LOCATIONS, SUPPORTED_RESOURCE_TYPES } from './supportedResources'; |
||||
|
||||
const RESOURCE_GRAPH_URL = '/providers/Microsoft.ResourceGraph/resources?api-version=2020-04-01-preview'; |
||||
|
||||
interface RawAzureResourceGroupItem { |
||||
subscriptionId: string; |
||||
subscriptionName: string; |
||||
resourceGroup: string; |
||||
resourceGroupId: string; |
||||
} |
||||
|
||||
interface RawAzureResourceItem { |
||||
id: string; |
||||
name: string; |
||||
subscriptionId: string; |
||||
resourceGroup: string; |
||||
type: string; |
||||
location: string; |
||||
} |
||||
|
||||
interface AzureGraphResponse<T = unknown> { |
||||
data: T; |
||||
} |
||||
|
||||
export default class ResourcePickerData { |
||||
private proxyUrl: string; |
||||
private cloud: string; |
||||
|
||||
constructor(instanceSettings: AzureDataSourceInstanceSettings) { |
||||
this.proxyUrl = instanceSettings.url!; |
||||
this.cloud = getAzureCloud(instanceSettings); |
||||
} |
||||
|
||||
async getResourcePickerData() { |
||||
const { ok, data: response } = await this.makeResourceGraphRequest<RawAzureResourceGroupItem[]>( |
||||
`resources
|
||||
| join kind=leftouter (ResourceContainers | where type=='microsoft.resources/subscriptions' | project subscriptionName=name, subscriptionId, resourceGroupId=id) on subscriptionId |
||||
| where type in (${SUPPORTED_RESOURCE_TYPES}) |
||||
| summarize count() by resourceGroup, subscriptionName, resourceGroupId, subscriptionId |
||||
| order by resourceGroup asc |
||||
` |
||||
); |
||||
|
||||
// TODO: figure out desired error handling strategy
|
||||
if (!ok) { |
||||
throw new Error('unable to fetch resource containers'); |
||||
} |
||||
|
||||
return this.formatResourceGroupData(response.data); |
||||
} |
||||
|
||||
async getResourcesForResourceGroup(resourceGroup: Row) { |
||||
const { ok, data: response } = await this.makeResourceGraphRequest<RawAzureResourceItem[]>(` |
||||
resources |
||||
| where resourceGroup == "${resourceGroup.name.toLowerCase()}" |
||||
| where type in (${SUPPORTED_RESOURCE_TYPES}) and location in (${SUPPORTED_LOCATIONS}) |
||||
`);
|
||||
|
||||
// TODO: figure out desired error handling strategy
|
||||
if (!ok) { |
||||
throw new Error('unable to fetch resource containers'); |
||||
} |
||||
|
||||
return this.formatResourceGroupChildren(response.data); |
||||
} |
||||
|
||||
async getResource(resourceURI: string) { |
||||
const query = ` |
||||
resources |
||||
| join ( |
||||
resourcecontainers |
||||
| where type == "microsoft.resources/subscriptions" |
||||
| project subscriptionName=name, subscriptionId |
||||
) on subscriptionId |
||||
| join ( |
||||
resourcecontainers |
||||
| where type == "microsoft.resources/subscriptions/resourcegroups" |
||||
| project resourceGroupName=name, resourceGroup |
||||
) on resourceGroup |
||||
| where id == "${resourceURI}" |
||||
| project id, name, subscriptionName, resourceGroupName |
||||
`;
|
||||
|
||||
const { ok, data: response } = await this.makeResourceGraphRequest<AzureResourceSummaryItem[]>(query); |
||||
|
||||
if (!ok || !response.data[0]) { |
||||
throw new Error('unable to fetch resource details'); |
||||
} |
||||
|
||||
return response.data[0]; |
||||
} |
||||
|
||||
async getResourceURIFromWorkspace(workspace: string) { |
||||
const { ok, data: response } = await this.makeResourceGraphRequest<RawAzureResourceItem[]>(` |
||||
resources |
||||
| where properties['customerId'] == "${workspace}" |
||||
| project id |
||||
`);
|
||||
|
||||
// TODO: figure out desired error handling strategy
|
||||
if (!ok) { |
||||
throw new Error('unable to fetch resource containers'); |
||||
} |
||||
|
||||
return response.data[0].id; |
||||
} |
||||
|
||||
formatResourceGroupData(rawData: RawAzureResourceGroupItem[]) { |
||||
const formatedSubscriptionsAndResourceGroups: RowGroup = {}; |
||||
|
||||
rawData.forEach((resourceGroup) => { |
||||
// if the subscription doesn't exist yet, create it
|
||||
if (!formatedSubscriptionsAndResourceGroups[resourceGroup.subscriptionId]) { |
||||
formatedSubscriptionsAndResourceGroups[resourceGroup.subscriptionId] = { |
||||
name: resourceGroup.subscriptionName, |
||||
id: resourceGroup.subscriptionId, |
||||
subscriptionId: resourceGroup.subscriptionId, |
||||
typeLabel: 'Subscription', |
||||
type: EntryType.Collection, |
||||
children: {}, |
||||
}; |
||||
} |
||||
|
||||
// add the resource group to the subscription
|
||||
// store by resourcegroupname not id to match resource uri
|
||||
(formatedSubscriptionsAndResourceGroups[resourceGroup.subscriptionId].children as RowGroup)[ |
||||
resourceGroup.resourceGroup |
||||
] = { |
||||
name: resourceGroup.resourceGroup, |
||||
id: resourceGroup.resourceGroupId, |
||||
subscriptionId: resourceGroup.subscriptionId, |
||||
type: EntryType.SubCollection, |
||||
typeLabel: 'Resource Group', |
||||
children: {}, |
||||
}; |
||||
}); |
||||
|
||||
return formatedSubscriptionsAndResourceGroups; |
||||
} |
||||
|
||||
formatResourceGroupChildren(rawData: RawAzureResourceItem[]) { |
||||
const children: RowGroup = {}; |
||||
|
||||
rawData.forEach((item: RawAzureResourceItem) => { |
||||
children[item.id] = { |
||||
name: item.name, |
||||
id: item.id, |
||||
subscriptionId: item.id, |
||||
resourceGroupName: item.resourceGroup, |
||||
type: EntryType.Resource, |
||||
typeLabel: item.type, // TODO: these types can be quite long, we may wish to format them more
|
||||
location: item.location, // TODO: we may wish to format these locations, by default they are written as 'northeurope' rather than a more human readable "North Europe"
|
||||
}; |
||||
}); |
||||
|
||||
return children; |
||||
} |
||||
|
||||
async makeResourceGraphRequest<T = unknown>( |
||||
query: string, |
||||
maxRetries = 1 |
||||
): Promise<FetchResponse<AzureGraphResponse<T>>> { |
||||
try { |
||||
return await getBackendSrv() |
||||
.fetch<AzureGraphResponse<T>>({ |
||||
url: this.proxyUrl + '/' + getLogAnalyticsResourcePickerApiRoute(this.cloud) + RESOURCE_GRAPH_URL, |
||||
method: 'POST', |
||||
data: { |
||||
query: query, |
||||
options: { |
||||
resultFormat: 'objectArray', |
||||
}, |
||||
}, |
||||
}) |
||||
.toPromise(); |
||||
} catch (error) { |
||||
if (maxRetries > 0) { |
||||
return this.makeResourceGraphRequest(query, maxRetries - 1); |
||||
} |
||||
|
||||
throw error; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,183 @@ |
||||
// TODO: should this list be different for logs vs metrics?
|
||||
export const SUPPORTED_RESOURCE_TYPES = [ |
||||
'microsoft.analysisservices/servers', |
||||
'microsoft.apimanagement/service', |
||||
'microsoft.network/applicationgateways', |
||||
'microsoft.insights/components', |
||||
'microsoft.web/hostingenvironments', |
||||
'microsoft.web/serverfarms', |
||||
'microsoft.web/sites', |
||||
'microsoft.automation/automationaccounts', |
||||
'microsoft.botservice/botservices', |
||||
'microsoft.appplatform/spring', |
||||
'microsoft.network/bastionhosts', |
||||
'microsoft.batch/batchaccounts', |
||||
'microsoft.cdn/cdnwebapplicationfirewallpolicies', |
||||
'microsoft.classiccompute/domainnames', |
||||
'microsoft.classiccompute/virtualmachines', |
||||
'microsoft.vmwarecloudsimple/virtualmachines', |
||||
'microsoft.cognitiveservices/accounts', |
||||
'microsoft.appconfiguration/configurationstores', |
||||
'microsoft.network/connections', |
||||
'microsoft.containerinstance/containergroups', |
||||
'microsoft.containerregistry/registries', |
||||
'microsoft.containerservice/managedclusters', |
||||
'microsoft.documentdb/databaseaccounts', |
||||
'microsoft.databoxedge/databoxedgedevices', |
||||
'microsoft.datafactory/datafactories', |
||||
'microsoft.datafactory/factories', |
||||
'microsoft.datalakeanalytics/accounts', |
||||
'microsoft.datalakestore/accounts', |
||||
'microsoft.datashare/accounts', |
||||
'microsoft.dbformysql/servers', |
||||
'microsoft.devices/provisioningservices', |
||||
'microsoft.compute/disks', |
||||
'microsoft.network/dnszones', |
||||
'microsoft.eventgrid/domains', |
||||
'microsoft.eventgrid/topics', |
||||
'microsoft.eventgrid/systemtopics', |
||||
'microsoft.eventhub/namespaces', |
||||
'microsoft.eventhub/clusters', |
||||
'microsoft.network/expressroutecircuits', |
||||
'microsoft.network/expressrouteports', |
||||
'microsoft.network/azurefirewalls', |
||||
'microsoft.network/frontdoors', |
||||
'microsoft.hdinsight/clusters', |
||||
'microsoft.iotcentral/iotapps', |
||||
'microsoft.devices/iothubs', |
||||
'microsoft.keyvault/vaults', |
||||
'microsoft.kubernetes/connectedclusters', |
||||
'microsoft.kusto/clusters', |
||||
'microsoft.network/loadbalancers', |
||||
'microsoft.operationalinsights/workspaces', |
||||
'microsoft.logic/workflows', |
||||
'microsoft.logic/integrationserviceenvironments', |
||||
'microsoft.machinelearningservices/workspaces', |
||||
'microsoft.dbformariadb/servers', |
||||
'microsoft.media/mediaservices', |
||||
'microsoft.media/mediaservices/streamingendpoints', |
||||
'microsoft.network/natgateways', |
||||
'microsoft.netapp/netappaccounts/capacitypools', |
||||
'microsoft.netapp/netappaccounts/capacitypools/volumes', |
||||
'microsoft.network/networkinterfaces', |
||||
'microsoft.notificationhubs/namespaces/notificationhubs', |
||||
'microsoft.peering/peeringservices', |
||||
'microsoft.dbforpostgresql/servers', |
||||
'microsoft.dbforpostgresql/serversv2', |
||||
'microsoft.powerbidedicated/capacities', |
||||
'microsoft.network/privateendpoints', |
||||
'microsoft.network/privatelinkservices', |
||||
'microsoft.network/publicipaddresses', |
||||
'microsoft.cache/redis', |
||||
'microsoft.cache/redisenterprise', |
||||
'microsoft.relay/namespaces', |
||||
'microsoft.search/searchservices', |
||||
'microsoft.dbforpostgresql/servergroupsv2', |
||||
'microsoft.servicebus/namespaces', |
||||
'microsoft.signalrservice/signalr', |
||||
'microsoft.operationsmanagement/solutions', |
||||
'microsoft.sql/managedinstances', |
||||
'microsoft.sql/servers/databases', |
||||
'microsoft.sql/servers/elasticpools', |
||||
'microsoft.storage/storageaccounts', |
||||
'microsoft.storagecache/caches', |
||||
'microsoft.classicstorage/storageaccounts', |
||||
'microsoft.storagesync/storagesyncservices', |
||||
'microsoft.streamanalytics/streamingjobs', |
||||
'microsoft.synapse/workspaces', |
||||
'microsoft.synapse/workspaces/bigdatapools', |
||||
'microsoft.synapse/workspaces/scopepools', |
||||
'microsoft.synapse/workspaces/sqlpools', |
||||
'microsoft.timeseriesinsights/environments', |
||||
'microsoft.network/trafficmanagerprofiles', |
||||
'microsoft.compute/virtualmachines', |
||||
'microsoft.compute/virtualmachinescalesets', |
||||
'microsoft.network/virtualnetworkgateways', |
||||
'microsoft.web/sites/slots', |
||||
'microsoft.resources/subscriptions', |
||||
'microsoft.insights/autoscalesettings', |
||||
'microsoft.aadiam/azureadmetrics', |
||||
'microsoft.azurestackresourcemonitor/storageaccountmonitor', |
||||
'microsoft.network/networkwatchers/connectionmonitors', |
||||
'microsoft.customerinsights/hubs', |
||||
'microsoft.insights/qos', |
||||
'microsoft.network/expressroutegateways', |
||||
'microsoft.fabric.admin/fabriclocations', |
||||
'microsoft.network/networkvirtualappliances', |
||||
'microsoft.media/mediaservices/liveevents', |
||||
'microsoft.network/networkwatchers', |
||||
'microsoft.network/p2svpngateways', |
||||
'microsoft.dbforpostgresql/flexibleservers', |
||||
'microsoft.network/vpngateways', |
||||
'microsoft.web/hostingenvironments/workerpools', |
||||
] |
||||
.map((type) => `"${type}"`) |
||||
.join(','); |
||||
|
||||
export const SUPPORTED_LOCATIONS = [ |
||||
'eastus', |
||||
'eastus2', |
||||
'southcentralus', |
||||
'westus2', |
||||
'westus3', |
||||
'australiaeast', |
||||
'southeastasia', |
||||
'northeurope', |
||||
'uksouth', |
||||
'westeurope', |
||||
'centralus', |
||||
'northcentralus', |
||||
'westus', |
||||
'southafricanorth', |
||||
'centralindia', |
||||
'eastasia', |
||||
'japaneast', |
||||
'jioindiawest', |
||||
'koreacentral', |
||||
'canadacentral', |
||||
'francecentral', |
||||
'germanywestcentral', |
||||
'norwayeast', |
||||
'switzerlandnorth', |
||||
'uaenorth', |
||||
'brazilsouth', |
||||
'centralusstage', |
||||
'eastusstage', |
||||
'eastus2stage', |
||||
'northcentralusstage', |
||||
'southcentralusstage', |
||||
'westusstage', |
||||
'westus2stage', |
||||
'asia', |
||||
'asiapacific', |
||||
'australia', |
||||
'brazil', |
||||
'canada', |
||||
'europe', |
||||
'global', |
||||
'india', |
||||
'japan', |
||||
'uk', |
||||
'unitedstates', |
||||
'eastasiastage', |
||||
'southeastasiastage', |
||||
'westcentralus', |
||||
'southafricawest', |
||||
'australiacentral', |
||||
'australiacentral2', |
||||
'australiasoutheast', |
||||
'japanwest', |
||||
'koreasouth', |
||||
'southindia', |
||||
'westindia', |
||||
'canadaeast', |
||||
'francesouth', |
||||
'germanynorth', |
||||
'norwaywest', |
||||
'switzerlandwest', |
||||
'ukwest', |
||||
'uaecentral', |
||||
'brazilsoutheast', |
||||
] |
||||
.map((type) => `"${type}"`) |
||||
.join(','); |
||||
Loading…
Reference in new issue