SNOW final tweaks (#4254)

- Show `external_url` for an AG that has its integration connected to
ServiceNow
- Addressed some of @raphael-batte 's remarks
- Prevent SNOW creation if connection to servicenow instance fails
- Skip error handling that showed 500 or 401: Unauthorized on some calls
to test_connection and instead rely purely on displaying `OK` or `Error`
This commit is contained in:
Rares Mardare 2024-04-19 17:03:28 +03:00 committed by GitHub
parent baef4e2642
commit 55b154841f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 102 additions and 59 deletions

View file

@ -38,7 +38,7 @@ 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, generateAssignToTeamInputDescription, DOCS_ROOT } from 'utils/consts';
import { PLUGIN_ROOT, generateAssignToTeamInputDescription, DOCS_ROOT, INTEGRATION_SERVICENOW } from 'utils/consts';
import { useIsLoading } from 'utils/hooks';
import { OmitReadonlyMembers } from 'utils/types';
@ -58,6 +58,10 @@ export interface IntegrationFormFields {
additional_settings: ApiSchemas['AlertReceiveChannel']['additional_settings'];
}
interface AuthSection {
testConnection(): Promise<boolean>;
}
interface IntegrationFormProps {
id: ApiSchemas['AlertReceiveChannel']['id'] | 'new';
isTableView?: boolean;
@ -174,6 +178,7 @@ export const IntegrationForm = observer(
}, []);
const labelsRef = useRef(null);
const authSectionRef = useRef<AuthSection>(null);
const [labelsErrors, setLabelErrors] = useState([]);
const isServiceNow = getIsBidirectionalIntegration(data as Partial<ApiSchemas['AlertReceiveChannel']>);
@ -347,7 +352,7 @@ export const IntegrationForm = observer(
)}
/>
<ServiceNowAuthSection />
<ServiceNowAuthSection ref={authSectionRef} />
<Controller
name={'create_default_webhooks'}
@ -414,14 +419,21 @@ export const IntegrationForm = observer(
labels: labels ? [...labels] : undefined,
};
if (formData.integration !== 'servicenow') {
const isServiceNow = formData.integration === INTEGRATION_SERVICENOW;
if (!isServiceNow) {
delete data.additional_settings;
}
const isCreate = id === 'new';
if (isServiceNow && isNew) {
const testResult = await authSectionRef?.current?.testConnection();
if (!testResult) {
return;
}
}
try {
if (isCreate) {
if (isNew) {
await createNewIntegration();
} else {
await alertReceiveChannelStore.update({ id, data, skipErrorHandling: true });

View file

@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, HorizontalGroup, Modal, useStyles2 } from '@grafana/ui';
import { FormProvider, useForm } from 'react-hook-form';
@ -51,12 +52,14 @@ export const CompleteServiceNowModal: React.FC<CompleteServiceNowConfigModalProp
>
<FormProvider {...formMethods}>
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className={styles.border}>
<ServiceNowStatusSection />
</div>
<div className={styles.scrollableContainer}>
<div className={styles.border}>
<ServiceNowStatusSection />
</div>
<div className={styles.border}>
<ServiceNowTokenSection />
<div className={styles.border}>
<ServiceNowTokenSection />
</div>
</div>
<div>
@ -126,5 +129,15 @@ export const CompleteServiceNowModal: React.FC<CompleteServiceNowConfigModalProp
const getStyles = (theme: GrafanaTheme2) => {
return {
...getCommonServiceNowConfigStyles(theme),
scrollableContainer: css`
max-height: 60vh;
overflow-y: auto;
margin-bottom: 16px;
@media (max-height: 764px) {
max-height: 40vh;
}
`,
};
};

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { forwardRef, useImperativeHandle, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, Button, HorizontalGroup, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
@ -11,55 +11,66 @@ import { IntegrationFormFields } from 'containers/IntegrationForm/IntegrationFor
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 { INTEGRATION_SERVICENOW } from 'utils/consts';
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>();
export const ServiceNowAuthSection = observer(
forwardRef(function ServiceNowAuthRef(_props, ref) {
const { getValues } = useFormContext<ServiceNowFormFields | IntegrationFormFields>();
const currentIntegration = useCurrentIntegration();
const [isAuthTestRunning, setIsAuthTestRunning] = useState(false);
const [authTestResult, setAuthTestResult] = useState<boolean>(undefined);
const styles = useStyles2(getStyles);
const currentIntegration = useCurrentIntegration();
const [isAuthTestRunning, setIsAuthTestRunning] = useState(false);
const [authTestResult, setAuthTestResult] = useState<boolean>(undefined);
const styles = useStyles2(getStyles);
return (
<div>
<RenderConditionally shouldRender={!isAuthTestRunning && authTestResult !== undefined}>
<Alert
severity={authTestResult ? 'success' : 'error'}
title={
(<Text type="primary">{authTestResult ? 'Connection OK' : 'Connection failed'}</Text>) as unknown as string
}
/>
</RenderConditionally>
useImperativeHandle(ref, () => ({
testConnection: onAuthTest,
}));
<HorizontalGroup>
<Button className={''} variant="secondary" onClick={onAuthTest} disabled={isAuthTestRunning}>
Test Connection
</Button>
<div>
<RenderConditionally shouldRender={isAuthTestRunning}>
<LoadingPlaceholder text="Loading..." className={styles.loader} />
</RenderConditionally>
</div>
</HorizontalGroup>
</div>
);
return (
<div>
<RenderConditionally shouldRender={!isAuthTestRunning && authTestResult !== undefined}>
<Alert
severity={authTestResult ? 'success' : 'error'}
title={
(
<Text type="primary">{authTestResult ? 'Connection OK' : 'Connection failed'}</Text>
) as unknown as string
}
/>
</RenderConditionally>
async function onAuthTest() {
const data: OmitReadonlyMembers<ApiSchemas['AlertReceiveChannel']> = {
integration: currentIntegration ? currentIntegration.integration : 'servicenow',
...getValues(),
};
<HorizontalGroup>
<Button className={''} variant="secondary" onClick={onAuthTest} disabled={isAuthTestRunning}>
Test Connection
</Button>
<div>
<RenderConditionally shouldRender={isAuthTestRunning}>
<LoadingPlaceholder text="Loading..." className={styles.loader} />
</RenderConditionally>
</div>
</HorizontalGroup>
</div>
);
setIsAuthTestRunning(true);
const result = await AlertReceiveChannelHelper.testServiceNowAuthentication({ id: currentIntegration?.id, data });
setAuthTestResult(result);
setIsAuthTestRunning(false);
}
});
async function onAuthTest(): Promise<boolean> {
const data: OmitReadonlyMembers<ApiSchemas['AlertReceiveChannel']> = {
integration: currentIntegration ? currentIntegration.integration : INTEGRATION_SERVICENOW,
...getValues(),
};
setIsAuthTestRunning(true);
const result = await AlertReceiveChannelHelper.testServiceNowAuthentication({ id: currentIntegration?.id, data });
setAuthTestResult(result);
setIsAuthTestRunning(false);
return result;
}
})
);
const getStyles = (theme: GrafanaTheme2) => {
return {

View file

@ -85,7 +85,7 @@ export class AlertReceiveChannelHelper {
? '/alert_receive_channels/{id}/test_connection/'
: '/alert_receive_channels/test_connection/';
const result = await onCallApi({ skipErrorHandling: false }).POST(endpoint, {
const result = await onCallApi({ skipErrorHandling: true }).POST(endpoint, {
body: data as ApiSchemas['AlertReceiveChannelUpdate'],
params: { path: { id } },
});

View file

@ -55,7 +55,7 @@ import { PageProps, WithStoreProps } from 'state/types';
import { useStore } from 'state/useStore';
import { withMobXProviderContext } from 'state/withStore';
import { UserActions } from 'utils/authorization/authorization';
import { PLUGIN_ROOT } from 'utils/consts';
import { INTEGRATION_SERVICENOW, PLUGIN_ROOT } from 'utils/consts';
import { sanitize } from 'utils/sanitize';
import { parseURL } from 'utils/url';
import { openNotification } from 'utils/utils';
@ -278,7 +278,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';
const isServiceNow = Boolean(incident?.external_urls?.find((el) => el.integration_type === INTEGRATION_SERVICENOW));
return (
<Block className={cx('block')}>

View file

@ -6,6 +6,7 @@ import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { AppFeature } from 'state/features';
import { RootStore } from 'state/rootStore';
import { INTEGRATION_SERVICENOW } from 'utils/consts';
import { MAX_CHARACTERS_COUNT, TEXTAREA_ROWS_COUNT } from './IntegrationCommon.config';
@ -122,4 +123,4 @@ export const IntegrationHelper = {
};
export const getIsBidirectionalIntegration = ({ integration }: Partial<ApiSchemas['AlertReceiveChannel']>) =>
integration === ('servicenow' as ApiSchemas['AlertReceiveChannel']['integration']); // TODO: add service now in backend schema as valid value and remove casting
integration === INTEGRATION_SERVICENOW;

View file

@ -67,7 +67,7 @@ import { useStore } from 'state/useStore';
import { withMobXProviderContext } from 'state/withStore';
import { LocationHelper } from 'utils/LocationHelper';
import { UserActions } from 'utils/authorization/authorization';
import { GENERIC_ERROR, PLUGIN_ROOT } from 'utils/consts';
import { GENERIC_ERROR, INTEGRATION_SERVICENOW, PLUGIN_ROOT } from 'utils/consts';
import { withDrawer } from 'utils/hoc';
import { useDrawer } from 'utils/hooks';
import { getItem, setItem } from 'utils/localStorage';
@ -275,7 +275,11 @@ class _IntegrationPage extends React.Component<IntegrationProps, IntegrationStat
{ label: 'Incoming', content: incomingPart },
{
label: 'Outgoing',
content: <OutgoingTab openSnowConfigurationDrawer={() => drawerConfig.openDrawer('servicenow')} />,
content: (
<OutgoingTab
openSnowConfigurationDrawer={() => drawerConfig.openDrawer(INTEGRATION_SERVICENOW)}
/>
),
},
]}
/>
@ -819,7 +823,7 @@ interface IntegrationActionsProps {
drawerConfig: ReturnType<typeof useDrawer<IntegrationDrawerKey>>;
}
type IntegrationDrawerKey = 'servicenow' | 'completeConfig';
type IntegrationDrawerKey = typeof INTEGRATION_SERVICENOW | 'completeConfig';
const IntegrationActions: React.FC<IntegrationActionsProps> = ({
alertReceiveChannel,
@ -885,7 +889,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
/>
)}
{getIsDrawerOpened('servicenow') && <ServiceNowConfigDrawer onHide={closeDrawer} />}
{getIsDrawerOpened(INTEGRATION_SERVICENOW) && <ServiceNowConfigDrawer onHide={closeDrawer} />}
{isCompleteServiceNowConfigOpen && (
<CompleteServiceNowModal onHide={() => setIsCompleteServiceNowConfigOpen(false)} />
@ -958,7 +962,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
{
label: 'ServiceNow configuration',
hidden: !getIsBidirectionalIntegration(alertReceiveChannel),
onClick: () => openDrawer('servicenow'),
onClick: () => openDrawer(INTEGRATION_SERVICENOW),
},
{
onClick: openLabelsForm,

View file

@ -85,3 +85,5 @@ export enum OnCallAGStatus {
}
export const GENERIC_ERROR = 'An error has occurred. Please try again';
export const INTEGRATION_SERVICENOW = 'servicenow';