diff --git a/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.module.scss b/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.module.scss index c134cce0..bda031a3 100644 --- a/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.module.scss +++ b/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.module.scss @@ -21,3 +21,9 @@ text-overflow: ellipsis; } } + +.button-input-height { + input { + height: 32px; + } +} \ No newline at end of file diff --git a/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.tsx b/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.tsx index 0ef46bee..4ea0aed9 100644 --- a/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.tsx +++ b/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.tsx @@ -16,6 +16,7 @@ interface IntegrationInputFieldProps { className?: string; inputClassName?: string; iconsClassName?: string; + placeholder?: string; } const cx = cn.bind(styles); @@ -27,6 +28,7 @@ export const IntegrationInputField: React.FC = ({ showCopy = true, showExternal = true, className, + placeholder = '', inputClassName = '', iconsClassName = '', }) => { @@ -47,7 +49,14 @@ export const IntegrationInputField: React.FC = ({ ); function renderInputField() { - return ; + return ( + + ); } function onInputReveal() { diff --git a/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.styles.ts b/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.styles.ts index 47fa1089..1ae8d33e 100644 --- a/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.styles.ts +++ b/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.styles.ts @@ -52,6 +52,7 @@ export const getIntegrationFormStyles = (theme: GrafanaTheme2) => { align-items: center; gap: 8px; margin-bottom: 24px; + padding-top: 12px; `, labels: css` diff --git a/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.tsx b/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.tsx index 063d891c..af89748d 100644 --- a/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.tsx +++ b/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.tsx @@ -18,6 +18,7 @@ import { useStyles2, } from '@grafana/ui'; import { observer } from 'mobx-react'; +import { parseUrl } from 'query-string'; import { Controller, useForm, useFormContext, FormProvider } from 'react-hook-form'; import { useHistory } from 'react-router-dom'; @@ -27,6 +28,7 @@ import { RenderConditionally } from 'components/RenderConditionally/RenderCondit import { Text } from 'components/Text/Text'; import { GSelect } from 'containers/GSelect/GSelect'; import { Labels } from 'containers/Labels/Labels'; +import { ServiceNowAuthSection } from 'containers/ServiceNowConfigDrawer/ServiceNowAuthSection'; import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers'; import { GrafanaTeam } from 'models/grafana_team/grafana_team.types'; @@ -36,14 +38,14 @@ import { IntegrationHelper, getIsBidirectionalIntegration } from 'pages/integrat import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; import { UserActions } from 'utils/authorization/authorization'; -import { PLUGIN_ROOT, URL_REGEX, generateAssignToTeamInputDescription } from 'utils/consts'; +import { PLUGIN_ROOT, generateAssignToTeamInputDescription } from 'utils/consts'; import { useIsLoading } from 'utils/hooks'; import { OmitReadonlyMembers } from 'utils/types'; import { prepareForEdit } from './IntegrationForm.helpers'; import { getIntegrationFormStyles } from './IntegrationForm.styles'; -interface FormFields { +export interface IntegrationFormFields { verbal_name?: string; description_short?: string; team?: string; @@ -115,7 +117,7 @@ export const IntegrationForm = observer( const { integration } = data; - const formMethods = useForm({ + const formMethods = useForm({ defaultValues: isNew ? { // these are the default values for creating an integration @@ -281,7 +283,7 @@ export const IntegrationForm = observer( {isTableView && } - +
ServiceNow configuration
@@ -334,9 +336,7 @@ export const IntegrationForm = observer( )} /> - + {} - - async function onFormSubmit(formData: FormFields): Promise { + async function onFormSubmit(formData: IntegrationFormFields): Promise { const labels = labelsRef.current?.getValue(); const data: OmitReadonlyMembers = { @@ -489,7 +486,7 @@ const GrafanaContactPoint = observer( setValue, formState: { errors }, register, - } = useFormContext(); + } = useFormContext(); useEffect(() => { (async function () { diff --git a/grafana-plugin/src/containers/ServiceNowConfigDrawer/CompleteServiceNowConfigModal.tsx b/grafana-plugin/src/containers/ServiceNowConfigDrawer/CompleteServiceNowConfigModal.tsx new file mode 100644 index 00000000..542682b9 --- /dev/null +++ b/grafana-plugin/src/containers/ServiceNowConfigDrawer/CompleteServiceNowConfigModal.tsx @@ -0,0 +1,124 @@ +import React, { useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, HorizontalGroup, Modal, useStyles2 } from '@grafana/ui'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { ApiSchemas } from 'network/oncall-api/api.types'; +import { useCurrentIntegration } from 'pages/integration/OutgoingTab/OutgoingTab.hooks'; +import { useStore } from 'state/useStore'; +import { OmitReadonlyMembers } from 'utils/types'; +import { openNotification } from 'utils/utils'; + +import { getCommonServiceNowConfigStyles } from './ServiceNow.styles'; +import { ServiceNowStatusSection } from './ServiceNowStatusSection'; +import { ServiceNowTokenSection } from './ServiceNowTokenSection'; + +interface CompleteServiceNowConfigModalProps { + onHide: () => void; +} + +interface FormFields { + additional_settings: ApiSchemas['AlertReceiveChannel']['additional_settings']; +} + +export const CompleteServiceNowModal: React.FC = ({ onHide }) => { + const { alertReceiveChannelStore } = useStore(); + const integration = useCurrentIntegration(); + + const formMethods = useForm({ + values: { + additional_settings: { + ...integration.additional_settings, + }, + }, + }); + + const [isFormActionsDisabled, setIsFormActionsDisabled] = useState(false); + + const styles = useStyles2(getStyles); + const { handleSubmit } = formMethods; + + const { id } = integration; + + return ( + + +
+
+ +
+ +
+ +
+ +
+ + + + +
+
+
+
+ ); + + async function onFormAcknowledge() { + setIsFormActionsDisabled(true); + + try { + await alertReceiveChannelStore.update({ + id, + data: { + ...integration, + additional_settings: { + // use existing fields + ...integration.additional_settings, + is_configured: true, + }, + }, + }); + + onHide(); + } catch (ex) { + setIsFormActionsDisabled(false); + } + } + + async function onFormSubmit(formData: FormFields) { + setIsFormActionsDisabled(true); + + const data: OmitReadonlyMembers = { + ...integration, + ...formData, + + additional_settings: { + ...integration.additional_settings, + ...formData.additional_settings, + state_mapping: { + ...formData.additional_settings.state_mapping, + }, + is_configured: true, + }, + }; + + try { + await alertReceiveChannelStore.update({ id, data }); + openNotification('You successfully completed your ServiceNow configuration'); + onHide(); + } finally { + setIsFormActionsDisabled(false); + } + } +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + ...getCommonServiceNowConfigStyles(theme), + }; +}; diff --git a/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNow.styles.tsx b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNow.styles.tsx new file mode 100644 index 00000000..b65d12c1 --- /dev/null +++ b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNow.styles.tsx @@ -0,0 +1,37 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; + +export const getCommonServiceNowConfigStyles = (theme: GrafanaTheme2) => { + return { + border: css` + padding: 12px; + margin-bottom: 24px; + border: 1px solid ${theme.colors.border.weak}; + border-radius: ${theme.shape.radius.default}; + `, + + tokenContainer: css` + display: flex; + width: 100%; + gap: 8px; + `, + + tokenInput: css` + height: 32px !important; + `, + + buttonInputHeight: css` + input { + height: 32px !important; + } + `, + + tokenIcons: css` + top: 10px !important; + `, + + loader: css` + margin-bottom: 0; + `, + }; +}; diff --git a/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowAuthSection.tsx b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowAuthSection.tsx new file mode 100644 index 00000000..b1566589 --- /dev/null +++ b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowAuthSection.tsx @@ -0,0 +1,66 @@ +import React, { useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, HorizontalGroup, Icon, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; +import { observer } from 'mobx-react'; +import { useFormContext } from 'react-hook-form'; + +import { RenderConditionally } from 'components/RenderConditionally/RenderConditionally'; +import { Text } from 'components/Text/Text'; +import { IntegrationFormFields } from 'containers/IntegrationForm/IntegrationForm'; +import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers'; +import { ApiSchemas } from 'network/oncall-api/api.types'; +import { useCurrentIntegration } from 'pages/integration/OutgoingTab/OutgoingTab.hooks'; +import { OmitReadonlyMembers } from 'utils/types'; + +import { getCommonServiceNowConfigStyles } from './ServiceNow.styles'; +import { ServiceNowFormFields } from './ServiceNowStatusSection'; + +export const ServiceNowAuthSection: React.FC = observer(() => { + const { getValues } = useFormContext(); + + const currentIntegration = useCurrentIntegration(); + const [isAuthTestRunning, setIsAuthTestRunning] = useState(false); + const [authTestResult, setAuthTestResult] = useState(undefined); + const styles = useStyles2(getStyles); + + return ( +
+ + +
+ + + + + + + {authTestResult ? 'Connection OK' : 'Connection failed'} + + + +
+
+
+ ); + + async function onAuthTest() { + const data: OmitReadonlyMembers = { + integration: currentIntegration ? currentIntegration.integration : 'servicenow', + ...getValues(), + }; + + setIsAuthTestRunning(true); + const result = await AlertReceiveChannelHelper.testServiceNowAuthentication({ data }); + setAuthTestResult(result); + setIsAuthTestRunning(false); + } +}); + +const getStyles = (theme: GrafanaTheme2) => { + return { + ...getCommonServiceNowConfigStyles(theme), + }; +}; diff --git a/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowConfig.helpers.ts b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowConfig.helpers.ts new file mode 100644 index 00000000..d479942e --- /dev/null +++ b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowConfig.helpers.ts @@ -0,0 +1,36 @@ +import { SelectableValue } from '@grafana/data'; +import { UseFormGetValues } from 'react-hook-form'; + +import { AlertReceiveChannelStore } from 'models/alert_receive_channel/alert_receive_channel'; +import { OnCallAGStatus } from 'utils/consts'; + +import { ServiceNowFormFields } from './ServiceNowStatusSection'; + +export class ServiceNowHelper { + static getAvailableStatusOptions({ + getValues, + currentAction, + alertReceiveChannelStore, + }: { + currentAction: OnCallAGStatus; + getValues: UseFormGetValues; + alertReceiveChannelStore: AlertReceiveChannelStore; + }): SelectableValue[] { + const stateMapping = getValues()?.additional_settings?.state_mapping || {}; + const keys = Object.keys(stateMapping); + + // values are list of array-like values [label, id] + const values = keys + .map((k) => stateMapping[k]) + .filter(Boolean) + .map((arr) => arr[1]); + const statusList = (alertReceiveChannelStore.serviceNowStatusList || []).map(([name, id]) => ({ id, name })); + + return statusList + .filter((status) => values.indexOf(status.id) === -1 || stateMapping?.[currentAction]?.[0] === status.name) + .map((status) => ({ + value: status.id, + label: status.name, + })); + } +} diff --git a/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowConfigDrawer.tsx b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowConfigDrawer.tsx index 43c64a8b..da306241 100644 --- a/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowConfigDrawer.tsx +++ b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowConfigDrawer.tsx @@ -1,394 +1,165 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { css } from '@emotion/css'; -import { GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { - Drawer, - Field, - HorizontalGroup, - Input, - VerticalGroup, - Icon, - useStyles2, - Button, - LoadingPlaceholder, - Select, - SelectBaseProps, -} from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Drawer, Field, HorizontalGroup, Input, VerticalGroup, Icon, useStyles2, Button } from '@grafana/ui'; import { observer } from 'mobx-react'; -import { Controller, useForm } from 'react-hook-form'; +import { parseUrl } from 'query-string'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; -import { IntegrationInputField } from 'components/IntegrationInputField/IntegrationInputField'; -import { RenderConditionally } from 'components/RenderConditionally/RenderConditionally'; import { Text } from 'components/Text/Text'; import { ActionKey } from 'models/loader/action-keys'; import { ApiSchemas } from 'network/oncall-api/api.types'; import { useCurrentIntegration } from 'pages/integration/OutgoingTab/OutgoingTab.hooks'; import { useStore } from 'state/useStore'; -import { URL_REGEX } from 'utils/consts'; import { useIsLoading } from 'utils/hooks'; import { OmitReadonlyMembers } from 'utils/types'; import { openNotification } from 'utils/utils'; +import { getCommonServiceNowConfigStyles } from './ServiceNow.styles'; +import { ServiceNowAuthSection } from './ServiceNowAuthSection'; +import { ServiceNowStatusSection } from './ServiceNowStatusSection'; +import { ServiceNowTokenSection } from './ServiceNowTokenSection'; + interface ServiceNowConfigurationDrawerProps { onHide(): void; } -enum OnCallAGStatus { - Firing = 'Firing', - Resolved = 'Resolved', - Silenced = 'Silenced', - Acknowledged = 'Acknowledged', -} - interface FormFields { additional_settings: ApiSchemas['AlertReceiveChannel']['additional_settings']; } -interface StatusMapping { - [OnCallAGStatus.Firing]?: string; - [OnCallAGStatus.Resolved]?: string; - [OnCallAGStatus.Silenced]?: string; - [OnCallAGStatus.Acknowledged]?: string; -} - export const ServiceNowConfigDrawer: React.FC = observer(({ onHide }) => { const styles = useStyles2(getStyles); const { alertReceiveChannelStore } = useStore(); - const integration = useCurrentIntegration(); + const currentIntegration = useCurrentIntegration(); - const [isAuthTestRunning, setIsAuthTestRunning] = useState(false); - const [authTestResult, setAuthTestResult] = useState(undefined); - const [statusMapping, setStatusMapping] = useState({}); - - const { - control, - handleSubmit, - setValue, - formState: { errors }, - } = useForm({ + const formMethods = useForm({ defaultValues: { - additional_settings: { ...integration.additional_settings }, + additional_settings: { ...currentIntegration.additional_settings }, }, mode: 'onChange', }); - const serviceNowAPIToken = 'http://url.com'; + const { + control, + handleSubmit, + formState: { errors }, + } = formMethods; + const isLoading = useIsLoading(ActionKey.UPDATE_INTEGRATION); - useEffect(() => { - (async () => { - await alertReceiveChannelStore.fetchServiceNowListOfStatus(); - })(); - }, []); - - const selectCommonProps: Partial> = { - backspaceRemovesValue: true, - isClearable: true, - placeholder: 'Not Selected', - }; - return ( <> -
-
- ( - - - - )} - /> + + +
+ ( + + + + )} + /> - ( - - - - )} - /> + ( + + + + )} + /> - ( - - - - )} - /> + ( + + + + )} + /> - - -
- - - + +
- - - {authTestResult ? 'Connection OK' : 'Connection failed'} - - - -
- -
+
+ +
-
- - - - Status Mapping +
+ + + + Labels Mapping + + + + + + Description for such object and{' '} + + link to documentation + - - + +
- - - - - - - - - - +
+ +
- - - - - - - - - - - - - - - - - - - -
OnCall Alert group statusServiceNow incident status
Firing - ( -
Acknowledged - ( -
Resolved - ( -
Silenced - ( -
-
-
- -
- - - - Labels Mapping - - - - - - Description for such object and{' '} - - link to documentation - - - -
- -
- - - - ServiceNow API Token - - - - - - Description for such object and{' '} - - link to documentation - - - -
- - -
-
-
- -
- - - - -
-
+ + + + +
); - function onTokenRegenerate() { - // Call API and reset token - } - - function getAvailableStatusOptions(currentAction: OnCallAGStatus) { - const keys = Object.keys(statusMapping); - const values = keys.map((k) => statusMapping[k]).filter(Boolean); - - return (alertReceiveChannelStore.serviceNowStatusList || []) - .filter((status) => values.indexOf(status.name) === -1 || statusMapping[currentAction] === status.name) - .map((status) => ({ - value: status.id, - label: status.name, - })); - } - - function onStatusSelectChange(option: SelectableValue, action: OnCallAGStatus) { - setStatusMapping({ - ...statusMapping, - [action]: option?.label, - }); - } - - function onAuthTest() { - return new Promise(() => { - setIsAuthTestRunning(true); - setTimeout(() => { - setIsAuthTestRunning(false); - setAuthTestResult(true); - }, 500); - }); - } - function validateURL(urlFieldValue: string): string | boolean { - const regex = new RegExp(URL_REGEX, 'i'); - return !regex.test(urlFieldValue) ? 'Instance URL is invalid' : true; + return !parseUrl(urlFieldValue) ? 'Instance URL is invalid' : true; } async function onFormSubmit(formData: FormFields): Promise { const data: OmitReadonlyMembers = { - ...integration, + ...currentIntegration, ...formData, }; - await alertReceiveChannelStore.update({ id: integration.id, data }); + await alertReceiveChannelStore.update({ id: currentIntegration.id, data }); openNotification('ServiceNow configuration has been updated'); @@ -398,27 +169,7 @@ export const ServiceNowConfigDrawer: React.FC { return { - tokenContainer: css` - display: flex; - width: 100%; - gap: 8px; - `, - - tokenInput: css` - height: 32px !important; - padding-top: 4px !important; - `, - - tokenIcons: css` - top: 10px !important; - `, - - border: css` - padding: 12px; - margin-bottom: 24px; - border: 1px solid ${theme.colors.border.weak}; - border-radius: ${theme.shape.radius.default}; - `, + ...getCommonServiceNowConfigStyles(theme), loader: css` margin-bottom: 0; diff --git a/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowStatusSection.tsx b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowStatusSection.tsx new file mode 100644 index 00000000..9bf749ea --- /dev/null +++ b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowStatusSection.tsx @@ -0,0 +1,204 @@ +import React, { useEffect, useReducer } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { HorizontalGroup, Select, SelectBaseProps, VerticalGroup } from '@grafana/ui'; +import { observer } from 'mobx-react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { Text } from 'components/Text/Text'; +import { ApiSchemas } from 'network/oncall-api/api.types'; +import { useCurrentIntegration } from 'pages/integration/OutgoingTab/OutgoingTab.hooks'; +import { useStore } from 'state/useStore'; +import { OnCallAGStatus } from 'utils/consts'; + +import { ServiceNowHelper } from './ServiceNowConfig.helpers'; + +export interface ServiceNowStatusMapping { + [OnCallAGStatus.Firing]?: string; + [OnCallAGStatus.Resolved]?: string; + [OnCallAGStatus.Silenced]?: string; + [OnCallAGStatus.Acknowledged]?: string; +} + +export interface ServiceNowFormFields { + additional_settings: ApiSchemas['AlertReceiveChannel']['additional_settings']; +} + +export const ServiceNowStatusSection: React.FC = observer(() => { + const { control, setValue, getValues } = useFormContext(); + + const [, forceUpdate] = useReducer((x) => x + 1, 0); + + const { alertReceiveChannelStore } = useStore(); + const currentIntegration = useCurrentIntegration(); + const { id } = currentIntegration; + + useEffect(() => { + (async () => { + await alertReceiveChannelStore.fetchServiceNowStatusList({ id }); + forceUpdate(); + })(); + }, []); + + const selectCommonProps: Partial> = { + backspaceRemovesValue: true, + isClearable: true, + placeholder: 'Not Selected', + }; + + return ( + + + + Status Mapping + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ OnCall Alert group status + + ServiceNow incident status +
Firing + ( +
Acknowledged + ( +
Resolved + ( +
Silenced + ( +
+
+ ); +}); diff --git a/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowTokenSection.tsx b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowTokenSection.tsx new file mode 100644 index 00000000..011ac846 --- /dev/null +++ b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowTokenSection.tsx @@ -0,0 +1,90 @@ +import React, { useEffect, useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, HorizontalGroup, LoadingPlaceholder, VerticalGroup, useStyles2 } from '@grafana/ui'; +import { observer } from 'mobx-react'; + +import { IntegrationInputField } from 'components/IntegrationInputField/IntegrationInputField'; +import { RenderConditionally } from 'components/RenderConditionally/RenderConditionally'; +import { Text } from 'components/Text/Text'; +import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers'; +import { ActionKey } from 'models/loader/action-keys'; +import { useCurrentIntegration } from 'pages/integration/OutgoingTab/OutgoingTab.hooks'; +import { useIsLoading } from 'utils/hooks'; + +import { getCommonServiceNowConfigStyles } from './ServiceNow.styles'; + +export const ServiceNowTokenSection: React.FC = observer(() => { + const styles = useStyles2(getStyles); + const { id } = useCurrentIntegration(); + const [isExistingToken, setIsExistingToken] = useState(undefined); + const [currentToken, setCurrentToken] = useState(undefined); + const isLoading = useIsLoading(ActionKey.UPDATE_SERVICENOW_TOKEN); + + useEffect(() => { + (async function () { + const hasToken = await AlertReceiveChannelHelper.checkIfServiceNowHasToken({ id }); + setIsExistingToken(hasToken); + })(); + }, []); + + return ( + + + + ServiceNow backsync API token + + + + + Description for such object and{' '} + + link to documentation + + + + + + + + +
+ + +
+
+
+ ); + + async function onTokenGenerate() { + const res = await AlertReceiveChannelHelper.generateServiceNowToken({ id }); + + if (res?.token) { + setCurrentToken(res.token); + } + } +}); + +const getStyles = (theme: GrafanaTheme2) => { + return { + ...getCommonServiceNowConfigStyles(theme), + }; +}; diff --git a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.helpers.ts b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.helpers.ts index 9de5be3d..4e131d03 100644 --- a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.helpers.ts +++ b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.helpers.ts @@ -1,9 +1,12 @@ import { ChannelFilter } from 'models/channel_filter/channel_filter.types'; import { GrafanaTeam } from 'models/grafana_team/grafana_team.types'; +import { ActionKey } from 'models/loader/action-keys'; import { makeRequest } from 'network/network'; import { ApiSchemas } from 'network/oncall-api/api.types'; import { onCallApi } from 'network/oncall-api/http-client'; import { SelectOption } from 'state/types'; +import { AutoLoadingState, WithGlobalNotification } from 'utils/decorators'; +import { OmitReadonlyMembers } from 'utils/types'; import { showApiError } from 'utils/utils'; import { AlertReceiveChannelStore } from './alert_receive_channel'; @@ -43,6 +46,49 @@ export class AlertReceiveChannelHelper { : undefined; } + static async checkIfServiceNowHasToken({ id }: { id: ApiSchemas['AlertReceiveChannel']['id'] }) { + try { + const response = await onCallApi({ skipErrorHandling: true }).GET('/alert_receive_channels/{id}/api_token/', { + params: { path: { id } }, + }); + return response?.response.status === 200; + } catch (ex) { + return false; + } + } + + @AutoLoadingState(ActionKey.UPDATE_SERVICENOW_TOKEN) + @WithGlobalNotification({ failure: 'There was an error generating the token. Please try again' }) + static async generateServiceNowToken({ + id, + skipErrorHandling, + }: { + id: ApiSchemas['AlertReceiveChannel']['id']; + skipErrorHandling?: boolean; + }): Promise { + const result = await onCallApi({ skipErrorHandling }).POST('/alert_receive_channels/{id}/api_token/', { + params: { path: { id } }, + }); + + return result.data; + } + + static async testServiceNowAuthentication({ + data, + }: { + data: OmitReadonlyMembers; + }) { + try { + const result = await onCallApi({ skipErrorHandling: false }).POST('/alert_receive_channels/test_connection/', { + body: data as ApiSchemas['AlertReceiveChannelUpdate'], + params: {}, + }); + return result?.response.status === 200; + } catch (ex) { + return false; + } + } + static getIntegrationSelectOption( store: AlertReceiveChannelStore, alertReceiveChannel: Partial diff --git a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts index 5717c998..2c79c5f2 100644 --- a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts +++ b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts @@ -14,7 +14,7 @@ import { RootBaseStore } from 'state/rootBaseStore/RootBaseStore'; import { AutoLoadingState, WithGlobalNotification } from 'utils/decorators'; import { OmitReadonlyMembers } from 'utils/types'; -import { AlertReceiveChannelCounters, ContactPoint, ServiceNowStatus } from './alert_receive_channel.types'; +import { AlertReceiveChannelCounters, ContactPoint } from './alert_receive_channel.types'; export class AlertReceiveChannelStore { rootStore: RootBaseStore; @@ -37,7 +37,7 @@ export class AlertReceiveChannelStore { alertReceiveChannelOptions: Array = []; templates: { [id: string]: AlertTemplatesDTO[] } = {}; connectedContactPoints: { [id: string]: ContactPoint[] } = {}; - serviceNowStatusList: ServiceNowStatus[]; + serviceNowStatusList: Array<[string, string]>; constructor(rootStore: RootBaseStore) { makeAutoObservable(this, undefined, { autoBind: true }); @@ -105,23 +105,21 @@ export class AlertReceiveChannelStore { return alertReceiveChannel.data; } - async fetchServiceNowListOfStatus(): Promise { - this.serviceNowStatusList = [ - { - id: 1, - name: 'Resolved', - }, - { - id: 2, - name: 'In Progress', - }, - { - id: 3, - name: 'New', - }, - ]; + async fetchServiceNowStatusList({ + id, + skipErrorHandling, + }: { + id: ApiSchemas['AlertReceiveChannel']['id']; + skipErrorHandling?: boolean; + }): Promise { + const statusList = await onCallApi({ skipErrorHandling }).GET('/alert_receive_channels/{id}/status_options/', { + params: { path: { id } }, + }); - return Promise.resolve(); + runInAction(() => { + // @ts-ignore // looks like wrong schema + this.serviceNowStatusList = statusList.data; + }); } async fetchItems(query: any = '') { diff --git a/grafana-plugin/src/models/loader/action-keys.ts b/grafana-plugin/src/models/loader/action-keys.ts index 270a5969..0b093adb 100644 --- a/grafana-plugin/src/models/loader/action-keys.ts +++ b/grafana-plugin/src/models/loader/action-keys.ts @@ -9,6 +9,7 @@ export enum ActionKey { FETCH_INCIDENTS_POLLING = 'FETCH_INCIDENTS_POLLING', FETCH_INCIDENTS_AND_STATS = 'FETCH_INCIDENTS_AND_STATS', UPDATE_FILTERS_AND_FETCH_INCIDENTS = 'UPDATE_FILTERS_AND_FETCH_INCIDENTS', + UPDATE_SERVICENOW_TOKEN = 'UPDATE_SERVICENOW_TOKEN', FETCH_INTEGRATIONS = 'FETCH_INTEGRATIONS', TEST_CALL_OR_SMS = 'TEST_CALL_OR_SMS', FETCH_INTEGRATION_CHANNELS = 'FETCH_INTEGRATION_CHANNELS', diff --git a/grafana-plugin/src/network/oncall-api/autogenerated-api.types.d.ts b/grafana-plugin/src/network/oncall-api/autogenerated-api.types.d.ts index 7b468869..93df5a6d 100644 --- a/grafana-plugin/src/network/oncall-api/autogenerated-api.types.d.ts +++ b/grafana-plugin/src/network/oncall-api/autogenerated-api.types.d.ts @@ -297,6 +297,23 @@ export interface paths { patch?: never; trace?: never; }; + '/alert_receive_channels/{id}/test_connection/': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Internal API endpoints for alert receive channels (integrations). */ + post: operations['alert_receive_channels_test_connection_create_2']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/alert_receive_channels/{id}/webhooks/': { parameters: { query?: never; @@ -2944,6 +2961,33 @@ export interface operations { }; }; }; + alert_receive_channels_test_connection_create_2: { + parameters: { + query?: never; + header?: never; + path: { + /** @description A string identifying this alert receive channel. */ + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': components['schemas']['AlertReceiveChannelUpdate']; + 'application/x-www-form-urlencoded': components['schemas']['AlertReceiveChannelUpdate']; + 'multipart/form-data': components['schemas']['AlertReceiveChannelUpdate']; + }; + }; + responses: { + /** @description No response body */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; alert_receive_channels_webhooks_list: { parameters: { query?: never; diff --git a/grafana-plugin/src/pages/incident/Incident.tsx b/grafana-plugin/src/pages/incident/Incident.tsx index 185a90ee..8294cbdc 100644 --- a/grafana-plugin/src/pages/incident/Incident.tsx +++ b/grafana-plugin/src/pages/incident/Incident.tsx @@ -276,6 +276,7 @@ class _IncidentPage extends React.Component; const sourceLink = incident?.render_for_web?.source_link; + const isServiceNow = incident?.alert_receive_channel?.integration === 'servicenow'; return ( @@ -391,6 +392,15 @@ class _IncidentPage extends React.Component + {isServiceNow && ( + + )} + void; } -type IntegrationDrawerKey = 'servicenow'; +type IntegrationDrawerKey = 'servicenow' | 'completeConfig'; const IntegrationActions: React.FC = ({ alertReceiveChannel, @@ -831,6 +832,7 @@ const IntegrationActions: React.FC = ({ onConfirm: () => void; }>(undefined); + const [isCompleteServiceNowConfigOpen, setIsCompleteServiceNowConfigOpen] = useState(false); const [isIntegrationSettingsOpen, setIsIntegrationSettingsOpen] = useState(false); const [isLabelsFormOpen, setLabelsFormOpen] = useState(false); const [isHeartbeatFormOpen, setIsHeartbeatFormOpen] = useState(false); @@ -844,6 +846,11 @@ const IntegrationActions: React.FC = ({ const { id } = alertReceiveChannel; + useEffect(() => { + /* ServiceNow Only */ + openServiceNowCompleteConfigurationDrawer(); + }, []); + return ( <> {confirmModal && ( @@ -868,7 +875,11 @@ const IntegrationActions: React.FC = ({ /> )} - {getIsDrawerOpened('servicenow') && closeDrawer()} />} + {getIsDrawerOpened('servicenow') && } + + {isCompleteServiceNowConfigOpen && ( + setIsCompleteServiceNowConfigOpen(false)} /> + )} {isIntegrationSettingsOpen && ( = ({ ); + function openServiceNowCompleteConfigurationDrawer() { + const isServiceNow = getIsBidirectionalIntegration(alertReceiveChannel); + const isConfigured = alertReceiveChannel.additional_settings?.is_configured; + if (isServiceNow && !isConfigured) { + setIsCompleteServiceNowConfigOpen(true); + } + } + function getMigrationDisplayName() { const name = alertReceiveChannel.integration.toLowerCase().replace('legacy_', ''); switch (name) { diff --git a/grafana-plugin/src/utils/consts.ts b/grafana-plugin/src/utils/consts.ts index b4fe7caf..2f607f35 100644 --- a/grafana-plugin/src/utils/consts.ts +++ b/grafana-plugin/src/utils/consts.ts @@ -77,4 +77,9 @@ export const TEXT_ELLIPSIS_CLASS = 'overflow-child'; export const INCIDENT_HORIZONTAL_SCROLLING_STORAGE = 'isIncidentalTableHorizontalScrolling'; export const IRM_TAB = 'IRM'; -export const URL_REGEX = /^((https?|ftp|smtp):\/\/)?(www.)?[a-z0-9]+\.[a-z]+(\/[a-zA-Z0-9#]+\/?)*$/; +export enum OnCallAGStatus { + Firing = 'firing', + Resolved = 'resolved', + Silenced = 'silenced', + Acknowledged = 'acknowledged', +} diff --git a/grafana-plugin/src/utils/decorators.ts b/grafana-plugin/src/utils/decorators.ts index 1e2d826c..933d7b64 100644 --- a/grafana-plugin/src/utils/decorators.ts +++ b/grafana-plugin/src/utils/decorators.ts @@ -9,7 +9,7 @@ export function AutoLoadingState(actionKey: string) { LoaderStore.setLoadingAction(actionKey, true); nbOfPendingActions++; try { - await originalFunction.apply(this, args); + return await originalFunction.apply(this, args); } finally { nbOfPendingActions--; // if there are other pending actions with the same key, wait till the last one is done