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>
This commit is contained in:
Josh Hunt
2021-05-19 17:39:08 +01:00
committed by GitHub
parent c61dd82163
commit 5dca9fd4d8
26 changed files with 1311 additions and 315 deletions

View File

@@ -34,6 +34,11 @@ export default function createMockDatasource() {
azureLogAnalyticsDatasource: {
getKustoSchema: () => Promise.resolve(),
},
resourcePickerData: {
getResourcePickerData: () => ({}),
getResourcesForResourceGroup: () => ({}),
getResourceURIFromWorkspace: () => '',
},
};
const mockDatasource = _mockDatasource as Datasource;

View File

@@ -49,3 +49,19 @@ export function getAppInsightsApiRoute(azureCloud: string): string {
throw new Error('The cloud not supported.');
}
}
export function getLogAnalyticsResourcePickerApiRoute(azureCloud: string) {
switch (azureCloud) {
case 'azuremonitor':
return 'loganalytics-resourcepickerdata';
case 'govazuremonitor': // Azure US Government
return 'loganalytics-resourcepickerdata-gov';
case 'chinaazuremonitor': // Azure China
return 'loganalytics-resourcepickerdata-china';
default:
throw new Error('The cloud not supported.');
}
}

View File

@@ -69,10 +69,10 @@ export default class AzureLogAnalyticsDatasource extends DataSourceWithBackend<
return this.doRequest(workspaceListUrl, true);
}
async getMetadata(workspace: string) {
const url = `${this.baseUrl}/${getTemplateSrv().replace(workspace, {})}/metadata`;
const resp = await this.doRequest<AzureLogAnalyticsMetadata>(url);
async getMetadata(resourceUri: string) {
const url = `${this.baseUrl}/v1${resourceUri}/metadata`;
const resp = await this.doRequest<AzureLogAnalyticsMetadata>(url);
if (!resp.ok) {
throw new Error('Unable to get metadata for workspace');
}
@@ -80,9 +80,9 @@ export default class AzureLogAnalyticsDatasource extends DataSourceWithBackend<
return resp.data;
}
async getKustoSchema(workspace: string) {
const metadata = await this.getMetadata(workspace);
return transformMetadataToKustoSchema(metadata, workspace);
async getKustoSchema(resourceUri: string) {
const metadata = await this.getMetadata(resourceUri);
return transformMetadataToKustoSchema(metadata, resourceUri);
}
applyTemplateVariables(target: AzureMonitorQuery, scopedVars: ScopedVars): Record<string, any> {
@@ -98,6 +98,8 @@ export default class AzureLogAnalyticsDatasource extends DataSourceWithBackend<
const subscriptionId = templateSrv.replace(target.subscription || this.subscriptionId, scopedVars);
const query = templateSrv.replace(item.query, scopedVars, this.interpolateVariable);
const resource = templateSrv.replace(item.resource, scopedVars);
return {
refId: target.refId,
format: target.format,
@@ -106,6 +108,9 @@ export default class AzureLogAnalyticsDatasource extends DataSourceWithBackend<
azureLogAnalytics: {
resultFormat: item.resultFormat,
query: query,
resource,
// TODO: Workspace is deprecated and should be migrated to Resources
workspace: workspace,
},
};
@@ -165,6 +170,9 @@ export default class AzureLogAnalyticsDatasource extends DataSourceWithBackend<
}
async getWorkspaceDetails(workspaceId: string) {
if (!this.subscriptionId) {
return {};
}
const response = await this.getWorkspaceList(this.subscriptionId);
const details = response.data.value.find((o: any) => {
@@ -236,7 +244,7 @@ export default class AzureLogAnalyticsDatasource extends DataSourceWithBackend<
'TimeGenerated'
);
const querystring = querystringBuilder.generate().uriString;
const url = `${this.baseUrl}/${workspace}/query?${querystring}`;
const url = `${this.baseUrl}/v1/workspaces/${workspace}/query?${querystring}`;
const queries: any[] = [];
queries.push({
datasourceId: this.id,
@@ -340,6 +348,7 @@ export default class AzureLogAnalyticsDatasource extends DataSourceWithBackend<
}
}
// TODO: update to be resource-centric
testDatasource(): Promise<any> {
const validationError = this.isValidConfig();
if (validationError) {
@@ -348,7 +357,7 @@ export default class AzureLogAnalyticsDatasource extends DataSourceWithBackend<
return this.getDefaultOrFirstWorkspace()
.then((ws: any) => {
const url = `${this.baseUrl}/${ws}/metadata`;
const url = `${this.baseUrl}/v1/workspaces/${ws}/metadata`;
return this.doRequest(url);
})

View File

@@ -2,10 +2,10 @@ import React from 'react';
import { AzureMonitorErrorish, AzureMonitorOption, AzureMonitorQuery } from '../../types';
import Datasource from '../../datasource';
import { InlineFieldRow } from '@grafana/ui';
import SubscriptionField from '../SubscriptionField';
import WorkspaceField from './WorkspaceField';
import QueryField from './QueryField';
import FormatAsField from './FormatAsField';
import ResourceField from './ResourceField';
import useMigrations from './useMigrations';
interface LogsQueryEditorProps {
query: AzureMonitorQuery;
@@ -24,18 +24,12 @@ const LogsQueryEditor: React.FC<LogsQueryEditorProps> = ({
onChange,
setError,
}) => {
useMigrations(datasource, query, onChange);
return (
<div data-testid="azure-monitor-logs-query-editor">
<InlineFieldRow>
<SubscriptionField
query={query}
datasource={datasource}
subscriptionId={subscriptionId}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
setError={setError}
/>
<WorkspaceField
<ResourceField
query={query}
datasource={datasource}
subscriptionId={subscriptionId}

View File

@@ -31,8 +31,12 @@ const QueryField: React.FC<AzureQueryEditorFieldProps> = ({ query, datasource, o
}
useEffect(() => {
if (!query.azureLogAnalytics.resource) {
return;
}
const promises = [
datasource.azureLogAnalyticsDatasource.getKustoSchema(query.azureLogAnalytics.workspace),
datasource.azureLogAnalyticsDatasource.getKustoSchema(query.azureLogAnalytics.resource),
getPromise(),
] as const;
@@ -50,7 +54,7 @@ const QueryField: React.FC<AzureQueryEditorFieldProps> = ({ query, datasource, o
});
});
});
}, [datasource.azureLogAnalyticsDatasource, query.azureLogAnalytics.workspace]);
}, [datasource.azureLogAnalyticsDatasource, query.azureLogAnalytics.resource]);
const handleEditorMount = useCallback((editor: MonacoEditor, monaco: Monaco) => {
monacoPromiseRef.current?.resolve?.({ editor, monaco });

View File

@@ -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;

View File

@@ -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]);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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),
}),
});

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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 };
}

View File

@@ -2,6 +2,7 @@ import { cloneDeep, upperFirst } from 'lodash';
import AzureMonitorDatasource from './azure_monitor/azure_monitor_datasource';
import AppInsightsDatasource from './app_insights/app_insights_datasource';
import AzureLogAnalyticsDatasource from './azure_log_analytics/azure_log_analytics_datasource';
import ResourcePickerData from './resourcePicker/resourcePickerData';
import { AzureDataSourceJsonData, AzureMonitorQuery, AzureQueryType, InsightsAnalyticsQuery } from './types';
import {
DataFrame,
@@ -24,6 +25,7 @@ export default class Datasource extends DataSourceApi<AzureMonitorQuery, AzureDa
appInsightsDatasource: AppInsightsDatasource;
azureLogAnalyticsDatasource: AzureLogAnalyticsDatasource;
insightsAnalyticsDatasource: InsightsAnalyticsDatasource;
resourcePickerData: ResourcePickerData;
azureResourceGraphDatasource: AzureResourceGraphDatasource;
pseudoDatasource: Record<AzureQueryType, DataSourceWithBackend>;
@@ -39,6 +41,7 @@ export default class Datasource extends DataSourceApi<AzureMonitorQuery, AzureDa
this.azureLogAnalyticsDatasource = new AzureLogAnalyticsDatasource(instanceSettings);
this.insightsAnalyticsDatasource = new InsightsAnalyticsDatasource(instanceSettings);
this.azureResourceGraphDatasource = new AzureResourceGraphDatasource(instanceSettings);
this.resourcePickerData = new ResourcePickerData(instanceSettings);
const pseudoDatasource: any = {};
pseudoDatasource[AzureQueryType.ApplicationInsights] = this.appInsightsDatasource;

View File

@@ -183,6 +183,57 @@
{ "name": "x-ms-app", "content": "Grafana" }
]
},
{
"path": "loganalytics-resourcepickerdata",
"method": "POST",
"url": "https://management.azure.com",
"authType": "azure",
"tokenAuth": {
"scopes": ["https://management.azure.com/.default"],
"params": {
"azure_auth_type": "{{.JsonData.azureAuthType | orEmpty}}",
"azure_cloud": "AzureCloud",
"tenant_id": "{{.JsonData.logAnalyticsTenantId | orEmpty}}",
"client_id": "{{.JsonData.logAnalyticsClientId | orEmpty}}",
"client_secret": "{{.SecureJsonData.logAnalyticsClientSecret | orEmpty}}"
}
},
"headers": [{ "name": "x-ms-app", "content": "Grafana" }]
},
{
"path": "loganalytics-resourcepickerdata-china",
"method": "POST",
"url": "https://management.chinacloudapi.cn",
"authType": "azure",
"tokenAuth": {
"scopes": ["https://management.chinacloudapi.cn/.default"],
"params": {
"azure_auth_type": "{{.JsonData.azureAuthType | orEmpty}}",
"azure_cloud": "AzureChinaCloud",
"tenant_id": "{{.JsonData.logAnalyticsTenantId | orEmpty}}",
"client_id": "{{.JsonData.logAnalyticsClientId | orEmpty}}",
"client_secret": "{{.SecureJsonData.logAnalyticsClientSecret | orEmpty}}"
}
},
"headers": [{ "name": "x-ms-app", "content": "Grafana" }]
},
{
"path": "loganalytics-resourcepickerdata-gov",
"method": "POST",
"url": "https://management.usgovcloudapi.net",
"authType": "azure",
"tokenAuth": {
"scopes": ["https://management.usgovcloudapi.net/.default"],
"params": {
"azure_auth_type": "{{.JsonData.azureAuthType | orEmpty}}",
"azure_cloud": "AzureUSGovernment",
"tenant_id": "{{.JsonData.logAnalyticsTenantId | orEmpty}}",
"client_id": "{{.JsonData.logAnalyticsClientId | orEmpty}}",
"client_secret": "{{.SecureJsonData.logAnalyticsClientSecret | orEmpty}}"
}
},
"headers": [{ "name": "x-ms-app", "content": "Grafana" }]
},
{
"path": "workspacesloganalytics",
"method": "GET",
@@ -237,7 +288,7 @@
{
"path": "loganalyticsazure",
"method": "GET",
"url": "https://api.loganalytics.io/v1/workspaces",
"url": "https://api.loganalytics.io/",
"authType": "azure",
"tokenAuth": {
"scopes": ["https://api.loganalytics.io/.default"],
@@ -257,7 +308,7 @@
{
"path": "chinaloganalyticsazure",
"method": "GET",
"url": "https://api.loganalytics.azure.cn/v1/workspaces",
"url": "https://api.loganalytics.azure.cn/",
"authType": "azure",
"tokenAuth": {
"scopes": ["https://api.loganalytics.azure.cn/.default"],
@@ -277,7 +328,7 @@
{
"path": "govloganalyticsazure",
"method": "GET",
"url": "https://api.loganalytics.us/v1/workspaces",
"url": "https://api.loganalytics.us/",
"authType": "azure",
"tokenAuth": {
"scopes": ["https://api.loganalytics.us/.default"],

View File

@@ -34,9 +34,9 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
reactQueryEditors = [
AzureQueryType.AzureMonitor,
AzureQueryType.LogAnalytics,
AzureQueryType.ApplicationInsights,
AzureQueryType.InsightsAnalytics,
AzureQueryType.AzureResourceGraph,
// AzureQueryType.ApplicationInsights,
// AzureQueryType.InsightsAnalytics,
];
// target: AzureMonitorQuery;

View File

@@ -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;
}
}
}

View File

@@ -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(',');

View File

@@ -91,7 +91,10 @@ export interface AzureMetricQuery {
export interface AzureLogsQuery {
query: string;
resultFormat: string;
workspace: string;
resource?: string;
/** @deprecated Queries should be migrated to use Resource instead */
workspace?: string;
}
export interface AzureResourceGraphQuery {
@@ -193,3 +196,10 @@ export interface AzureQueryEditorFieldProps {
onQueryChange: (newQuery: AzureMonitorQuery) => void;
setError: (source: string, error: AzureMonitorErrorish | undefined) => void;
}
export interface AzureResourceSummaryItem {
id: string;
name: string;
subscriptionName: string;
resourceGroupName: string;
}