Rares/add template editor to webhooks (#2455)

# What this PR does

Bring new Jinja editor to webhooks

## Which issue(s) this PR fixes

https://github.com/grafana/oncall/issues/2344

## Checklist

- [ ] Unit, integration, and e2e (if applicable) tests updated
- [ ] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)

---------

Co-authored-by: Maxim <maxim.mordasov@grafana.com>
Co-authored-by: Michael Derynck <michael.derynck@grafana.com>
This commit is contained in:
Rares Mardare 2023-07-11 21:03:34 +03:00 committed by GitHub
parent b951b6b6bd
commit 5ebf437283
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1024 additions and 385 deletions

View file

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Bring new Jinja editor to webhooks ([2344](https://github.com/grafana/oncall/issues/2344))
### Fixed
- Add debounce on Select UI components to avoid making API search requests on each key-down event by

View file

@ -22,6 +22,7 @@ class WebhookResponseSerializer(serializers.ModelSerializer):
"request_data",
"status_code",
"content",
"event_data",
]

View file

@ -9,6 +9,7 @@ from rest_framework.response import Response
from rest_framework.test import APIClient
from apps.api.permissions import LegacyAccessControlRole
from apps.api.views.webhooks import RECENT_RESPONSE_LIMIT, WEBHOOK_URL
from apps.webhooks.models import Webhook
from apps.webhooks.models.webhook import WEBHOOK_FIELD_PLACEHOLDER
@ -61,6 +62,7 @@ def test_get_list_webhooks(webhook_internal_api_setup, make_user_auth_headers):
"status_code": None,
"request_trigger": "",
"url": "",
"event_data": "",
},
"trigger_template": None,
"trigger_type": None,
@ -102,6 +104,7 @@ def test_get_detail_webhook(webhook_internal_api_setup, make_user_auth_headers):
"status_code": None,
"request_trigger": "",
"url": "",
"event_data": "",
},
"trigger_template": None,
"trigger_type": None,
@ -148,6 +151,7 @@ def test_create_webhook(mocked_check_webhooks_2_enabled, webhook_internal_api_se
"status_code": None,
"request_trigger": "",
"url": "",
"event_data": "",
},
"trigger_template": None,
"trigger_type_name": "Alert Group Created",
@ -207,6 +211,7 @@ def test_create_valid_templated_field(
"status_code": None,
"request_trigger": "",
"url": "",
"event_data": "",
},
"trigger_template": None,
"trigger_type_name": "Alert Group Created",
@ -485,3 +490,59 @@ def test_webhook_from_other_team_without_flag(
response = client.get(url, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
@pytest.mark.django_db
def test_get_webhook_responses(
make_organization_and_user_with_plugin_token,
make_team,
make_user_auth_headers,
make_custom_webhook,
make_webhook_response,
):
organization, user, token = make_organization_and_user_with_plugin_token()
team = make_team(organization)
webhook = make_custom_webhook(
organization=organization, team=team, trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED
)
for i in range(0, RECENT_RESPONSE_LIMIT + 1):
make_webhook_response(
webhook=webhook,
trigger_type=webhook.trigger_type,
status_code=200,
content=json.dumps({"id": "third-party-id"}),
event_data=json.dumps({"test": f"{i}"}),
)
client = APIClient()
url = reverse("api-internal:webhooks-responses", kwargs={"pk": webhook.public_primary_key})
response = client.get(url, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert len(response.data) == RECENT_RESPONSE_LIMIT
@pytest.mark.django_db
@pytest.mark.parametrize(
"test_template, test_payload, expected_result",
[
("https://test.com", None, "https://test.com"),
("https://test.com", "", "https://test.com"),
("{{ name }}", {"name": "test_1"}, "test_1"),
("{{ name }}", '{"name": "test_1"}', "test_1"),
],
)
def test_webhook_preview_template(
webhook_internal_api_setup, make_user_auth_headers, test_template, test_payload, expected_result
):
user, token, webhook = webhook_internal_api_setup
client = APIClient()
url = reverse("api-internal:webhooks-preview-template", kwargs={"pk": webhook.public_primary_key})
data = {
"template_name": WEBHOOK_URL,
"template_body": test_template,
"payload": test_payload,
}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.data["preview"] == expected_result

View file

@ -1,5 +1,8 @@
import json
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django_filters import rest_framework as filters
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import NotFound
from rest_framework.filters import SearchFilter
@ -8,12 +11,23 @@ from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from apps.api.permissions import RBACPermission
from apps.api.serializers.webhook import WebhookSerializer
from apps.api.serializers.webhook import WebhookResponseSerializer, WebhookSerializer
from apps.auth_token.auth import PluginAuthentication
from apps.webhooks.models import Webhook
from apps.webhooks.utils import is_webhooks_enabled_for_organization
from apps.webhooks.models import Webhook, WebhookResponse
from apps.webhooks.utils import apply_jinja_template_for_json, is_webhooks_enabled_for_organization
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, TeamModelMultipleChoiceFilter
from common.api_helpers.mixins import PublicPrimaryKeyMixin, TeamFilteringMixin
from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning
RECENT_RESPONSE_LIMIT = 20
WEBHOOK_URL = "url"
WEBHOOK_HEADERS = "headers"
WEBHOOK_TRIGGER_TEMPLATE = "trigger_template"
WEBHOOK_TRIGGER_DATA = "data"
WEBHOOK_TEMPLATE_NAMES = [WEBHOOK_URL, WEBHOOK_HEADERS, WEBHOOK_TRIGGER_TEMPLATE, WEBHOOK_TRIGGER_DATA]
class WebhooksFilter(ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, filters.FilterSet):
@ -33,6 +47,8 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
"update": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE],
"partial_update": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE],
"destroy": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE],
"responses": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ],
"preview_template": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE],
}
model = Webhook
@ -106,3 +122,44 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
filter_options = list(filter(lambda f: filter_name in f["name"], filter_options))
return Response(filter_options)
@action(methods=["get"], detail=True)
def responses(self, request, pk):
webhook = self.get_object()
queryset = WebhookResponse.objects.filter(webhook_id=webhook.id, trigger_type=webhook.trigger_type).order_by(
"-timestamp"
)[:RECENT_RESPONSE_LIMIT]
response_serializer = WebhookResponseSerializer(queryset, many=True)
return Response(response_serializer.data)
@action(methods=["post"], detail=True)
def preview_template(self, request, pk):
self.get_object() # Check webhook exists
template_body = request.data.get("template_body", None)
template_name = request.data.get("template_name", None)
payload = request.data.get("payload", None)
if not payload:
response = {"preview": template_body}
return Response(response, status=status.HTTP_200_OK)
if isinstance(payload, str):
try:
payload = json.loads(payload)
except json.JSONDecodeError:
raise BadRequest(detail={"payload": "Could not parse json"})
if template_body is None or template_name is None:
response = {"preview": None}
return Response(response, status=status.HTTP_200_OK)
if template_name not in WEBHOOK_TEMPLATE_NAMES:
raise BadRequest(detail={"template_name": "Unknown template name"})
try:
result = apply_jinja_template_for_json(template_body, payload)
except (JinjaTemplateError, JinjaTemplateWarning) as e:
return Response({"preview": e.fallback_message}, status.HTTP_200_OK)
response = {"preview": result}
return Response(response, status=status.HTTP_200_OK)

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.19 on 2023-07-05 18:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('webhooks', '0006_auto_20230426_1631'),
]
operations = [
migrations.AddField(
model_name='webhookresponse',
name='event_data',
field=models.TextField(default=None, null=True),
),
]

View file

@ -293,6 +293,7 @@ class WebhookResponse(models.Model):
url = models.TextField(null=True, default=None)
status_code = models.IntegerField(default=None, null=True)
content = models.TextField(null=True, default=None)
event_data = models.TextField(null=True, default=None)
def json(self):
if self.content:

View file

@ -110,6 +110,7 @@ def make_request(webhook, alert_group, data):
"status_code": None,
"content": None,
"webhook": webhook,
"event_data": json.dumps(data),
}
exception = error = None

View file

@ -12,7 +12,7 @@ import Block from 'components/GBlock/Block';
import MonacoEditor from 'components/MonacoEditor/MonacoEditor';
import SourceCode from 'components/SourceCode/SourceCode';
import Text from 'components/Text/Text';
import TemplatePreview from 'containers/TemplatePreview/TemplatePreview';
import TemplatePreview, { TEMPLATE_PAGE } from 'containers/TemplatePreview/TemplatePreview';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { Alert } from 'models/alertgroup/alertgroup.types';
@ -132,13 +132,6 @@ const AlertTemplatesForm = (props: AlertTemplatesFormProps) => {
}
}, [activeGroup]);
const getTemplatePreviewEditClickHandler = (templateName: string) => {
return () => {
const template = templatesToRender.find((template) => template.name === templateName);
setActiveTemplate(template);
};
};
useEffect(() => {
if (!activeTemplate && filteredTemplatesToRender.length) {
setActiveTemplate(filteredTemplatesToRender[0]);
@ -261,11 +254,10 @@ const AlertTemplatesForm = (props: AlertTemplatesFormProps) => {
<VerticalGroup style={{ width: '100%' }}>
{groups[activeGroup].map((template) => (
<TemplatePreview
active={template.name === activeTemplate?.name}
templatePage={TEMPLATE_PAGE.Integrations}
key={template.name}
templateName={template.name}
templateBody={tempValues[template.name] ?? templates[template.name]}
onEditClick={getTemplatePreviewEditClickHandler(template.name)}
alertReceiveChannelId={alertReceiveChannelId}
alertGroupId={alertGroupId}
/>

View file

@ -6,6 +6,8 @@ import cn from 'classnames/bind';
import Collapse from 'components/Collapse/Collapse';
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
import MonacoEditor from 'components/MonacoEditor/MonacoEditor';
import { MONACO_READONLY_CONFIG } from 'components/MonacoEditor/MonacoEditor.config';
import GSelect from 'containers/GSelect/GSelect';
import RemoteSelect from 'containers/RemoteSelect/RemoteSelect';
@ -17,6 +19,12 @@ interface GFormProps {
form: { name: string; fields: FormItem[] };
data: any;
onSubmit: (data: any) => void;
onFieldRender?: (
formItem: FormItem,
renderedControl: React.ReactElement,
values: any,
setValue: (value: string) => void
) => React.ReactElement;
}
const nullNormalizer = (value: string) => {
@ -110,6 +118,28 @@ function renderFormControl(formItem: FormItem, register: any, control: any, onCh
/>
);
case FormItemType.Monaco:
return (
<InputControl
control={control}
name={formItem.name}
render={({ field: { ...field } }) => {
return (
<MonacoEditor
{...field}
{...formItem.extra}
showLineNumbers={false}
monacoOptions={{
...MONACO_READONLY_CONFIG,
readOnly: formItem.isReadOnly,
}}
onChange={(value) => onChangeFn(field, value)}
/>
);
}}
/>
);
default:
return null;
}
@ -117,7 +147,7 @@ function renderFormControl(formItem: FormItem, register: any, control: any, onCh
class GForm extends React.Component<GFormProps, {}> {
render() {
const { form, data } = this.props;
const { form, data, onFieldRender } = this.props;
const openFields = form.fields.filter((field) => !field.collapsed);
const collapsedfields = form.fields.filter((field) => field.collapsed);
@ -131,6 +161,11 @@ class GForm extends React.Component<GFormProps, {}> {
return null;
}
const formControl = renderFormControl(formItem, register, control, (field, value) => {
field?.onChange(value);
this.forceUpdate();
});
return (
<Field
key={formIndex}
@ -140,10 +175,9 @@ class GForm extends React.Component<GFormProps, {}> {
error={formItem.label ? `${formItem.label} is required` : `${capitalCase(formItem.name)} is required`}
description={formItem.description}
>
{renderFormControl(formItem, register, control, (field, value) => {
field?.onChange(value);
this.forceUpdate();
})}
{onFieldRender
? onFieldRender(formItem, formControl, getValues(), (value) => setValue(formItem.name, value))
: formControl}
</Field>
);
};

View file

@ -7,12 +7,14 @@ export enum FormItemType {
'GSelect' = 'gselect',
'Switch' = 'switch',
'RemoteSelect' = 'remoteselect',
'Monaco' = 'monaco',
}
export interface FormItem {
name: string;
label?: string;
type: FormItemType;
isReadOnly?: boolean;
description?: string;
normalize?: (value: any) => any;
isVisible?: (data: any) => any;

View file

@ -0,0 +1,26 @@
// Mostly used for input fields where we're hiding scrollbars
export const MONACO_READONLY_CONFIG = {
renderLineHighlight: false,
readOnly: true,
scrollbar: {
vertical: 'hidden',
horizontal: 'hidden',
verticalScrollbarSize: 0,
handleMouseWheel: false,
},
hideCursorInOverviewRuler: true,
minimap: { enabled: false },
cursorStyle: {
display: 'none',
},
};
export const MONACO_EDITABLE_CONFIG = {
renderLineHighlight: false,
readOnly: false,
hideCursorInOverviewRuler: true,
minimap: { enabled: false },
cursorStyle: {
display: 'none',
},
};

View file

@ -11,7 +11,7 @@ declare const monaco: any;
interface MonacoEditorProps {
value: string;
disabled?: boolean;
height?: string;
height?: string | number;
focus?: boolean;
data: any;
showLineNumbers?: boolean;
@ -20,6 +20,7 @@ interface MonacoEditorProps {
onChange?: (value: string) => void;
loading?: boolean;
monacoOptions?: any;
suggestionPrefix?: string;
}
export enum MONACO_LANGUAGE {
@ -48,15 +49,18 @@ const MonacoEditor: FC<MonacoEditorProps> = (props) => {
monacoOptions,
showLineNumbers = true,
loading = false,
suggestionPrefix = 'payload.',
} = props;
const autoCompleteList = useCallback(
() =>
[...PREDEFINED_TERMS, ...getPaths(data?.payload_example).map((str) => `payload.${str}`)].map((str) => ({
label: str,
insertText: str,
kind: CodeEditorSuggestionItemKind.Field,
})),
[...PREDEFINED_TERMS, ...getPaths(data?.payload_example).map((str) => `${suggestionPrefix}${str}`)].map(
(str) => ({
label: str,
insertText: str,
kind: CodeEditorSuggestionItemKind.Field,
})
),
[data?.payload_example]
);

View file

@ -20,6 +20,7 @@ import HamburgerMenu from 'components/HamburgerMenu/HamburgerMenu';
import IntegrationBlock from 'components/Integrations/IntegrationBlock';
import IntegrationBlockItem from 'components/Integrations/IntegrationBlockItem';
import MonacoEditor from 'components/MonacoEditor/MonacoEditor';
import { MONACO_READONLY_CONFIG } from 'components/MonacoEditor/MonacoEditor.config';
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import TooltipBadge from 'components/TooltipBadge/TooltipBadge';
@ -35,7 +36,7 @@ import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
import { EscalationChain } from 'models/escalation_chain/escalation_chain.types';
import CommonIntegrationHelper from 'pages/integration/CommonIntegration.helper';
import IntegrationHelper from 'pages/integration/Integration.helper';
import { MONACO_INPUT_HEIGHT_SMALL, MONACO_OPTIONS } from 'pages/integration/IntegrationCommon.config';
import { MONACO_INPUT_HEIGHT_SMALL } from 'pages/integration/IntegrationCommon.config';
import { useStore } from 'state/useStore';
import { openNotification } from 'utils';
import { UserActions } from 'utils/authorization';
@ -164,7 +165,7 @@ const ExpandedIntegrationRouteDisplay: React.FC<ExpandedIntegrationRouteDisplayP
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
monacoOptions={MONACO_READONLY_CONFIG}
/>
</div>
<Button

View file

@ -6,13 +6,14 @@ import cn from 'classnames/bind';
import IntegrationBlockItem from 'components/Integrations/IntegrationBlockItem';
import IntegrationTemplateBlock from 'components/Integrations/IntegrationTemplateBlock';
import MonacoEditor from 'components/MonacoEditor/MonacoEditor';
import { MONACO_READONLY_CONFIG } from 'components/MonacoEditor/MonacoEditor.config';
import Text from 'components/Text/Text';
import { templatesToRender } from 'containers/IntegrationContainers/IntegrationTemplatesList.config';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertTemplatesDTO } from 'models/alert_templates';
import IntegrationHelper from 'pages/integration/Integration.helper';
import styles from 'pages/integration/Integration.module.scss';
import { MONACO_INPUT_HEIGHT_TALL, MONACO_OPTIONS } from 'pages/integration/IntegrationCommon.config';
import { MONACO_INPUT_HEIGHT_TALL } from 'pages/integration/IntegrationCommon.config';
import { useStore } from 'state/useStore';
import { openErrorNotification, openNotification } from 'utils';
@ -108,7 +109,7 @@ const IntegrationTemplateList: React.FC<IntegrationTemplateListProps> = ({
height={contents.height}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
monacoOptions={MONACO_READONLY_CONFIG}
/>
</div>
)}

View file

@ -1,6 +1,6 @@
import React, { useCallback, useState, useEffect } from 'react';
import { Button, HorizontalGroup, Drawer, VerticalGroup, Icon } from '@grafana/ui';
import { Button, HorizontalGroup, Drawer, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import { debounce } from 'lodash-es';
import { observer } from 'mobx-react';
@ -12,11 +12,10 @@ import {
slackMessageTemplateCheatSheet,
genericTemplateCheatSheet,
} from 'components/CheatSheet/CheatSheet.config';
import Block from 'components/GBlock/Block';
import MonacoEditor from 'components/MonacoEditor/MonacoEditor';
import Text from 'components/Text/Text';
import TemplatePreview from 'containers/TemplatePreview/TemplatePreview';
import TemplatesAlertGroupsList from 'containers/TemplatesAlertGroupsList/TemplatesAlertGroupsList';
import TemplateResult from 'containers/TemplateResult/TemplateResult';
import TemplatesAlertGroupsList, { TEMPLATE_PAGE } from 'containers/TemplatesAlertGroupsList/TemplatesAlertGroupsList';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertTemplatesDTO } from 'models/alert_templates';
@ -188,43 +187,15 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
<div className={cx('container-wrapper')}>
<div className={cx('container')} id={'content-container-id'}>
<TemplatesAlertGroupsList
templatePage={TEMPLATE_PAGE.Integrations}
alertReceiveChannelId={id}
onEditPayload={onEditPayload}
onSelectAlertGroup={onSelectAlertGroup}
templates={templates}
onLoadAlertGroupsList={onLoadAlertGroupsList}
/>
{isCheatSheetVisible ? (
<CheatSheet
cheatSheetName={template.displayName}
cheatSheetData={getCheatSheet(template.name)}
onClose={onCloseCheatSheet}
/>
) : (
<>
<div className={cx('template-block-codeeditor')}>
<div className={cx('template-editor-block-title')}>
<HorizontalGroup justify="space-between" align="center" wrap>
<Text>Template editor</Text>
<Button variant="secondary" fill="outline" onClick={onShowCheatSheet} icon="book" size="sm">
Cheatsheet
</Button>
</HorizontalGroup>
</div>
<div className={cx('template-editor-block-content')}>
<MonacoEditor
value={changedTemplateBody}
data={templates}
showLineNumbers={true}
height={editorHeight}
onChange={getChangeHandler()}
/>
</div>
</div>
</>
)}
<Result
{renderCheatSheet()}
<TemplateResult
alertReceiveChannelId={id}
template={template}
templateBody={changedTemplateBody}
@ -238,88 +209,43 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
</div>
</Drawer>
);
function renderCheatSheet() {
if (isCheatSheetVisible) {
return (
<CheatSheet
cheatSheetName={template.displayName}
cheatSheetData={getCheatSheet(template.name)}
onClose={onCloseCheatSheet}
/>
);
}
return (
<>
<div className={cx('template-block-codeeditor')}>
<div className={cx('template-editor-block-title')}>
<HorizontalGroup justify="space-between" align="center" wrap>
<Text>Template editor</Text>
<Button variant="secondary" fill="outline" onClick={onShowCheatSheet} icon="book" size="sm">
Cheatsheet
</Button>
</HorizontalGroup>
</div>
<div className={cx('template-editor-block-content')}>
<MonacoEditor
value={changedTemplateBody}
data={templates}
showLineNumbers={true}
height={editorHeight}
onChange={getChangeHandler()}
/>
</div>
</div>
</>
);
}
});
interface ResultProps {
alertReceiveChannelId: AlertReceiveChannel['id'];
// templateName: string;
templateBody: string;
template: TemplateForEdit;
isAlertGroupExisting?: boolean;
chatOpsPermalink?: string;
payload?: JSON;
error?: string;
onSaveAndFollowLink?: (link: string) => void;
templateIsRoute?: boolean;
}
const Result = (props: ResultProps) => {
const {
alertReceiveChannelId,
template,
templateBody,
chatOpsPermalink,
payload,
error,
isAlertGroupExisting,
onSaveAndFollowLink,
} = props;
return (
<div className={cx('template-block-result')}>
<div className={cx('template-block-title')}>
<HorizontalGroup justify="space-between">
<Text>Result</Text>
</HorizontalGroup>
</div>
<div className={cx('result')}>
{payload || error ? (
<VerticalGroup spacing="lg">
{error ? (
<Block bordered fullWidth withBackground>
<Text>{error}</Text>
</Block>
) : (
<Block bordered fullWidth withBackground>
<TemplatePreview
key={template.name}
templateName={template.name}
templateBody={templateBody}
templateType={template.type}
templateIsRoute={template.isRoute}
alertReceiveChannelId={alertReceiveChannelId}
payload={payload}
/>
</Block>
)}
{template?.additionalData?.additionalDescription && (
<Text type="secondary">{template?.additionalData.additionalDescription}</Text>
)}
{template?.additionalData?.chatOpsName && isAlertGroupExisting && (
<VerticalGroup>
<Button onClick={() => onSaveAndFollowLink(chatOpsPermalink)}>
<HorizontalGroup spacing="xs" align="center">
Save and open Alert Group in {template.additionalData.chatOpsDisplayName}{' '}
<Icon name="external-link-alt" />
</HorizontalGroup>
</Button>
{template.additionalData.data && <Text type="secondary">{template.additionalData.data}</Text>}
</VerticalGroup>
)}
</VerticalGroup>
) : (
<div>
<Block bordered fullWidth className={cx('block-style')} withBackground>
<Text> Select alert group or "Use custom payload"</Text>
</Block>
</div>
)}
</div>
</div>
);
};
export default IntegrationTemplate;

View file

@ -141,14 +141,18 @@ export const form: { name: string; fields: FormItem[] } = {
{
name: 'url',
label: 'Webhook URL',
type: FormItemType.Input,
type: FormItemType.Monaco,
validation: { required: true },
extra: {
height: 30,
},
},
{
name: 'headers',
label: 'Webhook Headers',
description: 'Request headers should be in JSON format.',
type: FormItemType.TextArea,
type: FormItemType.Monaco,
isReadOnly: true,
extra: {
rows: 3,
},
@ -169,7 +173,8 @@ export const form: { name: string; fields: FormItem[] } = {
},
{
name: 'trigger_template',
type: FormItemType.TextArea,
type: FormItemType.Monaco,
isReadOnly: true,
description:
'Trigger template is used to conditionally execute the webhook based on incoming data. The trigger template must be empty or evaluate to true or 1 for the webhook to be sent',
extra: {
@ -185,12 +190,11 @@ export const form: { name: string; fields: FormItem[] } = {
{
name: 'data',
getDisabled: (data) => Boolean(data?.forward_all),
type: FormItemType.TextArea,
type: FormItemType.Monaco,
isReadOnly: true,
description:
'Available variables: {{ event }}, {{ user }}, {{ alert_group }}, {{ alert_group_id }}, {{ alert_payload }}, {{ integration }}, {{ notified_users }}, {{ users_to_be_notified }}, {{ responses }}',
extra: {
rows: 9,
},
extra: {},
},
],
};

View file

@ -13,3 +13,18 @@
.tabs__content {
padding-top: 16px;
}
.form-row {
display: flex;
flex-wrap: nowrap;
gap: 4px;
}
.form-field {
flex-grow: 1;
}
/* TODO: figure out why this is not picked */
.webhooks__drawerContent .cursor.monaco-mouse-cursor-text {
display: none !important;
}

View file

@ -6,8 +6,10 @@ import { observer } from 'mobx-react';
import { useHistory } from 'react-router-dom';
import GForm from 'components/GForm/GForm';
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
import Text from 'components/Text/Text';
import OutgoingWebhook2Status from 'containers/OutgoingWebhook2Status/OutgoingWebhook2Status';
import WebhooksTemplateEditor from 'containers/WebhooksTemplateEditor/WebhooksTemplateEditor';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { OutgoingWebhook2 } from 'models/outgoing_webhook_2/outgoing_webhook_2.types';
import { WebhookFormActionType } from 'pages/outgoing_webhooks_2/OutgoingWebhooks2.types';
@ -38,6 +40,8 @@ export const WebhookTabs = {
const OutgoingWebhook2Form = observer((props: OutgoingWebhook2FormProps) => {
const history = useHistory();
const { id, action, onUpdate, onHide, onDelete } = props;
const [onFormChangeFn, setOnFormChangeFn] = useState<{ fn: (value: string) => void }>(undefined);
const [templateToEdit, setTemplateToEdit] = useState(undefined);
const [activeTab, setActiveTab] = useState<string>(
action === WebhookFormActionType.EDIT_SETTINGS ? WebhookTabs.Settings.key : WebhookTabs.LastRun.key
);
@ -56,6 +60,31 @@ const OutgoingWebhook2Form = observer((props: OutgoingWebhook2FormProps) => {
[id]
);
const getTemplateEditClickHandler = (formItem: FormItem, values, setFormFieldValue) => {
return () => {
const formValue = values[formItem.name];
setTemplateToEdit({ value: formValue, displayName: undefined, description: undefined, name: formItem.name });
setOnFormChangeFn({ fn: (value) => setFormFieldValue(value) });
};
};
const enrchField = (formItem: FormItem, renderedControl: React.ReactElement, values, setFormFieldValue) => {
if (formItem.type === FormItemType.Monaco) {
return (
<div className={cx('form-row')}>
<div className={cx('form-field')}>{renderedControl}</div>
<Button
icon="edit"
variant="secondary"
onClick={getTemplateEditClickHandler(formItem, values, setFormFieldValue)}
/>
</div>
);
}
return renderedControl;
};
if (
(action === WebhookFormActionType.EDIT_SETTINGS || action === WebhookFormActionType.VIEW_LAST_RUN) &&
!outgoingWebhook2Store.items[id]
@ -86,51 +115,79 @@ const OutgoingWebhook2Form = observer((props: OutgoingWebhook2FormProps) => {
return null;
}
const formElement = <GForm form={form} data={data} onSubmit={handleSubmit} onFieldRender={enrchField} />;
if (action === WebhookFormActionType.NEW || action === WebhookFormActionType.COPY) {
// show just the creation form, not the tabs
return (
<Drawer scrollableContent title={'Create Outgoing Webhook'} onClose={onHide} closeOnMaskClick={false}>
{renderWebhookForm()}
</Drawer>
<>
<Drawer scrollableContent title={'Create Outgoing Webhook'} onClose={onHide} closeOnMaskClick={false}>
<div className="webhooks__drawerContent">{renderWebhookForm()}</div>
</Drawer>
{templateToEdit && (
<WebhooksTemplateEditor
id={id}
handleSubmit={(value) => onFormChangeFn?.fn(value)}
onHide={() => setTemplateToEdit(undefined)}
template={templateToEdit}
/>
)}
</>
);
}
return (
// show tabbed drawer (edit/live_run)
<Drawer scrollableContent title={'Outgoing webhook details'} onClose={onHide} closeOnMaskClick={false}>
<TabsBar>
<Tab
key={WebhookTabs.Settings.key}
onChangeTab={() => {
setActiveTab(WebhookTabs.Settings.key);
history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2/edit/${id}`);
}}
active={activeTab === WebhookTabs.Settings.key}
label={WebhookTabs.Settings.value}
/>
<>
<Drawer scrollableContent title={'Outgoing webhook details'} onClose={onHide} closeOnMaskClick={false}>
<div className={cx('webhooks__drawerContent')}>
<TabsBar>
<Tab
key={WebhookTabs.Settings.key}
onChangeTab={() => {
setActiveTab(WebhookTabs.Settings.key);
history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2/edit/${id}`);
}}
active={activeTab === WebhookTabs.Settings.key}
label={WebhookTabs.Settings.value}
/>
<Tab
key={WebhookTabs.LastRun.key}
onChangeTab={() => {
setActiveTab(WebhookTabs.LastRun.key);
history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2/last_run/${id}`);
}}
active={activeTab === WebhookTabs.LastRun.key}
label={WebhookTabs.LastRun.value}
/>
</TabsBar>
<Tab
key={WebhookTabs.LastRun.key}
onChangeTab={() => {
setActiveTab(WebhookTabs.LastRun.key);
history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2/last_run/${id}`);
}}
active={activeTab === WebhookTabs.LastRun.key}
label={WebhookTabs.LastRun.value}
/>
</TabsBar>
<WebhookTabsContent
id={id}
action={action}
activeTab={activeTab}
data={data}
handleSubmit={handleSubmit}
onDelete={onDelete}
onHide={onHide}
onUpdate={onUpdate}
/>
</Drawer>
<WebhookTabsContent
id={id}
action={action}
activeTab={activeTab}
data={data}
handleSubmit={handleSubmit}
onDelete={onDelete}
onHide={onHide}
onUpdate={onUpdate}
formElement={formElement}
/>
</div>
</Drawer>
{templateToEdit && (
<WebhooksTemplateEditor
id={id}
handleSubmit={(value) => {
onFormChangeFn?.fn(value);
setTemplateToEdit(undefined);
}}
onHide={() => setTemplateToEdit(undefined)}
template={templateToEdit}
/>
)}
</>
);
function renderWebhookForm() {
@ -170,6 +227,7 @@ interface WebhookTabsProps {
onUpdate: () => void;
onDelete: () => void;
handleSubmit: (data: Partial<OutgoingWebhook2>) => void;
formElement: React.ReactElement;
}
const WebhookTabsContent: React.FC<WebhookTabsProps> = ({
@ -177,10 +235,10 @@ const WebhookTabsContent: React.FC<WebhookTabsProps> = ({
action,
activeTab,
data,
handleSubmit,
onHide,
onUpdate,
onDelete,
formElement,
}) => {
const [confirmationModal, setConfirmationModal] = useState<ConfirmModalProps>(undefined);
@ -193,7 +251,7 @@ const WebhookTabsContent: React.FC<WebhookTabsProps> = ({
{activeTab === WebhookTabs.Settings.key && (
<>
<div className={cx('content')} data-testid="test__outgoingWebhook2EditForm">
<GForm form={form} data={data} onSubmit={handleSubmit} />
{formElement}
<div className={cx('buttons')}>
<HorizontalGroup justify={'flex-end'}>
<Button variant="secondary" onClick={onHide}>

View file

@ -7,6 +7,7 @@ import { observer } from 'mobx-react';
import Text from 'components/Text/Text';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { Alert } from 'models/alertgroup/alertgroup.types';
import { OutgoingWebhook2 } from 'models/outgoing_webhook_2/outgoing_webhook_2.types';
import { useStore } from 'state/useStore';
import { openErrorNotification } from 'utils';
import { useDebouncedCallback } from 'utils/hooks';
@ -23,28 +24,43 @@ interface TemplatePreviewProps {
templateIsRoute?: boolean;
payload?: JSON;
alertReceiveChannelId: AlertReceiveChannel['id'];
onEditClick?: () => void;
alertGroupId?: Alert['pk'];
active?: boolean;
onResult?: (result) => void;
outgoingWebhookId?: OutgoingWebhook2['id'];
templatePage: TEMPLATE_PAGE;
}
interface ConditionalResult {
isResult?: boolean;
value?: string;
}
export enum TEMPLATE_PAGE {
Integrations,
Webhooks,
}
const TemplatePreview = observer((props: TemplatePreviewProps) => {
const { templateName, templateBody, templateType, payload, alertReceiveChannelId, alertGroupId, templateIsRoute } =
props;
const {
templateName,
templateBody,
templateType,
payload,
alertReceiveChannelId,
outgoingWebhookId,
alertGroupId,
templateIsRoute,
templatePage,
} = props;
const [result, setResult] = useState<{ preview: string | null } | undefined>(undefined);
const [conditionalResult, setConditionalResult] = useState<ConditionalResult>({});
const store = useStore();
const { alertReceiveChannelStore, alertGroupStore } = store;
const { alertReceiveChannelStore, alertGroupStore, outgoingWebhook2Store } = store;
const handleTemplateBodyChange = useDebouncedCallback(() => {
(alertGroupId
(templatePage === TEMPLATE_PAGE.Webhooks
? outgoingWebhook2Store.renderPreview(outgoingWebhookId, templateName, templateBody, payload)
: alertGroupId
? alertGroupStore.renderPreview(alertGroupId, templateName, templateBody)
: alertReceiveChannelStore.renderPreview(alertReceiveChannelId, templateName, templateBody, payload)
)

View file

@ -0,0 +1,105 @@
import React from 'react';
import { Button, HorizontalGroup, Icon, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import { TemplateForEdit } from 'components/AlertTemplates/CommonAlertTemplatesForm.config';
import Block from 'components/GBlock/Block';
import Text from 'components/Text/Text';
import styles from 'containers/IntegrationTemplate/IntegrationTemplate.module.scss';
import TemplatePreview, { TEMPLATE_PAGE } from 'containers/TemplatePreview/TemplatePreview';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { OutgoingWebhook2 } from 'models/outgoing_webhook_2/outgoing_webhook_2.types';
const cx = cn.bind(styles);
interface ResultProps {
alertReceiveChannelId?: AlertReceiveChannel['id'];
outgoingWebhookId?: OutgoingWebhook2['id'];
templateBody: string;
template: TemplateForEdit;
isAlertGroupExisting?: boolean;
chatOpsPermalink?: string;
payload?: JSON;
error?: string;
onSaveAndFollowLink?: (link: string) => void;
templateIsRoute?: boolean;
templatePage?: TEMPLATE_PAGE;
}
const TemplateResult = (props: ResultProps) => {
const {
alertReceiveChannelId,
outgoingWebhookId,
template,
templateBody,
chatOpsPermalink,
payload,
error,
isAlertGroupExisting,
onSaveAndFollowLink,
templatePage = TEMPLATE_PAGE.Integrations,
} = props;
return (
<div className={cx('template-block-result')}>
<div className={cx('template-block-title')}>
<HorizontalGroup justify="space-between">
<Text>Result</Text>
</HorizontalGroup>
</div>
<div className={cx('result')}>
{payload || error ? (
<VerticalGroup spacing="lg">
{error ? (
<Block bordered fullWidth withBackground>
<Text>{error}</Text>
</Block>
) : (
<Block bordered fullWidth withBackground>
<TemplatePreview
key={template.name}
templatePage={templatePage}
templateName={template.name}
templateBody={templateBody}
templateType={template.type}
templateIsRoute={template.isRoute}
alertReceiveChannelId={alertReceiveChannelId}
outgoingWebhookId={outgoingWebhookId}
payload={payload}
/>
</Block>
)}
{template?.additionalData?.additionalDescription && (
<Text type="secondary">{template?.additionalData.additionalDescription}</Text>
)}
{template?.additionalData?.chatOpsName && isAlertGroupExisting && (
<VerticalGroup>
<Button onClick={() => onSaveAndFollowLink(chatOpsPermalink)}>
<HorizontalGroup spacing="xs" align="center">
Save and open Alert Group in {template.additionalData.chatOpsDisplayName}{' '}
<Icon name="external-link-alt" />
</HorizontalGroup>
</Button>
{template.additionalData.data && <Text type="secondary">{template.additionalData.data}</Text>}
</VerticalGroup>
)}
</VerticalGroup>
) : (
<div>
<Block bordered fullWidth className={cx('block-style')} withBackground>
<Text>
Select {templatePage === TEMPLATE_PAGE.Webhooks ? 'event' : 'alert group'} or "Use custom payload"
</Text>
</Block>
</div>
)}
</div>
</div>
);
};
export default TemplateResult;

View file

@ -1,16 +1,17 @@
import React, { useEffect, useState } from 'react';
import { Button, HorizontalGroup, Tooltip, Icon, IconButton, Badge, LoadingPlaceholder } from '@grafana/ui';
import { Button, HorizontalGroup, Icon, IconButton, Badge, LoadingPlaceholder } from '@grafana/ui';
import cn from 'classnames/bind';
import { debounce } from 'lodash-es';
import MonacoEditor, { MONACO_LANGUAGE } from 'components/MonacoEditor/MonacoEditor';
import { MONACO_EDITABLE_CONFIG } from 'components/MonacoEditor/MonacoEditor.config';
import Text from 'components/Text/Text';
import TooltipBadge from 'components/TooltipBadge/TooltipBadge';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertTemplatesDTO } from 'models/alert_templates';
import { Alert } from 'models/alertgroup/alertgroup.types';
import { MONACO_PAYLOAD_OPTIONS } from 'pages/integration/IntegrationCommon.config';
import { OutgoingWebhook2, OutgoingWebhook2Response } from 'models/outgoing_webhook_2/outgoing_webhook_2.types';
import { useStore } from 'state/useStore';
import styles from './TemplatesAlertGroupsList.module.css';
@ -19,27 +20,55 @@ const cx = cn.bind(styles);
const HEADER_OF_CONTAINER_HEIGHT = 59;
const BADGE_WITH_PADDINGS_HEIGHT = 42;
export enum TEMPLATE_PAGE {
Integrations,
Webhooks,
}
interface TemplatesAlertGroupsListProps {
templatePage: TEMPLATE_PAGE;
templates: AlertTemplatesDTO[];
alertReceiveChannelId: AlertReceiveChannel['id'];
alertReceiveChannelId?: AlertReceiveChannel['id'];
outgoingwebhookId?: OutgoingWebhook2['id'];
heading?: string;
onSelectAlertGroup?: (alertGroup: Alert) => void;
onEditPayload?: (payload: string) => void;
onLoadAlertGroupsList?: (isRecentAlertExising: boolean) => void;
}
const TemplatesAlertGroupsList = (props: TemplatesAlertGroupsListProps) => {
const { alertReceiveChannelId, templates, onEditPayload, onSelectAlertGroup, onLoadAlertGroupsList } = props;
const {
templatePage,
heading = 'Recent Alert groups',
alertReceiveChannelId,
outgoingwebhookId,
templates,
onEditPayload,
onSelectAlertGroup,
onLoadAlertGroupsList,
} = props;
const store = useStore();
const [alertGroupsList, setAlertGroupsList] = useState(undefined);
const [selectedAlertPayload, setSelectedAlertPayload] = useState<string>(undefined);
const [selectedAlertName, setSelectedAlertName] = useState<string>(undefined);
const [outgoingWebhookLastResponses, setOutgoingWebhookLastResponses] =
useState<OutgoingWebhook2Response[]>(undefined);
const [selectedTitle, setSelectedTitle] = useState<string>(undefined);
const [selectedPayload, setSelectedPayload] = useState<string>(undefined);
const [isEditMode, setIsEditMode] = useState(false);
useEffect(() => {
store.alertGroupStore.getAlertGroupsForIntegration(alertReceiveChannelId).then((result) => {
setAlertGroupsList(result.slice(0, 30));
onLoadAlertGroupsList(result.length > 0);
});
if (templatePage === TEMPLATE_PAGE.Webhooks) {
if (outgoingwebhookId !== 'new') {
store.outgoingWebhook2Store.getLastResponses(outgoingwebhookId).then(setOutgoingWebhookLastResponses);
}
} else if (templatePage === TEMPLATE_PAGE.Integrations) {
store.alertGroupStore.getAlertGroupsForIntegration(alertReceiveChannelId).then((result) => {
setAlertGroupsList(result.slice(0, 30));
onLoadAlertGroupsList(result.length > 0);
});
}
}, []);
const getCodeEditorHeight = () => {
@ -62,181 +91,240 @@ const TemplatesAlertGroupsList = (props: TemplatesAlertGroupsListProps) => {
const returnToListView = () => {
setIsEditMode(false);
setSelectedAlertPayload(undefined);
setSelectedPayload(undefined);
onEditPayload(null);
};
// for Integrations
const getAlertGroupPayload = async (id) => {
const groupedAlert = await store.alertGroupStore.getAlertsFromGroup(id);
const currentIncidentRawResponse = await store.alertGroupStore.getPayloadForIncident(groupedAlert?.alerts[0]?.id);
setSelectedAlertName(getAlertGroupName(groupedAlert));
setSelectedAlertPayload(currentIncidentRawResponse?.raw_request_data);
setSelectedTitle(getAlertGroupName(groupedAlert));
setSelectedPayload(currentIncidentRawResponse?.raw_request_data);
// ?
onSelectAlertGroup(groupedAlert);
onEditPayload(JSON.stringify(currentIncidentRawResponse?.raw_request_data));
};
const getAlertGroupName = (alertGroup: Alert) => {
// Integrations page
return alertGroup.inside_organization_number
? `#${alertGroup.inside_organization_number} ${alertGroup.render_for_web.title}`
: alertGroup.render_for_web.title;
? `#${alertGroup.inside_organization_number} ${alertGroup.render_for_web?.title}`
: alertGroup.render_for_web?.title;
};
// for Outgoing webhooks
const handleOutgoingWebhookResponseSelect = (response: OutgoingWebhook2Response) => {
setSelectedTitle(response.timestamp);
setSelectedPayload(JSON.parse(response.event_data));
onEditPayload(response.event_data);
};
if (selectedPayload) {
// IF selected we either display it as ReadOnly or in EditMode
return (
<div className={cx('template-block-list')} id="alerts-content-container-id">
{isEditMode ? renderSelectedPayloadInEditMode() : renderSelectedPayloadInReadOnlyMode()}
</div>
);
}
return (
<div className={cx('template-block-list')} id="alerts-content-container-id">
{selectedAlertPayload ? (
{isEditMode ? (
<>
{isEditMode ? (
<>
<div className={cx('template-block-title-edit-mode')}>
<HorizontalGroup justify="space-between">
<Text>Edit custom payload</Text>
<div className={cx('template-block-title-edit-mode')}>
<HorizontalGroup justify="space-between">
<Text>Edit custom payload</Text>
<HorizontalGroup>
<IconButton name="times" onClick={() => returnToListView()} />
</HorizontalGroup>
</HorizontalGroup>
</div>
<div className={cx('alert-groups-editor')}>
<MonacoEditor
value={JSON.stringify(selectedAlertPayload, null, 4)}
data={templates}
height={getCodeEditorHeight()}
onChange={getChangeHandler()}
showLineNumbers
useAutoCompleteList={false}
language={MONACO_LANGUAGE.json}
monacoOptions={MONACO_PAYLOAD_OPTIONS}
/>
</div>
</>
) : (
<>
<div className={cx('template-block-title')}>
<div className={cx('selected-alert-name-container')}>
<div className={cx('selected-alert-name')}>
<Text>{selectedAlertName}</Text>
</div>
<div className={cx('title-action-icons')}>
<IconButton name="edit" onClick={() => setIsEditMode(true)} />
<IconButton name="times" onClick={() => returnToListView()} />
</div>
</div>
</div>
<div className={cx('alert-groups-editor')}>
<TooltipBadge
borderType="primary"
text="Last alert payload"
tooltipTitle=""
tooltipContent=""
className={cx('alert-groups-last-payload-badge')}
/>
<div className={cx('alert-groups-editor-withBadge')}>
{/* Editor used for Editing Given Payload */}
<MonacoEditor
value={JSON.stringify(selectedAlertPayload, null, 4)}
data={undefined}
disabled
height={getCodeEditorHeightWithBadge()}
onChange={getChangeHandler()}
useAutoCompleteList={false}
language={MONACO_LANGUAGE.json}
monacoOptions={{
...MONACO_PAYLOAD_OPTIONS,
readOnly: true,
}}
/>
</div>
</div>
</>
)}
<HorizontalGroup>
<IconButton name="times" onClick={returnToListView} />
</HorizontalGroup>
</HorizontalGroup>
</div>
<div className={cx('alert-groups-editor')}>
<MonacoEditor
value={null}
disabled={true}
useAutoCompleteList={false}
language={MONACO_LANGUAGE.json}
data={templates}
monacoOptions={{
...MONACO_EDITABLE_CONFIG,
readOnly: false,
}}
height={getCodeEditorHeight()}
onChange={getChangeHandler()}
/>
</div>
</>
) : (
<>
{isEditMode ? (
<>
<div className={cx('template-block-title-edit-mode')}>
<HorizontalGroup justify="space-between">
<Text>Edit custom payload</Text>
<div className={cx('template-block-title')}>
<HorizontalGroup justify="space-between" wrap>
<HorizontalGroup>
<Text>{heading}</Text>
{/* <Tooltip content="Here will be information about alert groups" placement="top">
<Icon name="info-circle" />
</Tooltip> */}
</HorizontalGroup>
<HorizontalGroup>
<IconButton name="times" onClick={() => returnToListView()} />
</HorizontalGroup>
</HorizontalGroup>
</div>
<div className={cx('alert-groups-editor')}>
<MonacoEditor
value={null}
disabled={true}
useAutoCompleteList={false}
language={MONACO_LANGUAGE.json}
data={templates}
monacoOptions={{
...MONACO_PAYLOAD_OPTIONS,
readOnly: false,
}}
height={getCodeEditorHeight()}
onChange={getChangeHandler()}
/>
</div>
</>
) : (
<>
<div className={cx('template-block-title')}>
<HorizontalGroup justify="space-between" wrap>
<HorizontalGroup>
<Text>Recent Alert groups</Text>
<Tooltip content="Here will be information about alert groups" placement="top">
<Icon name="info-circle" />
</Tooltip>
</HorizontalGroup>
<Button variant="secondary" fill="outline" onClick={() => setIsEditMode(true)} size="sm">
Use custom payload
</Button>
</HorizontalGroup>
</div>
<div className={cx('alert-groups-list')}>
{alertGroupsList ? (
<>
{alertGroupsList?.length > 0 ? (
<>
{alertGroupsList.map((alertGroup) => {
return (
<div
key={alertGroup.pk}
onClick={() => getAlertGroupPayload(alertGroup.pk)}
className={cx('alert-groups-list-item')}
>
<Text type="link"> {getAlertGroupName(alertGroup)}</Text>
</div>
);
})}
</>
) : (
<Badge
color="blue"
text={
<div className={cx('no-alert-groups-badge')}>
<Icon name="info-circle" />
<Text>
This integration did not receive any alerts. Use custom payload example to preview
results.
</Text>
</div>
}
/>
)}
</>
) : (
<LoadingPlaceholder text="Loading alert groups..." />
)}
</div>
</>
)}
<Button variant="secondary" fill="outline" onClick={() => setIsEditMode(true)} size="sm">
Use custom payload
</Button>
</HorizontalGroup>
</div>
<div className={cx('alert-groups-list')}>
{templatePage === TEMPLATE_PAGE.Webhooks ? renderOutgoingWebhookLastResponses() : renderAlertGroupList()}
</div>
</>
)}
</div>
);
function renderOutgoingWebhookLastResponses() {
if (!outgoingWebhookLastResponses) {
return <LoadingPlaceholder text="Loading last events..." />;
}
if (outgoingWebhookLastResponses.length) {
return outgoingWebhookLastResponses
.filter((response) => response.event_data)
.map((response) => {
return (
<div
key={response.timestamp}
onClick={() => handleOutgoingWebhookResponseSelect(response)}
className={cx('alert-groups-list-item')}
>
<Text type="link"> {response.timestamp}</Text>
</div>
);
});
} else {
return (
<Badge
color="blue"
text={
<div className={cx('no-alert-groups-badge')}>
<Icon name="info-circle" />
<Text>
This outgoing webhook did not receive any events yet. Use custom payload example to preview results.
</Text>
</div>
}
/>
);
}
}
function renderAlertGroupList() {
if (!alertGroupsList) {
return <LoadingPlaceholder text="Loading alert groups..." />;
}
if (alertGroupsList.length) {
return alertGroupsList.map((alertGroup) => {
return (
<div
key={alertGroup.pk}
onClick={() => getAlertGroupPayload(alertGroup.pk)}
className={cx('alert-groups-list-item')}
>
<Text type="link"> {getAlertGroupName(alertGroup)}</Text>
</div>
);
});
} else {
return (
<Badge
color="blue"
text={
<div className={cx('no-alert-groups-badge')}>
<Icon name="info-circle" />
<Text>This integration did not receive any alerts. Use custom payload example to preview results.</Text>
</div>
}
/>
);
}
}
function renderSelectedPayloadInEditMode() {
return (
<>
<div className={cx('template-block-title-edit-mode')}>
<HorizontalGroup justify="space-between">
<Text>Edit custom payload</Text>
<HorizontalGroup>
<IconButton name="times" onClick={() => returnToListView()} />
</HorizontalGroup>
</HorizontalGroup>
</div>
<div className={cx('alert-groups-editor')}>
<MonacoEditor
value={JSON.stringify(selectedPayload, null, 4)}
data={templates}
height={getCodeEditorHeight()}
onChange={getChangeHandler()}
showLineNumbers
useAutoCompleteList={false}
language={MONACO_LANGUAGE.json}
monacoOptions={MONACO_EDITABLE_CONFIG}
/>
</div>
</>
);
}
function renderSelectedPayloadInReadOnlyMode() {
return (
<>
<div className={cx('template-block-title')}>
<div className={cx('selected-alert-name-container')}>
<div className={cx('selected-alert-name')}>
<Text>{selectedTitle}</Text>
</div>
<div className={cx('title-action-icons')}>
<IconButton name="edit" onClick={() => setIsEditMode(true)} />
<IconButton name="times" onClick={() => returnToListView()} />
</div>
</div>
</div>
<div className={cx('alert-groups-editor')}>
<TooltipBadge
borderType="primary"
text="Payload"
tooltipTitle=""
tooltipContent=""
className={cx('alert-groups-last-payload-badge')}
/>
<div className={cx('alert-groups-editor-withBadge')}>
{/* Editor used for Editing Given Payload */}
<MonacoEditor
value={JSON.stringify(selectedPayload, null, 4)}
data={undefined}
disabled
height={getCodeEditorHeightWithBadge()}
onChange={getChangeHandler()}
useAutoCompleteList={false}
language={MONACO_LANGUAGE.json}
monacoOptions={{
...MONACO_EDITABLE_CONFIG,
readOnly: true,
}}
/>
</div>
</div>
</>
);
}
};
export default TemplatesAlertGroupsList;

View file

@ -0,0 +1,62 @@
export const WebhooksDefaultAlertGroup = {
pk: '0',
event: {
type: 'resolve',
time: '2023-04-19T21:59:21.714058+00:00',
},
user: {
id: 'UVMX6YI9VY9PV',
username: 'admin',
email: 'admin@localhost',
},
alert_group: {
id: 'I6HNZGUFG4K11',
integration_id: 'CZ7URAT4V3QF2',
route_id: 'RKHXJKVZYYVST',
alerts_count: 1,
state: 'resolved',
created_at: '2023-04-19T21:53:48.231148Z',
resolved_at: '2023-04-19T21:59:21.714058Z',
acknowledged_at: '2023-04-19T21:54:39.029347Z',
title: 'Incident',
permalinks: {
slack: null,
telegram: null,
web: 'https://**********.grafana.net/a/grafana-oncall-app/alert-groups/I6HNZGUFG4K11',
},
},
alert_group_id: 'I6HNZGUFG4K11',
alert_payload: {
endsAt: '0001-01-01T00:00:00Z',
labels: {
region: 'eu-1',
alertname: 'TestAlert',
},
status: 'firing',
startsAt: '2018-12-25T15:47:47.377363608Z',
amixr_demo: true,
annotations: {
description: 'This alert was sent by user for the demonstration purposes',
},
generatorURL: '',
},
integration: {
id: 'CZ7URAT4V3QF2',
type: 'webhook',
name: 'Main Integration - Webhook',
team: 'Webhooks Demo',
},
notified_users: [],
users_to_be_notified: [],
responses: {
WHP936BM1GPVHQ: {
id: '7Qw7TbPmzppRnhLvK3AdkQ',
created_at: '15:53:50',
status: 'new',
content: {
message: 'Ticket created!',
region: 'eu',
},
},
},
};

View file

@ -0,0 +1,176 @@
import React, { useEffect, useState } from 'react';
import { Button, Drawer, HorizontalGroup, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import { debounce } from 'lodash-es';
import CheatSheet from 'components/CheatSheet/CheatSheet';
import MonacoEditor from 'components/MonacoEditor/MonacoEditor';
import Text from 'components/Text/Text';
import styles from 'containers/IntegrationTemplate/IntegrationTemplate.module.scss';
import TemplateResult from 'containers/TemplateResult/TemplateResult';
import TemplatesAlertGroupsList, { TEMPLATE_PAGE } from 'containers/TemplatesAlertGroupsList/TemplatesAlertGroupsList';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { OutgoingWebhook2 } from 'models/outgoing_webhook_2/outgoing_webhook_2.types';
import { waitForElement } from 'utils/DOM';
import { UserActions } from 'utils/authorization';
const cx = cn.bind(styles);
interface Template {
value: string;
displayName: string;
description: string;
name: undefined;
}
interface WebhooksTemplateEditorProps {
template: Template;
id: OutgoingWebhook2['id'];
onHide: () => void;
handleSubmit: (template: string) => void;
}
const WebhooksTemplateEditor: React.FC<WebhooksTemplateEditorProps> = ({ template, id, onHide, handleSubmit }) => {
const [isCheatSheetVisible] = useState(false);
const [changedTemplateBody, setChangedTemplateBody] = useState<string>(template.value);
const [editorHeight, setEditorHeight] = useState<string>(undefined);
const [selectedPayload, setSelectedPayload] = useState(undefined);
const [resultError, setResultError] = useState<string>(undefined);
useEffect(() => {
waitForElement('#content-container-id').then(() => {
const mainDiv = document.getElementById('content-container-id');
const height = mainDiv?.getBoundingClientRect().height - 59;
setEditorHeight(`${height}px`);
});
}, []);
const getChangeHandler = () => {
return debounce((value: string) => {
setChangedTemplateBody(value);
}, 500);
};
const onEditPayload = (alertPayload: string) => {
if (alertPayload !== null) {
try {
const jsonPayload = JSON.parse(alertPayload);
if (typeof jsonPayload === 'object') {
setResultError(undefined);
setSelectedPayload(JSON.parse(alertPayload));
} else {
setResultError('Please check your JSON format');
}
} catch (e) {
setResultError(e.message);
}
} else {
setResultError(undefined);
setSelectedPayload(undefined);
}
};
return (
<Drawer
title={
<div className={cx('title-container')}>
<HorizontalGroup justify="space-between" align="flex-start">
<VerticalGroup>
<Text.Title level={3}>Edit {template.displayName} template</Text.Title>
{template.description && <Text type="secondary">{template.description}</Text>}
</VerticalGroup>
<HorizontalGroup>
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button variant="secondary" onClick={onHide}>
Cancel
</Button>
</WithPermissionControlTooltip>
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button variant="primary" onClick={() => handleSubmit(changedTemplateBody)}>
Save
</Button>
</WithPermissionControlTooltip>
</HorizontalGroup>
</HorizontalGroup>
</div>
}
onClose={onHide}
closeOnMaskClick={false}
width="95%"
>
<div className={cx('container-wrapper')}>
<div className={cx('container')} id={'content-container-id'}>
<TemplatesAlertGroupsList
heading="Last events"
templatePage={TEMPLATE_PAGE.Webhooks}
outgoingwebhookId={id}
onEditPayload={onEditPayload}
templates={
{
// TODO: this is just some dummy data, this will need replaced with an actual Webhook Template
acknowledge_condition_template: null,
acknowledge_condition_template_is_default: true,
} as any
}
onLoadAlertGroupsList={(_isRecentAlertExisting: boolean) => {}}
/>
{isCheatSheetVisible ? (
<CheatSheet
cheatSheetName={template.displayName}
cheatSheetData={getCheatSheet(template.name)}
onClose={onCloseCheatSheet}
/>
) : (
<>
<div className={cx('template-block-codeeditor')}>
<div className={cx('template-editor-block-title')}>
<HorizontalGroup justify="space-between" align="center" wrap>
<Text>Template editor</Text>
{/* <Button variant="secondary" fill="outline" onClick={onShowCheatSheet} icon="book" size="sm">
Cheatsheet
</Button> */}
</HorizontalGroup>
</div>
<div className={cx('template-editor-block-content')}>
<MonacoEditor
value={template.value}
data={{ payload_example: selectedPayload }}
showLineNumbers={true}
height={editorHeight}
onChange={getChangeHandler()}
suggestionPrefix=""
/>
</div>
</div>
</>
)}
<TemplateResult
templatePage={TEMPLATE_PAGE.Webhooks}
outgoingWebhookId={id}
template={template}
templateBody={changedTemplateBody}
isAlertGroupExisting={false}
chatOpsPermalink={undefined}
payload={selectedPayload}
error={resultError}
onSaveAndFollowLink={undefined}
/>
</div>
</div>
</Drawer>
);
// function onShowCheatSheet() {}
function onCloseCheatSheet() {}
function getCheatSheet(_templateName: string) {
return undefined;
}
};
export default WebhooksTemplateEditor;

View file

@ -94,4 +94,17 @@ export class OutgoingWebhook2Store extends BaseStore {
return this.searchResult[query].map((outgoingWebhook2Id: OutgoingWebhook2['id']) => this.items[outgoingWebhook2Id]);
}
async getLastResponses(id: OutgoingWebhook2['id']) {
const result = await makeRequest(`${this.path}${id}/responses`, {});
return result;
}
async renderPreview(id: OutgoingWebhook2['id'], template_name: string, template_body: string, payload) {
return await makeRequest(`${this.path}${id}/preview_template/`, {
method: 'POST',
data: { template_name, template_body, payload },
});
}
}

View file

@ -28,4 +28,5 @@ export interface OutgoingWebhook2Response {
request_data: string;
status_code: string;
content: string;
event_data: string;
}

View file

@ -32,6 +32,7 @@ import IntegrationInputField from 'components/IntegrationInputField/IntegrationI
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
import IntegrationBlock from 'components/Integrations/IntegrationBlock';
import MonacoEditor, { MONACO_LANGUAGE } from 'components/MonacoEditor/MonacoEditor';
import { MONACO_EDITABLE_CONFIG } from 'components/MonacoEditor/MonacoEditor.config';
import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
import { initErrorDataState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
import PluginLink from 'components/PluginLink/PluginLink';
@ -71,8 +72,6 @@ import { UserActions } from 'utils/authorization';
import { PLUGIN_ROOT } from 'utils/consts';
import sanitize from 'utils/sanitize';
import { MONACO_PAYLOAD_OPTIONS } from './IntegrationCommon.config';
const cx = cn.bind(styles);
interface IntegrationProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {}
@ -673,7 +672,7 @@ const IntegrationSendDemoPayloadModal: React.FC<IntegrationSendDemoPayloadModalP
useAutoCompleteList={false}
language={MONACO_LANGUAGE.json}
data={undefined}
monacoOptions={MONACO_PAYLOAD_OPTIONS}
monacoOptions={MONACO_EDITABLE_CONFIG}
showLineNumbers={false}
onChange={onPayloadChangeDebounced}
/>

View file

@ -3,33 +3,6 @@ import { KeyValuePair } from 'utils';
export const TEXTAREA_ROWS_COUNT = 4;
export const MAX_CHARACTERS_COUNT = 50;
// Mostly used for input fields where we're hiding scrollbars
export const MONACO_OPTIONS = {
renderLineHighlight: false,
readOnly: true,
scrollbar: {
vertical: 'hidden',
horizontal: 'hidden',
verticalScrollbarSize: 0,
handleMouseWheel: false,
},
hideCursorInOverviewRuler: true,
minimap: { enabled: false },
cursorStyle: {
display: 'none',
},
};
export const MONACO_PAYLOAD_OPTIONS = {
renderLineHighlight: false,
readOnly: false,
hideCursorInOverviewRuler: true,
minimap: { enabled: false },
cursorStyle: {
display: 'none',
},
};
export const MONACO_INPUT_HEIGHT_SMALL = '32px';
export const MONACO_INPUT_HEIGHT_TALL = '120px';