diff --git a/CHANGELOG.md b/CHANGELOG.md index 9316eade..90e034ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/engine/apps/api/serializers/webhook.py b/engine/apps/api/serializers/webhook.py index 72d6a935..e12c0b94 100644 --- a/engine/apps/api/serializers/webhook.py +++ b/engine/apps/api/serializers/webhook.py @@ -22,6 +22,7 @@ class WebhookResponseSerializer(serializers.ModelSerializer): "request_data", "status_code", "content", + "event_data", ] diff --git a/engine/apps/api/tests/test_webhooks.py b/engine/apps/api/tests/test_webhooks.py index f379425b..d3f55a8f 100644 --- a/engine/apps/api/tests/test_webhooks.py +++ b/engine/apps/api/tests/test_webhooks.py @@ -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 diff --git a/engine/apps/api/views/webhooks.py b/engine/apps/api/views/webhooks.py index 3b76b99a..7079ddab 100644 --- a/engine/apps/api/views/webhooks.py +++ b/engine/apps/api/views/webhooks.py @@ -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) diff --git a/engine/apps/webhooks/migrations/0007_webhookresponse_event_data.py b/engine/apps/webhooks/migrations/0007_webhookresponse_event_data.py new file mode 100644 index 00000000..f2772a09 --- /dev/null +++ b/engine/apps/webhooks/migrations/0007_webhookresponse_event_data.py @@ -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), + ), + ] diff --git a/engine/apps/webhooks/models/webhook.py b/engine/apps/webhooks/models/webhook.py index d9992a5e..d3d8098d 100644 --- a/engine/apps/webhooks/models/webhook.py +++ b/engine/apps/webhooks/models/webhook.py @@ -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: diff --git a/engine/apps/webhooks/tasks/trigger_webhook.py b/engine/apps/webhooks/tasks/trigger_webhook.py index 3073c2b6..75585c3b 100644 --- a/engine/apps/webhooks/tasks/trigger_webhook.py +++ b/engine/apps/webhooks/tasks/trigger_webhook.py @@ -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 diff --git a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx index 984042a4..66cf2a3a 100644 --- a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx +++ b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx @@ -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) => { {groups[activeGroup].map((template) => ( diff --git a/grafana-plugin/src/components/GForm/GForm.tsx b/grafana-plugin/src/components/GForm/GForm.tsx index a7e6b145..d60de4e8 100644 --- a/grafana-plugin/src/components/GForm/GForm.tsx +++ b/grafana-plugin/src/components/GForm/GForm.tsx @@ -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 ( + { + return ( + onChangeFn(field, value)} + /> + ); + }} + /> + ); + default: return null; } @@ -117,7 +147,7 @@ function renderFormControl(formItem: FormItem, register: any, control: any, onCh class GForm extends React.Component { 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 { return null; } + const formControl = renderFormControl(formItem, register, control, (field, value) => { + field?.onChange(value); + this.forceUpdate(); + }); + return ( { 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} ); }; diff --git a/grafana-plugin/src/components/GForm/GForm.types.ts b/grafana-plugin/src/components/GForm/GForm.types.ts index a5adbc4f..4795a3a1 100644 --- a/grafana-plugin/src/components/GForm/GForm.types.ts +++ b/grafana-plugin/src/components/GForm/GForm.types.ts @@ -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; diff --git a/grafana-plugin/src/components/MonacoEditor/MonacoEditor.config.ts b/grafana-plugin/src/components/MonacoEditor/MonacoEditor.config.ts new file mode 100644 index 00000000..bd0ea60d --- /dev/null +++ b/grafana-plugin/src/components/MonacoEditor/MonacoEditor.config.ts @@ -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', + }, +}; diff --git a/grafana-plugin/src/components/MonacoEditor/MonacoEditor.tsx b/grafana-plugin/src/components/MonacoEditor/MonacoEditor.tsx index ba0b9f25..77e5af37 100644 --- a/grafana-plugin/src/components/MonacoEditor/MonacoEditor.tsx +++ b/grafana-plugin/src/components/MonacoEditor/MonacoEditor.tsx @@ -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 = (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] ); diff --git a/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx b/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx index 3f6b5025..cff20d32 100644 --- a/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx +++ b/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx @@ -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 - - -
- -
- - - )} - { ); + + function renderCheatSheet() { + if (isCheatSheetVisible) { + return ( + + ); + } + + return ( + <> +
+
+ + Template editor + + + +
+
+ +
+
+ + ); + } }); -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 ( -
-
- - Result - -
-
- {payload || error ? ( - - {error ? ( - - {error} - - ) : ( - - - - )} - - {template?.additionalData?.additionalDescription && ( - {template?.additionalData.additionalDescription} - )} - - {template?.additionalData?.chatOpsName && isAlertGroupExisting && ( - - - - {template.additionalData.data && {template.additionalData.data}} - - )} - - ) : ( -
- - ← Select alert group or "Use custom payload" - -
- )} -
-
- ); -}; - export default IntegrationTemplate; diff --git a/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.config.tsx b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.config.tsx index e9713449..f7b6eb68 100644 --- a/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.config.tsx +++ b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.config.tsx @@ -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: {}, }, ], }; diff --git a/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.module.css b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.module.css index c335c84b..a4613c64 100644 --- a/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.module.css +++ b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.module.css @@ -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; +} diff --git a/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.tsx b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.tsx index e7baf4df..5ebed3b6 100644 --- a/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.tsx +++ b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.tsx @@ -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( 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 ( +
+
{renderedControl}
+
+ ); + } + + 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 = ; + if (action === WebhookFormActionType.NEW || action === WebhookFormActionType.COPY) { // show just the creation form, not the tabs return ( - - {renderWebhookForm()} - + <> + +
{renderWebhookForm()}
+
+ {templateToEdit && ( + onFormChangeFn?.fn(value)} + onHide={() => setTemplateToEdit(undefined)} + template={templateToEdit} + /> + )} + ); } return ( // show tabbed drawer (edit/live_run) - - - { - setActiveTab(WebhookTabs.Settings.key); - history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2/edit/${id}`); - }} - active={activeTab === WebhookTabs.Settings.key} - label={WebhookTabs.Settings.value} - /> + <> + +
+ + { + setActiveTab(WebhookTabs.Settings.key); + history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2/edit/${id}`); + }} + active={activeTab === WebhookTabs.Settings.key} + label={WebhookTabs.Settings.value} + /> - { - setActiveTab(WebhookTabs.LastRun.key); - history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2/last_run/${id}`); - }} - active={activeTab === WebhookTabs.LastRun.key} - label={WebhookTabs.LastRun.value} - /> - + { + setActiveTab(WebhookTabs.LastRun.key); + history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2/last_run/${id}`); + }} + active={activeTab === WebhookTabs.LastRun.key} + label={WebhookTabs.LastRun.value} + /> + - - + +
+
+ {templateToEdit && ( + { + 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) => void; + formElement: React.ReactElement; } const WebhookTabsContent: React.FC = ({ @@ -177,10 +235,10 @@ const WebhookTabsContent: React.FC = ({ action, activeTab, data, - handleSubmit, onHide, onUpdate, onDelete, + formElement, }) => { const [confirmationModal, setConfirmationModal] = useState(undefined); @@ -193,7 +251,7 @@ const WebhookTabsContent: React.FC = ({ {activeTab === WebhookTabs.Settings.key && ( <>
- + {formElement}
+ + {template.additionalData.data && {template.additionalData.data}} + + )} + + ) : ( +
+ + + ← Select {templatePage === TEMPLATE_PAGE.Webhooks ? 'event' : 'alert group'} or "Use custom payload" + + +
+ )} +
+
+ ); +}; + +export default TemplateResult; diff --git a/grafana-plugin/src/containers/TemplatesAlertGroupsList/TemplatesAlertGroupsList.tsx b/grafana-plugin/src/containers/TemplatesAlertGroupsList/TemplatesAlertGroupsList.tsx index 30aedff5..8f2e29b8 100644 --- a/grafana-plugin/src/containers/TemplatesAlertGroupsList/TemplatesAlertGroupsList.tsx +++ b/grafana-plugin/src/containers/TemplatesAlertGroupsList/TemplatesAlertGroupsList.tsx @@ -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(undefined); - const [selectedAlertName, setSelectedAlertName] = useState(undefined); + const [outgoingWebhookLastResponses, setOutgoingWebhookLastResponses] = + useState(undefined); + + const [selectedTitle, setSelectedTitle] = useState(undefined); + const [selectedPayload, setSelectedPayload] = useState(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 ( +
+ {isEditMode ? renderSelectedPayloadInEditMode() : renderSelectedPayloadInReadOnlyMode()} +
+ ); + } + return (
- {selectedAlertPayload ? ( + {isEditMode ? ( <> - {isEditMode ? ( - <> -
- - Edit custom payload +
+ + Edit custom payload - - returnToListView()} /> - - -
-
- -
- - ) : ( - <> -
-
-
- {selectedAlertName} -
-
- setIsEditMode(true)} /> - returnToListView()} /> -
-
-
-
- -
- {/* Editor used for Editing Given Payload */} - -
-
- - )} + + + +
+
+
+ +
) : ( <> - {isEditMode ? ( - <> -
- - Edit custom payload +
+ + + {heading} + {/* + + */} + - - returnToListView()} /> - - -
-
- -
- - ) : ( - <> -
- - - Recent Alert groups - - - - - - - -
-
- {alertGroupsList ? ( - <> - {alertGroupsList?.length > 0 ? ( - <> - {alertGroupsList.map((alertGroup) => { - return ( -
getAlertGroupPayload(alertGroup.pk)} - className={cx('alert-groups-list-item')} - > - {getAlertGroupName(alertGroup)} -
- ); - })} - - ) : ( - - - - This integration did not receive any alerts. Use custom payload example to preview - results. - -
- } - /> - )} - - ) : ( - - )} -
- - )} + + +
+
+ {templatePage === TEMPLATE_PAGE.Webhooks ? renderOutgoingWebhookLastResponses() : renderAlertGroupList()} +
)} ); + + function renderOutgoingWebhookLastResponses() { + if (!outgoingWebhookLastResponses) { + return ; + } + + if (outgoingWebhookLastResponses.length) { + return outgoingWebhookLastResponses + .filter((response) => response.event_data) + .map((response) => { + return ( +
handleOutgoingWebhookResponseSelect(response)} + className={cx('alert-groups-list-item')} + > + {response.timestamp} +
+ ); + }); + } else { + return ( + + + + This outgoing webhook did not receive any events yet. Use custom payload example to preview results. + + + } + /> + ); + } + } + + function renderAlertGroupList() { + if (!alertGroupsList) { + return ; + } + + if (alertGroupsList.length) { + return alertGroupsList.map((alertGroup) => { + return ( +
getAlertGroupPayload(alertGroup.pk)} + className={cx('alert-groups-list-item')} + > + {getAlertGroupName(alertGroup)} +
+ ); + }); + } else { + return ( + + + This integration did not receive any alerts. Use custom payload example to preview results. + + } + /> + ); + } + } + + function renderSelectedPayloadInEditMode() { + return ( + <> +
+ + Edit custom payload + + + returnToListView()} /> + + +
+
+ +
+ + ); + } + + function renderSelectedPayloadInReadOnlyMode() { + return ( + <> +
+
+
+ {selectedTitle} +
+
+ setIsEditMode(true)} /> + returnToListView()} /> +
+
+
+
+ +
+ {/* Editor used for Editing Given Payload */} + +
+
+ + ); + } }; export default TemplatesAlertGroupsList; diff --git a/grafana-plugin/src/containers/TemplatesAlertGroupsList/WebhooksDefaultAlertGroup.ts b/grafana-plugin/src/containers/TemplatesAlertGroupsList/WebhooksDefaultAlertGroup.ts new file mode 100644 index 00000000..1dcef0c5 --- /dev/null +++ b/grafana-plugin/src/containers/TemplatesAlertGroupsList/WebhooksDefaultAlertGroup.ts @@ -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', + }, + }, + }, +}; diff --git a/grafana-plugin/src/containers/WebhooksTemplateEditor/WebhooksTemplateEditor.tsx b/grafana-plugin/src/containers/WebhooksTemplateEditor/WebhooksTemplateEditor.tsx new file mode 100644 index 00000000..e9a84ed1 --- /dev/null +++ b/grafana-plugin/src/containers/WebhooksTemplateEditor/WebhooksTemplateEditor.tsx @@ -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 = ({ template, id, onHide, handleSubmit }) => { + const [isCheatSheetVisible] = useState(false); + const [changedTemplateBody, setChangedTemplateBody] = useState(template.value); + const [editorHeight, setEditorHeight] = useState(undefined); + const [selectedPayload, setSelectedPayload] = useState(undefined); + const [resultError, setResultError] = useState(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 ( + + + + Edit {template.displayName} template + {template.description && {template.description}} + + + + + + + + + + + + + } + onClose={onHide} + closeOnMaskClick={false} + width="95%" + > +
+
+ {}} + /> + + {isCheatSheetVisible ? ( + + ) : ( + <> +
+
+ + Template editor + + {/* */} + +
+
+ +
+
+ + )} + +
+
+
+ ); + + // function onShowCheatSheet() {} + + function onCloseCheatSheet() {} + + function getCheatSheet(_templateName: string) { + return undefined; + } +}; + +export default WebhooksTemplateEditor; diff --git a/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.ts b/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.ts index b23f8a86..5f8ff367 100644 --- a/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.ts +++ b/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.ts @@ -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 }, + }); + } } diff --git a/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.types.ts b/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.types.ts index 8eab346e..5035e930 100644 --- a/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.types.ts +++ b/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.types.ts @@ -28,4 +28,5 @@ export interface OutgoingWebhook2Response { request_data: string; status_code: string; content: string; + event_data: string; } diff --git a/grafana-plugin/src/pages/integration/Integration.tsx b/grafana-plugin/src/pages/integration/Integration.tsx index 54a2eff8..0bd83838 100644 --- a/grafana-plugin/src/pages/integration/Integration.tsx +++ b/grafana-plugin/src/pages/integration/Integration.tsx @@ -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 diff --git a/grafana-plugin/src/pages/integration/IntegrationCommon.config.ts b/grafana-plugin/src/pages/integration/IntegrationCommon.config.ts index 6856896c..c4590765 100644 --- a/grafana-plugin/src/pages/integration/IntegrationCommon.config.ts +++ b/grafana-plugin/src/pages/integration/IntegrationCommon.config.ts @@ -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';