AzureMonitor: Credentials UI logic encapsulation (#33620)

* Convert credentials form to a functional component

* Encapsulation of Azure credentials logic

* Fix selection of subscriptions

* Load subscriptions on init and save options only if they're unsaved

* Functions for resolution of routes

* Resolve routes of all API requests

* Reset default value if not found anymore

* Fix tests

* Symbol to conceal secret
This commit is contained in:
Sergey Kostrukov
2021-05-11 20:34:41 +01:00
committed by GitHub
parent 985a4dd2b7
commit 24b878ca4f
18 changed files with 840 additions and 815 deletions
@@ -0,0 +1,51 @@
export function getManagementApiRoute(azureCloud: string): string {
switch (azureCloud) {
case 'azuremonitor':
return 'azuremonitor';
case 'chinaazuremonitor':
return 'chinaazuremonitor';
case 'govazuremonitor':
return 'govazuremonitor';
case 'germanyazuremonitor':
return 'germanyazuremonitor';
default:
throw new Error('The cloud not supported.');
}
}
export function getLogAnalyticsManagementApiRoute(azureCloud: string): string {
switch (azureCloud) {
case 'azuremonitor':
return 'workspacesloganalytics';
case 'chinaazuremonitor':
return 'chinaworkspacesloganalytics';
case 'govazuremonitor':
return 'govworkspacesloganalytics';
default:
throw new Error('The cloud not supported.');
}
}
export function getLogAnalyticsApiRoute(azureCloud: string): string {
switch (azureCloud) {
case 'azuremonitor':
return 'loganalyticsazure';
case 'chinaazuremonitor':
return 'chinaloganalyticsazure';
case 'govazuremonitor':
return 'govloganalyticsazure';
default:
throw new Error('The cloud not supported.');
}
}
export function getAppInsightsApiRoute(azureCloud: string): string {
switch (azureCloud) {
case 'azuremonitor':
return 'appinsights';
case 'chinaazuremonitor':
return 'chinaappinsights';
default:
throw new Error('The cloud not supported.');
}
}
@@ -5,11 +5,14 @@ import { isString } from 'lodash';
import TimegrainConverter from '../time_grain_converter';
import { AzureDataSourceJsonData, AzureMonitorQuery, AzureQueryType } from '../types';
import ResponseParser from './response_parser';
import { getAzureCloud } from '../credentials';
import { getAppInsightsApiRoute } from '../api/routes';
export interface LogAnalyticsColumn {
text: string;
value: string;
}
export default class AppInsightsDatasource extends DataSourceWithBackend<AzureMonitorQuery, AzureDataSourceJsonData> {
url: string;
baseUrl: string;
@@ -21,21 +24,9 @@ export default class AppInsightsDatasource extends DataSourceWithBackend<AzureMo
super(instanceSettings);
this.applicationId = instanceSettings.jsonData.appInsightsAppId || '';
switch (instanceSettings.jsonData?.cloudName) {
// Azure US Government
case 'govazuremonitor':
break;
// Azure Germany
case 'germanyazuremonitor':
break;
// Azue China
case 'chinaazuremonitor':
this.baseUrl = `/chinaappinsights/${this.version}/apps/${this.applicationId}`;
break;
// Azure Global
default:
this.baseUrl = `/appinsights/${this.version}/apps/${this.applicationId}`;
}
const cloud = getAzureCloud(instanceSettings);
const route = getAppInsightsApiRoute(cloud);
this.baseUrl = `/${route}/${this.version}/apps/${this.applicationId}`;
this.url = instanceSettings.url || '';
}
@@ -85,7 +85,7 @@ describe('AzureLogAnalyticsDatasource', () => {
});
it('should use the loganalyticsazure plugin route', () => {
expect(workspacesUrl).toContain('azuremonitor');
expect(workspacesUrl).toContain('workspacesloganalytics');
expect(azureLogAnalyticsUrl).toContain('loganalyticsazure');
});
});
@@ -12,6 +12,8 @@ import {
import { getBackendSrv, getTemplateSrv, DataSourceWithBackend, FetchResponse } from '@grafana/runtime';
import { Observable, from } from 'rxjs';
import { mergeMap } from 'rxjs/operators';
import { getAzureCloud } from '../credentials';
import { getLogAnalyticsApiRoute, getLogAnalyticsManagementApiRoute } from '../api/routes';
import { AzureLogAnalyticsMetadata } from '../types/logAnalyticsMetadata';
export default class AzureLogAnalyticsDatasource extends DataSourceWithBackend<
@@ -22,11 +24,6 @@ export default class AzureLogAnalyticsDatasource extends DataSourceWithBackend<
baseUrl: string;
applicationId: string;
/**
* @deprecated
* TODO: Which one of these values should be used? Was there a migration?
* */
logAnalyticsSubscriptionId: string;
subscriptionId: string;
azureMonitorUrl: string;
@@ -37,56 +34,20 @@ export default class AzureLogAnalyticsDatasource extends DataSourceWithBackend<
super(instanceSettings);
this.cache = new Map();
switch (this.instanceSettings.jsonData.cloudName) {
case 'govazuremonitor': // Azure US Government
this.baseUrl = '/govloganalyticsazure';
break;
case 'germanyazuremonitor': // Azure Germany
break;
case 'chinaazuremonitor': // Azure China
this.baseUrl = '/chinaloganalyticsazure';
break;
default:
// Azure Global
this.baseUrl = '/loganalyticsazure';
}
const cloud = getAzureCloud(instanceSettings);
const logAnalyticsRoute = getLogAnalyticsApiRoute(cloud);
this.baseUrl = `/${logAnalyticsRoute}`;
const managementRoute = getLogAnalyticsManagementApiRoute(cloud);
this.azureMonitorUrl = `/${managementRoute}/subscriptions`;
this.url = instanceSettings.url || '';
this.subscriptionId = this.instanceSettings.jsonData.logAnalyticsSubscriptionId || '';
this.defaultOrFirstWorkspace = this.instanceSettings.jsonData.logAnalyticsDefaultWorkspace || '';
this.setWorkspaceUrl();
}
isConfigured(): boolean {
return (
(!!this.instanceSettings.jsonData.logAnalyticsSubscriptionId &&
this.instanceSettings.jsonData.logAnalyticsSubscriptionId.length > 0) ||
!!this.instanceSettings.jsonData.azureLogAnalyticsSameAs
);
}
setWorkspaceUrl() {
if (!!this.instanceSettings.jsonData.subscriptionId || !!this.instanceSettings.jsonData.azureLogAnalyticsSameAs) {
this.subscriptionId = this.instanceSettings.jsonData.subscriptionId;
const azureCloud = this.instanceSettings.jsonData.cloudName || 'azuremonitor';
this.azureMonitorUrl = `/${azureCloud}/subscriptions`;
} else {
this.subscriptionId = this.instanceSettings.jsonData.logAnalyticsSubscriptionId || '';
switch (this.instanceSettings.jsonData.cloudName) {
case 'govazuremonitor': // Azure US Government
this.azureMonitorUrl = `/govworkspacesloganalytics/subscriptions`;
break;
case 'germanyazuremonitor': // Azure Germany
break;
case 'chinaazuremonitor': // Azure China
this.azureMonitorUrl = `/chinaworkspacesloganalytics/subscriptions`;
break;
default:
// Azure Global
this.azureMonitorUrl = `/workspacesloganalytics/subscriptions`;
}
}
return !!this.subscriptionId && this.subscriptionId.length > 0;
}
async getWorkspaces(subscription: string): Promise<AzureLogsVariable[]> {
@@ -25,6 +25,8 @@ import { from, Observable } from 'rxjs';
import { mergeMap } from 'rxjs/operators';
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { getAzureCloud } from '../credentials';
import { getManagementApiRoute } from '../api/routes';
const defaultDropdownValue = 'select';
@@ -46,7 +48,6 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
resourceGroup: string;
resourceName: string;
url: string;
cloudName: string;
supportedMetricNamespaces: string[] = [];
timeSrv: TimeSrv;
@@ -55,10 +56,13 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
this.timeSrv = getTimeSrv();
this.subscriptionId = instanceSettings.jsonData.subscriptionId;
this.cloudName = instanceSettings.jsonData.cloudName || 'azuremonitor';
this.baseUrl = `/${this.cloudName}/subscriptions`;
const cloud = getAzureCloud(instanceSettings);
const route = getManagementApiRoute(cloud);
this.baseUrl = `/${route}/subscriptions`;
this.url = instanceSettings.url!;
this.supportedMetricNamespaces = new SupportedNamespaces(this.cloudName).get();
this.supportedMetricNamespaces = new SupportedNamespaces(cloud).get();
}
isConfigured(): boolean {
@@ -313,8 +317,8 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
return getTemplateSrv().replace((metric || '').trim());
}
getSubscriptions(route?: string) {
const url = `/${route || this.cloudName}/subscriptions?api-version=2019-03-01`;
getSubscriptions() {
const url = `${this.baseUrl}?api-version=2019-03-01`;
return this.doRequest(url).then((result: any) => {
return ResponseParser.parseSubscriptions(result);
});
@@ -469,7 +473,7 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
});
}
const url = `/${this.cloudName}/subscriptions?api-version=2019-03-01`;
const url = `${this.baseUrl}?api-version=2019-03-01`;
return this.doRequest(url)
.then((response: any) => {
if (response.status === 200) {
@@ -2,8 +2,8 @@ import React from 'react';
import { shallow } from 'enzyme';
import AnalyticsConfig, { Props } from './AnalyticsConfig';
const setup = (propOverrides?: object) => {
const props: Props = {
const setup = (propsFunc?: (props: Props) => Props) => {
let props: Props = {
options: {
id: 21,
uid: 'x',
@@ -38,18 +38,14 @@ const setup = (propOverrides?: object) => {
version: 1,
readOnly: false,
},
subscriptions: [],
workspaces: [],
makeSameAs: jest.fn(),
onUpdateDatasourceOptions: jest.fn(),
onUpdateJsonDataOption: jest.fn(),
onUpdateSecureJsonDataOption: jest.fn(),
onResetOptionKey: jest.fn(),
onLoadSubscriptions: jest.fn(),
onLoadWorkspaces: jest.fn(),
updateOptions: jest.fn(),
getSubscriptions: jest.fn(),
getWorkspaces: jest.fn(),
};
Object.assign(props, propOverrides);
if (propsFunc) {
props = propsFunc(props);
}
return shallow(<AnalyticsConfig {...props} />);
};
@@ -62,24 +58,35 @@ describe('Render', () => {
});
it('should disable log analytics credentials form', () => {
const wrapper = setup({
jsonData: {
azureLogAnalyticsSameAs: true,
const wrapper = setup((props) => ({
...props,
options: {
...props.options,
jsonData: {
...props.options.jsonData,
azureLogAnalyticsSameAs: true,
},
},
});
}));
expect(wrapper).toMatchSnapshot();
});
it('should enable azure log analytics load workspaces button', () => {
const wrapper = setup({
jsonData: {
logAnalyticsDefaultWorkspace: '',
logAnalyticsTenantId: 'e7f3f661-a933-4b3f-8176-51c4f982ec48',
logAnalyticsClientId: '44693801-6ee6-49de-9b2d-9106972f9572',
logAnalyticsSubscriptionId: 'e3fe4fde-ad5e-4d60-9974-e2f3562ffdf2',
logAnalyticsClientSecret: 'cddcc020-2c94-460a-a3d0-df3147ffa792',
const wrapper = setup((props) => ({
...props,
options: {
...props.options,
jsonData: {
...props.options.jsonData,
azureLogAnalyticsSameAs: false,
logAnalyticsDefaultWorkspace: '',
logAnalyticsTenantId: 'e7f3f661-a933-4b3f-8176-51c4f982ec48',
logAnalyticsClientId: '44693801-6ee6-49de-9b2d-9106972f9572',
logAnalyticsSubscriptionId: 'e3fe4fde-ad5e-4d60-9974-e2f3562ffdf2',
logAnalyticsClientSecret: 'cddcc020-2c94-460a-a3d0-df3147ffa792',
},
},
});
}));
expect(wrapper).toMatchSnapshot();
});
});
@@ -1,209 +1,195 @@
import React, { PureComponent, ChangeEvent } from 'react';
import React, { FunctionComponent, useEffect, useMemo, useReducer, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { AzureCredentialsForm } from './AzureCredentialsForm';
import { InlineFormLabel, LegacyForms, Button } from '@grafana/ui';
const { Select, Switch } = LegacyForms;
import { AzureDataSourceSettings } from '../types';
export interface State {
sameAsSwitched: boolean;
}
import { AzureDataSourceSettings, AzureCredentials } from '../types';
import {
getCredentials,
getLogAnalyticsCredentials,
isCredentialsComplete,
updateLogAnalyticsCredentials,
updateLogAnalyticsSameAs,
} from '../credentials';
export interface Props {
options: AzureDataSourceSettings;
subscriptions: SelectableValue[];
workspaces: SelectableValue[];
makeSameAs: () => void;
onUpdateDatasourceOptions: (options: AzureDataSourceSettings) => void;
onUpdateJsonDataOption: (key: string, val: any) => void;
onUpdateSecureJsonDataOption: (key: string, val: any) => void;
onResetOptionKey: (key: string) => void;
onLoadSubscriptions: (type?: string) => void;
onLoadWorkspaces: (type?: string) => void;
updateOptions: (optionsFunc: (options: AzureDataSourceSettings) => AzureDataSourceSettings) => void;
getSubscriptions: () => Promise<Array<SelectableValue<string>>>;
getWorkspaces: (subscriptionId: string) => Promise<Array<SelectableValue<string>>>;
}
export class AnalyticsConfig extends PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
sameAsSwitched: false,
export const AnalyticsConfig: FunctionComponent<Props> = (props: Props) => {
const { updateOptions, getSubscriptions, getWorkspaces } = props;
const primaryCredentials = useMemo(() => getCredentials(props.options), [props.options]);
const logAnalyticsCredentials = useMemo(() => getLogAnalyticsCredentials(props.options), [props.options]);
const subscriptionId = logAnalyticsCredentials
? props.options.jsonData.logAnalyticsSubscriptionId
: props.options.jsonData.subscriptionId;
const hasRequiredFields =
subscriptionId &&
(logAnalyticsCredentials
? isCredentialsComplete(logAnalyticsCredentials)
: isCredentialsComplete(primaryCredentials));
const defaultWorkspace = props.options.jsonData.logAnalyticsDefaultWorkspace;
const [workspaces, setWorkspaces] = useState<SelectableValue[]>([]);
const [loadWorkspaces, onLoadWorkspaces] = useReducer((val) => val + 1, 0);
useEffect(() => {
if (!hasRequiredFields || !subscriptionId) {
updateWorkspaces([]);
return;
}
let canceled = false;
getWorkspaces(subscriptionId).then((result) => {
if (!canceled) {
updateWorkspaces(result);
}
});
return () => {
canceled = true;
};
}
// This effect is intended to be called only once initially and on Load Workspaces click
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadWorkspaces, subscriptionId]);
onLogAnalyticsTenantIdChange = (event: ChangeEvent<HTMLInputElement>) => {
this.props.onUpdateJsonDataOption('logAnalyticsTenantId', event.target.value);
const updateWorkspaces = (received: Array<SelectableValue<string>>) => {
setWorkspaces(received);
if (!defaultWorkspace && received.length > 0) {
// Setting the default workspace if workspaces received but no default workspace selected
updateOptions((options) => {
return {
...options,
jsonData: {
...options.jsonData,
logAnalyticsDefaultWorkspace: received[0].value,
},
};
});
} else if (defaultWorkspace) {
const found = received.find((opt) => opt.value === defaultWorkspace);
if (!found) {
// Unsetting the default workspace if it isn't found among the received workspaces
updateOptions((options) => {
return {
...options,
jsonData: {
...options.jsonData,
logAnalyticsDefaultWorkspace: undefined,
},
};
});
}
}
};
onLogAnalyticsClientIdChange = (event: ChangeEvent<HTMLInputElement>) => {
this.props.onUpdateJsonDataOption('logAnalyticsClientId', event.target.value);
const [sameAsSwitched, setSameAsSwitched] = useState(false);
const onCredentialsChange = (updatedCredentials: AzureCredentials) => {
updateOptions((options) => updateLogAnalyticsCredentials(options, updatedCredentials));
};
onLogAnalyticsClientSecretChange = (event: ChangeEvent<HTMLInputElement>) => {
this.props.onUpdateSecureJsonDataOption('logAnalyticsClientSecret', event.target.value);
const onLogAnalyticsSameAsChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
const sameAs = event.currentTarget.checked;
updateOptions((options) => updateLogAnalyticsSameAs(options, sameAs));
setSameAsSwitched(true);
};
onLogAnalyticsSubscriptionSelect = (logAnalyticsSubscription: SelectableValue<string>) => {
this.props.onUpdateJsonDataOption('logAnalyticsSubscriptionId', logAnalyticsSubscription.value);
};
onWorkspaceSelectChange = (logAnalyticsDefaultWorkspace: SelectableValue<string>) => {
this.props.onUpdateJsonDataOption('logAnalyticsDefaultWorkspace', logAnalyticsDefaultWorkspace.value);
};
onAzureLogAnalyticsSameAsChange = () => {
const { options, onUpdateDatasourceOptions, makeSameAs } = this.props;
if (!options.jsonData.azureLogAnalyticsSameAs && options.secureJsonData!.clientSecret) {
makeSameAs();
} else if (!options.jsonData.azureLogAnalyticsSameAs) {
// if currently off, clear monitor secret
onUpdateDatasourceOptions({
const onLogAnalyticsDefaultSubscriptionChange = (subscriptionId: string | undefined) => {
updateOptions((options) => {
return {
...options,
jsonData: {
...options.jsonData,
azureLogAnalyticsSameAs: !options.jsonData.azureLogAnalyticsSameAs,
logAnalyticsSubscriptionId: subscriptionId || '',
},
secureJsonData: {
...options.secureJsonData,
clientSecret: '',
},
secureJsonFields: {
clientSecret: false,
},
});
this.setState({
sameAsSwitched: true,
});
} else {
this.props.onUpdateJsonDataOption('azureLogAnalyticsSameAs', !options.jsonData.azureLogAnalyticsSameAs);
}
};
});
};
onLogAnalyticsResetClientSecret = () => {
this.props.onResetOptionKey('logAnalyticsClientSecret');
const onDefaultWorkspaceChange = (selected: SelectableValue<string>) => {
updateOptions((options) => {
return {
...options,
jsonData: {
...options.jsonData,
logAnalyticsDefaultWorkspace: selected.value || '',
},
};
});
};
hasWorkspaceRequiredFields = () => {
const {
options: { jsonData, secureJsonData, secureJsonFields },
} = this.props;
if (jsonData.azureLogAnalyticsSameAs) {
return (
jsonData.tenantId &&
jsonData.clientId &&
jsonData.subscriptionId &&
(secureJsonData!.clientSecret || secureJsonFields.clientSecret)
);
}
return (
jsonData.logAnalyticsTenantId &&
jsonData.logAnalyticsTenantId.length &&
jsonData.logAnalyticsClientId &&
jsonData.logAnalyticsClientId.length &&
jsonData.logAnalyticsSubscriptionId &&
(secureJsonFields.logAnalyticsClientSecret || secureJsonData!.logAnalyticsClientSecret)
);
const tooltipAttribute = {
...(!logAnalyticsCredentials && {
tooltip: 'Workspaces are pulled from default subscription selected above.',
}),
};
render() {
const {
options: { jsonData, secureJsonData, secureJsonFields },
subscriptions,
workspaces,
} = this.props;
const showSameAsHelpMsg = sameAsSwitched && !primaryCredentials.clientSecret;
const { sameAsSwitched } = this.state;
if (!jsonData.hasOwnProperty('azureLogAnalyticsSameAs')) {
jsonData.azureLogAnalyticsSameAs = true;
}
const addtlAttrs = {
...(jsonData.azureLogAnalyticsSameAs && {
tooltip: 'Workspaces are pulled from default subscription selected above.',
}),
};
const showSameAsHelpMsg =
sameAsSwitched &&
jsonData.azureLogAnalyticsSameAs &&
secureJsonFields &&
!secureJsonFields.clientSecret &&
!secureJsonData!.clientSecret;
return (
<>
<h3 className="page-heading">Azure Monitor Logs Details</h3>
<Switch
label="Same details as Azure Monitor API"
checked={jsonData.azureLogAnalyticsSameAs ?? false}
onChange={this.onAzureLogAnalyticsSameAsChange}
{...addtlAttrs}
return (
<>
<h3 className="page-heading">Azure Monitor Logs Details</h3>
<Switch
label="Same details as Azure Monitor API"
checked={!logAnalyticsCredentials}
onChange={onLogAnalyticsSameAsChange}
{...tooltipAttribute}
/>
{showSameAsHelpMsg && (
<div className="grafana-info-box m-t-2">
<div className="alert-body">
<p>Re-enter your Azure Monitor Client Secret to use this setting.</p>
</div>
</div>
)}
{logAnalyticsCredentials && (
<AzureCredentialsForm
credentials={logAnalyticsCredentials}
defaultSubscription={subscriptionId}
onCredentialsChange={onCredentialsChange}
onDefaultSubscriptionChange={onLogAnalyticsDefaultSubscriptionChange}
getSubscriptions={getSubscriptions}
/>
{showSameAsHelpMsg && (
<div className="grafana-info-box m-t-2">
<div className="alert-body">
<p>Re-enter your Azure Monitor Client Secret to use this setting.</p>
</div>
</div>
)}
{!jsonData.azureLogAnalyticsSameAs && (
<AzureCredentialsForm
subscriptionOptions={subscriptions}
selectedSubscription={jsonData.logAnalyticsSubscriptionId}
tenantId={jsonData.logAnalyticsTenantId}
clientId={jsonData.logAnalyticsClientId}
clientSecret={secureJsonData!.logAnalyticsClientSecret}
clientSecretConfigured={secureJsonFields.logAnalyticsClientSecret}
onSubscriptionSelectChange={this.onLogAnalyticsSubscriptionSelect}
onTenantIdChange={this.onLogAnalyticsTenantIdChange}
onClientIdChange={this.onLogAnalyticsClientIdChange}
onClientSecretChange={this.onLogAnalyticsClientSecretChange}
onResetClientSecret={this.onLogAnalyticsResetClientSecret}
onLoadSubscriptions={() => this.props.onLoadSubscriptions('workspacesloganalytics')}
/>
)}
<div className="gf-form-group">
<div className="gf-form-inline">
<div className="gf-form">
<InlineFormLabel
className="width-12"
tooltip="Choose the default/preferred Workspace for Azure Log Analytics queries."
>
Default Workspace
</InlineFormLabel>
<div className="width-25">
<Select
value={workspaces.find((workspace) => workspace.value === jsonData.logAnalyticsDefaultWorkspace)}
options={workspaces}
defaultValue={jsonData.logAnalyticsDefaultWorkspace}
onChange={this.onWorkspaceSelectChange}
/>
</div>
</div>
</div>
<div className="gf-form-inline">
<div className="gf-form">
<div className="max-width-30 gf-form-inline">
<Button
variant="secondary"
size="sm"
type="button"
onClick={() => this.props.onLoadWorkspaces()}
disabled={!this.hasWorkspaceRequiredFields()}
>
Load Workspaces
</Button>
</div>
)}
<div className="gf-form-group">
<div className="gf-form-inline">
<div className="gf-form">
<InlineFormLabel
className="width-12"
tooltip="Choose the default/preferred Workspace for Azure Log Analytics queries."
>
Default Workspace
</InlineFormLabel>
<div className="width-25">
<Select
value={workspaces.find((opt) => opt.value === defaultWorkspace)}
options={workspaces}
onChange={onDefaultWorkspaceChange}
/>
</div>
</div>
</div>
</>
);
}
}
<div className="gf-form-inline">
<div className="gf-form">
<div className="max-width-30 gf-form-inline">
<Button
variant="secondary"
size="sm"
type="button"
onClick={onLoadWorkspaces}
disabled={!hasRequiredFields}
>
Load Workspaces
</Button>
</div>
</div>
</div>
</div>
</>
);
};
export default AnalyticsConfig;
@@ -2,31 +2,29 @@ import React from 'react';
import { shallow } from 'enzyme';
import AzureCredentialsForm, { Props } from './AzureCredentialsForm';
const setup = (propOverrides?: object) => {
const props: Props = {
selectedAzureCloud: 'azuremonitor',
selectedSubscription: '44987801-6nn6-49he-9b2d-9106972f9789',
const setup = (propsFunc?: (props: Props) => Props) => {
let props: Props = {
credentials: {
azureCloud: 'azuremonitor',
tenantId: 'e7f3f661-a933-3h3f-0294-31c4f962ec48',
clientId: '34509fad-c0r9-45df-9e25-f1ee34af6900',
clientSecret: undefined,
},
defaultSubscription: '44987801-6nn6-49he-9b2d-9106972f9789',
azureCloudOptions: [
{ value: 'azuremonitor', label: 'Azure' },
{ value: 'govazuremonitor', label: 'Azure US Government' },
{ value: 'germanyazuremonitor', label: 'Azure Germany' },
{ value: 'chinaazuremonitor', label: 'Azure China' },
],
tenantId: 'e7f3f661-a933-3h3f-0294-31c4f962ec48',
clientId: '34509fad-c0r9-45df-9e25-f1ee34af6900',
clientSecret: '',
clientSecretConfigured: false,
subscriptionOptions: [],
onAzureCloudChange: jest.fn(),
onSubscriptionSelectChange: jest.fn(),
onTenantIdChange: jest.fn(),
onClientIdChange: jest.fn(),
onClientSecretChange: jest.fn(),
onResetClientSecret: jest.fn(),
onLoadSubscriptions: jest.fn(),
onCredentialsChange: jest.fn(),
onDefaultSubscriptionChange: jest.fn(),
getSubscriptions: jest.fn(),
};
Object.assign(props, propOverrides);
if (propsFunc) {
props = propsFunc(props);
}
return shallow(<AzureCredentialsForm {...props} />);
};
@@ -38,16 +36,28 @@ describe('Render', () => {
});
it('should disable azure monitor secret input', () => {
const wrapper = setup({
clientSecretConfigured: true,
});
const wrapper = setup((props) => ({
...props,
credentials: {
azureCloud: 'azuremonitor',
tenantId: 'e7f3f661-a933-3h3f-0294-31c4f962ec48',
clientId: '34509fad-c0r9-45df-9e25-f1ee34af6900',
clientSecret: Symbol(),
},
}));
expect(wrapper).toMatchSnapshot();
});
it('should enable azure monitor load subscriptions button', () => {
const wrapper = setup({
clientSecret: 'e7f3f661-a933-4b3f-8176-51c4f982ec48',
});
const wrapper = setup((props) => ({
...props,
credentials: {
azureCloud: 'azuremonitor',
tenantId: 'e7f3f661-a933-3h3f-0294-31c4f962ec48',
clientId: '34509fad-c0r9-45df-9e25-f1ee34af6900',
clientSecret: 'e7f3f661-a933-4b3f-8176-51c4f982ec48',
},
}));
expect(wrapper).toMatchSnapshot();
});
});
@@ -1,158 +1,229 @@
import React, { ChangeEvent, PureComponent } from 'react';
import React, { ChangeEvent, FunctionComponent, useEffect, useReducer, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { InlineFormLabel, LegacyForms, Button } from '@grafana/ui';
import { AzureCredentials } from '../types';
import { isCredentialsComplete } from '../credentials';
const { Select, Input } = LegacyForms;
export interface Props {
selectedAzureCloud?: string;
selectedSubscription?: string;
credentials: AzureCredentials;
defaultSubscription?: string;
azureCloudOptions?: SelectableValue[];
tenantId?: string;
clientId?: string;
clientSecret?: string;
clientSecretConfigured?: boolean;
subscriptionOptions?: SelectableValue[];
onAzureCloudChange?: (value: SelectableValue<string>) => void;
onSubscriptionSelectChange?: (value: SelectableValue<string>) => void;
onTenantIdChange: (event: ChangeEvent<HTMLInputElement>) => void;
onClientIdChange: (event: ChangeEvent<HTMLInputElement>) => void;
onClientSecretChange: (event: ChangeEvent<HTMLInputElement>) => void;
onResetClientSecret: () => void;
onLoadSubscriptions?: () => void;
onCredentialsChange: (updatedCredentials: AzureCredentials) => void;
onDefaultSubscriptionChange?: (subscriptionId: string | undefined) => void;
getSubscriptions?: () => Promise<SelectableValue[]>;
}
export class AzureCredentialsForm extends PureComponent<Props> {
render() {
const {
selectedAzureCloud,
selectedSubscription,
tenantId,
clientId,
clientSecret,
clientSecretConfigured,
azureCloudOptions,
subscriptionOptions,
onAzureCloudChange,
onSubscriptionSelectChange,
onTenantIdChange,
onClientIdChange,
onClientSecretChange,
onResetClientSecret,
onLoadSubscriptions,
} = this.props;
const hasRequiredFields = tenantId && clientId && (clientSecret || clientSecretConfigured);
const hasSubscriptions = onLoadSubscriptions && subscriptionOptions;
export const AzureCredentialsForm: FunctionComponent<Props> = (props: Props) => {
const {
credentials,
defaultSubscription,
azureCloudOptions,
onCredentialsChange,
onDefaultSubscriptionChange,
getSubscriptions,
} = props;
const hasRequiredFields = isCredentialsComplete(credentials);
return (
<>
<div className="gf-form-group">
{azureCloudOptions && (
<div className="gf-form-inline">
<div className="gf-form">
<InlineFormLabel className="width-12" tooltip="Choose an Azure Cloud.">
Azure Cloud
</InlineFormLabel>
<Select
className="width-15"
value={azureCloudOptions.find((azureCloud) => azureCloud.value === selectedAzureCloud)}
options={azureCloudOptions}
defaultValue={selectedAzureCloud}
onChange={onAzureCloudChange}
/>
</div>
</div>
)}
const [subscriptions, setSubscriptions] = useState<Array<SelectableValue<string>>>([]);
const [loadSubscriptions, onLoadSubscriptions] = useReducer((val) => val + 1, 0);
useEffect(() => {
if (!getSubscriptions || !hasRequiredFields) {
updateSubscriptions([]);
return;
}
let canceled = false;
getSubscriptions().then((result) => {
if (!canceled) {
updateSubscriptions(result);
}
});
return () => {
canceled = true;
};
// This effect is intended to be called only once initially and on Load Subscriptions click
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadSubscriptions]);
const updateSubscriptions = (received: Array<SelectableValue<string>>) => {
setSubscriptions(received);
if (onDefaultSubscriptionChange) {
if (!defaultSubscription && received.length > 0) {
// Setting the default subscription if subscriptions received but no default subscription selected
onDefaultSubscriptionChange(received[0].value);
} else if (defaultSubscription) {
const found = received.find((opt) => opt.value === defaultSubscription);
if (!found) {
// Unsetting the default found if it isn't found among the received subscriptions
onDefaultSubscriptionChange(undefined);
}
}
}
};
const onAzureCloudChange = (selected: SelectableValue<string>) => {
if (onCredentialsChange) {
const updated: AzureCredentials = {
...credentials,
azureCloud: selected.value,
};
onCredentialsChange(updated);
}
};
const onTenantIdChange = (event: ChangeEvent<HTMLInputElement>) => {
if (onCredentialsChange) {
const updated: AzureCredentials = {
...credentials,
tenantId: event.target.value,
};
onCredentialsChange(updated);
}
};
const onClientIdChange = (event: ChangeEvent<HTMLInputElement>) => {
if (onCredentialsChange) {
const updated: AzureCredentials = {
...credentials,
clientId: event.target.value,
};
onCredentialsChange(updated);
}
};
const onClientSecretChange = (event: ChangeEvent<HTMLInputElement>) => {
if (onCredentialsChange) {
const updated: AzureCredentials = {
...credentials,
clientSecret: event.target.value,
};
onCredentialsChange(updated);
}
};
const onClientSecretReset = () => {
if (onCredentialsChange) {
const updated: AzureCredentials = {
...credentials,
clientSecret: '',
};
onCredentialsChange(updated);
}
};
const onSubscriptionChange = (selected: SelectableValue<string>) => {
if (onDefaultSubscriptionChange) {
onDefaultSubscriptionChange(selected?.value);
}
};
return (
<>
<div className="gf-form-group">
{azureCloudOptions && (
<div className="gf-form-inline">
<div className="gf-form">
<InlineFormLabel className="width-12">Directory (tenant) ID</InlineFormLabel>
<InlineFormLabel className="width-12" tooltip="Choose an Azure Cloud.">
Azure Cloud
</InlineFormLabel>
<Select
className="width-15"
value={azureCloudOptions.find((opt) => opt.value === credentials.azureCloud)}
options={azureCloudOptions}
onChange={onAzureCloudChange}
/>
</div>
</div>
)}
<div className="gf-form-inline">
<div className="gf-form">
<InlineFormLabel className="width-12">Directory (tenant) ID</InlineFormLabel>
<div className="width-15">
<Input
className="width-30"
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
value={credentials.tenantId || ''}
onChange={onTenantIdChange}
/>
</div>
</div>
</div>
<div className="gf-form-inline">
<div className="gf-form">
<InlineFormLabel className="width-12">Application (client) ID</InlineFormLabel>
<div className="width-15">
<Input
className="width-30"
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
value={credentials.clientId || ''}
onChange={onClientIdChange}
/>
</div>
</div>
</div>
{typeof credentials.clientSecret === 'symbol' ? (
<div className="gf-form-inline">
<div className="gf-form">
<InlineFormLabel className="width-12">Client Secret</InlineFormLabel>
<Input className="width-25" placeholder="configured" disabled={true} />
</div>
<div className="gf-form">
<div className="max-width-30 gf-form-inline">
<Button variant="secondary" type="button" onClick={onClientSecretReset}>
reset
</Button>
</div>
</div>
</div>
) : (
<div className="gf-form-inline">
<div className="gf-form">
<InlineFormLabel className="width-12">Client Secret</InlineFormLabel>
<div className="width-15">
<Input
className="width-30"
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
value={tenantId || ''}
onChange={onTenantIdChange}
value={credentials.clientSecret || ''}
onChange={onClientSecretChange}
/>
</div>
</div>
</div>
<div className="gf-form-inline">
<div className="gf-form">
<InlineFormLabel className="width-12">Application (client) ID</InlineFormLabel>
<div className="width-15">
<Input
className="width-30"
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
value={clientId || ''}
onChange={onClientIdChange}
/>
</div>
</div>
</div>
{clientSecretConfigured ? (
)}
{getSubscriptions && onDefaultSubscriptionChange && (
<>
<div className="gf-form-inline">
<div className="gf-form">
<InlineFormLabel className="width-12">Client Secret</InlineFormLabel>
<Input className="width-25" placeholder="configured" disabled={true} />
</div>
<div className="gf-form">
<div className="max-width-30 gf-form-inline">
<Button variant="secondary" type="button" onClick={onResetClientSecret}>
reset
</Button>
</div>
</div>
</div>
) : (
<div className="gf-form-inline">
<div className="gf-form">
<InlineFormLabel className="width-12">Client Secret</InlineFormLabel>
<div className="width-15">
<Input
className="width-30"
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
value={clientSecret || ''}
onChange={onClientSecretChange}
<InlineFormLabel className="width-12">Default Subscription</InlineFormLabel>
<div className="width-25">
<Select
value={subscriptions.find((opt) => opt.value === defaultSubscription)}
options={subscriptions}
onChange={onSubscriptionChange}
/>
</div>
</div>
</div>
)}
{hasSubscriptions && (
<>
<div className="gf-form-inline">
<div className="gf-form">
<InlineFormLabel className="width-12">Default Subscription</InlineFormLabel>
<div className="width-25">
<Select
value={subscriptionOptions!.find((subscription) => subscription.value === selectedSubscription)}
options={subscriptionOptions}
defaultValue={selectedSubscription}
onChange={onSubscriptionSelectChange}
/>
</div>
<div className="gf-form-inline">
<div className="gf-form">
<div className="max-width-30 gf-form-inline">
<Button
variant="secondary"
size="sm"
type="button"
onClick={onLoadSubscriptions}
disabled={!hasRequiredFields}
>
Load Subscriptions
</Button>
</div>
</div>
<div className="gf-form-inline">
<div className="gf-form">
<div className="max-width-30 gf-form-inline">
<Button
variant="secondary"
size="sm"
type="button"
onClick={onLoadSubscriptions}
disabled={!hasRequiredFields}
>
Load Subscriptions
</Button>
</div>
</div>
</div>
</>
)}
</div>
</>
);
}
}
</div>
</>
)}
</div>
</>
);
};
export default AzureCredentialsForm;
@@ -1,43 +1,35 @@
import React, { PureComponent } from 'react';
import {
SelectableValue,
DataSourcePluginOptionsEditorProps,
SelectableValue,
updateDatasourcePluginJsonDataOption,
updateDatasourcePluginOption,
updateDatasourcePluginResetOption,
updateDatasourcePluginJsonDataOption,
updateDatasourcePluginSecureJsonDataOption,
} from '@grafana/data';
import { MonitorConfig } from './MonitorConfig';
import { AnalyticsConfig } from './AnalyticsConfig';
import { getBackendSrv, TemplateSrv, getTemplateSrv } from '@grafana/runtime';
import { getBackendSrv, getTemplateSrv, TemplateSrv } from '@grafana/runtime';
import { InsightsConfig } from './InsightsConfig';
import ResponseParser from '../azure_monitor/response_parser';
import { AzureDataSourceJsonData, AzureDataSourceSecureJsonData, AzureDataSourceSettings } from '../types';
import { makePromiseCancelable, CancelablePromise } from 'app/core/utils/CancelablePromise';
import { getAzureCloud } from '../credentials';
import { getLogAnalyticsManagementApiRoute, getManagementApiRoute } from '../api/routes';
export type Props = DataSourcePluginOptionsEditorProps<AzureDataSourceJsonData, AzureDataSourceSecureJsonData>;
export interface State {
subscriptions: SelectableValue[];
logAnalyticsSubscriptions: SelectableValue[];
logAnalyticsWorkspaces: SelectableValue[];
subscriptionId: string;
logAnalyticsSubscriptionId: string;
unsaved: boolean;
}
export class ConfigEditor extends PureComponent<Props, State> {
initPromise: CancelablePromise<any> | null = null;
templateSrv: TemplateSrv = getTemplateSrv();
constructor(props: Props) {
super(props);
this.state = {
subscriptions: [],
logAnalyticsSubscriptions: [],
logAnalyticsWorkspaces: [],
subscriptionId: '',
logAnalyticsSubscriptionId: '',
unsaved: false,
};
if (this.props.options.id) {
@@ -45,115 +37,31 @@ export class ConfigEditor extends PureComponent<Props, State> {
}
}
componentDidMount() {
this.initPromise = makePromiseCancelable(this.init());
this.initPromise.promise.catch(({ isCanceled }) => {
if (isCanceled) {
console.warn('Azure Monitor ConfigEditor has unmounted, intialization was canceled');
}
});
}
private updateOptions = (optionsFunc: (options: AzureDataSourceSettings) => AzureDataSourceSettings): void => {
const updated = optionsFunc(this.props.options);
this.props.onOptionsChange(updated);
componentWillUnmount() {
this.initPromise!.cancel();
}
this.setState({ unsaved: true });
};
init = async () => {
await this.getSubscriptions();
private saveOptions = async (): Promise<void> => {
if (this.state.unsaved) {
await getBackendSrv()
.put(`/api/datasources/${this.props.options.id}`, this.props.options)
.then((result: { datasource: AzureDataSourceSettings }) => {
updateDatasourcePluginOption(this.props, 'version', result.datasource.version);
});
if (!this.props.options.jsonData.azureLogAnalyticsSameAs) {
await this.getLogAnalyticsSubscriptions();
this.setState({ unsaved: false });
}
};
updateJsonDataOption = (key: keyof AzureDataSourceJsonData, val: any) => {
updateDatasourcePluginJsonDataOption(this.props, key, val);
};
private getSubscriptions = async (): Promise<Array<SelectableValue<string>>> => {
await this.saveOptions();
updateSecureJsonDataOption = (key: keyof AzureDataSourceSecureJsonData, val: any) => {
updateDatasourcePluginSecureJsonDataOption(this.props, key, val);
};
resetSecureKey = (key: keyof AzureDataSourceSecureJsonData) => {
updateDatasourcePluginResetOption(this.props, key);
};
onUpdateJsonDataOption = (key: keyof AzureDataSourceJsonData) => (
event: React.SyntheticEvent<HTMLInputElement | HTMLSelectElement>
) => {
this.updateJsonDataOption(key, event.currentTarget.value);
};
onUpdateSecureJsonDataOption = (key: keyof AzureDataSourceSecureJsonData) => (
event: React.SyntheticEvent<HTMLInputElement | HTMLSelectElement>
) => {
this.updateSecureJsonDataOption(key, event.currentTarget.value);
};
makeSameAs = (updatedClientSecret?: string) => {
const { options } = this.props;
const clientSecret = updatedClientSecret || options.secureJsonData!.clientSecret;
this.props.onOptionsChange({
...options,
jsonData: {
...options.jsonData,
azureLogAnalyticsSameAs: true,
logAnalyticsSubscriptionId: options.jsonData.subscriptionId,
logAnalyticsTenantId: options.jsonData.tenantId,
logAnalyticsClientId: options.jsonData.clientId,
},
secureJsonData: {
...options.secureJsonData,
clientSecret,
logAnalyticsClientSecret: clientSecret,
},
});
};
hasNecessaryCredentials = () => {
if (!this.props.options.secureJsonFields.clientSecret && !this.props.options.secureJsonData!.clientSecret) {
return false;
}
if (!this.props.options.jsonData.clientId || !this.props.options.jsonData.tenantId) {
return false;
}
return true;
};
logAnalyticsHasNecessaryCredentials = () => {
if (
!this.props.options.secureJsonFields.logAnalyticsClientSecret &&
!this.props.options.secureJsonData!.logAnalyticsClientSecret
) {
return false;
}
if (!this.props.options.jsonData.logAnalyticsClientId || !this.props.options.jsonData.logAnalyticsTenantId) {
return false;
}
return true;
};
onLoadSubscriptions = async (type?: string) => {
await getBackendSrv()
.put(`/api/datasources/${this.props.options.id}`, this.props.options)
.then((result: AzureDataSourceSettings) => {
updateDatasourcePluginOption(this.props, 'version', result.version);
});
if (type && type === 'workspacesloganalytics') {
this.getLogAnalyticsSubscriptions();
} else {
this.getSubscriptions();
}
};
loadSubscriptions = async (route?: string) => {
const url = `/${route || this.props.options.jsonData.cloudName}/subscriptions?api-version=2019-03-01`;
const cloud = getAzureCloud(this.props.options);
const route = getManagementApiRoute(cloud);
const url = `/${route}/subscriptions?api-version=2019-03-01`;
const result = await getBackendSrv().datasourceRequest({
url: this.props.options.url + url,
@@ -163,122 +71,71 @@ export class ConfigEditor extends PureComponent<Props, State> {
return ResponseParser.parseSubscriptionsForSelect(result);
};
loadWorkspaces = async (subscription: string) => {
const { azureLogAnalyticsSameAs, cloudName, logAnalyticsSubscriptionId } = this.props.options.jsonData;
let azureMonitorUrl = '',
subscriptionId = this.templateSrv.replace(subscription || this.props.options.jsonData.subscriptionId);
private getLogAnalyticsSubscriptions = async (): Promise<Array<SelectableValue<string>>> => {
await this.saveOptions();
if (azureLogAnalyticsSameAs) {
const azureCloud = cloudName || 'azuremonitor';
azureMonitorUrl = `/${azureCloud}/subscriptions`;
} else {
subscriptionId = logAnalyticsSubscriptionId!;
azureMonitorUrl = `/workspacesloganalytics/subscriptions`;
}
const workspaceListUrl =
azureMonitorUrl +
`/${subscriptionId}/providers/Microsoft.OperationalInsights/workspaces?api-version=2017-04-26-preview`;
const cloud = getAzureCloud(this.props.options);
const route = getLogAnalyticsManagementApiRoute(cloud);
const url = `/${route}/subscriptions?api-version=2019-03-01`;
const result = await getBackendSrv().datasourceRequest({
url: this.props.options.url + workspaceListUrl,
url: this.props.options.url + url,
method: 'GET',
});
return ResponseParser.parseSubscriptionsForSelect(result);
};
private getWorkspaces = async (subscriptionId: string): Promise<Array<SelectableValue<string>>> => {
await this.saveOptions();
const cloud = getAzureCloud(this.props.options);
const route = getLogAnalyticsManagementApiRoute(cloud);
const url = `/${route}/subscriptions/${subscriptionId}/providers/Microsoft.OperationalInsights/workspaces?api-version=2017-04-26-preview`;
const result = await getBackendSrv().datasourceRequest({
url: this.props.options.url + url,
method: 'GET',
});
return ResponseParser.parseWorkspacesForSelect(result);
};
getSubscriptions = async () => {
if (!this.hasNecessaryCredentials()) {
return;
}
const subscriptions = ((await this.loadSubscriptions()) || []) as SelectableValue[];
if (subscriptions && subscriptions.length > 0) {
this.setState({ subscriptions });
this.updateJsonDataOption('subscriptionId', this.props.options.jsonData.subscriptionId || subscriptions[0].value);
}
if (this.props.options.jsonData.subscriptionId && this.props.options.jsonData.azureLogAnalyticsSameAs) {
await this.getWorkspaces();
}
// TODO: Used only by InsightsConfig
private onUpdateJsonDataOption = (key: keyof AzureDataSourceJsonData) => (
event: React.SyntheticEvent<HTMLInputElement | HTMLSelectElement>
) => {
updateDatasourcePluginJsonDataOption(this.props, key, event.currentTarget.value);
};
getLogAnalyticsSubscriptions = async () => {
if (!this.logAnalyticsHasNecessaryCredentials()) {
return;
}
const logAnalyticsSubscriptions = ((await this.loadSubscriptions('workspacesloganalytics')) ||
[]) as SelectableValue[];
if (logAnalyticsSubscriptions && logAnalyticsSubscriptions.length > 0) {
this.setState({ logAnalyticsSubscriptions });
this.updateJsonDataOption(
'logAnalyticsSubscriptionId',
this.props.options.jsonData.logAnalyticsSubscriptionId || logAnalyticsSubscriptions[0].value
);
}
if (this.props.options.jsonData.logAnalyticsSubscriptionId) {
await this.getWorkspaces();
}
// TODO: Used only by InsightsConfig
private onUpdateSecureJsonDataOption = (key: keyof AzureDataSourceSecureJsonData) => (
event: React.SyntheticEvent<HTMLInputElement | HTMLSelectElement>
) => {
updateDatasourcePluginSecureJsonDataOption(this.props, key, event.currentTarget.value);
};
getWorkspaces = async () => {
const { subscriptionId, azureLogAnalyticsSameAs, logAnalyticsSubscriptionId } = this.props.options.jsonData;
const subscriptionIdToUse = azureLogAnalyticsSameAs ? subscriptionId : logAnalyticsSubscriptionId;
if (!subscriptionIdToUse) {
return;
}
const logAnalyticsWorkspaces = await this.loadWorkspaces(subscriptionIdToUse);
if (logAnalyticsWorkspaces.length > 0) {
this.setState({ logAnalyticsWorkspaces });
this.updateJsonDataOption(
'logAnalyticsDefaultWorkspace',
this.props.options.jsonData.logAnalyticsDefaultWorkspace || logAnalyticsWorkspaces[0].value
);
}
// TODO: Used only by InsightsConfig
private resetSecureKey = (key: keyof AzureDataSourceSecureJsonData) => {
updateDatasourcePluginResetOption(this.props, key);
};
render() {
const { subscriptions, logAnalyticsSubscriptions, logAnalyticsWorkspaces } = this.state;
// TODO: Clean up
const { options } = this.props;
options.jsonData.cloudName = options.jsonData.cloudName || 'azuremonitor';
// This is bad, causes so many messy typing issues everwhere..
options.secureJsonData = (options.secureJsonData || {}) as AzureDataSourceSecureJsonData;
return (
<>
<MonitorConfig
options={options}
subscriptions={subscriptions}
makeSameAs={this.makeSameAs}
onLoadSubscriptions={this.onLoadSubscriptions}
onUpdateJsonDataOption={this.updateJsonDataOption}
onUpdateSecureJsonDataOption={this.updateSecureJsonDataOption}
onResetOptionKey={this.resetSecureKey}
/>
<MonitorConfig options={options} updateOptions={this.updateOptions} getSubscriptions={this.getSubscriptions} />
<AnalyticsConfig
options={options}
workspaces={logAnalyticsWorkspaces}
subscriptions={logAnalyticsSubscriptions}
makeSameAs={this.makeSameAs}
onUpdateDatasourceOptions={this.props.onOptionsChange}
onUpdateJsonDataOption={this.updateJsonDataOption}
onUpdateSecureJsonDataOption={this.updateSecureJsonDataOption}
onResetOptionKey={this.resetSecureKey}
onLoadSubscriptions={this.onLoadSubscriptions}
onLoadWorkspaces={this.getWorkspaces}
updateOptions={this.updateOptions}
getSubscriptions={this.getLogAnalyticsSubscriptions}
getWorkspaces={this.getWorkspaces}
/>
<InsightsConfig
@@ -1,7 +1,8 @@
import React, { PureComponent, ChangeEvent } from 'react';
import React, { FunctionComponent, useMemo } from 'react';
import { SelectableValue } from '@grafana/data';
import { AzureCredentialsForm } from './AzureCredentialsForm';
import { AzureDataSourceSettings } from '../types';
import { AzureDataSourceSettings, AzureCredentials } from '../types';
import { getCredentials, updateCredentials, isLogAnalyticsSameAs } from '../credentials';
const azureClouds = [
{ value: 'azuremonitor', label: 'Azure' },
@@ -12,70 +13,54 @@ const azureClouds = [
export interface Props {
options: AzureDataSourceSettings;
subscriptions: SelectableValue[];
makeSameAs: (updatedClientSecret?: string) => void;
onUpdateJsonDataOption: (key: string, val: any) => void;
onUpdateSecureJsonDataOption: (key: string, val: any) => void;
onResetOptionKey: (key: string) => void;
onLoadSubscriptions: () => void;
updateOptions: (optionsFunc: (options: AzureDataSourceSettings) => AzureDataSourceSettings) => void;
getSubscriptions: () => Promise<Array<SelectableValue<string>>>;
}
export class MonitorConfig extends PureComponent<Props> {
onAzureCloudSelect = (cloudName: SelectableValue<string>) => {
this.props.onUpdateJsonDataOption('cloudName', cloudName.value);
export const MonitorConfig: FunctionComponent<Props> = (props: Props) => {
const { updateOptions, getSubscriptions } = props;
const credentials = useMemo(() => getCredentials(props.options), [props.options]);
const subscriptionId = props.options.jsonData.subscriptionId;
const onCredentialsChange = (credentials: AzureCredentials): void => {
updateOptions((options) => updateCredentials(options, credentials));
};
onTenantIdChange = (event: ChangeEvent<HTMLInputElement>) => {
this.props.onUpdateJsonDataOption('tenantId', event.target.value);
const onDefaultSubscriptionChange = (subscriptionId: string | undefined) => {
updateOptions((options) => {
options = {
...options,
jsonData: {
...options.jsonData,
subscriptionId: subscriptionId || '',
},
};
if (isLogAnalyticsSameAs(options)) {
options = {
...options,
jsonData: {
...options.jsonData,
logAnalyticsSubscriptionId: subscriptionId || '',
},
};
}
return options;
});
};
onClientIdChange = (event: ChangeEvent<HTMLInputElement>) => {
this.props.onUpdateJsonDataOption('clientId', event.target.value);
};
onClientSecretChange = (event: ChangeEvent<HTMLInputElement>) => {
const { options, makeSameAs } = this.props;
if (options.jsonData.azureLogAnalyticsSameAs && event.target.value) {
makeSameAs(event.target.value);
} else {
this.props.onUpdateSecureJsonDataOption('clientSecret', event.target.value);
}
};
onResetClientSecret = () => {
this.props.onResetOptionKey('clientSecret');
};
onSubscriptionSelect = (subscription: SelectableValue<string>) => {
this.props.onUpdateJsonDataOption('subscriptionId', subscription.value);
};
render() {
const { options, subscriptions } = this.props;
return (
<>
<h3 className="page-heading">Azure Monitor Metrics Details</h3>
<AzureCredentialsForm
selectedAzureCloud={options.jsonData.cloudName || 'azuremonitor'}
azureCloudOptions={azureClouds}
subscriptionOptions={subscriptions}
selectedSubscription={options.jsonData.subscriptionId}
tenantId={options.jsonData.tenantId}
clientId={options.jsonData.clientId}
clientSecret={options.secureJsonData?.clientSecret}
clientSecretConfigured={options.secureJsonFields.clientSecret}
onAzureCloudChange={this.onAzureCloudSelect}
onSubscriptionSelectChange={this.onSubscriptionSelect}
onTenantIdChange={this.onTenantIdChange}
onClientIdChange={this.onClientIdChange}
onClientSecretChange={this.onClientSecretChange}
onResetClientSecret={this.onResetClientSecret}
onLoadSubscriptions={this.props.onLoadSubscriptions}
/>
</>
);
}
}
return (
<>
<h3 className="page-heading">Azure Monitor Metrics Details</h3>
<AzureCredentialsForm
credentials={credentials}
defaultSubscription={subscriptionId}
azureCloudOptions={azureClouds}
onCredentialsChange={onCredentialsChange}
onDefaultSubscriptionChange={onDefaultSubscriptionChange}
getSubscriptions={getSubscriptions}
/>
</>
);
};
export default MonitorConfig;
@@ -38,9 +38,7 @@ const SubscriptionField: React.FC<SubscriptionFieldProps> = ({
if (!newSubscription && query.queryType === AzureQueryType.AzureMonitor) {
newSubscription = datasource.azureMonitorDatasource.subscriptionId;
} else if (!query.subscription && query.queryType === AzureQueryType.LogAnalytics) {
newSubscription =
datasource.azureLogAnalyticsDatasource.logAnalyticsSubscriptionId ||
datasource.azureLogAnalyticsDatasource.subscriptionId;
newSubscription = datasource.azureLogAnalyticsDatasource.subscriptionId;
}
if (!newSubscription && newSubscriptions.length > 0) {
@@ -55,7 +53,6 @@ const SubscriptionField: React.FC<SubscriptionFieldProps> = ({
})
.catch((err) => setError(ERROR_SOURCE, err));
}, [
datasource.azureLogAnalyticsDatasource?.logAnalyticsSubscriptionId,
datasource.azureLogAnalyticsDatasource?.subscriptionId,
datasource.azureMonitorDatasource,
onQueryChange,
@@ -8,21 +8,10 @@ exports[`Render should disable log analytics credentials form 1`] = `
Azure Monitor Logs Details
</h3>
<Switch
checked={false}
checked={true}
label="Same details as Azure Monitor API"
onChange={[Function]}
/>
<AzureCredentialsForm
clientSecret=""
clientSecretConfigured={false}
onClientIdChange={[Function]}
onClientSecretChange={[Function]}
onLoadSubscriptions={[Function]}
onResetClientSecret={[Function]}
onSubscriptionSelectChange={[Function]}
onTenantIdChange={[Function]}
subscriptionOptions={Array []}
tenantId=""
tooltip="Workspaces are pulled from default subscription selected above."
/>
<div
className="gf-form-group"
@@ -56,7 +45,6 @@ exports[`Render should disable log analytics credentials form 1`] = `
"SingleValue": [Function],
}
}
defaultValue=""
isClearable={false}
isDisabled={false}
isLoading={false}
@@ -109,16 +97,18 @@ exports[`Render should enable azure log analytics load workspaces button 1`] = `
onChange={[Function]}
/>
<AzureCredentialsForm
clientSecret=""
clientSecretConfigured={false}
onClientIdChange={[Function]}
onClientSecretChange={[Function]}
onLoadSubscriptions={[Function]}
onResetClientSecret={[Function]}
onSubscriptionSelectChange={[Function]}
onTenantIdChange={[Function]}
subscriptionOptions={Array []}
tenantId=""
credentials={
Object {
"azureCloud": "azuremonitor",
"clientId": "44693801-6ee6-49de-9b2d-9106972f9572",
"clientSecret": undefined,
"tenantId": "e7f3f661-a933-4b3f-8176-51c4f982ec48",
}
}
defaultSubscription="e3fe4fde-ad5e-4d60-9974-e2f3562ffdf2"
getSubscriptions={[MockFunction]}
onCredentialsChange={[Function]}
onDefaultSubscriptionChange={[Function]}
/>
<div
className="gf-form-group"
@@ -152,7 +142,6 @@ exports[`Render should enable azure log analytics load workspaces button 1`] = `
"SingleValue": [Function],
}
}
defaultValue=""
isClearable={false}
isDisabled={false}
isLoading={false}
@@ -205,16 +194,17 @@ exports[`Render should render component 1`] = `
onChange={[Function]}
/>
<AzureCredentialsForm
clientSecret=""
clientSecretConfigured={false}
onClientIdChange={[Function]}
onClientSecretChange={[Function]}
onLoadSubscriptions={[Function]}
onResetClientSecret={[Function]}
onSubscriptionSelectChange={[Function]}
onTenantIdChange={[Function]}
subscriptionOptions={Array []}
tenantId=""
credentials={
Object {
"azureCloud": "azuremonitor",
"clientId": undefined,
"clientSecret": undefined,
"tenantId": "",
}
}
getSubscriptions={[MockFunction]}
onCredentialsChange={[Function]}
onDefaultSubscriptionChange={[Function]}
/>
<div
className="gf-form-group"
@@ -248,7 +238,6 @@ exports[`Render should render component 1`] = `
"SingleValue": [Function],
}
}
defaultValue=""
isClearable={false}
isDisabled={false}
isLoading={false}
@@ -31,14 +31,13 @@ exports[`Render should disable azure monitor secret input 1`] = `
"SingleValue": [Function],
}
}
defaultValue="azuremonitor"
isClearable={false}
isDisabled={false}
isLoading={false}
isMulti={false}
isSearchable={true}
maxMenuHeight={300}
onChange={[MockFunction]}
onChange={[Function]}
openMenuOnFocus={false}
options={
Array [
@@ -86,7 +85,7 @@ exports[`Render should disable azure monitor secret input 1`] = `
>
<Input
className="width-30"
onChange={[MockFunction]}
onChange={[Function]}
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
value="e7f3f661-a933-3h3f-0294-31c4f962ec48"
/>
@@ -109,7 +108,7 @@ exports[`Render should disable azure monitor secret input 1`] = `
>
<Input
className="width-30"
onChange={[MockFunction]}
onChange={[Function]}
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
value="34509fad-c0r9-45df-9e25-f1ee34af6900"
/>
@@ -140,7 +139,7 @@ exports[`Render should disable azure monitor secret input 1`] = `
className="max-width-30 gf-form-inline"
>
<Button
onClick={[MockFunction]}
onClick={[Function]}
type="button"
variant="secondary"
>
@@ -177,14 +176,13 @@ exports[`Render should disable azure monitor secret input 1`] = `
"SingleValue": [Function],
}
}
defaultValue="44987801-6nn6-49he-9b2d-9106972f9789"
isClearable={false}
isDisabled={false}
isLoading={false}
isMulti={false}
isSearchable={true}
maxMenuHeight={300}
onChange={[MockFunction]}
onChange={[Function]}
openMenuOnFocus={false}
options={Array []}
tabSelectsValue={true}
@@ -203,7 +201,7 @@ exports[`Render should disable azure monitor secret input 1`] = `
>
<Button
disabled={false}
onClick={[MockFunction]}
onClick={[Function]}
size="sm"
type="button"
variant="secondary"
@@ -248,14 +246,13 @@ exports[`Render should enable azure monitor load subscriptions button 1`] = `
"SingleValue": [Function],
}
}
defaultValue="azuremonitor"
isClearable={false}
isDisabled={false}
isLoading={false}
isMulti={false}
isSearchable={true}
maxMenuHeight={300}
onChange={[MockFunction]}
onChange={[Function]}
openMenuOnFocus={false}
options={
Array [
@@ -303,7 +300,7 @@ exports[`Render should enable azure monitor load subscriptions button 1`] = `
>
<Input
className="width-30"
onChange={[MockFunction]}
onChange={[Function]}
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
value="e7f3f661-a933-3h3f-0294-31c4f962ec48"
/>
@@ -326,7 +323,7 @@ exports[`Render should enable azure monitor load subscriptions button 1`] = `
>
<Input
className="width-30"
onChange={[MockFunction]}
onChange={[Function]}
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
value="34509fad-c0r9-45df-9e25-f1ee34af6900"
/>
@@ -349,7 +346,7 @@ exports[`Render should enable azure monitor load subscriptions button 1`] = `
>
<Input
className="width-30"
onChange={[MockFunction]}
onChange={[Function]}
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
value="e7f3f661-a933-4b3f-8176-51c4f982ec48"
/>
@@ -384,14 +381,13 @@ exports[`Render should enable azure monitor load subscriptions button 1`] = `
"SingleValue": [Function],
}
}
defaultValue="44987801-6nn6-49he-9b2d-9106972f9789"
isClearable={false}
isDisabled={false}
isLoading={false}
isMulti={false}
isSearchable={true}
maxMenuHeight={300}
onChange={[MockFunction]}
onChange={[Function]}
openMenuOnFocus={false}
options={Array []}
tabSelectsValue={true}
@@ -410,7 +406,7 @@ exports[`Render should enable azure monitor load subscriptions button 1`] = `
>
<Button
disabled={false}
onClick={[MockFunction]}
onClick={[Function]}
size="sm"
type="button"
variant="secondary"
@@ -455,14 +451,13 @@ exports[`Render should render component 1`] = `
"SingleValue": [Function],
}
}
defaultValue="azuremonitor"
isClearable={false}
isDisabled={false}
isLoading={false}
isMulti={false}
isSearchable={true}
maxMenuHeight={300}
onChange={[MockFunction]}
onChange={[Function]}
openMenuOnFocus={false}
options={
Array [
@@ -510,7 +505,7 @@ exports[`Render should render component 1`] = `
>
<Input
className="width-30"
onChange={[MockFunction]}
onChange={[Function]}
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
value="e7f3f661-a933-3h3f-0294-31c4f962ec48"
/>
@@ -533,7 +528,7 @@ exports[`Render should render component 1`] = `
>
<Input
className="width-30"
onChange={[MockFunction]}
onChange={[Function]}
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
value="34509fad-c0r9-45df-9e25-f1ee34af6900"
/>
@@ -556,7 +551,7 @@ exports[`Render should render component 1`] = `
>
<Input
className="width-30"
onChange={[MockFunction]}
onChange={[Function]}
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
value=""
/>
@@ -591,14 +586,13 @@ exports[`Render should render component 1`] = `
"SingleValue": [Function],
}
}
defaultValue="44987801-6nn6-49he-9b2d-9106972f9789"
isClearable={false}
isDisabled={false}
isLoading={false}
isMulti={false}
isSearchable={true}
maxMenuHeight={300}
onChange={[MockFunction]}
onChange={[Function]}
openMenuOnFocus={false}
options={Array []}
tabSelectsValue={true}
@@ -617,7 +611,7 @@ exports[`Render should render component 1`] = `
>
<Button
disabled={true}
onClick={[MockFunction]}
onClick={[Function]}
size="sm"
type="button"
variant="secondary"
@@ -3,11 +3,7 @@
exports[`Render should render component 1`] = `
<Fragment>
<MonitorConfig
makeSameAs={[Function]}
onLoadSubscriptions={[Function]}
onResetOptionKey={[Function]}
onUpdateJsonDataOption={[Function]}
onUpdateSecureJsonDataOption={[Function]}
getSubscriptions={[Function]}
options={
Object {
"access": "proxy",
@@ -38,56 +34,11 @@ exports[`Render should render component 1`] = `
"withCredentials": false,
}
}
subscriptions={Array []}
updateOptions={[Function]}
/>
<AnalyticsConfig
makeSameAs={[Function]}
onLoadSubscriptions={[Function]}
onLoadWorkspaces={[Function]}
onResetOptionKey={[Function]}
onUpdateDatasourceOptions={
[MockFunction] {
"calls": Array [
Array [
Object {
"access": "proxy",
"basicAuth": false,
"basicAuthPassword": "",
"basicAuthUser": "",
"database": "",
"id": 21,
"isDefault": false,
"jsonData": Object {
"azureLogAnalyticsSameAs": true,
"cloudName": "azuremonitor",
"subscriptionId": "44987801-6nn6-49he-9b2d-9106972f9789",
},
"name": "Azure Monitor-10-10",
"orgId": 1,
"password": "",
"readOnly": false,
"secureJsonFields": Object {},
"type": "grafana-azure-monitor-datasource",
"typeLogoUrl": "",
"typeName": "Azure",
"uid": "y",
"url": "/api/datasources/proxy/21",
"user": "",
"version": 1,
"withCredentials": false,
},
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
}
}
onUpdateJsonDataOption={[Function]}
onUpdateSecureJsonDataOption={[Function]}
getSubscriptions={[Function]}
getWorkspaces={[Function]}
options={
Object {
"access": "proxy",
@@ -118,8 +69,7 @@ exports[`Render should render component 1`] = `
"withCredentials": false,
}
}
subscriptions={Array []}
workspaces={Array []}
updateOptions={[Function]}
/>
<InsightsConfig
onResetOptionKey={[Function]}
@@ -0,0 +1,156 @@
import { AzureCredentials, AzureDataSourceInstanceSettings, AzureDataSourceSettings, ConcealedSecret } from './types';
const concealed: ConcealedSecret = Symbol('Concealed client secret');
export function getAzureCloud(options: AzureDataSourceSettings | AzureDataSourceInstanceSettings): string {
return options.jsonData.cloudName || 'azuremonitor';
}
function getSecret(options: AzureDataSourceSettings): undefined | string | ConcealedSecret {
if (options.secureJsonFields.clientSecret) {
// The secret is concealed on server
return concealed;
} else {
const secret = options.secureJsonData?.clientSecret;
return typeof secret === 'string' && secret.length > 0 ? secret : undefined;
}
}
function getLogAnalyticsSecret(options: AzureDataSourceSettings): undefined | string | ConcealedSecret {
if (options.secureJsonFields.logAnalyticsClientSecret) {
// The secret is concealed on server
return concealed;
} else {
const secret = options.secureJsonData?.logAnalyticsClientSecret;
return typeof secret === 'string' && secret.length > 0 ? secret : undefined;
}
}
export function isLogAnalyticsSameAs(options: AzureDataSourceSettings): boolean {
return typeof options.jsonData.azureLogAnalyticsSameAs !== 'boolean' || options.jsonData.azureLogAnalyticsSameAs;
}
export function isCredentialsComplete(credentials: AzureCredentials) {
return !!(credentials.tenantId && credentials.clientId && credentials.clientSecret);
}
export function getCredentials(options: AzureDataSourceSettings): AzureCredentials {
return {
azureCloud: getAzureCloud(options),
tenantId: options.jsonData.tenantId,
clientId: options.jsonData.clientId,
clientSecret: getSecret(options),
};
}
export function getLogAnalyticsCredentials(options: AzureDataSourceSettings): AzureCredentials | undefined {
if (isLogAnalyticsSameAs(options)) {
return undefined;
}
return {
azureCloud: getAzureCloud(options),
tenantId: options.jsonData.logAnalyticsTenantId,
clientId: options.jsonData.logAnalyticsClientId,
clientSecret: getLogAnalyticsSecret(options),
};
}
export function updateCredentials(
options: AzureDataSourceSettings,
credentials: AzureCredentials
): AzureDataSourceSettings {
options = {
...options,
jsonData: {
...options.jsonData,
cloudName: credentials.azureCloud || 'azuremonitor',
tenantId: credentials.tenantId,
clientId: credentials.clientId,
},
secureJsonData: {
...options.secureJsonData,
clientSecret:
typeof credentials.clientSecret === 'string' && credentials.clientSecret.length > 0
? credentials.clientSecret
: undefined,
},
secureJsonFields: {
...options.secureJsonFields,
clientSecret: typeof credentials.clientSecret === 'symbol',
},
};
if (isLogAnalyticsSameAs(options)) {
options = updateLogAnalyticsCredentials(options, credentials);
}
return options;
}
export function updateLogAnalyticsCredentials(
options: AzureDataSourceSettings,
credentials: AzureCredentials
): AzureDataSourceSettings {
options = {
...options,
jsonData: {
...options.jsonData,
logAnalyticsTenantId: credentials.tenantId,
logAnalyticsClientId: credentials.clientId,
},
secureJsonData: {
...options.secureJsonData,
logAnalyticsClientSecret:
typeof credentials.clientSecret === 'string' && credentials.clientSecret.length > 0
? credentials.clientSecret
: undefined,
},
secureJsonFields: {
...options.secureJsonFields,
logAnalyticsClientSecret: typeof credentials.clientSecret === 'symbol',
},
};
return options;
}
export function updateLogAnalyticsSameAs(options: AzureDataSourceSettings, sameAs: boolean): AzureDataSourceSettings {
if (sameAs !== isLogAnalyticsSameAs(options)) {
// Update the 'Same As' switch
options = {
...options,
jsonData: {
...options.jsonData,
azureLogAnalyticsSameAs: sameAs,
},
};
if (sameAs) {
// Get the primary credentials
let credentials = getCredentials(options);
// Check whether the client secret is concealed
if (typeof credentials.clientSecret === 'symbol') {
// Log Analytics credentials need to be synchronized but the client secret is concealed,
// so we have to reset the primary client secret to ensure that user enters a new secret
credentials.clientSecret = undefined;
options = updateCredentials(options, credentials);
}
// Synchronize the Log Analytics credentials with primary credentials
options = updateLogAnalyticsCredentials(options, credentials);
// Synchronize default subscription
options = {
...options,
jsonData: {
...options.jsonData,
logAnalyticsSubscriptionId: options.jsonData.subscriptionId,
},
};
}
}
return options;
}
@@ -320,7 +320,7 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
if (!this.target.subscription && this.target.queryType === 'Azure Monitor') {
this.target.subscription = this.datasource.azureMonitorDatasource.subscriptionId;
} else if (!this.target.subscription && this.target.queryType === 'Azure Log Analytics') {
this.target.subscription = this.datasource.azureLogAnalyticsDatasource.logAnalyticsSubscriptionId;
this.target.subscription = this.datasource.azureLogAnalyticsDatasource.subscriptionId;
}
if (!this.target.subscription && this.subscriptions.length > 0) {
@@ -1,7 +1,14 @@
import { DataQuery, DataSourceJsonData, DataSourceSettings, TableData } from '@grafana/data';
import {
DataQuery,
DataSourceInstanceSettings,
DataSourceJsonData,
DataSourceSettings,
TableData,
} from '@grafana/data';
import Datasource from '../datasource';
export type AzureDataSourceSettings = DataSourceSettings<AzureDataSourceJsonData, AzureDataSourceSecureJsonData>;
export type AzureDataSourceInstanceSettings = DataSourceInstanceSettings<AzureDataSourceJsonData>;
export type AzureResultFormat = 'time_series' | 'table';
@@ -23,19 +30,28 @@ export interface AzureMonitorQuery extends DataQuery {
insightsAnalytics: InsightsAnalyticsQuery;
}
export type ConcealedSecret = symbol;
export interface AzureCredentials {
azureCloud?: string;
tenantId?: string;
clientId?: string;
clientSecret?: string | ConcealedSecret;
}
export interface AzureDataSourceJsonData extends DataSourceJsonData {
subscriptionId: string;
cloudName: string;
// monitor
tenantId?: string;
clientId?: string;
subscriptionId: string;
// logs
logAnalyticsSubscriptionId?: string;
azureLogAnalyticsSameAs?: boolean;
logAnalyticsTenantId?: string;
logAnalyticsClientId?: string;
azureLogAnalyticsSameAs?: boolean;
logAnalyticsSubscriptionId?: string;
logAnalyticsDefaultWorkspace?: string;
// App Insights