From 23228bd0f62db94b5bf72c0f58ba87045e96301b Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Tue, 11 Jul 2023 12:07:29 +0200 Subject: [PATCH 01/11] push fix for e2e tests --- grafana-plugin/integration-tests/utils/alertGroup.ts | 3 ++- grafana-plugin/integration-tests/utils/forms.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/grafana-plugin/integration-tests/utils/alertGroup.ts b/grafana-plugin/integration-tests/utils/alertGroup.ts index 7f9b39fc..385b3cbd 100644 --- a/grafana-plugin/integration-tests/utils/alertGroup.ts +++ b/grafana-plugin/integration-tests/utils/alertGroup.ts @@ -30,12 +30,13 @@ export const verifyThatAlertGroupIsTriggered = async ( await goToOnCallPage(page, 'incidents'); // filter by integration - await selectDropdownValue({ + const selectElement = await selectDropdownValue({ page, selectType: 'grafanaSelect', placeholderText: 'Search or filter results...', value: 'Integration', }); + await selectElement.type(integrationName); await selectValuePickerValue(page, integrationName, false); /** diff --git a/grafana-plugin/integration-tests/utils/forms.ts b/grafana-plugin/integration-tests/utils/forms.ts index 10709207..30fb8cdd 100644 --- a/grafana-plugin/integration-tests/utils/forms.ts +++ b/grafana-plugin/integration-tests/utils/forms.ts @@ -86,7 +86,7 @@ const textMatchSelector = (optionExactMatch: boolean, value: string): string => const chooseDropdownValue = async ({ page, value, optionExactMatch = true }: SelectDropdownValueArgs): Promise => page.locator(`div[id^="react-select-"][id$="-listbox"] >> ${textMatchSelector(optionExactMatch, value)}`).click(); -export const selectDropdownValue = async (args: SelectDropdownValueArgs): Promise => { +export const selectDropdownValue = async (args: SelectDropdownValueArgs): Promise => { const selectElement = await openSelect(args); /** @@ -97,6 +97,8 @@ export const selectDropdownValue = async (args: SelectDropdownValueArgs): Promis await selectElement.type(args.value.slice(0, 5)); await chooseDropdownValue(args); + + return selectElement; }; export const generateRandomValue = (): string => randomUUID(); From 195181f57112b441d0dfc9806139dcabcb9fcb86 Mon Sep 17 00:00:00 2001 From: Yulya Artyukhina Date: Tue, 11 Jul 2023 12:32:10 +0200 Subject: [PATCH 02/11] Exclude schedules from deleted organizations from notification list (#2493) # What this PR does On notifying about shifts change in slack we don't check whether organization was deleted. This PR excludes schedules from deleted organizations from shift notification list ## Which issue(s) this PR fixes ## 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) --- CHANGELOG.md | 1 + .../tasks/notify_ical_schedule_shift.py | 3 +++ .../schedules/tasks/refresh_ical_files.py | 4 +-- .../tests/test_tasks_refresh_ical_files.py | 27 +++++++++++++++++-- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a817aa6..e6fd2a9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Address issue where we were improperly parsing Grafana feature flags that were enabled via the `feature_flags.enabled` method by @joeyorlando ([#2477](https://github.com/grafana/oncall/pull/2477)) - Fix cuddled list Markdown issue by @vadimkerr ([#2488](https://github.com/grafana/oncall/pull/2488)) +- Fixed schedules slack notifications for deleted organizations ([#2493](https://github.com/grafana/oncall/pull/2493)) ## v1.3.7 (2023-07-06) diff --git a/engine/apps/alerts/tasks/notify_ical_schedule_shift.py b/engine/apps/alerts/tasks/notify_ical_schedule_shift.py index aff7a4d1..af46779e 100644 --- a/engine/apps/alerts/tasks/notify_ical_schedule_shift.py +++ b/engine/apps/alerts/tasks/notify_ical_schedule_shift.py @@ -186,6 +186,9 @@ def notify_ical_schedule_shift(schedule_pk): if schedule.organization.slack_team_identity is None: task_logger.info(f"Trying to notify ical schedule shift with no slack team identity {schedule_pk}") return + elif schedule.organization.deleted_at: + task_logger.info(f"Trying to notify ical schedule shift from deleted organization {schedule_pk}") + return MIN_DAYS_TO_LOOKUP_FOR_THE_END_OF_EVENT = 3 diff --git a/engine/apps/schedules/tasks/refresh_ical_files.py b/engine/apps/schedules/tasks/refresh_ical_files.py index 18b78936..262a3032 100644 --- a/engine/apps/schedules/tasks/refresh_ical_files.py +++ b/engine/apps/schedules/tasks/refresh_ical_files.py @@ -16,7 +16,7 @@ def start_refresh_ical_files(): task_logger.info("Start refresh ical files") - schedules = OnCallSchedule.objects.all() + schedules = OnCallSchedule.objects.filter(organization__deleted_at__isnull=True) for schedule in schedules: refresh_ical_file.apply_async((schedule.pk,)) @@ -30,7 +30,7 @@ def start_refresh_ical_final_schedules(): task_logger.info("Start refresh ical final schedules") - schedules = OnCallSchedule.objects.all() + schedules = OnCallSchedule.objects.filter(organization__deleted_at__isnull=True) for schedule in schedules: refresh_ical_final_schedule.apply_async((schedule.pk,)) diff --git a/engine/apps/schedules/tests/test_tasks_refresh_ical_files.py b/engine/apps/schedules/tests/test_tasks_refresh_ical_files.py index 0a7ed738..a99fd8e8 100644 --- a/engine/apps/schedules/tests/test_tasks_refresh_ical_files.py +++ b/engine/apps/schedules/tests/test_tasks_refresh_ical_files.py @@ -1,9 +1,10 @@ +import datetime from unittest.mock import patch import pytest -from apps.schedules.models import OnCallScheduleICal -from apps.schedules.tasks.refresh_ical_files import refresh_ical_file +from apps.schedules.models import OnCallScheduleICal, OnCallScheduleWeb +from apps.schedules.tasks.refresh_ical_files import refresh_ical_file, start_refresh_ical_files @pytest.mark.django_db @@ -56,3 +57,25 @@ def test_refresh_ical_file_trigger_run( assert mock_notify_empty.apply_async.called == run_task assert mock_notify_gaps.apply_async.called == run_task + + +@pytest.mark.django_db +@patch("apps.slack.tasks.start_update_slack_user_group_for_schedules.apply_async") +def test_refresh_ical_files_filter_orgs( + mocked_start_update_slack_user_group_for_schedules, + make_organization, + make_schedule, +): + organization = make_organization() + deleted_organization = make_organization(deleted_at=datetime.datetime.now()) + + schedule_from_deleted_org = make_schedule(deleted_organization, schedule_class=OnCallScheduleWeb) + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + + with patch("apps.schedules.tasks.refresh_ical_file.apply_async") as mocked_refresh_ical_file: + start_refresh_ical_files() + assert mocked_refresh_ical_file.called + called_args = mocked_refresh_ical_file.call_args_list + assert len(called_args) == 1 + assert schedule.id in called_args[0].args[0] + assert schedule_from_deleted_org.id not in called_args[0].args[0] From 028cb7d7fc58cc8d0aec4fe4a18fdf9e435f7f22 Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Tue, 11 Jul 2023 14:09:02 +0100 Subject: [PATCH 03/11] Remove ability to create alert groups via Slack message shortcut (#2499) # What this PR does Removes an ability to create alert groups via Slack message shortcut (three dots menu near to a message), since this feature is outdated and not documented anywhere. ## Which issue(s) this PR fixes Related to https://github.com/grafana/oncall/issues/2442 ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] 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) --- docs/sources/open-source/_index.md | 4 - .../apps/slack/scenarios/manual_incident.py | 687 ------------------ engine/apps/slack/views.py | 2 - 3 files changed, 693 deletions(-) delete mode 100644 engine/apps/slack/scenarios/manual_incident.py diff --git a/docs/sources/open-source/_index.md b/docs/sources/open-source/_index.md index 10fcc2c3..27ca9e60 100644 --- a/docs/sources/open-source/_index.md +++ b/docs/sources/open-source/_index.md @@ -85,10 +85,6 @@ features: display_name: always_online: true shortcuts: - - name: Create a new incident - type: message - callback_id: incident_create - description: Creates a new OnCall incident - name: Add to resolution note type: message callback_id: add_resolution_note diff --git a/engine/apps/slack/scenarios/manual_incident.py b/engine/apps/slack/scenarios/manual_incident.py deleted file mode 100644 index 0b3dbbb7..00000000 --- a/engine/apps/slack/scenarios/manual_incident.py +++ /dev/null @@ -1,687 +0,0 @@ -import json -from uuid import uuid4 - -from django.apps import apps -from django.conf import settings - -from apps.alerts.models import AlertReceiveChannel -from apps.slack.scenarios import scenario_step -from apps.slack.slack_client.exceptions import SlackAPIException - -MANUAL_INCIDENT_TEAM_SELECT_ID = "manual_incident_team_select" -MANUAL_INCIDENT_ORG_SELECT_ID = "manual_incident_org_select" -MANUAL_INCIDENT_ROUTE_SELECT_ID = "manual_incident_route_select" -MANUAL_INCIDENT_TITLE_INPUT_ID = "manual_incident_title_input" -MANUAL_INCIDENT_MESSAGE_INPUT_ID = "manual_incident_message_input" - -DEFAULT_TEAM_VALUE = "default_team" - - -class StartCreateIncidentFromMessage(scenario_step.ScenarioStep): - """ - StartCreateIncidentFromMessage triggers creation of a manual incident from the slack message via submenu - """ - - callback_id = [ - "incident_create", - "incident_create_staging", - "incident_create_develop", - ] - - def process_scenario(self, slack_user_identity, slack_team_identity, payload): - input_id_prefix = _generate_input_id_prefix() - - channel_id = payload["channel"]["id"] - try: - image_url = payload["message"]["files"][0]["permalink"] - except KeyError: - image_url = None - private_metadata = { - "channel_id": channel_id, - "image_url": image_url, - "message": { - "user": payload["message"].get("user"), - "text": payload["message"].get("text"), - "ts": payload["message"].get("ts"), - }, - "input_id_prefix": input_id_prefix, - "with_title_and_message_inputs": False, - "submit_routing_uid": FinishCreateIncidentFromMessage.routing_uid(), - } - - blocks = _get_manual_incident_initial_form_fields( - slack_team_identity, slack_user_identity, input_id_prefix, payload - ) - view = _get_manual_incident_form_view( - FinishCreateIncidentFromMessage.routing_uid(), blocks, json.dumps(private_metadata) - ) - self._slack_client.api_call( - "views.open", - trigger_id=payload["trigger_id"], - view=view, - ) - - -class FinishCreateIncidentFromMessage(scenario_step.ScenarioStep): - """ - FinishCreateIncidentFromMessage creates a manual incident from the slack message via submenu - """ - - def process_scenario(self, slack_user_identity, slack_team_identity, payload): - Alert = apps.get_model("alerts", "Alert") - - private_metadata = json.loads(payload["view"]["private_metadata"]) - - channel_id = private_metadata["channel_id"] - - input_id_prefix = private_metadata["input_id_prefix"] - selected_organization = _get_selected_org_from_payload(payload, input_id_prefix) - selected_team = _get_selected_team_from_payload(payload, input_id_prefix) - selected_route = _get_selected_route_from_payload(payload, input_id_prefix) - - user = slack_user_identity.get_user(selected_organization) - alert_receive_channel = AlertReceiveChannel.get_or_create_manual_integration( - organization=selected_organization, - team=selected_team, - integration=AlertReceiveChannel.INTEGRATION_MANUAL, - deleted_at=None, - defaults={ - "author": user, - "verbal_name": f"Manual incidents ({selected_team.name if selected_team else 'General'} team)", - }, - ) - - author_username = slack_user_identity.slack_verbal - try: - permalink = self._slack_client.api_call( - "chat.getPermalink", - channel=private_metadata["channel_id"], - message_ts=private_metadata["message"]["ts"], - ) - permalink = permalink.get("permalink", None) - except SlackAPIException: - permalink = None - title = "Message from {}".format(author_username) - message = private_metadata["message"]["text"] - - # Deprecated, use custom oncall property instead. - # update private metadata in payload to use it in alert rendering - payload["view"]["private_metadata"] = private_metadata - payload["view"]["private_metadata"]["author_username"] = author_username - # Custom oncall property in payload to simplify rendering - payload["oncall"] = {} - payload["oncall"]["title"] = title - payload["oncall"]["message"] = message - payload["oncall"]["author_username"] = author_username - payload["oncall"]["permalink"] = permalink - Alert.create( - title=title, - message=message, - image_url=private_metadata["image_url"], - # Link to the slack message is not here bc it redirects to browser - link_to_upstream_details=None, - alert_receive_channel=alert_receive_channel, - raw_request_data=payload, - integration_unique_data={"created_by": user.get_username_with_slack_verbal()}, - force_route_id=selected_route.pk, - ) - - try: - self._slack_client.api_call( - "chat.postEphemeral", - channel=channel_id, - user=slack_user_identity.slack_id, - text=":white_check_mark: Alert successfully submitted", - ) - except SlackAPIException as e: - if e.response["error"] == "channel_not_found" or e.response["error"] == "user_not_in_channel": - self._slack_client.api_call( - "chat.postEphemeral", - channel=slack_user_identity.im_channel_id, - user=slack_user_identity.slack_id, - text=":white_check_mark: Alert successfully submitted", - ) - else: - raise e - - -class StartCreateIncidentFromSlashCommand(scenario_step.ScenarioStep): - """ - StartCreateIncidentFromSlashCommand triggers creation of a manual incident from the slack message via slash command - """ - - command_name = [settings.SLACK_SLASH_COMMAND_NAME] - TITLE_INPUT_BLOCK_ID = "TITLE_INPUT" - MESSAGE_INPUT_BLOCK_ID = "MESSAGE_INPUT" - - def process_scenario(self, slack_user_identity, slack_team_identity, payload): - input_id_prefix = _generate_input_id_prefix() - - try: - channel_id = payload["event"]["channel"] - except KeyError: - channel_id = payload["channel_id"] - - private_metadata = { - "channel_id": channel_id, - "input_id_prefix": input_id_prefix, - "with_title_and_message_inputs": True, - "submit_routing_uid": FinishCreateIncidentFromSlashCommand.routing_uid(), - } - - blocks = _get_manual_incident_initial_form_fields( - slack_team_identity, slack_user_identity, input_id_prefix, payload, with_title_and_message_inputs=True - ) - view = _get_manual_incident_form_view( - FinishCreateIncidentFromSlashCommand.routing_uid(), blocks, json.dumps(private_metadata) - ) - - self._slack_client.api_call( - "views.open", - trigger_id=payload["trigger_id"], - view=view, - ) - - -class FinishCreateIncidentFromSlashCommand(scenario_step.ScenarioStep): - """ - FinishCreateIncidentFromSlashCommand creates a manual incident from the slack message via slash message - """ - - def process_scenario(self, slack_user_identity, slack_team_identity, payload): - Alert = apps.get_model("alerts", "Alert") - - title = _get_title_from_payload(payload) - message = _get_message_from_payload(payload) - - private_metadata = json.loads(payload["view"]["private_metadata"]) - - channel_id = private_metadata["channel_id"] - - input_id_prefix = private_metadata["input_id_prefix"] - selected_organization = _get_selected_org_from_payload(payload, input_id_prefix) - selected_team = _get_selected_team_from_payload(payload, input_id_prefix) - selected_route = _get_selected_route_from_payload(payload, input_id_prefix) - - user = slack_user_identity.get_user(selected_organization) - alert_receive_channel = AlertReceiveChannel.get_or_create_manual_integration( - organization=selected_organization, - team=selected_team, - integration=AlertReceiveChannel.INTEGRATION_MANUAL, - deleted_at=None, - defaults={ - "author": user, - "verbal_name": f"Manual incidents ({selected_team.name if selected_team else 'General'} team)", - }, - ) - - author_username = slack_user_identity.slack_verbal - - try: - self._slack_client.api_call( - "chat.postEphemeral", - channel=channel_id, - user=slack_user_identity.slack_id, - text=":white_check_mark: Alert *{}* successfully submitted".format(title), - ) - except SlackAPIException as e: - if e.response["error"] == "channel_not_found": - self._slack_client.api_call( - "chat.postEphemeral", - channel=slack_user_identity.im_channel_id, - user=slack_user_identity.slack_id, - text=":white_check_mark: Alert *{}* successfully submitted".format(title), - ) - else: - raise e - - # Deprecated, use custom oncall property instead. - # Update private metadata to use it in rendering: - payload["view"]["private_metadata"] = private_metadata - # Custom oncall property to simplify rendering - payload["oncall"] = {} - payload["oncall"]["title"] = title - payload["oncall"]["message"] = message - payload["oncall"]["author_username"] = author_username - payload["oncall"]["permalink"] = None - - Alert.create( - title=title, - message=message, - image_url=None, - link_to_upstream_details=None, - alert_receive_channel=alert_receive_channel, - raw_request_data=payload, - integration_unique_data={ - "created_by": author_username, - }, - force_route_id=selected_route.pk, - ) - - -# OnChange steps responsible for rerendering manual incident creation form on change values in selects. -# They are works both with incident creation from submenu and slack command. - - -class OnOrgChange(scenario_step.ScenarioStep): - def process_scenario(self, slack_user_identity, slack_team_identity, payload): - private_metadata = json.loads(payload["view"]["private_metadata"]) - with_title_and_message_inputs = private_metadata.get("with_title_and_message_inputs", False) - submit_routing_uid = private_metadata.get("submit_routing_uid") - old_input_id_prefix, new_input_id_prefix, new_private_metadata = _get_and_change_input_id_prefix_from_metadata( - private_metadata - ) - - selected_organization = _get_selected_org_from_payload(payload, old_input_id_prefix) - # Set selected team to default because org is changed. - selected_team = None - - user = slack_user_identity.get_user(selected_organization) - manual_integration = AlertReceiveChannel.get_or_create_manual_integration( - organization=selected_organization, - team=selected_team, - integration=AlertReceiveChannel.INTEGRATION_MANUAL, - deleted_at=None, - defaults={ - "author": user, - "verbal_name": f"Manual incidents ({selected_team.name if selected_team else 'General'} team)", - }, - ) - selected_route = manual_integration.default_channel_filter - - organization_select = _get_organization_select( - slack_team_identity, slack_user_identity, selected_organization, new_input_id_prefix - ) - team_select = _get_team_select(slack_user_identity, selected_organization, selected_team, new_input_id_prefix) - route_select = _get_route_select(manual_integration, selected_route, new_input_id_prefix) - - blocks = [organization_select, team_select, route_select] - if with_title_and_message_inputs: - blocks.extend([_get_title_input(payload), _get_message_input(payload)]) - view = _get_manual_incident_form_view(submit_routing_uid, blocks, json.dumps(new_private_metadata)) - self._slack_client.api_call( - "views.update", - trigger_id=payload["trigger_id"], - view=view, - view_id=payload["view"]["id"], - ) - - -class OnTeamChange(scenario_step.ScenarioStep): - def process_scenario(self, slack_user_identity, slack_team_identity, payload): - private_metadata = json.loads(payload["view"]["private_metadata"]) - with_title_and_message_inputs = private_metadata.get("with_title_and_message_inputs", False) - submit_routing_uid = private_metadata.get("submit_routing_uid") - old_input_id_prefix, new_input_id_prefix, new_private_metadata = _get_and_change_input_id_prefix_from_metadata( - private_metadata - ) - - selected_organization = _get_selected_org_from_payload(payload, old_input_id_prefix) - selected_team = _get_selected_team_from_payload(payload, old_input_id_prefix) - - user = slack_user_identity.get_user(selected_organization) - manual_integration = AlertReceiveChannel.get_or_create_manual_integration( - organization=selected_organization, - team=selected_team, - integration=AlertReceiveChannel.INTEGRATION_MANUAL, - deleted_at=None, - defaults={ - "author": user, - "verbal_name": f"Manual incidents ({selected_team.name if selected_team else 'General'} team)", - }, - ) - initial_route = manual_integration.default_channel_filter - - organization_select = _get_organization_select( - slack_team_identity, slack_user_identity, selected_organization, new_input_id_prefix - ) - team_select = _get_team_select(slack_user_identity, selected_organization, selected_team, new_input_id_prefix) - route_select = _get_route_select(manual_integration, initial_route, new_input_id_prefix) - - blocks = [organization_select, team_select, route_select] - if with_title_and_message_inputs: - blocks.extend([_get_title_input(payload), _get_message_input(payload)]) - view = _get_manual_incident_form_view(submit_routing_uid, blocks, json.dumps(new_private_metadata)) - self._slack_client.api_call( - "views.update", - trigger_id=payload["trigger_id"], - view=view, - view_id=payload["view"]["id"], - ) - - -class OnRouteChange(scenario_step.ScenarioStep): - """ - OnRouteChange is just a plug to handle change of value on route select - """ - - def process_scenario(self, slack_user_identity, slack_team_identity, payload): - pass - - -def _get_manual_incident_form_view(routing_uid, blocks, private_metatada): - view = { - "type": "modal", - "callback_id": routing_uid, - "title": { - "type": "plain_text", - "text": "Start New Escalation", - }, - "close": { - "type": "plain_text", - "text": "Cancel", - "emoji": True, - }, - "submit": { - "type": "plain_text", - "text": "Submit", - }, - "blocks": blocks, - "private_metadata": private_metatada, - } - - return view - - -def _get_manual_incident_initial_form_fields( - slack_team_identity, slack_user_identity, input_id_prefix, payload, with_title_and_message_inputs=False -): - initial_organization = ( - slack_team_identity.organizations.filter(users__slack_user_identity=slack_user_identity) - .order_by("pk") - .distinct() - .first() - ) - - organization_select = _get_organization_select( - slack_team_identity, slack_user_identity, initial_organization, input_id_prefix - ) - - initial_team = None # means default team - team_select = _get_team_select(slack_user_identity, initial_organization, initial_team, input_id_prefix) - - user = slack_user_identity.get_user(initial_organization) - manual_integration = AlertReceiveChannel.get_or_create_manual_integration( - organization=initial_organization, - team=initial_team, - integration=AlertReceiveChannel.INTEGRATION_MANUAL, - deleted_at=None, - defaults={ - "author": user, - "verbal_name": f"Manual incidents ({initial_team.name if initial_team else 'General'} team)", - }, - ) - - initial_route = manual_integration.default_channel_filter - route_select = _get_route_select(manual_integration, initial_route, input_id_prefix) - blocks = [organization_select, team_select, route_select] - if with_title_and_message_inputs: - title_input = _get_title_input(payload) - message_input = _get_message_input(payload) - blocks.append(title_input) - blocks.append(message_input) - return blocks - - -def _get_organization_select(slack_team_identity, slack_user_identity, value, input_id_prefix): - organizations = slack_team_identity.organizations.filter( - users__slack_user_identity=slack_user_identity, - ).distinct() - organizations_options = [] - initial_option_idx = 0 - for idx, org in enumerate(organizations): - if org == value: - initial_option_idx = idx - organizations_options.append( - { - "text": { - "type": "plain_text", - "text": f"{org.stack_slug}", - "emoji": True, - }, - "value": f"{org.pk}", - } - ) - - organization_select = { - "type": "section", - "text": {"type": "mrkdwn", "text": "Select an organization"}, - "block_id": input_id_prefix + MANUAL_INCIDENT_ORG_SELECT_ID, - "accessory": { - "type": "static_select", - "placeholder": {"type": "plain_text", "text": "Select an organization", "emoji": True}, - "options": organizations_options, - "action_id": OnOrgChange.routing_uid(), - "initial_option": organizations_options[initial_option_idx], - }, - } - - return organization_select - - -def _get_selected_org_from_payload(payload, input_id_prefix): - Organization = apps.get_model("user_management", "Organization") - selected_org_id = payload["view"]["state"]["values"][input_id_prefix + MANUAL_INCIDENT_ORG_SELECT_ID][ - OnOrgChange.routing_uid() - ]["selected_option"]["value"] - org = Organization.objects.filter(pk=selected_org_id).first() - return org - - -def _get_team_select(slack_user_identity, organization, value, input_id_prefix): - teams = organization.teams.filter( - users__slack_user_identity=slack_user_identity, - ).distinct() - team_options = [] - # Adding pseudo option for default team - initial_option_idx = 0 - team_options.append( - { - "text": { - "type": "plain_text", - "text": f"General", - "emoji": True, - }, - "value": DEFAULT_TEAM_VALUE, - } - ) - for idx, team in enumerate(teams): - if team == value: - # Add 1 because default team option was added before cycle, so option indicies are shifted - initial_option_idx = idx + 1 - team_options.append( - { - "text": { - "type": "plain_text", - "text": f"{team.name}", - "emoji": True, - }, - "value": f"{team.pk}", - } - ) - - team_select = { - "type": "section", - "text": {"type": "mrkdwn", "text": "Select a team"}, - "block_id": input_id_prefix + MANUAL_INCIDENT_TEAM_SELECT_ID, - "accessory": { - "type": "static_select", - "placeholder": {"type": "plain_text", "text": "Select a team", "emoji": True}, - "options": team_options, - "action_id": OnTeamChange.routing_uid(), - "initial_option": team_options[initial_option_idx], - }, - } - return team_select - - -def _get_selected_team_from_payload(payload, input_id_prefix): - Team = apps.get_model("user_management", "Team") - selected_team_id = payload["view"]["state"]["values"][input_id_prefix + MANUAL_INCIDENT_TEAM_SELECT_ID][ - OnTeamChange.routing_uid() - ]["selected_option"]["value"] - if selected_team_id == DEFAULT_TEAM_VALUE: - return None - team = Team.objects.filter(pk=selected_team_id).first() - return team - - -def _get_route_select(integration, value, input_id_prefix): - route_options = [] - initial_option_idx = 0 - for idx, route in enumerate(integration.channel_filters.all()): - filtering_term = f'"{route.filtering_term}"' - if route.is_default: - filtering_term = "default" - if value == route: - initial_option_idx = idx - route_options.append( - { - "text": { - "type": "plain_text", - "text": f"{filtering_term}", - "emoji": True, - }, - "value": f"{route.pk}", - } - ) - route_select = { - "type": "section", - "text": {"type": "mrkdwn", "text": "Select a route"}, - "block_id": input_id_prefix + MANUAL_INCIDENT_ROUTE_SELECT_ID, - "accessory": { - "type": "static_select", - "placeholder": {"type": "plain_text", "text": "Select a route", "emoji": True}, - "options": route_options, - "initial_option": route_options[initial_option_idx], - "action_id": OnRouteChange.routing_uid(), - }, - } - return route_select - - -def _get_selected_route_from_payload(payload, input_id_prefix): - ChannelFilter = apps.get_model("alerts", "ChannelFilter") - selected_org_id = payload["view"]["state"]["values"][input_id_prefix + MANUAL_INCIDENT_ROUTE_SELECT_ID][ - OnRouteChange.routing_uid() - ]["selected_option"]["value"] - channel_filter = ChannelFilter.objects.filter(pk=selected_org_id).first() - return channel_filter - - -def _get_and_change_input_id_prefix_from_metadata(metadata): - old_input_id_prefix = metadata["input_id_prefix"] - new_input_id_prefix = _generate_input_id_prefix() - metadata["input_id_prefix"] = new_input_id_prefix - return old_input_id_prefix, new_input_id_prefix, metadata - - -def _get_title_input(payload): - title_input_block = { - "type": "input", - "block_id": MANUAL_INCIDENT_TITLE_INPUT_ID, - "label": { - "type": "plain_text", - "text": "Title:", - }, - "element": { - "type": "plain_text_input", - "action_id": FinishCreateIncidentFromSlashCommand.routing_uid(), - "placeholder": { - "type": "plain_text", - "text": " ", - }, - }, - } - if payload.get("text", None) is not None: - title_input_block["element"]["initial_value"] = payload["text"] - return title_input_block - - -def _get_title_from_payload(payload): - title = payload["view"]["state"]["values"][MANUAL_INCIDENT_TITLE_INPUT_ID][ - FinishCreateIncidentFromSlashCommand.routing_uid() - ]["value"] - return title - - -def _get_message_input(payload): - message_input_block = { - "type": "input", - "block_id": MANUAL_INCIDENT_MESSAGE_INPUT_ID, - "label": { - "type": "plain_text", - "text": "Message:", - }, - "element": { - "type": "plain_text_input", - "action_id": FinishCreateIncidentFromSlashCommand.routing_uid(), - "multiline": True, - "placeholder": { - "type": "plain_text", - "text": " ", - }, - }, - "optional": True, - } - if payload.get("message", {}).get("text") is not None: - message_input_block["element"]["initial_value"] = payload["message"]["text"] - return message_input_block - - -def _get_message_from_payload(payload): - message = ( - payload["view"]["state"]["values"][MANUAL_INCIDENT_MESSAGE_INPUT_ID][ - FinishCreateIncidentFromSlashCommand.routing_uid() - ]["value"] - or "" - ) - return message - - -# _generate_input_id_prefix returns uniq str to not to preserve input's values between view update -# https://api.slack.com/methods/views.update#markdown -def _generate_input_id_prefix(): - return str(uuid4()) - - -STEPS_ROUTING = [ - { - "payload_type": scenario_step.PAYLOAD_TYPE_MESSAGE_ACTION, - "message_action_callback_id": StartCreateIncidentFromMessage.callback_id, - "step": StartCreateIncidentFromMessage, - }, - { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, - "block_action_id": OnOrgChange.routing_uid(), - "step": OnOrgChange, - }, - { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, - "block_action_id": OnTeamChange.routing_uid(), - "step": OnTeamChange, - }, - { - "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, - "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, - "block_action_id": OnRouteChange.routing_uid(), - "step": OnRouteChange, - }, - { - "payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION, - "view_callback_id": FinishCreateIncidentFromMessage.routing_uid(), - "step": FinishCreateIncidentFromMessage, - }, - { - "payload_type": scenario_step.PAYLOAD_TYPE_SLASH_COMMAND, - "command_name": StartCreateIncidentFromSlashCommand.command_name, - "step": StartCreateIncidentFromSlashCommand, - }, - { - "payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION, - "view_callback_id": FinishCreateIncidentFromSlashCommand.routing_uid(), - "step": FinishCreateIncidentFromSlashCommand, - }, -] diff --git a/engine/apps/slack/views.py b/engine/apps/slack/views.py index da43077c..9f6ef2fa 100644 --- a/engine/apps/slack/views.py +++ b/engine/apps/slack/views.py @@ -22,7 +22,6 @@ from apps.slack.scenarios.alertgroup_appearance import STEPS_ROUTING as ALERTGRO from apps.slack.scenarios.declare_incident import STEPS_ROUTING as DECLARE_INCIDENT_ROUTING from apps.slack.scenarios.distribute_alerts import STEPS_ROUTING as DISTRIBUTION_STEPS_ROUTING from apps.slack.scenarios.invited_to_channel import STEPS_ROUTING as INVITED_TO_CHANNEL_ROUTING -from apps.slack.scenarios.manual_incident import STEPS_ROUTING as MANUAL_INCIDENT_ROUTING from apps.slack.scenarios.notified_user_not_in_channel import STEPS_ROUTING as NOTIFIED_USER_NOT_IN_CHANNEL_ROUTING from apps.slack.scenarios.onboarding import STEPS_ROUTING as ONBOARDING_STEPS_ROUTING from apps.slack.scenarios.paging import STEPS_ROUTING as DIRECT_PAGE_ROUTING @@ -73,7 +72,6 @@ SCENARIOS_ROUTES.extend(RESOLUTION_NOTE_ROUTING) SCENARIOS_ROUTES.extend(SLACK_USERGROUP_UPDATE_ROUTING) SCENARIOS_ROUTES.extend(CHANNEL_ROUTING) SCENARIOS_ROUTES.extend(PROFILE_UPDATE_ROUTING) -SCENARIOS_ROUTES.extend(MANUAL_INCIDENT_ROUTING) SCENARIOS_ROUTES.extend(DIRECT_PAGE_ROUTING) SCENARIOS_ROUTES.extend(DECLARE_INCIDENT_ROUTING) SCENARIOS_ROUTES.extend(NOTIFIED_USER_NOT_IN_CHANNEL_ROUTING) From b2fc9635bf8757d58dc1c67f047b81c48234d608 Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Tue, 11 Jul 2023 14:43:06 +0100 Subject: [PATCH 04/11] Bring back `/oncall` Slack command (#2500) Reverts grafana/oncall#2499 `/oncall` command was removed by mistake. --- .../apps/slack/scenarios/manual_incident.py | 549 ++++++++++++++++++ engine/apps/slack/views.py | 2 + 2 files changed, 551 insertions(+) create mode 100644 engine/apps/slack/scenarios/manual_incident.py diff --git a/engine/apps/slack/scenarios/manual_incident.py b/engine/apps/slack/scenarios/manual_incident.py new file mode 100644 index 00000000..bca16d62 --- /dev/null +++ b/engine/apps/slack/scenarios/manual_incident.py @@ -0,0 +1,549 @@ +import json +from uuid import uuid4 + +from django.apps import apps +from django.conf import settings + +from apps.alerts.models import AlertReceiveChannel +from apps.slack.scenarios import scenario_step +from apps.slack.slack_client.exceptions import SlackAPIException + +MANUAL_INCIDENT_TEAM_SELECT_ID = "manual_incident_team_select" +MANUAL_INCIDENT_ORG_SELECT_ID = "manual_incident_org_select" +MANUAL_INCIDENT_ROUTE_SELECT_ID = "manual_incident_route_select" +MANUAL_INCIDENT_TITLE_INPUT_ID = "manual_incident_title_input" +MANUAL_INCIDENT_MESSAGE_INPUT_ID = "manual_incident_message_input" + +DEFAULT_TEAM_VALUE = "default_team" + + +class StartCreateIncidentFromSlashCommand(scenario_step.ScenarioStep): + """ + StartCreateIncidentFromSlashCommand triggers creation of a manual incident from the slack message via slash command + """ + + command_name = [settings.SLACK_SLASH_COMMAND_NAME] + TITLE_INPUT_BLOCK_ID = "TITLE_INPUT" + MESSAGE_INPUT_BLOCK_ID = "MESSAGE_INPUT" + + def process_scenario(self, slack_user_identity, slack_team_identity, payload): + input_id_prefix = _generate_input_id_prefix() + + try: + channel_id = payload["event"]["channel"] + except KeyError: + channel_id = payload["channel_id"] + + private_metadata = { + "channel_id": channel_id, + "input_id_prefix": input_id_prefix, + "with_title_and_message_inputs": True, + "submit_routing_uid": FinishCreateIncidentFromSlashCommand.routing_uid(), + } + + blocks = _get_manual_incident_initial_form_fields( + slack_team_identity, slack_user_identity, input_id_prefix, payload, with_title_and_message_inputs=True + ) + view = _get_manual_incident_form_view( + FinishCreateIncidentFromSlashCommand.routing_uid(), blocks, json.dumps(private_metadata) + ) + + self._slack_client.api_call( + "views.open", + trigger_id=payload["trigger_id"], + view=view, + ) + + +class FinishCreateIncidentFromSlashCommand(scenario_step.ScenarioStep): + """ + FinishCreateIncidentFromSlashCommand creates a manual incident from the slack message via slash message + """ + + def process_scenario(self, slack_user_identity, slack_team_identity, payload): + Alert = apps.get_model("alerts", "Alert") + + title = _get_title_from_payload(payload) + message = _get_message_from_payload(payload) + + private_metadata = json.loads(payload["view"]["private_metadata"]) + + channel_id = private_metadata["channel_id"] + + input_id_prefix = private_metadata["input_id_prefix"] + selected_organization = _get_selected_org_from_payload(payload, input_id_prefix) + selected_team = _get_selected_team_from_payload(payload, input_id_prefix) + selected_route = _get_selected_route_from_payload(payload, input_id_prefix) + + user = slack_user_identity.get_user(selected_organization) + alert_receive_channel = AlertReceiveChannel.get_or_create_manual_integration( + organization=selected_organization, + team=selected_team, + integration=AlertReceiveChannel.INTEGRATION_MANUAL, + deleted_at=None, + defaults={ + "author": user, + "verbal_name": f"Manual incidents ({selected_team.name if selected_team else 'General'} team)", + }, + ) + + author_username = slack_user_identity.slack_verbal + + try: + self._slack_client.api_call( + "chat.postEphemeral", + channel=channel_id, + user=slack_user_identity.slack_id, + text=":white_check_mark: Alert *{}* successfully submitted".format(title), + ) + except SlackAPIException as e: + if e.response["error"] == "channel_not_found": + self._slack_client.api_call( + "chat.postEphemeral", + channel=slack_user_identity.im_channel_id, + user=slack_user_identity.slack_id, + text=":white_check_mark: Alert *{}* successfully submitted".format(title), + ) + else: + raise e + + # Deprecated, use custom oncall property instead. + # Update private metadata to use it in rendering: + payload["view"]["private_metadata"] = private_metadata + # Custom oncall property to simplify rendering + payload["oncall"] = {} + payload["oncall"]["title"] = title + payload["oncall"]["message"] = message + payload["oncall"]["author_username"] = author_username + payload["oncall"]["permalink"] = None + + Alert.create( + title=title, + message=message, + image_url=None, + link_to_upstream_details=None, + alert_receive_channel=alert_receive_channel, + raw_request_data=payload, + integration_unique_data={ + "created_by": author_username, + }, + force_route_id=selected_route.pk, + ) + + +# OnChange steps responsible for rerendering manual incident creation form on change values in selects. +# They are works both with incident creation from submenu and slack command. + + +class OnOrgChange(scenario_step.ScenarioStep): + def process_scenario(self, slack_user_identity, slack_team_identity, payload): + private_metadata = json.loads(payload["view"]["private_metadata"]) + with_title_and_message_inputs = private_metadata.get("with_title_and_message_inputs", False) + submit_routing_uid = private_metadata.get("submit_routing_uid") + old_input_id_prefix, new_input_id_prefix, new_private_metadata = _get_and_change_input_id_prefix_from_metadata( + private_metadata + ) + + selected_organization = _get_selected_org_from_payload(payload, old_input_id_prefix) + # Set selected team to default because org is changed. + selected_team = None + + user = slack_user_identity.get_user(selected_organization) + manual_integration = AlertReceiveChannel.get_or_create_manual_integration( + organization=selected_organization, + team=selected_team, + integration=AlertReceiveChannel.INTEGRATION_MANUAL, + deleted_at=None, + defaults={ + "author": user, + "verbal_name": f"Manual incidents ({selected_team.name if selected_team else 'General'} team)", + }, + ) + selected_route = manual_integration.default_channel_filter + + organization_select = _get_organization_select( + slack_team_identity, slack_user_identity, selected_organization, new_input_id_prefix + ) + team_select = _get_team_select(slack_user_identity, selected_organization, selected_team, new_input_id_prefix) + route_select = _get_route_select(manual_integration, selected_route, new_input_id_prefix) + + blocks = [organization_select, team_select, route_select] + if with_title_and_message_inputs: + blocks.extend([_get_title_input(payload), _get_message_input(payload)]) + view = _get_manual_incident_form_view(submit_routing_uid, blocks, json.dumps(new_private_metadata)) + self._slack_client.api_call( + "views.update", + trigger_id=payload["trigger_id"], + view=view, + view_id=payload["view"]["id"], + ) + + +class OnTeamChange(scenario_step.ScenarioStep): + def process_scenario(self, slack_user_identity, slack_team_identity, payload): + private_metadata = json.loads(payload["view"]["private_metadata"]) + with_title_and_message_inputs = private_metadata.get("with_title_and_message_inputs", False) + submit_routing_uid = private_metadata.get("submit_routing_uid") + old_input_id_prefix, new_input_id_prefix, new_private_metadata = _get_and_change_input_id_prefix_from_metadata( + private_metadata + ) + + selected_organization = _get_selected_org_from_payload(payload, old_input_id_prefix) + selected_team = _get_selected_team_from_payload(payload, old_input_id_prefix) + + user = slack_user_identity.get_user(selected_organization) + manual_integration = AlertReceiveChannel.get_or_create_manual_integration( + organization=selected_organization, + team=selected_team, + integration=AlertReceiveChannel.INTEGRATION_MANUAL, + deleted_at=None, + defaults={ + "author": user, + "verbal_name": f"Manual incidents ({selected_team.name if selected_team else 'General'} team)", + }, + ) + initial_route = manual_integration.default_channel_filter + + organization_select = _get_organization_select( + slack_team_identity, slack_user_identity, selected_organization, new_input_id_prefix + ) + team_select = _get_team_select(slack_user_identity, selected_organization, selected_team, new_input_id_prefix) + route_select = _get_route_select(manual_integration, initial_route, new_input_id_prefix) + + blocks = [organization_select, team_select, route_select] + if with_title_and_message_inputs: + blocks.extend([_get_title_input(payload), _get_message_input(payload)]) + view = _get_manual_incident_form_view(submit_routing_uid, blocks, json.dumps(new_private_metadata)) + self._slack_client.api_call( + "views.update", + trigger_id=payload["trigger_id"], + view=view, + view_id=payload["view"]["id"], + ) + + +class OnRouteChange(scenario_step.ScenarioStep): + """ + OnRouteChange is just a plug to handle change of value on route select + """ + + def process_scenario(self, slack_user_identity, slack_team_identity, payload): + pass + + +def _get_manual_incident_form_view(routing_uid, blocks, private_metatada): + view = { + "type": "modal", + "callback_id": routing_uid, + "title": { + "type": "plain_text", + "text": "Start New Escalation", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + "emoji": True, + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "blocks": blocks, + "private_metadata": private_metatada, + } + + return view + + +def _get_manual_incident_initial_form_fields( + slack_team_identity, slack_user_identity, input_id_prefix, payload, with_title_and_message_inputs=False +): + initial_organization = ( + slack_team_identity.organizations.filter(users__slack_user_identity=slack_user_identity) + .order_by("pk") + .distinct() + .first() + ) + + organization_select = _get_organization_select( + slack_team_identity, slack_user_identity, initial_organization, input_id_prefix + ) + + initial_team = None # means default team + team_select = _get_team_select(slack_user_identity, initial_organization, initial_team, input_id_prefix) + + user = slack_user_identity.get_user(initial_organization) + manual_integration = AlertReceiveChannel.get_or_create_manual_integration( + organization=initial_organization, + team=initial_team, + integration=AlertReceiveChannel.INTEGRATION_MANUAL, + deleted_at=None, + defaults={ + "author": user, + "verbal_name": f"Manual incidents ({initial_team.name if initial_team else 'General'} team)", + }, + ) + + initial_route = manual_integration.default_channel_filter + route_select = _get_route_select(manual_integration, initial_route, input_id_prefix) + blocks = [organization_select, team_select, route_select] + if with_title_and_message_inputs: + title_input = _get_title_input(payload) + message_input = _get_message_input(payload) + blocks.append(title_input) + blocks.append(message_input) + return blocks + + +def _get_organization_select(slack_team_identity, slack_user_identity, value, input_id_prefix): + organizations = slack_team_identity.organizations.filter( + users__slack_user_identity=slack_user_identity, + ).distinct() + organizations_options = [] + initial_option_idx = 0 + for idx, org in enumerate(organizations): + if org == value: + initial_option_idx = idx + organizations_options.append( + { + "text": { + "type": "plain_text", + "text": f"{org.stack_slug}", + "emoji": True, + }, + "value": f"{org.pk}", + } + ) + + organization_select = { + "type": "section", + "text": {"type": "mrkdwn", "text": "Select an organization"}, + "block_id": input_id_prefix + MANUAL_INCIDENT_ORG_SELECT_ID, + "accessory": { + "type": "static_select", + "placeholder": {"type": "plain_text", "text": "Select an organization", "emoji": True}, + "options": organizations_options, + "action_id": OnOrgChange.routing_uid(), + "initial_option": organizations_options[initial_option_idx], + }, + } + + return organization_select + + +def _get_selected_org_from_payload(payload, input_id_prefix): + Organization = apps.get_model("user_management", "Organization") + selected_org_id = payload["view"]["state"]["values"][input_id_prefix + MANUAL_INCIDENT_ORG_SELECT_ID][ + OnOrgChange.routing_uid() + ]["selected_option"]["value"] + org = Organization.objects.filter(pk=selected_org_id).first() + return org + + +def _get_team_select(slack_user_identity, organization, value, input_id_prefix): + teams = organization.teams.filter( + users__slack_user_identity=slack_user_identity, + ).distinct() + team_options = [] + # Adding pseudo option for default team + initial_option_idx = 0 + team_options.append( + { + "text": { + "type": "plain_text", + "text": f"General", + "emoji": True, + }, + "value": DEFAULT_TEAM_VALUE, + } + ) + for idx, team in enumerate(teams): + if team == value: + # Add 1 because default team option was added before cycle, so option indicies are shifted + initial_option_idx = idx + 1 + team_options.append( + { + "text": { + "type": "plain_text", + "text": f"{team.name}", + "emoji": True, + }, + "value": f"{team.pk}", + } + ) + + team_select = { + "type": "section", + "text": {"type": "mrkdwn", "text": "Select a team"}, + "block_id": input_id_prefix + MANUAL_INCIDENT_TEAM_SELECT_ID, + "accessory": { + "type": "static_select", + "placeholder": {"type": "plain_text", "text": "Select a team", "emoji": True}, + "options": team_options, + "action_id": OnTeamChange.routing_uid(), + "initial_option": team_options[initial_option_idx], + }, + } + return team_select + + +def _get_selected_team_from_payload(payload, input_id_prefix): + Team = apps.get_model("user_management", "Team") + selected_team_id = payload["view"]["state"]["values"][input_id_prefix + MANUAL_INCIDENT_TEAM_SELECT_ID][ + OnTeamChange.routing_uid() + ]["selected_option"]["value"] + if selected_team_id == DEFAULT_TEAM_VALUE: + return None + team = Team.objects.filter(pk=selected_team_id).first() + return team + + +def _get_route_select(integration, value, input_id_prefix): + route_options = [] + initial_option_idx = 0 + for idx, route in enumerate(integration.channel_filters.all()): + filtering_term = f'"{route.filtering_term}"' + if route.is_default: + filtering_term = "default" + if value == route: + initial_option_idx = idx + route_options.append( + { + "text": { + "type": "plain_text", + "text": f"{filtering_term}", + "emoji": True, + }, + "value": f"{route.pk}", + } + ) + route_select = { + "type": "section", + "text": {"type": "mrkdwn", "text": "Select a route"}, + "block_id": input_id_prefix + MANUAL_INCIDENT_ROUTE_SELECT_ID, + "accessory": { + "type": "static_select", + "placeholder": {"type": "plain_text", "text": "Select a route", "emoji": True}, + "options": route_options, + "initial_option": route_options[initial_option_idx], + "action_id": OnRouteChange.routing_uid(), + }, + } + return route_select + + +def _get_selected_route_from_payload(payload, input_id_prefix): + ChannelFilter = apps.get_model("alerts", "ChannelFilter") + selected_org_id = payload["view"]["state"]["values"][input_id_prefix + MANUAL_INCIDENT_ROUTE_SELECT_ID][ + OnRouteChange.routing_uid() + ]["selected_option"]["value"] + channel_filter = ChannelFilter.objects.filter(pk=selected_org_id).first() + return channel_filter + + +def _get_and_change_input_id_prefix_from_metadata(metadata): + old_input_id_prefix = metadata["input_id_prefix"] + new_input_id_prefix = _generate_input_id_prefix() + metadata["input_id_prefix"] = new_input_id_prefix + return old_input_id_prefix, new_input_id_prefix, metadata + + +def _get_title_input(payload): + title_input_block = { + "type": "input", + "block_id": MANUAL_INCIDENT_TITLE_INPUT_ID, + "label": { + "type": "plain_text", + "text": "Title:", + }, + "element": { + "type": "plain_text_input", + "action_id": FinishCreateIncidentFromSlashCommand.routing_uid(), + "placeholder": { + "type": "plain_text", + "text": " ", + }, + }, + } + if payload.get("text", None) is not None: + title_input_block["element"]["initial_value"] = payload["text"] + return title_input_block + + +def _get_title_from_payload(payload): + title = payload["view"]["state"]["values"][MANUAL_INCIDENT_TITLE_INPUT_ID][ + FinishCreateIncidentFromSlashCommand.routing_uid() + ]["value"] + return title + + +def _get_message_input(payload): + message_input_block = { + "type": "input", + "block_id": MANUAL_INCIDENT_MESSAGE_INPUT_ID, + "label": { + "type": "plain_text", + "text": "Message:", + }, + "element": { + "type": "plain_text_input", + "action_id": FinishCreateIncidentFromSlashCommand.routing_uid(), + "multiline": True, + "placeholder": { + "type": "plain_text", + "text": " ", + }, + }, + "optional": True, + } + if payload.get("message", {}).get("text") is not None: + message_input_block["element"]["initial_value"] = payload["message"]["text"] + return message_input_block + + +def _get_message_from_payload(payload): + message = ( + payload["view"]["state"]["values"][MANUAL_INCIDENT_MESSAGE_INPUT_ID][ + FinishCreateIncidentFromSlashCommand.routing_uid() + ]["value"] + or "" + ) + return message + + +# _generate_input_id_prefix returns uniq str to not to preserve input's values between view update +# https://api.slack.com/methods/views.update#markdown +def _generate_input_id_prefix(): + return str(uuid4()) + + +STEPS_ROUTING = [ + { + "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, + "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "block_action_id": OnOrgChange.routing_uid(), + "step": OnOrgChange, + }, + { + "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, + "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "block_action_id": OnTeamChange.routing_uid(), + "step": OnTeamChange, + }, + { + "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, + "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "block_action_id": OnRouteChange.routing_uid(), + "step": OnRouteChange, + }, + { + "payload_type": scenario_step.PAYLOAD_TYPE_SLASH_COMMAND, + "command_name": StartCreateIncidentFromSlashCommand.command_name, + "step": StartCreateIncidentFromSlashCommand, + }, + { + "payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION, + "view_callback_id": FinishCreateIncidentFromSlashCommand.routing_uid(), + "step": FinishCreateIncidentFromSlashCommand, + }, +] diff --git a/engine/apps/slack/views.py b/engine/apps/slack/views.py index 9f6ef2fa..da43077c 100644 --- a/engine/apps/slack/views.py +++ b/engine/apps/slack/views.py @@ -22,6 +22,7 @@ from apps.slack.scenarios.alertgroup_appearance import STEPS_ROUTING as ALERTGRO from apps.slack.scenarios.declare_incident import STEPS_ROUTING as DECLARE_INCIDENT_ROUTING from apps.slack.scenarios.distribute_alerts import STEPS_ROUTING as DISTRIBUTION_STEPS_ROUTING from apps.slack.scenarios.invited_to_channel import STEPS_ROUTING as INVITED_TO_CHANNEL_ROUTING +from apps.slack.scenarios.manual_incident import STEPS_ROUTING as MANUAL_INCIDENT_ROUTING from apps.slack.scenarios.notified_user_not_in_channel import STEPS_ROUTING as NOTIFIED_USER_NOT_IN_CHANNEL_ROUTING from apps.slack.scenarios.onboarding import STEPS_ROUTING as ONBOARDING_STEPS_ROUTING from apps.slack.scenarios.paging import STEPS_ROUTING as DIRECT_PAGE_ROUTING @@ -72,6 +73,7 @@ SCENARIOS_ROUTES.extend(RESOLUTION_NOTE_ROUTING) SCENARIOS_ROUTES.extend(SLACK_USERGROUP_UPDATE_ROUTING) SCENARIOS_ROUTES.extend(CHANNEL_ROUTING) SCENARIOS_ROUTES.extend(PROFILE_UPDATE_ROUTING) +SCENARIOS_ROUTES.extend(MANUAL_INCIDENT_ROUTING) SCENARIOS_ROUTES.extend(DIRECT_PAGE_ROUTING) SCENARIOS_ROUTES.extend(DECLARE_INCIDENT_ROUTING) SCENARIOS_ROUTES.extend(NOTIFIED_USER_NOT_IN_CHANNEL_ROUTING) From b951b6b6bd9bea9136475492b28b7bdea9e9a1cf Mon Sep 17 00:00:00 2001 From: Maxim Mordasov Date: Tue, 11 Jul 2023 16:51:22 +0300 Subject: [PATCH 05/11] add debounce for GSelect and RemoteSelect (#2466) # What this PR does Fix performance Issue in GSelect component when searching ## Which issue(s) this PR fixes https://github.com/grafana/oncall/issues/1628 ## Checklist - [ ] Unit, integration, and e2e (if applicable) tests updated - [ ] Documentation added (or `pr:no public docs` PR label added if not required) - [ ] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --------- Co-authored-by: Joey Orlando Co-authored-by: Joey Orlando --- CHANGELOG.md | 7 +++++++ .../integration-tests/utils/forms.ts | 9 +-------- grafana-plugin/playwright.config.ts | 2 +- .../src/containers/GSelect/GSelect.tsx | 19 ++++++------------- .../containers/RemoteSelect/RemoteSelect.tsx | 17 +++++++---------- .../src/models/grafana_team/grafana_team.ts | 5 ++++- 6 files changed, 26 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6fd2a9d..9316eade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Fixed + +- Add debounce on Select UI components to avoid making API search requests on each key-down event by + @maskin25 ([#2466](https://github.com/grafana/oncall/pull/2466)) + ## v1.3.8 (2023-07-11) ### Added diff --git a/grafana-plugin/integration-tests/utils/forms.ts b/grafana-plugin/integration-tests/utils/forms.ts index 30fb8cdd..fb00b9dd 100644 --- a/grafana-plugin/integration-tests/utils/forms.ts +++ b/grafana-plugin/integration-tests/utils/forms.ts @@ -88,14 +88,7 @@ const chooseDropdownValue = async ({ page, value, optionExactMatch = true }: Sel export const selectDropdownValue = async (args: SelectDropdownValueArgs): Promise => { const selectElement = await openSelect(args); - - /** - * use the select search to filter down the options - * TODO: get rid of the slice when we fix the GSelect component.. - * without slicing this would fire off an API request for every key-stroke - */ - await selectElement.type(args.value.slice(0, 5)); - + await selectElement.type(args.value); await chooseDropdownValue(args); return selectElement; diff --git a/grafana-plugin/playwright.config.ts b/grafana-plugin/playwright.config.ts index bab2ea80..424a03e3 100644 --- a/grafana-plugin/playwright.config.ts +++ b/grafana-plugin/playwright.config.ts @@ -38,7 +38,7 @@ const config: PlaywrightTestConfig = { * to flaky tests.. let's just retry failed tests. If the same test fails 3 times, you know something must be up */ retries: !!process.env.CI ? 3 : 0, - workers: !!process.env.CI ? 2 : 1, + workers: 3, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ diff --git a/grafana-plugin/src/containers/GSelect/GSelect.tsx b/grafana-plugin/src/containers/GSelect/GSelect.tsx index 16fb6db1..38c3e351 100644 --- a/grafana-plugin/src/containers/GSelect/GSelect.tsx +++ b/grafana-plugin/src/containers/GSelect/GSelect.tsx @@ -7,6 +7,7 @@ import { get, isNil } from 'lodash-es'; import { observer } from 'mobx-react'; import { useStore } from 'state/useStore'; +import { useDebouncedCallback } from 'utils/hooks'; import styles from './GSelect.module.scss'; @@ -89,30 +90,22 @@ const GSelect = observer((props: GSelectProps) => { [model, onChange] ); - /** - * without debouncing this function when search is available - * we risk hammering the API endpoint for every single key stroke - * some context on 250ms as the choice here - https://stackoverflow.com/a/44755058/3902555 - */ - const loadOptions = (query: string) => { - return model.updateItems(query).then(() => { + const loadOptions = useDebouncedCallback((query: string, cb) => { + model.updateItems(query).then(() => { const searchResult = model.getSearchResult(query); let items = Array.isArray(searchResult.results) ? searchResult.results : searchResult; if (filterOptions) { items = items.filter((opt: any) => filterOptions(opt[valueField])); } - - return items.map((item: any) => ({ + const options = items.map((item: any) => ({ value: item[valueField], label: get(item, displayField), imgUrl: item.avatar_url, description: getDescription && getDescription(item), })); + cb(options); }); - }; - - // TODO: why doesn't this work properly? - // const loadOptions = debounce(_loadOptions, showSearch ? 250 : 0); + }, 250); const values = isMulti ? (value ? (value as string[]) : []) diff --git a/grafana-plugin/src/containers/RemoteSelect/RemoteSelect.tsx b/grafana-plugin/src/containers/RemoteSelect/RemoteSelect.tsx index 91b17d19..b9572040 100644 --- a/grafana-plugin/src/containers/RemoteSelect/RemoteSelect.tsx +++ b/grafana-plugin/src/containers/RemoteSelect/RemoteSelect.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; +import React, { useCallback, useMemo, useReducer, useState } from 'react'; import { SelectableValue } from '@grafana/data'; import { AsyncMultiSelect, AsyncSelect } from '@grafana/ui'; @@ -6,6 +6,7 @@ import { inject, observer } from 'mobx-react'; import { makeRequest, isNetworkError } from 'network'; import { UserAction, generateMissingPermissionMessage } from 'utils/authorization'; +import { useDebouncedCallback } from 'utils/hooks'; interface RemoteSelectProps { autoFocus?: boolean; @@ -67,24 +68,20 @@ const RemoteSelect = inject('store')( const [options, setOptions] = useReducer(mergeOptions, []); - const loadOptionsCallback = useCallback(async (query?: string): Promise => { + const loadOptionsCallback = useDebouncedCallback(async (query: string, cb) => { try { const data = await makeRequest(href, { params: { search: query } }); const options = getOptions(data.results || data); setOptions(options); - return options; + cb(options); } catch (e) { if (isNetworkError(e) && e.response.status === 403 && requiredUserAction) { setNoOptionsMessage(generateMissingPermissionMessage(requiredUserAction)); } - return []; + cb([]); } - }, []); - - useEffect(() => { - loadOptionsCallback(); - }, []); + }, 250); const onChangeCallback = useCallback( (option) => { @@ -127,7 +124,7 @@ const RemoteSelect = inject('store')( isSearchable={showSearch} value={value} onChange={onChangeCallback} - defaultOptions={options} + defaultOptions loadOptions={loadOptionsCallback} getOptionLabel={getOptionLabel} noOptionsMessage={noOptionsMessage} diff --git a/grafana-plugin/src/models/grafana_team/grafana_team.ts b/grafana-plugin/src/models/grafana_team/grafana_team.ts index 243d3af2..8ca9a7b1 100644 --- a/grafana-plugin/src/models/grafana_team/grafana_team.ts +++ b/grafana-plugin/src/models/grafana_team/grafana_team.ts @@ -2,6 +2,7 @@ import { action, observable } from 'mobx'; import BaseStore from 'models/base_store'; import { GrafanaTeam } from 'models/grafana_team/grafana_team.types'; +import { makeRequest } from 'network'; import { RootStore } from 'state'; export class GrafanaTeamStore extends BaseStore { @@ -29,7 +30,9 @@ export class GrafanaTeamStore extends BaseStore { @action async updateItems(query = '') { - const result = await this.getAll(); + const result = await makeRequest(`${this.path}`, { + params: { search: query }, + }); this.items = { ...this.items, From 5ebf4372831301ef34073a822195f981afb1ece0 Mon Sep 17 00:00:00 2001 From: Rares Mardare Date: Tue, 11 Jul 2023 21:03:34 +0300 Subject: [PATCH 06/11] 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 Co-authored-by: Michael Derynck --- CHANGELOG.md | 4 + engine/apps/api/serializers/webhook.py | 1 + engine/apps/api/tests/test_webhooks.py | 61 +++ engine/apps/api/views/webhooks.py | 63 ++- .../0007_webhookresponse_event_data.py | 18 + engine/apps/webhooks/models/webhook.py | 1 + engine/apps/webhooks/tasks/trigger_webhook.py | 1 + .../AlertTemplates/AlertTemplatesForm.tsx | 12 +- grafana-plugin/src/components/GForm/GForm.tsx | 44 +- .../src/components/GForm/GForm.types.ts | 2 + .../MonacoEditor/MonacoEditor.config.ts | 26 ++ .../components/MonacoEditor/MonacoEditor.tsx | 16 +- .../ExpandedIntegrationRouteDisplay.tsx | 5 +- .../IntegrationTemplatesList.tsx | 5 +- .../IntegrationTemplate.tsx | 160 ++----- .../OutgoingWebhook2Form.config.tsx | 18 +- .../OutgoingWebhook2Form.module.css | 15 + .../OutgoingWebhook2Form.tsx | 132 ++++-- .../TemplatePreview/TemplatePreview.tsx | 30 +- .../TemplateResult/TemplateResult.tsx | 105 +++++ .../TemplatesAlertGroupsList.tsx | 406 +++++++++++------- .../WebhooksDefaultAlertGroup.ts | 62 +++ .../WebhooksTemplateEditor.tsx | 176 ++++++++ .../outgoing_webhook_2/outgoing_webhook_2.ts | 13 + .../outgoing_webhook_2.types.ts | 1 + .../src/pages/integration/Integration.tsx | 5 +- .../integration/IntegrationCommon.config.ts | 27 -- 27 files changed, 1024 insertions(+), 385 deletions(-) create mode 100644 engine/apps/webhooks/migrations/0007_webhookresponse_event_data.py create mode 100644 grafana-plugin/src/components/MonacoEditor/MonacoEditor.config.ts create mode 100644 grafana-plugin/src/containers/TemplateResult/TemplateResult.tsx create mode 100644 grafana-plugin/src/containers/TemplatesAlertGroupsList/WebhooksDefaultAlertGroup.ts create mode 100644 grafana-plugin/src/containers/WebhooksTemplateEditor/WebhooksTemplateEditor.tsx 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'; From fd9460f6bcfd856839ad6522df7892652d112117 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Tue, 11 Jul 2023 14:01:38 -0600 Subject: [PATCH 07/11] Webhooks 2 template editor fixes (#2504) - Fix Integrations drop down loading - Fix template editor preview for newly created webhooks --------- Co-authored-by: Maxim --- engine/apps/api/views/webhooks.py | 9 ++++++++- .../OutgoingWebhook2Form/OutgoingWebhook2Form.tsx | 7 +++++-- .../TemplatesAlertGroupsList.tsx | 6 +++--- .../alert_receive_channel/alert_receive_channel.ts | 12 ++++++------ 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/engine/apps/api/views/webhooks.py b/engine/apps/api/views/webhooks.py index 7079ddab..940ee683 100644 --- a/engine/apps/api/views/webhooks.py +++ b/engine/apps/api/views/webhooks.py @@ -20,6 +20,8 @@ from common.api_helpers.filters import ByTeamModelFieldFilterMixin, ModelFieldFi from common.api_helpers.mixins import PublicPrimaryKeyMixin, TeamFilteringMixin from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning +NEW_WEBHOOK_PK = "new" + RECENT_RESPONSE_LIMIT = 20 WEBHOOK_URL = "url" @@ -125,6 +127,9 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet): @action(methods=["get"], detail=True) def responses(self, request, pk): + if pk == NEW_WEBHOOK_PK: + return Response([], status=status.HTTP_200_OK) + webhook = self.get_object() queryset = WebhookResponse.objects.filter(webhook_id=webhook.id, trigger_type=webhook.trigger_type).order_by( "-timestamp" @@ -134,7 +139,9 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet): @action(methods=["post"], detail=True) def preview_template(self, request, pk): - self.get_object() # Check webhook exists + if pk != NEW_WEBHOOK_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) diff --git a/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.tsx b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.tsx index 5ebed3b6..2f915fc9 100644 --- a/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.tsx +++ b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.tsx @@ -127,7 +127,10 @@ const OutgoingWebhook2Form = observer((props: OutgoingWebhook2FormProps) => { {templateToEdit && ( onFormChangeFn?.fn(value)} + handleSubmit={(value) => { + onFormChangeFn?.fn(value); + setTemplateToEdit(undefined); + }} onHide={() => setTemplateToEdit(undefined)} template={templateToEdit} /> @@ -194,7 +197,7 @@ const OutgoingWebhook2Form = observer((props: OutgoingWebhook2FormProps) => { return ( <>
- +