v1.3.89
This commit is contained in:
commit
7fed606835
32 changed files with 529 additions and 226 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## Unreleased
|
||||
|
||||
## v1.3.89 (2024-01-17)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Webhooks UI not allowing simple webhooks to be created ([#3691](https://github.com/grafana/oncall/pull/3691))
|
||||
- Fix posting Slack message when route is deleted by @vadimkerr ([#3702](https://github.com/grafana/oncall/pull/3702))
|
||||
|
||||
### Changed
|
||||
|
||||
- Update schedules on-call users cache on every scheduled schedule refresh task ([#3699](https://github.com/grafana/oncall/pull/3699)).
|
||||
|
||||
## v1.3.88 (2024-01-16)
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -334,7 +334,48 @@ def test_preview_alert_receive_channel_backend_templater(
|
|||
response = client.post(url, format="json", data=data, **make_user_auth_headers(user, token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {"preview": "title: alert!"}
|
||||
assert response.json() == {"preview": "title: alert!", "is_valid_json_object": False}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_alert_receive_channel_template_is_valid_json_check(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
make_alert_receive_channel,
|
||||
make_channel_filter,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
default_channel_filter = make_channel_filter(alert_receive_channel, is_default=True)
|
||||
alert_group = make_alert_group(alert_receive_channel, channel_filter=default_channel_filter)
|
||||
make_alert(alert_group=alert_group, raw_request_data={"title": "alert!"})
|
||||
client = APIClient()
|
||||
|
||||
url = reverse(
|
||||
"api-internal:alert_receive_channel-preview-template", kwargs={"pk": alert_receive_channel.public_primary_key}
|
||||
)
|
||||
|
||||
# template which should produce valid json string
|
||||
data = {
|
||||
"template_body": "{{ payload | tojson }}",
|
||||
"template_name": "alert_group_multi_label",
|
||||
}
|
||||
response = client.post(url, format="json", data=data, **make_user_auth_headers(user, token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["is_valid_json_object"] is True
|
||||
|
||||
# template which produce not avalid json string
|
||||
data = {
|
||||
"template_body": "{{ payload.title }}",
|
||||
"template_name": "alert_group_multi_label",
|
||||
}
|
||||
response = client.post(url, format="json", data=data, **make_user_auth_headers(user, token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["is_valid_json_object"] is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -360,12 +401,12 @@ def test_preview_alert_group_labels(
|
|||
|
||||
data = {
|
||||
"template_body": "{{ payload.labels | tojson }}",
|
||||
"template_name": "alert_group_labels",
|
||||
"template_name": "alert_group_multi_label",
|
||||
}
|
||||
response = client.post(url, format="json", data=data, **make_user_auth_headers(user, token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {"preview": '{"1": "2"}'}
|
||||
assert response.json() == {"preview": '{"1": "2"}', "is_valid_json_object": True}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
|
|||
|
|
@ -298,6 +298,7 @@ def test_list_users_filtered_by_team(
|
|||
organization = make_organization()
|
||||
user1 = make_user_for_organization(organization)
|
||||
user2 = make_user_for_organization(organization)
|
||||
user3 = make_user_for_organization(organization)
|
||||
|
||||
team1 = make_team(organization)
|
||||
team2 = make_team(organization)
|
||||
|
|
@ -314,7 +315,7 @@ def test_list_users_filtered_by_team(
|
|||
def _get_user_pks(teams):
|
||||
response = client.get(
|
||||
url,
|
||||
data={"team": [team.public_primary_key for team in teams]}, # these are query params
|
||||
data={"team": [team.public_primary_key if team else "null" for team in teams]}, # these are query params
|
||||
**make_user_auth_headers(user1, token),
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
|
@ -323,9 +324,11 @@ def test_list_users_filtered_by_team(
|
|||
assert _get_user_pks([team1]) == [user1.public_primary_key]
|
||||
assert _get_user_pks([team1, team2]) == [user1.public_primary_key, user2.public_primary_key]
|
||||
assert _get_user_pks([team3]) == []
|
||||
assert _get_user_pks([team1, None]) == [user1.public_primary_key, user3.public_primary_key]
|
||||
assert _get_user_pks([None]) == [user3.public_primary_key]
|
||||
|
||||
# check non-existent team returns bad request
|
||||
response = client.get(f"{url}?team=null", **make_user_auth_headers(user1, token))
|
||||
response = client.get(f"{url}?team=non-existing", **make_user_auth_headers(user1, token))
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ class UserFilter(ByTeamModelFieldFilterMixin, filters.FilterSet):
|
|||
# TODO: remove "roles" in next version
|
||||
roles = filters.MultipleChoiceFilter(field_name="role", choices=LegacyAccessControlRole.choices())
|
||||
permission = filters.ChoiceFilter(method="filter_by_permission", choices=ALL_PERMISSION_CHOICES)
|
||||
team = TeamModelMultipleChoiceFilter(field_name="teams", null_label=None, null_value=None)
|
||||
team = TeamModelMultipleChoiceFilter(field_name="teams")
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from apps.mobile_app.views import MobileAppGatewayView
|
|||
DOWNSTREAM_BACKEND = "incident"
|
||||
MOCK_DOWNSTREAM_URL = "https://mockdownstream.com"
|
||||
MOCK_DOWNSTREAM_INCIDENT_API_URL = "https://mockdownstreamincidentapi.com"
|
||||
MOCK_DOWNSTREAM_HEADERS = {"Authorization": "Bearer mock_jwt"}
|
||||
MOCK_DOWNSTREAM_HEADERS = {"X-OnCall-Mobile-Proxy-Authorization": "Bearer mock_jwt"}
|
||||
MOCK_DOWNSTREAM_RESPONSE_DATA = {"foo": "bar"}
|
||||
|
||||
MOCK_TIMEZONE_NOW = timezone.datetime(2021, 1, 1, 3, 4, 5, tzinfo=timezone.utc)
|
||||
|
|
@ -311,7 +311,7 @@ def test_mobile_app_gateway_jwt_header(
|
|||
MOCK_DOWNSTREAM_URL,
|
||||
data={},
|
||||
params={},
|
||||
headers={"Authorization": f"Bearer {MOCK_JWT}"},
|
||||
headers={"X-OnCall-Mobile-Proxy-Authorization": f"Bearer {MOCK_JWT}"},
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -343,6 +343,7 @@ def test_mobile_app_gateway_properly_generates_a_jwt(
|
|||
"iat": MOCK_TIMEZONE_NOW,
|
||||
"exp": MOCK_TIMEZONE_NOW + timezone.timedelta(minutes=1),
|
||||
"user_id": user.user_id, # grafana user ID
|
||||
"user_email": user.email,
|
||||
"stack_id": organization.stack_id,
|
||||
"organization_id": organization.org_id, # grafana org ID
|
||||
"stack_slug": organization.stack_slug,
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ class MobileAppGatewayView(APIView):
|
|||
"exp": now + timezone.timedelta(minutes=1), # jwt is short lived. expires in 1 minute.
|
||||
# custom data
|
||||
"user_id": user.user_id, # grafana user ID
|
||||
"user_email": user.email,
|
||||
"stack_id": organization.stack_id,
|
||||
"organization_id": organization.org_id, # grafana org ID
|
||||
"stack_slug": organization.stack_slug,
|
||||
|
|
@ -177,7 +178,7 @@ class MobileAppGatewayView(APIView):
|
|||
@classmethod
|
||||
def _get_downstream_headers(cls, user: "User") -> typing.Dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {cls._construct_jwt(user)}",
|
||||
"X-OnCall-Mobile-Proxy-Authorization": f"Bearer {cls._construct_jwt(user)}",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -26,3 +26,6 @@ CALENDAR_TYPE_FINAL = "final"
|
|||
|
||||
EXPORT_WINDOW_DAYS_AFTER = 180
|
||||
EXPORT_WINDOW_DAYS_BEFORE = 15
|
||||
|
||||
SCHEDULE_ONCALL_CACHE_KEY_PREFIX = "schedule_oncall_users_"
|
||||
SCHEDULE_ONCALL_CACHE_TTL = 15 * 60 # 15 minutes in seconds
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ from apps.schedules.constants import (
|
|||
RE_EVENT_UID_V1,
|
||||
RE_EVENT_UID_V2,
|
||||
RE_PRIORITY,
|
||||
SCHEDULE_ONCALL_CACHE_KEY_PREFIX,
|
||||
SCHEDULE_ONCALL_CACHE_TTL,
|
||||
)
|
||||
from apps.schedules.ical_events import ical_events
|
||||
from common.cache import ensure_cache_key_allocates_to_the_same_hash_slot
|
||||
|
|
@ -387,6 +389,22 @@ def get_oncall_users_for_multiple_schedules(
|
|||
return oncall_users
|
||||
|
||||
|
||||
def _generate_cache_key_for_schedule_oncall_users(schedule: "OnCallSchedule") -> str:
|
||||
return ensure_cache_key_allocates_to_the_same_hash_slot(
|
||||
f"{SCHEDULE_ONCALL_CACHE_KEY_PREFIX}{schedule.public_primary_key}", SCHEDULE_ONCALL_CACHE_KEY_PREFIX
|
||||
)
|
||||
|
||||
|
||||
def update_cached_oncall_users_for_schedule(schedule: "OnCallSchedule"):
|
||||
oncall_users = get_oncall_users_for_multiple_schedules([schedule])
|
||||
users = oncall_users.get(schedule, [])
|
||||
cache.set(
|
||||
_generate_cache_key_for_schedule_oncall_users(schedule),
|
||||
[user.public_primary_key for user in users],
|
||||
timeout=SCHEDULE_ONCALL_CACHE_TTL,
|
||||
)
|
||||
|
||||
|
||||
def get_cached_oncall_users_for_multiple_schedules(schedules: typing.List["OnCallSchedule"]) -> SchedulesOnCallUsers:
|
||||
"""
|
||||
More "performant" version of `apps.schedules.ical_utils.get_oncall_users_for_multiple_schedules`
|
||||
|
|
@ -404,22 +422,13 @@ def get_cached_oncall_users_for_multiple_schedules(schedules: typing.List["OnCal
|
|||
from apps.schedules.models import OnCallSchedule
|
||||
from apps.user_management.models import User
|
||||
|
||||
CACHE_KEY_PREFIX = "schedule_oncall_users_"
|
||||
|
||||
def _generate_cache_key_for_schedule_oncall_users(schedule: "OnCallSchedule") -> str:
|
||||
return ensure_cache_key_allocates_to_the_same_hash_slot(
|
||||
f"{CACHE_KEY_PREFIX}{schedule.public_primary_key}", CACHE_KEY_PREFIX
|
||||
)
|
||||
|
||||
def _get_schedule_public_primary_key_from_schedule_oncall_users_cache_key(cache_key: str) -> str:
|
||||
"""
|
||||
remove any brackets that might be included in the cache key (when redis cluster is active).
|
||||
See `_generate_cache_key_for_schedule_oncall_users` just above
|
||||
"""
|
||||
cache_key = cache_key.replace("{", "").replace("}", "")
|
||||
return cache_key.replace(CACHE_KEY_PREFIX, "")
|
||||
|
||||
CACHE_TTL = 15 * 60 # 15 minutes in seconds
|
||||
return cache_key.replace(SCHEDULE_ONCALL_CACHE_KEY_PREFIX, "")
|
||||
|
||||
cache_keys = [_generate_cache_key_for_schedule_oncall_users(schedule) for schedule in schedules]
|
||||
|
||||
|
|
@ -464,7 +473,7 @@ def get_cached_oncall_users_for_multiple_schedules(schedules: typing.List["OnCal
|
|||
_generate_cache_key_for_schedule_oncall_users(schedule)
|
||||
] = oncall_user_public_primary_keys
|
||||
|
||||
cache.set_many(new_results_to_update_in_cache, timeout=CACHE_TTL)
|
||||
cache.set_many(new_results_to_update_in_cache, timeout=SCHEDULE_ONCALL_CACHE_TTL)
|
||||
|
||||
# make two queries to the database, one to fetch the schedule objects we need and the other to fetch
|
||||
# the user objects we need
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from celery.utils.log import get_task_logger
|
||||
|
||||
from apps.alerts.tasks import notify_ical_schedule_shift # type: ignore[no-redef]
|
||||
from apps.schedules.ical_utils import is_icals_equal
|
||||
from apps.schedules.ical_utils import is_icals_equal, update_cached_oncall_users_for_schedule
|
||||
from apps.schedules.tasks import notify_about_empty_shifts_in_schedule_task, notify_about_gaps_in_schedule_task
|
||||
from apps.slack.tasks import start_update_slack_user_group_for_schedules
|
||||
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
|
||||
|
|
@ -81,6 +81,9 @@ def refresh_ical_file(schedule_pk):
|
|||
task_logger.info(f"run_task_overrides {schedule_pk} {run_task_primary} icals not equal")
|
||||
run_task = run_task_primary or run_task_overrides
|
||||
|
||||
# update cached schedule on-call users
|
||||
update_cached_oncall_users_for_schedule(schedule)
|
||||
|
||||
if run_task:
|
||||
notify_about_empty_shifts_in_schedule_task.apply_async((schedule_pk,))
|
||||
notify_about_gaps_in_schedule_task.apply_async((schedule_pk,))
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import datetime
|
|||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.schedules.models import OnCallScheduleICal, OnCallScheduleWeb
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallScheduleICal, OnCallScheduleWeb
|
||||
from apps.schedules.tasks.refresh_ical_files import refresh_ical_file, start_refresh_ical_files
|
||||
|
||||
|
||||
|
|
@ -79,3 +81,48 @@ def test_refresh_ical_files_filter_orgs(
|
|||
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]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refresh_ical_updates_oncall_cache(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_schedule,
|
||||
make_on_call_shift,
|
||||
):
|
||||
organization = make_organization()
|
||||
users = [make_user_for_organization(organization) for _ in range(2)]
|
||||
|
||||
today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
|
||||
shift_start_time = today - timezone.timedelta(hours=1)
|
||||
on_call_shift = make_on_call_shift(
|
||||
organization=organization,
|
||||
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
|
||||
start=shift_start_time,
|
||||
rotation_start=shift_start_time,
|
||||
duration=timezone.timedelta(seconds=(24 * 60 * 60)),
|
||||
priority_level=1,
|
||||
frequency=CustomOnCallShift.FREQUENCY_DAILY,
|
||||
schedule=schedule,
|
||||
)
|
||||
on_call_shift.add_rolling_users([users])
|
||||
|
||||
def _generate_cache_key(schedule):
|
||||
return f"schedule_oncall_users_{schedule.public_primary_key}"
|
||||
|
||||
# start with empty cache
|
||||
cache.clear()
|
||||
|
||||
# patch ical comparison to string compare
|
||||
with patch("apps.schedules.tasks.refresh_ical_files.is_icals_equal", side_effect=lambda a, b: a == b):
|
||||
# patch schedule refresh to avoid changing schedule status (keep as defined above)
|
||||
with patch("apps.schedules.models.OnCallSchedule.refresh_ical_file", return_value=None):
|
||||
# do not trigger tasks for real
|
||||
with patch("apps.schedules.tasks.refresh_ical_files.notify_ical_schedule_shift"):
|
||||
with patch("apps.schedules.tasks.refresh_ical_files.notify_about_empty_shifts_in_schedule_task"):
|
||||
with patch("apps.schedules.tasks.refresh_ical_files.notify_about_gaps_in_schedule_task"):
|
||||
refresh_ical_file(schedule.pk)
|
||||
|
||||
cached_data = cache.get(_generate_cache_key(schedule))
|
||||
assert cached_data == [u.public_primary_key for u in users]
|
||||
|
|
|
|||
|
|
@ -76,7 +76,12 @@ class AlertShootingStep(scenario_step.ScenarioStep):
|
|||
|
||||
if num_updated_rows == 1:
|
||||
try:
|
||||
channel_id = alert.group.channel_filter.slack_channel_id_or_general_log_id
|
||||
channel_id = (
|
||||
alert.group.channel_filter.slack_channel_id_or_general_log_id
|
||||
if alert.group.channel_filter
|
||||
# if channel filter is deleted mid escalation, use default Slack channel
|
||||
else alert.group.channel.organization.general_log_channel_id
|
||||
)
|
||||
self._send_first_alert(alert, channel_id)
|
||||
except SlackAPIError:
|
||||
AlertGroup.objects.filter(pk=alert.group.pk).update(slack_message_sent=False)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import pytest
|
|||
from apps.alerts.models import AlertGroup
|
||||
from apps.slack.errors import SlackAPIRestrictedActionError
|
||||
from apps.slack.models import SlackMessage
|
||||
from apps.slack.scenarios.distribute_alerts import AlertShootingStep
|
||||
from apps.slack.scenarios.scenario_step import ScenarioStep
|
||||
from apps.slack.tests.conftest import build_slack_response
|
||||
|
||||
|
|
@ -36,3 +37,30 @@ def test_restricted_action_error(
|
|||
assert alert_group.slack_message is None
|
||||
assert SlackMessage.objects.count() == 0
|
||||
assert not alert.delivered
|
||||
|
||||
|
||||
@patch.object(AlertShootingStep, "_post_alert_group_to_slack")
|
||||
@pytest.mark.django_db
|
||||
def test_alert_shooting_no_channel_filter(
|
||||
mock_post_alert_group_to_slack,
|
||||
make_slack_team_identity,
|
||||
make_organization,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
):
|
||||
slack_team_identity = make_slack_team_identity()
|
||||
organization = make_organization(
|
||||
slack_team_identity=slack_team_identity, general_log_channel_id="DEFAULT_CHANNEL_ID"
|
||||
)
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
|
||||
# simulate an alert group with channel filter deleted in the middle of the escalation
|
||||
alert_group = make_alert_group(alert_receive_channel, channel_filter=None)
|
||||
alert = make_alert(alert_group, raw_request_data={})
|
||||
|
||||
step = AlertShootingStep(slack_team_identity, organization)
|
||||
step.process_signal(alert)
|
||||
|
||||
mock_post_alert_group_to_slack.assert_called_once()
|
||||
assert mock_post_alert_group_to_slack.call_args[1]["channel_id"] == "DEFAULT_CHANNEL_ID"
|
||||
|
|
|
|||
34
engine/apps/slack/tests/test_utils.py
Normal file
34
engine/apps/slack/tests/test_utils.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.slack.client import SlackClient
|
||||
from apps.slack.errors import (
|
||||
SlackAPIChannelArchivedError,
|
||||
SlackAPIChannelNotFoundError,
|
||||
SlackAPIError,
|
||||
SlackAPIInvalidAuthError,
|
||||
SlackAPITokenError,
|
||||
)
|
||||
from apps.slack.utils import post_message_to_channel
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error,raise_exception",
|
||||
[
|
||||
(SlackAPITokenError, False),
|
||||
(SlackAPIChannelNotFoundError, False),
|
||||
(SlackAPIChannelArchivedError, False),
|
||||
(SlackAPIInvalidAuthError, False),
|
||||
(SlackAPIError, True),
|
||||
],
|
||||
)
|
||||
def test_post_message_to_channel(error, raise_exception):
|
||||
organization = Mock()
|
||||
with patch.object(SlackClient, "chat_postMessage", side_effect=error(Mock())) as mock_chat_postMessage:
|
||||
if raise_exception:
|
||||
with pytest.raises(SlackAPIError):
|
||||
post_message_to_channel(organization, "test", "test")
|
||||
else:
|
||||
post_message_to_channel(organization, "test", "test")
|
||||
mock_chat_postMessage.assert_called_once()
|
||||
|
|
@ -3,7 +3,12 @@ import typing
|
|||
from datetime import datetime
|
||||
|
||||
from apps.slack.client import SlackClient
|
||||
from apps.slack.errors import SlackAPIChannelNotFoundError
|
||||
from apps.slack.errors import (
|
||||
SlackAPIChannelArchivedError,
|
||||
SlackAPIChannelNotFoundError,
|
||||
SlackAPIInvalidAuthError,
|
||||
SlackAPITokenError,
|
||||
)
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from apps.user_management.models import Organization
|
||||
|
|
@ -68,7 +73,12 @@ def post_message_to_channel(organization: "Organization", channel_id: str, text:
|
|||
slack_client = SlackClient(organization.slack_team_identity)
|
||||
try:
|
||||
slack_client.chat_postMessage(channel=channel_id, text=text)
|
||||
except SlackAPIChannelNotFoundError:
|
||||
except (
|
||||
SlackAPITokenError,
|
||||
SlackAPIInvalidAuthError,
|
||||
SlackAPIChannelNotFoundError,
|
||||
SlackAPIChannelArchivedError,
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -86,13 +86,14 @@ class ByTeamModelFieldFilterMixin:
|
|||
return queryset
|
||||
filter = self.filters[ByTeamModelFieldFilterMixin.FILTER_FIELD_NAME]
|
||||
null_team_lookup = None
|
||||
for value in values:
|
||||
if filter.null_value == value:
|
||||
null_team_lookup = Q(**{f"{name}__isnull": True})
|
||||
values.remove(value)
|
||||
teams_lookup = Q(**{f"{name}__in": values})
|
||||
if filter.null_value in values:
|
||||
null_team_lookup = Q(**{f"{name}__isnull": True})
|
||||
values.remove(filter.null_value)
|
||||
teams_lookup = None
|
||||
if values:
|
||||
teams_lookup = Q(**{f"{name}__in": values})
|
||||
if null_team_lookup is not None:
|
||||
teams_lookup = teams_lookup | null_team_lookup
|
||||
teams_lookup = teams_lookup | null_team_lookup if teams_lookup else null_team_lookup
|
||||
|
||||
return queryset.filter(teams_lookup).distinct()
|
||||
|
||||
|
|
|
|||
|
|
@ -250,6 +250,8 @@ GROUPING_ID = "grouping_id"
|
|||
SOURCE_LINK = "source_link"
|
||||
ROUTE = "route"
|
||||
ALERT_GROUP_LABELS = "alert_group_labels"
|
||||
ALERT_GROUP_MULTI_LABEL = "alert_group_multi_label"
|
||||
ALERT_GROUP_DYNAMIC_LABEL = "alert_group_dynamic_label"
|
||||
|
||||
NOTIFICATION_CHANNEL_TO_TEMPLATER_MAP = {
|
||||
SLACK: AlertSlackTemplater,
|
||||
|
|
@ -272,7 +274,8 @@ BEHAVIOUR_TEMPLATE_NAMES = [
|
|||
GROUPING_ID,
|
||||
SOURCE_LINK,
|
||||
ROUTE,
|
||||
ALERT_GROUP_LABELS,
|
||||
ALERT_GROUP_MULTI_LABEL,
|
||||
ALERT_GROUP_DYNAMIC_LABEL,
|
||||
]
|
||||
ALL_TEMPLATE_NAMES = APPEARANCE_TEMPLATE_NAMES + BEHAVIOUR_TEMPLATE_NAMES
|
||||
|
||||
|
|
@ -294,7 +297,10 @@ class PreviewTemplateMixin:
|
|||
),
|
||||
responses=inline_serializer(
|
||||
name="PreviewTemplateResponse",
|
||||
fields={"preview": serializers.CharField(allow_null=True)},
|
||||
fields={
|
||||
"preview": serializers.CharField(allow_null=True),
|
||||
"is_valid_json_object": serializers.BooleanField(),
|
||||
},
|
||||
),
|
||||
)
|
||||
@action(methods=["post"], detail=True)
|
||||
|
|
@ -351,9 +357,15 @@ class PreviewTemplateMixin:
|
|||
return Response({"preview": e.fallback_message}, status.HTTP_200_OK)
|
||||
else:
|
||||
templated_attr = None
|
||||
response = {"preview": templated_attr}
|
||||
response = {"preview": templated_attr, "is_valid_json_object": self.is_valid_json_object(templated_attr)}
|
||||
return Response(response, status=status.HTTP_200_OK)
|
||||
|
||||
def is_valid_json_object(self, json_str):
|
||||
try:
|
||||
return isinstance(json.loads(json_str), dict)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def get_alert_to_template(self, payload=None):
|
||||
raise NotImplementedError
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { BaseTemplateOptions } from 'pages/integration/IntegrationCommon.config';
|
||||
import { IntegrationTemplateOptions } from 'pages/integration/IntegrationCommon.config';
|
||||
|
||||
export interface Template {
|
||||
name: string;
|
||||
|
|
@ -22,19 +22,19 @@ export interface TemplateForEdit {
|
|||
export const commonTemplateForEdit: { [id: string]: TemplateForEdit } = {
|
||||
web_title_template: {
|
||||
displayName: 'Web title',
|
||||
name: BaseTemplateOptions.WebTitle.key,
|
||||
name: IntegrationTemplateOptions.WebTitle.key,
|
||||
description: '',
|
||||
type: 'html',
|
||||
},
|
||||
web_message_template: {
|
||||
displayName: 'Web message',
|
||||
name: BaseTemplateOptions.WebMessage.key,
|
||||
name: IntegrationTemplateOptions.WebMessage.key,
|
||||
description: '',
|
||||
type: 'html',
|
||||
},
|
||||
slack_title_template: {
|
||||
displayName: 'Slack title',
|
||||
name: BaseTemplateOptions.SlackTitle.key,
|
||||
name: IntegrationTemplateOptions.SlackTitle.key,
|
||||
description: '',
|
||||
additionalData: {
|
||||
chatOpsName: 'slack',
|
||||
|
|
@ -44,26 +44,26 @@ export const commonTemplateForEdit: { [id: string]: TemplateForEdit } = {
|
|||
type: 'plain',
|
||||
},
|
||||
sms_title_template: {
|
||||
name: BaseTemplateOptions.SMS.key,
|
||||
name: IntegrationTemplateOptions.SMS.key,
|
||||
displayName: 'Sms title',
|
||||
description:
|
||||
"Result of this template will be used as title of SMS message. Please don't include any urls, or phone numbers, to avoid SMS message being blocked by carriers.",
|
||||
type: 'plain',
|
||||
},
|
||||
phone_call_title_template: {
|
||||
name: BaseTemplateOptions.Phone.key,
|
||||
name: IntegrationTemplateOptions.Phone.key,
|
||||
displayName: 'Phone Call title',
|
||||
description: '',
|
||||
type: 'plain',
|
||||
},
|
||||
email_title_template: {
|
||||
name: BaseTemplateOptions.EmailTitle.key,
|
||||
name: IntegrationTemplateOptions.EmailTitle.key,
|
||||
displayName: 'Email title',
|
||||
description: '',
|
||||
type: 'plain',
|
||||
},
|
||||
telegram_title_template: {
|
||||
name: BaseTemplateOptions.TelegramTitle.key,
|
||||
name: IntegrationTemplateOptions.TelegramTitle.key,
|
||||
displayName: 'Telegram title',
|
||||
description: '',
|
||||
additionalData: {
|
||||
|
|
@ -73,7 +73,7 @@ export const commonTemplateForEdit: { [id: string]: TemplateForEdit } = {
|
|||
type: 'plain',
|
||||
},
|
||||
slack_message_template: {
|
||||
name: BaseTemplateOptions.SlackMessage.key,
|
||||
name: IntegrationTemplateOptions.SlackMessage.key,
|
||||
displayName: 'Slack message',
|
||||
description: '',
|
||||
additionalData: {
|
||||
|
|
@ -84,13 +84,13 @@ export const commonTemplateForEdit: { [id: string]: TemplateForEdit } = {
|
|||
type: 'plain',
|
||||
},
|
||||
email_message_template: {
|
||||
name: BaseTemplateOptions.EmailMessage.key,
|
||||
name: IntegrationTemplateOptions.EmailMessage.key,
|
||||
displayName: 'Email message',
|
||||
description: '',
|
||||
type: 'plain',
|
||||
},
|
||||
telegram_message_template: {
|
||||
name: BaseTemplateOptions.TelegramMessage.key,
|
||||
name: IntegrationTemplateOptions.TelegramMessage.key,
|
||||
displayName: 'Telegram message',
|
||||
description: '',
|
||||
additionalData: {
|
||||
|
|
@ -100,7 +100,7 @@ export const commonTemplateForEdit: { [id: string]: TemplateForEdit } = {
|
|||
type: 'plain',
|
||||
},
|
||||
slack_image_url_template: {
|
||||
name: BaseTemplateOptions.SlackImage.key,
|
||||
name: IntegrationTemplateOptions.SlackImage.key,
|
||||
displayName: 'Slack image url',
|
||||
description: '',
|
||||
additionalData: {
|
||||
|
|
@ -111,13 +111,13 @@ export const commonTemplateForEdit: { [id: string]: TemplateForEdit } = {
|
|||
type: 'plain',
|
||||
},
|
||||
web_image_url_template: {
|
||||
name: BaseTemplateOptions.WebImage.key,
|
||||
name: IntegrationTemplateOptions.WebImage.key,
|
||||
displayName: 'Web image url',
|
||||
description: '',
|
||||
type: 'image',
|
||||
},
|
||||
telegram_image_url_template: {
|
||||
name: BaseTemplateOptions.TelegramImage.key,
|
||||
name: IntegrationTemplateOptions.TelegramImage.key,
|
||||
displayName: 'Telegram image url',
|
||||
description: '',
|
||||
additionalData: {
|
||||
|
|
@ -127,33 +127,33 @@ export const commonTemplateForEdit: { [id: string]: TemplateForEdit } = {
|
|||
type: 'image',
|
||||
},
|
||||
grouping_id_template: {
|
||||
name: BaseTemplateOptions.Grouping.key,
|
||||
name: IntegrationTemplateOptions.Grouping.key,
|
||||
displayName: 'Grouping',
|
||||
description:
|
||||
'Reduce noise, minimize duplication with Alert Grouping, based on time, alert content, and even multiple features at the same time. Check the cheasheet to customize your template.',
|
||||
type: 'plain',
|
||||
},
|
||||
acknowledge_condition_template: {
|
||||
name: BaseTemplateOptions.Autoacknowledge.key,
|
||||
name: IntegrationTemplateOptions.Autoacknowledge.key,
|
||||
displayName: 'Acknowledge condition',
|
||||
description: '',
|
||||
type: 'boolean',
|
||||
},
|
||||
resolve_condition_template: {
|
||||
name: BaseTemplateOptions.Resolve.key,
|
||||
name: IntegrationTemplateOptions.Resolve.key,
|
||||
displayName: 'Resolve condition',
|
||||
description:
|
||||
'When monitoring systems return to normal, they can send "resolve" alerts. OnCall can use these signals to resolve alert groups accordingly.',
|
||||
type: 'boolean',
|
||||
},
|
||||
source_link_template: {
|
||||
name: BaseTemplateOptions.SourceLink.key,
|
||||
name: IntegrationTemplateOptions.SourceLink.key,
|
||||
displayName: 'Source link',
|
||||
description: '',
|
||||
type: 'plain',
|
||||
},
|
||||
route_template: {
|
||||
name: BaseTemplateOptions.Routing.key,
|
||||
name: IntegrationTemplateOptions.Routing.key,
|
||||
displayName: 'Routing',
|
||||
description:
|
||||
'Routes direct alerts to different escalation chains based on the content, such as severity or region.',
|
||||
|
|
|
|||
|
|
@ -58,7 +58,10 @@ export const genericTemplateCheatSheet: CheatSheetInterface = {
|
|||
{
|
||||
name: 'Jinja2 refresher ',
|
||||
listItems: [
|
||||
{ listItemName: ' {{ payload.labels.foo }} - extract field value' },
|
||||
{
|
||||
listItemName: 'Extract field value',
|
||||
codeExample: '{{ payload.labels.foo }}',
|
||||
},
|
||||
{
|
||||
listItemName: 'Conditions',
|
||||
codeExample: `{%- if "status" in payload %}
|
||||
|
|
@ -120,7 +123,10 @@ export const slackMessageTemplateCheatSheet: CheatSheetInterface = {
|
|||
{
|
||||
name: 'Jinja2 refresher ',
|
||||
listItems: [
|
||||
{ listItemName: ' {{ payload.labels.foo }} - extract field value' },
|
||||
{
|
||||
listItemName: 'Extract field value',
|
||||
codeExample: '{{ payload.labels.foo }}',
|
||||
},
|
||||
{
|
||||
listItemName: 'Conditions',
|
||||
codeExample: '{%- if "status" in payload %} \n {{ payload.status }} \n {% endif -%}',
|
||||
|
|
@ -172,3 +178,138 @@ export const slackMessageTemplateCheatSheet: CheatSheetInterface = {
|
|||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const alertGroupDynamicLabelCheatSheet: CheatSheetInterface = {
|
||||
name: 'Dynamic Label cheatsheet',
|
||||
description: 'Dynamic Label template is used to extract value for a specified key from the alert payload.',
|
||||
fields: [
|
||||
{
|
||||
name: 'Examples',
|
||||
listItems: [
|
||||
{
|
||||
listItemName:
|
||||
'Extracting the value associated with the "severity" key. If the key is not present, it defaults to "unknown."',
|
||||
codeExample: `{{ payload.get("severity", "unknown") }}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Jinja2 refresher ',
|
||||
listItems: [
|
||||
{
|
||||
listItemName: 'Extract field value',
|
||||
codeExample: '{{ payload.labels.foo }}',
|
||||
},
|
||||
{
|
||||
listItemName: 'Conditions',
|
||||
codeExample: '{%- if "status" in payload %} \n {{ payload.status }} \n {% endif -%}',
|
||||
},
|
||||
{ listItemName: 'Booleans', codeExample: '{{ payload.status == “resolved” }}' },
|
||||
{ listItemName: 'Loops', codeExample: '{% for label in labels %} \n {{ label.title }} \n {% endfor %}' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Additional jinja2 variables',
|
||||
listItems: [{ listItemName: 'payload - payload of the first alert in the group' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const alertGroupMultiLabelExtractionCheatSheet: CheatSheetInterface = {
|
||||
name: 'Multi-label extraction cheatsheet',
|
||||
description:
|
||||
'Multi-label extraction template allows extracting and modifying multiple labels from an alert payload. The Jinja template must result in string, representing valid JSON dictionary. See Examples for getting familiar with the idea',
|
||||
fields: [
|
||||
{
|
||||
name: 'Examples',
|
||||
listItems: [
|
||||
{
|
||||
listItemName: 'Extracting all the labels from the specific payload field',
|
||||
codeExample: `{{ payload.labels | tojson }}`,
|
||||
},
|
||||
{
|
||||
listItemName: 'Extract labels from different payload fields',
|
||||
codeExample: `{%- set labels = {} -%}
|
||||
{# add several labels #}
|
||||
{%- set labels = dict(labels, **payload.commonLabels) -%}
|
||||
{# add one label #}
|
||||
{%- set labels = dict(labels, **{"status": payload.status}) -%}
|
||||
{# add label not from payload #}
|
||||
{%- set labels = dict(labels, **{"service": "oncall"}) -%}
|
||||
{# dump labels dict to json string, so OnCall can parse it #}
|
||||
{{ labels | tojson }}
|
||||
`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Jinja2 refresher ',
|
||||
listItems: [
|
||||
{ listItemName: 'Dump a structure to JSON string', codeExample: '{{ payload.labels | tojson }}' },
|
||||
{
|
||||
listItemName: 'Extract field value',
|
||||
codeExample: '{{ payload.labels.foo }}',
|
||||
},
|
||||
{
|
||||
listItemName: 'Conditions',
|
||||
codeExample: '{%- if "status" in payload %} \n {{ payload.status }} \n {% endif -%}',
|
||||
},
|
||||
{ listItemName: 'Booleans', codeExample: '{{ payload.status == “resolved” }}' },
|
||||
{ listItemName: 'Loops', codeExample: '{% for label in labels %} \n {{ label.title }} \n {% endfor %}' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Additional jinja2 variables',
|
||||
listItems: [{ listItemName: 'payload - payload of the first alert in the group' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const webhookPayloadCheatSheet: CheatSheetInterface = {
|
||||
name: 'Webhook Payload cheatsheet ',
|
||||
description:
|
||||
"Webhook payload template is powered by Jinja2. It's used to process webhook data and customize the output",
|
||||
fields: [
|
||||
{
|
||||
name: 'Examples',
|
||||
listItems: [
|
||||
{
|
||||
listItemName:
|
||||
'Construct a custom webhook payload from various webhook data fields and output it as a JSON string',
|
||||
codeExample: `{%- set payload = {} -%}
|
||||
{# add alert group labels #}
|
||||
{%- set payload = dict(payload, **{"labels": alert_group.labels}) -%}
|
||||
{# add some other fields from webhook data just for example #}
|
||||
{%- set payload = dict(payload, **{"event": event.type, "integration": integration.name}) -%}
|
||||
{# encode payload dict to json #}
|
||||
{{ payload | tojson }}
|
||||
`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Jinja2 refresher ',
|
||||
listItems: [
|
||||
{
|
||||
listItemName: 'Extract field value',
|
||||
codeExample: '{{ payload.labels.foo }}',
|
||||
},
|
||||
{
|
||||
listItemName: 'Show field value or “N/A” is not exist',
|
||||
codeExample: '{{ payload.labels.foo | default(“N/A”) }}',
|
||||
},
|
||||
{
|
||||
listItemName: 'Conditions',
|
||||
codeExample: '{%- if "status" in payload %} \n {{ payload.status }} \n {% endif -%}',
|
||||
},
|
||||
{ listItemName: 'Booleans', codeExample: '{{ payload.status == “resolved” }}' },
|
||||
{ listItemName: 'Loops', codeExample: '{% for label in labels %} \n {{ label.title }} \n {% endfor %}' },
|
||||
{ listItemName: 'Dump a structure to JSON string', codeExample: '{{ payload.labels | tojson }}' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Additional jinja2 variables',
|
||||
listItems: [{ listItemName: 'payload - payload of the first alert in the group' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -189,8 +189,8 @@ class GForm extends React.Component<GFormProps, {}> {
|
|||
<Form maxWidth="none" id={form.name} defaultValues={data} onSubmit={this.handleSubmit}>
|
||||
{({ register, errors, control, getValues, setValue }) => {
|
||||
const renderField = (formItem: FormItem, formIndex: number) => {
|
||||
if (formItem.isVisible && !formItem.isVisible(getValues())) {
|
||||
return null;
|
||||
if (this.isFormItemHidden(formItem, getValues())) {
|
||||
return null; // don't render the field
|
||||
}
|
||||
|
||||
const disabled = formItem.disabled
|
||||
|
|
@ -270,13 +270,21 @@ class GForm extends React.Component<GFormProps, {}> {
|
|||
this.forceUpdate();
|
||||
};
|
||||
|
||||
isFormItemHidden(formItem: FormItem, data) {
|
||||
return formItem?.isHidden?.(data);
|
||||
}
|
||||
|
||||
handleSubmit = (data) => {
|
||||
const { form, onSubmit } = this.props;
|
||||
|
||||
const normalizedData = Object.keys(data).reduce((acc, key) => {
|
||||
const formItem = form.fields.find((formItem) => formItem.name === key);
|
||||
|
||||
const value = formItem?.normalize ? formItem.normalize(data[key]) : nullNormalizer(data[key]);
|
||||
let value = formItem?.normalize ? formItem.normalize(data[key]) : nullNormalizer(data[key]);
|
||||
|
||||
if (this.isFormItemHidden(formItem, data)) {
|
||||
value = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...acc,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export interface FormItem {
|
|||
description?: ReactNode;
|
||||
placeholder?: string;
|
||||
normalize?: (value: any) => any;
|
||||
isVisible?: (data: any) => any;
|
||||
isHidden?: (data: any) => any;
|
||||
getDisabled?: (value: any) => any;
|
||||
validation?: {
|
||||
required?: boolean;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { TEXT_ELLIPSIS_CLASS } from 'utils/consts';
|
|||
const cx = cn.bind(styles);
|
||||
|
||||
interface TextEllipsisTooltipProps {
|
||||
content: string;
|
||||
content?: string;
|
||||
queryClassName?: string;
|
||||
placement?: string;
|
||||
className?: string;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import IntegrationTemplate from 'containers/IntegrationTemplate/IntegrationTempl
|
|||
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
import { LabelsErrors } from 'models/label/label.types';
|
||||
import { ApiSchemas } from 'network/oncall-api/api.types';
|
||||
import { LabelTemplateOptions } from 'pages/integration/IntegrationCommon.config';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { openErrorNotification } from 'utils';
|
||||
import { DOCS_ROOT } from 'utils/consts';
|
||||
|
|
@ -199,8 +200,8 @@ const IntegrationLabelsForm = observer((props: IntegrationLabelsFormProps) => {
|
|||
<IntegrationTemplate
|
||||
id={id}
|
||||
template={{
|
||||
name: 'alert_group_labels',
|
||||
displayName: ``,
|
||||
name: LabelTemplateOptions.AlertGroupDynamicLabel.key,
|
||||
displayName: LabelTemplateOptions.AlertGroupDynamicLabel.value,
|
||||
}}
|
||||
templates={templates}
|
||||
templateBody={alertGroupLabels.custom[customLabelIndexToShowTemplateEditor].value.name}
|
||||
|
|
@ -222,8 +223,8 @@ const IntegrationLabelsForm = observer((props: IntegrationLabelsFormProps) => {
|
|||
<IntegrationTemplate
|
||||
id={id}
|
||||
template={{
|
||||
name: 'alert_group_labels',
|
||||
displayName: ``,
|
||||
name: LabelTemplateOptions.AlertGroupMultiLabel.key,
|
||||
displayName: LabelTemplateOptions.AlertGroupMultiLabel.value,
|
||||
}}
|
||||
templates={templates}
|
||||
templateBody={alertGroupLabels.template}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
groupingTemplateCheatSheet,
|
||||
slackMessageTemplateCheatSheet,
|
||||
genericTemplateCheatSheet,
|
||||
alertGroupDynamicLabelCheatSheet,
|
||||
alertGroupMultiLabelExtractionCheatSheet,
|
||||
} from 'components/CheatSheet/CheatSheet.config';
|
||||
import MonacoEditor from 'components/MonacoEditor/MonacoEditor';
|
||||
import Text from 'components/Text/Text';
|
||||
|
|
@ -22,7 +24,7 @@ import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_
|
|||
import { AlertTemplatesDTO } from 'models/alert_templates/alert_templates';
|
||||
import { Alert } from 'models/alertgroup/alertgroup.types';
|
||||
import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
|
||||
import { BaseTemplateOptions } from 'pages/integration/IntegrationCommon.config';
|
||||
import { IntegrationTemplateOptions, LabelTemplateOptions } from 'pages/integration/IntegrationCommon.config';
|
||||
import { useStore } from 'state/useStore';
|
||||
import LocationHelper from 'utils/LocationHelper';
|
||||
import { UserActions } from 'utils/authorization';
|
||||
|
|
@ -129,26 +131,30 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
|
|||
|
||||
const getCheatSheet = (templateKey: string) => {
|
||||
switch (templateKey) {
|
||||
case BaseTemplateOptions.Grouping.key:
|
||||
case BaseTemplateOptions.Resolve.key:
|
||||
case IntegrationTemplateOptions.Grouping.key:
|
||||
case IntegrationTemplateOptions.Resolve.key:
|
||||
return groupingTemplateCheatSheet;
|
||||
case BaseTemplateOptions.WebTitle.key:
|
||||
case BaseTemplateOptions.WebMessage.key:
|
||||
case BaseTemplateOptions.WebImage.key:
|
||||
case IntegrationTemplateOptions.WebTitle.key:
|
||||
case IntegrationTemplateOptions.WebMessage.key:
|
||||
case IntegrationTemplateOptions.WebImage.key:
|
||||
return genericTemplateCheatSheet;
|
||||
case BaseTemplateOptions.Autoacknowledge.key:
|
||||
case BaseTemplateOptions.SourceLink.key:
|
||||
case BaseTemplateOptions.Phone.key:
|
||||
case BaseTemplateOptions.SMS.key:
|
||||
case BaseTemplateOptions.SlackTitle.key:
|
||||
case BaseTemplateOptions.SlackMessage.key:
|
||||
case BaseTemplateOptions.SlackImage.key:
|
||||
case BaseTemplateOptions.TelegramTitle.key:
|
||||
case BaseTemplateOptions.TelegramMessage.key:
|
||||
case BaseTemplateOptions.TelegramImage.key:
|
||||
case BaseTemplateOptions.EmailTitle.key:
|
||||
case BaseTemplateOptions.EmailMessage.key:
|
||||
case IntegrationTemplateOptions.Autoacknowledge.key:
|
||||
case IntegrationTemplateOptions.SourceLink.key:
|
||||
case IntegrationTemplateOptions.Phone.key:
|
||||
case IntegrationTemplateOptions.SMS.key:
|
||||
case IntegrationTemplateOptions.SlackTitle.key:
|
||||
case IntegrationTemplateOptions.SlackMessage.key:
|
||||
case IntegrationTemplateOptions.SlackImage.key:
|
||||
case IntegrationTemplateOptions.TelegramTitle.key:
|
||||
case IntegrationTemplateOptions.TelegramMessage.key:
|
||||
case IntegrationTemplateOptions.TelegramImage.key:
|
||||
case IntegrationTemplateOptions.EmailTitle.key:
|
||||
case IntegrationTemplateOptions.EmailMessage.key:
|
||||
return slackMessageTemplateCheatSheet;
|
||||
case LabelTemplateOptions.AlertGroupDynamicLabel.key:
|
||||
return alertGroupDynamicLabelCheatSheet;
|
||||
case LabelTemplateOptions.AlertGroupMultiLabel.key:
|
||||
return alertGroupMultiLabelExtractionCheatSheet;
|
||||
default:
|
||||
return genericTemplateCheatSheet;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ export function createForm(
|
|||
},
|
||||
],
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.TriggerType),
|
||||
isHidden: (data) => !isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.TriggerType),
|
||||
normalize: (value) => value,
|
||||
},
|
||||
{
|
||||
|
|
@ -136,16 +136,16 @@ export function createForm(
|
|||
},
|
||||
],
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.HttpMethod),
|
||||
isHidden: (data) => !isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.HttpMethod),
|
||||
normalize: (value) => value,
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.IntegrationFilter,
|
||||
label: 'Integrations',
|
||||
type: FormItemType.MultiSelect,
|
||||
isVisible: (data) =>
|
||||
isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.IntegrationFilter) &&
|
||||
data.trigger_type !== WebhookTriggerType.EscalationStep.key,
|
||||
isHidden: (data) =>
|
||||
!isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.IntegrationFilter) ||
|
||||
data.trigger_type === WebhookTriggerType.EscalationStep.key,
|
||||
extra: {
|
||||
placeholder: 'Choose (Optional)',
|
||||
modelName: 'alertReceiveChannelStore',
|
||||
|
|
@ -170,7 +170,7 @@ export function createForm(
|
|||
extra: {
|
||||
height: 30,
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Url),
|
||||
isHidden: (data) => !isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Url),
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.Headers,
|
||||
|
|
@ -180,24 +180,24 @@ export function createForm(
|
|||
extra: {
|
||||
rows: 3,
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Headers),
|
||||
isHidden: (data) => !isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Headers),
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.Username,
|
||||
type: FormItemType.Input,
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Username),
|
||||
isHidden: (data) => !isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Username),
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.Password,
|
||||
type: FormItemType.Password,
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Password),
|
||||
isHidden: (data) => !isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Password),
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.AuthorizationHeader,
|
||||
description:
|
||||
'Value of the Authorization header, do not need to prefix with "Authorization:". For example: Bearer AbCdEf123456',
|
||||
type: FormItemType.Password,
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.AuthorizationHeader),
|
||||
isHidden: (data) => !isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.AuthorizationHeader),
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.TriggerTemplate,
|
||||
|
|
@ -207,14 +207,14 @@ export function createForm(
|
|||
extra: {
|
||||
rows: 2,
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.TriggerTemplate),
|
||||
isHidden: (data) => !isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.TriggerTemplate),
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.ForwardAll,
|
||||
normalize: (value) => (value ? Boolean(value) : value),
|
||||
type: FormItemType.Switch,
|
||||
description: "Forwards whole payload of the alert group and context data to the webhook's url as POST/PUT data",
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.ForwardAll),
|
||||
isHidden: (data) => !isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.ForwardAll),
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.Data,
|
||||
|
|
@ -224,7 +224,7 @@ export function createForm(
|
|||
hasLabelsFeature ? ' {{ webhook }}' : ''
|
||||
}`,
|
||||
extra: {},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Data),
|
||||
isHidden: (data) => !isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Data),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
|
||||
.tabs__content {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
TabsBar,
|
||||
VerticalGroup,
|
||||
} from '@grafana/ui';
|
||||
import { capitalCase } from 'change-case';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
|
@ -120,7 +121,12 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
|
|||
const getTemplateEditClickHandler = (formItem: FormItem, values, setFormFieldValue) => {
|
||||
return () => {
|
||||
const formValue = values[formItem.name];
|
||||
setTemplateToEdit({ value: formValue, displayName: undefined, description: undefined, name: formItem.name });
|
||||
setTemplateToEdit({
|
||||
value: formValue,
|
||||
displayName: `Webhook ${capitalCase(formItem.name)}`,
|
||||
description: undefined,
|
||||
name: formItem.name,
|
||||
});
|
||||
setOnFormChangeFn({ fn: (value) => setFormFieldValue(value) });
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,3 +26,7 @@
|
|||
.display-linebreak {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.extra-check {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { HorizontalGroup, Icon, LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
|
||||
import { Badge, HorizontalGroup, Icon, LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
|
|
@ -8,6 +8,7 @@ import Text from 'components/Text/Text';
|
|||
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
import { Alert } from 'models/alertgroup/alertgroup.types';
|
||||
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
|
||||
import { LabelTemplateOptions } from 'pages/integration/IntegrationCommon.config';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { openErrorNotification } from 'utils';
|
||||
import { useDebouncedCallback } from 'utils/hooks';
|
||||
|
|
@ -51,7 +52,9 @@ const TemplatePreview = observer((props: TemplatePreviewProps) => {
|
|||
templatePage,
|
||||
} = props;
|
||||
|
||||
const [result, setResult] = useState<{ preview: string | null } | undefined>(undefined);
|
||||
const [result, setResult] = useState<{ preview: string | null; is_valid_json_object?: boolean } | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [conditionalResult, setConditionalResult] = useState<ConditionalResult>({});
|
||||
|
||||
const store = useStore();
|
||||
|
|
@ -102,6 +105,29 @@ const TemplatePreview = observer((props: TemplatePreviewProps) => {
|
|||
}
|
||||
};
|
||||
|
||||
function renderExtraChecks() {
|
||||
function getExtraCheckResult() {
|
||||
switch (templateName) {
|
||||
case LabelTemplateOptions.AlertGroupMultiLabel.key:
|
||||
return result.is_valid_json_object ? (
|
||||
<Badge color="green" icon="check" text="Output is a valid labels dictionary" />
|
||||
) : (
|
||||
<Badge
|
||||
color="red"
|
||||
icon="times"
|
||||
text="Output is not a labels dictionary. Template should produce valid JSON object. Consider using tojson filter."
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const checkResult = getExtraCheckResult();
|
||||
|
||||
return checkResult ? <div className={cx('extra-check')}>{checkResult}</div> : null;
|
||||
}
|
||||
|
||||
function renderResult() {
|
||||
switch (templateType) {
|
||||
case 'html': {
|
||||
|
|
@ -182,7 +208,14 @@ const TemplatePreview = observer((props: TemplatePreviewProps) => {
|
|||
);
|
||||
}
|
||||
|
||||
return result ? <>{renderResult()}</> : <LoadingPlaceholder text="Loading..." />;
|
||||
return result ? (
|
||||
<>
|
||||
{renderExtraChecks()}
|
||||
{renderResult()}
|
||||
</>
|
||||
) : (
|
||||
<LoadingPlaceholder text="Loading..." />
|
||||
);
|
||||
});
|
||||
|
||||
export default TemplatePreview;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import cn from 'classnames/bind';
|
|||
import { debounce } from 'lodash-es';
|
||||
|
||||
import CheatSheet from 'components/CheatSheet/CheatSheet';
|
||||
import { genericTemplateCheatSheet } from 'components/CheatSheet/CheatSheet.config';
|
||||
import { genericTemplateCheatSheet, webhookPayloadCheatSheet } from 'components/CheatSheet/CheatSheet.config';
|
||||
import MonacoEditor from 'components/MonacoEditor/MonacoEditor';
|
||||
import Text from 'components/Text/Text';
|
||||
import styles from 'containers/IntegrationTemplate/IntegrationTemplate.module.scss';
|
||||
|
|
@ -70,6 +70,15 @@ const WebhooksTemplateEditor: React.FC<WebhooksTemplateEditorProps> = ({ templat
|
|||
setIsCheatSheetVisible(false);
|
||||
}, []);
|
||||
|
||||
const getCheatSheet = (templateKey: string) => {
|
||||
switch (templateKey) {
|
||||
case 'data':
|
||||
return webhookPayloadCheatSheet;
|
||||
default:
|
||||
return genericTemplateCheatSheet;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
|
|
@ -118,8 +127,8 @@ const WebhooksTemplateEditor: React.FC<WebhooksTemplateEditorProps> = ({ templat
|
|||
|
||||
{isCheatSheetVisible ? (
|
||||
<CheatSheet
|
||||
cheatSheetName="Generic"
|
||||
cheatSheetData={genericTemplateCheatSheet}
|
||||
cheatSheetName={template.displayName}
|
||||
cheatSheetData={getCheatSheet(template.name)}
|
||||
onClose={onCloseCheatSheet}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
import { AppFeature } from 'state/features';
|
||||
import { KeyValuePair } from 'utils';
|
||||
|
||||
import { BASE_INTEGRATION_TEMPLATES_LIST, BaseTemplateOptions } from './IntegrationCommon.config';
|
||||
|
||||
export const MsTeamsTemplateOptions = {
|
||||
MSTeams: new KeyValuePair('Microsoft Teams', 'Microsoft Teams'),
|
||||
MSTeamsTitle: new KeyValuePair('MSTeams Title', 'Title'),
|
||||
MSTeamsMessage: new KeyValuePair('MSTeams Message', 'Message'),
|
||||
MSTeamsImage: new KeyValuePair('MSTeams Image', 'Image'),
|
||||
};
|
||||
|
||||
export const getTemplateOptions = (features: Record<string, boolean>) => {
|
||||
if (features[AppFeature.MsTeams]) {
|
||||
return {
|
||||
...BaseTemplateOptions,
|
||||
...MsTeamsTemplateOptions,
|
||||
};
|
||||
}
|
||||
return BaseTemplateOptions;
|
||||
};
|
||||
|
||||
export const getIntegrationTemplatesList = (features: Record<string, boolean>) => {
|
||||
if (features[AppFeature.MsTeams]) {
|
||||
return [
|
||||
...BASE_INTEGRATION_TEMPLATES_LIST,
|
||||
|
||||
{
|
||||
label: MsTeamsTemplateOptions.MSTeams.value,
|
||||
value: MsTeamsTemplateOptions.MSTeams.key,
|
||||
children: [
|
||||
{
|
||||
label: MsTeamsTemplateOptions.MSTeamsTitle.value,
|
||||
value: MsTeamsTemplateOptions.MSTeamsTitle.key,
|
||||
},
|
||||
{
|
||||
label: MsTeamsTemplateOptions.MSTeamsMessage.value,
|
||||
value: MsTeamsTemplateOptions.MSTeamsMessage.key,
|
||||
},
|
||||
{
|
||||
label: MsTeamsTemplateOptions.MSTeamsImage.value,
|
||||
value: MsTeamsTemplateOptions.MSTeamsImage.key,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return BASE_INTEGRATION_TEMPLATES_LIST;
|
||||
};
|
||||
|
|
@ -6,7 +6,7 @@ export const MAX_CHARACTERS_COUNT = 50;
|
|||
export const MONACO_INPUT_HEIGHT_SMALL = '32px';
|
||||
export const MONACO_INPUT_HEIGHT_TALL = '120px';
|
||||
|
||||
export const BaseTemplateOptions = {
|
||||
export const IntegrationTemplateOptions = {
|
||||
WebTitle: new KeyValuePair('web_title_template', 'Web Title'),
|
||||
WebMessage: new KeyValuePair('web_message_template', 'Web Message'),
|
||||
WebImage: new KeyValuePair('web_image_url_template', 'Web Image'),
|
||||
|
|
@ -33,71 +33,7 @@ export const BaseTemplateOptions = {
|
|||
Telegram: new KeyValuePair('Telegram', 'Telegram'),
|
||||
};
|
||||
|
||||
export const BASE_INTEGRATION_TEMPLATES_LIST = [
|
||||
{
|
||||
label: BaseTemplateOptions.SourceLink.value,
|
||||
value: BaseTemplateOptions.SourceLink.key,
|
||||
},
|
||||
{
|
||||
label: BaseTemplateOptions.Autoacknowledge.value,
|
||||
value: BaseTemplateOptions.Autoacknowledge.key,
|
||||
},
|
||||
{
|
||||
label: BaseTemplateOptions.Phone.value,
|
||||
value: BaseTemplateOptions.Phone.key,
|
||||
},
|
||||
{
|
||||
label: BaseTemplateOptions.SMS.value,
|
||||
value: BaseTemplateOptions.SMS.key,
|
||||
},
|
||||
{
|
||||
label: BaseTemplateOptions.Email.value,
|
||||
value: BaseTemplateOptions.Email.key,
|
||||
children: [
|
||||
{
|
||||
label: BaseTemplateOptions.EmailTitle.value,
|
||||
value: BaseTemplateOptions.EmailTitle.key,
|
||||
},
|
||||
{
|
||||
label: BaseTemplateOptions.EmailMessage.value,
|
||||
value: BaseTemplateOptions.EmailMessage.key,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: BaseTemplateOptions.Slack.value,
|
||||
value: BaseTemplateOptions.Slack.key,
|
||||
children: [
|
||||
{
|
||||
label: BaseTemplateOptions.SlackTitle.value,
|
||||
value: BaseTemplateOptions.SlackTitle.key,
|
||||
},
|
||||
{
|
||||
label: BaseTemplateOptions.SlackMessage.value,
|
||||
value: BaseTemplateOptions.SlackMessage.key,
|
||||
},
|
||||
{
|
||||
label: BaseTemplateOptions.SlackImage.value,
|
||||
value: BaseTemplateOptions.SlackImage.key,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: BaseTemplateOptions.Telegram.value,
|
||||
value: BaseTemplateOptions.Telegram.key,
|
||||
children: [
|
||||
{
|
||||
label: BaseTemplateOptions.TelegramTitle.value,
|
||||
value: BaseTemplateOptions.TelegramTitle.key,
|
||||
},
|
||||
{
|
||||
label: BaseTemplateOptions.TelegramMessage.value,
|
||||
value: BaseTemplateOptions.TelegramMessage.key,
|
||||
},
|
||||
{
|
||||
label: BaseTemplateOptions.TelegramImage.value,
|
||||
value: BaseTemplateOptions.TelegramImage.key,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
export const LabelTemplateOptions = {
|
||||
AlertGroupDynamicLabel: new KeyValuePair('alert_group_dynamic_label', 'Alert Group Dynamic Label'),
|
||||
AlertGroupMultiLabel: new KeyValuePair('alert_group_multi_label', 'Alert Group Multi Label'),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -269,11 +269,7 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
};
|
||||
|
||||
renderTeam(record: OutgoingWebhook, teams: any) {
|
||||
return (
|
||||
<TextEllipsisTooltip placement="top" content={teams[record.team]?.name}>
|
||||
<TeamName className={TEXT_ELLIPSIS_CLASS} team={teams[record.team]} />
|
||||
</TextEllipsisTooltip>
|
||||
);
|
||||
return <TeamName className={TEXT_ELLIPSIS_CLASS} team={teams[record.team]} />;
|
||||
}
|
||||
|
||||
renderActionButtons = (record: OutgoingWebhook) => {
|
||||
|
|
@ -435,6 +431,9 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
is_legacy: false,
|
||||
};
|
||||
|
||||
// don't pass trigger_type to backend as it's not editable
|
||||
delete data.trigger_type;
|
||||
|
||||
outgoingWebhookStore
|
||||
.update(id, data)
|
||||
.then(() => this.update())
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue