commit
c68e2a5e73
89 changed files with 2189 additions and 498 deletions
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## Unreleased
|
||||
|
||||
## v1.3.62 (2023-11-28)
|
||||
|
||||
### Added
|
||||
|
||||
- Add ability to use Grafana Service Account Tokens for OnCall API (This is only enabled for resolution_notes
|
||||
endpoint currently) @mderynck ([#3189](https://github.com/grafana/oncall/pull/3189))
|
||||
- Add ability for webhook presets to mask sensitive headers @mderynck
|
||||
([#3189](https://github.com/grafana/oncall/pull/3189))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed issue that blocked saving webhooks with presets if the preset is controlling the URL @mderynck
|
||||
([#3189](https://github.com/grafana/oncall/pull/3189))
|
||||
- User filter doesn't display current value on Alert Groups page ([1714](https://github.com/grafana/oncall/issues/1714))
|
||||
- Remove displaying rotation modal for Terraform/API based schedules
|
||||
- Filters polishing ([3183](https://github.com/grafana/oncall/issues/3183))
|
||||
- Fixed permissions so User settings reader role included list users @mderynck ([#3419](https://github.com/grafana/oncall/pull/3419))
|
||||
- Fixed alert group rendering when some links were broken because of replacing `-` to `_` @Ferril ([#3424](https://github.com/grafana/oncall/pull/3424))
|
||||
|
||||
## v1.3.62 (2023-11-21)
|
||||
|
||||
### Added
|
||||
|
|
|
|||
2
Tiltfile
2
Tiltfile
|
|
@ -58,7 +58,7 @@ local_resource(
|
|||
allow_parallel=True,
|
||||
)
|
||||
|
||||
yaml = helm("helm/oncall", name=HELM_PREFIX, values=["./dev/helm-local.yml"])
|
||||
yaml = helm("helm/oncall", name=HELM_PREFIX, values=["./dev/helm-local.yml", "./dev/helm-local.dev.yml"])
|
||||
|
||||
k8s_yaml(yaml)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ weight: 500
|
|||
|
||||
Inbound Email integration will consume emails from dedicated email address and make alert groups from them.
|
||||
|
||||
## Configure required environment variables
|
||||
|
||||
See [Inbound Email Setup]({{< relref "../../open-source/_index.md#inbound-email-setup" >}}) for details.
|
||||
|
||||
## Configure Inbound Email integration for Grafana OnCall
|
||||
|
||||
You must have an Admin role to create integrations in Grafana OnCall.
|
||||
|
|
|
|||
|
|
@ -265,7 +265,9 @@ To configure Inbound Email integration for Grafana OnCall OSS populate env varia
|
|||
- `INBOUND_EMAIL_DOMAIN` - Inbound email domain
|
||||
- `INBOUND_EMAIL_WEBHOOK_SECRET` - Inbound email webhook secret
|
||||
|
||||
You will also need to configure your ESP to forward messages to the following URL: `<ONCALL_ENGINE_PUBLIC_URL>/integrations/v1/inbound_email_webhook`.
|
||||
Required secret syntax: `part1ofsecret:part2ofsecret` (The colon `:` is a mandatory delimiter separating both parts of your secret.)
|
||||
|
||||
You will also need to configure your ESP to forward messages to the following URL: `scheme://<INBOUND_EMAIL_WEBHOOK_SECRET>@<ONCALL_ENGINE_PUBLIC_URL>/integrations/v1/inbound_email_webhook`.
|
||||
|
||||
## Limits
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 4.2.7 on 2023-11-27 17:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('alerts', '0039_remove_alertreceivechannel_unique_integration_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='alertreceivechannel',
|
||||
name='alert_group_labels_custom',
|
||||
field=models.JSONField(default=None, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='alertreceivechannel',
|
||||
name='alert_group_labels_template',
|
||||
field=models.TextField(default=None, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -12,7 +12,7 @@ from django.db.models import JSONField
|
|||
from apps.alerts import tasks
|
||||
from apps.alerts.constants import TASK_DELAY_SECONDS
|
||||
from apps.alerts.incident_appearance.templaters import TemplateLoader
|
||||
from apps.labels.utils import assign_labels
|
||||
from apps.labels.alert_group_labels import assign_labels
|
||||
from common.jinja_templater import apply_jinja_template
|
||||
from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning
|
||||
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
|
||||
|
|
@ -108,7 +108,7 @@ class Alert(models.Model):
|
|||
)
|
||||
|
||||
if group_created:
|
||||
assign_labels(group, alert_receive_channel)
|
||||
assign_labels(group, alert_receive_channel, raw_request_data)
|
||||
group.log_records.create(type=AlertGroupLogRecord.TYPE_REGISTERED)
|
||||
group.log_records.create(type=AlertGroupLogRecord.TYPE_ROUTE_ASSIGNED)
|
||||
|
||||
|
|
@ -144,7 +144,9 @@ class Alert(models.Model):
|
|||
if settings.DEBUG:
|
||||
tasks.distribute_alert(alert.pk)
|
||||
else:
|
||||
tasks.distribute_alert.apply_async((alert.pk,), countdown=TASK_DELAY_SECONDS)
|
||||
transaction.on_commit(
|
||||
partial(tasks.distribute_alert.apply_async, (alert.pk,), countdown=TASK_DELAY_SECONDS)
|
||||
)
|
||||
|
||||
if group_created:
|
||||
# all code below related to maintenance mode
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ if typing.TYPE_CHECKING:
|
|||
ResolutionNoteSlackMessage,
|
||||
)
|
||||
from apps.base.models import UserNotificationPolicyLogRecord
|
||||
from apps.labels.models import AlertGroupAssociatedLabel
|
||||
from apps.slack.models import SlackMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -194,6 +195,7 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
|
|||
slack_log_message: typing.Optional["SlackMessage"]
|
||||
slack_messages: "RelatedManager['SlackMessage']"
|
||||
users: "RelatedManager['User']"
|
||||
labels: "RelatedManager['AlertGroupAssociatedLabel']"
|
||||
|
||||
objects: models.Manager["AlertGroup"] = AlertGroupQuerySet.as_manager()
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ if typing.TYPE_CHECKING:
|
|||
from django.db.models.manager import RelatedManager
|
||||
|
||||
from apps.alerts.models import AlertGroup, ChannelFilter
|
||||
from apps.labels.models import AlertReceiveChannelAssociatedLabel
|
||||
from apps.user_management.models import Organization, Team
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -87,10 +88,6 @@ def number_to_smiles_translator(number):
|
|||
return "".join(reversed(smileset))
|
||||
|
||||
|
||||
class IntegrationAlertGroupLabels(typing.TypedDict):
|
||||
inheritable: typing.Dict[str, bool]
|
||||
|
||||
|
||||
class AlertReceiveChannelQueryset(models.QuerySet):
|
||||
def delete(self):
|
||||
self.update(deleted_at=timezone.now())
|
||||
|
|
@ -123,6 +120,7 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
|
|||
channel_filters: "RelatedManager['ChannelFilter']"
|
||||
organization: "Organization"
|
||||
team: typing.Optional["Team"]
|
||||
labels: "RelatedManager['AlertReceiveChannelAssociatedLabel']"
|
||||
|
||||
objects = AlertReceiveChannelManager()
|
||||
objects_with_maintenance = AlertReceiveChannelManagerWithMaintenance()
|
||||
|
|
@ -206,6 +204,17 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
|
|||
rate_limited_in_slack_at = models.DateTimeField(null=True, default=None)
|
||||
rate_limit_message_task_id = models.CharField(max_length=100, null=True, default=None)
|
||||
|
||||
AlertGroupCustomLabels = list[tuple[str, str | None, str | None]] | None
|
||||
alert_group_labels_custom: AlertGroupCustomLabels = models.JSONField(null=True, default=None)
|
||||
"""
|
||||
Stores "custom labels" for alert group labels. Custom labels can be either "plain" or "templated".
|
||||
For plain labels, the format is: [<LABEL_KEY_ID>, <LABEL_VALUE_ID>, None]
|
||||
For templated labels, the format is: [<LABEL_KEY_ID>, None, <JINJA2_TEMPLATE>]
|
||||
"""
|
||||
|
||||
alert_group_labels_template: str | None = models.TextField(null=True, default=None)
|
||||
"""Stores a Jinja2 template for "advanced label templating" for alert group labels."""
|
||||
|
||||
def __str__(self):
|
||||
short_name_with_emojis = emojize(self.short_name, language="alias")
|
||||
return f"{self.pk}: {short_name_with_emojis}"
|
||||
|
|
@ -635,21 +644,6 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
|
|||
result["team"] = "General"
|
||||
return result
|
||||
|
||||
@property
|
||||
def alert_group_labels(self) -> IntegrationAlertGroupLabels:
|
||||
"""
|
||||
Alert group labels configuration for the integration used by AlertReceiveChannelSerializer.
|
||||
See AlertReceiveChannelAssociatedLabel.inheritable for more details.
|
||||
"""
|
||||
return {"inheritable": {label.key_id: label.inheritable for label in self.labels.all()}}
|
||||
|
||||
@alert_group_labels.setter
|
||||
def alert_group_labels(self, value: IntegrationAlertGroupLabels) -> None:
|
||||
"""Setter for alert_group_labels used by AlertReceiveChannelSerializer"""
|
||||
inheritable_key_ids = [key_id for key_id, inheritable in value["inheritable"].items() if inheritable]
|
||||
self.labels.filter(key_id__in=inheritable_key_ids).update(inheritable=True)
|
||||
self.labels.filter(~Q(key_id__in=inheritable_key_ids)).update(inheritable=False)
|
||||
|
||||
|
||||
@receiver(post_save, sender=AlertReceiveChannel)
|
||||
def listen_for_alertreceivechannel_model_save(
|
||||
|
|
|
|||
|
|
@ -7,22 +7,32 @@ from apps.alerts.tasks import distribute_alert, escalate_alert_group
|
|||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_alert_create_default_channel_filter(make_organization, make_alert_receive_channel, make_channel_filter):
|
||||
@patch("apps.alerts.tasks.distribute_alert.distribute_alert.apply_async", return_value=None)
|
||||
def test_alert_create_default_channel_filter(
|
||||
mocked_distribute_alert_task,
|
||||
make_organization,
|
||||
make_alert_receive_channel,
|
||||
make_channel_filter,
|
||||
django_capture_on_commit_callbacks,
|
||||
):
|
||||
organization = make_organization()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
channel_filter = make_channel_filter(alert_receive_channel, is_default=True)
|
||||
|
||||
alert = Alert.create(
|
||||
title="the title",
|
||||
message="the message",
|
||||
alert_receive_channel=alert_receive_channel,
|
||||
raw_request_data={},
|
||||
integration_unique_data={},
|
||||
image_url=None,
|
||||
link_to_upstream_details=None,
|
||||
)
|
||||
with django_capture_on_commit_callbacks(execute=True) as callbacks:
|
||||
alert = Alert.create(
|
||||
title="the title",
|
||||
message="the message",
|
||||
alert_receive_channel=alert_receive_channel,
|
||||
raw_request_data={},
|
||||
integration_unique_data={},
|
||||
image_url=None,
|
||||
link_to_upstream_details=None,
|
||||
)
|
||||
|
||||
assert alert.group.channel_filter == channel_filter
|
||||
assert len(callbacks) == 1
|
||||
mocked_distribute_alert_task.assert_called_once_with((alert.pk,), countdown=1)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
|
|||
15
engine/apps/api/label_filtering.py
Normal file
15
engine/apps/api/label_filtering.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from typing import List, Tuple
|
||||
|
||||
|
||||
def parse_label_query(label_query: List[str]) -> List[Tuple[str, str]]:
|
||||
"""
|
||||
parse_label_query returns list of key-value tuples from a list of "raw" labels – key-value pairs separated with ':'.
|
||||
"""
|
||||
kv_pairs = []
|
||||
for label in label_query:
|
||||
label_data = label.split(":")
|
||||
# Check if label_data is a valid key-value label pair]: ["key1", "value1"]
|
||||
if len(label_data) != 2:
|
||||
continue
|
||||
kv_pairs.append((label_data[0], label_data[1]))
|
||||
return kv_pairs
|
||||
|
|
@ -125,6 +125,7 @@ class AlertGroupListSerializer(
|
|||
PREFETCH_RELATED = [
|
||||
"dependent_alert_groups",
|
||||
"log_records__author",
|
||||
"labels",
|
||||
]
|
||||
|
||||
SELECT_RELATED = [
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from collections import OrderedDict
|
|||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db.models import Q
|
||||
from jinja2 import TemplateSyntaxError
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
|
@ -14,30 +15,191 @@ 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 apps.labels.models import LabelKeyCache, LabelValueCache
|
||||
from apps.user_management.models import Organization
|
||||
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
|
||||
from common.api_helpers.utils import CurrentTeamDefault
|
||||
from common.jinja_templater import apply_jinja_template, jinja_template_env
|
||||
from common.jinja_templater.apply_jinja_template import JinjaTemplateWarning
|
||||
from common.jinja_templater import jinja_template_env
|
||||
|
||||
from .integration_heartbeat import IntegrationHeartBeatSerializer
|
||||
from .labels import LabelsSerializerMixin
|
||||
|
||||
|
||||
def valid_jinja_template_for_serializer_method_field(template):
|
||||
for _, val in template.items():
|
||||
try:
|
||||
apply_jinja_template(val, payload={})
|
||||
except JinjaTemplateWarning:
|
||||
# Suppress warnings, template may be valid with payload
|
||||
pass
|
||||
class AlertGroupCustomLabelKey(typing.TypedDict):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class AlertGroupCustomLabelValue(typing.TypedDict):
|
||||
id: str | None # None for templated labels, label value ID for plain labels
|
||||
name: str # Jinja template for templated labels, label value name for plain labels
|
||||
|
||||
|
||||
class AlertGroupCustomLabel(typing.TypedDict):
|
||||
key: AlertGroupCustomLabelKey
|
||||
value: AlertGroupCustomLabelValue
|
||||
|
||||
|
||||
AlertGroupCustomLabels = list[AlertGroupCustomLabel]
|
||||
|
||||
|
||||
class IntegrationAlertGroupLabels(typing.TypedDict):
|
||||
inheritable: dict[str, bool]
|
||||
custom: AlertGroupCustomLabels
|
||||
template: str | None
|
||||
|
||||
|
||||
class CustomLabelSerializer(serializers.Serializer):
|
||||
"""This serializer is consistent with apps.api.serializers.labels.LabelSerializer, but allows null for value ID."""
|
||||
|
||||
class KeySerializer(serializers.Serializer):
|
||||
id = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
|
||||
class ValueSerializer(serializers.Serializer):
|
||||
# ID is null for templated labels. For such labels, the "name" value is a Jinja2 template.
|
||||
id = serializers.CharField(allow_null=True)
|
||||
name = serializers.CharField()
|
||||
|
||||
key = KeySerializer()
|
||||
value = ValueSerializer()
|
||||
|
||||
|
||||
class IntegrationAlertGroupLabelsSerializer(serializers.Serializer):
|
||||
"""Alert group labels configuration for the integration. See AlertReceiveChannel.alert_group_labels for details."""
|
||||
|
||||
inheritable = serializers.DictField(child=serializers.BooleanField())
|
||||
custom = CustomLabelSerializer(many=True)
|
||||
template = serializers.CharField(allow_null=True)
|
||||
|
||||
@staticmethod
|
||||
def pop_alert_group_labels(validated_data: dict) -> IntegrationAlertGroupLabels | None:
|
||||
"""Get alert group labels from validated data."""
|
||||
|
||||
# the "alert_group_labels" field is optional, so either all 3 fields are present or none
|
||||
if "inheritable" not in validated_data:
|
||||
return None
|
||||
|
||||
return {
|
||||
"inheritable": validated_data.pop("inheritable"),
|
||||
"custom": validated_data.pop("custom"),
|
||||
"template": validated_data.pop("template"),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def update(
|
||||
cls, instance: AlertReceiveChannel, alert_group_labels: IntegrationAlertGroupLabels | None
|
||||
) -> AlertReceiveChannel:
|
||||
if alert_group_labels is None:
|
||||
return instance
|
||||
|
||||
# update inheritable labels
|
||||
inheritable_key_ids = [
|
||||
key_id for key_id, inheritable in alert_group_labels["inheritable"].items() if inheritable
|
||||
]
|
||||
instance.labels.filter(key_id__in=inheritable_key_ids).update(inheritable=True)
|
||||
instance.labels.filter(~Q(key_id__in=inheritable_key_ids)).update(inheritable=False)
|
||||
|
||||
# update DB cache for custom labels
|
||||
cls._create_custom_labels(instance.organization, alert_group_labels["custom"])
|
||||
# update custom labels
|
||||
instance.alert_group_labels_custom = cls._custom_labels_to_internal_value(alert_group_labels["custom"])
|
||||
|
||||
# update template
|
||||
instance.alert_group_labels_template = alert_group_labels["template"]
|
||||
|
||||
instance.save(update_fields=["alert_group_labels_custom", "alert_group_labels_template"])
|
||||
return instance
|
||||
|
||||
@staticmethod
|
||||
def _create_custom_labels(organization: Organization, labels: AlertGroupCustomLabels) -> None:
|
||||
"""Create LabelKeyCache and LabelValueCache objects for custom labels."""
|
||||
|
||||
label_keys = [
|
||||
LabelKeyCache(id=label["key"]["id"], name=label["key"]["name"], organization=organization)
|
||||
for label in labels
|
||||
]
|
||||
|
||||
label_values = [
|
||||
LabelValueCache(id=label["value"]["id"], name=label["value"]["name"], key_id=label["key"]["id"])
|
||||
for label in labels
|
||||
if label["value"]["id"] # don't create LabelValueCache objects for templated labels
|
||||
]
|
||||
|
||||
LabelKeyCache.objects.bulk_create(label_keys, ignore_conflicts=True, batch_size=5000)
|
||||
LabelValueCache.objects.bulk_create(label_values, ignore_conflicts=True, batch_size=5000)
|
||||
|
||||
@classmethod
|
||||
def to_representation(cls, instance: AlertReceiveChannel) -> IntegrationAlertGroupLabels:
|
||||
"""
|
||||
The API representation of alert group labels is very different from the underlying model.
|
||||
|
||||
"inheritable" is based on AlertReceiveChannelAssociatedLabel.inheritable, a property of another model.
|
||||
"custom" is based on AlertReceiveChannel.alert_group_labels_custom, a JSONField with a different schema.
|
||||
"template" is based on AlertReceiveChannel.alert_group_labels_template, this one is straightforward.
|
||||
"""
|
||||
|
||||
return {
|
||||
"inheritable": {label.key_id: label.inheritable for label in instance.labels.all()},
|
||||
"custom": cls._custom_labels_to_representation(instance.alert_group_labels_custom),
|
||||
"template": instance.alert_group_labels_template,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _custom_labels_to_internal_value(
|
||||
custom_labels: AlertGroupCustomLabels,
|
||||
) -> AlertReceiveChannel.AlertGroupCustomLabels:
|
||||
"""Convert custom labels from API representation to the schema used by the JSONField on the model."""
|
||||
|
||||
return [
|
||||
[label["key"]["id"], label["value"]["id"], None if label["value"]["id"] else label["value"]["name"]]
|
||||
for label in custom_labels
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _custom_labels_to_representation(
|
||||
custom_labels: AlertReceiveChannel.AlertGroupCustomLabels,
|
||||
) -> AlertGroupCustomLabels:
|
||||
"""
|
||||
Inverse of the _custom_labels_to_internal_value method above.
|
||||
Fetches label names from DB cache, so the API response schema is consistent with other label endpoints.
|
||||
"""
|
||||
|
||||
from apps.labels.models import LabelKeyCache, LabelValueCache
|
||||
|
||||
if custom_labels is None:
|
||||
return []
|
||||
|
||||
# get up-to-date label key names
|
||||
label_key_names = {
|
||||
k.id: k.name
|
||||
for k in LabelKeyCache.objects.filter(id__in=[label[0] for label in custom_labels]).only("id", "name")
|
||||
}
|
||||
|
||||
# get up-to-date label value names
|
||||
label_value_names = {
|
||||
v.id: v.name
|
||||
for v in LabelValueCache.objects.filter(id__in=[label[1] for label in custom_labels if label[1]]).only(
|
||||
"id", "name"
|
||||
)
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
"key": {
|
||||
"id": key_id,
|
||||
"name": label_key_names[key_id],
|
||||
},
|
||||
"value": {
|
||||
"id": value_id if value_id else None,
|
||||
"name": label_value_names[value_id] if value_id else typing.cast(str, template),
|
||||
},
|
||||
}
|
||||
for key_id, value_id, template in custom_labels
|
||||
if key_id in label_key_names and (value_id in label_value_names or not value_id)
|
||||
]
|
||||
|
||||
|
||||
class AlertReceiveChannelSerializer(
|
||||
|
|
@ -64,7 +226,7 @@ class AlertReceiveChannelSerializer(
|
|||
connected_escalations_chains_count = serializers.SerializerMethodField()
|
||||
inbound_email = serializers.CharField(required=False)
|
||||
is_legacy = serializers.SerializerMethodField()
|
||||
alert_group_labels = IntegrationAlertGroupLabelsSerializer(required=False)
|
||||
alert_group_labels = IntegrationAlertGroupLabelsSerializer(source="*", required=False)
|
||||
|
||||
# integration heartbeat is in PREFETCH_RELATED not by mistake.
|
||||
# With using of select_related ORM builds strange join
|
||||
|
|
@ -138,8 +300,10 @@ class AlertReceiveChannelSerializer(
|
|||
if _integration.slug == integration:
|
||||
is_able_to_autoresolve = _integration.is_able_to_autoresolve
|
||||
|
||||
# pop associated labels and alert group labels, so they are not passed to AlertReceiveChannel.create
|
||||
labels = validated_data.pop("labels", None)
|
||||
alert_group_labels = validated_data.pop("alert_group_labels", None)
|
||||
alert_group_labels = IntegrationAlertGroupLabelsSerializer.pop_alert_group_labels(validated_data)
|
||||
|
||||
try:
|
||||
instance = AlertReceiveChannel.create(
|
||||
**validated_data,
|
||||
|
|
@ -150,17 +314,22 @@ class AlertReceiveChannelSerializer(
|
|||
except AlertReceiveChannel.DuplicateDirectPagingError:
|
||||
raise BadRequest(detail=AlertReceiveChannel.DuplicateDirectPagingError.DETAIL)
|
||||
|
||||
# Create label associations first, then update inheritable labels
|
||||
# Create label associations first, then update alert group labels
|
||||
self.update_labels_association_if_needed(labels, instance, organization)
|
||||
if alert_group_labels:
|
||||
instance.alert_group_labels = alert_group_labels
|
||||
instance = IntegrationAlertGroupLabelsSerializer.update(instance, alert_group_labels)
|
||||
|
||||
return instance
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# update associated labels
|
||||
labels = validated_data.pop("labels", None)
|
||||
organization = self.context["request"].auth.organization
|
||||
self.update_labels_association_if_needed(labels, instance, organization)
|
||||
self.update_labels_association_if_needed(labels, instance, self.context["request"].auth.organization)
|
||||
|
||||
# update alert group labels
|
||||
instance = IntegrationAlertGroupLabelsSerializer.update(
|
||||
instance, IntegrationAlertGroupLabelsSerializer.pop_alert_group_labels(validated_data)
|
||||
)
|
||||
|
||||
try:
|
||||
return super().update(instance, validated_data)
|
||||
except AlertReceiveChannel.DuplicateDirectPagingError:
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ import typing
|
|||
from rest_framework import serializers
|
||||
|
||||
from apps.alerts.models import AlertReceiveChannel, ChannelFilter, EscalationChain
|
||||
from apps.api.serializers.alert_receive_channel import valid_jinja_template_for_serializer_method_field
|
||||
from apps.base.messaging import get_messaging_backend_from_id
|
||||
from apps.telegram.models import TelegramToOrganizationConnector
|
||||
from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
from common.api_helpers.mixins import EagerLoadingMixin
|
||||
from common.api_helpers.utils import valid_jinja_template_for_serializer_method_field
|
||||
from common.jinja_templater.apply_jinja_template import JinjaTemplateError
|
||||
from common.utils import is_regex_valid
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from collections import defaultdict
|
|||
from rest_framework import serializers
|
||||
from rest_framework.validators import UniqueTogetherValidator
|
||||
|
||||
from apps.api.serializers.labels import LabelsSerializerMixin
|
||||
from apps.webhooks.models import Webhook, WebhookResponse
|
||||
from apps.webhooks.models.webhook import PUBLIC_WEBHOOK_HTTP_METHODS, WEBHOOK_FIELD_PLACEHOLDER
|
||||
from apps.webhooks.presets.preset_options import WebhookPresetOptions
|
||||
|
|
@ -27,7 +28,7 @@ class WebhookResponseSerializer(serializers.ModelSerializer):
|
|||
]
|
||||
|
||||
|
||||
class WebhookSerializer(serializers.ModelSerializer):
|
||||
class WebhookSerializer(LabelsSerializerMixin, serializers.ModelSerializer):
|
||||
id = serializers.CharField(read_only=True, source="public_primary_key")
|
||||
organization = serializers.HiddenField(default=CurrentOrganizationDefault())
|
||||
team = TeamPrimaryKeyRelatedField(allow_null=True, default=CurrentTeamDefault())
|
||||
|
|
@ -37,6 +38,8 @@ class WebhookSerializer(serializers.ModelSerializer):
|
|||
trigger_type = serializers.CharField(allow_null=True)
|
||||
trigger_type_name = serializers.SerializerMethodField()
|
||||
|
||||
PREFETCH_RELATED = ["labels", "labels__key", "labels__value"]
|
||||
|
||||
class Meta:
|
||||
model = Webhook
|
||||
fields = [
|
||||
|
|
@ -61,10 +64,25 @@ class WebhookSerializer(serializers.ModelSerializer):
|
|||
"last_response_log",
|
||||
"integration_filter",
|
||||
"preset",
|
||||
"labels",
|
||||
]
|
||||
|
||||
validators = [UniqueTogetherValidator(queryset=Webhook.objects.all(), fields=["name", "organization"])]
|
||||
|
||||
def create(self, validated_data):
|
||||
organization = self.context["request"].auth.organization
|
||||
labels = validated_data.pop("labels", None)
|
||||
|
||||
instance = super().create(validated_data)
|
||||
self.update_labels_association_if_needed(labels, instance, organization)
|
||||
return instance
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
labels = validated_data.pop("labels", None)
|
||||
organization = self.context["request"].auth.organization
|
||||
self.update_labels_association_if_needed(labels, instance, organization)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def to_representation(self, instance):
|
||||
result = super().to_representation(instance)
|
||||
if instance.password:
|
||||
|
|
@ -164,7 +182,9 @@ class WebhookSerializer(serializers.ModelSerializer):
|
|||
for controlled_field in preset_metadata.controlled_fields:
|
||||
if controlled_field in self.initial_data:
|
||||
if self.instance:
|
||||
if self.initial_data[controlled_field] != getattr(self.instance, controlled_field):
|
||||
if self.initial_data[controlled_field] is not None and self.initial_data[
|
||||
controlled_field
|
||||
] != getattr(self.instance, controlled_field):
|
||||
raise serializers.ValidationError(
|
||||
detail=f"{controlled_field} is controlled by preset, cannot update"
|
||||
)
|
||||
|
|
@ -176,7 +196,7 @@ class WebhookSerializer(serializers.ModelSerializer):
|
|||
return preset
|
||||
|
||||
def get_last_response_log(self, obj):
|
||||
return WebhookResponseSerializer(obj.responses.all().last()).data
|
||||
return WebhookResponseSerializer(obj.responses.last()).data
|
||||
|
||||
def get_trigger_type_name(self, obj):
|
||||
trigger_type_name = ""
|
||||
|
|
|
|||
|
|
@ -863,6 +863,72 @@ def test_get_filter_escalation_chain(
|
|||
assert len(response.data["results"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_filter_by_teams(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_team,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
client = APIClient()
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
team1 = make_team(organization)
|
||||
team2 = make_team(organization)
|
||||
|
||||
alert_receive_channel_0 = make_alert_receive_channel(organization)
|
||||
alert_receive_channel_1 = make_alert_receive_channel(organization, team=team1)
|
||||
alert_receive_channel_2 = make_alert_receive_channel(organization, team=team2)
|
||||
|
||||
alert_group_0 = make_alert_group(alert_receive_channel_0)
|
||||
make_alert(alert_group=alert_group_0, raw_request_data=alert_raw_request_data)
|
||||
|
||||
alert_group_1 = make_alert_group(alert_receive_channel_1)
|
||||
make_alert(alert_group=alert_group_1, raw_request_data=alert_raw_request_data)
|
||||
|
||||
alert_group_2 = make_alert_group(alert_receive_channel_2)
|
||||
make_alert(alert_group=alert_group_2, raw_request_data=alert_raw_request_data)
|
||||
|
||||
url = reverse("api-internal:alertgroup-list")
|
||||
|
||||
# check no team is given
|
||||
response = client.get(url, **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 3
|
||||
assert {ag["pk"] for ag in response.data["results"]} == {
|
||||
alert_group_0.public_primary_key,
|
||||
alert_group_1.public_primary_key,
|
||||
alert_group_2.public_primary_key,
|
||||
}
|
||||
|
||||
# check the "No team" case
|
||||
response = client.get(url + "?team=null", **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
assert {ag["pk"] for ag in response.data["results"]} == {alert_group_0.public_primary_key}
|
||||
|
||||
# check the "No team" + other team case
|
||||
response = client.get(url + f"?team=null&team={team2.public_primary_key}", **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 2
|
||||
assert {ag["pk"] for ag in response.data["results"]} == {
|
||||
alert_group_0.public_primary_key,
|
||||
alert_group_2.public_primary_key,
|
||||
}
|
||||
|
||||
# check the multiple teams case
|
||||
response = client.get(
|
||||
url + f"?team={team1.public_primary_key}&team={team2.public_primary_key}", **make_user_auth_headers(user, token)
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 2
|
||||
assert {ag["pk"] for ag in response.data["results"]} == {
|
||||
alert_group_1.public_primary_key,
|
||||
alert_group_2.public_primary_key,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_filter_labels(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from rest_framework.test import APIClient
|
|||
|
||||
from apps.alerts.models import AlertReceiveChannel, EscalationPolicy
|
||||
from apps.api.permissions import LegacyAccessControlRole
|
||||
from apps.labels.models import LabelKeyCache, LabelValueCache
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
|
@ -1310,7 +1311,6 @@ def test_integration_filter_by_labels(
|
|||
def test_update_alert_receive_channel_labels(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_alert_receive_channel,
|
||||
make_integration_label_association,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
|
|
@ -1353,7 +1353,6 @@ def test_update_alert_receive_channel_labels(
|
|||
def test_update_alert_receive_channel_labels_duplicate_key(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_alert_receive_channel,
|
||||
make_integration_label_association,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
|
|
@ -1385,23 +1384,49 @@ def test_update_alert_receive_channel_labels_duplicate_key(
|
|||
def test_alert_group_labels_get(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_alert_receive_channel,
|
||||
make_label_key_and_value,
|
||||
make_integration_label_association,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
label_key, label_value = make_label_key_and_value(organization)
|
||||
label_key_1, _ = make_label_key_and_value(organization)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": alert_receive_channel.public_primary_key})
|
||||
|
||||
response = client.get(url, **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["alert_group_labels"] == {"inheritable": {}}
|
||||
assert response.json()["alert_group_labels"] == {"inheritable": {}, "custom": [], "template": None}
|
||||
|
||||
label = make_integration_label_association(organization, alert_receive_channel)
|
||||
|
||||
template = "{{ payload.labels | tojson }}"
|
||||
alert_receive_channel.alert_group_labels_template = template
|
||||
|
||||
alert_receive_channel.alert_group_labels_custom = [
|
||||
(label_key.id, label_value.id, None),
|
||||
(label_key_1.id, None, "{{ payload.foo }}"),
|
||||
]
|
||||
alert_receive_channel.save(update_fields=["alert_group_labels_custom", "alert_group_labels_template"])
|
||||
|
||||
response = client.get(url, **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["alert_group_labels"] == {"inheritable": {label.key_id: True}}
|
||||
assert response.json()["alert_group_labels"] == {
|
||||
"inheritable": {label.key_id: True},
|
||||
"custom": [
|
||||
{
|
||||
"key": {"id": label_key.id, "name": label_key.name},
|
||||
"value": {"id": label_value.id, "name": label_value.name},
|
||||
},
|
||||
{
|
||||
"key": {"id": label_key_1.id, "name": label_key_1.name},
|
||||
"value": {"id": None, "name": "{{ payload.foo }}"},
|
||||
},
|
||||
],
|
||||
"template": template,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -1415,14 +1440,75 @@ def test_alert_group_labels_put(
|
|||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
label_1 = make_integration_label_association(organization, alert_receive_channel)
|
||||
label_2 = make_integration_label_association(organization, alert_receive_channel, inheritable=False)
|
||||
label_3 = make_integration_label_association(organization, alert_receive_channel, inheritable=False)
|
||||
|
||||
custom = [
|
||||
# plain label
|
||||
{
|
||||
"key": {"id": label_2.key.id, "name": label_2.key.name},
|
||||
"value": {"id": label_2.value.id, "name": label_2.value.name},
|
||||
},
|
||||
# plain label not present in DB cache
|
||||
{
|
||||
"key": {"id": "hello", "name": "world"},
|
||||
"value": {"id": "foo", "name": "bar"},
|
||||
},
|
||||
# templated label
|
||||
{
|
||||
"key": {"id": label_3.key.id, "name": label_3.key.name},
|
||||
"value": {"id": None, "name": "{{ payload.foo }}"},
|
||||
},
|
||||
]
|
||||
template = "{{ payload.labels | tojson }}" # advanced template
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": alert_receive_channel.public_primary_key})
|
||||
data = {"alert_group_labels": {"inheritable": {label_1.key_id: False, label_2.key_id: True}}}
|
||||
data = {
|
||||
"alert_group_labels": {
|
||||
"inheritable": {label_1.key_id: False, label_2.key_id: True, label_3.key_id: False},
|
||||
"custom": custom,
|
||||
"template": template,
|
||||
}
|
||||
}
|
||||
response = client.put(url, data, format="json", **make_user_auth_headers(user, token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["alert_group_labels"] == {"inheritable": {label_1.key_id: False, label_2.key_id: True}}
|
||||
assert response.json()["alert_group_labels"] == {
|
||||
"inheritable": {label_1.key_id: False, label_2.key_id: True, label_3.key_id: False},
|
||||
"custom": custom,
|
||||
"template": template,
|
||||
}
|
||||
|
||||
alert_receive_channel.refresh_from_db()
|
||||
assert alert_receive_channel.alert_group_labels_custom == [
|
||||
[label_2.key_id, label_2.value_id, None],
|
||||
["hello", "foo", None],
|
||||
[label_3.key_id, None, "{{ payload.foo }}"],
|
||||
]
|
||||
assert alert_receive_channel.alert_group_labels_template == template
|
||||
|
||||
# check label keys & values are created
|
||||
key = LabelKeyCache.objects.filter(id="hello", name="world", organization=organization).first()
|
||||
assert key is not None
|
||||
assert LabelValueCache.objects.filter(key=key, id="foo", name="bar").exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_alert_group_labels_put_none(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_alert_receive_channel,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": alert_receive_channel.public_primary_key})
|
||||
response = client.put(url, {"verbal_name": "123"}, format="json", **make_user_auth_headers(user, token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["verbal_name"] == "123"
|
||||
assert response.json()["alert_group_labels"] == {"inheritable": {}, "custom": [], "template": None}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -1430,7 +1516,11 @@ def test_alert_group_labels_post(alert_receive_channel_internal_api_setup, make_
|
|||
user, token, _ = alert_receive_channel_internal_api_setup
|
||||
|
||||
labels = [{"key": {"id": "test", "name": "test"}, "value": {"id": "123", "name": "123"}}]
|
||||
alert_group_labels = {"inheritable": {"test": False}}
|
||||
alert_group_labels = {
|
||||
"inheritable": {"test": False},
|
||||
"custom": [{"key": {"id": "test", "name": "test"}, "value": {"id": "123", "name": "123"}}],
|
||||
"template": "{{ payload.labels | tojson }}",
|
||||
}
|
||||
data = {
|
||||
"integration": AlertReceiveChannel.INTEGRATION_GRAFANA,
|
||||
"team": None,
|
||||
|
|
@ -1445,3 +1535,7 @@ def test_alert_group_labels_post(alert_receive_channel_internal_api_setup, make_
|
|||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["labels"] == labels
|
||||
assert response.json()["alert_group_labels"] == alert_group_labels
|
||||
|
||||
alert_receive_channel = AlertReceiveChannel.objects.get(public_primary_key=response.json()["id"])
|
||||
assert alert_receive_channel.alert_group_labels_custom == [["test", "123", None]]
|
||||
assert alert_receive_channel.alert_group_labels_template == "{{ payload.labels | tojson }}"
|
||||
|
|
|
|||
|
|
@ -337,6 +337,37 @@ def test_preview_alert_receive_channel_backend_templater(
|
|||
assert response.json() == {"preview": "title: alert!"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_preview_alert_group_labels(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
make_alert_receive_channel,
|
||||
make_channel_filter,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
default_channel_filter = make_channel_filter(alert_receive_channel, is_default=True)
|
||||
alert_group = make_alert_group(alert_receive_channel, channel_filter=default_channel_filter)
|
||||
make_alert(alert_group=alert_group, raw_request_data={"labels": {"1": "2"}})
|
||||
|
||||
client = APIClient()
|
||||
url = reverse(
|
||||
"api-internal:alert_receive_channel-preview-template",
|
||||
kwargs={"pk": alert_receive_channel.public_primary_key},
|
||||
)
|
||||
|
||||
data = {
|
||||
"template_body": "{{ payload.labels | tojson }}",
|
||||
"template_name": "alert_group_labels",
|
||||
}
|
||||
response = client.post(url, format="json", data=data, **make_user_auth_headers(user, token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {"preview": '{"1": "2"}'}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_alert_receive_channel_templates(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ from django.urls import reverse
|
|||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from common.api_helpers.filters import NO_TEAM_VALUE
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def escalation_chain_internal_api_setup(make_organization_and_user_with_plugin_token, make_escalation_chain):
|
||||
|
|
@ -103,7 +105,7 @@ def test_escalation_chain_copy(
|
|||
escalation_chain = make_escalation_chain(organization, team=team)
|
||||
data = {
|
||||
"name": "escalation_chain_updated",
|
||||
"team": new_team.public_primary_key if new_team else "null",
|
||||
"team": new_team.public_primary_key if new_team else NO_TEAM_VALUE,
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
|
|
@ -125,6 +127,8 @@ def test_escalation_chain_copy_empty_name(
|
|||
client = APIClient()
|
||||
url = reverse("api-internal:escalation_chain-copy", kwargs={"pk": escalation_chain.public_primary_key})
|
||||
|
||||
response = client.post(url, {"name": "", "team": "null"}, format="json", **make_user_auth_headers(user, token))
|
||||
response = client.post(
|
||||
url, {"name": "", "team": NO_TEAM_VALUE}, format="json", **make_user_auth_headers(user, token)
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ def test_get_update_key_get(
|
|||
mocked_get_values,
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
make_alert_receive_channel,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
client = APIClient()
|
||||
|
|
@ -68,7 +67,6 @@ def test_get_update_key_put(
|
|||
mocked_rename_key,
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
make_alert_receive_channel,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
client = APIClient()
|
||||
|
|
@ -94,7 +92,6 @@ def test_add_value(
|
|||
mocked_add_value,
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
make_alert_receive_channel,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
client = APIClient()
|
||||
|
|
@ -120,7 +117,6 @@ def test_rename_value(
|
|||
mocked_rename_value,
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
make_alert_receive_channel,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
client = APIClient()
|
||||
|
|
@ -146,7 +142,6 @@ def test_get_value(
|
|||
mocked_get_value,
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
make_alert_receive_channel,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
client = APIClient()
|
||||
|
|
@ -171,7 +166,6 @@ def test_labels_create_label(
|
|||
mocked_create_label,
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
make_alert_receive_channel,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
client = APIClient()
|
||||
|
|
@ -189,7 +183,6 @@ def test_labels_create_label(
|
|||
def test_labels_feature_false(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
make_alert_receive_channel,
|
||||
settings,
|
||||
):
|
||||
setattr(settings, "FEATURE_LABELS_ENABLED_FOR_ALL", False)
|
||||
|
|
@ -239,7 +232,6 @@ def test_labels_feature_false(
|
|||
def test_labels_permissions_get_actions(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
make_alert_receive_channel,
|
||||
role,
|
||||
expected_status,
|
||||
):
|
||||
|
|
@ -274,7 +266,6 @@ def test_labels_permissions_get_actions(
|
|||
def test_labels_permissions_create_update_actions(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
make_alert_receive_channel,
|
||||
role,
|
||||
expected_status,
|
||||
):
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ from apps.alerts.models import AlertReceiveChannel
|
|||
from apps.api.permissions import LegacyAccessControlRole
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar, OnCallScheduleWeb
|
||||
from apps.user_management.models import Team
|
||||
from common.api_helpers.filters import NO_TEAM_VALUE
|
||||
|
||||
GENERAL_TEAM = Team(public_primary_key="null", name="No team", email=None, avatar_url=None)
|
||||
GENERAL_TEAM = Team(public_primary_key=NO_TEAM_VALUE, name="No team", email=None, avatar_url=None)
|
||||
|
||||
|
||||
def get_payload_from_team(team, long=False):
|
||||
|
|
@ -203,7 +204,7 @@ def test_teams_number_of_users_currently_oncall_attribute_works_properly(
|
|||
team1.public_primary_key: 2,
|
||||
team2.public_primary_key: 1,
|
||||
team3.public_primary_key: 0,
|
||||
"null": 0, # this covers the case of "No team"
|
||||
NO_TEAM_VALUE: 0, # this covers the case of "No team"
|
||||
}
|
||||
|
||||
for team in response.json():
|
||||
|
|
|
|||
|
|
@ -410,7 +410,7 @@ def test_user_update_other_permissions(
|
|||
[
|
||||
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
|
||||
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
|
||||
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
|
||||
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
|
||||
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
|
||||
],
|
||||
)
|
||||
|
|
@ -1291,14 +1291,14 @@ def test_viewer_cant_update_himself(make_organization_and_user_with_plugin_token
|
|||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_viewer_cant_list_users(make_organization_and_user_with_plugin_token, make_user_auth_headers):
|
||||
def test_viewer_can_list_users(make_organization_and_user_with_plugin_token, make_user_auth_headers):
|
||||
_, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:user-list")
|
||||
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ def test_create_webhook_from_preset(
|
|||
"http_method": "GET",
|
||||
"integration_filter": None,
|
||||
"is_webhook_enabled": True,
|
||||
"labels": [],
|
||||
"is_legacy": False,
|
||||
"last_response_log": {
|
||||
"request_data": "",
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ def test_get_list_webhooks(webhook_internal_api_setup, make_user_auth_headers):
|
|||
"http_method": "POST",
|
||||
"integration_filter": None,
|
||||
"is_webhook_enabled": True,
|
||||
"labels": [],
|
||||
"is_legacy": False,
|
||||
"last_response_log": {
|
||||
"request_data": "",
|
||||
|
|
@ -95,6 +96,7 @@ def test_get_detail_webhook(webhook_internal_api_setup, make_user_auth_headers):
|
|||
"http_method": "POST",
|
||||
"integration_filter": None,
|
||||
"is_webhook_enabled": True,
|
||||
"labels": [],
|
||||
"is_legacy": False,
|
||||
"last_response_log": {
|
||||
"request_data": "",
|
||||
|
|
@ -143,6 +145,7 @@ def test_create_webhook(webhook_internal_api_setup, make_user_auth_headers):
|
|||
"http_method": "POST",
|
||||
"integration_filter": None,
|
||||
"is_webhook_enabled": True,
|
||||
"labels": [],
|
||||
"is_legacy": False,
|
||||
"last_response_log": {
|
||||
"request_data": "",
|
||||
|
|
@ -203,6 +206,7 @@ def test_create_valid_templated_field(webhook_internal_api_setup, make_user_auth
|
|||
"http_method": "POST",
|
||||
"integration_filter": None,
|
||||
"is_webhook_enabled": True,
|
||||
"labels": [],
|
||||
"is_legacy": False,
|
||||
"last_response_log": {
|
||||
"request_data": "",
|
||||
|
|
@ -583,6 +587,7 @@ def test_webhook_field_masking(webhook_internal_api_setup, make_user_auth_header
|
|||
"http_method": "POST",
|
||||
"integration_filter": None,
|
||||
"is_webhook_enabled": True,
|
||||
"labels": [],
|
||||
"is_legacy": False,
|
||||
"last_response_log": {
|
||||
"request_data": "",
|
||||
|
|
@ -642,6 +647,7 @@ def test_webhook_copy(webhook_internal_api_setup, make_user_auth_headers):
|
|||
"http_method": "POST",
|
||||
"integration_filter": None,
|
||||
"is_webhook_enabled": True,
|
||||
"labels": [],
|
||||
"is_legacy": False,
|
||||
"last_response_log": {
|
||||
"request_data": "",
|
||||
|
|
@ -711,3 +717,184 @@ def test_create_invalid_missing_fields(webhook_internal_api_setup, make_user_aut
|
|||
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json()["trigger_type"][0] == "This field is required."
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_webhook_filter_by_labels(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_custom_webhook,
|
||||
make_webhook_label_association,
|
||||
make_label_key_and_value,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
webhook_with_label = make_custom_webhook(organization)
|
||||
label = make_webhook_label_association(organization, webhook_with_label)
|
||||
|
||||
webhook_with_another_label = make_custom_webhook(organization)
|
||||
another_label = make_webhook_label_association(organization, webhook_with_another_label)
|
||||
|
||||
not_attached_key, not_attached_value = make_label_key_and_value(organization)
|
||||
|
||||
client = APIClient()
|
||||
|
||||
# test filter by label, which is attached to only one webhook
|
||||
url = reverse("api-internal:webhooks-list")
|
||||
response = client.get(
|
||||
f"{url}?label={label.key_id}:{label.value_id}",
|
||||
content_type="application/json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()) == 1
|
||||
assert response.json()[0]["id"] == webhook_with_label.public_primary_key
|
||||
|
||||
url = reverse("api-internal:webhooks-list")
|
||||
response = client.get(
|
||||
f"{url}?label={another_label.key_id}:{another_label.value_id}",
|
||||
content_type="application/json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()) == 1
|
||||
assert response.json()[0]["id"] == webhook_with_another_label.public_primary_key
|
||||
|
||||
# test filter by label which is not attached to any webhooks
|
||||
response = client.get(
|
||||
f"{url}?label={not_attached_key.id}:{not_attached_value.id}",
|
||||
content_type="application/json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
assert len(response.json()) == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_webhook_labels(
|
||||
webhook_internal_api_setup,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
user, token, webhook = webhook_internal_api_setup
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-internal:webhooks-detail", kwargs={"pk": webhook.public_primary_key})
|
||||
key_id = "testkey"
|
||||
value_id = "testvalue"
|
||||
data = {"labels": [{"key": {"id": key_id, "name": "test"}, "value": {"id": value_id, "name": "testv"}}]}
|
||||
response = client.patch(
|
||||
url,
|
||||
data=json.dumps(data),
|
||||
content_type="application/json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
|
||||
webhook.refresh_from_db()
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert webhook.labels.count() == 1
|
||||
label = webhook.labels.first()
|
||||
assert label.key_id == key_id
|
||||
assert label.value_id == value_id
|
||||
|
||||
response = client.patch(
|
||||
url,
|
||||
data=json.dumps({"labels": []}),
|
||||
content_type="application/json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
|
||||
webhook.refresh_from_db()
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert webhook.labels.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_webhook_with_labels(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-internal:webhooks-list")
|
||||
|
||||
key_id = "testkey"
|
||||
value_id = "testvalue"
|
||||
data = {
|
||||
"name": "the_webhook",
|
||||
"url": TEST_URL,
|
||||
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
|
||||
"http_method": "POST",
|
||||
"labels": [{"key": {"id": key_id, "name": "test"}, "value": {"id": value_id, "name": "testv"}}],
|
||||
"team": None,
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
url,
|
||||
data=json.dumps(data),
|
||||
content_type="application/json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
webhook = Webhook.objects.get(public_primary_key=response.json()["id"])
|
||||
expected_response = data | {
|
||||
"id": webhook.public_primary_key,
|
||||
"data": None,
|
||||
"username": None,
|
||||
"password": None,
|
||||
"authorization_header": None,
|
||||
"forward_all": True,
|
||||
"headers": None,
|
||||
"http_method": "POST",
|
||||
"integration_filter": None,
|
||||
"is_webhook_enabled": True,
|
||||
"is_legacy": False,
|
||||
"last_response_log": {
|
||||
"request_data": "",
|
||||
"request_headers": "",
|
||||
"timestamp": None,
|
||||
"content": "",
|
||||
"status_code": None,
|
||||
"request_trigger": "",
|
||||
"url": "",
|
||||
"event_data": "",
|
||||
},
|
||||
"trigger_template": None,
|
||||
"trigger_type": str(data["trigger_type"]),
|
||||
"trigger_type_name": "Alert Group Created",
|
||||
"preset": None,
|
||||
}
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json() == expected_response
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_webhook_labels_duplicate_key(
|
||||
webhook_internal_api_setup,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
user, token, webhook = webhook_internal_api_setup
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-internal:webhooks-detail", kwargs={"pk": webhook.public_primary_key})
|
||||
key_id = "testkey"
|
||||
data = {
|
||||
"labels": [
|
||||
{"key": {"id": key_id, "name": "test"}, "value": {"id": "testvalue1", "name": "testv1"}},
|
||||
{"key": {"id": key_id, "name": "test"}, "value": {"id": "testvalue2", "name": "testv2"}},
|
||||
]
|
||||
}
|
||||
response = client.patch(
|
||||
url,
|
||||
data=json.dumps(data),
|
||||
content_type="application/json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
|
||||
webhook.refresh_from_db()
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert webhook.labels.count() == 0
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel, Escalatio
|
|||
from apps.alerts.paging import unpage_user
|
||||
from apps.alerts.tasks import delete_alert_group, send_update_resolution_note_signal
|
||||
from apps.api.errors import AlertGroupAPIError
|
||||
from apps.api.label_filtering import parse_label_query
|
||||
from apps.api.permissions import RBACPermission
|
||||
from apps.api.serializers.alert_group import AlertGroupListSerializer, AlertGroupSerializer
|
||||
from apps.api.serializers.team import TeamSerializer
|
||||
|
|
@ -27,12 +28,7 @@ from apps.labels.utils import is_labels_feature_enabled
|
|||
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
|
||||
from apps.user_management.models import Team, User
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
from common.api_helpers.filters import (
|
||||
ByTeamModelFieldFilterMixin,
|
||||
DateRangeFilterMixin,
|
||||
ModelFieldFilterMixin,
|
||||
TeamModelMultipleChoiceFilter,
|
||||
)
|
||||
from common.api_helpers.filters import NO_TEAM_VALUE, DateRangeFilterMixin, ModelFieldFilterMixin
|
||||
from common.api_helpers.mixins import PreviewTemplateMixin, PublicPrimaryKeyMixin, TeamFilteringMixin
|
||||
from common.api_helpers.paginators import TwentyFiveCursorPaginator
|
||||
|
||||
|
|
@ -83,7 +79,7 @@ class AlertGroupFilterBackend(filters.DjangoFilterBackend):
|
|||
return filterset
|
||||
|
||||
|
||||
class AlertGroupFilter(DateRangeFilterMixin, ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, filters.FilterSet):
|
||||
class AlertGroupFilter(DateRangeFilterMixin, ModelFieldFilterMixin, filters.FilterSet):
|
||||
"""
|
||||
Examples of possible date formats here https://docs.djangoproject.com/en/1.9/ref/settings/#datetime-input-formats
|
||||
"""
|
||||
|
|
@ -140,7 +136,6 @@ class AlertGroupFilter(DateRangeFilterMixin, ByTeamModelFieldFilterMixin, ModelF
|
|||
)
|
||||
with_resolution_note = filters.BooleanFilter(method="filter_with_resolution_note")
|
||||
mine = filters.BooleanFilter(method="filter_mine")
|
||||
team = TeamModelMultipleChoiceFilter(field_name="channel__team")
|
||||
|
||||
class Meta:
|
||||
model = AlertGroup
|
||||
|
|
@ -336,22 +331,28 @@ class AlertGroupView(
|
|||
if not ignore_filtering_by_available_teams:
|
||||
alert_receive_channels_qs = alert_receive_channels_qs.filter(*self.available_teams_lookup_args)
|
||||
|
||||
# Filter by team(s). Since we really filter teams from integrations, this is not an AlertGroup model filter.
|
||||
# This is based on the common.api_helpers.ByTeamModelFieldFilterMixin implementation
|
||||
team_values = self.request.query_params.getlist("team", [])
|
||||
if team_values:
|
||||
null_team_lookup = Q(team__isnull=True) if NO_TEAM_VALUE in team_values else None
|
||||
teams_lookup = Q(team__public_primary_key__in=[ppk for ppk in team_values if ppk != NO_TEAM_VALUE])
|
||||
if null_team_lookup:
|
||||
teams_lookup = teams_lookup | null_team_lookup
|
||||
alert_receive_channels_qs = alert_receive_channels_qs.filter(teams_lookup)
|
||||
|
||||
alert_receive_channels_ids = list(alert_receive_channels_qs.values_list("id", flat=True))
|
||||
queryset = AlertGroup.objects.filter(channel__in=alert_receive_channels_ids)
|
||||
|
||||
# filter by labels
|
||||
labels = self.request.query_params.getlist("label")
|
||||
for label in labels:
|
||||
label_split = label.split(":")
|
||||
if len(label_split) != 2:
|
||||
continue
|
||||
key_name, value_name = label_split
|
||||
|
||||
# Filter by labels. Since alert group labels are "static" filter by names, not IDs.
|
||||
label_query = self.request.query_params.getlist("label", [])
|
||||
kv_pairs = parse_label_query(label_query)
|
||||
for key, value in kv_pairs:
|
||||
# Utilize (organization, key_name, value_name, alert_group) index on AlertGroupAssociatedLabel
|
||||
queryset = queryset.filter(
|
||||
labels__organization=self.request.auth.organization,
|
||||
labels__key_name=key_name,
|
||||
labels__value_name=value_name,
|
||||
labels__key_name=key,
|
||||
labels__value_name=value,
|
||||
)
|
||||
|
||||
queryset = queryset.only("id")
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from rest_framework.viewsets import ModelViewSet
|
|||
from apps.alerts.grafana_alerting_sync_manager.grafana_alerting_sync import GrafanaAlertingSyncManager
|
||||
from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel
|
||||
from apps.alerts.models.maintainable_object import MaintainableObject
|
||||
from apps.api.label_filtering import parse_label_query
|
||||
from apps.api.permissions import RBACPermission
|
||||
from apps.api.serializers.alert_receive_channel import (
|
||||
AlertReceiveChannelSerializer,
|
||||
|
|
@ -18,13 +19,13 @@ from apps.api.serializers.alert_receive_channel import (
|
|||
FilterAlertReceiveChannelSerializer,
|
||||
)
|
||||
from apps.api.throttlers import DemoAlertThrottler
|
||||
from apps.api.views.labels import LabelsAssociatingMixin
|
||||
from apps.api.views.labels import schedule_update_label_cache
|
||||
from apps.auth_token.auth import PluginAuthentication
|
||||
from apps.integrations.legacy_prefix import has_legacy_prefix, remove_legacy_prefix
|
||||
from apps.labels.utils import is_labels_feature_enabled
|
||||
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, TeamModelMultipleChoiceFilter
|
||||
from common.api_helpers.filters import NO_TEAM_VALUE, ByTeamModelFieldFilterMixin, TeamModelMultipleChoiceFilter
|
||||
from common.api_helpers.mixins import (
|
||||
FilterSerializerMixin,
|
||||
PreviewTemplateException,
|
||||
|
|
@ -76,7 +77,6 @@ class AlertReceiveChannelView(
|
|||
PublicPrimaryKeyMixin,
|
||||
FilterSerializerMixin,
|
||||
UpdateSerializerMixin,
|
||||
LabelsAssociatingMixin,
|
||||
ModelViewSet,
|
||||
):
|
||||
authentication_classes = (
|
||||
|
|
@ -159,7 +159,17 @@ class AlertReceiveChannelView(
|
|||
if not ignore_filtering_by_available_teams:
|
||||
queryset = queryset.filter(*self.available_teams_lookup_args).distinct()
|
||||
|
||||
queryset = self.filter_by_labels(queryset)
|
||||
# filter labels
|
||||
label_query = self.request.query_params.getlist("label", [])
|
||||
kv_pairs = parse_label_query(label_query)
|
||||
for key, value in kv_pairs:
|
||||
queryset = queryset.filter(
|
||||
labels__key_id=key,
|
||||
labels__value_id=value,
|
||||
)
|
||||
|
||||
# distinct to remove duplicates after alert_receive_channels X labels join
|
||||
queryset = queryset.distinct()
|
||||
|
||||
return queryset
|
||||
|
||||
|
|
@ -170,7 +180,11 @@ class AlertReceiveChannelView(
|
|||
"""
|
||||
if self.request.query_params.get("skip_pagination", "false").lower() == "true":
|
||||
return None
|
||||
return super().paginate_queryset(queryset)
|
||||
page = super().paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
ids = [d.id for d in queryset]
|
||||
schedule_update_label_cache(self.model.__name__, self.request.auth.organization, ids)
|
||||
return page
|
||||
|
||||
@action(detail=True, methods=["post"], throttle_classes=[DemoAlertThrottler])
|
||||
def send_demo_alert(self, request, pk):
|
||||
|
|
@ -217,7 +231,7 @@ class AlertReceiveChannelView(
|
|||
raise BadRequest(detail="team_id must be specified")
|
||||
|
||||
team_id = request.query_params["team_id"]
|
||||
if team_id == "null":
|
||||
if team_id == NO_TEAM_VALUE:
|
||||
team_id = None
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -18,7 +18,12 @@ from apps.auth_token.auth import PluginAuthentication
|
|||
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
|
||||
from apps.user_management.models import Team
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, TeamModelMultipleChoiceFilter
|
||||
from common.api_helpers.filters import (
|
||||
NO_TEAM_VALUE,
|
||||
ByTeamModelFieldFilterMixin,
|
||||
ModelFieldFilterMixin,
|
||||
TeamModelMultipleChoiceFilter,
|
||||
)
|
||||
from common.api_helpers.mixins import (
|
||||
FilterSerializerMixin,
|
||||
ListSerializerMixin,
|
||||
|
|
@ -128,7 +133,7 @@ class EscalationChainViewSet(
|
|||
|
||||
name = request.data.get("name")
|
||||
team_id = request.data.get("team")
|
||||
if team_id == "null":
|
||||
if team_id == NO_TEAM_VALUE:
|
||||
team_id = None
|
||||
|
||||
if not name:
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from rest_framework.permissions import IsAuthenticated
|
|||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ViewSet
|
||||
|
||||
from apps.alerts.models import AlertReceiveChannel
|
||||
from apps.api.permissions import BasicRolePermission, LegacyAccessControlRole
|
||||
from apps.api.serializers.labels import (
|
||||
LabelKeySerializer,
|
||||
|
|
@ -172,30 +171,8 @@ class AlertGroupLabelsViewSet(LabelsFeatureFlagViewSet):
|
|||
)
|
||||
|
||||
|
||||
class LabelsAssociatingMixin: # use for labelable objects views (ex. AlertReceiveChannelView)
|
||||
def filter_by_labels(self, queryset):
|
||||
"""Call this method in `get_queryset()` to add filtering by labels"""
|
||||
if not is_labels_feature_enabled(self.request.auth.organization):
|
||||
return queryset
|
||||
labels = self.request.query_params.getlist("label") # ["key1:value1", "key2:value2"]
|
||||
if not labels:
|
||||
return queryset
|
||||
for label in labels:
|
||||
label_data = label.split(":")
|
||||
if len(label_data) != 2: # ["key1", "value1"]
|
||||
continue
|
||||
key_id, value_id = label_data
|
||||
queryset &= AlertReceiveChannel.objects_with_deleted.filter(
|
||||
labels__key_id=key_id, labels__value_id=value_id
|
||||
).distinct()
|
||||
return queryset
|
||||
|
||||
def paginate_queryset(self, queryset):
|
||||
organization = self.request.auth.organization
|
||||
data = super().paginate_queryset(queryset)
|
||||
if not is_labels_feature_enabled(self.request.auth.organization):
|
||||
return data
|
||||
ids = [d.id for d in data]
|
||||
logger.info(f"start update_instances_labels_cache for ids: {ids}")
|
||||
update_instances_labels_cache.apply_async((organization.id, ids, self.model.__name__))
|
||||
return data
|
||||
def schedule_update_label_cache(model_name, org, ids):
|
||||
if not is_labels_feature_enabled(org):
|
||||
return
|
||||
logger.info(f"start update_instances_labels_cache for ids: {ids}")
|
||||
update_instances_labels_cache.apply_async((org.id, ids, model_name))
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from apps.auth_token.auth import PluginAuthentication
|
|||
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
|
||||
from apps.schedules.ical_utils import get_cached_oncall_users_for_multiple_schedules
|
||||
from apps.user_management.models import Team
|
||||
from common.api_helpers.filters import NO_TEAM_VALUE
|
||||
from common.api_helpers.mixins import PublicPrimaryKeyMixin
|
||||
|
||||
|
||||
|
|
@ -62,7 +63,7 @@ class TeamViewSet(
|
|||
return TeamLongSerializer if self._is_long_request() else TeamSerializer
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
general_team = [Team(public_primary_key="null", name="No team", email=None, avatar_url=None)]
|
||||
general_team = [Team(public_primary_key=NO_TEAM_VALUE, name="No team", email=None, avatar_url=None)]
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
if self.request.query_params.get("only_include_notifiable_teams", "false") == "true":
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ class UserView(
|
|||
"timezone_options": [RBACPermission.Permissions.USER_SETTINGS_READ],
|
||||
"check_availability": [RBACPermission.Permissions.USER_SETTINGS_READ],
|
||||
"metadata": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
|
||||
"list": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
|
||||
"list": [RBACPermission.Permissions.USER_SETTINGS_READ],
|
||||
"update": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
|
||||
"partial_update": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
|
||||
"verify_number": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
|
||||
|
|
|
|||
|
|
@ -11,9 +11,12 @@ from rest_framework.permissions import IsAuthenticated
|
|||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from apps.api.label_filtering import parse_label_query
|
||||
from apps.api.permissions import RBACPermission
|
||||
from apps.api.serializers.webhook import WebhookResponseSerializer, WebhookSerializer
|
||||
from apps.api.views.labels import schedule_update_label_cache
|
||||
from apps.auth_token.auth import PluginAuthentication
|
||||
from apps.labels.utils import is_labels_feature_enabled
|
||||
from apps.webhooks.models import Webhook, WebhookResponse
|
||||
from apps.webhooks.presets.preset_options import WebhookPresetOptions
|
||||
from apps.webhooks.utils import apply_jinja_template_for_json
|
||||
|
|
@ -91,9 +94,24 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
|
|||
def get_queryset(self, ignore_filtering_by_available_teams=False):
|
||||
queryset = Webhook.objects.filter(
|
||||
organization=self.request.auth.organization,
|
||||
).prefetch_related("responses")
|
||||
)
|
||||
if not ignore_filtering_by_available_teams:
|
||||
queryset = queryset.filter(*self.available_teams_lookup_args).distinct()
|
||||
|
||||
# filter by labels
|
||||
label_query = self.request.query_params.getlist("label", [])
|
||||
kv_pairs = parse_label_query(label_query)
|
||||
for key, value in kv_pairs:
|
||||
queryset = queryset.filter(
|
||||
labels__key_id=key,
|
||||
labels__value_id=value,
|
||||
)
|
||||
# distinct to remove duplicates after webhooks X labels join
|
||||
queryset = queryset.distinct()
|
||||
# schedule update of labels cache
|
||||
ids = [d.id for d in queryset]
|
||||
schedule_update_label_cache(self.model.__name__, self.request.auth.organization, ids)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_object(self):
|
||||
|
|
@ -132,6 +150,15 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
|
|||
},
|
||||
]
|
||||
|
||||
if is_labels_feature_enabled(self.request.auth.organization):
|
||||
filter_options.append(
|
||||
{
|
||||
"name": "label",
|
||||
"display_name": "Label",
|
||||
"type": "labels",
|
||||
}
|
||||
)
|
||||
|
||||
if filter_name is not None:
|
||||
filter_options = list(filter(lambda f: filter_name in f["name"], filter_options))
|
||||
|
||||
|
|
|
|||
|
|
@ -8,14 +8,16 @@ from rest_framework import exceptions
|
|||
from rest_framework.authentication import BaseAuthentication, get_authorization_header
|
||||
from rest_framework.request import Request
|
||||
|
||||
from apps.api.permissions import RBACPermission, user_is_authorized
|
||||
from apps.api.permissions import GrafanaAPIPermission, LegacyAccessControlRole, RBACPermission, user_is_authorized
|
||||
from apps.grafana_plugin.helpers.gcom import check_token
|
||||
from apps.user_management.exceptions import OrganizationDeletedException, OrganizationMovedException
|
||||
from apps.user_management.models import User
|
||||
from apps.user_management.models.organization import Organization
|
||||
from settings.base import SELF_HOSTED_SETTINGS
|
||||
|
||||
from .constants import SCHEDULE_EXPORT_TOKEN_NAME, SLACK_AUTH_TOKEN_NAME
|
||||
from .exceptions import InvalidToken
|
||||
from .grafana.grafana_auth_token import get_service_account_token_permissions
|
||||
from .models import ApiAuthToken, PluginAuthToken, ScheduleExportAuthToken, SlackAuthToken, UserScheduleExportAuthToken
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -262,3 +264,71 @@ class UserScheduleExportAuthentication(BaseAuthentication):
|
|||
raise exceptions.AuthenticationFailed("Export token is deactivated")
|
||||
|
||||
return auth_token.user, auth_token
|
||||
|
||||
|
||||
X_GRAFANA_ORG_SLUG = "X-Grafana-Org-Slug"
|
||||
X_GRAFANA_INSTANCE_SLUG = "X-Grafana-Instance-Slug"
|
||||
GRAFANA_SA_PREFIX = "glsa_"
|
||||
|
||||
|
||||
class GrafanaServiceAccountAuthentication(BaseAuthentication):
|
||||
def authenticate(self, request):
|
||||
auth = get_authorization_header(request).decode("utf-8")
|
||||
if not auth:
|
||||
raise exceptions.AuthenticationFailed("Invalid token.")
|
||||
if not auth.startswith(GRAFANA_SA_PREFIX):
|
||||
return None
|
||||
|
||||
organization = self.get_organization(request)
|
||||
if not organization:
|
||||
raise exceptions.AuthenticationFailed("Invalid organization.")
|
||||
if organization.is_moved:
|
||||
raise OrganizationMovedException(organization)
|
||||
if organization.deleted_at:
|
||||
raise OrganizationDeletedException(organization)
|
||||
|
||||
return self.authenticate_credentials(organization, auth)
|
||||
|
||||
def get_organization(self, request):
|
||||
org_slug = SELF_HOSTED_SETTINGS["ORG_SLUG"]
|
||||
instance_slug = SELF_HOSTED_SETTINGS["STACK_SLUG"]
|
||||
if settings.LICENSE == settings.CLOUD_LICENSE_NAME:
|
||||
org_slug = request.headers.get(X_GRAFANA_ORG_SLUG)
|
||||
if not org_slug:
|
||||
raise exceptions.AuthenticationFailed(f"Missing {X_GRAFANA_ORG_SLUG}")
|
||||
instance_slug = request.headers.get(X_GRAFANA_INSTANCE_SLUG)
|
||||
if not instance_slug:
|
||||
raise exceptions.AuthenticationFailed(f"Missing {X_GRAFANA_INSTANCE_SLUG}")
|
||||
|
||||
return Organization.objects.filter(org_slug=org_slug, stack_slug=instance_slug).first()
|
||||
|
||||
def authenticate_credentials(self, organization, token):
|
||||
permissions = get_service_account_token_permissions(organization, token)
|
||||
if not permissions:
|
||||
raise exceptions.AuthenticationFailed("Invalid token.")
|
||||
|
||||
role = LegacyAccessControlRole.NONE
|
||||
if not organization.is_rbac_permissions_enabled:
|
||||
role = self.determine_role_from_permissions(permissions)
|
||||
|
||||
user = User(
|
||||
organization_id=organization.pk,
|
||||
name="Grafana Service Account",
|
||||
username="grafana_service_account",
|
||||
role=role,
|
||||
permissions=[GrafanaAPIPermission(action=key) for key, _ in permissions.items()],
|
||||
)
|
||||
|
||||
auth_token = ApiAuthToken(organization=organization, user=user, name="Grafana Service Account")
|
||||
|
||||
return user, auth_token
|
||||
|
||||
# Using default permissions as proxies for roles since we cannot explicitly get role from the service account token
|
||||
def determine_role_from_permissions(self, permissions):
|
||||
if "plugins:write" in permissions:
|
||||
return LegacyAccessControlRole.ADMIN
|
||||
if "dashboards:write" in permissions:
|
||||
return LegacyAccessControlRole.EDITOR
|
||||
if "dashboards:read" in permissions:
|
||||
return LegacyAccessControlRole.VIEWER
|
||||
return LegacyAccessControlRole.NONE
|
||||
|
|
|
|||
|
|
@ -1,2 +1,6 @@
|
|||
class InvalidToken(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ServiceAccountDoesNotExist(Exception):
|
||||
pass
|
||||
|
|
|
|||
0
engine/apps/auth_token/grafana/__init__.py
Normal file
0
engine/apps/auth_token/grafana/__init__.py
Normal file
48
engine/apps/auth_token/grafana/grafana_auth_token.py
Normal file
48
engine/apps/auth_token/grafana/grafana_auth_token.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import typing
|
||||
|
||||
from apps.auth_token.exceptions import ServiceAccountDoesNotExist
|
||||
from apps.grafana_plugin.helpers import GrafanaAPIClient
|
||||
from apps.user_management.models import Organization
|
||||
|
||||
SA_ONCALL_API_NAME = "sa-autogen-OnCall"
|
||||
|
||||
|
||||
def find_service_account(
|
||||
organization: Organization, service_account_name=SA_ONCALL_API_NAME
|
||||
) -> typing.Optional["GrafanaAPIClient.Types.GrafanaServiceAccount"]:
|
||||
grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token)
|
||||
response, _ = grafana_api_client.get_service_account(service_account_name)
|
||||
if response and "serviceAccounts" in response and response["serviceAccounts"]:
|
||||
return response["serviceAccounts"][0]
|
||||
return None
|
||||
|
||||
|
||||
def create_service_account(
|
||||
organization: Organization, name: str, role: str
|
||||
) -> GrafanaAPIClient.Types.GrafanaServiceAccount:
|
||||
grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token)
|
||||
response, _ = grafana_api_client.create_service_account(name, role)
|
||||
return response
|
||||
|
||||
|
||||
def create_service_account_token(
|
||||
organization: Organization,
|
||||
token_name: str,
|
||||
seconds_to_live=int | None,
|
||||
service_account_name=SA_ONCALL_API_NAME,
|
||||
) -> typing.Optional[str]:
|
||||
grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token)
|
||||
service_account = find_service_account(organization, service_account_name)
|
||||
if not service_account:
|
||||
raise ServiceAccountDoesNotExist
|
||||
|
||||
response, _ = grafana_api_client.create_service_account_token(service_account["id"], token_name, seconds_to_live)
|
||||
if response:
|
||||
return response["key"]
|
||||
return None
|
||||
|
||||
|
||||
def get_service_account_token_permissions(organization: Organization, token: str) -> typing.Dict[str, typing.List[str]]:
|
||||
grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=token)
|
||||
permissions, _ = grafana_api_client.get_service_account_token_permissions()
|
||||
return permissions
|
||||
80
engine/apps/auth_token/tests/test_grafana_auth.py
Normal file
80
engine/apps/auth_token/tests/test_grafana_auth.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import typing
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
from apps.auth_token.auth import (
|
||||
GRAFANA_SA_PREFIX,
|
||||
X_GRAFANA_INSTANCE_SLUG,
|
||||
X_GRAFANA_ORG_SLUG,
|
||||
GrafanaServiceAccountAuthentication,
|
||||
)
|
||||
from settings.base import CLOUD_LICENSE_NAME, OPEN_SOURCE_LICENSE_NAME, SELF_HOSTED_SETTINGS
|
||||
|
||||
|
||||
def fake_authenticate_credentials(organization, token):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_grafana_authentication_oss_inputs(make_organization, settings):
|
||||
settings.LICENSE = OPEN_SOURCE_LICENSE_NAME
|
||||
|
||||
headers, token = check_common_inputs()
|
||||
organization = make_organization(
|
||||
stack_slug=SELF_HOSTED_SETTINGS["STACK_SLUG"], org_slug=SELF_HOSTED_SETTINGS["ORG_SLUG"]
|
||||
)
|
||||
request = APIRequestFactory().get("/", **headers)
|
||||
with patch(
|
||||
"apps.auth_token.auth.GrafanaServiceAccountAuthentication.authenticate_credentials",
|
||||
wraps=fake_authenticate_credentials,
|
||||
) as mock:
|
||||
GrafanaServiceAccountAuthentication().authenticate(request)
|
||||
mock.assert_called_once_with(organization, token)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_grafana_authentication_cloud_inputs(make_organization, settings):
|
||||
settings.LICENSE = CLOUD_LICENSE_NAME
|
||||
headers, token = check_common_inputs()
|
||||
|
||||
test_org_slug = "test_org_123"
|
||||
test_stack_slug = "test_stack_123"
|
||||
headers[f"HTTP_{X_GRAFANA_ORG_SLUG}"] = test_org_slug
|
||||
headers[f"HTTP_{X_GRAFANA_INSTANCE_SLUG}"] = test_stack_slug
|
||||
request = APIRequestFactory().get("/", **headers)
|
||||
with pytest.raises(exceptions.AuthenticationFailed):
|
||||
GrafanaServiceAccountAuthentication().authenticate(request)
|
||||
|
||||
organization = make_organization(stack_slug=test_stack_slug, org_slug=test_org_slug)
|
||||
with patch(
|
||||
"apps.auth_token.auth.GrafanaServiceAccountAuthentication.authenticate_credentials",
|
||||
wraps=fake_authenticate_credentials,
|
||||
) as mock:
|
||||
GrafanaServiceAccountAuthentication().authenticate(request)
|
||||
mock.assert_called_once_with(organization, token)
|
||||
|
||||
|
||||
def check_common_inputs() -> (dict[str, typing.Any], str):
|
||||
request = APIRequestFactory().get("/")
|
||||
with pytest.raises(exceptions.AuthenticationFailed):
|
||||
GrafanaServiceAccountAuthentication().authenticate(request)
|
||||
|
||||
headers = {
|
||||
"HTTP_AUTHORIZATION": "xyz",
|
||||
}
|
||||
request = APIRequestFactory().get("/", **headers)
|
||||
result = GrafanaServiceAccountAuthentication().authenticate(request)
|
||||
assert result is None
|
||||
|
||||
token = f"{GRAFANA_SA_PREFIX}xyz"
|
||||
headers = {
|
||||
"HTTP_AUTHORIZATION": token,
|
||||
}
|
||||
request = APIRequestFactory().get("/", **headers)
|
||||
with pytest.raises(exceptions.AuthenticationFailed):
|
||||
GrafanaServiceAccountAuthentication().authenticate(request)
|
||||
|
||||
return headers, token
|
||||
|
|
@ -176,9 +176,27 @@ class GrafanaAPIClient(APIClient):
|
|||
avatarUrl: str
|
||||
memberCount: int
|
||||
|
||||
class GrafanaServiceAccount(typing.TypedDict):
|
||||
id: int
|
||||
name: str
|
||||
login: str
|
||||
orgId: int
|
||||
isDisabled: bool
|
||||
role: str
|
||||
tokens: int
|
||||
avatarUrl: str
|
||||
|
||||
class GrafanaServiceAccountToken(typing.TypedDict):
|
||||
id: int
|
||||
name: str
|
||||
key: str
|
||||
|
||||
class TeamsResponse(_BaseGrafanaAPIResponse):
|
||||
teams: typing.List["GrafanaAPIClient.Types.GrafanaTeam"]
|
||||
|
||||
class ServiceAccountResponse(_BaseGrafanaAPIResponse):
|
||||
serviceAccounts: typing.List["GrafanaAPIClient.Types.GrafanaServiceAccount"]
|
||||
|
||||
def __init__(self, api_url: str, api_token: str) -> None:
|
||||
super().__init__(api_url, api_token)
|
||||
|
||||
|
|
@ -274,6 +292,25 @@ class GrafanaAPIClient(APIClient):
|
|||
def get_grafana_plugin_settings(self, recipient: str) -> APIClientResponse:
|
||||
return self.api_get(f"api/plugins/{recipient}/settings")
|
||||
|
||||
def get_service_account(self, login: str) -> APIClientResponse["GrafanaAPIClient.Types.ServiceAccountResponse"]:
|
||||
return self.api_get(f"api/serviceaccounts/search?query={login}")
|
||||
|
||||
def create_service_account(
|
||||
self, name: str, role: str
|
||||
) -> APIClientResponse["GrafanaAPIClient.Types.GrafanaServiceAccount"]:
|
||||
return self.api_post("api/serviceaccounts", {"name": name, "role": role})
|
||||
|
||||
def create_service_account_token(
|
||||
self, service_account_id: int, name: str, seconds_to_live=int | None
|
||||
) -> APIClientResponse["GrafanaAPIClient.Types.GrafanaServiceAccountToken"]:
|
||||
token_config = {"name": name}
|
||||
if seconds_to_live:
|
||||
token_config["secondsToLive"] = seconds_to_live
|
||||
return self.api_post(f"api/serviceaccounts/{service_account_id}/tokens", token_config)
|
||||
|
||||
def get_service_account_token_permissions(self) -> APIClientResponse[typing.Dict[str, typing.List[str]]]:
|
||||
return self.api_get("api/access-control/user/permissions")
|
||||
|
||||
|
||||
class GcomAPIClient(APIClient):
|
||||
ACTIVE_INSTANCE_QUERY = "instances?status=active"
|
||||
|
|
|
|||
161
engine/apps/labels/alert_group_labels.py
Normal file
161
engine/apps/labels/alert_group_labels.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import json
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from apps.labels.utils import is_labels_feature_enabled
|
||||
from common.jinja_templater import apply_jinja_template
|
||||
from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from apps.alerts.models import AlertGroup, AlertReceiveChannel
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# What can be used as a label key/value coming out from the template
|
||||
LABEL_VALUE_TYPES = (str, int, float, bool)
|
||||
|
||||
|
||||
def assign_labels(
|
||||
alert_group: "AlertGroup", alert_receive_channel: "AlertReceiveChannel", raw_request_data: typing.Any
|
||||
) -> None:
|
||||
from apps.labels.models import AlertGroupAssociatedLabel
|
||||
|
||||
if not is_labels_feature_enabled(alert_receive_channel.organization):
|
||||
return
|
||||
|
||||
# inherit labels from the integration
|
||||
labels = {
|
||||
label.key.name: label.value.name
|
||||
for label in alert_receive_channel.labels.filter(inheritable=True).select_related("key", "value")
|
||||
}
|
||||
|
||||
# apply custom labels
|
||||
labels.update(_custom_labels(alert_receive_channel, raw_request_data))
|
||||
|
||||
# apply template labels
|
||||
labels.update(_template_labels(alert_receive_channel, raw_request_data))
|
||||
|
||||
# create associated labels
|
||||
alert_group_labels = [
|
||||
AlertGroupAssociatedLabel(
|
||||
alert_group=alert_group,
|
||||
organization=alert_receive_channel.organization,
|
||||
key_name=key,
|
||||
value_name=value,
|
||||
)
|
||||
for key, value in labels.items()
|
||||
]
|
||||
# sort associated labels by key and value
|
||||
alert_group_labels.sort(key=lambda label: (label.key_name, label.value_name))
|
||||
# bulk create associated labels
|
||||
AlertGroupAssociatedLabel.objects.bulk_create(alert_group_labels)
|
||||
|
||||
|
||||
def _custom_labels(alert_receive_channel: "AlertReceiveChannel", raw_request_data: typing.Any) -> dict[str, str]:
|
||||
from apps.labels.models import MAX_VALUE_NAME_LENGTH, LabelKeyCache, LabelValueCache
|
||||
|
||||
if alert_receive_channel.alert_group_labels_custom is None:
|
||||
return {}
|
||||
|
||||
# fetch up-to-date label key names
|
||||
label_key_names = {
|
||||
k.id: k.name
|
||||
for k in LabelKeyCache.objects.filter(
|
||||
id__in=[label[0] for label in alert_receive_channel.alert_group_labels_custom]
|
||||
).only("id", "name")
|
||||
}
|
||||
|
||||
# fetch up-to-date label value names
|
||||
label_value_names = {
|
||||
v.id: v.name
|
||||
for v in LabelValueCache.objects.filter(
|
||||
id__in=[label[1] for label in alert_receive_channel.alert_group_labels_custom if label[1]]
|
||||
).only("id", "name")
|
||||
}
|
||||
|
||||
rendered_labels = {}
|
||||
for label in alert_receive_channel.alert_group_labels_custom:
|
||||
key_id, value_id, template = label
|
||||
|
||||
if key_id in label_key_names:
|
||||
key = label_key_names[key_id]
|
||||
else:
|
||||
logger.warning("Label key cache not found. %s", key_id)
|
||||
continue
|
||||
|
||||
if value_id:
|
||||
if value_id in label_value_names:
|
||||
rendered_labels[key] = label_value_names[value_id]
|
||||
else:
|
||||
logger.warning("Label value cache not found. %s", value_id)
|
||||
continue
|
||||
else:
|
||||
try:
|
||||
rendered_labels[key] = apply_jinja_template(template, raw_request_data)
|
||||
except (JinjaTemplateError, JinjaTemplateWarning) as e:
|
||||
logger.warning("Failed to apply template. %s", e.fallback_message)
|
||||
continue
|
||||
|
||||
labels = {}
|
||||
for key in rendered_labels:
|
||||
value = rendered_labels[key]
|
||||
|
||||
# check value length
|
||||
if len(value) > MAX_VALUE_NAME_LENGTH:
|
||||
logger.warning("Template result value is too long. %s", value)
|
||||
continue
|
||||
|
||||
labels[key] = value
|
||||
|
||||
return labels
|
||||
|
||||
|
||||
def _template_labels(alert_receive_channel: "AlertReceiveChannel", raw_request_data: typing.Any) -> dict[str, str]:
|
||||
from apps.labels.models import MAX_KEY_NAME_LENGTH, MAX_VALUE_NAME_LENGTH
|
||||
|
||||
if not alert_receive_channel.alert_group_labels_template:
|
||||
return {}
|
||||
|
||||
try:
|
||||
rendered = apply_jinja_template(alert_receive_channel.alert_group_labels_template, raw_request_data)
|
||||
except (JinjaTemplateError, JinjaTemplateWarning) as e:
|
||||
logger.warning("Failed to apply template. %s", e.fallback_message)
|
||||
return {}
|
||||
|
||||
try:
|
||||
rendered_labels = json.loads(rendered)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
logger.warning("Failed to parse template result. %s", rendered)
|
||||
return {}
|
||||
|
||||
if not isinstance(rendered_labels, dict):
|
||||
logger.warning("Template result is not a dict. %s", rendered_labels)
|
||||
return {}
|
||||
|
||||
labels = {}
|
||||
for key in rendered_labels:
|
||||
value = rendered_labels[key]
|
||||
|
||||
# check value type
|
||||
if not isinstance(value, LABEL_VALUE_TYPES):
|
||||
logger.warning("Template result value has invalid type. %s", value)
|
||||
continue
|
||||
|
||||
# convert value to string
|
||||
value = str(value)
|
||||
|
||||
# check key length
|
||||
if len(key) > MAX_KEY_NAME_LENGTH:
|
||||
logger.warning("Template result key is too long. %s", key)
|
||||
continue
|
||||
|
||||
# check value length
|
||||
if len(value) > MAX_VALUE_NAME_LENGTH:
|
||||
logger.warning("Template result value is too long. %s", value)
|
||||
continue
|
||||
|
||||
labels[key] = value
|
||||
|
||||
return labels
|
||||
29
engine/apps/labels/migrations/0004_webhookassociatedlabel.py
Normal file
29
engine/apps/labels/migrations/0004_webhookassociatedlabel.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Generated by Django 4.2.7 on 2023-11-22 06:10
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('webhooks', '0011_auto_20230920_1813'),
|
||||
('user_management', '0017_alter_organization_maintenance_author'),
|
||||
('labels', '0003_alertreceivechannelassociatedlabel_inherit'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WebhookAssociatedLabel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='labels.labelkeycache')),
|
||||
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webhook_labels', to='user_management.organization')),
|
||||
('value', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='labels.labelvaluecache')),
|
||||
('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='labels', to='webhooks.webhook')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('key_id', 'value_id', 'webhook_id')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -10,9 +10,13 @@ if typing.TYPE_CHECKING:
|
|||
from apps.user_management.models import Organization
|
||||
|
||||
|
||||
MAX_KEY_NAME_LENGTH = 200
|
||||
MAX_VALUE_NAME_LENGTH = 200
|
||||
|
||||
|
||||
class LabelKeyCache(models.Model):
|
||||
id = models.CharField(primary_key=True, editable=False, max_length=36)
|
||||
name = models.CharField(max_length=200)
|
||||
name = models.CharField(max_length=MAX_KEY_NAME_LENGTH)
|
||||
organization = models.ForeignKey("user_management.Organization", on_delete=models.CASCADE)
|
||||
last_synced = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
|
@ -23,7 +27,7 @@ class LabelKeyCache(models.Model):
|
|||
|
||||
class LabelValueCache(models.Model):
|
||||
id = models.CharField(primary_key=True, editable=False, max_length=36)
|
||||
name = models.CharField(max_length=200)
|
||||
name = models.CharField(max_length=MAX_VALUE_NAME_LENGTH)
|
||||
key = models.ForeignKey("labels.LabelKeyCache", on_delete=models.CASCADE, related_name="values")
|
||||
last_synced = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
|
@ -129,8 +133,8 @@ class AlertGroupAssociatedLabel(models.Model):
|
|||
"user_management.Organization", on_delete=models.CASCADE, related_name="alert_group_labels"
|
||||
)
|
||||
|
||||
key_name = models.CharField(max_length=200)
|
||||
value_name = models.CharField(max_length=200)
|
||||
key_name = models.CharField(max_length=MAX_KEY_NAME_LENGTH)
|
||||
value_name = models.CharField(max_length=MAX_VALUE_NAME_LENGTH)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
|
|
@ -139,3 +143,24 @@ class AlertGroupAssociatedLabel(models.Model):
|
|||
name="unique_alert_group_label",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class WebhookAssociatedLabel(AssociatedLabel):
|
||||
"""Keeps information about label association with outgoing webhooks instances"""
|
||||
|
||||
webhook = models.ForeignKey(
|
||||
"webhooks.Webhook",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="labels",
|
||||
)
|
||||
organization = models.ForeignKey(
|
||||
"user_management.Organization", on_delete=models.CASCADE, related_name="webhook_labels"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["key_id", "value_id", "webhook_id"]
|
||||
|
||||
@staticmethod
|
||||
def get_associating_label_field_name() -> str:
|
||||
"""Returns ForeignKey field name for the associated model"""
|
||||
return "webhook"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,13 @@ from django.conf import settings
|
|||
from django.utils import timezone
|
||||
|
||||
from apps.labels.client import LabelsAPIClient
|
||||
from apps.labels.utils import LABEL_OUTDATED_TIMEOUT_MINUTES, LabelKeyData, LabelsData, get_associating_label_model
|
||||
from apps.labels.utils import (
|
||||
LABEL_OUTDATED_TIMEOUT_MINUTES,
|
||||
LabelKeyData,
|
||||
LabelsData,
|
||||
ValueData,
|
||||
get_associating_label_model,
|
||||
)
|
||||
from apps.user_management.models import Organization
|
||||
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
|
||||
|
||||
|
|
@ -14,11 +20,6 @@ logger = get_task_logger(__name__)
|
|||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
class ValueData(typing.TypedDict):
|
||||
value_name: str
|
||||
key_name: str
|
||||
|
||||
|
||||
def unify_labels_data(labels_data: LabelsData | LabelKeyData) -> typing.Dict[str, ValueData]:
|
||||
values_data: typing.Dict[str, ValueData]
|
||||
if isinstance(labels_data, list): # LabelsData
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from apps.labels.models import (
|
|||
AlertReceiveChannelAssociatedLabel,
|
||||
LabelKeyCache,
|
||||
LabelValueCache,
|
||||
WebhookAssociatedLabel,
|
||||
)
|
||||
from common.utils import UniqueFaker
|
||||
|
||||
|
|
@ -33,3 +34,8 @@ class AlertReceiveChannelAssociatedLabelFactory(factory.DjangoModelFactory):
|
|||
class AlertGroupAssociatedLabelFactory(factory.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = AlertGroupAssociatedLabel
|
||||
|
||||
|
||||
class WebhookAssociatedLabelFactory(factory.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = WebhookAssociatedLabel
|
||||
|
|
|
|||
|
|
@ -3,9 +3,13 @@ from unittest import mock
|
|||
import pytest
|
||||
|
||||
from apps.alerts.models import Alert
|
||||
from apps.labels.models import MAX_KEY_NAME_LENGTH, MAX_VALUE_NAME_LENGTH
|
||||
|
||||
TOO_LONG_KEY_NAME = "k" * (MAX_KEY_NAME_LENGTH + 1)
|
||||
TOO_LONG_VALUE_NAME = "v" * (MAX_VALUE_NAME_LENGTH + 1)
|
||||
|
||||
|
||||
@mock.patch("apps.labels.utils.is_labels_feature_enabled", return_value=False)
|
||||
@mock.patch("apps.labels.alert_group_labels.is_labels_feature_enabled", return_value=False)
|
||||
@pytest.mark.django_db
|
||||
def test_assign_labels_feature_flag_disabled(
|
||||
_, make_organization, make_alert_receive_channel, make_integration_label_association
|
||||
|
|
@ -28,11 +32,75 @@ def test_assign_labels_feature_flag_disabled(
|
|||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_assign_labels(make_organization, make_alert_receive_channel, make_integration_label_association):
|
||||
def test_assign_labels(
|
||||
make_organization,
|
||||
make_alert_receive_channel,
|
||||
make_label_key_and_value,
|
||||
make_label_key,
|
||||
make_integration_label_association,
|
||||
):
|
||||
organization = make_organization()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
label = make_integration_label_association(organization, alert_receive_channel)
|
||||
make_integration_label_association(organization, alert_receive_channel, inheritable=False)
|
||||
|
||||
# create label repo labels
|
||||
label_key, label_value = make_label_key_and_value(organization, key_name="a", value_name="b")
|
||||
label_key_1 = make_label_key(organization=organization, key_name="c")
|
||||
label_key_2 = make_label_key(organization=organization)
|
||||
label_key_3 = make_label_key(organization=organization)
|
||||
|
||||
# create alert receive channel with all 3 types of labels
|
||||
alert_receive_channel = make_alert_receive_channel(
|
||||
organization,
|
||||
alert_group_labels_custom=[
|
||||
[label_key.id, label_value.id, None], # plain label
|
||||
["nonexistent", label_value.id, None], # plain label with nonexistent key ID
|
||||
[label_key_2.id, "nonexistent", None], # plain label with nonexistent value ID
|
||||
[label_key_1.id, None, "{{ payload.c }}"], # templated label
|
||||
[label_key_3.id, None, TOO_LONG_VALUE_NAME], # templated label too long
|
||||
],
|
||||
alert_group_labels_template="{{ payload.advanced_template | tojson }}",
|
||||
)
|
||||
make_integration_label_association(organization, alert_receive_channel, key_name="e", value_name="f")
|
||||
|
||||
# create alert group
|
||||
alert = Alert.create(
|
||||
title="the title",
|
||||
message="the message",
|
||||
alert_receive_channel=alert_receive_channel,
|
||||
raw_request_data={
|
||||
"c": "d",
|
||||
"advanced_template": {
|
||||
"g": 123,
|
||||
"too_long": TOO_LONG_VALUE_NAME,
|
||||
TOO_LONG_KEY_NAME: "too_long",
|
||||
"invalid_type": {"test": "test"},
|
||||
},
|
||||
"extra": "hi",
|
||||
},
|
||||
integration_unique_data={},
|
||||
image_url=None,
|
||||
link_to_upstream_details=None,
|
||||
)
|
||||
|
||||
# check alert group labels are assigned correctly, in the lexicographical order
|
||||
assert [(label.key_name, label.value_name) for label in alert.group.labels.all()] == [
|
||||
("a", "b"),
|
||||
("c", "d"),
|
||||
("e", "f"),
|
||||
("g", "123"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_assign_labels_custom_labels_none(
|
||||
make_organization,
|
||||
make_alert_receive_channel,
|
||||
make_label_key_and_value,
|
||||
make_label_key,
|
||||
make_integration_label_association,
|
||||
):
|
||||
organization = make_organization()
|
||||
alert_receive_channel = make_alert_receive_channel(organization, alert_group_labels_custom=None)
|
||||
make_integration_label_association(organization, alert_receive_channel, key_name="a", value_name="b")
|
||||
|
||||
alert = Alert.create(
|
||||
title="the title",
|
||||
|
|
@ -44,6 +112,4 @@ def test_assign_labels(make_organization, make_alert_receive_channel, make_integ
|
|||
link_to_upstream_details=None,
|
||||
)
|
||||
|
||||
assert alert.group.labels.count() == 1
|
||||
assert alert.group.labels.first().key_name == label.key.name
|
||||
assert alert.group.labels.first().value_name == label.value.name
|
||||
assert [(label.key_name, label.value_name) for label in alert.group.labels.all()] == [("a", "b")]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
import pytest
|
||||
|
||||
from apps.alerts.models import AlertReceiveChannel
|
||||
from apps.labels.models import AlertReceiveChannelAssociatedLabel, AssociatedLabel, LabelValueCache
|
||||
from apps.labels.models import (
|
||||
AlertReceiveChannelAssociatedLabel,
|
||||
AssociatedLabel,
|
||||
LabelValueCache,
|
||||
WebhookAssociatedLabel,
|
||||
)
|
||||
from apps.labels.utils import get_associating_label_model, is_labels_feature_enabled
|
||||
from apps.webhooks.models import Webhook
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -104,6 +110,11 @@ def test_get_associating_label_model():
|
|||
result = get_associating_label_model(model_name)
|
||||
assert result == expected_result
|
||||
|
||||
model_name = Webhook.__name__
|
||||
expected_result = WebhookAssociatedLabel
|
||||
result = get_associating_label_model(model_name)
|
||||
assert result == expected_result
|
||||
|
||||
wrong_model_name = "SomeModel"
|
||||
with pytest.raises(LookupError):
|
||||
get_associating_label_model(wrong_model_name)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import logging
|
||||
import typing
|
||||
|
||||
from django.apps import apps # noqa: I251
|
||||
from django.conf import settings
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from apps.alerts.models import AlertGroup, AlertReceiveChannel
|
||||
from apps.alerts.models import AlertGroup
|
||||
from apps.labels.models import AssociatedLabel
|
||||
from apps.user_management.models import Organization
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
LABEL_OUTDATED_TIMEOUT_MINUTES = 30
|
||||
ASSOCIATED_MODEL_NAME = "AssociatedLabel"
|
||||
|
|
@ -27,6 +30,11 @@ class LabelData(typing.TypedDict):
|
|||
value: LabelParams
|
||||
|
||||
|
||||
class ValueData(typing.TypedDict):
|
||||
value_name: str
|
||||
key_name: str
|
||||
|
||||
|
||||
class LabelKeyData(typing.TypedDict):
|
||||
key: LabelParams
|
||||
values: typing.List[LabelParams]
|
||||
|
|
@ -49,20 +57,10 @@ def is_labels_feature_enabled(organization: "Organization") -> bool:
|
|||
)
|
||||
|
||||
|
||||
def assign_labels(alert_group: "AlertGroup", alert_receive_channel: "AlertReceiveChannel") -> None:
|
||||
from apps.labels.models import AlertGroupAssociatedLabel
|
||||
def get_label_verbal(obj: typing.Any) -> dict[str, str]:
|
||||
return {label.key.name: label.value.name for label in obj.labels.all().select_related("key", "value")}
|
||||
|
||||
if not is_labels_feature_enabled(alert_receive_channel.organization):
|
||||
return
|
||||
|
||||
# inherit labels from the integration
|
||||
alert_group_labels = [
|
||||
AlertGroupAssociatedLabel(
|
||||
alert_group=alert_group,
|
||||
organization=alert_receive_channel.organization,
|
||||
key_name=label.key.name,
|
||||
value_name=label.value.name,
|
||||
)
|
||||
for label in alert_receive_channel.labels.filter(inheritable=True).select_related("key", "value")
|
||||
]
|
||||
AlertGroupAssociatedLabel.objects.bulk_create(alert_group_labels)
|
||||
def get_alert_group_label_verbal(alert_group: "AlertGroup") -> dict[str, str]:
|
||||
"""This is different from get_label_verbal because alert group labels store key/value names, not IDs"""
|
||||
return {label.key_name: label.value_name for label in alert_group.labels.all()}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ class ResolutionNoteSerializer(EagerLoadingMixin, serializers.ModelSerializer):
|
|||
SELECT_RELATED = ["alert_group", "resolution_note_slack_message", "author"]
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data["author"] = self.context["request"].user
|
||||
if self.context["request"].user.pk:
|
||||
validated_data["author"] = self.context["request"].user
|
||||
validated_data["source"] = ResolutionNote.Source.WEB
|
||||
return super().create(validated_data)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
from rest_framework import fields, serializers
|
||||
|
||||
from apps.alerts.models import AlertReceiveChannel, ChannelFilter, EscalationChain
|
||||
from apps.api.serializers.alert_receive_channel import valid_jinja_template_for_serializer_method_field
|
||||
from apps.base.messaging import get_messaging_backend_from_id, get_messaging_backends
|
||||
from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
from common.api_helpers.utils import valid_jinja_template_for_serializer_method_field
|
||||
from common.jinja_templater.apply_jinja_template import JinjaTemplateError
|
||||
from common.ordered_model.serializer import OrderedModelSerializer
|
||||
from common.utils import is_regex_valid
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.alerts.models import ResolutionNote
|
||||
from apps.auth_token.auth import GRAFANA_SA_PREFIX, ApiTokenAuthentication, GrafanaServiceAccountAuthentication
|
||||
from apps.auth_token.models import ApiAuthToken
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -273,3 +277,75 @@ def test_delete_resolution_note(
|
|||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.data["detail"] == "Not found."
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_resolution_note_grafana_auth(make_organization_and_user, make_alert_receive_channel, make_alert_group):
|
||||
organization, user = make_organization_and_user()
|
||||
client = APIClient()
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
|
||||
url = reverse("api-public:resolution_notes-list")
|
||||
|
||||
data = {
|
||||
"alert_group_id": alert_group.public_primary_key,
|
||||
"text": "Test Resolution Note Message",
|
||||
}
|
||||
|
||||
api_token_auth = ApiTokenAuthentication()
|
||||
grafana_sa_auth = GrafanaServiceAccountAuthentication()
|
||||
|
||||
# GrafanaServiceAccountAuthentication handles empty auth
|
||||
with patch(
|
||||
"apps.auth_token.auth.ApiTokenAuthentication.authenticate", wraps=api_token_auth.authenticate
|
||||
) as mock_api_key_auth, patch(
|
||||
"apps.auth_token.auth.GrafanaServiceAccountAuthentication.authenticate", wraps=grafana_sa_auth.authenticate
|
||||
) as mock_grafana_auth:
|
||||
response = client.post(url, data=data, format="json")
|
||||
mock_grafana_auth.assert_called_once()
|
||||
mock_api_key_auth.assert_not_called()
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
token = "abc123"
|
||||
# GrafanaServiceAccountAuthentication passes through api key auth
|
||||
with patch(
|
||||
"apps.auth_token.auth.ApiTokenAuthentication.authenticate", wraps=api_token_auth.authenticate
|
||||
) as mock_api_key_auth, patch(
|
||||
"apps.auth_token.auth.GrafanaServiceAccountAuthentication.authenticate", wraps=grafana_sa_auth.authenticate
|
||||
) as mock_grafana_auth:
|
||||
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
|
||||
mock_grafana_auth.assert_called_once()
|
||||
mock_api_key_auth.assert_called_once()
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
token = f"{GRAFANA_SA_PREFIX}123"
|
||||
# GrafanaServiceAccountAuthentication handle invalid token
|
||||
with patch(
|
||||
"apps.auth_token.auth.ApiTokenAuthentication.authenticate", wraps=api_token_auth.authenticate
|
||||
) as mock_api_key_auth, patch(
|
||||
"apps.auth_token.auth.GrafanaServiceAccountAuthentication.authenticate", wraps=grafana_sa_auth.authenticate
|
||||
) as mock_grafana_auth:
|
||||
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
|
||||
mock_grafana_auth.assert_called_once()
|
||||
mock_api_key_auth.assert_not_called()
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
success_token = ApiAuthToken(organization=organization, user=user, name="Grafana Service Account")
|
||||
# GrafanaServiceAccountAuthentication handle successful token
|
||||
with patch(
|
||||
"apps.auth_token.auth.GrafanaServiceAccountAuthentication.authenticate", return_value=(user, success_token)
|
||||
):
|
||||
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
resolution_note = ResolutionNote.objects.get(public_primary_key=response.data["id"])
|
||||
result = {
|
||||
"id": resolution_note.public_primary_key,
|
||||
"alert_group_id": alert_group.public_primary_key,
|
||||
"author": user.public_primary_key,
|
||||
"source": resolution_note.get_source_display(),
|
||||
"created_at": response.data["created_at"],
|
||||
"text": data["text"],
|
||||
}
|
||||
assert response.data == result
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ from apps.public_api.helpers import is_valid_group_creation_date, team_has_slack
|
|||
from apps.public_api.serializers import IncidentSerializer
|
||||
from apps.public_api.throttlers.user_throttle import UserThrottle
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, get_team_queryset
|
||||
from common.api_helpers.filters import NO_TEAM_VALUE, ByTeamModelFieldFilterMixin, get_team_queryset
|
||||
from common.api_helpers.mixins import RateLimitHeadersMixin
|
||||
from common.api_helpers.paginators import FiftyPageSizePaginator
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ class IncidentByTeamFilter(ByTeamModelFieldFilterMixin, filters.FilterSet):
|
|||
queryset=get_team_queryset,
|
||||
to_field_name="public_primary_key",
|
||||
null_label="noteam",
|
||||
null_value="null",
|
||||
null_value=NO_TEAM_VALUE,
|
||||
method=ByTeamModelFieldFilterMixin.filter_model_field_with_single_value.__name__,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ from rest_framework.viewsets import ModelViewSet
|
|||
|
||||
from apps.alerts.models import ResolutionNote
|
||||
from apps.alerts.tasks import send_update_resolution_note_signal
|
||||
from apps.auth_token.auth import ApiTokenAuthentication
|
||||
from apps.api.permissions import RBACPermission
|
||||
from apps.auth_token.auth import ApiTokenAuthentication, GrafanaServiceAccountAuthentication
|
||||
from apps.public_api.serializers.resolution_notes import ResolutionNoteSerializer, ResolutionNoteUpdateSerializer
|
||||
from apps.public_api.throttlers.user_throttle import UserThrottle
|
||||
from common.api_helpers.mixins import RateLimitHeadersMixin, UpdateSerializerMixin
|
||||
|
|
@ -13,8 +14,18 @@ from common.api_helpers.paginators import FiftyPageSizePaginator
|
|||
|
||||
|
||||
class ResolutionNoteView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet):
|
||||
authentication_classes = (ApiTokenAuthentication,)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication)
|
||||
permission_classes = (IsAuthenticated, RBACPermission)
|
||||
|
||||
rbac_permissions = {
|
||||
"metadata": [RBACPermission.Permissions.ALERT_GROUPS_READ],
|
||||
"list": [RBACPermission.Permissions.ALERT_GROUPS_READ],
|
||||
"retrieve": [RBACPermission.Permissions.ALERT_GROUPS_READ],
|
||||
"create": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
|
||||
"update": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
|
||||
"partial_update": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
|
||||
"destroy": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
|
||||
}
|
||||
|
||||
throttle_classes = [UserThrottle]
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,18 @@ class SlackFormatter(SlackFormatterBase):
|
|||
|
||||
return message
|
||||
|
||||
def slack_to_accepted_emoji(self, message):
|
||||
"""Overridden original method to fix regex that replaces dashes in links"""
|
||||
message = re.sub(
|
||||
r":([a-zA-Z0-9<>/:])([^ <>/:]+):", # overridden regex
|
||||
lambda x: ":{}{}:".format(x.group(1), x.group(2).replace("-", "_")),
|
||||
message,
|
||||
)
|
||||
|
||||
# https://github.com/Ranks/emojione/issues/114
|
||||
message = message.replace(":simple_smile:", ":slightly_smiling_face:")
|
||||
return message
|
||||
|
||||
def _sub_hyperlink(self, matchobj):
|
||||
compound = matchobj.group(0)[1:-1]
|
||||
if len(compound.split("|")) == 2:
|
||||
|
|
|
|||
16
engine/apps/slack/tests/test_slack_formatter.py
Normal file
16
engine/apps/slack/tests/test_slack_formatter.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.slack.slack_formatter import SlackFormatter
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_slack_to_accepted_emoji():
|
||||
sf = SlackFormatter(MagicMock())
|
||||
test_message = """[:book: Runbook:link:](https://example-test.com/explore?panes=%7B:%7Bname-with-dash%22:%22FE%22:%5B%7B%22another-one%22:namespace-with-dash)
|
||||
Test emoji :male-construction-worker:https://another-example.com/test:=%22-dash
|
||||
:female-construction-worker:"""
|
||||
expected_result = test_message.replace("-construction-worker", "_construction_worker")
|
||||
result = sf.slack_to_accepted_emoji(test_message)
|
||||
assert result == expected_result
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import typing
|
||||
|
||||
from apps.webhooks.models import Webhook
|
||||
from apps.webhooks.presets.preset import WebhookPreset, WebhookPresetMetadata
|
||||
|
||||
|
|
@ -17,3 +19,6 @@ class AdvancedWebhookPreset(WebhookPreset):
|
|||
|
||||
def override_parameters_at_runtime(self, webhook: Webhook):
|
||||
pass
|
||||
|
||||
def get_masked_headers(self) -> typing.List[str]:
|
||||
return []
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import typing
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
|
@ -34,3 +35,8 @@ class WebhookPreset(ABC):
|
|||
def override_parameters_at_runtime(self, webhook: Webhook):
|
||||
"""Implement this to write parameters before the webhook is executed (These will not be persisted)"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_masked_headers(self) -> typing.List[str]:
|
||||
"""Implement this to write sensitive header data as ******** when writing to logs"""
|
||||
return []
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import typing
|
||||
|
||||
from apps.webhooks.models import Webhook
|
||||
from apps.webhooks.presets.preset import WebhookPreset, WebhookPresetMetadata
|
||||
|
||||
|
|
@ -30,3 +32,6 @@ class SimpleWebhookPreset(WebhookPreset):
|
|||
|
||||
def override_parameters_at_runtime(self, webhook: Webhook):
|
||||
pass
|
||||
|
||||
def get_masked_headers(self) -> typing.List[str]:
|
||||
return []
|
||||
|
|
|
|||
|
|
@ -91,15 +91,17 @@ def _build_payload(webhook, alert_group, user):
|
|||
response_data = r.content
|
||||
responses_data[r.webhook.public_primary_key] = response_data
|
||||
|
||||
data = serialize_event(event, alert_group, user, responses_data)
|
||||
data = serialize_event(event, alert_group, user, webhook, responses_data)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def mask_authorization_header(headers):
|
||||
def mask_authorization_header(headers, header_keys_to_mask):
|
||||
masked_headers = headers.copy()
|
||||
if "Authorization" in masked_headers:
|
||||
masked_headers["Authorization"] = WEBHOOK_FIELD_PLACEHOLDER
|
||||
lower_keys = set(k.lower() for k in header_keys_to_mask)
|
||||
for k in headers.keys():
|
||||
if k.lower() in lower_keys:
|
||||
masked_headers[k] = WEBHOOK_FIELD_PLACEHOLDER
|
||||
return masked_headers
|
||||
|
||||
|
||||
|
|
@ -114,6 +116,7 @@ def make_request(webhook, alert_group, data):
|
|||
"webhook": webhook,
|
||||
"event_data": json.dumps(data),
|
||||
}
|
||||
masked_header_keys = ["Authorization"]
|
||||
|
||||
exception = error = None
|
||||
try:
|
||||
|
|
@ -121,7 +124,9 @@ def make_request(webhook, alert_group, data):
|
|||
if webhook.preset not in WebhookPresetOptions.WEBHOOK_PRESETS:
|
||||
raise Exception(f"Invalid preset {webhook.preset}")
|
||||
else:
|
||||
WebhookPresetOptions.WEBHOOK_PRESETS[webhook.preset].override_parameters_at_runtime(webhook)
|
||||
preset = WebhookPresetOptions.WEBHOOK_PRESETS[webhook.preset]
|
||||
preset.override_parameters_at_runtime(webhook)
|
||||
masked_header_keys.extend(preset.get_masked_headers())
|
||||
|
||||
if not webhook.check_integration_filter(alert_group):
|
||||
status["request_trigger"] = NOT_FROM_SELECTED_INTEGRATION
|
||||
|
|
@ -131,7 +136,7 @@ def make_request(webhook, alert_group, data):
|
|||
if triggered:
|
||||
status["url"] = webhook.build_url(data)
|
||||
request_kwargs = webhook.build_request_kwargs(data, raise_data_errors=True)
|
||||
display_headers = mask_authorization_header(request_kwargs.get("headers", {}))
|
||||
display_headers = mask_authorization_header(request_kwargs.get("headers", {}), masked_header_keys)
|
||||
status["request_headers"] = json.dumps(display_headers)
|
||||
if "json" in request_kwargs:
|
||||
status["request_data"] = json.dumps(request_kwargs["json"])
|
||||
|
|
|
|||
|
|
@ -302,6 +302,7 @@ def test_execute_webhook_ok_forward_all(
|
|||
"type": alert_receive_channel.integration,
|
||||
"name": alert_receive_channel.short_name,
|
||||
"team": None,
|
||||
"labels": {},
|
||||
},
|
||||
"notified_users": [
|
||||
{
|
||||
|
|
@ -310,10 +311,15 @@ def test_execute_webhook_ok_forward_all(
|
|||
"email": notified_user.email,
|
||||
}
|
||||
],
|
||||
"alert_group": IncidentSerializer(alert_group).data,
|
||||
"alert_group": {**IncidentSerializer(alert_group).data, "labels": {}},
|
||||
"alert_group_id": alert_group.public_primary_key,
|
||||
"alert_payload": "",
|
||||
"users_to_be_notified": [],
|
||||
"webhook": {
|
||||
"id": webhook.public_primary_key,
|
||||
"name": webhook.name,
|
||||
"labels": {},
|
||||
},
|
||||
}
|
||||
expected_call = call(
|
||||
"https://something/{}/".format(alert_group.public_primary_key),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import json
|
||||
import typing
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.webhooks.models import Webhook
|
||||
from apps.webhooks.models.webhook import WEBHOOK_FIELD_PLACEHOLDER
|
||||
from apps.webhooks.presets.preset import WebhookPreset, WebhookPresetMetadata
|
||||
from apps.webhooks.tasks.trigger_webhook import make_request
|
||||
from apps.webhooks.tests.test_trigger_webhook import MockResponse
|
||||
|
|
@ -14,6 +17,8 @@ TEST_WEBHOOK_LOGO = "test_logo"
|
|||
TEST_WEBHOOK_PRESET_DESCRIPTION = "Description of test webhook preset"
|
||||
TEST_WEBHOOK_PRESET_CONTROLLED_FIELDS = ["url", "http_method", "data", "authorization_header"]
|
||||
TEST_WEBHOOK_AUTHORIZATION_HEADER = "Test Auth header 12345"
|
||||
TEST_WEBHOOK_MASK_HEADER = "X-Secret-Header"
|
||||
TEST_WEBHOOK_MASK_HEADER_VALUE = "abc123"
|
||||
INVALID_PRESET_ID = "invalid_preset_id"
|
||||
|
||||
|
||||
|
|
@ -34,6 +39,12 @@ class TestWebhookPreset(WebhookPreset):
|
|||
|
||||
def override_parameters_at_runtime(self, webhook: Webhook):
|
||||
webhook.authorization_header = TEST_WEBHOOK_AUTHORIZATION_HEADER
|
||||
webhook.headers = json.dumps(
|
||||
{"Content-Type": "application/json", TEST_WEBHOOK_MASK_HEADER: TEST_WEBHOOK_MASK_HEADER_VALUE}
|
||||
)
|
||||
|
||||
def get_masked_headers(self) -> typing.List[str]:
|
||||
return [TEST_WEBHOOK_MASK_HEADER]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -124,11 +135,20 @@ def test_webhook_preset_runtime_override(make_organization, webhook_preset_api_s
|
|||
with patch.object(webhook, "build_url"):
|
||||
response = MockResponse()
|
||||
with patch.object(webhook, "make_request", return_value=response) as mock_make_request:
|
||||
triggered, webhook_status, error, exception = make_request(webhook, None, None)
|
||||
triggered, webhook_status, error, exception = make_request(webhook, None, {})
|
||||
assert mock_make_request.call_args.args[1]["headers"]["Content-Type"] == "application/json"
|
||||
assert mock_make_request.call_args.args[1]["headers"]["Authorization"] == TEST_WEBHOOK_AUTHORIZATION_HEADER
|
||||
assert (
|
||||
mock_make_request.call_args.args[1]["headers"][TEST_WEBHOOK_MASK_HEADER]
|
||||
== TEST_WEBHOOK_MASK_HEADER_VALUE
|
||||
)
|
||||
assert triggered
|
||||
assert error is None
|
||||
assert exception is None
|
||||
webhook_status_headers = json.loads(webhook_status["request_headers"])
|
||||
assert webhook_status_headers["Content-Type"] == "application/json"
|
||||
assert webhook_status_headers["Authorization"] == WEBHOOK_FIELD_PLACEHOLDER
|
||||
assert webhook_status_headers[TEST_WEBHOOK_MASK_HEADER] == WEBHOOK_FIELD_PLACEHOLDER
|
||||
|
||||
webhook.refresh_from_db()
|
||||
assert webhook.authorization_header is None
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from urllib.parse import urlparse
|
|||
from django.conf import settings
|
||||
|
||||
from apps.base.utils import live_settings
|
||||
from apps.labels.utils import get_alert_group_label_verbal, get_label_verbal, is_labels_feature_enabled
|
||||
from apps.schedules.ical_utils import list_users_to_notify_from_ical
|
||||
from common.jinja_templater import apply_jinja_template
|
||||
|
||||
|
|
@ -150,7 +151,7 @@ def _extract_users_from_escalation_snapshot(escalation_snapshot):
|
|||
return list({u["id"]: u for u in users if u}.values())
|
||||
|
||||
|
||||
def serialize_event(event, alert_group, user, responses=None):
|
||||
def serialize_event(event, alert_group, user, webhook, responses=None):
|
||||
from apps.public_api.serializers import IncidentSerializer
|
||||
|
||||
alert_payload = alert_group.alerts.first()
|
||||
|
|
@ -179,4 +180,10 @@ def serialize_event(event, alert_group, user, responses=None):
|
|||
if responses:
|
||||
data["responses"] = responses
|
||||
|
||||
# Enrich webhook data with labels payloads if labels feature is enabled
|
||||
# TODO: once feature flag will be removed this code should go to the 'data' dict declaration
|
||||
if is_labels_feature_enabled(alert_group.channel.organization):
|
||||
data["webhook"] = {"id": webhook.public_primary_key, "name": webhook.name, "labels": get_label_verbal(webhook)}
|
||||
data["integration"]["labels"] = get_label_verbal(alert_group.channel)
|
||||
data["alert_group"]["labels"] = get_alert_group_label_verbal(alert_group)
|
||||
return data
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ from django_filters.utils import handle_timezone
|
|||
from apps.user_management.models import Team
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
|
||||
NO_TEAM_VALUE = "null"
|
||||
|
||||
|
||||
class DateRangeFilterMixin:
|
||||
DATE_FORMAT = "%Y-%m-%dT%H:%M:%S"
|
||||
|
|
@ -100,7 +102,7 @@ class ByTeamFilter(ByTeamModelFieldFilterMixin, filters.FilterSet):
|
|||
queryset=get_team_queryset,
|
||||
to_field_name="public_primary_key",
|
||||
null_label="noteam",
|
||||
null_value="null",
|
||||
null_value=NO_TEAM_VALUE,
|
||||
method=ByTeamModelFieldFilterMixin.filter_model_field_with_single_value.__name__,
|
||||
)
|
||||
|
||||
|
|
@ -112,7 +114,7 @@ class TeamModelMultipleChoiceFilter(filters.ModelMultipleChoiceFilter):
|
|||
queryset=get_team_queryset,
|
||||
to_field_name="public_primary_key",
|
||||
null_label="noteam",
|
||||
null_value="null",
|
||||
null_value=NO_TEAM_VALUE,
|
||||
method=ByTeamModelFieldFilterMixin.filter_model_field_with_multiple_values.__name__,
|
||||
):
|
||||
super().__init__(
|
||||
|
|
|
|||
|
|
@ -248,6 +248,7 @@ ACKNOWLEDGE_CONDITION = "acknowledge_condition"
|
|||
GROUPING_ID = "grouping_id"
|
||||
SOURCE_LINK = "source_link"
|
||||
ROUTE = "route"
|
||||
ALERT_GROUP_LABELS = "alert_group_labels"
|
||||
|
||||
NOTIFICATION_CHANNEL_TO_TEMPLATER_MAP = {
|
||||
SLACK: AlertSlackTemplater,
|
||||
|
|
@ -264,9 +265,15 @@ for backend_id, backend in get_messaging_backends():
|
|||
NOTIFICATION_CHANNEL_TO_TEMPLATER_MAP[backend.slug] = backend.get_templater_class()
|
||||
|
||||
APPEARANCE_TEMPLATE_NAMES = [TITLE, MESSAGE, IMAGE_URL]
|
||||
BEHAVIOUR_TEMPLATE_NAMES = [RESOLVE_CONDITION, ACKNOWLEDGE_CONDITION, GROUPING_ID, SOURCE_LINK]
|
||||
ROUTE_TEMPLATE_NAMES = [ROUTE]
|
||||
ALL_TEMPLATE_NAMES = APPEARANCE_TEMPLATE_NAMES + BEHAVIOUR_TEMPLATE_NAMES + ROUTE_TEMPLATE_NAMES
|
||||
BEHAVIOUR_TEMPLATE_NAMES = [
|
||||
RESOLVE_CONDITION,
|
||||
ACKNOWLEDGE_CONDITION,
|
||||
GROUPING_ID,
|
||||
SOURCE_LINK,
|
||||
ROUTE,
|
||||
ALERT_GROUP_LABELS,
|
||||
]
|
||||
ALL_TEMPLATE_NAMES = APPEARANCE_TEMPLATE_NAMES + BEHAVIOUR_TEMPLATE_NAMES
|
||||
|
||||
|
||||
class PreviewTemplateException(Exception):
|
||||
|
|
@ -326,11 +333,6 @@ class PreviewTemplateMixin:
|
|||
templated_attr = apply_jinja_template(template_body, payload=alert_to_template.raw_request_data)
|
||||
except (JinjaTemplateError, JinjaTemplateWarning) as e:
|
||||
return Response({"preview": e.fallback_message}, status.HTTP_200_OK)
|
||||
elif attr_name in ROUTE_TEMPLATE_NAMES:
|
||||
try:
|
||||
templated_attr = apply_jinja_template(template_body, payload=alert_to_template.raw_request_data)
|
||||
except (JinjaTemplateError, JinjaTemplateWarning) as e:
|
||||
return Response({"preview": e.fallback_message}, status.HTTP_200_OK)
|
||||
else:
|
||||
templated_attr = None
|
||||
response = {"preview": templated_attr}
|
||||
|
|
@ -346,8 +348,6 @@ class PreviewTemplateMixin:
|
|||
destination = None
|
||||
if template_param.startswith(tuple(BEHAVIOUR_TEMPLATE_NAMES)):
|
||||
attr_name = template_param
|
||||
if template_param.startswith(tuple(ROUTE_TEMPLATE_NAMES)):
|
||||
attr_name = template_param
|
||||
elif template_param.startswith(tuple(NOTIFICATION_CHANNEL_OPTIONS)):
|
||||
for notification_channel in NOTIFICATION_CHANNEL_OPTIONS:
|
||||
if template_param.startswith(notification_channel):
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ from rest_framework.request import Request
|
|||
|
||||
from apps.schedules.ical_utils import fetch_ical_file
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
from common.jinja_templater import apply_jinja_template
|
||||
from common.jinja_templater.apply_jinja_template import JinjaTemplateWarning
|
||||
from common.timezones import raise_exception_if_not_valid_timezone
|
||||
|
||||
|
||||
|
|
@ -165,3 +167,12 @@ def check_phone_number_is_valid(phone_number):
|
|||
|
||||
def serialize_datetime_as_utc_timestamp(dt: datetime.datetime) -> str:
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
|
||||
|
||||
def valid_jinja_template_for_serializer_method_field(template):
|
||||
for _, val in template.items():
|
||||
try:
|
||||
apply_jinja_template(val, payload={})
|
||||
except JinjaTemplateWarning:
|
||||
# Suppress warnings, template may be valid with payload
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ from apps.labels.tests.factories import (
|
|||
AlertReceiveChannelAssociatedLabelFactory,
|
||||
LabelKeyFactory,
|
||||
LabelValueFactory,
|
||||
WebhookAssociatedLabelFactory,
|
||||
)
|
||||
from apps.mobile_app.models import MobileAppAuthToken, MobileAppVerificationToken
|
||||
from apps.phone_notifications.phone_backend import PhoneBackend
|
||||
|
|
@ -953,7 +954,13 @@ def webhook_preset_api_setup():
|
|||
|
||||
@pytest.fixture
|
||||
def make_label_key():
|
||||
def _make_label_key(organization, **kwargs):
|
||||
def _make_label_key(organization, key_id=None, key_name=None, **kwargs):
|
||||
if key_id is not None:
|
||||
kwargs["id"] = key_id
|
||||
|
||||
if key_name is not None:
|
||||
kwargs["name"] = key_name
|
||||
|
||||
return LabelKeyFactory(organization=organization, **kwargs)
|
||||
|
||||
return _make_label_key
|
||||
|
|
@ -961,7 +968,13 @@ def make_label_key():
|
|||
|
||||
@pytest.fixture
|
||||
def make_label_value():
|
||||
def _make_label_value(key, **kwargs):
|
||||
def _make_label_value(key, value_id=None, value_name=None, **kwargs):
|
||||
if value_id is not None:
|
||||
kwargs["id"] = value_id
|
||||
|
||||
if value_name is not None:
|
||||
kwargs["name"] = value_name
|
||||
|
||||
return LabelValueFactory(key=key, **kwargs)
|
||||
|
||||
return _make_label_value
|
||||
|
|
@ -969,9 +982,9 @@ def make_label_value():
|
|||
|
||||
@pytest.fixture
|
||||
def make_label_key_and_value(make_label_key, make_label_value):
|
||||
def _make_label_key_and_value(organization):
|
||||
key = make_label_key(organization=organization)
|
||||
value = make_label_value(key=key)
|
||||
def _make_label_key_and_value(organization, key_id=None, key_name=None, value_id=None, value_name=None):
|
||||
key = make_label_key(organization=organization, key_id=key_id, key_name=key_name)
|
||||
value = make_label_value(key=key, value_id=value_id, value_name=value_name)
|
||||
return key, value
|
||||
|
||||
return _make_label_key_and_value
|
||||
|
|
@ -979,8 +992,12 @@ def make_label_key_and_value(make_label_key, make_label_value):
|
|||
|
||||
@pytest.fixture
|
||||
def make_integration_label_association(make_label_key_and_value):
|
||||
def _make_integration_label_association(organization, alert_receive_channel, **kwargs):
|
||||
key, value = make_label_key_and_value(organization)
|
||||
def _make_integration_label_association(
|
||||
organization, alert_receive_channel, key_id=None, key_name=None, value_id=None, value_name=None, **kwargs
|
||||
):
|
||||
key, value = make_label_key_and_value(
|
||||
organization, key_id=key_id, key_name=key_name, value_id=value_id, value_name=value_name
|
||||
)
|
||||
return AlertReceiveChannelAssociatedLabelFactory(
|
||||
alert_receive_channel=alert_receive_channel, organization=organization, key=key, value=value, **kwargs
|
||||
)
|
||||
|
|
@ -994,3 +1011,12 @@ def make_alert_group_label_association():
|
|||
return AlertGroupAssociatedLabelFactory(alert_group=alert_group, organization=organization, **kwargs)
|
||||
|
||||
return _make_alert_group_label_association
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_webhook_label_association(make_label_key_and_value):
|
||||
def _make_integration_label_association(organization, webhook, **kwargs):
|
||||
key, value = make_label_key_and_value(organization)
|
||||
return WebhookAssociatedLabelFactory(webhook=webhook, organization=organization, key=key, value=value, **kwargs)
|
||||
|
||||
return _make_integration_label_association
|
||||
|
|
|
|||
|
|
@ -46,9 +46,6 @@ class StartupProbeView(View):
|
|||
if cache.get(AlertChannelDefiningMixin.CACHE_KEY_DB_FALLBACK) is None:
|
||||
AlertChannelDefiningMixin().update_alert_receive_channel_cache()
|
||||
|
||||
cache.set("healthcheck", "healthcheck", 30) # Checking cache connectivity
|
||||
assert cache.get("healthcheck") == "healthcheck"
|
||||
|
||||
return HttpResponse("Ok")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ module.exports = {
|
|||
],
|
||||
'no-duplicate-imports': 'error',
|
||||
'no-restricted-imports': 'warn',
|
||||
// https://eslint.org/docs/latest/rules/no-redeclare#handled_by_typescript
|
||||
'no-redeclare': 0,
|
||||
'react/display-name': 'warn',
|
||||
/**
|
||||
* It appears as though the react/prop-types rule has a bug in it
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"description": "Grafana OnCall Plugin",
|
||||
"scripts": {
|
||||
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx --max-warnings=0 ./src ./e2e-tests",
|
||||
"lint:fix": "eslint --fix --cache --ext .js,.jsx,.ts,.tsx --max-warnings=0 --quiet ./src ./e2e-tests",
|
||||
"lint:fix": "eslint --fix --cache --ext .js,.jsx,.ts,.tsx --quiet ./src ./e2e-tests",
|
||||
"stylelint": "stylelint ./src/**/*.{css,scss,module.css,module.scss}",
|
||||
"stylelint:fix": "stylelint --fix ./src/**/*.{css,scss,module.css,module.scss}",
|
||||
"build": "grafana-toolkit plugin:build",
|
||||
|
|
@ -115,7 +115,7 @@
|
|||
"@grafana/data": "^9.2.4",
|
||||
"@grafana/faro-web-sdk": "^1.0.0-beta4",
|
||||
"@grafana/faro-web-tracing": "^1.0.0-beta4",
|
||||
"@grafana/labels": "~1.2.1",
|
||||
"@grafana/labels": "1.3.4",
|
||||
"@grafana/runtime": "9.3.0-beta1",
|
||||
"@grafana/ui": "^9.4.7",
|
||||
"@opentelemetry/api": "^1.3.0",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { PlaywrightTestConfig, PlaywrightTestProject, defineConfig, devices } from '@playwright/test';
|
||||
import { PlaywrightTestProject, defineConfig, devices } from '@playwright/test';
|
||||
|
||||
import path from 'path';
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -10,13 +10,21 @@ import { FormItem, FormItemType } from 'components/GForm/GForm.types';
|
|||
import MonacoEditor from 'components/MonacoEditor/MonacoEditor';
|
||||
import { MONACO_READONLY_CONFIG } from 'components/MonacoEditor/MonacoEditor.config';
|
||||
import GSelect from 'containers/GSelect/GSelect';
|
||||
import { CustomFieldSectionRendererProps } from 'containers/IntegrationForm/IntegrationForm';
|
||||
import RemoteSelect from 'containers/RemoteSelect/RemoteSelect';
|
||||
|
||||
import styles from './GForm.module.scss';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
export interface CustomFieldSectionRendererProps {
|
||||
control: any;
|
||||
formItem: FormItem;
|
||||
errors: any;
|
||||
register: any;
|
||||
setValue: (fieldName: string, fieldValue: any) => void;
|
||||
getValues: <T = unknown>(fieldName: string | string[]) => T;
|
||||
}
|
||||
|
||||
interface GFormProps {
|
||||
form: { name: string; fields: FormItem[] };
|
||||
data: any;
|
||||
|
|
@ -211,6 +219,7 @@ class GForm extends React.Component<GFormProps, {}> {
|
|||
}}
|
||||
errors={errors}
|
||||
register={register}
|
||||
getValues={getValues}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,14 +21,15 @@ const LabelsFilter: FC<LabelsFilterProps> = (props) => {
|
|||
const [search, setSearch] = useState('');
|
||||
|
||||
const handleChange = useCallback((value) => {
|
||||
onChange(value.map((v) => v.value));
|
||||
onChange(value.map((v) => v.data));
|
||||
}, []);
|
||||
|
||||
const handleLoadOptions = (search) => {
|
||||
return onLoadOptions(search).then((options) =>
|
||||
options.map((v) => ({
|
||||
label: `${v.key[FieldName]} : ${v.value[FieldName]}`,
|
||||
value: v,
|
||||
value: `${v.key[FieldName]} : ${v.value[FieldName]}`,
|
||||
data: v,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
|
@ -37,7 +38,8 @@ const LabelsFilter: FC<LabelsFilterProps> = (props) => {
|
|||
() =>
|
||||
propsValue.map((v) => ({
|
||||
label: `${v.key[FieldName]} : ${v.value[FieldName]}`,
|
||||
value: v,
|
||||
value: `${v.key[FieldName]} : ${v.value[FieldName]}`,
|
||||
data: v,
|
||||
})),
|
||||
[propsValue]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import React, { FC } from 'react';
|
||||
|
||||
import { LabelTag } from '@grafana/labels';
|
||||
import { VerticalGroup, HorizontalGroup, Button } from '@grafana/ui';
|
||||
|
||||
import TooltipBadge from 'components/TooltipBadge/TooltipBadge';
|
||||
import { LabelKeyValue } from 'models/label/label.types';
|
||||
|
||||
interface LabelsTooltipBadgeProps {
|
||||
labels: LabelKeyValue[];
|
||||
onClick: (label: LabelKeyValue) => void;
|
||||
}
|
||||
|
||||
const LabelsTooltipBadge: FC<LabelsTooltipBadgeProps> = ({ labels, onClick }) =>
|
||||
labels.length ? (
|
||||
<TooltipBadge
|
||||
borderType="secondary"
|
||||
icon="tag-alt"
|
||||
addPadding
|
||||
text={labels?.length}
|
||||
tooltipContent={
|
||||
<VerticalGroup spacing="sm">
|
||||
{labels.map((label) => (
|
||||
<HorizontalGroup spacing="sm" key={label.key.id}>
|
||||
<LabelTag label={label.key.name} value={label.value.name} />
|
||||
<Button
|
||||
size="sm"
|
||||
icon="filter"
|
||||
tooltip="Apply filter"
|
||||
variant="secondary"
|
||||
onClick={() => onClick(label)}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
))}
|
||||
</VerticalGroup>
|
||||
}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
export default LabelsTooltipBadge;
|
||||
|
|
@ -99,6 +99,7 @@ const MonacoEditor: FC<MonacoEditorProps> = (props) => {
|
|||
height={height}
|
||||
onEditorDidMount={handleMount}
|
||||
getSuggestions={useAutoCompleteList ? autoCompleteList : undefined}
|
||||
containerStyles="u-width-100"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,8 +21,7 @@ import { useHistory } from 'react-router-dom';
|
|||
|
||||
import Collapse from 'components/Collapse/Collapse';
|
||||
import Block from 'components/GBlock/Block';
|
||||
import GForm from 'components/GForm/GForm';
|
||||
import { FormItem } from 'components/GForm/GForm.types';
|
||||
import GForm, { CustomFieldSectionRendererProps } from 'components/GForm/GForm';
|
||||
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
|
||||
import Text from 'components/Text/Text';
|
||||
import Labels from 'containers/Labels/Labels';
|
||||
|
|
@ -262,14 +261,6 @@ const IntegrationForm = observer((props: IntegrationFormProps) => {
|
|||
}
|
||||
});
|
||||
|
||||
export interface CustomFieldSectionRendererProps {
|
||||
control: any;
|
||||
formItem: FormItem;
|
||||
errors: any;
|
||||
register: any;
|
||||
setValue: (fieldName: string, fieldValue: any) => void;
|
||||
}
|
||||
|
||||
interface CustomFieldSectionRendererState {
|
||||
isExistingContactPoint: boolean;
|
||||
selectedAlertManagerOption: string;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,37 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { ChangeEvent, useCallback, useState } from 'react';
|
||||
|
||||
import { Button, Drawer, HorizontalGroup, Icon, InlineSwitch, Input, Label, Tooltip, VerticalGroup } from '@grafana/ui';
|
||||
import { ServiceLabels } from '@grafana/labels';
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Dropdown,
|
||||
HorizontalGroup,
|
||||
Icon,
|
||||
InlineSwitch,
|
||||
Input,
|
||||
Label,
|
||||
Menu,
|
||||
Tooltip,
|
||||
VerticalGroup,
|
||||
} from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import Collapse from 'components/Collapse/Collapse';
|
||||
import MonacoEditor, { MONACO_LANGUAGE } from 'components/MonacoEditor/MonacoEditor';
|
||||
import Text from 'components/Text/Text';
|
||||
import IntegrationTemplate from 'containers/IntegrationTemplate/IntegrationTemplate';
|
||||
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
import { LabelKey } from 'models/label/label.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { openErrorNotification } from 'utils';
|
||||
|
||||
import styles from './IntegrationLabelsForm.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
const INPUT_WIDTH = 280;
|
||||
|
||||
interface IntegrationLabelsFormProps {
|
||||
id: AlertReceiveChannel['id'];
|
||||
onSubmit: () => void;
|
||||
|
|
@ -25,9 +44,13 @@ const IntegrationLabelsForm = observer((props: IntegrationLabelsFormProps) => {
|
|||
|
||||
const store = useStore();
|
||||
|
||||
const [showTemplateEditor, setShowTemplateEditor] = useState<boolean>(false);
|
||||
const [customLabelIndexToShowTemplateEditor, setCustomLabelIndexToShowTemplateEditor] = useState<number>(undefined);
|
||||
|
||||
const { alertReceiveChannelStore } = store;
|
||||
|
||||
const alertReceiveChannel = alertReceiveChannelStore.items[id];
|
||||
const templates = alertReceiveChannelStore.templates[id];
|
||||
|
||||
const [alertGroupLabels, setAlertGroupLabels] = useState(alertReceiveChannel.alert_group_labels);
|
||||
|
||||
|
|
@ -55,51 +78,273 @@ const IntegrationLabelsForm = observer((props: IntegrationLabelsFormProps) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<Drawer scrollableContent title="Alert group labels" onClose={onHide} closeOnMaskClick={false} width="640px">
|
||||
<VerticalGroup>
|
||||
<HorizontalGroup spacing="xs" align="flex-start">
|
||||
<Label>Inherited labels</Label>
|
||||
<Tooltip content="Labels inherited from integration">
|
||||
<Icon name="info-circle" className={cx('extra-fields__icon')} />
|
||||
</Tooltip>
|
||||
</HorizontalGroup>
|
||||
<ul className={cx('labels-list')}>
|
||||
{alertReceiveChannel.labels.length ? (
|
||||
alertReceiveChannel.labels.map((label) => (
|
||||
<li key={label.key.id}>
|
||||
<HorizontalGroup spacing="xs">
|
||||
<Input width={38} value={label.key.name} disabled />
|
||||
<Input width={31} value={label.value.name} disabled />
|
||||
<InlineSwitch
|
||||
value={alertGroupLabels.inheritable[label.key.id]}
|
||||
transparent
|
||||
onChange={getInheritanceChangeHandler(label.key.id)}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<Drawer scrollableContent title="Alert group labels" onClose={onHide} closeOnMaskClick={false} width="640px">
|
||||
<VerticalGroup spacing="lg">
|
||||
<VerticalGroup>
|
||||
<HorizontalGroup spacing="xs" align="flex-start">
|
||||
<Label>Inherited labels</Label>
|
||||
<Tooltip content="Labels inherited from integration">
|
||||
<Icon name="info-circle" className={cx('extra-fields__icon')} />
|
||||
</Tooltip>
|
||||
</HorizontalGroup>
|
||||
{alertReceiveChannel.labels.length ? (
|
||||
<ul className={cx('labels-list')}>
|
||||
{alertReceiveChannel.labels.map((label) => (
|
||||
<li key={label.key.id}>
|
||||
<HorizontalGroup spacing="xs">
|
||||
<Input width={INPUT_WIDTH / 8} value={label.key.name} disabled />
|
||||
<Input width={INPUT_WIDTH / 8} value={label.value.name} disabled />
|
||||
<InlineSwitch
|
||||
value={alertGroupLabels.inheritable[label.key.id]}
|
||||
transparent
|
||||
onChange={getInheritanceChangeHandler(label.key.id)}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<VerticalGroup>
|
||||
<Text type="secondary">There are no labels to inherit yet</Text>
|
||||
<Text type="link" onClick={handleOpenIntegrationSettings} clickable>
|
||||
Add labels to the integration
|
||||
</Text>
|
||||
</VerticalGroup>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
|
||||
<CustomLabels
|
||||
alertGroupLabels={alertGroupLabels}
|
||||
onChange={setAlertGroupLabels}
|
||||
onShowTemplateEditor={setCustomLabelIndexToShowTemplateEditor}
|
||||
/>
|
||||
|
||||
<Collapse isOpen={false} label="Advanced label templating">
|
||||
<VerticalGroup>
|
||||
<Text type="secondary">There are no labels to inherit yet</Text>
|
||||
<Text type="link" onClick={handleOpenIntegrationSettings} clickable>
|
||||
Add labels to the integration
|
||||
</Text>
|
||||
<HorizontalGroup justify="space-between" style={{ marginBottom: '10px' }}>
|
||||
<Text type="secondary">Jinja2 template to parse all labels at once</Text>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="edit"
|
||||
onClick={() => {
|
||||
setShowTemplateEditor(true);
|
||||
}}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
<MonacoEditor
|
||||
value={alertGroupLabels.template}
|
||||
height="200px"
|
||||
data={{}}
|
||||
showLineNumbers={false}
|
||||
language={MONACO_LANGUAGE.jinja2}
|
||||
onChange={(value) => {
|
||||
setAlertGroupLabels({ ...alertGroupLabels, template: value });
|
||||
}}
|
||||
/>
|
||||
</VerticalGroup>
|
||||
)}
|
||||
</ul>
|
||||
<div className={cx('buttons')}>
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant="secondary" onClick={onHide}>
|
||||
Close
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
</VerticalGroup>
|
||||
</Drawer>
|
||||
</Collapse>
|
||||
|
||||
<div className={cx('buttons')}>
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant="secondary" onClick={onHide}>
|
||||
Close
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
</VerticalGroup>
|
||||
</Drawer>
|
||||
{customLabelIndexToShowTemplateEditor !== undefined && (
|
||||
<IntegrationTemplate
|
||||
id={id}
|
||||
template={{
|
||||
name: 'alert_group_labels',
|
||||
displayName: ``,
|
||||
}}
|
||||
templates={templates}
|
||||
templateBody={alertGroupLabels.custom[customLabelIndexToShowTemplateEditor].value.name}
|
||||
onHide={() => setCustomLabelIndexToShowTemplateEditor(undefined)}
|
||||
onUpdateTemplates={({ alert_group_labels }) => {
|
||||
const newCustom = [...alertGroupLabels.custom];
|
||||
newCustom[customLabelIndexToShowTemplateEditor].value.name = alert_group_labels;
|
||||
|
||||
setAlertGroupLabels({
|
||||
...alertGroupLabels,
|
||||
custom: newCustom,
|
||||
});
|
||||
|
||||
setCustomLabelIndexToShowTemplateEditor(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showTemplateEditor && (
|
||||
<IntegrationTemplate
|
||||
id={id}
|
||||
template={{
|
||||
name: 'alert_group_labels',
|
||||
displayName: ``,
|
||||
}}
|
||||
templates={templates}
|
||||
templateBody={alertGroupLabels.template}
|
||||
onHide={() => setShowTemplateEditor(false)}
|
||||
onUpdateTemplates={({ alert_group_labels }) => {
|
||||
setAlertGroupLabels({
|
||||
...alertGroupLabels,
|
||||
template: alert_group_labels,
|
||||
});
|
||||
|
||||
setShowTemplateEditor(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface CustomLabelsProps {
|
||||
alertGroupLabels: AlertReceiveChannel['alert_group_labels'];
|
||||
onChange: (value: AlertReceiveChannel['alert_group_labels']) => void;
|
||||
onShowTemplateEditor: (index: number) => void;
|
||||
}
|
||||
|
||||
const CustomLabels = (props: CustomLabelsProps) => {
|
||||
const { alertGroupLabels, onChange, onShowTemplateEditor } = props;
|
||||
|
||||
const { labelsStore } = useStore();
|
||||
|
||||
const handlePlainLabelAdd = () => {
|
||||
onChange({
|
||||
...alertGroupLabels,
|
||||
custom: [
|
||||
...alertGroupLabels.custom,
|
||||
{
|
||||
key: { id: undefined, name: undefined },
|
||||
value: { id: undefined, name: undefined },
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
const handleTemplatedLabelAdd = () => {
|
||||
onChange({
|
||||
...alertGroupLabels,
|
||||
custom: [
|
||||
...alertGroupLabels.custom,
|
||||
{
|
||||
key: { id: undefined, name: undefined },
|
||||
value: { id: null, name: undefined }, // id = null means it's a templated value
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const cachedOnLoadKeys = useCallback(() => {
|
||||
let result = undefined;
|
||||
return async (search?: string) => {
|
||||
if (!result) {
|
||||
try {
|
||||
result = await labelsStore.loadKeys();
|
||||
} catch (error) {
|
||||
openErrorNotification('There was an error processing your request. Please try again');
|
||||
}
|
||||
}
|
||||
|
||||
return result.filter((k) => k.name.toLowerCase().includes(search.toLowerCase()));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cachedOnLoadValuesForKey = useCallback(() => {
|
||||
let result = undefined;
|
||||
return async (key: string, search?: string) => {
|
||||
if (!result) {
|
||||
try {
|
||||
const { values } = await labelsStore.loadValuesForKey(key, search);
|
||||
result = values;
|
||||
} catch (error) {
|
||||
openErrorNotification('There was an error processing your request. Please try again');
|
||||
}
|
||||
}
|
||||
|
||||
return result.filter((k) => k.name.toLowerCase().includes(search.toLowerCase()));
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<VerticalGroup>
|
||||
<HorizontalGroup spacing="xs" align="flex-start">
|
||||
<Label>Custom labels</Label>
|
||||
</HorizontalGroup>
|
||||
<ServiceLabels
|
||||
isAddingDisabled
|
||||
loadById
|
||||
inputWidth={INPUT_WIDTH}
|
||||
value={alertGroupLabels.custom}
|
||||
onLoadKeys={cachedOnLoadKeys()}
|
||||
onLoadValuesForKey={cachedOnLoadValuesForKey()}
|
||||
onCreateKey={labelsStore.createKey.bind(labelsStore)}
|
||||
onUpdateKey={labelsStore.updateKey.bind(labelsStore)}
|
||||
onCreateValue={labelsStore.createValue.bind(labelsStore)}
|
||||
onUpdateValue={labelsStore.updateKeyValue.bind(labelsStore)}
|
||||
onUpdateError={(res) => {
|
||||
if (res?.response?.status === 409) {
|
||||
openErrorNotification(`Duplicate values are not allowed`);
|
||||
} else {
|
||||
openErrorNotification('An error has occurred. Please try again');
|
||||
}
|
||||
}}
|
||||
renderValue={(option, index, renderValueDefault) => {
|
||||
if (option.value.id === null) {
|
||||
return (
|
||||
<Input
|
||||
placeholder="Jinja2 template"
|
||||
autoFocus
|
||||
disabled={!alertGroupLabels.custom[index].key.id}
|
||||
width={INPUT_WIDTH / 8}
|
||||
value={option.value.name}
|
||||
addonAfter={
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="edit"
|
||||
onClick={() => {
|
||||
onShowTemplateEditor(index);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newCustom = [...alertGroupLabels.custom];
|
||||
newCustom[index].value.name = e.currentTarget.value;
|
||||
|
||||
onChange({ ...alertGroupLabels, custom: newCustom });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return renderValueDefault(option, index);
|
||||
}
|
||||
}}
|
||||
onDataUpdate={(value) => {
|
||||
onChange({
|
||||
...alertGroupLabels,
|
||||
custom: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Dropdown
|
||||
overlay={
|
||||
<Menu>
|
||||
<Menu.Item label="Plain label" onClick={handlePlainLabelAdd} />
|
||||
<Menu.Item label="Templated label" onClick={handleTemplatedLabelAdd} />
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Button variant="secondary" icon="plus">
|
||||
Add
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</VerticalGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntegrationLabelsForm;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import cn from 'classnames/bind';
|
|||
import { debounce } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import { templateForEdit } from 'components/AlertTemplates/AlertTemplatesForm.config';
|
||||
import { TemplateForEdit } from 'components/AlertTemplates/CommonAlertTemplatesForm.config';
|
||||
import CheatSheet from 'components/CheatSheet/CheatSheet';
|
||||
import {
|
||||
|
|
@ -38,7 +39,7 @@ interface IntegrationTemplateProps {
|
|||
templates: AlertTemplatesDTO[];
|
||||
onHide: () => void;
|
||||
onUpdateTemplates: (values: any) => void;
|
||||
onUpdateRoute: (values: any, channelFilterId?: ChannelFilter['id']) => void;
|
||||
onUpdateRoute?: (values: any, channelFilterId?: ChannelFilter['id']) => void;
|
||||
}
|
||||
|
||||
const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
|
||||
|
|
@ -53,11 +54,13 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
|
|||
const [isRecentAlertGroupExisting, setIsRecentAlertGroupExisting] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const locationParams: any = { template: template.name };
|
||||
if (template.isRoute) {
|
||||
locationParams.routeId = channelFilterId;
|
||||
if (templateForEdit[template.name]) {
|
||||
const locationParams: any = { template: template.name };
|
||||
if (template.isRoute) {
|
||||
locationParams.routeId = channelFilterId;
|
||||
}
|
||||
LocationHelper.update(locationParams, 'partial');
|
||||
}
|
||||
LocationHelper.update(locationParams, 'partial');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react';
|
||||
|
||||
import ServiceLabels from '@grafana/labels';
|
||||
import { ServiceLabels, ServiceLabelsProps } from '@grafana/labels';
|
||||
import { Field } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
|
|
@ -14,14 +14,15 @@ import styles from './Labels.module.css';
|
|||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface LabelsProps {
|
||||
export interface LabelsProps {
|
||||
value: LabelKeyValue[];
|
||||
errors: any;
|
||||
onDataUpdate?: ServiceLabelsProps['onDataUpdate'];
|
||||
}
|
||||
|
||||
const Labels = observer(
|
||||
forwardRef(function Labels2(props: LabelsProps, ref) {
|
||||
const { value: defaultValue, errors: propsErrors } = props;
|
||||
const { value: defaultValue, errors: propsErrors, onDataUpdate } = props;
|
||||
|
||||
// propsErrors are 'external' caused by attaching/detaching labels to oncall entities,
|
||||
// state errors are errors caused by CRUD operations on labels storage
|
||||
|
|
@ -30,6 +31,13 @@ const Labels = observer(
|
|||
|
||||
const { labelsStore } = useStore();
|
||||
|
||||
const onChange = (value: LabelKeyValue[]) => {
|
||||
if (onDataUpdate) {
|
||||
onDataUpdate(value);
|
||||
}
|
||||
setValue(value);
|
||||
};
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => {
|
||||
|
|
@ -113,7 +121,7 @@ const Labels = observer(
|
|||
onRowItemRemoval={(_pair, _index) => {}}
|
||||
onUpdateError={onUpdateError}
|
||||
errors={isValid() ? {} : { ...propsErrors }}
|
||||
onDataUpdate={setValue}
|
||||
onDataUpdate={onChange}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { OutgoingWebhookPreset } from 'models/outgoing_webhook/outgoing_webhook.
|
|||
import { KeyValuePair } from 'utils';
|
||||
import { generateAssignToTeamInputDescription } from 'utils/consts';
|
||||
|
||||
import { WebhookFormFieldName } from './OutgoingWebhookForm.types';
|
||||
|
||||
export const WebhookTriggerType = {
|
||||
EscalationStep: new KeyValuePair('0', 'Escalation Step'),
|
||||
AlertGroupCreated: new KeyValuePair('1', 'Alert Group Created'),
|
||||
|
|
@ -19,23 +21,29 @@ export const WebhookTriggerType = {
|
|||
Unacknowledged: new KeyValuePair('7', 'Unacknowledged'),
|
||||
};
|
||||
|
||||
export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fields: FormItem[] } {
|
||||
export function createForm(
|
||||
presets: OutgoingWebhookPreset[],
|
||||
hasLabelsFeature?: boolean
|
||||
): {
|
||||
name: string;
|
||||
fields: FormItem[];
|
||||
} {
|
||||
return {
|
||||
name: 'OutgoingWebhook',
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
name: WebhookFormFieldName.Name,
|
||||
type: FormItemType.Input,
|
||||
validation: { required: true },
|
||||
},
|
||||
{
|
||||
name: 'is_webhook_enabled',
|
||||
name: WebhookFormFieldName.IsWebhookEnabled,
|
||||
label: 'Enabled',
|
||||
normalize: (value) => Boolean(value),
|
||||
type: FormItemType.Switch,
|
||||
},
|
||||
{
|
||||
name: 'team',
|
||||
name: WebhookFormFieldName.Team,
|
||||
label: 'Assign to Team',
|
||||
description: `${generateAssignToTeamInputDescription(
|
||||
'Outgoing Webhooks'
|
||||
|
|
@ -51,7 +59,7 @@ export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fi
|
|||
},
|
||||
},
|
||||
{
|
||||
name: 'trigger_type',
|
||||
name: WebhookFormFieldName.TriggerType,
|
||||
label: 'Trigger Type',
|
||||
description: 'The type of event which will cause this webhook to execute.',
|
||||
type: FormItemType.Select,
|
||||
|
|
@ -92,13 +100,11 @@ export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fi
|
|||
},
|
||||
],
|
||||
},
|
||||
isVisible: (data) => {
|
||||
return isPresetFieldVisible(data.preset, presets, 'trigger_type');
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.TriggerType),
|
||||
normalize: (value) => value,
|
||||
},
|
||||
{
|
||||
name: 'http_method',
|
||||
name: WebhookFormFieldName.HttpMethod,
|
||||
label: 'HTTP Method',
|
||||
type: FormItemType.Select,
|
||||
extra: {
|
||||
|
|
@ -126,19 +132,16 @@ export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fi
|
|||
},
|
||||
],
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, 'http_method'),
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.HttpMethod),
|
||||
normalize: (value) => value,
|
||||
},
|
||||
{
|
||||
name: 'integration_filter',
|
||||
name: WebhookFormFieldName.IntegrationFilter,
|
||||
label: 'Integrations',
|
||||
type: FormItemType.MultiSelect,
|
||||
isVisible: (data) => {
|
||||
return (
|
||||
isPresetFieldVisible(data.preset, presets, 'integration_filter') &&
|
||||
data.trigger_type !== WebhookTriggerType.EscalationStep.key
|
||||
);
|
||||
},
|
||||
isVisible: (data) =>
|
||||
isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.IntegrationFilter) &&
|
||||
data.trigger_type !== WebhookTriggerType.EscalationStep.key,
|
||||
extra: {
|
||||
placeholder: 'Choose (Optional)',
|
||||
modelName: 'alertReceiveChannelStore',
|
||||
|
|
@ -151,88 +154,79 @@ export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fi
|
|||
'Integrations that this webhook applies to. If this is empty the webhook will execute for all integrations',
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
name: WebhookFormFieldName.Labels,
|
||||
label: 'Labels',
|
||||
type: FormItemType.Other,
|
||||
render: true,
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.Url,
|
||||
label: 'Webhook URL',
|
||||
type: FormItemType.Monaco,
|
||||
extra: {
|
||||
height: 30,
|
||||
},
|
||||
isVisible: (data) => {
|
||||
return isPresetFieldVisible(data.preset, presets, 'url');
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Url),
|
||||
},
|
||||
{
|
||||
name: 'headers',
|
||||
name: WebhookFormFieldName.Headers,
|
||||
label: 'Webhook Headers',
|
||||
description: 'Request headers should be in JSON format.',
|
||||
type: FormItemType.Monaco,
|
||||
extra: {
|
||||
rows: 3,
|
||||
},
|
||||
isVisible: (data) => {
|
||||
return isPresetFieldVisible(data.preset, presets, 'headers');
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Headers),
|
||||
},
|
||||
{
|
||||
name: 'username',
|
||||
name: WebhookFormFieldName.Username,
|
||||
type: FormItemType.Input,
|
||||
isVisible: (data) => {
|
||||
return isPresetFieldVisible(data.preset, presets, 'username');
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Username),
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
name: WebhookFormFieldName.Password,
|
||||
type: FormItemType.Password,
|
||||
isVisible: (data) => {
|
||||
return isPresetFieldVisible(data.preset, presets, 'password');
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Password),
|
||||
},
|
||||
{
|
||||
name: 'authorization_header',
|
||||
name: WebhookFormFieldName.AuthorizationHeader,
|
||||
description:
|
||||
'Value of the Authorization header, do not need to prefix with "Authorization:". For example: Bearer AbCdEf123456',
|
||||
type: FormItemType.Password,
|
||||
isVisible: (data) => {
|
||||
return isPresetFieldVisible(data.preset, presets, 'authorization_header');
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.AuthorizationHeader),
|
||||
},
|
||||
{
|
||||
name: 'trigger_template',
|
||||
name: WebhookFormFieldName.TriggerTemplate,
|
||||
type: FormItemType.Monaco,
|
||||
description:
|
||||
'Trigger template is used to conditionally execute the webhook based on incoming data. The trigger template must be empty or evaluate to true or 1 for the webhook to be sent',
|
||||
extra: {
|
||||
rows: 2,
|
||||
},
|
||||
isVisible: (data) => {
|
||||
return isPresetFieldVisible(data.preset, presets, 'trigger_template');
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.TriggerTemplate),
|
||||
},
|
||||
{
|
||||
name: 'forward_all',
|
||||
name: WebhookFormFieldName.ForwardAll,
|
||||
normalize: (value) => (value ? Boolean(value) : value),
|
||||
type: FormItemType.Switch,
|
||||
description: "Forwards whole payload of the alert group and context data to the webhook's url as POST/PUT data",
|
||||
isVisible: (data) => {
|
||||
return isPresetFieldVisible(data.preset, presets, 'forward_all');
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.ForwardAll),
|
||||
},
|
||||
{
|
||||
name: 'data',
|
||||
name: WebhookFormFieldName.Data,
|
||||
getDisabled: (data) => Boolean(data?.forward_all),
|
||||
type: FormItemType.Monaco,
|
||||
description:
|
||||
'Available variables: {{ event }}, {{ user }}, {{ alert_group }}, {{ alert_group_id }}, {{ alert_payload }}, {{ integration }}, {{ notified_users }}, {{ users_to_be_notified }}, {{ responses }}',
|
||||
description: `Available variables: {{ event }}, {{ user }}, {{ alert_group }}, {{ alert_group_id }}, {{ alert_payload }}, {{ integration }}, {{ notified_users }}, {{ users_to_be_notified }}, {{ responses }}${
|
||||
hasLabelsFeature ? ' {{ webhook }}' : ''
|
||||
}`,
|
||||
extra: {},
|
||||
isVisible: (data) => {
|
||||
return isPresetFieldVisible(data.preset, presets, 'data');
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Data),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function isPresetFieldVisible(presetId: string, presets: OutgoingWebhookPreset[], fieldName: string) {
|
||||
function isPresetFieldVisible(presetId: string, presets: OutgoingWebhookPreset[], fieldName: WebhookFormFieldName) {
|
||||
if (presetId == null) {
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,23 +17,28 @@ import { observer } from 'mobx-react';
|
|||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import Block from 'components/GBlock/Block';
|
||||
import GForm from 'components/GForm/GForm';
|
||||
import GForm, { CustomFieldSectionRendererProps } from 'components/GForm/GForm';
|
||||
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
|
||||
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
|
||||
import { logoCoors } from 'components/IntegrationLogo/IntegrationLogo.config';
|
||||
import RenderConditionally from 'components/RenderConditionally/RenderConditionally';
|
||||
import Text from 'components/Text/Text';
|
||||
import Labels, { LabelsProps } from 'containers/Labels/Labels';
|
||||
import { webhookPresetIcons } from 'containers/OutgoingWebhookForm/WebhookPresetIcons.config';
|
||||
import OutgoingWebhookStatus from 'containers/OutgoingWebhookStatus/OutgoingWebhookStatus';
|
||||
import WebhooksTemplateEditor from 'containers/WebhooksTemplateEditor/WebhooksTemplateEditor';
|
||||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { LabelKeyValue } from 'models/label/label.types';
|
||||
import { OutgoingWebhook, OutgoingWebhookPreset } from 'models/outgoing_webhook/outgoing_webhook.types';
|
||||
import { WebhookFormActionType } from 'pages/outgoing_webhooks/OutgoingWebhooks.types';
|
||||
import { AppFeature } from 'state/features';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { KeyValuePair } from 'utils';
|
||||
import { UserActions } from 'utils/authorization';
|
||||
import { PLUGIN_ROOT } from 'utils/consts';
|
||||
|
||||
import { createForm } from './OutgoingWebhookForm.config';
|
||||
import { WebhookFormFieldName } from './OutgoingWebhookForm.types';
|
||||
|
||||
import styles from 'containers/OutgoingWebhookForm/OutgoingWebhookForm.module.css';
|
||||
|
||||
|
|
@ -52,6 +57,23 @@ export const WebhookTabs = {
|
|||
LastRun: new KeyValuePair('LastRun', 'Last Run'),
|
||||
};
|
||||
|
||||
const CustomFieldSectionRenderer: React.FC<CustomFieldSectionRendererProps> = observer(
|
||||
({ errors, setValue, getValues }) => {
|
||||
const { hasFeature } = useStore();
|
||||
const onDataUpdate: LabelsProps['onDataUpdate'] = (val) => setValue(WebhookFormFieldName.Labels, val);
|
||||
|
||||
return (
|
||||
<RenderConditionally shouldRender={hasFeature(AppFeature.Labels)}>
|
||||
<Labels
|
||||
value={getValues<LabelKeyValue[]>(WebhookFormFieldName.Labels) || []}
|
||||
errors={errors?.[WebhookFormFieldName.Labels]}
|
||||
onDataUpdate={onDataUpdate}
|
||||
/>
|
||||
</RenderConditionally>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
|
||||
const history = useHistory();
|
||||
const { id, action, onUpdate, onHide, onDelete } = props;
|
||||
|
|
@ -65,10 +87,10 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
|
|||
const [selectedPreset, setSelectedPreset] = useState<OutgoingWebhookPreset>(undefined);
|
||||
const [filterValue, setFilterValue] = useState('');
|
||||
|
||||
const { outgoingWebhookStore } = useStore();
|
||||
const { outgoingWebhookStore, hasFeature } = useStore();
|
||||
const isNew = action === WebhookFormActionType.NEW;
|
||||
const isNewOrCopy = isNew || action === WebhookFormActionType.COPY;
|
||||
const form = createForm(outgoingWebhookStore.outgoingWebhookPresets);
|
||||
const form = createForm(outgoingWebhookStore.outgoingWebhookPresets, hasFeature(AppFeature.Labels));
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(data: Partial<OutgoingWebhook>) => {
|
||||
|
|
@ -149,7 +171,15 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
|
|||
return null;
|
||||
}
|
||||
|
||||
const formElement = <GForm form={form} data={data} onSubmit={handleSubmit} onFieldRender={enrchField} />;
|
||||
const formElement = (
|
||||
<GForm
|
||||
form={form}
|
||||
data={data}
|
||||
onSubmit={handleSubmit}
|
||||
onFieldRender={enrchField}
|
||||
customFieldSectionRenderer={CustomFieldSectionRenderer}
|
||||
/>
|
||||
);
|
||||
const createWebhookParameters = (
|
||||
<>
|
||||
<Drawer scrollableContent title={'New Outgoing Webhook'} onClose={onHide} closeOnMaskClick={false}>
|
||||
|
|
@ -279,7 +309,13 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
|
|||
return (
|
||||
<>
|
||||
<div className={cx('content')}>
|
||||
<GForm form={form} data={data} onSubmit={handleSubmit} onFieldRender={enrchField} />
|
||||
<GForm
|
||||
form={form}
|
||||
data={data}
|
||||
onSubmit={handleSubmit}
|
||||
onFieldRender={enrchField}
|
||||
customFieldSectionRenderer={CustomFieldSectionRenderer}
|
||||
/>
|
||||
<div className={cx('buttons')}>
|
||||
<HorizontalGroup justify={'flex-end'}>
|
||||
{id === 'new' ? (
|
||||
|
|
@ -339,8 +375,8 @@ const WebhookTabsContent: React.FC<WebhookTabsProps> = ({
|
|||
formElement,
|
||||
}) => {
|
||||
const [confirmationModal, setConfirmationModal] = useState<ConfirmModalProps>(undefined);
|
||||
const { outgoingWebhookStore } = useStore();
|
||||
const form = createForm(outgoingWebhookStore.outgoingWebhookPresets);
|
||||
const { outgoingWebhookStore, hasFeature } = useStore();
|
||||
const form = createForm(outgoingWebhookStore.outgoingWebhookPresets, hasFeature(AppFeature.Labels));
|
||||
return (
|
||||
<div className={cx('tabs__content')}>
|
||||
{confirmationModal && (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
export const WebhookFormFieldName = {
|
||||
Name: 'name',
|
||||
IsWebhookEnabled: 'is_webhook_enabled',
|
||||
Team: 'team',
|
||||
TriggerType: 'trigger_type',
|
||||
HttpMethod: 'http_method',
|
||||
IntegrationFilter: 'integration_filter',
|
||||
Labels: 'labels',
|
||||
Url: 'url',
|
||||
Headers: 'headers',
|
||||
Username: 'username',
|
||||
Password: 'password',
|
||||
AuthorizationHeader: 'authorization_header',
|
||||
TriggerTemplate: 'trigger_template',
|
||||
ForwardAll: 'forward_all',
|
||||
Data: 'data',
|
||||
} as const;
|
||||
export type WebhookFormFieldName = (typeof WebhookFormFieldName)[keyof typeof WebhookFormFieldName];
|
||||
|
|
@ -15,10 +15,11 @@ export function parseFilters(
|
|||
filterOptions: FilterOption[],
|
||||
query: { [key: string]: any }
|
||||
) {
|
||||
const filters = filterOptions.filter((filterOption: FilterOption) => filterOption.name in data);
|
||||
const dataWithPredefinedTeams = { ...data, team: data.team || [] };
|
||||
const filters = filterOptions.filter((filterOption: FilterOption) => filterOption.name in dataWithPredefinedTeams);
|
||||
|
||||
const values = filters.reduce((memo: any, filterOption: FilterOption) => {
|
||||
const rawValue = query[filterOption.name] || data[filterOption.name]; // query takes priority over local storage
|
||||
const rawValue = query[filterOption.name] || dataWithPredefinedTeams[filterOption.name]; // query takes priority over local storage
|
||||
|
||||
let value: any = rawValue;
|
||||
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ class RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
|
|||
|
||||
const { filterOptions } = this.state;
|
||||
|
||||
let { filters, values } = parseFilters(query, filterOptions, query);
|
||||
let { filters, values } = parseFilters({ ...query, ...filtersStore.globalValues }, filterOptions, query);
|
||||
|
||||
this.setState({ filterOptions, filters, values }, () => this.onChange());
|
||||
}
|
||||
|
|
@ -103,7 +103,7 @@ class RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
|
|||
let { filters, values } = parseFilters({ ...query, ...filtersStore.globalValues }, filterOptions, query);
|
||||
|
||||
if (isEmpty(values)) {
|
||||
({ filters, values } = parseFilters(defaultFilters || { team: [] }, filterOptions, query));
|
||||
({ filters, values } = parseFilters(defaultFilters, filterOptions, query));
|
||||
}
|
||||
|
||||
this.setState({ filterOptions, filters, values }, () => this.onChange(true));
|
||||
|
|
@ -273,6 +273,7 @@ class RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
|
|||
value={values[filter.name]}
|
||||
onChange={this.getRemoteOptionsChangeHandler(filter.name)}
|
||||
getOptionLabel={(item: SelectableValue) => <Emoji text={item.label || ''} />}
|
||||
predefinedOptions={filter.default ? [filter.default] : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ interface RemoteSelectProps {
|
|||
showError?: boolean;
|
||||
maxMenuHeight?: number;
|
||||
requiredUserAction?: UserAction;
|
||||
predefinedOptions?: any[];
|
||||
}
|
||||
|
||||
const RemoteSelect = inject('store')(
|
||||
|
|
@ -49,6 +50,7 @@ const RemoteSelect = inject('store')(
|
|||
showError,
|
||||
maxMenuHeight,
|
||||
requiredUserAction,
|
||||
predefinedOptions,
|
||||
} = props;
|
||||
|
||||
const [noOptionsMessage, setNoOptionsMessage] = useState<string>('No options found');
|
||||
|
|
@ -66,7 +68,7 @@ const RemoteSelect = inject('store')(
|
|||
return oldOptions.concat(newOptions.filter(({ value }) => !existingValues.includes(value)));
|
||||
}
|
||||
|
||||
const [options, setOptions] = useReducer(mergeOptions, []);
|
||||
const [options, setOptions] = useReducer(mergeOptions, getOptions(predefinedOptions || []));
|
||||
|
||||
const loadOptionsCallback = useDebouncedCallback(async (query: string, cb) => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -49,7 +49,11 @@ export interface AlertReceiveChannel {
|
|||
allow_delete: boolean;
|
||||
deleted?: boolean;
|
||||
labels: LabelKeyValue[];
|
||||
alert_group_labels: { inheritable: Record<LabelKeyValue['key']['id'], boolean> };
|
||||
alert_group_labels: {
|
||||
inheritable: Record<LabelKeyValue['key']['id'], boolean>;
|
||||
custom: LabelKeyValue[];
|
||||
template: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AlertReceiveChannelChoice {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { action, observable } from 'mobx';
|
||||
|
||||
import BaseStore from 'models/base_store';
|
||||
import { LabelKeyValue } from 'models/label/label.types';
|
||||
import { makeRequest } from 'network';
|
||||
import { RootStore } from 'state';
|
||||
import LocationHelper from 'utils/LocationHelper';
|
||||
import { PAGE } from 'utils/consts';
|
||||
import { getItem, setItem } from 'utils/localStorage';
|
||||
|
||||
|
|
@ -79,4 +81,21 @@ export class FiltersStore extends BaseStore {
|
|||
setCurrentTablePageNum(page: PAGE, currentTablePageNum: number) {
|
||||
this.currentTablePageNum[page] = currentTablePageNum;
|
||||
}
|
||||
|
||||
@action
|
||||
applyLabelFilter = (label: LabelKeyValue, page: PAGE) => {
|
||||
const currentLabelFilterValues = this.values[page]?.label || [];
|
||||
const labelToAddString = `${label.key.id}:${label.value.id}`;
|
||||
const newLabelFilter = [...currentLabelFilterValues, labelToAddString];
|
||||
|
||||
if (currentLabelFilterValues?.some((label) => label === labelToAddString)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateValuesForPage(page, {
|
||||
label: newLabelFilter,
|
||||
});
|
||||
LocationHelper.update({ label: newLabelFilter }, 'partial');
|
||||
this.setNeedToParseFilters(true);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
|
||||
import { LabelKeyValue } from 'models/label/label.types';
|
||||
|
||||
export interface OutgoingWebhook {
|
||||
authorization_header: string;
|
||||
|
|
@ -19,6 +20,7 @@ export interface OutgoingWebhook {
|
|||
is_webhook_enabled: boolean;
|
||||
is_legacy: boolean;
|
||||
preset: string;
|
||||
labels: LabelKeyValue[];
|
||||
}
|
||||
|
||||
export interface OutgoingWebhookResponse {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React, { SyntheticEvent } from 'react';
|
||||
|
||||
import { LabelTag } from '@grafana/labels';
|
||||
import { Button, HorizontalGroup, Icon, VerticalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
|
@ -12,11 +11,11 @@ import CardButton from 'components/CardButton/CardButton';
|
|||
import CursorPagination from 'components/CursorPagination/CursorPagination';
|
||||
import GTable from 'components/GTable/GTable';
|
||||
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
|
||||
import LabelsTooltipBadge from 'components/LabelsTooltipBadge/LabelsTooltipBadge';
|
||||
import ManualAlertGroup from 'components/ManualAlertGroup/ManualAlertGroup';
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import Text from 'components/Text/Text';
|
||||
import TextEllipsisTooltip from 'components/TextEllipsisTooltip/TextEllipsisTooltip';
|
||||
import TooltipBadge from 'components/TooltipBadge/TooltipBadge';
|
||||
import Tutorial from 'components/Tutorial/Tutorial';
|
||||
import { TutorialStep } from 'components/Tutorial/Tutorial.types';
|
||||
import { IncidentsFiltersType } from 'containers/IncidentsFilters/IncidentFilters.types';
|
||||
|
|
@ -24,7 +23,6 @@ import RemoteFilters from 'containers/RemoteFilters/RemoteFilters';
|
|||
import TeamName from 'containers/TeamName/TeamName';
|
||||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { Alert, Alert as AlertType, AlertAction, IncidentStatus } from 'models/alertgroup/alertgroup.types';
|
||||
import { LabelKeyValue } from 'models/label/label.types';
|
||||
import { renderRelatedUsers } from 'pages/incident/Incident.helpers';
|
||||
import { AppFeature } from 'state/features';
|
||||
import { PageProps, WithStoreProps } from 'state/types';
|
||||
|
|
@ -587,37 +585,6 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
|
|||
);
|
||||
}
|
||||
|
||||
renderLabels(item: AlertType) {
|
||||
if (!item.labels.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipBadge
|
||||
borderType="secondary"
|
||||
icon="tag-alt"
|
||||
addPadding
|
||||
text={item.labels?.length}
|
||||
tooltipContent={
|
||||
<VerticalGroup spacing="sm">
|
||||
{item.labels.map((label) => (
|
||||
<HorizontalGroup spacing="sm" key={label.key.id}>
|
||||
<LabelTag label={label.key.name} value={label.value.name} key={label.key.id} />
|
||||
<Button
|
||||
size="sm"
|
||||
icon="filter"
|
||||
tooltip="Apply filter"
|
||||
variant="secondary"
|
||||
onClick={this.getApplyLabelFilterClickHandler(label)}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
))}
|
||||
</VerticalGroup>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderTeam(record: AlertType, teams: any) {
|
||||
return (
|
||||
<TextEllipsisTooltip placement="top" content={teams[record.team]?.name}>
|
||||
|
|
@ -626,29 +593,6 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
|
|||
);
|
||||
}
|
||||
|
||||
getApplyLabelFilterClickHandler = (label: LabelKeyValue) => {
|
||||
const {
|
||||
store: { filtersStore },
|
||||
} = this.props;
|
||||
|
||||
return () => {
|
||||
const {
|
||||
filters: { label: oldLabelFilter = [] },
|
||||
} = this.state;
|
||||
|
||||
const labelToAddString = `${label.key.id}:${label.value.id}`;
|
||||
if (oldLabelFilter.some((label) => label === labelToAddString)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newLabelFilter = [...oldLabelFilter, labelToAddString];
|
||||
|
||||
LocationHelper.update({ label: newLabelFilter }, 'partial');
|
||||
|
||||
filtersStore.setNeedToParseFilters(true);
|
||||
};
|
||||
};
|
||||
|
||||
shouldShowPagination() {
|
||||
const { alertGroupStore } = this.props.store;
|
||||
|
||||
|
|
@ -666,7 +610,9 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
|
|||
};
|
||||
|
||||
getTableColumns(): Array<{ width: string; title: string; key: string; render }> {
|
||||
const { store } = this.props;
|
||||
const {
|
||||
store: { filtersStore, grafanaTeamStore, hasFeature },
|
||||
} = this.props;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
|
|
@ -709,7 +655,7 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
|
|||
width: '10%',
|
||||
title: 'Team',
|
||||
key: 'team',
|
||||
render: (item: AlertType) => this.renderTeam(item, store.grafanaTeamStore.items),
|
||||
render: (item: AlertType) => this.renderTeam(item, grafanaTeamStore.items),
|
||||
},
|
||||
{
|
||||
width: '15%',
|
||||
|
|
@ -719,12 +665,17 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
|
|||
},
|
||||
];
|
||||
|
||||
if (store.hasFeature(AppFeature.Labels)) {
|
||||
if (hasFeature(AppFeature.Labels)) {
|
||||
columns.splice(-2, 0, {
|
||||
width: '5%',
|
||||
title: 'Labels',
|
||||
key: 'labels',
|
||||
render: (item: AlertType) => this.renderLabels(item),
|
||||
render: ({ labels }: AlertType) => (
|
||||
<LabelsTooltipBadge
|
||||
labels={labels}
|
||||
onClick={(label) => filtersStore.applyLabelFilter(label, PAGE.Incidents)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
columns.find((column) => column.key === 'title').width = '30%';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React from 'react';
|
||||
|
||||
import { LabelTag } from '@grafana/labels';
|
||||
import {
|
||||
HorizontalGroup,
|
||||
Button,
|
||||
|
|
@ -23,6 +22,7 @@ import { RouteComponentProps, withRouter } from 'react-router-dom';
|
|||
import GTable from 'components/GTable/GTable';
|
||||
import HamburgerMenu from 'components/HamburgerMenu/HamburgerMenu';
|
||||
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
|
||||
import LabelsTooltipBadge from 'components/LabelsTooltipBadge/LabelsTooltipBadge';
|
||||
import { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
|
||||
import {
|
||||
getWrongTeamResponseInfo,
|
||||
|
|
@ -46,7 +46,6 @@ import {
|
|||
MaintenanceMode,
|
||||
SupportedIntegrationFilters,
|
||||
} from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
import { LabelKeyValue } from 'models/label/label.types';
|
||||
import IntegrationHelper from 'pages/integration/Integration.helper';
|
||||
import { AppFeature } from 'state/features';
|
||||
import { PageProps, WithStoreProps } from 'state/types';
|
||||
|
|
@ -275,7 +274,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
data-testid="integrations-table"
|
||||
rowKey="id"
|
||||
data={results}
|
||||
columns={this.getTableColumns(store.hasFeature.bind(store))}
|
||||
columns={this.getTableColumns(store.hasFeature)}
|
||||
className={cx('integrations-table')}
|
||||
rowClassName={cx('integrations-table-row')}
|
||||
pagination={{
|
||||
|
|
@ -472,37 +471,6 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
return null;
|
||||
}
|
||||
|
||||
renderLabels(item: AlertReceiveChannel) {
|
||||
if (!item.labels.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipBadge
|
||||
borderType="secondary"
|
||||
icon="tag-alt"
|
||||
addPadding
|
||||
text={item.labels?.length}
|
||||
tooltipContent={
|
||||
<VerticalGroup spacing="sm">
|
||||
{item.labels.map((label) => (
|
||||
<HorizontalGroup spacing="sm" key={label.key.id}>
|
||||
<LabelTag label={label.key.name} value={label.value.name} key={label.key.id} />
|
||||
<Button
|
||||
size="sm"
|
||||
icon="filter"
|
||||
tooltip="Apply filter"
|
||||
variant="secondary"
|
||||
onClick={this.getApplyLabelFilterClickHandler(label)}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
))}
|
||||
</VerticalGroup>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderTeam(item: AlertReceiveChannel, teams: any) {
|
||||
return (
|
||||
<TextEllipsisTooltip placement="top" content={teams[item.team]?.name}>
|
||||
|
|
@ -583,7 +551,11 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
};
|
||||
|
||||
getTableColumns = (hasFeatureFn) => {
|
||||
const { grafanaTeamStore, alertReceiveChannelStore } = this.props.store;
|
||||
const {
|
||||
grafanaTeamStore,
|
||||
alertReceiveChannelStore,
|
||||
filtersStore: { applyLabelFilter },
|
||||
} = this.props.store;
|
||||
const isConnectionsTab = this.state.activeTab === TabType.Connections;
|
||||
|
||||
const columns = [
|
||||
|
|
@ -639,7 +611,9 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
columns.splice(-2, 0, {
|
||||
width: '10%',
|
||||
title: 'Labels',
|
||||
render: (item: AlertReceiveChannel) => this.renderLabels(item),
|
||||
render: ({ labels }: AlertReceiveChannel) => (
|
||||
<LabelsTooltipBadge labels={labels} onClick={(label) => applyLabelFilter(label, PAGE.Integrations)} />
|
||||
),
|
||||
});
|
||||
columns.find((column) => column.key === 'datasource').width = '15%';
|
||||
}
|
||||
|
|
@ -683,29 +657,6 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
this.setState({ integrationsFilters }, () => this.debouncedUpdateIntegrations(isOnMount));
|
||||
};
|
||||
|
||||
getApplyLabelFilterClickHandler = (label: LabelKeyValue) => {
|
||||
const {
|
||||
store: { filtersStore },
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
integrationsFilters: { label: oldLabelFilter = [] },
|
||||
} = this.state;
|
||||
|
||||
return () => {
|
||||
const labelToAddString = `${label.key.id}:${label.value.id}`;
|
||||
if (oldLabelFilter.some((label) => label === labelToAddString)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newLabelFilter = [...oldLabelFilter, labelToAddString];
|
||||
|
||||
LocationHelper.update({ label: newLabelFilter }, 'partial');
|
||||
|
||||
filtersStore.setNeedToParseFilters(true);
|
||||
};
|
||||
};
|
||||
|
||||
applyFilters = async (isOnMount: boolean) => {
|
||||
const { store } = this.props;
|
||||
const { alertReceiveChannelStore } = store;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { RouteComponentProps, withRouter } from 'react-router-dom';
|
|||
|
||||
import GTable from 'components/GTable/GTable';
|
||||
import HamburgerMenu from 'components/HamburgerMenu/HamburgerMenu';
|
||||
import LabelsTooltipBadge from 'components/LabelsTooltipBadge/LabelsTooltipBadge';
|
||||
import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
|
||||
import {
|
||||
getWrongTeamResponseInfo,
|
||||
|
|
@ -33,6 +34,7 @@ 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 { AppFeature } from 'state/features';
|
||||
import { PageProps, WithStoreProps } from 'state/types';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
import { openErrorNotification, openNotification } from 'utils';
|
||||
|
|
@ -98,13 +100,15 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
};
|
||||
|
||||
update = () => {
|
||||
const { store } = this.props;
|
||||
return store.outgoingWebhookStore.updateItems();
|
||||
const {
|
||||
store: { outgoingWebhookStore },
|
||||
} = this.props;
|
||||
return outgoingWebhookStore.updateItems();
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
store,
|
||||
store: { outgoingWebhookStore, filtersStore, grafanaTeamStore, hasFeature },
|
||||
history,
|
||||
match: {
|
||||
params: { id },
|
||||
|
|
@ -112,7 +116,7 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
} = this.props;
|
||||
const { outgoingWebhookId, outgoingWebhookAction, errorData, confirmationModal } = this.state;
|
||||
|
||||
const webhooks = store.outgoingWebhookStore.getSearchResult();
|
||||
const webhooks = outgoingWebhookStore.getSearchResult();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
|
|
@ -134,13 +138,27 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
},
|
||||
{
|
||||
width: '10%',
|
||||
title: 'Last run',
|
||||
render: this.renderLastRun,
|
||||
title: 'Last event',
|
||||
render: this.renderLastEvent,
|
||||
},
|
||||
...(hasFeature(AppFeature.Labels)
|
||||
? [
|
||||
{
|
||||
width: '10%',
|
||||
title: 'Labels',
|
||||
render: ({ labels }: OutgoingWebhook) => (
|
||||
<LabelsTooltipBadge
|
||||
labels={labels}
|
||||
onClick={(label) => filtersStore.applyLabelFilter(label, PAGE.Webhooks)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
width: '15%',
|
||||
title: 'Team',
|
||||
render: (item: OutgoingWebhook) => this.renderTeam(item, store.grafanaTeamStore.items),
|
||||
render: (item: OutgoingWebhook) => this.renderTeam(item, grafanaTeamStore.items),
|
||||
},
|
||||
{
|
||||
width: '20%',
|
||||
|
|
@ -357,17 +375,17 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
);
|
||||
}
|
||||
|
||||
renderLastRun(record: OutgoingWebhook) {
|
||||
const lastRunMoment = moment(record.last_response_log?.timestamp);
|
||||
renderLastEvent(record: OutgoingWebhook) {
|
||||
const lastEventMoment = 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">{lastEventMoment.isValid() ? lastEventMoment.format('MMM DD, YYYY') : '-'}</Text>
|
||||
<Text type="secondary">{lastEventMoment.isValid() ? lastEventMoment.format('HH:mm') : ''}</Text>
|
||||
<Text type="secondary">
|
||||
{lastRunMoment.isValid()
|
||||
{lastEventMoment.isValid()
|
||||
? record.last_response_log?.status_code
|
||||
? 'Status: ' + record.last_response_log?.status_code
|
||||
: 'Check Status'
|
||||
|
|
|
|||
|
|
@ -291,7 +291,13 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
shiftSwapIdToShowForm
|
||||
? this.adjustShiftSwapForm
|
||||
: (event: Event) => {
|
||||
this.handleShowForm(event.shift.pk);
|
||||
const shiftId = event.shift.pk;
|
||||
|
||||
if (event.shift.type === 2 && !disabledRotationForm) {
|
||||
this.handleShowRotationForm(shiftId);
|
||||
} else if (event.shift.type === 3 && !disabledOverrideForm) {
|
||||
this.handleShowOverridesForm(shiftId);
|
||||
}
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
|
@ -382,20 +388,6 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
});
|
||||
};
|
||||
|
||||
handleShowForm = async (shiftId: Shift['id'] | 'new') => {
|
||||
const {
|
||||
store: { scheduleStore },
|
||||
} = this.props;
|
||||
|
||||
const shift = await scheduleStore.updateOncallShift(shiftId);
|
||||
|
||||
if (shift.type === 2) {
|
||||
this.handleShowRotationForm(shiftId);
|
||||
} else if (shift.type === 3) {
|
||||
this.handleShowOverridesForm(shiftId);
|
||||
}
|
||||
};
|
||||
|
||||
handleShowRotationForm = (shiftId: Shift['id'] | 'new') => {
|
||||
this.setState({ shiftIdToShowRotationForm: shiftId });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -263,10 +263,8 @@ export class RootBaseStore {
|
|||
});
|
||||
}
|
||||
|
||||
hasFeature(feature: string | AppFeature) {
|
||||
// todo use AppFeature only
|
||||
return this.features?.[feature];
|
||||
}
|
||||
// todo use AppFeature only
|
||||
hasFeature = (feature: string | AppFeature) => this.features?.[feature];
|
||||
|
||||
get license() {
|
||||
if (this.backendLicense) {
|
||||
|
|
|
|||
|
|
@ -1966,10 +1966,10 @@
|
|||
"@opentelemetry/sdk-trace-web" "^1.8.0"
|
||||
"@opentelemetry/semantic-conventions" "^1.8.0"
|
||||
|
||||
"@grafana/labels@~1.2.1":
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@grafana/labels/-/labels-1.2.1.tgz#4113d584bf5cd826d011f957cb69c90bd0416ea8"
|
||||
integrity sha512-Nlqqvjwh0MjWsqnfpYbKdYwByeKSmEpiit5mKd6Mnnbc5Hxb8ORIruMr40lTxxWLEnDfhENcAs6pvlBuIMG7tQ==
|
||||
"@grafana/labels@1.3.4":
|
||||
version "1.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@grafana/labels/-/labels-1.3.4.tgz#8d9cdd215a80a1da1045d402c037be85d7efd6f5"
|
||||
integrity sha512-YYCuLGvtrMz7KkbMc6qoNJQr6drDLo6mMI27LcqsTDMHCNO3uJWpzC1Q2Y9MIwctIuTFYhbgfLvIunEegCx6PQ==
|
||||
dependencies:
|
||||
"@emotion/css" "^11.11.2"
|
||||
"@grafana/ui" "^10.0.0"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue