Show servicenow indicator for AGs + complete your servicenow config (#4078)

# What this PR does

- Adds indicator on the AG for servicenow integrations
- Adds "Complete your servicenow configuration" modal on the integration
page

## Checklist

- [ ] Unit, integration, and e2e (if applicable) tests updated
- [ ] Documentation added (or `pr:no public docs` PR label added if not
required)
- [ ] Added the relevant release notes label (see labels prefixed w/
`release:`). These labels dictate how your PR will
    show up in the autogenerated release notes.
This commit is contained in:
Rares Mardare 2024-03-26 14:37:07 +02:00 committed by GitHub
parent d6f6de3c84
commit 4854c835a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 836 additions and 392 deletions

View file

@ -21,3 +21,9 @@
text-overflow: ellipsis;
}
}
.button-input-height {
input {
height: 32px;
}
}

View file

@ -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<IntegrationInputFieldProps> = ({
showCopy = true,
showExternal = true,
className,
placeholder = '',
inputClassName = '',
iconsClassName = '',
}) => {
@ -47,7 +49,14 @@ export const IntegrationInputField: React.FC<IntegrationInputFieldProps> = ({
);
function renderInputField() {
return <Input className={inputClassName} value={isInputMasked ? value?.replace(/./g, '*') : value} disabled />;
return (
<Input
className={cx(inputClassName)}
value={isInputMasked ? value?.replace(/./g, '*') : value}
placeholder={placeholder}
disabled
/>
);
}
function onInputReveal() {

View file

@ -52,6 +52,7 @@ export const getIntegrationFormStyles = (theme: GrafanaTheme2) => {
align-items: center;
gap: 8px;
margin-bottom: 24px;
padding-top: 12px;
`,
labels: css`

View file

@ -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<FormFields>({
const formMethods = useForm<IntegrationFormFields>({
defaultValues: isNew
? {
// these are the default values for creating an integration
@ -281,7 +283,7 @@ export const IntegrationForm = observer(
{isTableView && <HowTheIntegrationWorks selectedOption={selectedIntegration} />}
<RenderConditionally shouldRender={isServiceNow}>
<RenderConditionally shouldRender={isServiceNow && isNew}>
<div className={styles.serviceNowHeading}>
<Text type="primary">ServiceNow configuration</Text>
</div>
@ -334,9 +336,7 @@ export const IntegrationForm = observer(
)}
/>
<Button className={styles.webhookTest} variant="secondary" onClick={onWebhookTestClick}>
Test
</Button>
<ServiceNowAuthSection />
<Controller
name={'create_default_webhooks'}
@ -382,13 +382,10 @@ export const IntegrationForm = observer(
}
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 onWebhookTestClick(): Promise<void> {}
async function onFormSubmit(formData: FormFields): Promise<void> {
async function onFormSubmit(formData: IntegrationFormFields): Promise<void> {
const labels = labelsRef.current?.getValue();
const data: OmitReadonlyMembers<ApiSchemas['AlertReceiveChannelCreate']> = {
@ -489,7 +486,7 @@ const GrafanaContactPoint = observer(
setValue,
formState: { errors },
register,
} = useFormContext<FormFields>();
} = useFormContext<IntegrationFormFields>();
useEffect(() => {
(async function () {

View file

@ -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<CompleteServiceNowConfigModalProps> = ({ onHide }) => {
const { alertReceiveChannelStore } = useStore();
const integration = useCurrentIntegration();
const formMethods = useForm<FormFields>({
values: {
additional_settings: {
...integration.additional_settings,
},
},
});
const [isFormActionsDisabled, setIsFormActionsDisabled] = useState(false);
const styles = useStyles2(getStyles);
const { handleSubmit } = formMethods;
const { id } = integration;
return (
<Modal closeOnEscape={false} isOpen title={'Complete ServiceNow configuration'} onDismiss={onFormAcknowledge}>
<FormProvider {...formMethods}>
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className={styles.border}>
<ServiceNowStatusSection />
</div>
<div className={styles.border}>
<ServiceNowTokenSection />
</div>
<div>
<HorizontalGroup justify="flex-end">
<Button variant="secondary" onClick={onFormAcknowledge} disabled={isFormActionsDisabled}>
Close
</Button>
<Button variant="primary" type="submit" disabled={isFormActionsDisabled}>
Proceed
</Button>
</HorizontalGroup>
</div>
</form>
</FormProvider>
</Modal>
);
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<ApiSchemas['AlertReceiveChannel']> = {
...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),
};
};

View file

@ -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;
`,
};
};

View file

@ -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<ServiceNowFormFields | IntegrationFormFields>();
const currentIntegration = useCurrentIntegration();
const [isAuthTestRunning, setIsAuthTestRunning] = useState(false);
const [authTestResult, setAuthTestResult] = useState<boolean>(undefined);
const styles = useStyles2(getStyles);
return (
<div>
<HorizontalGroup>
<Button className={''} variant="secondary" onClick={onAuthTest}>
Test
</Button>
<div>
<RenderConditionally shouldRender={isAuthTestRunning}>
<LoadingPlaceholder text="Loading..." className={styles.loader} />
</RenderConditionally>
<RenderConditionally shouldRender={!isAuthTestRunning && authTestResult !== undefined}>
<HorizontalGroup align="center" justify="center">
<Text type="primary">{authTestResult ? 'Connection OK' : 'Connection failed'}</Text>
<Icon name={authTestResult ? 'check-circle' : 'x'} />
</HorizontalGroup>
</RenderConditionally>
</div>
</HorizontalGroup>
</div>
);
async function onAuthTest() {
const data: OmitReadonlyMembers<ApiSchemas['AlertReceiveChannel']> = {
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),
};
};

View file

@ -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<ServiceNowFormFields>;
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,
}));
}
}

View file

@ -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<ServiceNowConfigurationDrawerProps> = 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<StatusMapping>({});
const {
control,
handleSubmit,
setValue,
formState: { errors },
} = useForm<FormFields>({
const formMethods = useForm<FormFields>({
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<SelectBaseProps<any>> = {
backspaceRemovesValue: true,
isClearable: true,
placeholder: 'Not Selected',
};
return (
<>
<Drawer title="ServiceNow configuration" onClose={onHide} closeOnMaskClick={false} size="md">
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className={styles.border}>
<Controller
name={'additional_settings.instance_url'}
control={control}
rules={{ required: 'Instance URL is required', validate: validateURL }}
render={({ field }) => (
<Field
key={'InstanceURL'}
label={'Instance URL'}
invalid={!!errors.additional_settings?.instance_url}
error={errors.additional_settings?.instance_url?.message}
>
<Input {...field} />
</Field>
)}
/>
<FormProvider {...formMethods}>
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className={styles.border}>
<Controller
name={'additional_settings.instance_url'}
control={control}
rules={{ required: 'Instance URL is required', validate: validateURL }}
render={({ field }) => (
<Field
key={'InstanceURL'}
label={'Instance URL'}
invalid={!!errors.additional_settings?.instance_url}
error={errors.additional_settings?.instance_url?.message}
>
<Input {...field} />
</Field>
)}
/>
<Controller
name={'additional_settings.username'}
control={control}
rules={{ required: 'Username is required' }}
render={({ field }) => (
<Field
key={'AuthUsername'}
label={'Username'}
invalid={!!errors.additional_settings?.username}
error={errors.additional_settings?.username?.message}
>
<Input {...field} />
</Field>
)}
/>
<Controller
name={'additional_settings.username'}
control={control}
rules={{ required: 'Username is required' }}
render={({ field }) => (
<Field
key={'AuthUsername'}
label={'Username'}
invalid={!!errors.additional_settings?.username}
error={errors.additional_settings?.username?.message}
>
<Input {...field} />
</Field>
)}
/>
<Controller
name={'additional_settings.password'}
control={control}
rules={{ required: 'Password is required' }}
render={({ field }) => (
<Field
key={'AuthPassword'}
label={'Password'}
invalid={!!errors.additional_settings?.password}
error={errors.additional_settings?.password?.message}
>
<Input {...field} type="password" />
</Field>
)}
/>
<Controller
name={'additional_settings.password'}
control={control}
rules={{ required: 'Password is required' }}
render={({ field }) => (
<Field
key={'AuthPassword'}
label={'Password'}
invalid={!!errors.additional_settings?.password}
error={errors.additional_settings?.password?.message}
>
<Input {...field} type="password" />
</Field>
)}
/>
<HorizontalGroup>
<Button className={''} variant="secondary" onClick={onAuthTest}>
Test
</Button>
<div>
<RenderConditionally shouldRender={isAuthTestRunning}>
<LoadingPlaceholder text="Loading" className={styles.loader} />
</RenderConditionally>
<ServiceNowAuthSection />
</div>
<RenderConditionally shouldRender={!isAuthTestRunning && authTestResult !== undefined}>
<HorizontalGroup align="center" justify="center">
<Text type="primary">{authTestResult ? 'Connection OK' : 'Connection failed'}</Text>
<Icon name={authTestResult ? 'check-circle' : 'x'} />
</HorizontalGroup>
</RenderConditionally>
</div>
</HorizontalGroup>
</div>
<div className={styles.border}>
<ServiceNowStatusSection />
</div>
<div className={styles.border}>
<VerticalGroup spacing="md">
<HorizontalGroup spacing="xs" align="center">
<Text type="primary" size="small">
Status Mapping
<div className={styles.border}>
<VerticalGroup>
<HorizontalGroup spacing="xs" align="center">
<Text type="primary" strong>
Labels Mapping
</Text>
<Icon name="info-circle" />
</HorizontalGroup>
<Text>
Description for such object and{' '}
<a href={'#'} target="_blank" rel="noreferrer">
<Text type="link">link to documentation</Text>
</a>
</Text>
<Icon name="info-circle" />
</HorizontalGroup>
</VerticalGroup>
</div>
<table className={'filter-table'}>
<thead>
<tr>
<th>OnCall Alert group status</th>
<th>ServiceNow incident status</th>
</tr>
</thead>
<tbody>
<tr>
<td>Firing</td>
<div className={styles.border}>
<ServiceNowTokenSection />
</div>
<td>
<Controller
name={'additional_settings.state_mapping.firing'}
control={control}
render={({ field }) => (
<Select
{...field}
key="state_mapping.firing"
menuShouldPortal
className="select control"
options={getAvailableStatusOptions(OnCallAGStatus.Firing)}
onChange={(option: SelectableValue) => {
onStatusSelectChange(option, OnCallAGStatus.Firing);
setValue('additional_settings.state_mapping.firing', null);
}}
{...selectCommonProps}
/>
)}
/>
</td>
</tr>
<tr>
<td>Acknowledged</td>
<td>
<Controller
name={'additional_settings.state_mapping.acknowledged'}
control={control}
render={({ field }) => (
<Select
{...field}
menuShouldPortal
className="select control"
disabled={false}
options={getAvailableStatusOptions(OnCallAGStatus.Acknowledged)}
onChange={(option: SelectableValue) => {
onStatusSelectChange(option, OnCallAGStatus.Acknowledged);
setValue('additional_settings.state_mapping.acknowledged', null);
}}
{...selectCommonProps}
/>
)}
/>
</td>
</tr>
<tr>
<td>Resolved</td>
<td>
<Controller
name={'additional_settings.state_mapping.resolved'}
control={control}
render={({ field }) => (
<Select
{...field}
menuShouldPortal
className="select control"
disabled={false}
options={getAvailableStatusOptions(OnCallAGStatus.Resolved)}
onChange={(option: SelectableValue) => {
onStatusSelectChange(option, OnCallAGStatus.Resolved);
setValue('additional_settings.state_mapping.resolved', null);
}}
{...selectCommonProps}
/>
)}
/>
</td>
</tr>
<tr>
<td>Silenced</td>
<td>
<Controller
name={'additional_settings.state_mapping.silenced'}
control={control}
render={({ field }) => (
<Select
{...field}
menuShouldPortal
className="select control"
disabled={false}
options={getAvailableStatusOptions(OnCallAGStatus.Silenced)}
onChange={(option: SelectableValue) => {
onStatusSelectChange(option, OnCallAGStatus.Silenced);
setValue('additional_settings.state_mapping.silenced', null);
}}
{...selectCommonProps}
/>
)}
/>
</td>
</tr>
</tbody>
</table>
</VerticalGroup>
</div>
<div className={styles.border}>
<VerticalGroup>
<HorizontalGroup spacing="xs" align="center">
<Text type="primary" size="small">
Labels Mapping
</Text>
<Icon name="info-circle" />
</HorizontalGroup>
<Text>
Description for such object and{' '}
<a href={'#'} target="_blank" rel="noreferrer">
<Text type="link">link to documentation</Text>
</a>
</Text>
</VerticalGroup>
</div>
<div className={styles.border}>
<VerticalGroup>
<HorizontalGroup spacing="xs" align="center">
<Text type="primary" size="small">
ServiceNow API Token
</Text>
<Icon name="info-circle" />
</HorizontalGroup>
<Text>
Description for such object and{' '}
<a href={'#'} target="_blank" rel="noreferrer">
<Text type="link">link to documentation</Text>
</a>
</Text>
<div className={styles.tokenContainer}>
<IntegrationInputField
inputClassName={styles.tokenInput}
iconsClassName={styles.tokenIcons}
value={serviceNowAPIToken}
showExternal={false}
isMasked
/>
<Button variant="secondary" onClick={onTokenRegenerate}>
Regenerate
<div className={styles.formButtons}>
<HorizontalGroup justify="flex-end">
<Button variant="secondary" onClick={onHide} disabled={isLoading}>
Close
</Button>
</div>
</VerticalGroup>
</div>
<div className={styles.formButtons}>
<HorizontalGroup justify="flex-end">
<Button variant="secondary" onClick={onHide}>
Close
</Button>
<Button variant="primary" type="submit" disabled={isLoading}>
{isLoading ? <LoadingPlaceholder className={styles.loader} text="Loading..." /> : 'Update'}
</Button>
</HorizontalGroup>
</div>
</form>
<Button variant="primary" type="submit" disabled={isLoading}>
Update
</Button>
</HorizontalGroup>
</div>
</form>
</FormProvider>
</Drawer>
</>
);
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<void> {
const data: OmitReadonlyMembers<ApiSchemas['AlertReceiveChannel']> = {
...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<ServiceNowConfigurationDrawerProps
const getStyles = (theme: GrafanaTheme2) => {
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;

View file

@ -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<ServiceNowFormFields>();
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<SelectBaseProps<any>> = {
backspaceRemovesValue: true,
isClearable: true,
placeholder: 'Not Selected',
};
return (
<VerticalGroup spacing="md">
<HorizontalGroup spacing="xs" align="center">
<Text type="primary" strong>
Status Mapping
</Text>
</HorizontalGroup>
<table className={'filter-table'}>
<thead>
<tr>
<th>
<Text type="primary">OnCall Alert group status</Text>
</th>
<th>
<Text type="primary">ServiceNow incident status</Text>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>Firing</td>
<td>
<Controller
name={'additional_settings.state_mapping.firing'}
control={control}
render={({ field }) => (
<Select
{...field}
value={field.value?.[1]}
key="state_mapping.firing"
menuShouldPortal
className="select control"
options={ServiceNowHelper.getAvailableStatusOptions({
getValues,
currentAction: OnCallAGStatus.Firing,
alertReceiveChannelStore,
})}
onChange={(option: SelectableValue) => {
setValue(
'additional_settings.state_mapping.firing',
option ? [option.label, option.value] : null
);
forceUpdate();
}}
{...selectCommonProps}
/>
)}
/>
</td>
</tr>
<tr>
<td>Acknowledged</td>
<td>
<Controller
name={'additional_settings.state_mapping.acknowledged'}
control={control}
render={({ field }) => (
<Select
{...field}
value={field.value?.[1]}
defaultValue={field.value?.[1]}
menuShouldPortal
className="select control"
disabled={false}
options={ServiceNowHelper.getAvailableStatusOptions({
getValues,
currentAction: OnCallAGStatus.Acknowledged,
alertReceiveChannelStore,
})}
onChange={(option: SelectableValue) => {
setValue(
'additional_settings.state_mapping.acknowledged',
option ? [option.label, option.value] : null
);
forceUpdate();
}}
{...selectCommonProps}
/>
)}
/>
</td>
</tr>
<tr>
<td>Resolved</td>
<td>
<Controller
name={'additional_settings.state_mapping.resolved'}
control={control}
render={({ field }) => (
<Select
{...field}
value={field.value?.[1]}
defaultValue={field.value?.[1]}
menuShouldPortal
className="select control"
disabled={false}
options={ServiceNowHelper.getAvailableStatusOptions({
getValues,
currentAction: OnCallAGStatus.Resolved,
alertReceiveChannelStore,
})}
onChange={(option: SelectableValue) => {
setValue(
'additional_settings.state_mapping.resolved',
option ? [option.label, option.value] : null
);
forceUpdate();
}}
{...selectCommonProps}
/>
)}
/>
</td>
</tr>
<tr>
<td>Silenced</td>
<td>
<Controller
name={'additional_settings.state_mapping.silenced'}
control={control}
render={({ field }) => (
<Select
{...field}
value={field.value?.[1]}
menuShouldPortal
className="select control"
disabled={false}
options={ServiceNowHelper.getAvailableStatusOptions({
getValues,
currentAction: OnCallAGStatus.Silenced,
alertReceiveChannelStore,
})}
onChange={(option: SelectableValue) => {
setValue(
'additional_settings.state_mapping.silenced',
option ? [option.label, option.value] : null
);
forceUpdate();
}}
{...selectCommonProps}
/>
)}
/>
</td>
</tr>
</tbody>
</table>
</VerticalGroup>
);
});

View file

@ -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<string>(undefined);
const isLoading = useIsLoading(ActionKey.UPDATE_SERVICENOW_TOKEN);
useEffect(() => {
(async function () {
const hasToken = await AlertReceiveChannelHelper.checkIfServiceNowHasToken({ id });
setIsExistingToken(hasToken);
})();
}, []);
return (
<VerticalGroup>
<HorizontalGroup spacing="xs" align="center">
<Text type="primary" strong>
ServiceNow backsync API token
</Text>
</HorizontalGroup>
<Text>
Description for such object and{' '}
<a href={'#'} target="_blank" rel="noreferrer">
<Text type="link">link to documentation</Text>
</a>
</Text>
<RenderConditionally shouldRender={isExistingToken === undefined}>
<LoadingPlaceholder text="Loading..." />
</RenderConditionally>
<RenderConditionally shouldRender={isExistingToken !== undefined}>
<div className={styles.tokenContainer}>
<IntegrationInputField
placeholder={
currentToken
? ''
: isExistingToken
? 'A token had already been generated'
: 'Click Generate to create a token'
}
className={styles.buttonInputHeight}
inputClassName={styles.tokenInput}
iconsClassName={styles.tokenIcons}
value={currentToken}
showExternal={false}
showCopy={Boolean(currentToken)}
showEye={false}
isMasked={false}
/>
<Button variant="secondary" onClick={onTokenGenerate} disabled={isLoading}>
{isExistingToken ? 'Regenerate' : 'Generate'}
</Button>
</div>
</RenderConditionally>
</VerticalGroup>
);
async function onTokenGenerate() {
const res = await AlertReceiveChannelHelper.generateServiceNowToken({ id });
if (res?.token) {
setCurrentToken(res.token);
}
}
});
const getStyles = (theme: GrafanaTheme2) => {
return {
...getCommonServiceNowConfigStyles(theme),
};
};

View file

@ -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<ApiSchemas['IntegrationTokenPostResponse']> {
const result = await onCallApi({ skipErrorHandling }).POST('/alert_receive_channels/{id}/api_token/', {
params: { path: { id } },
});
return result.data;
}
static async testServiceNowAuthentication({
data,
}: {
data: OmitReadonlyMembers<ApiSchemas['AlertReceiveChannelUpdate']>;
}) {
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<ApiSchemas['AlertReceiveChannel'] | ApiSchemas['FastAlertReceiveChannel']>

View file

@ -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<ApiSchemas['AlertReceiveChannelIntegrationOptions']> = [];
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<void> {
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<void> {
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 = '') {

View file

@ -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',

View file

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

View file

@ -276,6 +276,7 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
const showLinkTo = !incident.dependent_alert_groups.length && !incident.root_alert_group && !incident.resolved;
const integrationNameWithEmojies = <Emoji text={incident.alert_receive_channel.verbal_name} />;
const sourceLink = incident?.render_for_web?.source_link;
const isServiceNow = incident?.alert_receive_channel?.integration === 'servicenow';
return (
<Block className={cx('block')}>
@ -391,6 +392,15 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
</Button>
</PluginLink>
{isServiceNow && (
<Button variant="secondary" fill="outline" size="sm" className={cx('label-button')}>
<HorizontalGroup spacing="xs">
<Icon name="exchange-alt" />
<span>Service Now</span>
</HorizontalGroup>
</Button>
)}
<Tooltip
placement="top"
content={

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { LabelTag } from '@grafana/labels';
import {
@ -48,6 +48,7 @@ import { IntegrationFormContainer } from 'containers/IntegrationForm/Integration
import { IntegrationLabelsForm } from 'containers/IntegrationLabelsForm/IntegrationLabelsForm';
import { IntegrationTemplate } from 'containers/IntegrationTemplate/IntegrationTemplate';
import { MaintenanceForm } from 'containers/MaintenanceForm/MaintenanceForm';
import { CompleteServiceNowModal } from 'containers/ServiceNowConfigDrawer/CompleteServiceNowConfigModal';
import { ServiceNowConfigDrawer } from 'containers/ServiceNowConfigDrawer/ServiceNowConfigDrawer';
import { TeamName } from 'containers/TeamName/TeamName';
import { UserDisplayWithAvatar } from 'containers/UserDisplay/UserDisplayWithAvatar';
@ -808,7 +809,7 @@ interface IntegrationActionsProps {
changeIsTemplateSettingsOpen: () => void;
}
type IntegrationDrawerKey = 'servicenow';
type IntegrationDrawerKey = 'servicenow' | 'completeConfig';
const IntegrationActions: React.FC<IntegrationActionsProps> = ({
alertReceiveChannel,
@ -831,6 +832,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
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<IntegrationActionsProps> = ({
const { id } = alertReceiveChannel;
useEffect(() => {
/* ServiceNow Only */
openServiceNowCompleteConfigurationDrawer();
}, []);
return (
<>
{confirmModal && (
@ -868,7 +875,11 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
/>
)}
{getIsDrawerOpened('servicenow') && <ServiceNowConfigDrawer onHide={() => closeDrawer()} />}
{getIsDrawerOpened('servicenow') && <ServiceNowConfigDrawer onHide={closeDrawer} />}
{isCompleteServiceNowConfigOpen && (
<CompleteServiceNowModal onHide={() => setIsCompleteServiceNowConfigOpen(false)} />
)}
{isIntegrationSettingsOpen && (
<IntegrationFormContainer
@ -1060,6 +1071,14 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
</>
);
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) {

View file

@ -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',
}

View file

@ -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