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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1311 additions and 315 deletions

View File

@ -255,6 +255,7 @@
"file-saver": "2.0.2",
"history": "4.10.1",
"hoist-non-react-statics": "3.3.2",
"immer": "8.0.0",
"immutable": "3.8.2",
"is-hotkey": "0.1.6",
"jquery": "3.5.1",

View File

@ -52,7 +52,8 @@ const IconWrapper = ({ name }: { name: IconName }) => {
);
};
const icons = getAvailableIcons().sort((a, b) => a.localeCompare(b));
const icons = [...getAvailableIcons()];
icons.sort((a, b) => a.localeCompare(b));
export const IconsOverview = () => {
const [filter, setFilter] = useState('');

View File

@ -2,280 +2,144 @@ import { ComponentSize } from './size';
export type IconType = 'mono' | 'default';
export type IconSize = ComponentSize | 'xl' | 'xxl' | 'xxxl';
export type IconName =
| 'angle-double-down'
| 'angle-double-right'
| 'angle-down'
| 'angle-left'
| 'angle-right'
| 'angle-up'
| 'apps'
| 'arrow-down'
| 'arrow-from-right'
| 'arrow-left'
| 'arrow-random'
| 'arrow-right'
| 'arrow-up'
| 'arrow'
| 'arrows-h'
| 'bars'
| 'bell-slash'
| 'bell'
| 'bolt'
| 'book-open'
| 'book'
| 'brackets-curly'
| 'bug'
| 'calculator-alt'
| 'calendar-alt'
| 'camera'
| 'channel-add'
| 'chart-line'
| 'check-circle'
| 'check'
| 'circle'
| 'clipboard-alt'
| 'clock-nine'
| 'cloud-download'
| 'cloud-upload'
| 'cloud'
| 'code-branch'
| 'cog'
| 'columns'
| 'comment-alt'
| 'comments-alt'
| 'compass'
| 'copy'
| 'cube'
| 'database'
| 'document-info'
| 'download-alt'
| 'draggabledots'
| 'edit'
| 'ellipsis-v'
| 'envelope'
| 'exchange-alt'
| 'exclamation-triangle'
| 'exclamation'
| 'external-link-alt'
| 'eye-slash'
| 'eye'
| 'fa fa-spinner'
| 'favorite'
| 'file-alt'
| 'file-blank'
| 'file-copy-alt'
| 'filter'
| 'folder-open'
| 'folder-plus'
| 'folder-upload'
| 'folder'
| 'forward'
| 'gf-interpolation-linear'
| 'gf-interpolation-smooth'
| 'gf-interpolation-step-after'
| 'gf-interpolation-step-before'
| 'gf-logs'
| 'github'
| 'gitlab'
| 'grafana'
| 'graph-bar'
| 'google'
| 'heart-break'
| 'heart'
| 'history'
| 'home-alt'
| 'import'
| 'info-circle'
| 'key-skeleton-alt'
| 'keyboard'
| 'line-alt'
| 'link'
| 'list-ul'
| 'lock'
| 'microsoft'
| 'minus-circle'
| 'minus'
| 'mobile-android'
| 'monitor'
| 'okta'
| 'palette'
| 'panel-add'
| 'pause'
| 'pen'
| 'percentage'
| 'play'
| 'plug'
| 'plus-circle'
| 'plus-square'
| 'plus'
| 'power'
| 'question-circle'
| 'repeat'
| 'library-panel'
| 'rocket'
| 'save'
| 'search-minus'
| 'search-plus'
| 'search'
| 'share-alt'
| 'shield'
| 'shield-exclamation'
| 'sign-in-alt'
| 'signal'
| 'signin'
| 'signout'
| 'slack'
| 'sliders-v-alt'
| 'sort-amount-down'
| 'square-shape'
| 'star'
| 'step-backward'
| 'sync'
| 'table'
| 'tag-alt'
| 'times'
| 'trash-alt'
| 'unlock'
| 'upload'
| 'user'
| 'users-alt'
| 'wrap-text'
| 'heart-rate'
| 'x';
export const getAvailableIcons = () =>
[
'angle-double-down',
'angle-double-right',
'angle-down',
'angle-left',
'angle-right',
'angle-up',
'apps',
'arrow-down',
'arrow-from-right',
'arrow-left',
'arrow-random',
'arrow-right',
'arrow-up',
'arrow',
'arrows-h',
'bars',
'bell-slash',
'bell',
'bolt',
'book-open',
'book',
'brackets-curly',
'bug',
'calculator-alt',
'calendar-alt',
'camera',
'channel-add',
'chart-line',
'check-circle',
'check',
'circle',
'clipboard-alt',
'clock-nine',
'cloud-download',
'cloud-upload',
'cloud',
'code-branch',
'cog',
'columns',
'comment-alt',
'comments-alt',
'compass',
'copy',
'cube',
'database',
'document-info',
'download-alt',
'draggabledots',
'edit',
'ellipsis-v',
'envelope',
'exchange-alt',
'exclamation-triangle',
'external-link-alt',
'eye-slash',
'eye',
'fa fa-spinner',
'favorite',
'file-alt',
'file-blank',
'file-copy-alt',
'filter',
'folder-open',
'folder-plus',
'folder-upload',
'folder',
'forward',
'gf-interpolation-linear',
'gf-interpolation-smooth',
'gf-interpolation-step-after',
'gf-interpolation-step-before',
'gf-logs',
'grafana',
'graph-bar',
'heart-break',
'heart',
'history',
'home-alt',
'import',
'info-circle',
'key-skeleton-alt',
'keyboard',
'layer-group',
'line-alt',
'link',
'list-ul',
'lock',
'minus-circle',
'minus',
'mobile-android',
'monitor',
'palette',
'panel-add',
'pause',
'pen',
'percentage',
'play',
'plug',
'plus-circle',
'plus-square',
'plus',
'power',
'question-circle',
'repeat',
'library-panel',
'rocket',
'save',
'search-minus',
'search-plus',
'search',
'share-alt',
'shield',
'shield-exclamation',
'sign-in-alt',
'signal',
'signin',
'signout',
'slack',
'sliders-v-alt',
'sort-amount-down',
'square-shape',
'star',
'step-backward',
'sync',
'table',
'tag-alt',
'times',
'trash-alt',
'unlock',
'upload',
'user',
'users-alt',
'wrap-text',
'x',
] as const;
export const getAvailableIcons = (): IconName[] => [
'angle-double-down',
'angle-double-right',
'angle-down',
'angle-left',
'angle-right',
'angle-up',
'apps',
'arrow-down',
'arrow-from-right',
'arrow-left',
'arrow-random',
'arrow-right',
'arrow-up',
'arrow',
'arrows-h',
'bars',
'bell-slash',
'bell',
'bolt',
'book-open',
'book',
'brackets-curly',
'bug',
'calculator-alt',
'calendar-alt',
'camera',
'channel-add',
'chart-line',
'check-circle',
'check',
'circle',
'clipboard-alt',
'clock-nine',
'cloud-download',
'cloud-upload',
'cloud',
'code-branch',
'cog',
'columns',
'comment-alt',
'comments-alt',
'compass',
'copy',
'cube',
'database',
'document-info',
'download-alt',
'draggabledots',
'edit',
'ellipsis-v',
'envelope',
'exchange-alt',
'exclamation-triangle',
'external-link-alt',
'eye-slash',
'eye',
'fa fa-spinner',
'favorite',
'file-alt',
'file-blank',
'file-copy-alt',
'filter',
'folder-open',
'folder-plus',
'folder-upload',
'folder',
'forward',
'gf-interpolation-linear',
'gf-interpolation-smooth',
'gf-interpolation-step-after',
'gf-interpolation-step-before',
'gf-logs',
'grafana',
'graph-bar',
'heart-break',
'heart',
'history',
'home-alt',
'import',
'info-circle',
'key-skeleton-alt',
'keyboard',
'line-alt',
'link',
'list-ul',
'lock',
'minus-circle',
'minus',
'mobile-android',
'monitor',
'palette',
'panel-add',
'pause',
'pen',
'percentage',
'play',
'plug',
'plus-circle',
'plus-square',
'plus',
'power',
'question-circle',
'repeat',
'library-panel',
'rocket',
'save',
'search-minus',
'search-plus',
'search',
'share-alt',
'shield',
'shield-exclamation',
'sign-in-alt',
'signal',
'signin',
'signout',
'slack',
'sliders-v-alt',
'sort-amount-down',
'square-shape',
'star',
'step-backward',
'sync',
'table',
'tag-alt',
'times',
'trash-alt',
'unlock',
'upload',
'user',
'users-alt',
'wrap-text',
'x',
];
type BrandIconNames = 'google' | 'microsoft' | 'github' | 'gitlab' | 'okta';
export type IconName = ReturnType<typeof getAvailableIcons>[number] | BrandIconNames;

View File

@ -89,9 +89,13 @@ func (e *AzureLogAnalyticsDatasource) buildQueries(queries []plugins.DataSubQuer
resultFormat = timeSeries
}
urlComponents := map[string]string{}
urlComponents["workspace"] = azureLogAnalyticsTarget.Workspace
apiURL := fmt.Sprintf("%s/query", urlComponents["workspace"])
// Handle legacy queries without a Resource
var apiURL string
if azureLogAnalyticsTarget.Resource != "" {
apiURL = fmt.Sprintf("v1%s/query", azureLogAnalyticsTarget.Resource)
} else {
apiURL = fmt.Sprintf("v1/workspaces/%s/query", azureLogAnalyticsTarget.Workspace)
}
params := url.Values{}
rawQuery, err := KqlInterpolate(query, timeRange, azureLogAnalyticsTarget.Query, "TimeGenerated")

View File

@ -32,6 +32,45 @@ func TestBuildingAzureLogAnalyticsQueries(t *testing.T) {
From: fmt.Sprintf("%v", fromStart.Unix()*1000),
To: fmt.Sprintf("%v", fromStart.Add(34*time.Minute).Unix()*1000),
},
queryModel: []plugins.DataSubQuery{
{
DataSource: &models.DataSource{
JsonData: simplejson.NewFromAny(map[string]interface{}{}),
},
Model: simplejson.NewFromAny(map[string]interface{}{
"queryType": "Azure Log Analytics",
"azureLogAnalytics": map[string]interface{}{
"resource": "/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/resourceGroups/cloud-datasources/providers/Microsoft.OperationalInsights/workspaces/AppInsightsTestDataWorkspace",
"query": "query=Perf | where $__timeFilter() | where $__contains(Computer, 'comp1','comp2') | summarize avg(CounterValue) by bin(TimeGenerated, $__interval), Computer",
"resultFormat": timeSeries,
},
}),
RefID: "A",
},
},
azureLogAnalyticsQueries: []*AzureLogAnalyticsQuery{
{
RefID: "A",
ResultFormat: timeSeries,
URL: "v1/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/resourceGroups/cloud-datasources/providers/Microsoft.OperationalInsights/workspaces/AppInsightsTestDataWorkspace/query",
Model: simplejson.NewFromAny(map[string]interface{}{
"azureLogAnalytics": map[string]interface{}{
"query": "query=Perf | where $__timeFilter() | where $__contains(Computer, 'comp1','comp2') | summarize avg(CounterValue) by bin(TimeGenerated, $__interval), Computer",
"resultFormat": timeSeries,
"workspace": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
},
}),
Params: url.Values{"query": {"query=Perf | where ['TimeGenerated'] >= datetime('2018-03-15T13:00:00Z') and ['TimeGenerated'] <= datetime('2018-03-15T13:34:00Z') | where ['Computer'] in ('comp1','comp2') | summarize avg(CounterValue) by bin(TimeGenerated, 34000ms), Computer"}},
Target: "query=query%3DPerf+%7C+where+%5B%27TimeGenerated%27%5D+%3E%3D+datetime%28%272018-03-15T13%3A00%3A00Z%27%29+and+%5B%27TimeGenerated%27%5D+%3C%3D+datetime%28%272018-03-15T13%3A34%3A00Z%27%29+%7C+where+%5B%27Computer%27%5D+in+%28%27comp1%27%2C%27comp2%27%29+%7C+summarize+avg%28CounterValue%29+by+bin%28TimeGenerated%2C+34000ms%29%2C+Computer",
},
},
Err: require.NoError,
}, {
name: "Legacy workspace queries should use workspace query endpoint",
timeRange: plugins.DataTimeRange{
From: fmt.Sprintf("%v", fromStart.Unix()*1000),
To: fmt.Sprintf("%v", fromStart.Add(34*time.Minute).Unix()*1000),
},
queryModel: []plugins.DataSubQuery{
{
DataSource: &models.DataSource{
@ -52,7 +91,7 @@ func TestBuildingAzureLogAnalyticsQueries(t *testing.T) {
{
RefID: "A",
ResultFormat: timeSeries,
URL: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/query",
URL: "v1/workspaces/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/query",
Model: simplejson.NewFromAny(map[string]interface{}{
"azureLogAnalytics": map[string]interface{}{
"query": "query=Perf | where $__timeFilter() | where $__contains(Computer, 'comp1','comp2') | summarize avg(CounterValue) by bin(TimeGenerated, $__interval), Computer",
@ -92,7 +131,7 @@ func TestPluginRoutes(t *testing.T) {
{
Path: "loganalyticsazure",
Method: "GET",
URL: "https://api.loganalytics.io/v1/workspaces",
URL: "https://api.loganalytics.io/",
Headers: []plugins.AppPluginRouteHeader{
{Name: "x-ms-app", Content: "Grafana"},
},
@ -100,7 +139,7 @@ func TestPluginRoutes(t *testing.T) {
{
Path: "chinaloganalyticsazure",
Method: "GET",
URL: "https://api.loganalytics.azure.cn/v1/workspaces",
URL: "https://api.loganalytics.azure.cn/",
Headers: []plugins.AppPluginRouteHeader{
{Name: "x-ms-app", Content: "Grafana"},
},
@ -108,7 +147,7 @@ func TestPluginRoutes(t *testing.T) {
{
Path: "govloganalyticsazure",
Method: "GET",
URL: "https://api.loganalytics.us/v1/workspaces",
URL: "https://api.loganalytics.us/",
Headers: []plugins.AppPluginRouteHeader{
{Name: "x-ms-app", Content: "Grafana"},
},
@ -135,7 +174,7 @@ func TestPluginRoutes(t *testing.T) {
},
},
expectedProxypass: "loganalyticsazure",
expectedRouteURL: "https://api.loganalytics.io/v1/workspaces",
expectedRouteURL: "https://api.loganalytics.io/",
Err: require.NoError,
},
{
@ -150,7 +189,7 @@ func TestPluginRoutes(t *testing.T) {
},
},
expectedProxypass: "chinaloganalyticsazure",
expectedRouteURL: "https://api.loganalytics.azure.cn/v1/workspaces",
expectedRouteURL: "https://api.loganalytics.azure.cn/",
Err: require.NoError,
},
{
@ -165,7 +204,7 @@ func TestPluginRoutes(t *testing.T) {
},
},
expectedProxypass: "govloganalyticsazure",
expectedRouteURL: "https://api.loganalytics.us/v1/workspaces",
expectedRouteURL: "https://api.loganalytics.us/",
Err: require.NoError,
},
}

View File

@ -138,7 +138,10 @@ type logJSONQuery struct {
AzureLogAnalytics struct {
Query string `json:"query"`
ResultFormat string `json:"resultFormat"`
Workspace string `json:"workspace"`
Resource string `json:"resource"`
// Deprecated: Queries should be migrated to use Resource instead
Workspace string `json:"workspace"`
} `json:"azureLogAnalytics"`
}

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

View File

@ -12304,6 +12304,11 @@ image-size@~0.5.0:
resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c"
integrity sha1-Cd/Uq50g4p6xw+gLiZA3jfnjy5w=
immer@8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/immer/-/immer-8.0.0.tgz#08763549ba9dd7d5e2eb4bec504a8315bd9440c2"
integrity sha512-jm87NNBAIG4fHwouilCHIecFXp5rMGkiFrAuhVO685UnMAlOneEAnOyzPt8OnP47TC11q/E7vpzZe0WvwepFTg==
immer@8.0.1, immer@^8.0.0:
version "8.0.1"
resolved "https://registry.yarnpkg.com/immer/-/immer-8.0.1.tgz#9c73db683e2b3975c424fb0572af5889877ae656"