From 24b878ca4fde81744bd6255431f16aa22a4ff9c9 Mon Sep 17 00:00:00 2001 From: Sergey Kostrukov Date: Tue, 11 May 2021 12:34:41 -0700 Subject: [PATCH] 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 --- .../api/routes.ts | 51 +++ .../app_insights/app_insights_datasource.ts | 21 +- .../azure_log_analytics_datasource.test.ts | 2 +- .../azure_log_analytics_datasource.ts | 59 +-- .../azure_monitor/azure_monitor_datasource.ts | 18 +- .../components/AnalyticsConfig.test.tsx | 55 +-- .../components/AnalyticsConfig.tsx | 336 +++++++++--------- .../components/AzureCredentialsForm.test.tsx | 56 +-- .../components/AzureCredentialsForm.tsx | 329 ++++++++++------- .../components/ConfigEditor.tsx | 271 ++++---------- .../components/MonitorConfig.tsx | 107 +++--- .../components/SubscriptionField.tsx | 5 +- .../AnalyticsConfig.test.tsx.snap | 61 ++-- .../AzureCredentialsForm.test.tsx.snap | 42 +-- .../__snapshots__/ConfigEditor.test.tsx.snap | 60 +--- .../credentials.ts | 156 ++++++++ .../query_ctrl.ts | 2 +- .../types/index.ts | 24 +- 18 files changed, 840 insertions(+), 815 deletions(-) create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/api/routes.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/credentials.ts diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/api/routes.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/api/routes.ts new file mode 100644 index 00000000000..587e2106343 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/api/routes.ts @@ -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.'); + } +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/app_insights/app_insights_datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/app_insights/app_insights_datasource.ts index 26ba7346c1c..51ecdcf136e 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/app_insights/app_insights_datasource.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/app_insights/app_insights_datasource.ts @@ -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 { url: string; baseUrl: string; @@ -21,21 +24,9 @@ export default class AppInsightsDatasource extends DataSourceWithBackend { }); it('should use the loganalyticsazure plugin route', () => { - expect(workspacesUrl).toContain('azuremonitor'); + expect(workspacesUrl).toContain('workspacesloganalytics'); expect(azureLogAnalyticsUrl).toContain('loganalyticsazure'); }); }); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.ts index c13d35cdc7d..5e6093b1c9b 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.ts @@ -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 { diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts index 66f636b2ab3..5192821abca 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts @@ -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 { return ResponseParser.parseSubscriptions(result); }); @@ -469,7 +473,7 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend { if (response.status === 200) { diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AnalyticsConfig.test.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AnalyticsConfig.test.tsx index 2be5a7b59e5..3143207db55 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AnalyticsConfig.test.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AnalyticsConfig.test.tsx @@ -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(); }; @@ -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(); }); }); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AnalyticsConfig.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AnalyticsConfig.tsx index ed27e06448e..4a8867ec612 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AnalyticsConfig.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AnalyticsConfig.tsx @@ -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>>; + getWorkspaces: (subscriptionId: string) => Promise>>; } -export class AnalyticsConfig extends PureComponent { - constructor(props: Props) { - super(props); - this.state = { - sameAsSwitched: false, +export const AnalyticsConfig: FunctionComponent = (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([]); + 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) => { - this.props.onUpdateJsonDataOption('logAnalyticsTenantId', event.target.value); + const updateWorkspaces = (received: Array>) => { + 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) => { - this.props.onUpdateJsonDataOption('logAnalyticsClientId', event.target.value); + const [sameAsSwitched, setSameAsSwitched] = useState(false); + + const onCredentialsChange = (updatedCredentials: AzureCredentials) => { + updateOptions((options) => updateLogAnalyticsCredentials(options, updatedCredentials)); }; - onLogAnalyticsClientSecretChange = (event: ChangeEvent) => { - this.props.onUpdateSecureJsonDataOption('logAnalyticsClientSecret', event.target.value); + const onLogAnalyticsSameAsChange = (event: React.SyntheticEvent) => { + const sameAs = event.currentTarget.checked; + updateOptions((options) => updateLogAnalyticsSameAs(options, sameAs)); + setSameAsSwitched(true); }; - onLogAnalyticsSubscriptionSelect = (logAnalyticsSubscription: SelectableValue) => { - this.props.onUpdateJsonDataOption('logAnalyticsSubscriptionId', logAnalyticsSubscription.value); - }; - - onWorkspaceSelectChange = (logAnalyticsDefaultWorkspace: SelectableValue) => { - 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) => { + 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 ( - <> -

Azure Monitor Logs Details

- +

Azure Monitor Logs Details

+ + {showSameAsHelpMsg && ( +
+
+

Re-enter your Azure Monitor Client Secret to use this setting.

+
+
+ )} + {logAnalyticsCredentials && ( + - {showSameAsHelpMsg && ( -
-
-

Re-enter your Azure Monitor Client Secret to use this setting.

-
-
- )} - {!jsonData.azureLogAnalyticsSameAs && ( - this.props.onLoadSubscriptions('workspacesloganalytics')} - /> - )} -
-
-
- - Default Workspace - -
- opt.value === defaultWorkspace)} + options={workspaces} + onChange={onDefaultWorkspaceChange} + />
- - ); - } -} +
+
+
+ +
+
+
+
+ + ); +}; export default AnalyticsConfig; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.test.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.test.tsx index 8f1cb73ca1f..507e9ccdeea 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.test.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.test.tsx @@ -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(); }; @@ -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(); }); }); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.tsx index 5be266eb590..5ce8389a109 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.tsx @@ -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) => void; - onSubscriptionSelectChange?: (value: SelectableValue) => void; - onTenantIdChange: (event: ChangeEvent) => void; - onClientIdChange: (event: ChangeEvent) => void; - onClientSecretChange: (event: ChangeEvent) => void; - onResetClientSecret: () => void; - onLoadSubscriptions?: () => void; + onCredentialsChange: (updatedCredentials: AzureCredentials) => void; + onDefaultSubscriptionChange?: (subscriptionId: string | undefined) => void; + getSubscriptions?: () => Promise; } -export class AzureCredentialsForm extends PureComponent { - 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) => { + const { + credentials, + defaultSubscription, + azureCloudOptions, + onCredentialsChange, + onDefaultSubscriptionChange, + getSubscriptions, + } = props; + const hasRequiredFields = isCredentialsComplete(credentials); - return ( - <> -
- {azureCloudOptions && ( -
-
- - Azure Cloud - - opt.value === credentials.azureCloud)} + options={azureCloudOptions} + onChange={onAzureCloudChange} + /> +
+
+ )} +
+
+ Directory (tenant) ID +
+ +
+
+
+
+
+ Application (client) ID +
+ +
+
+
+ {typeof credentials.clientSecret === 'symbol' ? ( +
+
+ Client Secret + +
+
+
+ +
+
+
+ ) : ( +
+
+ Client Secret
-
-
- Application (client) ID -
- -
-
-
- {clientSecretConfigured ? ( + )} + {getSubscriptions && onDefaultSubscriptionChange && ( + <>
- Client Secret - -
-
-
- -
-
-
- ) : ( -
-
- Client Secret -
- Default Subscription +
+ subscription.value === selectedSubscription)} - options={subscriptionOptions} - defaultValue={selectedSubscription} - onChange={onSubscriptionSelectChange} - /> -
+
+
+
+
-
-
-
- -
-
-
- - )} -
- - ); - } -} +
+ + )} +
+ + ); +}; export default AzureCredentialsForm; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ConfigEditor.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ConfigEditor.tsx index 033a6066761..6ee7bbf07c4 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ConfigEditor.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ConfigEditor.tsx @@ -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; export interface State { - subscriptions: SelectableValue[]; - logAnalyticsSubscriptions: SelectableValue[]; - logAnalyticsWorkspaces: SelectableValue[]; - subscriptionId: string; - logAnalyticsSubscriptionId: string; + unsaved: boolean; } export class ConfigEditor extends PureComponent { - initPromise: CancelablePromise | 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 { } } - 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 => { + 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>> => { + 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 - ) => { - this.updateJsonDataOption(key, event.currentTarget.value); - }; - - onUpdateSecureJsonDataOption = (key: keyof AzureDataSourceSecureJsonData) => ( - event: React.SyntheticEvent - ) => { - 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 { 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>> => { + 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>> => { + 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 + ) => { + 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 + ) => { + 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 ( <> - + 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>>; } -export class MonitorConfig extends PureComponent { - onAzureCloudSelect = (cloudName: SelectableValue) => { - this.props.onUpdateJsonDataOption('cloudName', cloudName.value); +export const MonitorConfig: FunctionComponent = (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) => { - 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) => { - this.props.onUpdateJsonDataOption('clientId', event.target.value); - }; - - onClientSecretChange = (event: ChangeEvent) => { - 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) => { - this.props.onUpdateJsonDataOption('subscriptionId', subscription.value); - }; - - render() { - const { options, subscriptions } = this.props; - return ( - <> -

Azure Monitor Metrics Details

- - - ); - } -} + return ( + <> +

Azure Monitor Metrics Details

+ + + ); +}; export default MonitorConfig; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/SubscriptionField.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/SubscriptionField.tsx index b466ec6d800..0c0e889136f 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/SubscriptionField.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/SubscriptionField.tsx @@ -38,9 +38,7 @@ const SubscriptionField: React.FC = ({ 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 = ({ }) .catch((err) => setError(ERROR_SOURCE, err)); }, [ - datasource.azureLogAnalyticsDatasource?.logAnalyticsSubscriptionId, datasource.azureLogAnalyticsDatasource?.subscriptionId, datasource.azureMonitorDatasource, onQueryChange, diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/__snapshots__/AnalyticsConfig.test.tsx.snap b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/__snapshots__/AnalyticsConfig.test.tsx.snap index 2f52d74994e..37e46cb60db 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/__snapshots__/AnalyticsConfig.test.tsx.snap +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/__snapshots__/AnalyticsConfig.test.tsx.snap @@ -8,21 +8,10 @@ exports[`Render should disable log analytics credentials form 1`] = ` Azure Monitor Logs Details -
@@ -109,7 +108,7 @@ exports[`Render should disable azure monitor secret input 1`] = ` > @@ -140,7 +139,7 @@ exports[`Render should disable azure monitor secret input 1`] = ` className="max-width-30 gf-form-inline" >