Merge pull request #2702 from grafana/dev

Dev to main
This commit is contained in:
Innokentii Konstantinov 2023-08-01 14:55:46 +08:00 committed by GitHub
commit df75bdd45c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
76 changed files with 2598 additions and 2042 deletions

View file

@ -5,11 +5,19 @@ 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).
## v1.3.21 (2023-08-01)
### Added
- [Helm] Add `extraContainers` for engine, celery and migrate-job pods to define sidecars by @lu1as ([#2650](https://github.com/grafana/oncall/pull/2650))
Rework of AlertManager integration ([#2643](https://github.com/grafana/oncall/pull/2643))
## v1.3.20 (2023-07-31)
### Added
- Add filter_shift_swaps endpoint to schedules API ([#2684](https://github.com/grafana/oncall/pull/2684))
- Add shifts endpoint to shift swap API ([#2697](https://github.com/grafana/oncall/pull/2697/))
### Fixed

View file

@ -12,7 +12,10 @@ weight: 300
# Get started with Grafana OnCall
Grafana OnCall was built to help DevOps and SRE teams improve their on-call management process and resolve incidents faster. With OnCall, users can create and manage on-call schedules, automate escalations, and monitor incident response from a central view, right within the Grafana UI. Teams no longer have to manage separate alerts from Grafana, Prometheus, and Alertmanager, lowering the risk of missing an important update and limiting the time spent receiving and responding to notifications.
Grafana OnCall was built to help DevOps and SRE teams improve their on-call management process and resolve incidents faster. With OnCall,
users can create and manage on-call schedules, automate escalations, and monitor incident response from a central view, right within
the Grafana UI. Teams no longer have to manage separate alerts from Grafana, Prometheus, and Alertmanager, lowering the risk of
missing an important update and limiting the time spent receiving and responding to notifications.
With a centralized view of all your alerts and alert groups, automated escalations and grouping, and on-call scheduling, Grafana
OnCall helps ensure that alert notifications reach the right people, at the right time using the right notification method.

View file

@ -15,7 +15,13 @@ weight: 300
# Alertmanager integration for Grafana OnCall
> You must have the [role of Admin][user-and-team-management] to be able to create integrations in Grafana OnCall.
> ⚠️ A note about **(Legacy)** integrations:
> We are changing internal behaviour of AlertManager integration.
> Integrations that were created before version 1.3.21 are marked as **(Legacy)**.
> These integrations are still receiving and escalating alerts but will be automatically migrated after 1 November 2023.
> <br/><br/>
> To ensure a smooth transition you can migrate legacy integrations by yourself now.
> [Here][migration] you can read more about changes and migration process.
The Alertmanager integration handles alerts from [Prometheus Alertmanager](https://prometheus.io/docs/alerting/latest/alertmanager/).
This integration is the recommended way to send alerts from Prometheus deployed in your infrastructure, to Grafana OnCall.
@ -28,17 +34,16 @@ This integration is the recommended way to send alerts from Prometheus deployed
2. Select **Alertmanager Prometheus** from the list of available integrations.
3. Enter a name and description for the integration, click **Create**
4. A new page will open with the integration details. Copy the **OnCall Integration URL** from **HTTP Endpoint** section.
You will need it when configuring Alertmanager.
<!--![123](../_images/connect-new-monitoring.png)-->
You will need it when configuring Alertmanager.
## Configuring Alertmanager to Send Alerts to Grafana OnCall
1. Add a new [Webhook](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config) receiver to `receivers`
section of your Alertmanager configuration
section of your Alertmanager configuration
2. Set `url` to the **OnCall Integration URL** from previous section
- **Note:** The url has a trailing slash that is required for it to work properly.
3. Set `send_resolved` to `true`, so Grafana OnCall can autoresolve alert groups when they are resolved in Alertmanager
4. It is recommended to set `max_alerts` to less than `300` to avoid rate-limiting issues
4. It is recommended to set `max_alerts` to less than `100` to avoid requests that are too large.
5. Use this receiver in your route configuration
Here is the example of final configuration:
@ -53,7 +58,7 @@ receivers:
webhook_configs:
- url: <integation-url>
send_resolved: true
max_alerts: 300
max_alerts: 100
```
## Complete the Integration Configuration
@ -71,7 +76,7 @@ Grafana OnCall will notify you about that.
1. Go to **Integration Page**, click on three dots on top right, click **Heartbeat settings**
2. Copy **OnCall Heartbeat URL**, you will need it when configuring Alertmanager
3. Set up **Heartbeat Interval**, time period after which Grafana OnCall will start a new alert group if it
doesn't receive a heartbeat request
doesn't receive a heartbeat request
### Configuring Alertmanager to send heartbeats to Grafana OnCall Heartbeat
@ -80,43 +85,99 @@ generator to `prometheus.yaml`. It will always return true and act like always f
Grafana OnCall once in a given period of time:
```yaml
groups:
- name: meta
rules:
- alert: heartbeat
expr: vector(1)
labels:
severity: none
annotations:
description: This is a heartbeat alert for Grafana OnCall
summary: Heartbeat for Grafana OnCall
groups:
- name: meta
rules:
- alert: heartbeat
expr: vector(1)
labels:
severity: none
annotations:
description: This is a heartbeat alert for Grafana OnCall
summary: Heartbeat for Grafana OnCall
```
Add receiver configuration to `prometheus.yaml` with the **OnCall Heartbeat URL**:
```yaml
...
route:
...
routes:
- match:
alertname: heartbeat
receiver: 'grafana-oncall-heartbeat'
group_wait: 0s
group_interval: 1m
repeat_interval: 50s
receivers:
- name: 'grafana-oncall-heartbeat'
webhook_configs:
- url: https://oncall-dev-us-central-0.grafana.net/oncall/integrations/v1/alertmanager/1234567890/heartbeat/
send_resolved: false
...
route:
...
routes:
- match:
alertname: heartbeat
receiver: 'grafana-oncall-heartbeat'
group_wait: 0s
group_interval: 1m
repeat_interval: 50s
receivers:
- name: 'grafana-oncall-heartbeat'
webhook_configs:
- url: https://oncall-dev-us-central-0.grafana.net/oncall/integrations/v1/alertmanager/1234567890/heartbeat/
send_resolved: false
```
## Migrating from Legacy Integration
Before we were using each alert from AlertManager group as a separate payload:
```json
{
"labels": {
"severity": "critical",
"alertname": "InstanceDown"
},
"annotations": {
"title": "Instance localhost:8081 down",
"description": "Node has been down for more than 1 minute"
},
...
}
```
This behaviour was leading to mismatch in alert state between OnCall and AlertManager and draining of rate-limits,
since each AlertManager alert was counted separately.
We decided to change this behaviour to respect AlertManager grouping by using AlertManager group as one payload.
```json
{
"alerts": [...],
"groupLabels": {
"alertname": "InstanceDown"
},
"commonLabels": {
"job": "node",
"alertname": "InstanceDown"
},
"commonAnnotations": {
"description": "Node has been down for more than 1 minute"
},
"groupKey": "{}:{alertname=\"InstanceDown\"}",
...
}
```
You can read more about AlertManager Data model [here](https://prometheus.io/docs/alerting/latest/notifications/#data).
### How to migrate
> Integration URL will stay the same, so no need to change AlertManager or Grafana Alerting configuration.
> Integration templates will be reset to suit new payload.
> It is needed to adjust routes manually to new payload.
1. Go to **Integration Page**, click on three dots on top right, click **Migrate**
2. Confirmation Modal will be shown, read it carefully and proceed with migration.
3. Send demo alert to make sure everything went well.
4. Adjust routes to the new shape of payload. You can use payload of the demo alert from previous step as an example.
{{% docs/reference %}}
[user-and-team-management]: "/docs/oncall/ -> /docs/oncall/<ONCALL VERSION>/user-and-team-management"
[user-and-team-management]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/oncall/user-and-team-management"
[complete-the-integration-configuration]: "/docs/oncall/ -> /docs/oncall/<ONCALL VERSION>/integrations#complete-the-integration-configuration"
[complete-the-integration-configuration]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/oncall/integrations#complete-the-integration-configuration"
[migration]: "/docs/oncall/ -> /docs/oncall/<ONCALL VERSION>/integrations/alertmanager#migrating-from-legacy-integration"
[migration]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/oncall/integrations/alertmanager#migrating-from-legacy-integration"
{{% /docs/reference %}}

View file

@ -14,6 +14,14 @@ weight: 100
# Grafana Alerting integration for Grafana OnCall
> ⚠️ A note about **(Legacy)** integrations:
> We are changing internal behaviour of Grafana Alerting integration.
> Integrations that were created before version 1.3.21 are marked as **(Legacy)**.
> These integrations are still receiving and escalating alerts but will be automatically migrated after 1 November 2023.
> <br/><br/>
> To ensure a smooth transition you can migrate them by yourself now.
> [Here][migration] you can read more about changes and migration process.
Grafana Alerting for Grafana OnCall can be set up using two methods:
- Grafana Alerting: Grafana OnCall is connected to the same Grafana instance being used to manage Grafana OnCall.
@ -53,11 +61,9 @@ Connect Grafana OnCall with alerts coming from a Grafana instance that is differ
OnCall is being managed:
1. In Grafana OnCall, navigate to the **Integrations** tab and select **New Integration to receive alerts**.
2. Select the **Grafana (Other Grafana)** tile.
3. Follow the configuration steps that display in the **How to connect** window to retrieve your unique integration URL
and complete any necessary configurations.
4. Determine the escalation chain for the new integration by either selecting an existing one or by creating a
new escalation chain.
2. Select the **Alertmanager** tile.
3. Enter a name and description for the integration, click Create
4. A new page will open with the integration details. Copy the OnCall Integration URL from HTTP Endpoint section.
5. Go to the other Grafana instance to connect to Grafana OnCall and navigate to **Alerting > Contact Points**.
6. Select **New Contact Point**.
7. Choose the contact point type `webhook`, then paste the URL generated in step 3 into the URL field.
@ -66,3 +72,61 @@ OnCall is being managed:
> see [Contact points in Grafana Alerting](https://grafana.com/docs/grafana/latest/alerting/unified-alerting/contact-points/).
8. Click the **Edit** (pencil) icon, then click **Test**. This will send a test alert to Grafana OnCall.
## Migrating from Legacy Integration
Before we were using each alert from Grafana Alerting group as a separate payload:
```json
{
"labels": {
"severity": "critical",
"alertname": "InstanceDown"
},
"annotations": {
"title": "Instance localhost:8081 down",
"description": "Node has been down for more than 1 minute"
},
...
}
```
This behaviour was leading to mismatch in alert state between OnCall and Grafana Alerting and draining of rate-limits,
since each Grafana Alerting alert was counted separately.
We decided to change this behaviour to respect Grafana Alerting grouping by using AlertManager group as one payload.
```json
{
"alerts": [...],
"groupLabels": {
"alertname": "InstanceDown"
},
"commonLabels": {
"job": "node",
"alertname": "InstanceDown"
},
"commonAnnotations": {
"description": "Node has been down for more than 1 minute"
},
"groupKey": "{}:{alertname=\"InstanceDown\"}",
...
}
```
You can read more about AlertManager Data model [here](https://prometheus.io/docs/alerting/latest/notifications/#data).
### How to migrate
> Integration URL will stay the same, so no need to make changes on Grafana Alerting side.
> Integration templates will be reset to suit new payload.
> It is needed to adjust routes manually to new payload.
1. Go to **Integration Page**, click on three dots on top right, click **Migrate**
2. Confirmation Modal will be shown, read it carefully and proceed with migration.
3. Adjust routes to the new shape of payload.
{{% docs/reference %}}
[migration]: "/docs/oncall/ -> /docs/oncall/<ONCALL VERSION>/integrations/grafana-alerting#migrating-from-legacy-integration"
[migration]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/oncall/integrations/grafana-alerting#migrating-from-legacy-integration"
{{% /docs/reference %}}

View file

@ -67,7 +67,8 @@ Within Zabbix web interface, do the following:
1. In a browser, open localhost:80.
2. Navigate to **Adminitstration > Media Types > Create Media Type**.
<!--![](../_images/zabbix-1.png)-->
<!--![](../_images/zabbix-1.png)-->
3. Create a Media Type with the following fields.
@ -87,13 +88,16 @@ To send alerts to Grafana OnCall, the {ALERT.SEND_TO} value must be set in the [
1. In the web UI, navigate to **Administration > Users** and open the **user properties** form.
2. In the **Media** tab, click **Add** and copy the link from Grafana OnCall in the `Send to` field.
<!--![](../_images/zabbix-7.png)-->
<!--![](../_images/zabbix-7.png)-->
3. Click **Test** in the last column to send a test alert to Grafana OnCall.
<!--![](../_images/zabbix-3.png)-->
<!--![](../_images/zabbix-3.png)-->
4. Specify **Send to** OnCall using the unique integration URL from the above step in the testing window that opens.
Create a test message with a body and optional subject and click **Test**.
<!--![](../_images/zabbix-4.png)
WHERE DID SLACK COME FROM?! 1. View the Grafana OnCall incident that appears in the Slack channel.

View file

@ -25,6 +25,8 @@ class IntegrationOptionsMixin:
for integration_config in _config:
vars()[f"INTEGRATION_{integration_config.slug.upper()}"] = integration_config.slug
INTEGRATION_TYPES = {integration_config.slug for integration_config in _config}
INTEGRATION_CHOICES = tuple(
(
(
@ -39,7 +41,6 @@ class IntegrationOptionsMixin:
WEB_INTEGRATION_CHOICES = [
integration_config.slug for integration_config in _config if integration_config.is_displayed_on_web
]
PUBLIC_API_INTEGRATION_MAP = {integration_config.slug: integration_config.slug for integration_config in _config}
INTEGRATION_SHORT_DESCRIPTION = {
integration_config.slug: integration_config.short_description for integration_config in _config
}

View file

@ -0,0 +1,37 @@
# Generated by Django 3.2.19 on 2023-07-31 03:41
from django.db import migrations
integration_alertmanager = "alertmanager"
integration_grafana_alerting = "grafana_alerting"
legacy_alertmanager = "legacy_alertmanager"
legacy_grafana_alerting = "legacy_grafana_alerting"
def make_integrations_legacy(apps, schema_editor):
AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel")
AlertReceiveChannel.objects.filter(integration=integration_alertmanager).update(integration=legacy_alertmanager)
AlertReceiveChannel.objects.filter(integration=integration_grafana_alerting).update(integration=legacy_grafana_alerting)
def revert_make_integrations_legacy(apps, schema_editor):
AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel")
AlertReceiveChannel.objects.filter(integration=legacy_alertmanager).update(integration=integration_alertmanager)
AlertReceiveChannel.objects.filter(integration=legacy_grafana_alerting).update(integration=integration_grafana_alerting)
class Migration(migrations.Migration):
dependencies = [
('alerts', '0029_auto_20230728_0802'),
]
operations = [
migrations.RunPython(make_integrations_legacy, revert_make_integrations_legacy),
]

View file

@ -18,9 +18,10 @@ from emoji import emojize
from apps.alerts.grafana_alerting_sync_manager.grafana_alerting_sync import GrafanaAlertingSyncManager
from apps.alerts.integration_options_mixin import IntegrationOptionsMixin
from apps.alerts.models.maintainable_object import MaintainableObject
from apps.alerts.tasks import disable_maintenance, sync_grafana_alerting_contact_points
from apps.alerts.tasks import disable_maintenance
from apps.base.messaging import get_messaging_backend_from_id
from apps.base.utils import live_settings
from apps.integrations.legacy_prefix import remove_legacy_prefix
from apps.integrations.metadata import heartbeat
from apps.integrations.tasks import create_alert, create_alertmanager_alerts
from apps.metrics_exporter.helpers import (
@ -339,7 +340,8 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
@property
def description(self):
if self.integration == AlertReceiveChannel.INTEGRATION_GRAFANA_ALERTING:
# TODO: AMV2: Remove this check after legacy integrations are migrated.
if self.integration == AlertReceiveChannel.INTEGRATION_LEGACY_GRAFANA_ALERTING:
contact_points = self.contact_points.all()
rendered_description = jinja_template_env.from_string(self.config.description).render(
is_finished_alerting_setup=self.is_finished_alerting_setup,
@ -421,7 +423,8 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
AlertReceiveChannel.INTEGRATION_MAINTENANCE,
]:
return None
return create_engine_url(f"integrations/v1/{self.config.slug}/{self.token}/")
slug = remove_legacy_prefix(self.config.slug)
return create_engine_url(f"integrations/v1/{slug}/{self.token}/")
@property
def inbound_email(self):
@ -552,7 +555,12 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
if payload is None:
payload = self.config.example_payload
if self.has_alertmanager_payload_structure:
# hack to keep demo alert working for integration with legacy alertmanager behaviour.
if self.integration in {
AlertReceiveChannel.INTEGRATION_LEGACY_GRAFANA_ALERTING,
AlertReceiveChannel.INTEGRATION_LEGACY_ALERTMANAGER,
AlertReceiveChannel.INTEGRATION_GRAFANA,
}:
alerts = payload.get("alerts", None)
if not isinstance(alerts, list) or not len(alerts):
raise UnableToSendDemoAlert(
@ -573,12 +581,8 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
)
@property
def has_alertmanager_payload_structure(self):
return self.integration in (
AlertReceiveChannel.INTEGRATION_ALERTMANAGER,
AlertReceiveChannel.INTEGRATION_GRAFANA,
AlertReceiveChannel.INTEGRATION_GRAFANA_ALERTING,
)
def based_on_alertmanager(self):
return getattr(self.config, "based_on_alertmanager", False)
# Insight logs
@property
@ -652,14 +656,3 @@ def listen_for_alertreceivechannel_model_save(
metrics_remove_deleted_integration_from_cache(instance)
else:
metrics_update_integration_cache(instance)
if instance.integration == AlertReceiveChannel.INTEGRATION_GRAFANA_ALERTING:
if created:
instance.grafana_alerting_sync_manager.create_contact_points()
# do not trigger sync contact points if field "is_finished_alerting_setup" was updated
elif (
kwargs is None
or not kwargs.get("update_fields")
or "is_finished_alerting_setup" not in kwargs["update_fields"]
):
sync_grafana_alerting_contact_points.apply_async((instance.pk,), countdown=5)

View file

@ -117,9 +117,9 @@ def test_send_demo_alert(mocked_create_alert, make_organization, make_alert_rece
@pytest.mark.parametrize(
"integration",
[
AlertReceiveChannel.INTEGRATION_ALERTMANAGER,
AlertReceiveChannel.INTEGRATION_LEGACY_ALERTMANAGER,
AlertReceiveChannel.INTEGRATION_GRAFANA,
AlertReceiveChannel.INTEGRATION_GRAFANA_ALERTING,
AlertReceiveChannel.INTEGRATION_LEGACY_GRAFANA_ALERTING,
],
)
@pytest.mark.parametrize(

View file

@ -12,6 +12,7 @@ from apps.alerts.grafana_alerting_sync_manager.grafana_alerting_sync import Graf
from apps.alerts.models import AlertReceiveChannel
from apps.alerts.models.channel_filter import ChannelFilter
from apps.base.messaging import get_messaging_backends
from apps.integrations.legacy_prefix import has_legacy_prefix
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.mixins import APPEARANCE_TEMPLATE_NAMES, EagerLoadingMixin
@ -52,6 +53,7 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializ
routes_count = serializers.SerializerMethodField()
connected_escalations_chains_count = serializers.SerializerMethodField()
inbound_email = serializers.CharField(required=False)
is_legacy = serializers.SerializerMethodField()
# integration heartbeat is in PREFETCH_RELATED not by mistake.
# With using of select_related ORM builds strange join
@ -90,6 +92,7 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializ
"connected_escalations_chains_count",
"is_based_on_alertmanager",
"inbound_email",
"is_legacy",
]
read_only_fields = [
"created_at",
@ -105,12 +108,15 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializ
"connected_escalations_chains_count",
"is_based_on_alertmanager",
"inbound_email",
"is_legacy",
]
extra_kwargs = {"integration": {"required": True}}
def create(self, validated_data):
organization = self.context["request"].auth.organization
integration = validated_data.get("integration")
if has_legacy_prefix(integration):
raise BadRequest(detail="This integration is deprecated")
if integration == AlertReceiveChannel.INTEGRATION_GRAFANA_ALERTING:
connection_error = GrafanaAlertingSyncManager.check_for_connection_errors(organization)
if connection_error:
@ -185,6 +191,9 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializ
def get_routes_count(self, obj) -> int:
return obj.channel_filters.count()
def get_is_legacy(self, obj) -> bool:
return has_legacy_prefix(obj.integration)
def get_connected_escalations_chains_count(self, obj) -> int:
return (
ChannelFilter.objects.filter(alert_receive_channel=obj, escalation_chain__isnull=False)
@ -262,7 +271,7 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode
return None
def get_is_based_on_alertmanager(self, obj):
return obj.has_alertmanager_payload_structure
return obj.based_on_alertmanager
# Override method to pass field_name directly in set_value to handle None values for WritableSerializerField
def to_internal_value(self, data):

View file

@ -10,7 +10,7 @@ from rest_framework.response import Response
from rest_framework.test import APIClient
from apps.api.permissions import LegacyAccessControlRole
from apps.schedules.models import OnCallScheduleWeb, ShiftSwapRequest
from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb, ShiftSwapRequest
from common.api_helpers.utils import serialize_datetime_as_utc_timestamp
from common.insight_log import EntityEvent
@ -466,6 +466,54 @@ def test_partial_update_time_related_fields(ssr_setup, make_user_auth_headers):
assert response.json() == expected_response
@pytest.mark.skip(reason="Skipping to unblock release")
@pytest.mark.django_db
def test_related_shifts(ssr_setup, make_on_call_shift, make_user_auth_headers):
ssr, beneficiary, token, _ = ssr_setup()
schedule = ssr.schedule
organization = schedule.organization
user = beneficiary
today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
start = today + timezone.timedelta(days=2)
duration = timezone.timedelta(hours=8)
data = {
"start": start,
"rotation_start": start,
"duration": duration,
"priority_level": 1,
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
"schedule": schedule,
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
)
on_call_shift.add_rolling_users([[user]])
client = APIClient()
url = reverse("api-internal:shift_swap-shifts", kwargs={"pk": ssr.public_primary_key})
auth_headers = make_user_auth_headers(beneficiary, token)
response = client.get(url, **auth_headers)
assert response.status_code == status.HTTP_200_OK
response_json = response.json()
expected = [
# start, end, user, swap request ID
(
start.strftime("%Y-%m-%dT%H:%M:%SZ"),
(start + duration).strftime("%Y-%m-%dT%H:%M:%SZ"),
user.public_primary_key,
ssr.public_primary_key,
),
]
returned_events = [
(e["start"], e["end"], e["users"][0]["pk"], e["users"][0]["swap_request"]["pk"])
for e in response_json["events"]
]
assert returned_events == expected
@pytest.mark.django_db
@pytest.mark.parametrize(
"role,expected_status",
@ -714,3 +762,28 @@ def test_take_permissions(
response = client.post(url, format="json", **make_user_auth_headers(benefactor, token))
assert response.status_code == expected_status
@patch("apps.api.views.shift_swap.ShiftSwapViewSet.shifts", return_value=mock_success_response)
@pytest.mark.django_db
@pytest.mark.parametrize(
"role,expected_status",
[
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
],
)
def test_list_shifts_permissions(
mock_endpoint_handler,
ssr_setup,
make_user_auth_headers,
role,
expected_status,
):
ssr, beneficiary, token, _ = ssr_setup(beneficiary_role=role)
client = APIClient()
url = reverse("api-internal:shift_swap-shifts", kwargs={"pk": ssr.public_primary_key})
response = client.get(url, format="json", **make_user_auth_headers(beneficiary, token))
assert response.status_code == expected_status

View file

@ -18,6 +18,7 @@ from apps.api.serializers.alert_receive_channel import (
)
from apps.api.throttlers import DemoAlertThrottler
from apps.auth_token.auth import PluginAuthentication
from apps.integrations.legacy_prefix import has_legacy_prefix, remove_legacy_prefix
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, TeamModelMultipleChoiceFilter
from common.api_helpers.mixins import (
@ -101,6 +102,7 @@ class AlertReceiveChannelView(
"filters": [RBACPermission.Permissions.INTEGRATIONS_READ],
"start_maintenance": [RBACPermission.Permissions.INTEGRATIONS_WRITE],
"stop_maintenance": [RBACPermission.Permissions.INTEGRATIONS_WRITE],
"migrate": [RBACPermission.Permissions.INTEGRATIONS_WRITE],
}
def perform_update(self, serializer):
@ -296,3 +298,38 @@ class AlertReceiveChannelView(
user = request.user
instance.force_disable_maintenance(user)
return Response(status=status.HTTP_200_OK)
@action(detail=True, methods=["post"])
def migrate(self, request, pk):
instance = self.get_object()
integration_type = instance.integration
if not has_legacy_prefix(integration_type):
raise BadRequest(detail="Integration is not legacy")
instance.integration = remove_legacy_prefix(instance.integration)
# drop all templates since they won't work for new payload shape
templates = [
"web_title_template",
"web_message_template",
"web_image_url_template",
"sms_title_template",
"phone_call_title_template",
"source_link_template",
"grouping_id_template",
"resolve_condition_template",
"acknowledge_condition_template",
"slack_title_template",
"slack_message_template",
"slack_image_url_template",
"telegram_title_template",
"telegram_message_template",
"telegram_image_url_template",
"messaging_backends_templates",
]
for f in templates:
setattr(instance, f, None)
instance.save()
return Response(status=status.HTTP_200_OK)

View file

@ -36,6 +36,7 @@ class ShiftSwapViewSet(PublicPrimaryKeyMixin, ModelViewSet):
"partial_update": [RBACPermission.Permissions.SCHEDULES_WRITE],
"destroy": [RBACPermission.Permissions.SCHEDULES_WRITE],
"take": [RBACPermission.Permissions.SCHEDULES_WRITE],
"shifts": [RBACPermission.Permissions.SCHEDULES_READ],
}
is_beneficiary = IsOwner(ownership_field="beneficiary")
@ -87,6 +88,13 @@ class ShiftSwapViewSet(PublicPrimaryKeyMixin, ModelViewSet):
update_shift_swap_request_message.apply_async((shift_swap_request.pk,))
@action(methods=["get"], detail=True)
def shifts(self, request, pk) -> Response:
shift_swap = self.get_object()
result = {"events": shift_swap.shifts()}
return Response(result, status=status.HTTP_200_OK)
@action(methods=["post"], detail=True)
def take(self, request, pk) -> Response:
shift_swap = self.get_object()

View file

@ -0,0 +1,13 @@
"""
legacy_prefix.py provides utils to work with legacy integration types, which are prefixed with 'legacy_'.
"""
legacy_prefix = "legacy_"
def has_legacy_prefix(integration_type: str) -> bool:
return integration_type.startswith(legacy_prefix)
def remove_legacy_prefix(integration_type: str) -> str:
return integration_type.removeprefix(legacy_prefix)

View file

@ -4,10 +4,10 @@ Files from this modules are integrations for which heartbeat is available (if fi
Filename MUST match INTEGRATION_TO_REVERSE_URL_MAP.
"""
import apps.integrations.metadata.heartbeat.alertmanager # noqa
import apps.integrations.metadata.heartbeat.elastalert # noqa
import apps.integrations.metadata.heartbeat.formatted_webhook # noqa
import apps.integrations.metadata.heartbeat.grafana # noqa
import apps.integrations.metadata.heartbeat.legacy_alertmanager # noqa
import apps.integrations.metadata.heartbeat.prtg # noqa
import apps.integrations.metadata.heartbeat.webhook # noqa
import apps.integrations.metadata.heartbeat.zabbix # noqa

View file

@ -1,9 +1,9 @@
from pathlib import PurePath
from apps.integrations.metadata.heartbeat._heartbeat_text_creator import HeartBeatTextCreatorForTitleGrouping
from apps.integrations.metadata.heartbeat._heartbeat_text_creator import HeartBeatTextCreator
integration_verbal = PurePath(__file__).stem
creator = HeartBeatTextCreatorForTitleGrouping(integration_verbal)
creator = HeartBeatTextCreator(integration_verbal)
heartbeat_text = creator.get_heartbeat_texts()
@ -11,24 +11,65 @@ heartbeat_expired_title = heartbeat_text.heartbeat_expired_title
heartbeat_expired_message = heartbeat_text.heartbeat_expired_message
heartbeat_expired_payload = {
"endsAt": "",
"labels": {"alertname": heartbeat_expired_title},
"alerts": [
{
"endsAt": "",
"labels": {
"alertname": "OnCallHeartBeatMissing",
},
"status": "firing",
"startsAt": "",
"annotations": {
"title": heartbeat_expired_title,
"description": heartbeat_expired_message,
},
"fingerprint": "fingerprint",
"generatorURL": "",
},
],
"status": "firing",
"startsAt": "",
"annotations": {
"message": heartbeat_expired_message,
},
"generatorURL": None,
"version": "4",
"groupKey": '{}:{alertname="OnCallHeartBeatMissing"}',
"receiver": "",
"numFiring": 1,
"externalURL": "",
"groupLabels": {"alertname": "OnCallHeartBeatMissing"},
"numResolved": 0,
"commonLabels": {"alertname": "OnCallHeartBeatMissing"},
"truncatedAlerts": 0,
"commonAnnotations": {},
}
heartbeat_restored_title = heartbeat_text.heartbeat_restored_title
heartbeat_restored_message = heartbeat_text.heartbeat_restored_message
heartbeat_restored_payload = {
"endsAt": "",
"labels": {"alertname": heartbeat_restored_title},
"status": "resolved",
"startsAt": "",
"annotations": {"message": heartbeat_restored_message},
"generatorURL": None,
"alerts": [
{
"endsAt": "",
"labels": {
"alertname": "OnCallHeartBeatMissing",
},
"status": "resolved",
"startsAt": "",
"annotations": {
"title": heartbeat_restored_title,
"description": heartbeat_restored_message,
},
"fingerprint": "fingerprint",
"generatorURL": "",
},
],
"status": "firing",
"version": "4",
"groupKey": '{}:{alertname="OnCallHeartBeatMissing"}',
"receiver": "",
"numFiring": 0,
"externalURL": "",
"groupLabels": {"alertname": "OnCallHeartBeatMissing"},
"numResolved": 1,
"commonLabels": {"alertname": "OnCallHeartBeatMissing"},
"truncatedAlerts": 0,
"commonAnnotations": {},
}

View file

@ -0,0 +1,33 @@
from pathlib import PurePath
from apps.integrations.metadata.heartbeat._heartbeat_text_creator import HeartBeatTextCreatorForTitleGrouping
integration_verbal = PurePath(__file__).stem
creator = HeartBeatTextCreatorForTitleGrouping(integration_verbal)
heartbeat_text = creator.get_heartbeat_texts()
heartbeat_expired_title = heartbeat_text.heartbeat_expired_title
heartbeat_expired_message = heartbeat_text.heartbeat_expired_message
heartbeat_expired_payload = {
"endsAt": "",
"labels": {"alertname": heartbeat_expired_title},
"status": "firing",
"startsAt": "",
"annotations": {
"message": heartbeat_expired_message,
},
"generatorURL": None,
}
heartbeat_restored_title = heartbeat_text.heartbeat_restored_title
heartbeat_restored_message = heartbeat_text.heartbeat_restored_message
heartbeat_restored_payload = {
"endsAt": "",
"labels": {"alertname": heartbeat_restored_title},
"status": "resolved",
"startsAt": "",
"annotations": {"message": heartbeat_restored_message},
"generatorURL": None,
}

View file

@ -0,0 +1,106 @@
from unittest import mock
import pytest
from django.urls import reverse
from rest_framework.test import APIClient
from apps.alerts.models import AlertReceiveChannel
@mock.patch("apps.integrations.tasks.create_alertmanager_alerts.apply_async", return_value=None)
@mock.patch("apps.integrations.tasks.create_alert.apply_async", return_value=None)
@pytest.mark.django_db
def test_legacy_am_integrations(
mocked_create_alert, mocked_create_am_alert, make_organization_and_user, make_alert_receive_channel
):
organization, user = make_organization_and_user()
alertmanager = make_alert_receive_channel(
organization=organization,
author=user,
integration=AlertReceiveChannel.INTEGRATION_ALERTMANAGER,
)
legacy_alertmanager = make_alert_receive_channel(
organization=organization,
author=user,
integration=AlertReceiveChannel.INTEGRATION_LEGACY_ALERTMANAGER,
)
data = {
"alerts": [
{
"endsAt": "0001-01-01T00:00:00Z",
"labels": {
"job": "node",
"group": "production",
"instance": "localhost:8081",
"severity": "critical",
"alertname": "InstanceDown",
},
"status": "firing",
"startsAt": "2023-06-12T08:24:38.326Z",
"annotations": {
"title": "Instance localhost:8081 down",
"description": "localhost:8081 of job node has been down for more than 1 minute.",
},
"fingerprint": "f404ecabc8dd5cd7",
"generatorURL": "",
},
{
"endsAt": "0001-01-01T00:00:00Z",
"labels": {
"job": "node",
"group": "canary",
"instance": "localhost:8082",
"severity": "critical",
"alertname": "InstanceDown",
},
"status": "firing",
"startsAt": "2023-06-12T08:24:38.326Z",
"annotations": {
"title": "Instance localhost:8082 down",
"description": "localhost:8082 of job node has been down for more than 1 minute.",
},
"fingerprint": "f8f08d4e32c61a9d",
"generatorURL": "",
},
{
"endsAt": "0001-01-01T00:00:00Z",
"labels": {
"job": "node",
"group": "production",
"instance": "localhost:8083",
"severity": "critical",
"alertname": "InstanceDown",
},
"status": "firing",
"startsAt": "2023-06-12T08:24:38.326Z",
"annotations": {
"title": "Instance localhost:8083 down",
"description": "localhost:8083 of job node has been down for more than 1 minute.",
},
"fingerprint": "39f38c0611ee7abd",
"generatorURL": "",
},
],
"status": "firing",
"version": "4",
"groupKey": '{}:{alertname="InstanceDown"}',
"receiver": "combo",
"numFiring": 3,
"externalURL": "",
"groupLabels": {"alertname": "InstanceDown"},
"numResolved": 0,
"commonLabels": {"job": "node", "severity": "critical", "alertname": "InstanceDown"},
"truncatedAlerts": 0,
"commonAnnotations": {},
}
client = APIClient()
url = reverse("integrations:alertmanager", kwargs={"alert_channel_key": alertmanager.token})
client.post(url, data=data, format="json")
assert mocked_create_alert.call_count == 1
url = reverse("integrations:alertmanager", kwargs={"alert_channel_key": legacy_alertmanager.token})
client.post(url, data=data, format="json")
assert mocked_create_am_alert.call_count == 3

View file

@ -8,7 +8,6 @@ from common.api_helpers.optional_slash_router import optional_slash_path
from .views import (
AlertManagerAPIView,
AlertManagerV2View,
AmazonSNS,
GrafanaAlertingAPIView,
GrafanaAPIView,
@ -32,7 +31,6 @@ urlpatterns = [
path("grafana_alerting/<str:alert_channel_key>/", GrafanaAlertingAPIView.as_view(), name="grafana_alerting"),
path("alertmanager/<str:alert_channel_key>/", AlertManagerAPIView.as_view(), name="alertmanager"),
path("amazon_sns/<str:alert_channel_key>/", AmazonSNS.as_view(), name="amazon_sns"),
path("alertmanager_v2/<str:alert_channel_key>/", AlertManagerV2View.as_view(), name="alertmanager_v2"),
path("<str:integration_type>/<str:alert_channel_key>/", UniversalAPIView.as_view(), name="universal"),
]

View file

@ -12,6 +12,7 @@ from rest_framework.views import APIView
from apps.alerts.models import AlertReceiveChannel
from apps.heartbeat.tasks import process_heartbeat_task
from apps.integrations.legacy_prefix import has_legacy_prefix
from apps.integrations.mixins import (
AlertChannelDefiningMixin,
BrowsableInstructionMixin,
@ -104,6 +105,17 @@ class AlertManagerAPIView(
+ str(alert_receive_channel.get_integration_display())
)
if has_legacy_prefix(alert_receive_channel.integration):
self.process_v1(request, alert_receive_channel)
else:
self.process_v2(request, alert_receive_channel)
return Response("Ok.")
def process_v1(self, request, alert_receive_channel):
"""
process_v1 creates alerts from each alert in incoming AlertManager payload.
"""
for alert in request.data.get("alerts", []):
if settings.DEBUG:
create_alertmanager_alerts(alert_receive_channel.pk, alert)
@ -115,27 +127,78 @@ class AlertManagerAPIView(
create_alertmanager_alerts.apply_async((alert_receive_channel.pk, alert))
return Response("Ok.")
def process_v2(self, request, alert_receive_channel):
"""
process_v2 creates one alert from one incoming AlertManager payload
"""
alerts = request.data.get("alerts", [])
data = request.data
if "numFiring" not in request.data:
# Count firing and resolved alerts manually if not present in payload
num_firing = len(list(filter(lambda a: a.get("status", "") == "firing", alerts)))
num_resolved = len(list(filter(lambda a: a.get("status", "") == "resolved", alerts)))
data = {**request.data, "numFiring": num_firing, "numResolved": num_resolved}
create_alert.apply_async(
[],
{
"title": None,
"message": None,
"image_url": None,
"link_to_upstream_details": None,
"alert_receive_channel_pk": alert_receive_channel.pk,
"integration_unique_data": None,
"raw_request_data": data,
},
)
def check_integration_type(self, alert_receive_channel):
return alert_receive_channel.integration == AlertReceiveChannel.INTEGRATION_ALERTMANAGER
return alert_receive_channel.integration in {
AlertReceiveChannel.INTEGRATION_ALERTMANAGER,
AlertReceiveChannel.INTEGRATION_LEGACY_ALERTMANAGER,
}
class GrafanaAlertingAPIView(AlertManagerAPIView):
"""Grafana Alerting has the same payload structure as AlertManager"""
def check_integration_type(self, alert_receive_channel):
return alert_receive_channel.integration == AlertReceiveChannel.INTEGRATION_GRAFANA_ALERTING
return alert_receive_channel.integration in {
AlertReceiveChannel.INTEGRATION_GRAFANA_ALERTING,
AlertReceiveChannel.INTEGRATION_LEGACGRAFANA_ALERTING,
}
class GrafanaAPIView(AlertManagerAPIView):
class GrafanaAPIView(
BrowsableInstructionMixin,
AlertChannelDefiningMixin,
IntegrationRateLimitMixin,
APIView,
):
"""Support both new and old versions of Grafana Alerting"""
def post(self, request):
alert_receive_channel = self.request.alert_receive_channel
# New Grafana has the same payload structure as AlertManager
if not self.check_integration_type(alert_receive_channel):
return HttpResponseBadRequest(
"This url is for integration with Grafana. Key is for "
+ str(alert_receive_channel.get_integration_display())
)
# Grafana Alerting 9 has the same payload structure as AlertManager
if "alerts" in request.data:
return super().post(request)
for alert in request.data.get("alerts", []):
if settings.DEBUG:
create_alertmanager_alerts(alert_receive_channel.pk, alert)
else:
self.execute_rate_limit_with_notification_logic()
if self.request.limited and not is_ratelimit_ignored(alert_receive_channel):
return self.get_ratelimit_http_response()
create_alertmanager_alerts.apply_async((alert_receive_channel.pk, alert))
return Response("Ok.")
"""
Example of request.data from old Grafana:
@ -158,12 +221,6 @@ class GrafanaAPIView(AlertManagerAPIView):
'title': '[Alerting] Test notification'
}
"""
if not self.check_integration_type(alert_receive_channel):
return HttpResponseBadRequest(
"This url is for integration with Grafana. Key is for "
+ str(alert_receive_channel.get_integration_display())
)
if "attachments" in request.data:
# Fallback in case user by mistake configured Slack url instead of webhook
"""
@ -270,46 +327,3 @@ class IntegrationHeartBeatAPIView(AlertChannelDefiningMixin, IntegrationHeartBea
process_heartbeat_task.apply_async(
(alert_receive_channel.pk,),
)
class AlertManagerV2View(BrowsableInstructionMixin, AlertChannelDefiningMixin, IntegrationRateLimitMixin, APIView):
"""
AlertManagerV2View consumes alerts from AlertManager. It expects data to be in format of AM webhook receiver.
"""
def post(self, request, *args, **kwargs):
alert_receive_channel = self.request.alert_receive_channel
if not alert_receive_channel.integration == AlertReceiveChannel.INTEGRATION_ALERTMANAGER_V2:
return HttpResponseBadRequest(
f"This url is for integration with {alert_receive_channel.config.title}."
f"Key is for {alert_receive_channel.get_integration_display()}"
)
alerts = request.data.get("alerts", [])
data = request.data
if "numFiring" not in request.data:
num_firing = 0
num_resolved = 0
for a in alerts:
if a["status"] == "firing":
num_firing += 1
elif a["status"] == "resolved":
num_resolved += 1
# Count firing and resolved alerts manually if not present in payload
data = {**request.data, "numFiring": num_firing, "numResolved": num_resolved}
else:
data = request.data
create_alert.apply_async(
[],
{
"title": None,
"message": None,
"image_url": None,
"link_to_upstream_details": None,
"alert_receive_channel_pk": alert_receive_channel.pk,
"integration_unique_data": None,
"raw_request_data": data,
},
)
return Response("Ok.")

View file

@ -6,6 +6,7 @@ from rest_framework import fields, serializers
from apps.alerts.grafana_alerting_sync_manager.grafana_alerting_sync import GrafanaAlertingSyncManager
from apps.alerts.models import AlertReceiveChannel
from apps.base.messaging import get_messaging_backends
from apps.integrations.legacy_prefix import has_legacy_prefix, remove_legacy_prefix
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.mixins import PHONE_CALL, SLACK, SMS, TELEGRAM, WEB, EagerLoadingMixin
@ -59,16 +60,14 @@ for backend_id, backend in get_messaging_backends():
class IntegrationTypeField(fields.CharField):
def to_representation(self, value):
return AlertReceiveChannel.PUBLIC_API_INTEGRATION_MAP[value]
return remove_legacy_prefix(value)
def to_internal_value(self, data):
try:
integration_type = [
key for key, value in AlertReceiveChannel.PUBLIC_API_INTEGRATION_MAP.items() if value == data
][0]
except IndexError:
if data not in AlertReceiveChannel.INTEGRATION_TYPES:
raise BadRequest(detail="Invalid integration type")
return integration_type
if has_legacy_prefix(data):
raise BadRequest("This integration type is deprecated")
return data
class IntegrationSerializer(EagerLoadingMixin, serializers.ModelSerializer, MaintainableObjectSerializerMixin):
@ -117,10 +116,8 @@ class IntegrationSerializer(EagerLoadingMixin, serializers.ModelSerializer, Main
default_route_data = validated_data.pop("default_route", None)
organization = self.context["request"].auth.organization
integration = validated_data.get("integration")
# hack to block alertmanager_v2 integration, will be removed
if integration == "alertmanager_v2":
raise BadRequest
if integration == AlertReceiveChannel.INTEGRATION_GRAFANA_ALERTING:
# TODO: probably only needs to check if unified alerting is on
connection_error = GrafanaAlertingSyncManager.check_for_connection_errors(organization)
if connection_error:
raise serializers.ValidationError(connection_error)

View file

@ -871,3 +871,71 @@ def test_update_integrations_direct_paging(
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data["detail"] == AlertReceiveChannel.DuplicateDirectPagingError.DETAIL
@pytest.mark.django_db
def test_get_integration_type_legacy(
make_organization_and_user_with_token, make_alert_receive_channel, make_channel_filter, make_integration_heartbeat
):
organization, user, token = make_organization_and_user_with_token()
am = make_alert_receive_channel(
organization, verbal_name="AMV2", integration=AlertReceiveChannel.INTEGRATION_ALERTMANAGER
)
legacy_am = make_alert_receive_channel(
organization, verbal_name="AMV2", integration=AlertReceiveChannel.INTEGRATION_LEGACY_ALERTMANAGER
)
client = APIClient()
url = reverse("api-public:integrations-detail", args=[am.public_primary_key])
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_200_OK
assert response.data["type"] == "alertmanager"
url = reverse("api-public:integrations-detail", args=[legacy_am.public_primary_key])
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_200_OK
assert response.data["type"] == "alertmanager"
@pytest.mark.django_db
def test_create_integration_type_legacy(
make_organization_and_user_with_token, make_alert_receive_channel, make_channel_filter, make_integration_heartbeat
):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
url = reverse("api-public:integrations-list")
response = client.post(url, data={"type": "alertmanager"}, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_201_CREATED
assert response.data["type"] == "alertmanager"
response = client.post(url, data={"type": "legacy_alertmanager"}, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_update_integration_type_legacy(
make_organization_and_user_with_token, make_alert_receive_channel, make_channel_filter, make_integration_heartbeat
):
organization, user, token = make_organization_and_user_with_token()
am = make_alert_receive_channel(
organization, verbal_name="AMV2", integration=AlertReceiveChannel.INTEGRATION_ALERTMANAGER
)
legacy_am = make_alert_receive_channel(
organization, verbal_name="AMV2", integration=AlertReceiveChannel.INTEGRATION_LEGACY_ALERTMANAGER
)
data_for_update = {"type": "alertmanager", "description_short": "Updated description"}
client = APIClient()
url = reverse("api-public:integrations-detail", args=[am.public_primary_key])
response = client.put(url, data=data_for_update, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_200_OK
assert response.data["type"] == "alertmanager"
assert response.data["description_short"] == "Updated description"
url = reverse("api-public:integrations-detail", args=[legacy_am.public_primary_key])
response = client.put(url, data=data_for_update, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_200_OK
assert response.data["description_short"] == "Updated description"
assert response.data["type"] == "alertmanager"

View file

@ -165,6 +165,17 @@ class ShiftSwapRequest(models.Model):
# make sure final schedule ical representation is updated
refresh_ical_final_schedule.apply_async((self.schedule.pk,))
def shifts(self):
"""Return shifts affected by this swap request."""
schedule = self.schedule.get_real_instance()
events = schedule.final_events(self.swap_start, self.swap_end)
related_shifts = [
e
for e in events
if self.public_primary_key in set(u["swap_request"]["pk"] for u in e["users"] if u.get("swap_request"))
]
return related_shifts
def take(self, benefactor: "User") -> None:
if benefactor == self.beneficiary:
raise exceptions.BeneficiaryCannotTakeOwnShiftSwapRequest()

View file

@ -2,9 +2,10 @@ import datetime
from unittest.mock import patch
import pytest
from django.utils import timezone
from apps.schedules import exceptions
from apps.schedules.models import ShiftSwapRequest
from apps.schedules.models import CustomOnCallShift, ShiftSwapRequest
@pytest.mark.django_db
@ -116,3 +117,38 @@ def test_take_own_ssr(shift_swap_request_setup) -> None:
ssr, beneficiary, _ = shift_swap_request_setup()
with pytest.raises(exceptions.BeneficiaryCannotTakeOwnShiftSwapRequest):
ssr.take(beneficiary)
@pytest.mark.skip(reason="Skipping to unblock release")
@pytest.mark.django_db
def test_related_shifts(shift_swap_request_setup, make_on_call_shift) -> None:
ssr, beneficiary, _ = shift_swap_request_setup()
schedule = ssr.schedule
organization = schedule.organization
user = beneficiary
today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
start = today + timezone.timedelta(days=2)
duration = timezone.timedelta(hours=8)
data = {
"start": start,
"rotation_start": start,
"duration": duration,
"priority_level": 1,
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
"schedule": schedule,
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
)
on_call_shift.add_rolling_users([[user]])
events = ssr.shifts()
expected = [
# start, end, user, swap request ID
(start, start + duration, user.public_primary_key, ssr.public_primary_key),
]
returned_events = [(e["start"], e["end"], e["users"][0]["pk"], e["users"][0]["swap_request"]["pk"]) for e in events]
assert returned_events == expected

View file

@ -7,32 +7,44 @@ is_displayed_on_web = True
is_featured = False
is_able_to_autoresolve = True
is_demo_alert_enabled = True
description = None
based_on_alertmanager = True
# Behaviour
source_link = "{{ payload.externalURL }}"
grouping_id = "{{ payload.groupKey }}"
resolve_condition = """{{ payload.status == "resolved" }}"""
acknowledge_condition = None
# Web
web_title = """{{- payload.get("labels", {}).get("alertname", "No title (check Title Template)") -}}"""
web_title = """\
{%- set groupLabels = payload.groupLabels.copy() -%}
{%- set alertname = groupLabels.pop('alertname') | default("") -%}
[{{ payload.status }}{% if payload.status == 'firing' %}:{{ payload.numFiring }}{% endif %}] {{ alertname }} {% if groupLabels | length > 0 %}({{ groupLabels|join(", ") }}){% endif %}
""" # noqa
web_message = """\
{%- set annotations = payload.annotations.copy() -%}
{%- set labels = payload.labels.copy() -%}
{%- set annotations = payload.commonAnnotations.copy() -%}
{%- if "summary" in annotations %}
{{ annotations.summary }}
{%- set _ = annotations.pop('summary') -%}
{%- endif %}
{%- if "message" in annotations %}
{{ annotations.message }}
{%- set _ = annotations.pop('message') -%}
{%- endif %}
{% set severity = labels.severity | default("Unknown") -%}
{% set severity = payload.groupLabels.severity -%}
{% if severity %}
{%- set severity_emoji = {"critical": ":rotating_light:", "warning": ":warning:" }[severity] | default(":question:") -%}
Severity: {{ severity }} {{ severity_emoji }}
{% endif %}
{%- set status = payload.status | default("Unknown") %}
{%- set status_emoji = {"firing": ":fire:", "resolved": ":white_check_mark:"}[status] | default(":warning:") %}
Status: {{ status }} {{ status_emoji }} (on the source)
{% if status == "firing" %}
Firing alerts {{ payload.numFiring }}
Resolved alerts {{ payload.numResolved }}
{% endif %}
{% if "runbook_url" in annotations -%}
[:book: Runbook:link:]({{ annotations.runbook_url }})
@ -44,35 +56,34 @@ Status: {{ status }} {{ status_emoji }} (on the source)
{%- set _ = annotations.pop('runbook_url_internal') -%}
{%- endif %}
:label: Labels:
{%- for k, v in payload["labels"].items() %}
- {{ k }}: {{ v }}
GroupLabels:
{%- for k, v in payload["groupLabels"].items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% if payload["commonLabels"] | length > 0 -%}
CommonLabels:
{%- for k, v in payload["commonLabels"].items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% endif %}
{% if annotations | length > 0 -%}
:pushpin: Other annotations:
Annotations:
{%- for k, v in annotations.items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% endif %}
""" # noqa: W291
web_image_url = None
[View in AlertManager]({{ source_link }})
"""
# Behaviour
source_link = "{{ payload.generatorURL }}"
grouping_id = "{{ payload.labels }}"
resolve_condition = """{{ payload.status == "resolved" }}"""
acknowledge_condition = None
# Slack
slack_title = """\
{% set title = payload.get("labels", {}).get("alertname", "No title (check Title Template)") %}
{# Combine the title from different built-in variables into slack-formatted url #}
*<{{ grafana_oncall_link }}|#{{ grafana_oncall_incident_id }} {{ title }}>* via {{ integration_name }}
{%- set groupLabels = payload.groupLabels.copy() -%}
{%- set alertname = groupLabels.pop('alertname') | default("") -%}
*<{{ grafana_oncall_link }}|#{{ grafana_oncall_incident_id }} {{ web_title }}>* via {{ integration_name }}
{% if source_link %}
(*<{{ source_link }}|source>*)
{%- endif %}
@ -88,32 +99,21 @@ slack_title = """\
# """
slack_message = """\
{%- set annotations = payload.annotations.copy() -%}
{%- set labels = payload.labels.copy() -%}
{%- set annotations = payload.commonAnnotations.copy() -%}
{%- if "summary" in annotations %}
{{ annotations.summary }}
{%- set _ = annotations.pop('summary') -%}
{%- endif %}
{%- if "message" in annotations %}
{{ annotations.message }}
{%- set _ = annotations.pop('message') -%}
{%- endif %}
{# Optionally set oncall_slack_user_group to slack user group in the following format "@users-oncall" #}
{%- set oncall_slack_user_group = None -%}
{%- if oncall_slack_user_group %}
Heads up {{ oncall_slack_user_group }}
{%- endif %}
{% set severity = labels.severity | default("Unknown") -%}
{% set severity = payload.groupLabels.severity -%}
{% if severity %}
{%- set severity_emoji = {"critical": ":rotating_light:", "warning": ":warning:" }[severity] | default(":question:") -%}
Severity: {{ severity }} {{ severity_emoji }}
{% endif %}
{%- set status = payload.status | default("Unknown") %}
{%- set status_emoji = {"firing": ":fire:", "resolved": ":white_check_mark:"}[status] | default(":warning:") %}
Status: {{ status }} {{ status_emoji }} (on the source)
{% if status == "firing" %}
Firing alerts {{ payload.numFiring }}
Resolved alerts {{ payload.numResolved }}
{% endif %}
{% if "runbook_url" in annotations -%}
<{{ annotations.runbook_url }}|:book: Runbook:link:>
@ -125,59 +125,57 @@ Status: {{ status }} {{ status_emoji }} (on the source)
{%- set _ = annotations.pop('runbook_url_internal') -%}
{%- endif %}
:label: Labels:
{%- for k, v in payload["labels"].items() %}
- {{ k }}: {{ v }}
GroupLabels:
{%- for k, v in payload["groupLabels"].items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% if payload["commonLabels"] | length > 0 -%}
CommonLabels:
{%- for k, v in payload["commonLabels"].items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% endif %}
{% if annotations | length > 0 -%}
:pushpin: Other annotations:
Annotations:
{%- for k, v in annotations.items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% endif %}
""" # noqa: W291
"""
# noqa: W291
slack_image_url = None
web_image_url = None
# SMS
sms_title = web_title
# Phone
phone_call_title = web_title
phone_call_title = """{{ payload.groupLabels|join(", ") }}"""
# Telegram
telegram_title = web_title
# default telegram message template is identical to web message template, except urls
# It can be based on web message template (see example), but it can affect existing templates
# telegram_message = """
# {% set mkdwn_link_regex = "\[([\w\s\d:]+)\]\((https?:\/\/[\w\d./?=#]+)\)" %}
# {{ web_message
# | regex_replace(mkdwn_link_regex, "<a href='\\2'>\\1</a>")
# }}
# """
telegram_message = """\
{%- set annotations = payload.annotations.copy() -%}
{%- set labels = payload.labels.copy() -%}
{%- set annotations = payload.commonAnnotations.copy() -%}
{%- if "summary" in annotations %}
{{ annotations.summary }}
{%- set _ = annotations.pop('summary') -%}
{%- endif %}
{%- if "message" in annotations %}
{{ annotations.message }}
{%- set _ = annotations.pop('message') -%}
{%- endif %}
{% set severity = labels.severity | default("Unknown") -%}
{% set severity = payload.groupLabels.severity -%}
{% if severity %}
{%- set severity_emoji = {"critical": ":rotating_light:", "warning": ":warning:" }[severity] | default(":question:") -%}
Severity: {{ severity }} {{ severity_emoji }}
{% endif %}
{%- set status = payload.status | default("Unknown") %}
{%- set status_emoji = {"firing": ":fire:", "resolved": ":white_check_mark:"}[status] | default(":warning:") %}
Status: {{ status }} {{ status_emoji }} (on the source)
{% if status == "firing" %}
Firing alerts {{ payload.numFiring }}
Resolved alerts {{ payload.numResolved }}
{% endif %}
{% if "runbook_url" in annotations -%}
<a href='{{ annotations.runbook_url }}'>:book: Runbook:link:</a>
@ -189,96 +187,79 @@ Status: {{ status }} {{ status_emoji }} (on the source)
{%- set _ = annotations.pop('runbook_url_internal') -%}
{%- endif %}
:label: Labels:
{%- for k, v in payload["labels"].items() %}
- {{ k }}: {{ v }}
GroupLabels:
{%- for k, v in payload["groupLabels"].items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% if payload["commonLabels"] | length > 0 -%}
CommonLabels:
{%- for k, v in payload["commonLabels"].items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% endif %}
{% if annotations | length > 0 -%}
:pushpin: Other annotations:
Annotations:
{%- for k, v in annotations.items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% endif %}
""" # noqa: W291
<a href='{{ source_link }}'>View in AlertManager</a>
"""
telegram_image_url = None
tests = {
"payload": {
"endsAt": "0001-01-01T00:00:00Z",
"labels": {
"job": "kube-state-metrics",
"instance": "10.143.139.7:8443",
"job_name": "email-tracking-perform-initialization-1.0.50",
"severity": "warning",
"alertname": "KubeJobCompletion",
"namespace": "default",
"prometheus": "monitoring/k8s",
},
"status": "firing",
"startsAt": "2019-12-13T08:57:35.095800493Z",
"annotations": {
"message": "Job default/email-tracking-perform-initialization-1.0.50 is taking more than one hour to complete.",
"runbook_url": "https://github.com/kubernetes-monitoring/kubernetes-mixin/tree/master/runbook.md#alert-name-kubejobcompletion",
},
"generatorURL": (
"https://localhost/prometheus/graph?g0.expr=kube_job_spec_completions%7Bjob%3D%22kube-state-metrics%22%7D"
"+-+kube_job_status_succeeded%7Bjob%3D%22kube-state-metrics%22%7D+%3E+0&g0.tab=1"
),
},
"slack": {
"title": (
"*<{web_link}|#1 KubeJobCompletion>* via {integration_name} "
"(*<"
"https://localhost/prometheus/graph?g0.expr=kube_job_spec_completions%7Bjob%3D%22kube-state-metrics%22%7D"
"+-+kube_job_status_succeeded%7Bjob%3D%22kube-state-metrics%22%7D+%3E+0&g0.tab=1"
"|source>*)"
),
"message": "\nJob default/email-tracking-perform-initialization-1.0.50 is taking more than one hour to complete.\n\n\n\nSeverity: warning :warning:\nStatus: firing :fire: (on the source)\n\n<https://github.com/kubernetes-monitoring/kubernetes-mixin/tree/master/runbook.md#alert-name-kubejobcompletion|:book: Runbook:link:>\n\n:label: Labels:\n- job: kube-state-metrics\n- instance: 10.143.139.7:8443\n- job_name: email-tracking-perform-initialization-1.0.50\n- severity: warning\n- alertname: KubeJobCompletion\n- namespace: default\n- prometheus: monitoring/k8s\n\n", # noqa
"image_url": None,
},
"web": {
"title": "KubeJobCompletion",
"message": '<p>Job default/email-tracking-perform-initialization-1.0.50 is taking more than one hour to complete. </p>\n<p>Severity: warning ⚠️ <br/>\nStatus: firing 🔥 (on the source) </p>\n<p><a href="https://github.com/kubernetes-monitoring/kubernetes-mixin/tree/master/runbook.md#alert-name-kubejobcompletion" rel="nofollow noopener" target="_blank">📖 Runbook🔗</a> </p>\n<p>🏷️ Labels: </p>\n<ul>\n<li>job: kube-state-metrics </li>\n<li>instance: 10.143.139.7:8443 </li>\n<li>job_name: email-tracking-perform-initialization-1.0.50 </li>\n<li>severity: warning </li>\n<li>alertname: KubeJobCompletion </li>\n<li>namespace: default </li>\n<li>prometheus: monitoring/k8s </li>\n</ul>', # noqa
"image_url": None,
},
"sms": {
"title": "KubeJobCompletion",
},
"phone_call": {
"title": "KubeJobCompletion",
},
"telegram": {
"title": "KubeJobCompletion",
"message": "\nJob default/email-tracking-perform-initialization-1.0.50 is taking more than one hour to complete.\n\nSeverity: warning ⚠️\nStatus: firing 🔥 (on the source)\n\n<a href='https://github.com/kubernetes-monitoring/kubernetes-mixin/tree/master/runbook.md#alert-name-kubejobcompletion'>📖 Runbook🔗</a>\n\n🏷️ Labels:\n- job: kube-state-metrics\n- instance: 10.143.139.7:8443\n- job_name: email-tracking-perform-initialization-1.0.50\n- severity: warning\n- alertname: KubeJobCompletion\n- namespace: default\n- prometheus: monitoring/k8s\n\n", # noqa
"image_url": None,
},
}
# Misc
example_payload = {
"receiver": "amixr",
"status": "firing",
"alerts": [
{
"status": "firing",
"labels": {"alertname": "TestAlert", "region": "eu-1", "severity": "critical"},
"annotations": {
"message": "This is test alert",
"description": "This alert was sent by user for demonstration purposes",
"runbook_url": "https://grafana.com/",
},
"startsAt": "2018-12-25T15:47:47.377363608Z",
"endsAt": "0001-01-01T00:00:00Z",
"labels": {
"job": "node",
"group": "production",
"instance": "localhost:8081",
"severity": "critical",
"alertname": "InstanceDown",
},
"status": "firing",
"startsAt": "2023-06-12T08:24:38.326Z",
"annotations": {
"title": "Instance localhost:8081 down",
"description": "localhost:8081 of job node has been down for more than 1 minute.",
},
"fingerprint": "f404ecabc8dd5cd7",
"generatorURL": "",
"amixr_demo": True,
}
},
{
"endsAt": "0001-01-01T00:00:00Z",
"labels": {
"job": "node",
"group": "canary",
"instance": "localhost:8082",
"severity": "critical",
"alertname": "InstanceDown",
},
"status": "firing",
"startsAt": "2023-06-12T08:24:38.326Z",
"annotations": {
"title": "Instance localhost:8082 down",
"description": "localhost:8082 of job node has been down for more than 1 minute.",
},
"fingerprint": "f8f08d4e32c61a9d",
"generatorURL": "",
},
],
"groupLabels": {},
"commonLabels": {},
"commonAnnotations": {},
"externalURL": "http://f1d1ef51d710:9093",
"status": "firing",
"version": "4",
"groupKey": "{}:{}",
"groupKey": '{}:{alertname="InstanceDown"}',
"receiver": "combo",
"numFiring": 2,
"externalURL": "",
"groupLabels": {"alertname": "InstanceDown"},
"numResolved": 0,
"commonLabels": {"job": "node", "severity": "critical", "alertname": "InstanceDown"},
"truncatedAlerts": 0,
"commonAnnotations": {},
}

View file

@ -1,281 +0,0 @@
# Main
enabled = True
title = "AlertManagerV2"
slug = "alertmanager_v2"
short_description = "Prometheus"
is_displayed_on_web = False
is_featured = False
is_able_to_autoresolve = True
is_demo_alert_enabled = True
description = None
# Behaviour
source_link = "{{ payload.externalURL }}"
grouping_id = "{{ payload.groupKey }}"
resolve_condition = """{{ payload.status == "resolved" }}"""
acknowledge_condition = None
web_title = """\
{%- set groupLabels = payload.groupLabels.copy() -%}
{%- set alertname = groupLabels.pop('alertname') | default("") -%}
[{{ payload.status }}{% if payload.status == 'firing' %}:{{ payload.numFiring }}{% endif %}] {{ alertname }} {% if groupLabels | length > 0 %}({{ groupLabels|join(", ") }}){% endif %}
""" # noqa
web_message = """\
{%- set annotations = payload.commonAnnotations.copy() -%}
{% set severity = payload.groupLabels.severity -%}
{% if severity %}
{%- set severity_emoji = {"critical": ":rotating_light:", "warning": ":warning:" }[severity] | default(":question:") -%}
Severity: {{ severity }} {{ severity_emoji }}
{% endif %}
{%- set status = payload.status | default("Unknown") %}
{%- set status_emoji = {"firing": ":fire:", "resolved": ":white_check_mark:"}[status] | default(":warning:") %}
Status: {{ status }} {{ status_emoji }} (on the source)
{% if status == "firing" %}
Firing alerts {{ payload.numFiring }}
Resolved alerts {{ payload.numResolved }}
{% endif %}
{% if "runbook_url" in annotations -%}
[:book: Runbook:link:]({{ annotations.runbook_url }})
{%- set _ = annotations.pop('runbook_url') -%}
{%- endif %}
{%- if "runbook_url_internal" in annotations -%}
[:closed_book: Runbook (internal):link:]({{ annotations.runbook_url_internal }})
{%- set _ = annotations.pop('runbook_url_internal') -%}
{%- endif %}
GroupLabels:
{%- for k, v in payload["groupLabels"].items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% if payload["commonLabels"] | length > 0 -%}
CommonLabels:
{%- for k, v in payload["commonLabels"].items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% endif %}
{% if annotations | length > 0 -%}
Annotations:
{%- for k, v in annotations.items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% endif %}
[View in AlertManager]({{ source_link }})
"""
# Slack templates
slack_title = """\
{%- set groupLabels = payload.groupLabels.copy() -%}
{%- set alertname = groupLabels.pop('alertname') | default("") -%}
*<{{ grafana_oncall_link }}|#{{ grafana_oncall_incident_id }} {{ web_title }}>* via {{ integration_name }}
{% if source_link %}
(*<{{ source_link }}|source>*)
{%- endif %}
"""
# default slack message template is identical to web message template, except urls
# It can be based on web message template (see example), but it can affect existing templates
# slack_message = """
# {% set mkdwn_link_regex = "\[([\w\s\d:]+)\]\((https?:\/\/[\w\d./?=#]+)\)" %}
# {{ web_message
# | regex_replace(mkdwn_link_regex, "<\\2|\\1>")
# }}
# """
slack_message = """\
{%- set annotations = payload.commonAnnotations.copy() -%}
{% set severity = payload.groupLabels.severity -%}
{% if severity %}
{%- set severity_emoji = {"critical": ":rotating_light:", "warning": ":warning:" }[severity] | default(":question:") -%}
Severity: {{ severity }} {{ severity_emoji }}
{% endif %}
{%- set status = payload.status | default("Unknown") %}
{%- set status_emoji = {"firing": ":fire:", "resolved": ":white_check_mark:"}[status] | default(":warning:") %}
Status: {{ status }} {{ status_emoji }} (on the source)
{% if status == "firing" %}
Firing alerts {{ payload.numFiring }}
Resolved alerts {{ payload.numResolved }}
{% endif %}
{% if "runbook_url" in annotations -%}
<{{ annotations.runbook_url }}|:book: Runbook:link:>
{%- set _ = annotations.pop('runbook_url') -%}
{%- endif %}
{%- if "runbook_url_internal" in annotations -%}
<{{ annotations.runbook_url_internal }}|:closed_book: Runbook (internal):link:>
{%- set _ = annotations.pop('runbook_url_internal') -%}
{%- endif %}
GroupLabels:
{%- for k, v in payload["groupLabels"].items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% if payload["commonLabels"] | length > 0 -%}
CommonLabels:
{%- for k, v in payload["commonLabels"].items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% endif %}
{% if annotations | length > 0 -%}
Annotations:
{%- for k, v in annotations.items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% endif %}
"""
# noqa: W291
slack_image_url = None
web_image_url = None
sms_title = web_title
phone_call_title = """{{ payload.groupLabels|join(", ") }}"""
telegram_title = web_title
telegram_message = """\
{%- set annotations = payload.commonAnnotations.copy() -%}
{% set severity = payload.groupLabels.severity -%}
{% if severity %}
{%- set severity_emoji = {"critical": ":rotating_light:", "warning": ":warning:" }[severity] | default(":question:") -%}
Severity: {{ severity }} {{ severity_emoji }}
{% endif %}
{%- set status = payload.status | default("Unknown") %}
{%- set status_emoji = {"firing": ":fire:", "resolved": ":white_check_mark:"}[status] | default(":warning:") %}
Status: {{ status }} {{ status_emoji }} (on the source)
{% if status == "firing" %}
Firing alerts {{ payload.numFiring }}
Resolved alerts {{ payload.numResolved }}
{% endif %}
{% if "runbook_url" in annotations -%}
<a href='{{ annotations.runbook_url }}'>:book: Runbook:link:</a>
{%- set _ = annotations.pop('runbook_url') -%}
{%- endif %}
{%- if "runbook_url_internal" in annotations -%}
<a href='{{ annotations.runbook_url_internal }}'>:closed_book: Runbook (internal):link:</a>
{%- set _ = annotations.pop('runbook_url_internal') -%}
{%- endif %}
GroupLabels:
{%- for k, v in payload["groupLabels"].items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% if payload["commonLabels"] | length > 0 -%}
CommonLabels:
{%- for k, v in payload["commonLabels"].items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% endif %}
{% if annotations | length > 0 -%}
Annotations:
{%- for k, v in annotations.items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% endif %}
<a href='{{ source_link }}'>View in AlertManager</a>
"""
telegram_image_url = None
example_payload = {
"alerts": [
{
"endsAt": "0001-01-01T00:00:00Z",
"labels": {
"job": "node",
"group": "production",
"instance": "localhost:8081",
"severity": "critical",
"alertname": "InstanceDown",
},
"status": "firing",
"startsAt": "2023-06-12T08:24:38.326Z",
"annotations": {
"title": "Instance localhost:8081 down",
"description": "localhost:8081 of job node has been down for more than 1 minute.",
},
"fingerprint": "f404ecabc8dd5cd7",
"generatorURL": "",
},
{
"endsAt": "0001-01-01T00:00:00Z",
"labels": {
"job": "node",
"group": "canary",
"instance": "localhost:8082",
"severity": "critical",
"alertname": "InstanceDown",
},
"status": "firing",
"startsAt": "2023-06-12T08:24:38.326Z",
"annotations": {
"title": "Instance localhost:8082 down",
"description": "localhost:8082 of job node has been down for more than 1 minute.",
},
"fingerprint": "f8f08d4e32c61a9d",
"generatorURL": "",
},
{
"endsAt": "0001-01-01T00:00:00Z",
"labels": {
"job": "node",
"group": "production",
"instance": "localhost:8083",
"severity": "critical",
"alertname": "InstanceDown",
},
"status": "firing",
"startsAt": "2023-06-12T08:24:38.326Z",
"annotations": {
"title": "Instance localhost:8083 down",
"description": "localhost:8083 of job node has been down for more than 1 minute.",
},
"fingerprint": "39f38c0611ee7abd",
"generatorURL": "",
},
],
"status": "firing",
"version": "4",
"groupKey": '{}:{alertname="InstanceDown"}',
"receiver": "combo",
"numFiring": 3,
"externalURL": "",
"groupLabels": {"alertname": "InstanceDown"},
"numResolved": 0,
"commonLabels": {"job": "node", "severity": "critical", "alertname": "InstanceDown"},
"truncatedBytes": 0,
"truncatedAlerts": 0,
"commonAnnotations": {},
}

View file

@ -8,8 +8,8 @@ is_displayed_on_web = True
is_featured = False
is_able_to_autoresolve = True
is_demo_alert_enabled = True
based_on_alertmanager = True
description = None
# Default templates
slack_title = """\

View file

@ -12,120 +12,272 @@ featured_tag_name = "Quick Connect"
is_able_to_autoresolve = True
is_demo_alert_enabled = True
description = """ \
Alerts from Grafana Alertmanager are automatically routed to this integration.
{% for dict_item in grafana_alerting_entities %}
<br>Click <a href='{{dict_item.contact_point_url}}' target='_blank'>here</a>
to open contact point, and
<a href='{{dict_item.routes_url}}' target='_blank'>here</a>
to open Notification policy for {{dict_item.alertmanager_name}} Alertmanager.
{% endfor %}
{% if not is_finished_alerting_setup %}
<br>Creating contact points and routes for other alertmanagers...
# Behaviour
source_link = "{{ payload.externalURL }}"
grouping_id = "{{ payload.groupKey }}"
resolve_condition = """{{ payload.status == "resolved" }}"""
acknowledge_condition = None
web_title = """\
{%- set groupLabels = payload.groupLabels.copy() -%}
{%- set alertname = groupLabels.pop('alertname') | default("") -%}
[{{ payload.status }}{% if payload.status == 'firing' %}:{{ payload.numFiring }}{% endif %}] {{ alertname }} {% if groupLabels | length > 0 %}({{ groupLabels|join(", ") }}){% endif %}
""" # noqa
web_message = """\
{%- set annotations = payload.commonAnnotations.copy() -%}
{% set severity = payload.groupLabels.severity -%}
{% if severity %}
{%- set severity_emoji = {"critical": ":rotating_light:", "warning": ":warning:" }[severity] | default(":question:") -%}
Severity: {{ severity }} {{ severity_emoji }}
{% endif %}
{%- set status = payload.status | default("Unknown") %}
{%- set status_emoji = {"firing": ":fire:", "resolved": ":white_check_mark:"}[status] | default(":warning:") %}
Status: {{ status }} {{ status_emoji }} (on the source)
{% if status == "firing" %}
Firing alerts {{ payload.numFiring }}
Resolved alerts {{ payload.numResolved }}
{% endif %}
{% if "runbook_url" in annotations -%}
[:book: Runbook:link:]({{ annotations.runbook_url }})
{%- set _ = annotations.pop('runbook_url') -%}
{%- endif %}
{%- if "runbook_url_internal" in annotations -%}
[:closed_book: Runbook (internal):link:]({{ annotations.runbook_url_internal }})
{%- set _ = annotations.pop('runbook_url_internal') -%}
{%- endif %}
GroupLabels:
{%- for k, v in payload["groupLabels"].items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% if payload["commonLabels"] | length > 0 -%}
CommonLabels:
{%- for k, v in payload["commonLabels"].items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% endif %}
{% if annotations | length > 0 -%}
Annotations:
{%- for k, v in annotations.items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% endif %}
[View in AlertManager]({{ source_link }})
"""
# Default templates
# Slack templates
slack_title = """\
{# Usually title is located in payload.labels.alertname #}
{% set title = payload.get("labels", {}).get("alertname", "No title (check Web Title Template)") %}
{# Combine the title from different built-in variables into slack-formatted url #}
*<{{ grafana_oncall_link }}|#{{ grafana_oncall_incident_id }} {{ title }}>* via {{ integration_name }}
{%- set groupLabels = payload.groupLabels.copy() -%}
{%- set alertname = groupLabels.pop('alertname') | default("") -%}
*<{{ grafana_oncall_link }}|#{{ grafana_oncall_incident_id }} {{ web_title }}>* via {{ integration_name }}
{% if source_link %}
(*<{{ source_link }}|source>*)
{%- endif %}
"""
# default slack message template is identical to web message template, except urls
# It can be based on web message template (see example), but it can affect existing templates
# slack_message = """
# {% set mkdwn_link_regex = "\[([\w\s\d:]+)\]\((https?:\/\/[\w\d./?=#]+)\)" %}
# {{ web_message
# | regex_replace(mkdwn_link_regex, "<\\2|\\1>")
# }}
# """
slack_message = """\
{{- payload.message }}
{%- if "status" in payload -%}
*Status*: {{ payload.status }}
{% endif -%}
*Labels:* {% for k, v in payload["labels"].items() %}
{{ k }}: {{ v }}{% endfor %}
*Annotations:*
{%- for k, v in payload.get("annotations", {}).items() %}
{#- render annotation as slack markdown url if it starts with http #}
{{ k }}: {% if v.startswith("http") %} <{{v}}|here> {% else %} {{v}} {% endif -%}
{% endfor %}
""" # noqa:W291
{%- set annotations = payload.commonAnnotations.copy() -%}
{% set severity = payload.groupLabels.severity -%}
{% if severity %}
{%- set severity_emoji = {"critical": ":rotating_light:", "warning": ":warning:" }[severity] | default(":question:") -%}
Severity: {{ severity }} {{ severity_emoji }}
{% endif %}
{%- set status = payload.status | default("Unknown") %}
{%- set status_emoji = {"firing": ":fire:", "resolved": ":white_check_mark:"}[status] | default(":warning:") %}
Status: {{ status }} {{ status_emoji }} (on the source)
{% if status == "firing" %}
Firing alerts {{ payload.numFiring }}
Resolved alerts {{ payload.numResolved }}
{% endif %}
{% if "runbook_url" in annotations -%}
<{{ annotations.runbook_url }}|:book: Runbook:link:>
{%- set _ = annotations.pop('runbook_url') -%}
{%- endif %}
{%- if "runbook_url_internal" in annotations -%}
<{{ annotations.runbook_url_internal }}|:closed_book: Runbook (internal):link:>
{%- set _ = annotations.pop('runbook_url_internal') -%}
{%- endif %}
GroupLabels:
{%- for k, v in payload["groupLabels"].items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% if payload["commonLabels"] | length > 0 -%}
CommonLabels:
{%- for k, v in payload["commonLabels"].items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% endif %}
{% if annotations | length > 0 -%}
Annotations:
{%- for k, v in annotations.items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% endif %}
"""
# noqa: W291
slack_image_url = None
web_title = """\
{# Usually title is located in payload.labels.alertname #}
{{- payload.get("labels", {}).get("alertname", "No title (check Web Title Template)") }}
"""
web_image_url = None
web_message = """\
{{- payload.message }}
{%- if "status" in payload %}
**Status**: {{ payload.status }}
{% endif -%}
**Labels:** {% for k, v in payload["labels"].items() %}
*{{ k }}*: {{ v }}{% endfor %}
**Annotations:**
{%- for k, v in payload.get("annotations", {}).items() %}
{#- render annotation as markdown url if it starts with http #}
*{{ k }}*: {% if v.startswith("http") %} [here]({{v}}){% else %} {{v}} {% endif -%}
{% endfor %}
""" # noqa:W291
sms_title = web_title
web_image_url = slack_image_url
phone_call_title = """{{ payload.groupLabels|join(", ") }}"""
sms_title = '{{ payload.get("labels", {}).get("alertname", "Title undefined") }}'
phone_call_title = sms_title
telegram_title = sms_title
telegram_title = web_title
telegram_message = """\
{{- payload.messsage }}
{%- if "status" in payload -%}
<b>Status</b>: {{ payload.status }}
{% endif -%}
<b>Labels:</b> {% for k, v in payload["labels"].items() %}
{{ k }}: {{ v }}{% endfor %}
<b>Annotations:</b>
{%- for k, v in payload.get("annotations", {}).items() %}
{#- render annotation as markdown url if it starts with http #}
{{ k }}: {{ v }}
{% endfor %}""" # noqa:W291
{%- set annotations = payload.commonAnnotations.copy() -%}
telegram_image_url = slack_image_url
{% set severity = payload.groupLabels.severity -%}
{% if severity %}
{%- set severity_emoji = {"critical": ":rotating_light:", "warning": ":warning:" }[severity] | default(":question:") -%}
Severity: {{ severity }} {{ severity_emoji }}
{% endif %}
source_link = "{{ payload.generatorURL }}"
{%- set status = payload.status | default("Unknown") %}
{%- set status_emoji = {"firing": ":fire:", "resolved": ":white_check_mark:"}[status] | default(":warning:") %}
Status: {{ status }} {{ status_emoji }} (on the source)
{% if status == "firing" %}
Firing alerts {{ payload.numFiring }}
Resolved alerts {{ payload.numResolved }}
{% endif %}
grouping_id = web_title
{% if "runbook_url" in annotations -%}
<a href='{{ annotations.runbook_url }}'>:book: Runbook:link:</a>
{%- set _ = annotations.pop('runbook_url') -%}
{%- endif %}
resolve_condition = """\
{{ payload.get("status", "") == "resolved" }}
{%- if "runbook_url_internal" in annotations -%}
<a href='{{ annotations.runbook_url_internal }}'>:closed_book: Runbook (internal):link:</a>
{%- set _ = annotations.pop('runbook_url_internal') -%}
{%- endif %}
GroupLabels:
{%- for k, v in payload["groupLabels"].items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% if payload["commonLabels"] | length > 0 -%}
CommonLabels:
{%- for k, v in payload["commonLabels"].items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% endif %}
{% if annotations | length > 0 -%}
Annotations:
{%- for k, v in annotations.items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% endif %}
<a href='{{ source_link }}'>View in AlertManager</a>
"""
acknowledge_condition = None
telegram_image_url = None
example_payload = {
"receiver": "amixr",
"status": "firing",
"alerts": [
{
"status": "firing",
"labels": {
"alertname": "TestAlert",
"region": "eu-1",
},
"annotations": {"description": "This alert was sent by user for demonstration purposes"},
"startsAt": "2018-12-25T15:47:47.377363608Z",
"endsAt": "0001-01-01T00:00:00Z",
"labels": {
"job": "node",
"group": "production",
"instance": "localhost:8081",
"severity": "critical",
"alertname": "InstanceDown",
},
"status": "firing",
"startsAt": "2023-06-12T08:24:38.326Z",
"annotations": {
"title": "Instance localhost:8081 down",
"description": "localhost:8081 of job node has been down for more than 1 minute.",
},
"fingerprint": "f404ecabc8dd5cd7",
"generatorURL": "",
"amixr_demo": True,
}
},
{
"endsAt": "0001-01-01T00:00:00Z",
"labels": {
"job": "node",
"group": "canary",
"instance": "localhost:8082",
"severity": "critical",
"alertname": "InstanceDown",
},
"status": "firing",
"startsAt": "2023-06-12T08:24:38.326Z",
"annotations": {
"title": "Instance localhost:8082 down",
"description": "localhost:8082 of job node has been down for more than 1 minute.",
},
"fingerprint": "f8f08d4e32c61a9d",
"generatorURL": "",
},
{
"endsAt": "0001-01-01T00:00:00Z",
"labels": {
"job": "node",
"group": "production",
"instance": "localhost:8083",
"severity": "critical",
"alertname": "InstanceDown",
},
"status": "firing",
"startsAt": "2023-06-12T08:24:38.326Z",
"annotations": {
"title": "Instance localhost:8083 down",
"description": "localhost:8083 of job node has been down for more than 1 minute.",
},
"fingerprint": "39f38c0611ee7abd",
"generatorURL": "",
},
],
"groupLabels": {},
"commonLabels": {},
"commonAnnotations": {},
"externalURL": "http://f1d1ef51d710:9093",
"status": "firing",
"version": "4",
"groupKey": "{}:{}",
"groupKey": '{}:{alertname="InstanceDown"}',
"receiver": "combo",
"numFiring": 3,
"externalURL": "",
"groupLabels": {"alertname": "InstanceDown"},
"numResolved": 0,
"commonLabels": {"job": "node", "severity": "critical", "alertname": "InstanceDown"},
"truncatedAlerts": 0,
"commonAnnotations": {},
}

View file

@ -0,0 +1,285 @@
# Main
enabled = True
title = "(Legacy) AlertManager"
slug = "legacy_alertmanager"
short_description = "Prometheus"
is_displayed_on_web = True
is_featured = False
is_able_to_autoresolve = True
is_demo_alert_enabled = True
based_on_alertmanager = True
description = None
# Web
web_title = """{{- payload.get("labels", {}).get("alertname", "No title (check Title Template)") -}}"""
web_message = """\
{%- set annotations = payload.annotations.copy() -%}
{%- set labels = payload.labels.copy() -%}
{%- if "summary" in annotations %}
{{ annotations.summary }}
{%- set _ = annotations.pop('summary') -%}
{%- endif %}
{%- if "message" in annotations %}
{{ annotations.message }}
{%- set _ = annotations.pop('message') -%}
{%- endif %}
{% set severity = labels.severity | default("Unknown") -%}
{%- set severity_emoji = {"critical": ":rotating_light:", "warning": ":warning:" }[severity] | default(":question:") -%}
Severity: {{ severity }} {{ severity_emoji }}
{%- set status = payload.status | default("Unknown") %}
{%- set status_emoji = {"firing": ":fire:", "resolved": ":white_check_mark:"}[status] | default(":warning:") %}
Status: {{ status }} {{ status_emoji }} (on the source)
{% if "runbook_url" in annotations -%}
[:book: Runbook:link:]({{ annotations.runbook_url }})
{%- set _ = annotations.pop('runbook_url') -%}
{%- endif %}
{%- if "runbook_url_internal" in annotations -%}
[:closed_book: Runbook (internal):link:]({{ annotations.runbook_url_internal }})
{%- set _ = annotations.pop('runbook_url_internal') -%}
{%- endif %}
:label: Labels:
{%- for k, v in payload["labels"].items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% if annotations | length > 0 -%}
:pushpin: Other annotations:
{%- for k, v in annotations.items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% endif %}
""" # noqa: W291
web_image_url = None
# Behaviour
source_link = "{{ payload.generatorURL }}"
grouping_id = "{{ payload.labels }}"
resolve_condition = """{{ payload.status == "resolved" }}"""
acknowledge_condition = None
# Slack
slack_title = """\
{% set title = payload.get("labels", {}).get("alertname", "No title (check Title Template)") %}
{# Combine the title from different built-in variables into slack-formatted url #}
*<{{ grafana_oncall_link }}|#{{ grafana_oncall_incident_id }} {{ title }}>* via {{ integration_name }}
{% if source_link %}
(*<{{ source_link }}|source>*)
{%- endif %}
"""
# default slack message template is identical to web message template, except urls
# It can be based on web message template (see example), but it can affect existing templates
# slack_message = """
# {% set mkdwn_link_regex = "\[([\w\s\d:]+)\]\((https?:\/\/[\w\d./?=#]+)\)" %}
# {{ web_message
# | regex_replace(mkdwn_link_regex, "<\\2|\\1>")
# }}
# """
slack_message = """\
{%- set annotations = payload.annotations.copy() -%}
{%- set labels = payload.labels.copy() -%}
{%- if "summary" in annotations %}
{{ annotations.summary }}
{%- set _ = annotations.pop('summary') -%}
{%- endif %}
{%- if "message" in annotations %}
{{ annotations.message }}
{%- set _ = annotations.pop('message') -%}
{%- endif %}
{# Optionally set oncall_slack_user_group to slack user group in the following format "@users-oncall" #}
{%- set oncall_slack_user_group = None -%}
{%- if oncall_slack_user_group %}
Heads up {{ oncall_slack_user_group }}
{%- endif %}
{% set severity = labels.severity | default("Unknown") -%}
{%- set severity_emoji = {"critical": ":rotating_light:", "warning": ":warning:" }[severity] | default(":question:") -%}
Severity: {{ severity }} {{ severity_emoji }}
{%- set status = payload.status | default("Unknown") %}
{%- set status_emoji = {"firing": ":fire:", "resolved": ":white_check_mark:"}[status] | default(":warning:") %}
Status: {{ status }} {{ status_emoji }} (on the source)
{% if "runbook_url" in annotations -%}
<{{ annotations.runbook_url }}|:book: Runbook:link:>
{%- set _ = annotations.pop('runbook_url') -%}
{%- endif %}
{%- if "runbook_url_internal" in annotations -%}
<{{ annotations.runbook_url_internal }}|:closed_book: Runbook (internal):link:>
{%- set _ = annotations.pop('runbook_url_internal') -%}
{%- endif %}
:label: Labels:
{%- for k, v in payload["labels"].items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% if annotations | length > 0 -%}
:pushpin: Other annotations:
{%- for k, v in annotations.items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% endif %}
""" # noqa: W291
slack_image_url = None
# SMS
sms_title = web_title
# Phone
phone_call_title = web_title
# Telegram
telegram_title = web_title
# default telegram message template is identical to web message template, except urls
# It can be based on web message template (see example), but it can affect existing templates
# telegram_message = """
# {% set mkdwn_link_regex = "\[([\w\s\d:]+)\]\((https?:\/\/[\w\d./?=#]+)\)" %}
# {{ web_message
# | regex_replace(mkdwn_link_regex, "<a href='\\2'>\\1</a>")
# }}
# """
telegram_message = """\
{%- set annotations = payload.annotations.copy() -%}
{%- set labels = payload.labels.copy() -%}
{%- if "summary" in annotations %}
{{ annotations.summary }}
{%- set _ = annotations.pop('summary') -%}
{%- endif %}
{%- if "message" in annotations %}
{{ annotations.message }}
{%- set _ = annotations.pop('message') -%}
{%- endif %}
{% set severity = labels.severity | default("Unknown") -%}
{%- set severity_emoji = {"critical": ":rotating_light:", "warning": ":warning:" }[severity] | default(":question:") -%}
Severity: {{ severity }} {{ severity_emoji }}
{%- set status = payload.status | default("Unknown") %}
{%- set status_emoji = {"firing": ":fire:", "resolved": ":white_check_mark:"}[status] | default(":warning:") %}
Status: {{ status }} {{ status_emoji }} (on the source)
{% if "runbook_url" in annotations -%}
<a href='{{ annotations.runbook_url }}'>:book: Runbook:link:</a>
{%- set _ = annotations.pop('runbook_url') -%}
{%- endif %}
{%- if "runbook_url_internal" in annotations -%}
<a href='{{ annotations.runbook_url_internal }}'>:closed_book: Runbook (internal):link:</a>
{%- set _ = annotations.pop('runbook_url_internal') -%}
{%- endif %}
:label: Labels:
{%- for k, v in payload["labels"].items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% if annotations | length > 0 -%}
:pushpin: Other annotations:
{%- for k, v in annotations.items() %}
- {{ k }}: {{ v }}
{%- endfor %}
{% endif %}
""" # noqa: W291
telegram_image_url = None
tests = {
"payload": {
"endsAt": "0001-01-01T00:00:00Z",
"labels": {
"job": "kube-state-metrics",
"instance": "10.143.139.7:8443",
"job_name": "email-tracking-perform-initialization-1.0.50",
"severity": "warning",
"alertname": "KubeJobCompletion",
"namespace": "default",
"prometheus": "monitoring/k8s",
},
"status": "firing",
"startsAt": "2019-12-13T08:57:35.095800493Z",
"annotations": {
"message": "Job default/email-tracking-perform-initialization-1.0.50 is taking more than one hour to complete.",
"runbook_url": "https://github.com/kubernetes-monitoring/kubernetes-mixin/tree/master/runbook.md#alert-name-kubejobcompletion",
},
"generatorURL": (
"https://localhost/prometheus/graph?g0.expr=kube_job_spec_completions%7Bjob%3D%22kube-state-metrics%22%7D"
"+-+kube_job_status_succeeded%7Bjob%3D%22kube-state-metrics%22%7D+%3E+0&g0.tab=1"
),
},
"slack": {
"title": (
"*<{web_link}|#1 KubeJobCompletion>* via {integration_name} "
"(*<"
"https://localhost/prometheus/graph?g0.expr=kube_job_spec_completions%7Bjob%3D%22kube-state-metrics%22%7D"
"+-+kube_job_status_succeeded%7Bjob%3D%22kube-state-metrics%22%7D+%3E+0&g0.tab=1"
"|source>*)"
),
"message": "\nJob default/email-tracking-perform-initialization-1.0.50 is taking more than one hour to complete.\n\n\n\nSeverity: warning :warning:\nStatus: firing :fire: (on the source)\n\n<https://github.com/kubernetes-monitoring/kubernetes-mixin/tree/master/runbook.md#alert-name-kubejobcompletion|:book: Runbook:link:>\n\n:label: Labels:\n- job: kube-state-metrics\n- instance: 10.143.139.7:8443\n- job_name: email-tracking-perform-initialization-1.0.50\n- severity: warning\n- alertname: KubeJobCompletion\n- namespace: default\n- prometheus: monitoring/k8s\n\n", # noqa
"image_url": None,
},
"web": {
"title": "KubeJobCompletion",
"message": '<p>Job default/email-tracking-perform-initialization-1.0.50 is taking more than one hour to complete. </p>\n<p>Severity: warning ⚠️ <br/>\nStatus: firing 🔥 (on the source) </p>\n<p><a href="https://github.com/kubernetes-monitoring/kubernetes-mixin/tree/master/runbook.md#alert-name-kubejobcompletion" rel="nofollow noopener" target="_blank">📖 Runbook🔗</a> </p>\n<p>🏷️ Labels: </p>\n<ul>\n<li>job: kube-state-metrics </li>\n<li>instance: 10.143.139.7:8443 </li>\n<li>job_name: email-tracking-perform-initialization-1.0.50 </li>\n<li>severity: warning </li>\n<li>alertname: KubeJobCompletion </li>\n<li>namespace: default </li>\n<li>prometheus: monitoring/k8s </li>\n</ul>', # noqa
"image_url": None,
},
"sms": {
"title": "KubeJobCompletion",
},
"phone_call": {
"title": "KubeJobCompletion",
},
"telegram": {
"title": "KubeJobCompletion",
"message": "\nJob default/email-tracking-perform-initialization-1.0.50 is taking more than one hour to complete.\n\nSeverity: warning ⚠️\nStatus: firing 🔥 (on the source)\n\n<a href='https://github.com/kubernetes-monitoring/kubernetes-mixin/tree/master/runbook.md#alert-name-kubejobcompletion'>📖 Runbook🔗</a>\n\n🏷️ Labels:\n- job: kube-state-metrics\n- instance: 10.143.139.7:8443\n- job_name: email-tracking-perform-initialization-1.0.50\n- severity: warning\n- alertname: KubeJobCompletion\n- namespace: default\n- prometheus: monitoring/k8s\n\n", # noqa
"image_url": None,
},
}
# Misc
example_payload = {
"receiver": "amixr",
"status": "firing",
"alerts": [
{
"status": "firing",
"labels": {"alertname": "TestAlert", "region": "eu-1", "severity": "critical"},
"annotations": {
"message": "This is test alert",
"description": "This alert was sent by user for demonstration purposes",
"runbook_url": "https://grafana.com/",
},
"startsAt": "2018-12-25T15:47:47.377363608Z",
"endsAt": "0001-01-01T00:00:00Z",
"generatorURL": "",
"amixr_demo": True,
}
],
"groupLabels": {},
"commonLabels": {},
"commonAnnotations": {},
"externalURL": "http://f1d1ef51d710:9093",
"version": "4",
"groupKey": "{}:{}",
}

View file

@ -0,0 +1,129 @@
# Main
enabled = True
title = "(Legacy) Grafana Alerting"
slug = "legacy_grafana_alerting"
short_description = "Why I am legacy?"
is_displayed_on_web = True
is_featured = False
featured_tag_name = None
is_able_to_autoresolve = True
is_demo_alert_enabled = True
based_on_alertmanager = True
description = """ \
Alerts from Grafana Alertmanager are automatically routed to this integration.
{% for dict_item in grafana_alerting_entities %}
<br>Click <a href='{{dict_item.contact_point_url}}' target='_blank'>here</a>
to open contact point, and
<a href='{{dict_item.routes_url}}' target='_blank'>here</a>
to open Notification policy for {{dict_item.alertmanager_name}} Alertmanager.
{% endfor %}
{% if not is_finished_alerting_setup %}
<br>Creating contact points and routes for other alertmanagers...
{% endif %}
"""
# Default templates
slack_title = """\
{# Usually title is located in payload.labels.alertname #}
{% set title = payload.get("labels", {}).get("alertname", "No title (check Web Title Template)") %}
{# Combine the title from different built-in variables into slack-formatted url #}
*<{{ grafana_oncall_link }}|#{{ grafana_oncall_incident_id }} {{ title }}>* via {{ integration_name }}
{% if source_link %}
(*<{{ source_link }}|source>*)
{%- endif %}
"""
slack_message = """\
{{- payload.message }}
{%- if "status" in payload -%}
*Status*: {{ payload.status }}
{% endif -%}
*Labels:* {% for k, v in payload["labels"].items() %}
{{ k }}: {{ v }}{% endfor %}
*Annotations:*
{%- for k, v in payload.get("annotations", {}).items() %}
{#- render annotation as slack markdown url if it starts with http #}
{{ k }}: {% if v.startswith("http") %} <{{v}}|here> {% else %} {{v}} {% endif -%}
{% endfor %}
""" # noqa:W291
slack_image_url = None
web_title = """\
{# Usually title is located in payload.labels.alertname #}
{{- payload.get("labels", {}).get("alertname", "No title (check Web Title Template)") }}
"""
web_message = """\
{{- payload.message }}
{%- if "status" in payload %}
**Status**: {{ payload.status }}
{% endif -%}
**Labels:** {% for k, v in payload["labels"].items() %}
*{{ k }}*: {{ v }}{% endfor %}
**Annotations:**
{%- for k, v in payload.get("annotations", {}).items() %}
{#- render annotation as markdown url if it starts with http #}
*{{ k }}*: {% if v.startswith("http") %} [here]({{v}}){% else %} {{v}} {% endif -%}
{% endfor %}
""" # noqa:W291
web_image_url = slack_image_url
sms_title = '{{ payload.get("labels", {}).get("alertname", "Title undefined") }}'
phone_call_title = sms_title
telegram_title = sms_title
telegram_message = """\
{{- payload.messsage }}
{%- if "status" in payload -%}
<b>Status</b>: {{ payload.status }}
{% endif -%}
<b>Labels:</b> {% for k, v in payload["labels"].items() %}
{{ k }}: {{ v }}{% endfor %}
<b>Annotations:</b>
{%- for k, v in payload.get("annotations", {}).items() %}
{#- render annotation as markdown url if it starts with http #}
{{ k }}: {{ v }}
{% endfor %}""" # noqa:W291
telegram_image_url = slack_image_url
source_link = "{{ payload.generatorURL }}"
grouping_id = web_title
resolve_condition = """\
{{ payload.get("status", "") == "resolved" }}
"""
acknowledge_condition = None
example_payload = {
"receiver": "amixr",
"status": "firing",
"alerts": [
{
"status": "firing",
"labels": {
"alertname": "TestAlert",
"region": "eu-1",
},
"annotations": {"description": "This alert was sent by user for demonstration purposes"},
"startsAt": "2018-12-25T15:47:47.377363608Z",
"endsAt": "0001-01-01T00:00:00Z",
"generatorURL": "",
"amixr_demo": True,
}
],
"groupLabels": {},
"commonLabels": {},
"commonAnnotations": {},
"externalURL": "http://f1d1ef51d710:9093",
"version": "4",
"groupKey": "{}:{}",
}

View file

@ -669,10 +669,11 @@ INBOUND_EMAIL_DOMAIN = os.getenv("INBOUND_EMAIL_DOMAIN")
INBOUND_EMAIL_WEBHOOK_SECRET = os.getenv("INBOUND_EMAIL_WEBHOOK_SECRET")
INSTALLED_ONCALL_INTEGRATIONS = [
"config_integrations.alertmanager_v2",
"config_integrations.alertmanager",
"config_integrations.legacy_alertmanager",
"config_integrations.grafana",
"config_integrations.grafana_alerting",
"config_integrations.legacy_grafana_alerting",
"config_integrations.formatted_webhook",
"config_integrations.webhook",
"config_integrations.kapacitor",

View file

@ -1,13 +1,39 @@
.link {
text-decoration: none !important;
/* -----
* Flex
*/
.u-flex {
display: flex;
flex-direction: row;
}
.u-position-relative {
position: relative;
.u-align-items-center {
align-items: center;
}
.u-overflow-x-auto {
overflow-x: auto;
.u-flex-center {
justify-content: center;
align-items: center;
}
.u-flex-grow-1 {
flex-grow: 1;
}
.u-flex-gap-xs {
gap: 4px;
}
/* -----
* Margins/Paddings
*/
.u-margin-right-xs {
margin-right: 4px;
}
.u-padding-top-md {
padding-top: 16px;
}
.u-pull-right {
@ -18,9 +44,9 @@
margin-right: auto;
}
.u-break-word {
word-break: break-word;
}
/* -----
* Display
*/
.u-width-100 {
width: 100%;
@ -34,26 +60,36 @@
display: block;
}
.u-flex {
display: flex;
flex-direction: row;
/* -----
* Other
*/
.back-arrow {
padding-top: 8px;
}
.u-flex-center {
justify-content: center;
align-items: center;
.link {
text-decoration: none !important;
}
.u-flex-grow-1 {
flex-grow: 1;
.u-position-relative {
position: relative;
}
.u-align-items-center {
align-items: center;
.u-overflow-x-auto {
overflow-x: auto;
}
.u-break-word {
word-break: break-word;
}
.u-opacity,
.u-disabled {
opacity: var(--opacity);
}
.u-disabled {
opacity: var(--opacity);
cursor: not-allowed !important;
pointer-events: none;
}
@ -69,18 +105,6 @@
opacity: 15%;
}
.u-flex-xs {
gap: 4px;
}
.u-margin-right-xs {
margin-right: 4px;
}
.u-margin-right-md {
margin-right: 8px;
}
.buttons {
padding-bottom: 24px;
}

View file

@ -41,7 +41,6 @@ function renderFormControl(
) {
switch (formItem.type) {
case FormItemType.Input:
console.log({ ...register(formItem.name, formItem.validation) });
return (
<Input {...register(formItem.name, formItem.validation)} onChange={(value) => onChangeFn(undefined, value)} />
);

View file

@ -22,7 +22,6 @@ import {
} from 'models/escalation_policy/escalation_policy.types';
import { GrafanaTeamStore } from 'models/grafana_team/grafana_team';
import { OutgoingWebhookStore } from 'models/outgoing_webhook/outgoing_webhook';
import { OutgoingWebhook2Store } from 'models/outgoing_webhook_2/outgoing_webhook_2';
import { ScheduleStore } from 'models/schedule/schedule';
import { WaitDelay } from 'models/wait_delay';
import { SelectOption } from 'state/types';
@ -54,7 +53,6 @@ export interface EscalationPolicyProps extends ElementSortableProps {
isSlackInstalled: boolean;
teamStore: GrafanaTeamStore;
outgoingWebhookStore: OutgoingWebhookStore;
outgoingWebhook2Store: OutgoingWebhook2Store;
scheduleStore: ScheduleStore;
}
@ -111,8 +109,6 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
return this._renderNotifyUserGroup();
case 'schedule':
return this._renderNotifySchedule();
case 'custom_action':
return this._renderTriggerCustomAction();
case 'custom_webhook':
return this._renderTriggerCustomWebhook();
case 'num_alerts_in_window':
@ -381,39 +377,8 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
);
}
private _renderTriggerCustomAction() {
const { data, isDisabled, teamStore, outgoingWebhookStore } = this.props;
const { custom_button_trigger } = data;
return (
<WithPermissionControlTooltip key="custom-button" disableByPaywall userAction={UserActions.EscalationChainsWrite}>
<GSelect
showSearch
disabled={isDisabled}
modelName="outgoingWebhookStore"
displayField="name"
valueField="id"
placeholder="Select Webhook"
className={cx('select', 'control')}
value={custom_button_trigger}
onChange={this._getOnChangeHandler('custom_button_trigger')}
getOptionLabel={(item: SelectableValue) => {
const team = teamStore.items[outgoingWebhookStore.items[item.value].team];
return (
<>
<Text>{item.label} </Text>
<TeamName team={team} size="small" />
</>
);
}}
width={'auto'}
/>
</WithPermissionControlTooltip>
);
}
private _renderTriggerCustomWebhook() {
const { data, isDisabled, teamStore, outgoingWebhook2Store } = this.props;
const { data, isDisabled, teamStore, outgoingWebhookStore } = this.props;
const { custom_webhook } = data;
return (
@ -425,7 +390,7 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
<GSelect
showSearch
disabled={isDisabled}
modelName="outgoingWebhook2Store"
modelName="outgoingWebhookStore"
displayField="name"
valueField="id"
placeholder="Select Webhook"
@ -433,7 +398,7 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
value={custom_webhook}
onChange={this._getOnChangeHandler('custom_webhook')}
getOptionLabel={(item: SelectableValue) => {
const team = teamStore.items[outgoingWebhook2Store.items[item.value].team];
const team = teamStore.items[outgoingWebhookStore.items[item.value].team];
return (
<>
<Text>{item.label} </Text>
@ -443,7 +408,7 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
}}
width={'auto'}
filterOptions={(id) => {
const webhook = outgoingWebhook2Store.items[id];
const webhook = outgoingWebhookStore.items[id];
return webhook.trigger_type_name === 'Escalation step';
}}
/>

View file

@ -12,7 +12,6 @@ import Text from 'components/Text/Text';
import TeamName from 'containers/TeamName/TeamName';
import { HeartGreenIcon, HeartRedIcon } from 'icons';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { AppFeature } from 'state/features';
import { useStore } from 'state/useStore';
import styles from './AlertReceiveChannelCard.module.scss';
@ -65,22 +64,20 @@ const AlertReceiveChannelCard = observer((props: AlertReceiveChannelCardProps) =
<Text type="primary" size="medium">
<Emoji className={cx('title')} text={alertReceiveChannel.verbal_name} />
</Text>
{store.hasFeature(AppFeature.Webhooks2) && (
<CopyToClipboard text={alertReceiveChannel.id}>
<IconButton
variant="primary"
tooltip={
<div>
ID {alertReceiveChannel.id}
<br />
(click to copy ID to clipboard)
</div>
}
tooltipPlacement="top"
name="info-circle"
/>
</CopyToClipboard>
)}
<CopyToClipboard text={alertReceiveChannel.id}>
<IconButton
variant="primary"
tooltip={
<div>
ID {alertReceiveChannel.id}
<br />
(click to copy ID to clipboard)
</div>
}
tooltipPlacement="top"
name="info-circle"
/>
</CopyToClipboard>
{alertReceiveChannelCounter && (
<PluginLink
query={{ page: 'alert-groups', integration: alertReceiveChannel.id }}

View file

@ -90,7 +90,6 @@ const EscalationChainSteps = observer((props: EscalationChainStepsProps) => {
teamStore={store.grafanaTeamStore}
scheduleStore={store.scheduleStore}
outgoingWebhookStore={store.outgoingWebhookStore}
outgoingWebhook2Store={store.outgoingWebhook2Store}
isDisabled={isDisabled}
/>
);

View file

@ -123,7 +123,7 @@ const CollapsedIntegrationRouteDisplay: React.FC<CollapsedIntegrationRouteDispla
)}
<div className={cx('collapsedRoute__item')}>
<div className={cx('u-flex', 'u-align-items-center', 'u-flex-xs')}>
<div className={cx('u-flex', 'u-align-items-center', 'u-flex-gap-xs')}>
<Icon name="list-ui-alt" />
<Text type="secondary" className={cx('u-margin-right-xs')}>
Trigger escalation chain
@ -141,7 +141,7 @@ const CollapsedIntegrationRouteDisplay: React.FC<CollapsedIntegrationRouteDispla
)}
{!escalationChain?.name && (
<div className={cx('u-flex', 'u-align-items-center', 'u-flex-xs')}>
<div className={cx('u-flex', 'u-align-items-center', 'u-flex-gap-xs')}>
<div className={cx('icon-exclamation')}>
<Icon name="exclamation-triangle" />
</div>

View file

@ -93,8 +93,10 @@ const IntegrationForm = observer((props: IntegrationFormProps) => {
const { alertReceiveChannelOptions } = alertReceiveChannelStore;
const options = alertReceiveChannelOptions
? alertReceiveChannelOptions.filter((option: AlertReceiveChannelOption) =>
option.display_name.toLowerCase().includes(filterValue.toLowerCase())
? alertReceiveChannelOptions.filter(
(option: AlertReceiveChannelOption) =>
option.display_name.toLowerCase().includes(filterValue.toLowerCase()) &&
!option.value.toLowerCase().startsWith('legacy_')
)
: [];

View file

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

View file

@ -1,312 +0,0 @@
import React, { useCallback, useState } from 'react';
import { Button, ConfirmModal, ConfirmModalProps, Drawer, HorizontalGroup, Tab, TabsBar } from '@grafana/ui';
import cn from 'classnames/bind';
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';
import { useStore } from 'state/useStore';
import { KeyValuePair } from 'utils';
import { UserActions } from 'utils/authorization';
import { PLUGIN_ROOT } from 'utils/consts';
import { form } from './OutgoingWebhook2Form.config';
import styles from 'containers/OutgoingWebhook2Form/OutgoingWebhook2Form.module.css';
const cx = cn.bind(styles);
interface OutgoingWebhook2FormProps {
id: OutgoingWebhook2['id'] | 'new';
action: WebhookFormActionType;
onHide: () => void;
onUpdate: () => void;
onDelete: () => void;
}
export const WebhookTabs = {
Settings: new KeyValuePair('Settings', 'Settings'),
LastRun: new KeyValuePair('LastRun', 'Last Run'),
};
const OutgoingWebhook2Form = observer((props: OutgoingWebhook2FormProps) => {
const history = useHistory();
const { id, action, onUpdate, onHide, onDelete } = props;
const [onFormChangeFn, setOnFormChangeFn] = useState<{ fn: (value: string) => void }>(undefined);
const [templateToEdit, setTemplateToEdit] = useState(undefined);
const [activeTab, setActiveTab] = useState<string>(
action === WebhookFormActionType.EDIT_SETTINGS ? WebhookTabs.Settings.key : WebhookTabs.LastRun.key
);
const { outgoingWebhook2Store } = useStore();
const isNew = action === WebhookFormActionType.NEW;
const isNewOrCopy = isNew || action === WebhookFormActionType.COPY;
const handleSubmit = useCallback(
(data: Partial<OutgoingWebhook2>) => {
(isNewOrCopy ? outgoingWebhook2Store.create(data) : outgoingWebhook2Store.update(id, data)).then(() => {
onHide();
onUpdate();
});
},
[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,
disabled: boolean,
renderedControl: React.ReactElement,
values,
setFormFieldValue
) => {
if (formItem.type === FormItemType.Monaco) {
return (
<div className={cx('form-row')}>
<div className={cx('form-field')}>{renderedControl}</div>
<Button
disabled={disabled}
icon="edit"
variant="secondary"
onClick={getTemplateEditClickHandler(formItem, values, setFormFieldValue)}
/>
</div>
);
}
return renderedControl;
};
if (
(action === WebhookFormActionType.EDIT_SETTINGS || action === WebhookFormActionType.VIEW_LAST_RUN) &&
!outgoingWebhook2Store.items[id]
) {
return null;
}
let data:
| OutgoingWebhook2
| {
is_webhook_enabled: boolean;
is_legacy: boolean;
};
if (isNew) {
data = { is_webhook_enabled: true, is_legacy: false };
} else if (isNewOrCopy) {
data = { ...outgoingWebhook2Store.items[id], is_legacy: false, name: '' };
} else {
data = outgoingWebhook2Store.items[id];
}
if (
(action === WebhookFormActionType.EDIT_SETTINGS || action === WebhookFormActionType.VIEW_LAST_RUN) &&
!outgoingWebhook2Store.items[id]
) {
// nothing to show if we open invalid ID for edit/last_run
return null;
}
const formElement = <GForm form={form} data={data} onSubmit={handleSubmit} onFieldRender={enrchField} />;
if (action === WebhookFormActionType.NEW || action === WebhookFormActionType.COPY) {
// show just the creation form, not the tabs
return (
<>
<Drawer scrollableContent title={'Create Outgoing Webhook'} onClose={onHide} closeOnMaskClick={false}>
<div className="webhooks__drawerContent">{renderWebhookForm()}</div>
</Drawer>
{templateToEdit && (
<WebhooksTemplateEditor
id={id}
handleSubmit={(value) => {
onFormChangeFn?.fn(value);
setTemplateToEdit(undefined);
}}
onHide={() => setTemplateToEdit(undefined)}
template={templateToEdit}
/>
)}
</>
);
}
return (
// show tabbed drawer (edit/live_run)
<>
<Drawer scrollableContent title={'Outgoing webhook details'} onClose={onHide} closeOnMaskClick={false}>
<div className={cx('webhooks__drawerContent')}>
<TabsBar>
<Tab
key={WebhookTabs.Settings.key}
onChangeTab={() => {
setActiveTab(WebhookTabs.Settings.key);
history.push(`${PLUGIN_ROOT}/outgoing_webhooks/edit/${id}`);
}}
active={activeTab === WebhookTabs.Settings.key}
label={WebhookTabs.Settings.value}
/>
<Tab
key={WebhookTabs.LastRun.key}
onChangeTab={() => {
setActiveTab(WebhookTabs.LastRun.key);
history.push(`${PLUGIN_ROOT}/outgoing_webhooks/last_run/${id}`);
}}
active={activeTab === WebhookTabs.LastRun.key}
label={WebhookTabs.LastRun.value}
/>
</TabsBar>
<WebhookTabsContent
id={id}
action={action}
activeTab={activeTab}
data={data}
handleSubmit={handleSubmit}
onDelete={onDelete}
onHide={onHide}
onUpdate={onUpdate}
formElement={formElement}
/>
</div>
</Drawer>
{templateToEdit && (
<WebhooksTemplateEditor
id={id}
handleSubmit={(value) => {
onFormChangeFn?.fn(value);
setTemplateToEdit(undefined);
}}
onHide={() => setTemplateToEdit(undefined)}
template={templateToEdit}
/>
)}
</>
);
function renderWebhookForm() {
return (
<>
<div className={cx('content')} data-testid="test__outgoingWebhook2EditForm">
<GForm form={form} data={data} onSubmit={handleSubmit} onFieldRender={enrchField} />
<div className={cx('buttons')}>
<HorizontalGroup justify={'flex-end'}>
<Button variant="secondary" onClick={onHide}>
Cancel
</Button>
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button form={form.name} type="submit" disabled={data.is_legacy}>
{isNewOrCopy ? 'Create' : 'Update'} Webhook
</Button>
</WithPermissionControlTooltip>
</HorizontalGroup>
</div>
</div>
</>
);
}
});
interface WebhookTabsProps {
id: OutgoingWebhook2['id'] | 'new';
activeTab: string;
action: WebhookFormActionType;
data:
| OutgoingWebhook2
| {
is_webhook_enabled: boolean;
is_legacy: boolean;
};
onHide: () => void;
onUpdate: () => void;
onDelete: () => void;
handleSubmit: (data: Partial<OutgoingWebhook2>) => void;
formElement: React.ReactElement;
}
const WebhookTabsContent: React.FC<WebhookTabsProps> = ({
id,
action,
activeTab,
data,
onHide,
onUpdate,
onDelete,
formElement,
}) => {
const [confirmationModal, setConfirmationModal] = useState<ConfirmModalProps>(undefined);
return (
<div className={cx('tabs__content')}>
{confirmationModal && (
<ConfirmModal {...(confirmationModal as ConfirmModalProps)} onDismiss={() => setConfirmationModal(undefined)} />
)}
{activeTab === WebhookTabs.Settings.key && (
<>
<div className={cx('content')} data-testid="test__outgoingWebhook2EditForm">
{formElement}
<div className={cx('buttons')}>
<HorizontalGroup justify={'flex-end'}>
<Button variant="secondary" onClick={onHide}>
Cancel
</Button>
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button
form={form.name}
variant="destructive"
type="button"
disabled={data.is_legacy}
onClick={() => {
setConfirmationModal({
isOpen: true,
body: 'The action cannot be undone.',
confirmText: 'Delete',
dismissText: 'Cancel',
onConfirm: onDelete,
title: `Are you sure you want to delete webhook?`,
} as ConfirmModalProps);
}}
>
Delete Webhook
</Button>
</WithPermissionControlTooltip>
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button form={form.name} type="submit" disabled={data.is_legacy}>
{action === WebhookFormActionType.NEW ? 'Create' : 'Update'} Webhook
</Button>
</WithPermissionControlTooltip>
</HorizontalGroup>
</div>
</div>
{data.is_legacy ? (
<div className={cx('content')}>
<Text type="secondary">Legacy migrated webhooks are not editable. Make a copy to make changes.</Text>
</div>
) : (
''
)}
</>
)}
{activeTab === WebhookTabs.LastRun.key && <OutgoingWebhook2Status id={id} onUpdate={onUpdate} />}
</div>
);
};
export default OutgoingWebhook2Form;

View file

@ -1,62 +0,0 @@
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
export const form: { name: string; fields: FormItem[] } = {
name: 'OutgoingWebhook',
fields: [
{
name: 'name',
type: FormItemType.Input,
validation: { required: true },
},
{
name: 'team',
label: 'Assign to team',
description:
'Assigning to the teams allows you to filter Outgoing Webhooks and configure their visibility. Go to OnCall -> Settings -> Team and Access Settings for more details',
type: FormItemType.GSelect,
extra: {
modelName: 'grafanaTeamStore',
displayField: 'name',
valueField: 'id',
showSearch: true,
allowClear: true,
},
},
{
name: 'webhook',
label: 'Webhook URL',
type: FormItemType.Input,
validation: { required: true },
},
{
name: 'user',
type: FormItemType.Input,
},
{
name: 'password',
type: FormItemType.Input,
},
{
name: 'authorization_header',
type: FormItemType.TextArea,
extra: {
rows: 5,
},
},
{
name: 'data',
getDisabled: (form_data) => Boolean(form_data?.forward_whole_payload),
type: FormItemType.TextArea,
description: 'Available variables: {{ alert_payload }}, {{ alert_group_id }}',
extra: {
rows: 9,
},
},
{
name: 'forward_whole_payload',
normalize: (value) => Boolean(value),
type: FormItemType.Switch,
description: "Forwards whole payload of the alert to the webhook's url as POST data",
},
],
};

View file

@ -18,7 +18,7 @@ export const WebhookTriggerType = {
};
export const form: { name: string; fields: FormItem[] } = {
name: 'OutgoingWebhook2',
name: 'OutgoingWebhook',
fields: [
{
name: 'name',

View file

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

View file

@ -1,14 +1,22 @@
import React, { useCallback } from 'react';
import React, { useCallback, useState } from 'react';
import { Button, Drawer, HorizontalGroup } from '@grafana/ui';
import { Button, ConfirmModal, ConfirmModalProps, Drawer, HorizontalGroup, Tab, TabsBar } from '@grafana/ui';
import cn from 'classnames/bind';
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 OutgoingWebhookStatus from 'containers/OutgoingWebhookStatus/OutgoingWebhookStatus';
import WebhooksTemplateEditor from 'containers/WebhooksTemplateEditor/WebhooksTemplateEditor';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { WebhookFormActionType } from 'pages/outgoing_webhooks/OutgoingWebhooks.types';
import { useStore } from 'state/useStore';
import { KeyValuePair } from 'utils';
import { UserActions } from 'utils/authorization';
import { PLUGIN_ROOT } from 'utils/consts';
import { form } from './OutgoingWebhookForm.config';
@ -18,53 +26,287 @@ const cx = cn.bind(styles);
interface OutgoingWebhookFormProps {
id: OutgoingWebhook['id'] | 'new';
action: WebhookFormActionType;
onHide: () => void;
onUpdate: () => void;
onDelete: () => void;
}
export const WebhookTabs = {
Settings: new KeyValuePair('Settings', 'Settings'),
LastRun: new KeyValuePair('LastRun', 'Last Run'),
};
const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
const { id, onUpdate, onHide } = props;
const history = useHistory();
const { id, action, onUpdate, onHide, onDelete } = props;
const [onFormChangeFn, setOnFormChangeFn] = useState<{ fn: (value: string) => void }>(undefined);
const [templateToEdit, setTemplateToEdit] = useState(undefined);
const [activeTab, setActiveTab] = useState<string>(
action === WebhookFormActionType.EDIT_SETTINGS ? WebhookTabs.Settings.key : WebhookTabs.LastRun.key
);
const store = useStore();
const { outgoingWebhookStore, userStore } = store;
const user = userStore.currentUser;
const data = id === 'new' ? { team: user.current_team } : outgoingWebhookStore.items[id];
const { outgoingWebhookStore } = useStore();
const isNew = action === WebhookFormActionType.NEW;
const isNewOrCopy = isNew || action === WebhookFormActionType.COPY;
const handleSubmit = useCallback(
(data: Partial<OutgoingWebhook>) => {
(id === 'new' ? outgoingWebhookStore.create(data) : outgoingWebhookStore.update(id, data)).then(() => {
(isNewOrCopy ? outgoingWebhookStore.create(data) : outgoingWebhookStore.update(id, data)).then(() => {
onHide();
onUpdate();
});
},
[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,
disabled: boolean,
renderedControl: React.ReactElement,
values,
setFormFieldValue
) => {
if (formItem.type === FormItemType.Monaco) {
return (
<div className={cx('form-row')}>
<div className={cx('form-field')}>{renderedControl}</div>
<Button
disabled={disabled}
icon="edit"
variant="secondary"
onClick={getTemplateEditClickHandler(formItem, values, setFormFieldValue)}
/>
</div>
);
}
return renderedControl;
};
if (
(action === WebhookFormActionType.EDIT_SETTINGS || action === WebhookFormActionType.VIEW_LAST_RUN) &&
!outgoingWebhookStore.items[id]
) {
return null;
}
let data:
| OutgoingWebhook
| {
is_webhook_enabled: boolean;
is_legacy: boolean;
};
if (isNew) {
data = { is_webhook_enabled: true, is_legacy: false };
} else if (isNewOrCopy) {
data = { ...outgoingWebhookStore.items[id], is_legacy: false, name: '' };
} else {
data = outgoingWebhookStore.items[id];
}
if (
(action === WebhookFormActionType.EDIT_SETTINGS || action === WebhookFormActionType.VIEW_LAST_RUN) &&
!outgoingWebhookStore.items[id]
) {
// nothing to show if we open invalid ID for edit/last_run
return null;
}
const formElement = <GForm form={form} data={data} onSubmit={handleSubmit} onFieldRender={enrchField} />;
if (action === WebhookFormActionType.NEW || action === WebhookFormActionType.COPY) {
// show just the creation form, not the tabs
return (
<>
<Drawer scrollableContent title={'Create Outgoing Webhook'} onClose={onHide} closeOnMaskClick={false}>
<div className="webhooks__drawerContent">{renderWebhookForm()}</div>
</Drawer>
{templateToEdit && (
<WebhooksTemplateEditor
id={id}
handleSubmit={(value) => {
onFormChangeFn?.fn(value);
setTemplateToEdit(undefined);
}}
onHide={() => setTemplateToEdit(undefined)}
template={templateToEdit}
/>
)}
</>
);
}
return (
<Drawer
scrollableContent
title={id === 'new' ? 'Create Outgoing Webhook' : 'Edit Outgoing Webhook'}
onClose={onHide}
closeOnMaskClick={false}
>
<div className={cx('content')} data-testid="test__outgoingWebhookEditForm">
<GForm form={form} data={data} onSubmit={handleSubmit} />
<HorizontalGroup justify="flex-end">
<Button variant="secondary" onClick={onHide}>
Cancel
</Button>
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button form={form.name} type="submit">
{id === 'new' ? 'Create' : 'Update'} Webhook
</Button>
</WithPermissionControlTooltip>
</HorizontalGroup>
</div>
</Drawer>
// show tabbed drawer (edit/live_run)
<>
<Drawer scrollableContent title={'Outgoing webhook details'} onClose={onHide} closeOnMaskClick={false}>
<div className={cx('webhooks__drawerContent')}>
<TabsBar>
<Tab
key={WebhookTabs.Settings.key}
onChangeTab={() => {
setActiveTab(WebhookTabs.Settings.key);
history.push(`${PLUGIN_ROOT}/outgoing_webhooks/edit/${id}`);
}}
active={activeTab === WebhookTabs.Settings.key}
label={WebhookTabs.Settings.value}
/>
<Tab
key={WebhookTabs.LastRun.key}
onChangeTab={() => {
setActiveTab(WebhookTabs.LastRun.key);
history.push(`${PLUGIN_ROOT}/outgoing_webhooks/last_run/${id}`);
}}
active={activeTab === WebhookTabs.LastRun.key}
label={WebhookTabs.LastRun.value}
/>
</TabsBar>
<WebhookTabsContent
id={id}
action={action}
activeTab={activeTab}
data={data}
handleSubmit={handleSubmit}
onDelete={onDelete}
onHide={onHide}
onUpdate={onUpdate}
formElement={formElement}
/>
</div>
</Drawer>
{templateToEdit && (
<WebhooksTemplateEditor
id={id}
handleSubmit={(value) => {
onFormChangeFn?.fn(value);
setTemplateToEdit(undefined);
}}
onHide={() => setTemplateToEdit(undefined)}
template={templateToEdit}
/>
)}
</>
);
function renderWebhookForm() {
return (
<>
<div className={cx('content')}>
<GForm form={form} data={data} onSubmit={handleSubmit} onFieldRender={enrchField} />
<div className={cx('buttons')}>
<HorizontalGroup justify={'flex-end'}>
<Button variant="secondary" onClick={onHide}>
Cancel
</Button>
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button form={form.name} type="submit" disabled={data.is_legacy}>
{isNewOrCopy ? 'Create' : 'Update'} Webhook
</Button>
</WithPermissionControlTooltip>
</HorizontalGroup>
</div>
</div>
</>
);
}
});
interface WebhookTabsProps {
id: OutgoingWebhook['id'] | 'new';
activeTab: string;
action: WebhookFormActionType;
data:
| OutgoingWebhook
| {
is_webhook_enabled: boolean;
is_legacy: boolean;
};
onHide: () => void;
onUpdate: () => void;
onDelete: () => void;
handleSubmit: (data: Partial<OutgoingWebhook>) => void;
formElement: React.ReactElement;
}
const WebhookTabsContent: React.FC<WebhookTabsProps> = ({
id,
action,
activeTab,
data,
onHide,
onUpdate,
onDelete,
formElement,
}) => {
const [confirmationModal, setConfirmationModal] = useState<ConfirmModalProps>(undefined);
return (
<div className={cx('tabs__content')}>
{confirmationModal && (
<ConfirmModal {...(confirmationModal as ConfirmModalProps)} onDismiss={() => setConfirmationModal(undefined)} />
)}
{activeTab === WebhookTabs.Settings.key && (
<>
<div className={cx('content')}>
{formElement}
<div className={cx('buttons')}>
<HorizontalGroup justify={'flex-end'}>
<Button variant="secondary" onClick={onHide}>
Cancel
</Button>
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button
form={form.name}
variant="destructive"
type="button"
disabled={data.is_legacy}
onClick={() => {
setConfirmationModal({
isOpen: true,
body: 'The action cannot be undone.',
confirmText: 'Delete',
dismissText: 'Cancel',
onConfirm: onDelete,
title: `Are you sure you want to delete webhook?`,
} as ConfirmModalProps);
}}
>
Delete Webhook
</Button>
</WithPermissionControlTooltip>
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button form={form.name} type="submit" disabled={data.is_legacy}>
{action === WebhookFormActionType.NEW ? 'Create' : 'Update'} Webhook
</Button>
</WithPermissionControlTooltip>
</HorizontalGroup>
</div>
</div>
{data.is_legacy ? (
<div className={cx('content')}>
<Text type="secondary">Legacy migrated webhooks are not editable. Make a copy to make changes.</Text>
</div>
) : (
''
)}
</>
)}
{activeTab === WebhookTabs.LastRun.key && <OutgoingWebhookStatus id={id} onUpdate={onUpdate} />}
</div>
);
};
export default OutgoingWebhookForm;

View file

@ -7,15 +7,15 @@ import { observer } from 'mobx-react';
import Block from 'components/GBlock/Block';
import SourceCode from 'components/SourceCode/SourceCode';
import Text from 'components/Text/Text';
import { OutgoingWebhook2 } from 'models/outgoing_webhook_2/outgoing_webhook_2.types';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { useStore } from 'state/useStore';
import styles from 'containers/OutgoingWebhook2Form/OutgoingWebhook2Form.module.css';
import styles from 'containers/OutgoingWebhookForm/OutgoingWebhookForm.module.css';
const cx = cn.bind(styles);
interface OutgoingWebhook2StatusProps {
id: OutgoingWebhook2['id'];
interface OutgoingWebhookStatusProps {
id: OutgoingWebhook['id'];
onUpdate: () => void;
}
@ -47,14 +47,14 @@ function format_response_field(str) {
}
}
const OutgoingWebhook2Status = observer((props: OutgoingWebhook2StatusProps) => {
const OutgoingWebhookStatus = observer((props: OutgoingWebhookStatusProps) => {
const { id } = props;
const store = useStore();
const { outgoingWebhook2Store } = store;
const { outgoingWebhookStore } = store;
const data = outgoingWebhook2Store.items[id];
const data = outgoingWebhookStore.items[id];
return (
<div className={cx('content')}>
@ -119,4 +119,4 @@ const OutgoingWebhook2Status = observer((props: OutgoingWebhook2StatusProps) =>
);
});
export default OutgoingWebhook2Status;
export default OutgoingWebhookStatus;

View file

@ -7,7 +7,7 @@ import { observer } from 'mobx-react';
import Text from 'components/Text/Text';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { Alert } from 'models/alertgroup/alertgroup.types';
import { OutgoingWebhook2 } from 'models/outgoing_webhook_2/outgoing_webhook_2.types';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { useStore } from 'state/useStore';
import { openErrorNotification } from 'utils';
import { useDebouncedCallback } from 'utils/hooks';
@ -25,7 +25,7 @@ interface TemplatePreviewProps {
payload?: JSON;
alertReceiveChannelId: AlertReceiveChannel['id'];
alertGroupId?: Alert['pk'];
outgoingWebhookId?: OutgoingWebhook2['id'];
outgoingWebhookId?: OutgoingWebhook['id'];
templatePage: TEMPLATE_PAGE;
}
interface ConditionalResult {
@ -55,11 +55,11 @@ const TemplatePreview = observer((props: TemplatePreviewProps) => {
const [conditionalResult, setConditionalResult] = useState<ConditionalResult>({});
const store = useStore();
const { alertReceiveChannelStore, alertGroupStore, outgoingWebhook2Store } = store;
const { alertReceiveChannelStore, alertGroupStore, outgoingWebhookStore } = store;
const handleTemplateBodyChange = useDebouncedCallback(() => {
(templatePage === TEMPLATE_PAGE.Webhooks
? outgoingWebhook2Store.renderPreview(outgoingWebhookId, templateName, templateBody, payload)
? outgoingWebhookStore.renderPreview(outgoingWebhookId, templateName, templateBody, payload)
: alertGroupId
? alertGroupStore.renderPreview(alertGroupId, templateName, templateBody)
: alertReceiveChannelStore.renderPreview(alertReceiveChannelId, templateName, templateBody, payload)

View file

@ -9,13 +9,13 @@ import Text from 'components/Text/Text';
import styles from 'containers/IntegrationTemplate/IntegrationTemplate.module.scss';
import TemplatePreview, { TEMPLATE_PAGE } from 'containers/TemplatePreview/TemplatePreview';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { OutgoingWebhook2 } from 'models/outgoing_webhook_2/outgoing_webhook_2.types';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
const cx = cn.bind(styles);
interface ResultProps {
alertReceiveChannelId?: AlertReceiveChannel['id'];
outgoingWebhookId?: OutgoingWebhook2['id'];
outgoingWebhookId?: OutgoingWebhook['id'];
templateBody: string;
template: TemplateForEdit;
isAlertGroupExisting?: boolean;

View file

@ -11,7 +11,7 @@ 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 { OutgoingWebhook2, OutgoingWebhook2Response } from 'models/outgoing_webhook_2/outgoing_webhook_2.types';
import { OutgoingWebhook, OutgoingWebhookResponse } from 'models/outgoing_webhook/outgoing_webhook.types';
import { useStore } from 'state/useStore';
import styles from './TemplatesAlertGroupsList.module.css';
@ -29,7 +29,7 @@ interface TemplatesAlertGroupsListProps {
templatePage: TEMPLATE_PAGE;
templates: AlertTemplatesDTO[];
alertReceiveChannelId?: AlertReceiveChannel['id'];
outgoingwebhookId?: OutgoingWebhook2['id'];
outgoingwebhookId?: OutgoingWebhook['id'];
heading?: string;
onSelectAlertGroup?: (alertGroup: Alert) => void;
@ -52,7 +52,7 @@ const TemplatesAlertGroupsList = (props: TemplatesAlertGroupsListProps) => {
const store = useStore();
const [alertGroupsList, setAlertGroupsList] = useState(undefined);
const [outgoingWebhookLastResponses, setOutgoingWebhookLastResponses] =
useState<OutgoingWebhook2Response[]>(undefined);
useState<OutgoingWebhookResponse[]>(undefined);
const [selectedTitle, setSelectedTitle] = useState<string>(undefined);
const [selectedPayload, setSelectedPayload] = useState<string>(undefined);
@ -61,7 +61,7 @@ const TemplatesAlertGroupsList = (props: TemplatesAlertGroupsListProps) => {
useEffect(() => {
if (templatePage === TEMPLATE_PAGE.Webhooks) {
if (outgoingwebhookId !== 'new') {
store.outgoingWebhook2Store.getLastResponses(outgoingwebhookId).then(setOutgoingWebhookLastResponses);
store.outgoingWebhookStore.getLastResponses(outgoingwebhookId).then(setOutgoingWebhookLastResponses);
}
} else if (templatePage === TEMPLATE_PAGE.Integrations) {
store.alertGroupStore.getAlertGroupsForIntegration(alertReceiveChannelId).then((result) => {
@ -117,7 +117,7 @@ const TemplatesAlertGroupsList = (props: TemplatesAlertGroupsListProps) => {
// for Outgoing webhooks
const handleOutgoingWebhookResponseSelect = (response: OutgoingWebhook2Response) => {
const handleOutgoingWebhookResponseSelect = (response: OutgoingWebhookResponse) => {
setSelectedTitle(response.timestamp);
setSelectedPayload(JSON.parse(response.event_data));

View file

@ -12,7 +12,7 @@ import styles from 'containers/IntegrationTemplate/IntegrationTemplate.module.sc
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 { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { waitForElement } from 'utils/DOM';
import { UserActions } from 'utils/authorization';
@ -27,7 +27,7 @@ interface Template {
interface WebhooksTemplateEditorProps {
template: Template;
id: OutgoingWebhook2['id'];
id: OutgoingWebhook['id'];
onHide: () => void;
handleSubmit: (template: string) => void;
}

View file

@ -1,12 +0,0 @@
import { AlertReceiveChannel } from './alert_receive_channel/alert_receive_channel.types';
export interface ActionDTO {
id: string;
name: string;
webhook: string;
user: string;
password: string;
alert_receive_channel: AlertReceiveChannel['id'];
data: string;
authorization_header: string;
}

View file

@ -1,7 +1,6 @@
import { omit } from 'lodash-es';
import { action, observable } from 'mobx';
import { ActionDTO } from 'models/action';
import { AlertTemplatesDTO } from 'models/alert_templates';
import { Alert } from 'models/alertgroup/alertgroup.types';
import BaseStore from 'models/base_store';
@ -228,6 +227,13 @@ export class AlertReceiveChannelStore extends BaseStore {
};
}
@action
async migrateChannel(id: AlertReceiveChannel['id']) {
return await makeRequest(`/alert_receive_channels/${id}/migrate`, {
method: 'POST',
});
}
@action
async createChannelFilter(data: Partial<ChannelFilter>) {
return await makeRequest('/channel_filters/', {
@ -359,28 +365,6 @@ export class AlertReceiveChannelStore extends BaseStore {
};
}
@action
async updateCustomButtons(alertReceiveChannelId: AlertReceiveChannel['id']) {
const response = await makeRequest(`/custom_buttons/`, {
params: {
alert_receive_channel: alertReceiveChannelId,
},
withCredentials: true,
});
this.actions = {
...this.actions,
[alertReceiveChannelId]: response,
};
}
async deleteCustomButton(id: ActionDTO['id']) {
await makeRequest(`/custom_buttons/${id}/`, {
method: 'DELETE',
withCredentials: true,
});
}
async getAccessLogs(alertReceiveChannelId: AlertReceiveChannel['id']) {
const { integration_log } = await makeRequest(`/alert_receive_channel_access_log/${alertReceiveChannelId}/`, {});

View file

@ -10,7 +10,7 @@ export enum MaintenanceMode {
export interface AlertReceiveChannelOption {
display_name: string;
value: number;
value: string;
featured: boolean;
short_description: string;
featured_tag_name: string;

View file

@ -2,7 +2,6 @@ import { Channel } from 'models/channel';
import { Schedule } from 'models/schedule/schedule.types';
import { UserGroup } from 'models/user_group/user_group.types';
import { ActionDTO } from './action';
import { ChannelFilter } from './channel_filter';
import { ScheduleDTO } from './schedule';
import { UserDTO as User } from './user';
@ -20,7 +19,6 @@ export interface EscalationPolicyType {
to_time: string | null;
notify_to_schedule: ScheduleDTO['id'] | null;
notify_to_channel: Channel['id'] | null;
custom_button_trigger: ActionDTO['id'] | null;
notify_to_group: UserGroup['id'];
notify_schedule: Schedule['id'];
}
@ -34,6 +32,5 @@ export function prepareEscalationPolicy(value: EscalationPolicyType): Escalation
from_time: null,
to_time: null,
notify_to_schedule: null,
custom_button_trigger: null,
};
}

View file

@ -1,6 +1,6 @@
import { ActionDTO } from 'models/action';
import { Channel } from 'models/channel';
import { EscalationChain } from 'models/escalation_chain/escalation_chain.types';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { Schedule } from 'models/schedule/schedule.types';
import { User } from 'models/user/user.types';
import { UserGroup } from 'models/user_group/user_group.types';
@ -17,8 +17,7 @@ export interface EscalationPolicy {
from_time: string | null;
to_time: string | null;
notify_to_channel: Channel['id'] | null;
custom_button_trigger: ActionDTO['id'] | null;
custom_webhook: ActionDTO['id'] | null;
custom_webhook: OutgoingWebhook['id'] | null;
notify_to_group: UserGroup['id'] | null;
notify_schedule: Schedule['id'] | null;
important: boolean | null;

View file

@ -13,13 +13,10 @@ export class OutgoingWebhookStore extends BaseStore {
@observable.shallow
searchResult: { [key: string]: Array<OutgoingWebhook['id']> } = {};
@observable
incidentFilters: any;
constructor(rootStore: RootStore) {
super(rootStore);
this.path = '/custom_buttons/';
this.path = '/webhooks/';
}
@action
@ -46,26 +43,11 @@ export class OutgoingWebhookStore extends BaseStore {
@action
async updateItem(id: OutgoingWebhook['id'], fromOrganization = false) {
let outgoingWebhook;
try {
outgoingWebhook = await this.getById(id, true, fromOrganization);
} catch (error) {
if (error.response.data.error_code === 'wrong_team') {
outgoingWebhook = {
id,
name: '🔒 Private outgoing webhook',
private: true,
};
}
}
if (outgoingWebhook) {
this.items = {
...this.items,
[id]: outgoingWebhook,
};
}
const response = await this.getById(id, false, fromOrganization);
this.items = {
...this.items,
[id]: response,
};
}
@action
@ -95,13 +77,6 @@ export class OutgoingWebhookStore extends BaseStore {
};
}
@action
async updateOutgoingWebhooksFilters(params: any) {
this.incidentFilters = params;
this.updateItems();
}
getSearchResult(query = '') {
if (!this.searchResult[query]) {
return undefined;
@ -109,4 +84,17 @@ export class OutgoingWebhookStore extends BaseStore {
return this.searchResult[query].map((outgoingWebhookId: OutgoingWebhook['id']) => this.items[outgoingWebhookId]);
}
async getLastResponses(id: OutgoingWebhook['id']) {
const result = await makeRequest(`${this.path}${id}/responses`, {});
return result;
}
async renderPreview(id: OutgoingWebhook['id'], template_name: string, template_body: string, payload) {
return await makeRequest(`${this.path}${id}/preview_template/`, {
method: 'POST',
data: { template_name, template_body, payload },
});
}
}

View file

@ -3,11 +3,30 @@ import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
export interface OutgoingWebhook {
authorization_header: string;
data: string;
forward_whole_payload: boolean;
forward_all: boolean;
http_method: string;
id: string;
name: string;
password: string;
team: GrafanaTeam['id'];
user: null;
webhook: string;
trigger_type: number;
trigger_type_name: string;
url: string;
username: null;
headers: string;
trigger_template: string;
last_response_log?: OutgoingWebhookResponse;
is_webhook_enabled: boolean;
is_legacy: boolean;
}
export interface OutgoingWebhookResponse {
timestamp: string;
url: string;
request_trigger: string;
request_headers: string;
request_data: string;
status_code: string;
content: string;
event_data: string;
}

View file

@ -1,110 +0,0 @@
import { action, observable } from 'mobx';
import BaseStore from 'models/base_store';
import { makeRequest } from 'network';
import { RootStore } from 'state';
import { OutgoingWebhook2 } from './outgoing_webhook_2.types';
export class OutgoingWebhook2Store extends BaseStore {
@observable.shallow
items: { [id: string]: OutgoingWebhook2 } = {};
@observable.shallow
searchResult: { [key: string]: Array<OutgoingWebhook2['id']> } = {};
@observable
incidentFilters: any;
constructor(rootStore: RootStore) {
super(rootStore);
this.path = '/webhooks/';
}
@action
async loadItem(id: OutgoingWebhook2['id'], skipErrorHandling = false): Promise<OutgoingWebhook2> {
const outgoingWebhook2 = await this.getById(id, skipErrorHandling);
this.items = {
...this.items,
[id]: outgoingWebhook2,
};
return outgoingWebhook2;
}
@action
async updateById(id: OutgoingWebhook2['id']) {
const response = await this.getById(id);
this.items = {
...this.items,
[id]: response,
};
}
@action
async updateItem(id: OutgoingWebhook2['id'], fromOrganization = false) {
const response = await this.getById(id, false, fromOrganization);
this.items = {
...this.items,
[id]: response,
};
}
@action
async updateItems(query: any = '') {
const params = typeof query === 'string' ? { search: query } : query;
const results = await makeRequest(`${this.path}`, {
params,
});
this.items = {
...this.items,
...results.reduce(
(acc: { [key: number]: OutgoingWebhook2 }, item: OutgoingWebhook2) => ({
...acc,
[item.id]: item,
}),
{}
),
};
const key = typeof query === 'string' ? query : '';
this.searchResult = {
...this.searchResult,
[key]: results.map((item: OutgoingWebhook2) => item.id),
};
}
@action
async updateOutgoingWebhooks2Filters(params: any) {
this.incidentFilters = params;
this.updateItems();
}
getSearchResult(query = '') {
if (!this.searchResult[query]) {
return undefined;
}
return this.searchResult[query].map((outgoingWebhook2Id: OutgoingWebhook2['id']) => this.items[outgoingWebhook2Id]);
}
async getLastResponses(id: OutgoingWebhook2['id']) {
const result = await makeRequest(`${this.path}${id}/responses`, {});
return result;
}
async renderPreview(id: OutgoingWebhook2['id'], template_name: string, template_body: string, payload) {
return await makeRequest(`${this.path}${id}/preview_template/`, {
method: 'POST',
data: { template_name, template_body, payload },
});
}
}

View file

@ -1,32 +0,0 @@
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
export interface OutgoingWebhook2 {
authorization_header: string;
data: string;
forward_all: boolean;
http_method: string;
id: string;
name: string;
password: string;
team: GrafanaTeam['id'];
trigger_type: number;
trigger_type_name: string;
url: string;
username: null;
headers: string;
trigger_template: string;
last_response_log?: OutgoingWebhook2Response;
is_webhook_enabled: boolean;
is_legacy: boolean;
}
export interface OutgoingWebhook2Response {
timestamp: string;
url: string;
request_trigger: string;
request_headers: string;
request_data: string;
status_code: string;
content: string;
event_data: string;
}

View file

@ -13,6 +13,7 @@ $LARGE-MARGIN: 24px;
&__heading-container {
display: flex;
gap: $FLEX-GAP;
align-items: center;
}
&__heading {
@ -52,6 +53,10 @@ $LARGE-MARGIN: 24px;
&__input-field {
margin-right: 24px;
}
&__name {
margin: 0;
}
}
.integration__actionItem {
@ -204,4 +209,4 @@ $LARGE-MARGIN: 24px;
.inline-switch {
height: 34px;
}
}

View file

@ -163,6 +163,7 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
const integration = alertReceiveChannelStore.getIntegration(alertReceiveChannel);
const alertReceiveChannelCounter = alertReceiveChannelStore.counters[id];
const isLegacyIntegration = integration && (integration?.value as string).toLowerCase().startsWith('legacy_');
return (
<PageErrorHandlingWrapper errorData={errorData} objectName="integration" pageName="Integration">
@ -194,24 +195,23 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
)}
<div className={cx('integration__heading-container')}>
<PluginLink query={{ page: 'integrations', p }}>
<PluginLink query={{ page: 'integrations', p }} className={cx('back-arrow')}>
<IconButton name="arrow-left" size="xl" />
</PluginLink>
<h1 className={cx('integration__name')}>
<h2 className={cx('integration__name')}>
<Emoji text={alertReceiveChannel.verbal_name} />
</h1>
</h2>
<IntegrationActions
alertReceiveChannel={alertReceiveChannel}
changeIsTemplateSettingsOpen={() => this.setState({ isTemplateSettingsOpen: true })}
isLegacyIntegration={isLegacyIntegration}
/>
</div>
<div className={cx('integration__subheading-container')}>
{alertReceiveChannel.description_short && (
<Text type="secondary" className={cx('integration__description')}>
{alertReceiveChannel.description_short}
</Text>
)}
{this.renderDeprecatedHeaderMaybe(integration, isLegacyIntegration)}
{this.renderDescriptionMaybe(alertReceiveChannel)}
<div className={cx('no-wrap')}>
<IntegrationHeader
@ -225,8 +225,11 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
<div className={cx('integration__description-alert')}>
<Alert
style={{ marginBottom: '0' }}
// @ts-ignore
title={<div dangerouslySetInnerHTML={{ __html: sanitize(alertReceiveChannel.description) }}></div>}
title={
(
<div dangerouslySetInnerHTML={{ __html: sanitize(alertReceiveChannel.description) }}></div>
) as any
}
severity="info"
/>
</div>
@ -275,6 +278,64 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
);
}
renderDeprecatedHeaderMaybe(integration: SelectOption, isLegacyIntegration: boolean) {
if (!isLegacyIntegration) {
return null;
}
return (
<div className="u-padding-top-md">
<Alert
severity="warning"
title={
(
<VerticalGroup>
<Text type="secondary">
We are introducing a new {getDisplayName()} integration. The existing integration is marked as Legacy
and will be migrated after 1 November 2023.
</Text>
<Text type="secondary">
To ensure a smooth transition you can migrate now using "Migrate" button in the menu on the right.
</Text>
<Text type="secondary">
Please, check{' '}
<a
href={`https://grafana.com/docs/oncall/latest/integrations/${getIntegrationName()}`}
target="_blank"
rel="noreferrer"
>
documentation
</a>{' '}
for more information.
</Text>
</VerticalGroup>
) as any
}
/>
</div>
);
function getDisplayName() {
return integration.display_name.toString().replace('(Legacy) ', '');
}
function getIntegrationName() {
return integration.value.toString().replace('legacy_', '').replace('_', '-');
}
}
renderDescriptionMaybe(alertReceiveChannel: AlertReceiveChannel) {
if (!alertReceiveChannel.description_short) {
return null;
}
return (
<Text type="secondary" className={cx('integration__description')}>
{alertReceiveChannel.description_short}
</Text>
);
}
getConfigForTreeComponent(id: string, templates: AlertTemplatesDTO[]) {
return [
{
@ -528,9 +589,7 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
.saveTemplates(id, data)
.then(() => {
openNotification('The Alert templates have been updated');
this.setState({
isEditTemplateModalOpen: undefined,
});
this.setState({ isEditTemplateModalOpen: undefined });
this.setState({ isTemplateSettingsOpen: true });
LocationHelper.update({ template: undefined, routeId: undefined }, 'partial');
})
@ -717,12 +776,14 @@ const IntegrationSendDemoPayloadModal: React.FC<IntegrationSendDemoPayloadModalP
};
interface IntegrationActionsProps {
isLegacyIntegration: boolean;
alertReceiveChannel: AlertReceiveChannel;
changeIsTemplateSettingsOpen: () => void;
}
const IntegrationActions: React.FC<IntegrationActionsProps> = ({
alertReceiveChannel,
isLegacyIntegration,
changeIsTemplateSettingsOpen,
}) => {
const { alertReceiveChannelStore } = useStore();
@ -876,6 +937,44 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
</WithPermissionControlTooltip>
)}
{isLegacyIntegration && (
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<div
className={cx('integration__actionItem')}
onClick={() =>
setConfirmModal({
isOpen: true,
title: 'Migrate Integration?',
body: (
<VerticalGroup spacing="lg">
<Text type="primary">
Are you sure you want to migrate <Emoji text={alertReceiveChannel.verbal_name} /> ?
</Text>
<VerticalGroup spacing="xs">
<Text type="secondary">- Integration internal behaviour will be changed</Text>
<Text type="secondary">
- Integration URL will stay the same, so no need to change {getMigrationDisplayName()}{' '}
configuration
</Text>
<Text type="secondary">
- Integration templates will be reset to suit the new payload
</Text>
<Text type="secondary">- It is needed to adjust routes manually to the new payload</Text>
</VerticalGroup>
</VerticalGroup>
),
onConfirm: onIntegrationMigrate,
dismissText: 'Cancel',
confirmText: 'Migrate',
})
}
>
Migrate
</div>
</WithPermissionControlTooltip>
)}
<CopyToClipboard
text={alertReceiveChannel.id}
onCopy={() => openNotification('Integration ID is copied')}
@ -900,8 +999,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
title: 'Delete Integration?',
body: (
<Text type="primary">
Are you sure you want to delete <Emoji text={alertReceiveChannel.verbal_name} />{' '}
integration?{' '}
Are you sure you want to delete <Emoji text={alertReceiveChannel.verbal_name} /> ?
</Text>
),
onConfirm: deleteIntegration,
@ -909,7 +1007,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
confirmText: 'Delete',
});
}}
style={{ width: '100%' }}
className="u-width-100"
>
<Text type="danger">
<HorizontalGroup spacing={'xs'}>
@ -929,6 +1027,33 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
</>
);
function getMigrationDisplayName() {
const name = alertReceiveChannel.integration.toLowerCase().replace('legacy_', '');
switch (name) {
case 'grafana_alerting':
return 'Grafana Alerting';
case 'alertmanager':
default:
return 'AlertManager';
}
}
function onIntegrationMigrate() {
alertReceiveChannelStore
.migrateChannel(alertReceiveChannel.id)
.then(() => {
setConfirmModal(undefined);
openNotification('Integration has been successfully migrated.');
})
.then(() =>
Promise.all([
alertReceiveChannelStore.updateItem(alertReceiveChannel.id),
alertReceiveChannelStore.updateTemplates(alertReceiveChannel.id),
])
)
.catch(() => openErrorNotification('An error has occurred. Please try again.'));
}
function showHeartbeatSettings() {
return alertReceiveChannel.is_available_for_integration_heartbeat;
}
@ -936,7 +1061,9 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
function deleteIntegration() {
alertReceiveChannelStore
.deleteAlertReceiveChannel(alertReceiveChannel.id)
.then(() => history.push(`${PLUGIN_ROOT}/integrations`));
.then(() => history.push(`${PLUGIN_ROOT}/integrations`))
.then(() => openNotification('Integration has been succesfully deleted.'))
.catch(() => openErrorNotification('An error has occurred. Please try again.'));
}
function openIntegrationSettings() {

View file

@ -1,6 +1,6 @@
import React from 'react';
import { HorizontalGroup, Button, VerticalGroup, Icon, ConfirmModal } from '@grafana/ui';
import { HorizontalGroup, Button, VerticalGroup, Icon, ConfirmModal, Tooltip } from '@grafana/ui';
import cn from 'classnames/bind';
import { debounce } from 'lodash-es';
import { observer } from 'mobx-react';
@ -26,6 +26,7 @@ import RemoteFilters from 'containers/RemoteFilters/RemoteFilters';
import TeamName from 'containers/TeamName/TeamName';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { HeartIcon, HeartRedIcon } from 'icons';
import { AlertReceiveChannelStore } from 'models/alert_receive_channel/alert_receive_channel';
import { AlertReceiveChannel, MaintenanceMode } from 'models/alert_receive_channel/alert_receive_channel.types';
import IntegrationHelper from 'pages/integration/Integration.helper';
import { PageProps, WithStoreProps } from 'state/types';
@ -126,55 +127,10 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
render() {
const { store, query } = this.props;
const { alertReceiveChannelId, page, confirmationModal } = this.state;
const { grafanaTeamStore, alertReceiveChannelStore } = store;
const { alertReceiveChannelStore } = store;
const { count, results } = alertReceiveChannelStore.getPaginatedSearchResult();
const columns = [
{
width: '35%',
title: 'Name',
key: 'name',
render: this.renderName,
},
{
width: '15%',
title: 'Status',
key: 'status',
render: (item: AlertReceiveChannel) => this.renderIntegrationStatus(item, alertReceiveChannelStore),
},
{
width: '20%',
title: 'Type',
key: 'datasource',
render: (item: AlertReceiveChannel) => this.renderDatasource(item, alertReceiveChannelStore),
},
{
width: '10%',
title: 'Maintenance',
key: 'maintenance',
render: (item: AlertReceiveChannel) => this.renderMaintenance(item),
},
{
width: '5%',
title: 'Heartbeat',
key: 'heartbeat',
render: (item: AlertReceiveChannel) => this.renderHeartbeat(item),
},
{
width: '15%',
title: 'Team',
render: (item: AlertReceiveChannel) => this.renderTeam(item, grafanaTeamStore.items),
},
{
width: '50px',
key: 'buttons',
render: (item: AlertReceiveChannel) => this.renderButtons(item),
className: cx('buttons'),
},
];
return (
<>
<div className={cx('root')}>
@ -211,7 +167,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
data-testid="integrations-table"
rowKey="id"
data={results}
columns={columns}
columns={this.getTableColumns()}
className={cx('integrations-table')}
rowClassName={cx('integrations-table-row')}
pagination={{
@ -253,10 +209,6 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
);
}
handleChangePage = (page: number) => {
this.setState({ page }, this.update);
};
renderNotFound() {
return (
<div className={cx('loader')}>
@ -286,13 +238,28 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
);
};
renderDatasource(item: AlertReceiveChannel, alertReceiveChannelStore) {
renderDatasource(item: AlertReceiveChannel, alertReceiveChannelStore: AlertReceiveChannelStore) {
const alertReceiveChannel = alertReceiveChannelStore.items[item.id];
const integration = alertReceiveChannelStore.getIntegration(alertReceiveChannel);
const isLegacyIntegration = (integration?.value as string)?.toLowerCase().startsWith('legacy_');
return (
<HorizontalGroup spacing="xs">
<IntegrationLogo scale={0.08} integration={integration} />
<Text type="secondary">{integration?.display_name}</Text>
{isLegacyIntegration ? (
<>
<Tooltip placement="top" content={'This integration has been deprecated, consider migrating it.'}>
<Icon name="info-circle" className="u-opacity" />
</Tooltip>
<Text type="secondary">
<span className="u-opacity">{integration?.display_name}</span>
</Text>
</>
) : (
<>
<IntegrationLogo scale={0.08} integration={integration} />
<Text type="secondary">{integration?.display_name}</Text>
</>
)}
</HorizontalGroup>
);
}
@ -453,6 +420,59 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
);
};
getTableColumns = () => {
const { grafanaTeamStore, alertReceiveChannelStore } = this.props.store;
return [
{
width: '35%',
title: 'Name',
key: 'name',
render: this.renderName,
},
{
width: '15%',
title: 'Status',
key: 'status',
render: (item: AlertReceiveChannel) => this.renderIntegrationStatus(item, alertReceiveChannelStore),
},
{
width: '20%',
title: 'Type',
key: 'datasource',
render: (item: AlertReceiveChannel) => this.renderDatasource(item, alertReceiveChannelStore),
},
{
width: '10%',
title: 'Maintenance',
key: 'maintenance',
render: (item: AlertReceiveChannel) => this.renderMaintenance(item),
},
{
width: '5%',
title: 'Heartbeat',
key: 'heartbeat',
render: (item: AlertReceiveChannel) => this.renderHeartbeat(item),
},
{
width: '15%',
title: 'Team',
render: (item: AlertReceiveChannel) => this.renderTeam(item, grafanaTeamStore.items),
},
{
width: '50px',
key: 'buttons',
render: (item: AlertReceiveChannel) => this.renderButtons(item),
className: cx('buttons'),
},
];
};
handleChangePage = (page: number) => {
this.setState({ page }, this.update);
};
onIntegrationEditClick = (id: AlertReceiveChannel['id']) => {
this.setState({ alertReceiveChannelId: id });
};

View file

@ -1,9 +0,0 @@
.header {
display: flex;
align-items: center;
width: 100%;
}
.filters {
margin-bottom: 20px;
}

View file

@ -1,12 +1,24 @@
import React from 'react';
import { Button, HorizontalGroup } from '@grafana/ui';
import {
Button,
ConfirmModal,
ConfirmModalProps,
HorizontalGroup,
Icon,
IconButton,
VerticalGroup,
WithContextMenu,
} from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import moment from 'moment-timezone';
import LegacyNavHeading from 'navbar/LegacyNavHeading';
import CopyToClipboard from 'react-copy-to-clipboard';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import GTable from 'components/GTable/GTable';
import HamburgerMenu from 'components/HamburgerMenu/HamburgerMenu';
import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
import {
getWrongTeamResponseInfo,
@ -14,37 +26,43 @@ import {
} from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import OutgoingWebhookForm from 'containers/OutgoingWebhookForm/OutgoingWebhookForm';
import RemoteFilters from 'containers/RemoteFilters/RemoteFilters';
import TeamName from 'containers/TeamName/TeamName';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { ActionDTO } from 'models/action';
import { FiltersValues } from 'models/filters/filters.types';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { PageProps, WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import { openErrorNotification, openNotification } from 'utils';
import { isUserActionAllowed, UserActions } from 'utils/authorization';
import { PLUGIN_ROOT } from 'utils/consts';
import styles from './OutgoingWebhooks.module.css';
import styles from './OutgoingWebhooks.module.scss';
import { WebhookFormActionType } from './OutgoingWebhooks.types';
const cx = cn.bind(styles);
interface OutgoingWebhooksProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {}
interface OutgoingWebhooksProps
extends WithStoreProps,
PageProps,
RouteComponentProps<{ id: string; action: string }> {}
interface OutgoingWebhooksState extends PageBaseState {
outgoingWebhookIdToEdit?: OutgoingWebhook['id'] | 'new';
outgoingWebhookAction?: WebhookFormActionType;
outgoingWebhookId?: OutgoingWebhook['id'];
confirmationModal: ConfirmModalProps;
}
@observer
class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWebhooksState> {
state: OutgoingWebhooksState = {
errorData: initErrorDataState(),
confirmationModal: undefined,
};
componentDidUpdate(prevProps: OutgoingWebhooksProps) {
if (prevProps.match.params.id !== this.props.match.params.id) {
if (prevProps.match.params.id !== this.props.match.params.id && !this.state.outgoingWebhookAction) {
this.parseQueryParams();
}
}
@ -52,56 +70,71 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
parseQueryParams = async () => {
this.setState((_prevState) => ({
errorData: initErrorDataState(),
outgoingWebhookIdToEdit: undefined,
outgoingWebhookId: undefined,
})); // reset state on query parse
const {
store,
match: {
params: { id },
params: { id, action },
},
} = this.props;
if (!id) {
return;
if (action) {
this.setState({ outgoingWebhookId: id, outgoingWebhookAction: convertWebhookUrlToAction(action) });
}
let outgoingWebhook: OutgoingWebhook | void = undefined;
const isNewWebhook = id === 'new';
if (!isNewWebhook) {
outgoingWebhook = await store.outgoingWebhookStore
if (isNewWebhook) {
this.setState({ outgoingWebhookId: id, outgoingWebhookAction: WebhookFormActionType.NEW });
} else if (id) {
await store.outgoingWebhookStore
.loadItem(id, true)
.catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } }));
}
if (outgoingWebhook || isNewWebhook) {
this.setState({ outgoingWebhookIdToEdit: id });
.catch((error) =>
this.setState({ errorData: { ...getWrongTeamResponseInfo(error) }, outgoingWebhookAction: undefined })
);
}
};
update = () => {
const { store } = this.props;
return store.outgoingWebhookStore.updateItems();
};
render() {
const { store, query } = this.props;
const { outgoingWebhookIdToEdit, errorData } = this.state;
const {
store,
history,
match: {
params: { id },
},
} = this.props;
const { outgoingWebhookId, outgoingWebhookAction, errorData, confirmationModal } = this.state;
const webhooks = store.outgoingWebhookStore.getSearchResult();
const columns = [
{
width: '35%',
width: '25%',
title: 'Name',
dataIndex: 'name',
render: this.renderName,
},
{
width: '10%',
title: 'Trigger type',
dataIndex: 'trigger_type_name',
},
{
width: '35%',
title: 'Url',
dataIndex: 'webhook',
title: 'URL',
dataIndex: 'url',
render: this.renderUrl,
},
{
width: '10%',
title: 'Last run',
render: this.renderLastRun,
},
{
width: '15%',
@ -109,7 +142,7 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
render: (item: OutgoingWebhook) => this.renderTeam(item, store.grafanaTeamStore.items),
},
{
width: '15%',
width: '20%',
key: 'action',
render: this.renderActionButtons,
},
@ -120,19 +153,32 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
errorData={errorData}
objectName="outgoing webhook"
pageName="outgoing_webhooks"
itemNotFoundMessage={`Outgoing webhook with id=${query?.id} is not found. Please select outgoing webhook from the list.`}
itemNotFoundMessage={`Outgoing webhook with id=${id} was not found. Please select outgoing webhook from the list.`}
>
{() => (
<>
{confirmationModal && (
<ConfirmModal
{...(confirmationModal as ConfirmModalProps)}
onDismiss={() =>
this.setState({
confirmationModal: undefined,
})
}
/>
)}
<div className={cx('root')}>
{this.renderOutgoingWebhooksFilters()}
<GTable
emptyText={webhooks ? 'No outgoing webhooks found' : 'Loading...'}
title={() => (
<div className={cx('header')}>
<LegacyNavHeading>
<Text.Title level={3}>Outgoing Webhooks</Text.Title>
</LegacyNavHeading>
<div className="header__title">
<LegacyNavHeading>
<Text.Title level={3}>Outgoing Webhooks</Text.Title>
</LegacyNavHeading>
</div>
<div className="u-pull-right">
<PluginLink
query={{ page: 'outgoing_webhooks', id: 'new' }}
@ -140,7 +186,7 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
>
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button variant="primary" icon="plus">
New outgoing webhook
Create
</Button>
</WithPermissionControlTooltip>
</PluginLink>
@ -152,11 +198,19 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
data={webhooks}
/>
</div>
{outgoingWebhookIdToEdit && (
{outgoingWebhookId && outgoingWebhookAction && (
<OutgoingWebhookForm
id={outgoingWebhookIdToEdit}
id={outgoingWebhookId}
action={outgoingWebhookAction}
onUpdate={this.update}
onHide={this.handleOutgoingWebhookFormHide}
onDelete={() => {
this.onDeleteClick(outgoingWebhookId).then(() => {
this.setState({ outgoingWebhookId: undefined, outgoingWebhookAction: undefined });
history.push(`${PLUGIN_ROOT}/outgoing_webhooks`);
});
}}
/>
)}
</>
@ -171,7 +225,7 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
<div className={cx('filters')}>
<RemoteFilters
query={query}
page="outgoing_webhooks"
page="webhooks"
grafanaTeamStore={store.grafanaTeamStore}
onChange={this.handleFiltersChange}
/>
@ -195,50 +249,198 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
return <TeamName team={teams[record.team]} />;
}
renderActionButtons = (record: ActionDTO) => {
renderActionButtons = (record: OutgoingWebhook) => {
return (
<HorizontalGroup justify="flex-end">
<WithPermissionControlTooltip key={'edit_action'} userAction={UserActions.OutgoingWebhooksWrite}>
<Button onClick={this.getEditClickHandler(record.id)} fill="text">
Edit
</Button>
</WithPermissionControlTooltip>
<WithPermissionControlTooltip key={'delete_action'} userAction={UserActions.OutgoingWebhooksWrite}>
<WithConfirm>
<Button onClick={this.getDeleteClickHandler(record.id)} fill="text" variant="destructive">
Delete
</Button>
</WithConfirm>
</WithPermissionControlTooltip>
</HorizontalGroup>
<WithContextMenu
renderMenuItems={() => (
<div className={cx('hamburgerMenu')}>
<div className={cx('hamburgerMenu__item')} onClick={() => this.onLastRunClick(record.id)}>
<WithPermissionControlTooltip key={'status_action'} userAction={UserActions.OutgoingWebhooksRead}>
<Text type="primary">View Last Run</Text>
</WithPermissionControlTooltip>
</div>
<div className={cx('hamburgerMenu__item')} onClick={() => this.onEditClick(record.id)}>
<WithPermissionControlTooltip key={'edit_action'} userAction={UserActions.OutgoingWebhooksWrite}>
<Text type="primary">Edit settings</Text>
</WithPermissionControlTooltip>
</div>
<div
className={cx('hamburgerMenu__item')}
onClick={() =>
this.setState({
confirmationModal: {
isOpen: true,
confirmText: 'Confirm',
dismissText: 'Cancel',
onConfirm: () => this.onDisableWebhook(record.id, !record.is_webhook_enabled),
title: `Are you sure you want to ${record.is_webhook_enabled ? 'disable' : 'enable'} webhook?`,
} as ConfirmModalProps,
})
}
>
<WithPermissionControlTooltip key={'disable_action'} userAction={UserActions.OutgoingWebhooksWrite}>
<Text type="primary">{record.is_webhook_enabled ? 'Disable' : 'Enable'}</Text>
</WithPermissionControlTooltip>
</div>
<div className={cx('hamburgerMenu__item')} onClick={() => this.onCopyClick(record.id)}>
<WithPermissionControlTooltip key={'copy_action'} userAction={UserActions.OutgoingWebhooksWrite}>
<Text type="primary">Make a copy</Text>
</WithPermissionControlTooltip>
</div>
<CopyToClipboard text={record.id} onCopy={() => openNotification('Webhook ID has been copied')}>
<div className={cx('hamburgerMenu__item')}>
<HorizontalGroup type="primary" spacing="xs">
<Icon name="clipboard-alt" />
<Text type="primary">UID: {record.id}</Text>
</HorizontalGroup>
</div>
</CopyToClipboard>
<div className={cx('thin-line-break')} />
<div
className={cx('hamburgerMenu__item')}
onClick={() =>
this.setState({
confirmationModal: {
isOpen: true,
confirmText: 'Confirm',
dismissText: 'Cancel',
onConfirm: () => this.onDeleteClick(record.id),
body: 'The action cannot be undone.',
title: `Are you sure you want to delete webhook?`,
} as Partial<ConfirmModalProps> as ConfirmModalProps,
})
}
>
<WithPermissionControlTooltip key={'delete_action'} userAction={UserActions.OutgoingWebhooksWrite}>
<HorizontalGroup spacing="xs">
<IconButton tooltip="Remove" tooltipPlacement="top" variant="destructive" name="trash-alt" />
<Text type="danger">Delete Webhook</Text>
</HorizontalGroup>
</WithPermissionControlTooltip>
</div>
</div>
)}
>
{({ openMenu }) => <HamburgerMenu openMenu={openMenu} listBorder={2} listWidth={225} withBackground />}
</WithContextMenu>
);
};
getDeleteClickHandler = (id: OutgoingWebhook['id']) => {
renderName(name: String) {
return (
<div className="u-break-word">
<span>{name}</span>
</div>
);
}
renderUrl(url: string) {
return (
<div className="u-break-word">
<span>{url}</span>
</div>
);
}
renderLastRun(record: OutgoingWebhook) {
const lastRunMoment = moment(record.last_response_log?.timestamp);
return !record.is_webhook_enabled ? (
<Text type="secondary">Disabled</Text>
) : (
<VerticalGroup spacing="none">
<Text type="secondary">{lastRunMoment.isValid() ? lastRunMoment.format('MMM DD, YYYY') : '-'}</Text>
<Text type="secondary">{lastRunMoment.isValid() ? lastRunMoment.format('HH:mm') : ''}</Text>
<Text type="secondary">
{lastRunMoment.isValid()
? record.last_response_log?.status_code
? 'Status: ' + record.last_response_log?.status_code
: 'Check Status'
: ''}
</Text>
</VerticalGroup>
);
}
onDeleteClick = (id: OutgoingWebhook['id']): Promise<void> => {
const { store } = this.props;
return () => {
store.alertReceiveChannelStore.deleteCustomButton(id).then(this.update);
};
return store.outgoingWebhookStore
.delete(id)
.then(this.update)
.then(() => openNotification('Webhook has been removed'))
.catch(() => openNotification('Webook could not been removed'))
.finally(() => this.setState({ confirmationModal: undefined }));
};
getEditClickHandler = (id: OutgoingWebhook['id']) => {
onEditClick = (id: OutgoingWebhook['id']) => {
const { history } = this.props;
return () => {
this.setState({ outgoingWebhookIdToEdit: id });
this.setState({ outgoingWebhookId: id, outgoingWebhookAction: WebhookFormActionType.EDIT_SETTINGS }, () =>
history.push(`${PLUGIN_ROOT}/outgoing_webhooks/edit/${id}`)
);
};
history.push(`${PLUGIN_ROOT}/outgoing_webhooks/${id}`);
onCopyClick = (id: OutgoingWebhook['id']) => {
const { history } = this.props;
this.setState({ outgoingWebhookId: id, outgoingWebhookAction: WebhookFormActionType.COPY }, () =>
history.push(`${PLUGIN_ROOT}/outgoing_webhooks/copy/${id}`)
);
};
onDisableWebhook = (id: OutgoingWebhook['id'], isEnabled: boolean) => {
const {
store: { outgoingWebhookStore },
} = this.props;
const data = {
...{ ...outgoingWebhookStore.items[id], is_webhook_enabled: isEnabled },
is_legacy: false,
};
outgoingWebhookStore
.update(id, data)
.then(() => this.update())
.then(() => openNotification(`Webhook has been ${isEnabled ? 'enabled' : 'disabled'}`))
.catch(() => openErrorNotification('Webhook could not been updated'))
.finally(() => this.setState({ confirmationModal: undefined }));
};
onLastRunClick = (id: OutgoingWebhook['id']) => {
const { history } = this.props;
this.setState({ outgoingWebhookId: id, outgoingWebhookAction: WebhookFormActionType.VIEW_LAST_RUN }, () =>
history.push(`${PLUGIN_ROOT}/outgoing_webhooks/last_run/${id}`)
);
};
handleOutgoingWebhookFormHide = () => {
const { history } = this.props;
this.setState({ outgoingWebhookIdToEdit: undefined });
this.setState({ outgoingWebhookId: undefined, outgoingWebhookAction: undefined });
history.push(`${PLUGIN_ROOT}/outgoing_webhooks`);
};
}
function convertWebhookUrlToAction(urlAction: string) {
if (urlAction === 'new') {
return WebhookFormActionType.NEW;
} else if (urlAction === 'copy') {
return WebhookFormActionType.COPY;
} else if (urlAction === 'edit') {
return WebhookFormActionType.EDIT_SETTINGS;
} else {
return WebhookFormActionType.VIEW_LAST_RUN;
}
}
export { OutgoingWebhooks };
export default withRouter(withMobXProviderContext(OutgoingWebhooks));

View file

@ -1,450 +0,0 @@
import React from 'react';
import {
Button,
ConfirmModal,
ConfirmModalProps,
HorizontalGroup,
Icon,
IconButton,
VerticalGroup,
WithContextMenu,
} from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import moment from 'moment-timezone';
import LegacyNavHeading from 'navbar/LegacyNavHeading';
import CopyToClipboard from 'react-copy-to-clipboard';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import GTable from 'components/GTable/GTable';
import HamburgerMenu from 'components/HamburgerMenu/HamburgerMenu';
import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
import {
getWrongTeamResponseInfo,
initErrorDataState,
} from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import OutgoingWebhook2Form from 'containers/OutgoingWebhook2Form/OutgoingWebhook2Form';
import RemoteFilters from 'containers/RemoteFilters/RemoteFilters';
import TeamName from 'containers/TeamName/TeamName';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { FiltersValues } from 'models/filters/filters.types';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { OutgoingWebhook2 } from 'models/outgoing_webhook_2/outgoing_webhook_2.types';
import { AppFeature } from 'state/features';
import { PageProps, WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import { openErrorNotification, openNotification } from 'utils';
import { isUserActionAllowed, UserActions } from 'utils/authorization';
import { PLUGIN_ROOT } from 'utils/consts';
import styles from './OutgoingWebhooks2.module.scss';
import { WebhookFormActionType } from './OutgoingWebhooks2.types';
const cx = cn.bind(styles);
interface OutgoingWebhooks2Props
extends WithStoreProps,
PageProps,
RouteComponentProps<{ id: string; action: string }> {}
interface OutgoingWebhooks2State extends PageBaseState {
outgoingWebhook2Action?: WebhookFormActionType;
outgoingWebhook2Id?: OutgoingWebhook2['id'];
confirmationModal: ConfirmModalProps;
}
@observer
class OutgoingWebhooks2 extends React.Component<OutgoingWebhooks2Props, OutgoingWebhooks2State> {
state: OutgoingWebhooks2State = {
errorData: initErrorDataState(),
confirmationModal: undefined,
};
componentDidUpdate(prevProps: OutgoingWebhooks2Props) {
if (prevProps.match.params.id !== this.props.match.params.id && !this.state.outgoingWebhook2Action) {
this.parseQueryParams();
}
}
parseQueryParams = async () => {
this.setState((_prevState) => ({
errorData: initErrorDataState(),
outgoingWebhook2Id: undefined,
})); // reset state on query parse
const {
store,
match: {
params: { id, action },
},
} = this.props;
if (action) {
this.setState({ outgoingWebhook2Id: id, outgoingWebhook2Action: convertWebhookUrlToAction(action) });
}
const isNewWebhook = id === 'new';
if (isNewWebhook) {
this.setState({ outgoingWebhook2Id: id, outgoingWebhook2Action: WebhookFormActionType.NEW });
} else if (id) {
await store.outgoingWebhook2Store
.loadItem(id, true)
.catch((error) =>
this.setState({ errorData: { ...getWrongTeamResponseInfo(error) }, outgoingWebhook2Action: undefined })
);
}
};
update = () => {
const { store } = this.props;
return store.outgoingWebhook2Store.updateItems();
};
render() {
const {
store,
history,
match: {
params: { id },
},
} = this.props;
const { outgoingWebhook2Id, outgoingWebhook2Action, errorData, confirmationModal } = this.state;
const webhooks = store.outgoingWebhook2Store.getSearchResult();
const columns = [
{
width: '25%',
title: 'Name',
dataIndex: 'name',
render: this.renderName,
},
{
width: '10%',
title: 'Trigger type',
dataIndex: 'trigger_type_name',
},
{
width: '35%',
title: 'URL',
dataIndex: 'url',
render: this.renderUrl,
},
{
width: '10%',
title: 'Last run',
render: this.renderLastRun,
},
{
width: '15%',
title: 'Team',
render: (item: OutgoingWebhook) => this.renderTeam(item, store.grafanaTeamStore.items),
},
{
width: '20%',
key: 'action',
render: this.renderActionButtons,
},
];
return store.hasFeature(AppFeature.Webhooks2) ? (
<PageErrorHandlingWrapper
errorData={errorData}
objectName="outgoing webhook 2"
pageName="outgoing_webhooks_2"
itemNotFoundMessage={`Outgoing webhook with id=${id} was not found. Please select outgoing webhook from the list.`}
>
{() => (
<>
{confirmationModal && (
<ConfirmModal
{...(confirmationModal as ConfirmModalProps)}
onDismiss={() =>
this.setState({
confirmationModal: undefined,
})
}
/>
)}
<div className={cx('root')}>
{this.renderOutgoingWebhooksFilters()}
<GTable
emptyText={webhooks ? 'No outgoing webhooks found' : 'Loading...'}
title={() => (
<div className={cx('header')}>
<div className="header__title">
<LegacyNavHeading>
<Text.Title level={3}>Outgoing Webhooks</Text.Title>
</LegacyNavHeading>
</div>
<div className="u-pull-right">
<PluginLink
query={{ page: 'outgoing_webhooks', id: 'new' }}
disabled={!isUserActionAllowed(UserActions.OutgoingWebhooksWrite)}
>
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button variant="primary" icon="plus">
Create
</Button>
</WithPermissionControlTooltip>
</PluginLink>
</div>
</div>
)}
rowKey="id"
columns={columns}
data={webhooks}
/>
</div>
{outgoingWebhook2Id && outgoingWebhook2Action && (
<OutgoingWebhook2Form
id={outgoingWebhook2Id}
action={outgoingWebhook2Action}
onUpdate={this.update}
onHide={this.handleOutgoingWebhookFormHide}
onDelete={() => {
this.onDeleteClick(outgoingWebhook2Id).then(() => {
this.setState({ outgoingWebhook2Id: undefined, outgoingWebhook2Action: undefined });
history.push(`${PLUGIN_ROOT}/outgoing_webhooks`);
});
}}
/>
)}
</>
)}
</PageErrorHandlingWrapper>
) : (
<Text>Outgoing webhooks 2 functionality is not enabled.</Text>
);
}
renderOutgoingWebhooksFilters() {
const { query, store } = this.props;
return (
<div className={cx('filters')}>
<RemoteFilters
query={query}
page="webhooks"
grafanaTeamStore={store.grafanaTeamStore}
onChange={this.handleFiltersChange}
/>
</div>
);
}
handleFiltersChange = (filters: FiltersValues, isOnMount) => {
const { store } = this.props;
const { outgoingWebhook2Store } = store;
outgoingWebhook2Store.updateItems(filters).then(() => {
if (isOnMount) {
this.parseQueryParams();
}
});
};
renderTeam(record: OutgoingWebhook, teams: any) {
return <TeamName team={teams[record.team]} />;
}
renderActionButtons = (record: OutgoingWebhook2) => {
return (
<WithContextMenu
renderMenuItems={() => (
<div className={cx('hamburgerMenu')}>
<div className={cx('hamburgerMenu__item')} onClick={() => this.onLastRunClick(record.id)}>
<WithPermissionControlTooltip key={'status_action'} userAction={UserActions.OutgoingWebhooksRead}>
<Text type="primary">View Last Run</Text>
</WithPermissionControlTooltip>
</div>
<div className={cx('hamburgerMenu__item')} onClick={() => this.onEditClick(record.id)}>
<WithPermissionControlTooltip key={'edit_action'} userAction={UserActions.OutgoingWebhooksWrite}>
<Text type="primary">Edit settings</Text>
</WithPermissionControlTooltip>
</div>
<div
className={cx('hamburgerMenu__item')}
onClick={() =>
this.setState({
confirmationModal: {
isOpen: true,
confirmText: 'Confirm',
dismissText: 'Cancel',
onConfirm: () => this.onDisableWebhook(record.id, !record.is_webhook_enabled),
title: `Are you sure you want to ${record.is_webhook_enabled ? 'disable' : 'enable'} webhook?`,
} as ConfirmModalProps,
})
}
>
<WithPermissionControlTooltip key={'disable_action'} userAction={UserActions.OutgoingWebhooksWrite}>
<Text type="primary">{record.is_webhook_enabled ? 'Disable' : 'Enable'}</Text>
</WithPermissionControlTooltip>
</div>
<div className={cx('hamburgerMenu__item')} onClick={() => this.onCopyClick(record.id)}>
<WithPermissionControlTooltip key={'copy_action'} userAction={UserActions.OutgoingWebhooksWrite}>
<Text type="primary">Make a copy</Text>
</WithPermissionControlTooltip>
</div>
<CopyToClipboard text={record.id} onCopy={() => openNotification('Webhook ID has been copied')}>
<div className={cx('hamburgerMenu__item')}>
<HorizontalGroup type="primary" spacing="xs">
<Icon name="clipboard-alt" />
<Text type="primary">UID: {record.id}</Text>
</HorizontalGroup>
</div>
</CopyToClipboard>
<div className={cx('thin-line-break')} />
<div
className={cx('hamburgerMenu__item')}
onClick={() =>
this.setState({
confirmationModal: {
isOpen: true,
confirmText: 'Confirm',
dismissText: 'Cancel',
onConfirm: () => this.onDeleteClick(record.id),
body: 'The action cannot be undone.',
title: `Are you sure you want to delete webhook?`,
} as Partial<ConfirmModalProps> as ConfirmModalProps,
})
}
>
<WithPermissionControlTooltip key={'delete_action'} userAction={UserActions.OutgoingWebhooksWrite}>
<HorizontalGroup spacing="xs">
<IconButton tooltip="Remove" tooltipPlacement="top" variant="destructive" name="trash-alt" />
<Text type="danger">Delete Webhook</Text>
</HorizontalGroup>
</WithPermissionControlTooltip>
</div>
</div>
)}
>
{({ openMenu }) => <HamburgerMenu openMenu={openMenu} listBorder={2} listWidth={225} withBackground />}
</WithContextMenu>
);
};
renderName(name: String) {
return (
<div className="u-break-word">
<span>{name}</span>
</div>
);
}
renderUrl(url: string) {
return (
<div className="u-break-word">
<span>{url}</span>
</div>
);
}
renderLastRun(record: OutgoingWebhook2) {
const lastRunMoment = moment(record.last_response_log?.timestamp);
return !record.is_webhook_enabled ? (
<Text type="secondary">Disabled</Text>
) : (
<VerticalGroup spacing="none">
<Text type="secondary">{lastRunMoment.isValid() ? lastRunMoment.format('MMM DD, YYYY') : '-'}</Text>
<Text type="secondary">{lastRunMoment.isValid() ? lastRunMoment.format('HH:mm') : ''}</Text>
<Text type="secondary">
{lastRunMoment.isValid()
? record.last_response_log?.status_code
? 'Status: ' + record.last_response_log?.status_code
: 'Check Status'
: ''}
</Text>
</VerticalGroup>
);
}
onDeleteClick = (id: OutgoingWebhook2['id']): Promise<void> => {
const { store } = this.props;
return store.outgoingWebhook2Store
.delete(id)
.then(this.update)
.then(() => openNotification('Webhook has been removed'))
.catch(() => openNotification('Webook could not been removed'))
.finally(() => this.setState({ confirmationModal: undefined }));
};
onEditClick = (id: OutgoingWebhook2['id']) => {
const { history } = this.props;
this.setState({ outgoingWebhook2Id: id, outgoingWebhook2Action: WebhookFormActionType.EDIT_SETTINGS }, () =>
history.push(`${PLUGIN_ROOT}/outgoing_webhooks/edit/${id}`)
);
};
onCopyClick = (id: OutgoingWebhook2['id']) => {
const { history } = this.props;
this.setState({ outgoingWebhook2Id: id, outgoingWebhook2Action: WebhookFormActionType.COPY }, () =>
history.push(`${PLUGIN_ROOT}/outgoing_webhooks/copy/${id}`)
);
};
onDisableWebhook = (id: OutgoingWebhook2['id'], isEnabled: boolean) => {
const {
store: { outgoingWebhook2Store },
} = this.props;
const data = {
...{ ...outgoingWebhook2Store.items[id], is_webhook_enabled: isEnabled },
is_legacy: false,
};
outgoingWebhook2Store
.update(id, data)
.then(() => this.update())
.then(() => openNotification(`Webhook has been ${isEnabled ? 'enabled' : 'disabled'}`))
.catch(() => openErrorNotification('Webhook could not been updated'))
.finally(() => this.setState({ confirmationModal: undefined }));
};
onLastRunClick = (id: OutgoingWebhook2['id']) => {
const { history } = this.props;
this.setState({ outgoingWebhook2Id: id, outgoingWebhook2Action: WebhookFormActionType.VIEW_LAST_RUN }, () =>
history.push(`${PLUGIN_ROOT}/outgoing_webhooks/last_run/${id}`)
);
};
handleOutgoingWebhookFormHide = () => {
const { history } = this.props;
this.setState({ outgoingWebhook2Id: undefined, outgoingWebhook2Action: undefined });
history.push(`${PLUGIN_ROOT}/outgoing_webhooks`);
};
}
function convertWebhookUrlToAction(urlAction: string) {
if (urlAction === 'new') {
return WebhookFormActionType.NEW;
} else if (urlAction === 'copy') {
return WebhookFormActionType.COPY;
} else if (urlAction === 'edit') {
return WebhookFormActionType.EDIT_SETTINGS;
} else {
return WebhookFormActionType.VIEW_LAST_RUN;
}
}
export { OutgoingWebhooks2 };
export default withRouter(withMobXProviderContext(OutgoingWebhooks2));

View file

@ -28,7 +28,6 @@ import Integration from 'pages/integration/Integration';
import Integrations from 'pages/integrations/Integrations';
import Maintenance from 'pages/maintenance/Maintenance';
import OutgoingWebhooks from 'pages/outgoing_webhooks/OutgoingWebhooks';
import OutgoingWebhooks2 from 'pages/outgoing_webhooks_2/OutgoingWebhooks2';
import Schedule from 'pages/schedule/Schedule';
import Schedules from 'pages/schedules/Schedules';
import SettingsPage from 'pages/settings/SettingsPage';
@ -37,7 +36,6 @@ import CloudPage from 'pages/settings/tabs/Cloud/CloudPage';
import LiveSettings from 'pages/settings/tabs/LiveSettings/LiveSettingsPage';
import Users from 'pages/users/Users';
import { rootStore } from 'state';
import { AppFeature } from 'state/features';
import { useStore } from 'state/useStore';
import { isUserActionAllowed } from 'utils/authorization';
@ -154,11 +152,7 @@ export const Root = observer((props: AppRootProps) => {
<Schedule query={query} basicDataLoaded={basicDataLoaded} />
</Route>
<Route path={getRoutesForPage('outgoing_webhooks')} exact>
{rootStore.hasFeature(AppFeature.Webhooks2) ? (
<OutgoingWebhooks2 query={query} />
) : (
<OutgoingWebhooks query={query} />
)}
<OutgoingWebhooks query={query} />
</Route>
<Route path={getRoutesForPage('maintenance')} exact>
<Maintenance />

View file

@ -5,5 +5,4 @@ export enum AppFeature {
CloudNotifications = 'grafana_cloud_notifications',
CloudConnection = 'grafana_cloud_connection',
WebSchedules = 'web_schedules',
Webhooks2 = 'webhooks2',
}

View file

@ -21,7 +21,6 @@ import { GrafanaTeamStore } from 'models/grafana_team/grafana_team';
import { HeartbeatStore } from 'models/heartbeat/heartbeat';
import { OrganizationStore } from 'models/organization/organization';
import { OutgoingWebhookStore } from 'models/outgoing_webhook/outgoing_webhook';
import { OutgoingWebhook2Store } from 'models/outgoing_webhook_2/outgoing_webhook_2';
import { ResolutionNotesStore } from 'models/resolution_note/resolution_note';
import { ScheduleStore } from 'models/schedule/schedule';
import { SlackStore } from 'models/slack/slack';
@ -84,15 +83,12 @@ export class RootBaseStore {
onCallApiUrl: string;
// --------------------------
userStore = new UserStore(this);
cloudStore = new CloudStore(this);
directPagingStore = new DirectPagingStore(this);
grafanaTeamStore = new GrafanaTeamStore(this);
alertReceiveChannelStore = new AlertReceiveChannelStore(this);
outgoingWebhookStore = new OutgoingWebhookStore(this);
outgoingWebhook2Store = new OutgoingWebhook2Store(this);
alertReceiveChannelFiltersStore = new AlertReceiveChannelFiltersStore(this);
escalationChainStore = new EscalationChainStore(this);
escalationPolicyStore = new EscalationPolicyStore(this);
@ -108,6 +104,7 @@ export class RootBaseStore {
apiTokenStore = new ApiTokenStore(this);
globalSettingStore = new GlobalSettingStore(this);
filtersStore = new FiltersStore(this);
// stores
async updateBasicData() {

View file

@ -90,4 +90,7 @@ spec:
{{- end }}
resources:
{{- toYaml .Values.celery.resources | nindent 12 }}
{{- with .Values.celery.extraContainers }}
{{- tpl . $ | nindent 8 }}
{{- end }}
{{- end}}

View file

@ -81,6 +81,9 @@ spec:
timeoutSeconds: 3
resources:
{{- toYaml .Values.engine.resources | nindent 12 }}
{{- with .Values.engine.extraContainers }}
{{- tpl . $ | nindent 8 }}
{{- end }}
{{- with .Values.engine.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}

View file

@ -93,4 +93,7 @@ spec:
{{- include "oncall.extraEnvs" . | nindent 12 }}
resources:
{{- toYaml .Values.engine.resources | nindent 12 }}
{{- with .Values.migrate.extraContainers }}
{{- tpl . $ | nindent 6 }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,25 @@
suite: test extra containers for celery pod
templates:
- celery/deployment-celery.yaml
release:
name: oncall
tests:
- it: celery.extraContainers="" -> should not create additional containers
set:
celery.extraContainers: ""
asserts:
- lengthEqual:
path: spec.template.spec.containers
count : 1
- it: celery.extraContainers -> should add sidecar containers
set:
celery.extraContainers: |
- name: cloud-sql-proxy
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy
asserts:
- contains:
path: spec.template.spec.containers
content:
name: cloud-sql-proxy
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy

View file

@ -0,0 +1,25 @@
suite: test extra containers for engine pod
templates:
- engine/deployment.yaml
release:
name: oncall
tests:
- it: engine.extraContainers="" -> should not create additional containers
set:
engine.extraContainers: ""
asserts:
- lengthEqual:
path: spec.template.spec.containers
count : 1
- it: engine.extraContainers -> should add sidecar containers
set:
engine.extraContainers: |
- name: cloud-sql-proxy
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy
asserts:
- contains:
path: spec.template.spec.containers
content:
name: cloud-sql-proxy
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy

View file

@ -0,0 +1,25 @@
suite: test migrate extra containers
templates:
- engine/job-migrate.yaml
release:
name: oncall
tests:
- it: migrate.extraContainers="" -> should not create additional containers
set:
migrate.extraContainers: ""
asserts:
- lengthEqual:
path: spec.template.spec.containers
count : 1
- it: migrate.extraContainers -> should add sidecar containers
set:
migrate.extraContainers: |
- name: cloud-sql-proxy
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy
asserts:
- contains:
path: spec.template.spec.containers
content:
name: cloud-sql-proxy
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy

View file

@ -65,6 +65,16 @@ engine:
## ref: https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/
priorityClassName: ""
# Extra containers which runs as sidecar
extraContainers: ""
# extraContainers: |
# - name: cloud-sql-proxy
# image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.1.2
# args:
# - --private-ip
# - --port=5432
# - example:europe-west3:grafana-oncall-db
# Celery workers pods configuration
celery:
replicaCount: 1
@ -111,6 +121,16 @@ celery:
## ref: https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/
priorityClassName: ""
# Extra containers which runs as sidecar
extraContainers: ""
# extraContainers: |
# - name: cloud-sql-proxy
# image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.1.2
# args:
# - --private-ip
# - --port=5432
# - example:europe-west3:grafana-oncall-db
oncall:
# Override default MIRAGE_CIPHER_IV (must be 16 bytes long)
# For existing installation, this should not be changed.
@ -221,6 +241,15 @@ migrate:
## ref: https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/
tolerations: []
# Extra containers which runs as sidecar
extraContainers: ""
# extraContainers: |
# - name: cloud-sql-proxy
# image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.1.2
# args:
# - --private-ip
# - --port=5432
# - example:europe-west3:grafana-oncall-db
# Sets environment variables with name capitalized and prefixed with UWSGI_, and dashes are substituted with underscores.
# see more: https://uwsgi-docs.readthedocs.io/en/latest/Configuration.html#environment-variables