AzureMonitor: Add Azure Resource Graph (#33293)

* Add Azure Resource Graph in Azure Plugin

* fix lodash import

* Fix mock queries

* use "backend" sdk

* Address comments

* add converter for object type

* Query error should cause 400 & apply template var

* fix backend test & add documentation

* update image

* Address comments

* marshal body from map

* use interpolated query instead of raw query

* fix test

* filter out empty queries

* fix go linting problem

* use new query field language name

* improve variable tests

* add better tests for interpolate variable

Co-authored-by: joshhunt <josh@trtr.co>
Co-authored-by: Erik Sundell <erik.sundell87@gmail.com>
This commit is contained in:
shuotli
2021-05-19 01:31:27 -07:00
committed by GitHub
parent 4e31169a43
commit 71fd0981ca
26 changed files with 934 additions and 25 deletions

View File

@@ -11,6 +11,11 @@ export default function createMockQuery(): AzureMonitorQuery {
workspace: 'e3fe4fde-ad5e-4d60-9974-e2f3562ffdf2',
},
azureResourceGraph: {
query: 'Resources | summarize count()',
resultFormat: 'time_series',
},
azureMonitor: {
// aggOptions: [],
aggregation: 'Average',
@@ -35,8 +40,8 @@ export default function createMockQuery(): AzureMonitorQuery {
queryType: AzureQueryType.AzureMonitor,
refId: 'A',
subscription: 'abc-123',
subscription: '99999999-cccc-bbbb-aaaa-9106972f9572',
subscriptions: ['99999999-cccc-bbbb-aaaa-9106972f9572'],
format: 'dunno lol', // unsure what this value should be. It's not there at runtime, but it's in the ts interface
};
}

View File

@@ -0,0 +1,149 @@
import { TemplateSrv } from 'app/features/templating/template_srv';
import { backendSrv } from 'app/core/services/backend_srv';
import AzureResourceGraphDatasource from './azure_resource_graph_datasource';
import { CustomVariableModel, initialVariableModelState, VariableHide } from 'app/features/variables/types';
import { initialCustomVariableModelState } from 'app/features/variables/custom/reducer';
const templateSrv = new TemplateSrv();
const single: CustomVariableModel = {
...initialVariableModelState,
id: 'var1',
name: 'var1',
index: 0,
current: { value: 'var1-foo', text: 'var1-foo', selected: true },
options: [{ value: 'var1-foo', text: 'var1-foo', selected: true }],
multi: false,
includeAll: false,
query: '',
hide: VariableHide.dontHide,
type: 'custom',
};
const multi: CustomVariableModel = {
...initialVariableModelState,
id: 'var3',
name: 'var3',
index: 2,
current: { value: ['var3-foo', 'var3-baz'], text: 'var3-foo + var3-baz', selected: true },
options: [
{ selected: true, value: 'var3-foo', text: 'var3-foo' },
{ selected: false, value: 'var3-bar', text: 'var3-bar' },
{ selected: true, value: 'var3-baz', text: 'var3-baz' },
],
multi: true,
includeAll: false,
query: '',
hide: VariableHide.dontHide,
type: 'custom',
};
templateSrv.init([single, multi]);
jest.mock('app/core/services/backend_srv');
jest.mock('@grafana/runtime', () => ({
...((jest.requireActual('@grafana/runtime') as unknown) as object),
getBackendSrv: () => backendSrv,
getTemplateSrv: () => templateSrv,
}));
describe('AzureResourceGraphDatasource', () => {
const datasourceRequestMock = jest.spyOn(backendSrv, 'datasourceRequest');
beforeEach(() => {
jest.clearAllMocks();
datasourceRequestMock.mockImplementation(jest.fn());
});
const ctx: any = {};
beforeEach(() => {
ctx.instanceSettings = {
url: 'http://azureresourcegraphapi',
};
ctx.ds = new AzureResourceGraphDatasource(ctx.instanceSettings);
});
describe('When applying template variables', () => {
it('should expand single value template variable', () => {
const target = {
azureResourceGraph: {
query: 'Resources | $var1',
resultFormat: '',
},
};
expect(ctx.ds.applyTemplateVariables(target)).toStrictEqual({
azureResourceGraph: { query: 'Resources | var1-foo', resultFormat: 'table' },
format: undefined,
queryType: 'Azure Resource Graph',
refId: undefined,
subscriptions: undefined,
});
});
it('should expand multi value template variable', () => {
const target = {
azureResourceGraph: {
query: 'resources | where $__contains(name, $var3)',
resultFormat: '',
},
};
expect(ctx.ds.applyTemplateVariables(target)).toStrictEqual({
azureResourceGraph: {
query: `resources | where $__contains(name, 'var3-foo','var3-baz')`,
resultFormat: 'table',
},
format: undefined,
queryType: 'Azure Resource Graph',
refId: undefined,
subscriptions: undefined,
});
});
});
describe('When interpolating variables', () => {
beforeEach(() => {
ctx.variable = { ...initialCustomVariableModelState };
});
describe('and value is a string', () => {
it('should return an unquoted value', () => {
expect(ctx.ds.interpolateVariable('abc', ctx.variable)).toEqual('abc');
});
});
describe('and value is a number', () => {
it('should return an unquoted value', () => {
expect(ctx.ds.interpolateVariable(1000, ctx.variable)).toEqual(1000);
});
});
describe('and value is an array of strings', () => {
it('should return comma separated quoted values', () => {
expect(ctx.ds.interpolateVariable(['a', 'b', 'c'], ctx.variable)).toEqual("'a','b','c'");
});
});
describe('and variable allows multi-value and value is a string', () => {
it('should return a quoted value', () => {
ctx.variable.multi = true;
expect(ctx.ds.interpolateVariable('abc', ctx.variable)).toEqual("'abc'");
});
});
describe('and variable contains single quote', () => {
it('should return a quoted value', () => {
ctx.variable.multi = true;
expect(ctx.ds.interpolateVariable("a'bc", ctx.variable)).toEqual("'a'bc'");
});
});
describe('and variable allows all and value is a string', () => {
it('should return a quoted value', () => {
ctx.variable.includeAll = true;
expect(ctx.ds.interpolateVariable('abc', ctx.variable)).toEqual("'abc'");
});
});
});
});

View File

@@ -0,0 +1,56 @@
// eslint-disable-next-line lodash/import-scope
import _ from 'lodash';
import { AzureMonitorQuery, AzureDataSourceJsonData, AzureQueryType } from '../types';
import { ScopedVars } from '@grafana/data';
import { getTemplateSrv, DataSourceWithBackend } from '@grafana/runtime';
export default class AzureResourceGraphDatasource extends DataSourceWithBackend<
AzureMonitorQuery,
AzureDataSourceJsonData
> {
filterQuery(item: AzureMonitorQuery): boolean {
return !!item.azureResourceGraph?.query;
}
applyTemplateVariables(target: AzureMonitorQuery, scopedVars: ScopedVars): Record<string, any> {
const item = target.azureResourceGraph;
const templateSrv = getTemplateSrv();
const query = templateSrv.replace(item.query, scopedVars, this.interpolateVariable);
return {
refId: target.refId,
format: target.format,
queryType: AzureQueryType.AzureResourceGraph,
subscriptions: target.subscriptions,
azureResourceGraph: {
resultFormat: 'table',
query,
},
};
}
interpolateVariable(value: string, variable: { multi: any; includeAll: any }) {
if (typeof value === 'string') {
if (variable.multi || variable.includeAll) {
return "'" + value + "'";
} else {
return value;
}
}
if (typeof value === 'number') {
return value;
}
const quotedValues = _.map(value, (val) => {
if (typeof value === 'number') {
return value;
}
return "'" + val + "'";
});
return quotedValues.join(',');
}
}

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { AzureMonitorErrorish, AzureMonitorOption, AzureMonitorQuery } from '../../types';
import Datasource from '../../datasource';
import { InlineFieldRow } from '@grafana/ui';
import SubscriptionField from '../SubscriptionField';
import QueryField from './QueryField';
interface LogsQueryEditorProps {
query: AzureMonitorQuery;
datasource: Datasource;
subscriptionId: string;
onChange: (newQuery: AzureMonitorQuery) => void;
variableOptionGroup: { label: string; options: AzureMonitorOption[] };
setError: (source: string, error: AzureMonitorErrorish | undefined) => void;
}
const ArgQueryEditor: React.FC<LogsQueryEditorProps> = ({
query,
datasource,
subscriptionId,
variableOptionGroup,
onChange,
setError,
}) => {
return (
<div data-testid="azure-monitor-logs-query-editor">
<InlineFieldRow>
<SubscriptionField
multiSelect
query={query}
datasource={datasource}
subscriptionId={subscriptionId}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
setError={setError}
/>
</InlineFieldRow>
<QueryField
query={query}
datasource={datasource}
subscriptionId={subscriptionId}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
setError={setError}
/>
</div>
);
};
export default ArgQueryEditor;

View File

@@ -0,0 +1,32 @@
import { CodeEditor } from '@grafana/ui';
import React, { useCallback } from 'react';
import { AzureQueryEditorFieldProps } from '../../types';
const QueryField: React.FC<AzureQueryEditorFieldProps> = ({ query, onQueryChange }) => {
const onChange = useCallback(
(newQuery: string) => {
onQueryChange({
...query,
azureResourceGraph: {
...query.azureResourceGraph,
query: newQuery,
},
});
},
[onQueryChange, query]
);
return (
<CodeEditor
value={query.azureResourceGraph.query}
language="kusto"
height={200}
width={1000}
showMiniMap={false}
onBlur={onChange}
onSave={onChange}
/>
);
};
export default QueryField;

View File

@@ -0,0 +1 @@
export { default } from './ArgQueryEditor';

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useMemo } from 'react';
import { SelectableValue } from '@grafana/data';
import { Select } from '@grafana/ui';
import React, { useCallback, useMemo } from 'react';
import { AzureMonitorOption, AzureQueryEditorFieldProps, AzureResultFormat } from '../../types';
import { findOption } from '../../utils/common';
import { Field } from '../Field';

View File

@@ -6,6 +6,7 @@ import MetricsQueryEditor from '../MetricsQueryEditor';
import QueryTypeField from './QueryTypeField';
import useLastError from '../../utils/useLastError';
import LogsQueryEditor from '../LogsQueryEditor';
import ArgQueryEditor from '../ArgQueryEditor';
import ApplicationInsightsEditor from '../ApplicationInsightsEditor';
import InsightsAnalyticsEditor from '../InsightsAnalyticsEditor';
import { Space } from '../Space';
@@ -93,6 +94,18 @@ const EditorForQueryType: React.FC<EditorForQueryTypeProps> = ({
case AzureQueryType.InsightsAnalytics:
return <InsightsAnalyticsEditor query={query} />;
case AzureQueryType.AzureResourceGraph:
return (
<ArgQueryEditor
subscriptionId={subscriptionId}
query={query}
datasource={datasource}
onChange={onChange}
variableOptionGroup={variableOptionGroup}
setError={setError}
/>
);
}
return null;

View File

@@ -10,6 +10,7 @@ const QUERY_TYPES = [
{ value: AzureQueryType.LogAnalytics, label: 'Logs' },
{ value: AzureQueryType.ApplicationInsights, label: 'Application Insights' },
{ value: AzureQueryType.InsightsAnalytics, label: 'Insights Analytics' },
{ value: AzureQueryType.AzureResourceGraph, label: 'Azure Resource Graph' },
];
interface QueryTypeFieldProps {

View File

@@ -1,13 +1,14 @@
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import { SelectableValue } from '@grafana/data';
import { Select } from '@grafana/ui';
import { Select, MultiSelect } from '@grafana/ui';
import { AzureMonitorQuery, AzureQueryType, AzureQueryEditorFieldProps, AzureMonitorOption } from '../types';
import { findOption } from '../utils/common';
import { findOption, findOptions } from '../utils/common';
import { Field } from './Field';
interface SubscriptionFieldProps extends AzureQueryEditorFieldProps {
onQueryChange: (newQuery: AzureMonitorQuery) => void;
multiSelect?: boolean;
}
const ERROR_SOURCE = 'metrics-subscription';
@@ -17,6 +18,7 @@ const SubscriptionField: React.FC<SubscriptionFieldProps> = ({
variableOptionGroup,
onQueryChange,
setError,
multiSelect = false,
}) => {
const [subscriptions, setSubscriptions] = useState<AzureMonitorOption[]>([]);
@@ -92,9 +94,33 @@ const SubscriptionField: React.FC<SubscriptionFieldProps> = ({
[query, onQueryChange]
);
const onSubscriptionsChange = useCallback(
(change: Array<SelectableValue<string>>) => {
if (!change) {
return;
}
query.subscriptions = change.map((c) => c.value ?? '');
onQueryChange(query);
},
[query, onQueryChange]
);
const options = useMemo(() => [...subscriptions, variableOptionGroup], [subscriptions, variableOptionGroup]);
return (
return multiSelect ? (
<Field label="Subscriptions">
<MultiSelect
isClearable
value={findOptions(subscriptions, query.subscriptions)}
inputId="azure-monitor-subscriptions-field"
onChange={onSubscriptionsChange}
options={options}
width={38}
/>
</Field>
) : (
<Field label="Subscription">
<Select
value={findOption(subscriptions, query.subscription)}

View File

@@ -17,12 +17,14 @@ import { DataSourceWithBackend, getTemplateSrv, TemplateSrv } from '@grafana/run
import InsightsAnalyticsDatasource from './insights_analytics/insights_analytics_datasource';
import { migrateMetricsDimensionFilters } from './query_ctrl';
import { map } from 'rxjs/operators';
import AzureResourceGraphDatasource from './azure_resource_graph/azure_resource_graph_datasource';
export default class Datasource extends DataSourceApi<AzureMonitorQuery, AzureDataSourceJsonData> {
azureMonitorDatasource: AzureMonitorDatasource;
appInsightsDatasource: AppInsightsDatasource;
azureLogAnalyticsDatasource: AzureLogAnalyticsDatasource;
insightsAnalyticsDatasource: InsightsAnalyticsDatasource;
azureResourceGraphDatasource: AzureResourceGraphDatasource;
pseudoDatasource: Record<AzureQueryType, DataSourceWithBackend>;
optionsKey: Record<AzureQueryType, string>;
@@ -36,12 +38,14 @@ export default class Datasource extends DataSourceApi<AzureMonitorQuery, AzureDa
this.appInsightsDatasource = new AppInsightsDatasource(instanceSettings);
this.azureLogAnalyticsDatasource = new AzureLogAnalyticsDatasource(instanceSettings);
this.insightsAnalyticsDatasource = new InsightsAnalyticsDatasource(instanceSettings);
this.azureResourceGraphDatasource = new AzureResourceGraphDatasource(instanceSettings);
const pseudoDatasource: any = {};
pseudoDatasource[AzureQueryType.ApplicationInsights] = this.appInsightsDatasource;
pseudoDatasource[AzureQueryType.AzureMonitor] = this.azureMonitorDatasource;
pseudoDatasource[AzureQueryType.InsightsAnalytics] = this.insightsAnalyticsDatasource;
pseudoDatasource[AzureQueryType.LogAnalytics] = this.azureLogAnalyticsDatasource;
pseudoDatasource[AzureQueryType.AzureResourceGraph] = this.azureResourceGraphDatasource;
this.pseudoDatasource = pseudoDatasource;
const optionsKey: any = {};
@@ -49,6 +53,7 @@ export default class Datasource extends DataSourceApi<AzureMonitorQuery, AzureDa
optionsKey[AzureQueryType.AzureMonitor] = 'azureMonitor';
optionsKey[AzureQueryType.InsightsAnalytics] = 'insightsAnalytics';
optionsKey[AzureQueryType.LogAnalytics] = 'azureLogAnalytics';
optionsKey[AzureQueryType.AzureResourceGraph] = 'azureResourceGraph';
this.optionsKey = optionsKey;
}

View File

@@ -97,6 +97,74 @@
},
"headers": [{ "name": "x-ms-app", "content": "Grafana" }]
},
{
"path": "azureresourcegraph",
"method": "POST",
"url": "https://management.azure.com",
"authType": "azure",
"tokenAuth": {
"scopes": ["https://management.azure.com/.default"],
"params": {
"azure_auth_type": "{{.JsonData.azureAuthType}}",
"azure_cloud": "AzureCloud",
"tenant_id": "{{.JsonData.tenantId}}",
"client_id": "{{.JsonData.clientId}}",
"client_secret": "{{.SecureJsonData.clientSecret}}"
}
},
"headers": [{ "name": "x-ms-app", "content": "Grafana" }]
},
{
"path": "chinaazureresourcegraph",
"method": "POST",
"url": "https://management.azure.com",
"authType": "azure",
"tokenAuth": {
"scopes": ["https://management.chinacloudapi.cn/.default"],
"params": {
"azure_auth_type": "{{.JsonData.azureAuthType}}",
"azure_cloud": "AzureChinaCloud",
"tenant_id": "{{.JsonData.tenantId}}",
"client_id": "{{.JsonData.clientId}}",
"client_secret": "{{.SecureJsonData.clientSecret}}"
}
},
"headers": [{ "name": "x-ms-app", "content": "Grafana" }]
},
{
"path": "govazureresourcegraph",
"method": "POST",
"url": "https://management.usgovcloudapi.net",
"authType": "azure",
"tokenAuth": {
"scopes": ["https://management.usgovcloudapi.net/.default"],
"params": {
"azure_auth_type": "{{.JsonData.azureAuthType}}",
"azure_cloud": "AzureUSGovernment",
"tenant_id": "{{.JsonData.tenantId}}",
"client_id": "{{.JsonData.clientId}}",
"client_secret": "{{.SecureJsonData.clientSecret}}"
}
},
"headers": [{ "name": "x-ms-app", "content": "Grafana" }]
},
{
"path": "germanyazureresourcegraph",
"method": "POST",
"url": "https://management.microsoftazure.de",
"authType": "azure",
"tokenAuth": {
"scopes": ["https://management.microsoftazure.de/.default"],
"params": {
"azure_auth_type": "{{.JsonData.azureAuthType}}",
"azure_cloud": "AzureGermanCloud",
"tenant_id": "{{.JsonData.tenantId}}",
"client_id": "{{.JsonData.clientId}}",
"client_secret": "{{.SecureJsonData.clientSecret}}"
}
},
"headers": [{ "name": "x-ms-app", "content": "Grafana" }]
},
{
"path": "appinsights",
"method": "GET",

View File

@@ -27,12 +27,14 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
{ id: AzureQueryType.LogAnalytics, label: 'Logs' },
{ id: AzureQueryType.ApplicationInsights, label: 'Application Insights' },
{ id: AzureQueryType.InsightsAnalytics, label: 'Insights Analytics' },
{ id: AzureQueryType.AzureResourceGraph, label: 'Azure Resource Graph' },
];
// Query types that have been migrated to React
reactQueryEditors = [
AzureQueryType.AzureMonitor,
AzureQueryType.LogAnalytics,
AzureQueryType.AzureResourceGraph,
// AzureQueryType.ApplicationInsights,
// AzureQueryType.InsightsAnalytics,
];
@@ -44,12 +46,17 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
refId: string;
queryType: AzureQueryType;
subscription: string;
subscriptions: string[];
azureMonitor: AzureMetricQuery;
azureLogAnalytics: {
query: string;
resultFormat: string;
workspace: string;
};
azureResourceGraph: {
query: string;
resultFormat: string;
};
appInsights: {
// metric style query when rawQuery == false
metricName: string;
@@ -105,6 +112,9 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
? this.datasource.azureLogAnalyticsDatasource.defaultOrFirstWorkspace
: '',
},
azureResourceGraph: {
resultFormat: 'table',
},
appInsights: {
metricName: this.defaultDropdownValue,
// dimension: [],
@@ -327,6 +337,10 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
this.target.subscription = this.subscriptions[0].value;
}
if (!this.target.subscriptions) {
this.target.subscriptions = subscriptions.map((sub) => sub.value);
}
return this.subscriptions;
});
}

View File

@@ -17,17 +17,20 @@ export enum AzureQueryType {
ApplicationInsights = 'Application Insights',
InsightsAnalytics = 'Insights Analytics',
LogAnalytics = 'Azure Log Analytics',
AzureResourceGraph = 'Azure Resource Graph',
}
export interface AzureMonitorQuery extends DataQuery {
queryType: AzureQueryType;
format: string;
subscription: string;
subscriptions: string[];
azureMonitor: AzureMetricQuery;
azureLogAnalytics: AzureLogsQuery;
appInsights?: ApplicationInsightsQuery;
insightsAnalytics: InsightsAnalyticsQuery;
azureResourceGraph: AzureResourceGraphQuery;
}
export type ConcealedSecret = symbol;
@@ -91,6 +94,11 @@ export interface AzureLogsQuery {
workspace: string;
}
export interface AzureResourceGraphQuery {
query: string;
resultFormat: string;
}
export interface ApplicationInsightsQuery {
metricName: string;
timeGrain: string;

View File

@@ -6,6 +6,17 @@ import { AzureMonitorOption } from '../types';
export const findOption = (options: AzureMonitorOption[], value: string | undefined) =>
value ? options.find((v) => v.value === value) ?? { value, label: value } : null;
export const findOptions = (options: AzureMonitorOption[], values: string[] = []) => {
if (values.length === 0) {
return [];
}
const set = values.reduce((accum, item) => {
accum.add(item);
return accum;
}, new Set());
return options.filter((option) => set.has(option.value));
};
export const toOption = (v: { text: string; value: string }) => ({ value: v.value, label: v.text });
export function convertTimeGrainsToMs<T extends { value: string }>(timeGrains: T[]) {