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:
parent
d6f6de3c84
commit
4854c835a2
19 changed files with 836 additions and 392 deletions
|
|
@ -21,3 +21,9 @@
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.button-input-height {
|
||||
input {
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export const getIntegrationFormStyles = (theme: GrafanaTheme2) => {
|
|||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
padding-top: 12px;
|
||||
`,
|
||||
|
||||
labels: css`
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
|
@ -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;
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
|
@ -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']>
|
||||
|
|
|
|||
|
|
@ -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 = '') {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue