Merge pull request #3248 from grafana/dev

v1.3.50
This commit is contained in:
Ildar Iskhakov 2023-11-02 18:20:43 +08:00 committed by GitHub
commit 60761a62c6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 445 additions and 90 deletions

View file

@ -107,6 +107,8 @@ steps:
target: prod
config:
from_secret: gcr_admin
build_args:
- BUILDKIT_INLINE_CACHE=1
depends_on:
- Lint Backend
- Unit Test Backend
@ -128,6 +130,8 @@ steps:
from_secret: docker_password
username:
from_secret: docker_username
build_args:
- BUILDKIT_INLINE_CACHE=1
depends_on:
- Lint Backend
- Unit Test Backend
@ -244,6 +248,11 @@ steps:
# Be aware that the this plugin requires privileged capabilities, otherwise the integrated
# Docker daemon is not able to start.
privileged: true
environment:
# force docker to use buildkit feature, this will skip build stages that aren't required in
# the final image (ie. dev & dev-enterprise)
# https://github.com/docker/cli/issues/1134#issuecomment-406449342
DOCKER_BUILDKIT: 1
settings:
repo: grafana/oncall
tags: latest,${DRONE_TAG}
@ -255,9 +264,36 @@ steps:
from_secret: docker_password
username:
from_secret: docker_username
build_args:
- BUILDKIT_INLINE_CACHE=1
depends_on:
- set engine version
- name: merge helm release oss pr
image: alpine
environment:
GITHUB_API_KEY:
from_secret: github_api_token
# Allow this step to fail as it's not critical to the build process and we don't want to block
# the build promotion. If this step fails we can always merge the PR manually
failure: ignore
commands:
# yamllint disable rule:line-length
- apk add --no-cache curl jq
# Step 1. Fetch PRs from GitHub's API that're open and have a particular head ref indicative of a helm release PR
# API docs - https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#list-pull-requests
# NOTE: ${DRONE_TAG:1} will slice off the "v" prefix from the tag
- 'curl -L -H "Accept: application/vnd.github+json" -H "Authorization: Bearer $${GITHUB_API_KEY}" -H "X-GitHub-Api-Version: 2022-11-28" "https://api.github.com/repos/grafana/oncall/pulls?head=grafana:helm-release/$${DRONE_TAG:1}&state=open" > prs.json'
- cat prs.json
# Step 2. Extract the PR number from the first PR in the list to be able to pass that to the next API call
- cat prs.json | jq -r ".[0].number" > pr_number.txt
- cat pr_number.txt
# Step 3. Merge the PR (https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#merge-a-pull-request)
- 'cat pr_number.txt | xargs -r -I{pull_number} curl -L -X PUT -H "Accept: application/vnd.github+json" -H "Authorization: Bearer $${GITHUB_API_KEY}" -H "X-GitHub-Api-Version: 2022-11-28" "https://api.github.com/repos/grafana/oncall/pulls/{pull_number}/merge"'
# yamllint enable rule:line-length
depends_on:
- build and push docker image
trigger:
event:
- promote
@ -327,8 +363,14 @@ get:
path: infra/data/ci/drone
kind: secret
name: drone_token
---
# GitHub API token (only scoped to grafana/oncall + PR access)
get:
name: github-api-token
path: ci/data/repo/grafana/oncall/drone
kind: secret
name: github_api_token
---
kind: signature
hmac: 8b387638ef35f86ed9f8c8c964726e0ae703beb7d0f18e053b5041ab8ee9bed2
...
hmac: 9cc2cc2db01455d34768577047f8069d19c0573b6a05dd26c88b9deb3af3a0c9

View file

@ -124,8 +124,11 @@ jobs:
engine/requirements-dev.txt
- name: Lint migrations
working-directory: engine
# makemigrations --check = Exit with a non-zero status if model changes are missing migrations
# and don't actually write them.
run: |
pip install -r requirements.txt -r requirements-dev.txt
python manage.py makemigrations --check
python manage.py lintmigrations
unit-test-helm-chart:

View file

@ -12,16 +12,16 @@ jobs:
- name: Update procedure
run: |
BRANCH=update-make-docs
git checkout -b "${BRANCH}"
curl -s -Lo docs/docs.mk https://raw.githubusercontent.com/grafana/writers-toolkit/main/docs/docs.mk
curl -s -Lo docs/make-docs https://raw.githubusercontent.com/grafana/writers-toolkit/main/docs/make-docs
if git diff --exit-code; then exit 0; fi
BRANCH="$(date +%Y-%m-%d)/update-make-docs"
git checkout -b "${BRANCH}"
git add .
git config --local user.email "bot@grafana.com"
git config --local user.name "grafanabot"
git commit -m "Update \`make docs\` procedure"
git push -v origin "refs/heads/${BRANCH}"
gh pr create --fill --label "pr:no changelog"
gh pr create --fill --label "pr:no changelog" || true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -5,7 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
## v1.3.50 (2023-11-02)
### Fixed
- Return alert groups for deleted integrations on private api ([3223](https://github.com/grafana/oncall/pull/3223))
## v1.3.49 (2023-10-31)
@ -15,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Prevent additional polling on Incidents if the previous request didn't complete
([#3205](https://github.com/grafana/oncall/pull/3205))
- Order results from `GET /teams` internal API endpoint by ascending name by @joeyorlando ([#3220](https://github.com/grafana/oncall/pull/3220))
- Order alert groups internal API endpoint by descending started_at by @mderynck ([#3240](https://github.com/grafana/oncall/pull/3240))
### Fixed

View file

@ -31,13 +31,11 @@ def plugin_json():
allow_k8s_contexts(["kind-kind"])
local_resource("download-cache", cmd="docker pull grafana/oncall:latest; docker tag grafana/oncall localhost:63628/grafana/oncall:latest")
# Build the image including frontend folder for pytest
docker_build_sub(
"localhost:63628/oncall/engine:dev",
context="./engine",
cache_from="localhost:63628/grafana/oncall:latest",
cache_from="grafana/oncall:latest",
# only=["./engine", "./grafana-plugin"],
ignore=["./grafana-plugin/test-results/", "./grafana-plugin/dist/", "./grafana-plugin/e2e-tests/"],
child_context=".",

View file

@ -6,6 +6,12 @@
# [Semantic versioning](https://semver.org/) is used to help the reader identify the significance of changes.
# Changes are relevant to this script and the support docs.mk GNU Make interface.
#
# ## 5.1.1 (2023-10-30)
#
# ### Added
#
# - Support for Datadog and Oracle data source plugins repositories.
#
# ## 5.1.0 (2023-10-20)
#
# ### Added
@ -249,8 +255,10 @@ SOURCES_grafana_cloud_frontend_observability_faro_web_sdk='faro-web-sdk'
SOURCES_helm_charts_mimir_distributed='mimir'
SOURCES_helm_charts_tempo_distributed='tempo'
SOURCES_opentelemetry='opentelemetry-docs'
SOURCES_plugins_grafana_datadog_datasource='datadog-datasource'
SOURCES_plugins_grafana_jira_datasource='jira-datasource'
SOURCES_plugins_grafana_mongodb_datasource='mongodb-datasource'
SOURCES_plugins_grafana_oracle_datasource='oracle-datasource'
SOURCES_plugins_grafana_splunk_datasource='splunk-datasource'
VERSIONS_as_code='UNVERSIONED'
@ -261,8 +269,10 @@ VERSIONS_grafana_cloud_k6='UNVERSIONED'
VERSIONS_grafana_cloud_data_configuration_integrations='UNVERSIONED'
VERSIONS_grafana_cloud_frontend_observability_faro_web_sdk='UNVERSIONED'
VERSIONS_opentelemetry='UNVERSIONED'
VERSIONS_plugins_grafana_datadog_datasource='latest'
VERSIONS_plugins_grafana_jira_datasource='latest'
VERSIONS_plugins_grafana_mongodb_datasource='latest'
VERSIONS_plugins_grafana_oracle_datasource='latest'
VERSIONS_plugins_grafana_splunk_datasource='latest'
VERSIONS_technical_documentation='UNVERSIONED'
VERSIONS_website='UNVERSIONED'
@ -272,8 +282,10 @@ PATHS_grafana_cloud='content/docs/grafana-cloud'
PATHS_helm_charts_mimir_distributed='docs/sources/helm-charts/mimir-distributed'
PATHS_helm_charts_tempo_distributed='docs/sources/helm-charts/tempo-distributed'
PATHS_mimir='docs/sources/mimir'
PATHS_plugins_grafana_datadog_datasource='docs/sources'
PATHS_plugins_grafana_jira_datasource='docs/sources'
PATHS_plugins_grafana_mongodb_datasource='docs/sources'
PATHS_plugins_grafana_oracle_datasource='docs/sources'
PATHS_plugins_grafana_splunk_datasource='docs/sources'
PATHS_tempo='docs/sources/tempo'
PATHS_website='content'

View file

@ -247,10 +247,9 @@ class EscalationSnapshotMixin:
is_on_maintenance_or_debug_mode = self.channel.maintenance_mode is not None
if self.is_restricted or is_on_maintenance_or_debug_mode or not self.escalation_chain_exists:
if is_on_maintenance_or_debug_mode or not self.escalation_chain_exists:
logger.debug(
f"Not escalating alert group w/ pk: {self.pk}\n"
f"is_restricted: {self.is_restricted}\n"
f"is_on_maintenance_or_debug_mode: {is_on_maintenance_or_debug_mode}\n"
f"escalation_chain_exists: {self.escalation_chain_exists}"
)

View file

@ -1,6 +1,5 @@
from apps.alerts.incident_appearance.renderers.base_renderer import AlertBaseRenderer, AlertGroupBaseRenderer
from apps.alerts.incident_appearance.templaters import AlertClassicMarkdownTemplater
from common.constants.alert_group_restrictions import IS_RESTRICTED_MESSAGE, IS_RESTRICTED_TITLE
from common.utils import str_or_backup
@ -11,13 +10,11 @@ class AlertClassicMarkdownRenderer(AlertBaseRenderer):
def render(self):
templated_alert = self.templated_alert
is_restricted = self.alert.group.is_restricted
return {
"title": IS_RESTRICTED_TITLE if is_restricted else str_or_backup(templated_alert.title, "Alert"),
"message": IS_RESTRICTED_MESSAGE if is_restricted else str_or_backup(templated_alert.message, ""),
"image_url": None if is_restricted else str_or_backup(templated_alert.image_url, None),
"source_link": None if is_restricted else str_or_backup(templated_alert.source_link, None),
"title": str_or_backup(templated_alert.title, "Alert"),
"message": str_or_backup(templated_alert.message, ""),
"image_url": str_or_backup(templated_alert.image_url, None),
"source_link": str_or_backup(templated_alert.source_link, None),
}

View file

@ -1,6 +1,5 @@
from apps.alerts.incident_appearance.renderers.base_renderer import AlertBaseRenderer, AlertGroupBaseRenderer
from apps.alerts.incident_appearance.templaters import AlertWebTemplater
from common.constants.alert_group_restrictions import IS_RESTRICTED_MESSAGE, IS_RESTRICTED_TITLE
from common.utils import str_or_backup
@ -11,13 +10,11 @@ class AlertWebRenderer(AlertBaseRenderer):
def render(self):
templated_alert = self.templated_alert
is_restricted = self.alert.group.is_restricted
return {
"title": IS_RESTRICTED_TITLE if is_restricted else str_or_backup(templated_alert.title, "Alert"),
"message": IS_RESTRICTED_MESSAGE if is_restricted else str_or_backup(templated_alert.message, ""),
"image_url": None if is_restricted else str_or_backup(templated_alert.image_url, None),
"source_link": None if is_restricted else str_or_backup(templated_alert.source_link, None),
"title": str_or_backup(templated_alert.title, "Alert"),
"message": str_or_backup(templated_alert.message, ""),
"image_url": str_or_backup(templated_alert.image_url, None),
"source_link": str_or_backup(templated_alert.source_link, None),
}

View file

@ -0,0 +1,20 @@
# Generated by Django 4.2.6 on 2023-10-31 19:56
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('user_management', '0017_alter_organization_maintenance_author'),
('alerts', '0034_alter_resolutionnote_source'),
]
operations = [
migrations.AlterField(
model_name='alertreceivechannel',
name='maintenance_author',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_maintenances_created', to='user_management.user'),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 4.2.6 on 2023-10-31 20:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('alerts', '0035_alter_alertreceivechannel_maintenance_author'),
]
operations = [
migrations.AddField(
model_name='alertgroup',
name='grafana_incident_id',
field=models.CharField(default=None, max_length=100, null=True),
),
]

View file

@ -389,8 +389,11 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
# https://code.djangoproject.com/ticket/28545
is_open_for_grouping = models.BooleanField(default=None, null=True, blank=True)
# TODO: drop this column in an upcoming release
is_restricted = models.BooleanField(default=False, null=True)
grafana_incident_id = models.CharField(max_length=100, null=True, default=None)
@staticmethod
def get_silenced_state_filter():
"""

View file

@ -44,7 +44,13 @@ class DirectPagingAlertPayload(typing.TypedDict):
def _trigger_alert(
organization: Organization, team: Team | None, message: str, title: str, from_user: User
organization: Organization,
team: Team | None,
message: str,
title: str,
permalink: str | None,
grafana_incident_id: str | None,
from_user: User,
) -> AlertGroup:
"""Trigger manual integration alert from params."""
alert_receive_channel = AlertReceiveChannel.get_or_create_manual_integration(
@ -73,7 +79,7 @@ def _trigger_alert(
"message": message,
"uid": str(uuid4()), # avoid grouping
"author_username": from_user.username,
"permalink": None,
"permalink": permalink,
},
}
@ -87,7 +93,13 @@ def _trigger_alert(
link_to_upstream_details=None,
channel_filter=channel_filter,
)
return alert.group
alert_group = alert.group
if grafana_incident_id is not None:
alert_group.grafana_incident_id = grafana_incident_id
alert_group.save(update_fields=["grafana_incident_id"])
return alert_group
def _construct_title(from_user: User, team: Team | None, users: UserNotifications) -> str:
@ -111,6 +123,8 @@ def direct_paging(
from_user: User,
message: str,
title: str | None = None,
source_url: str | None = None,
grafana_incident_id: str | None = None,
team: Team | None = None,
users: UserNotifications | None = None,
alert_group: AlertGroup | None = None,
@ -135,7 +149,7 @@ def direct_paging(
# create alert group if needed
if alert_group is None:
alert_group = _trigger_alert(organization, team, message, title, from_user)
alert_group = _trigger_alert(organization, team, message, title, source_url, grafana_incident_id, from_user)
for u, important in users:
alert_group.log_records.create(

View file

@ -71,5 +71,4 @@ class AlertRawSerializer(serializers.ModelSerializer):
]
def get_raw_request_data(self, obj):
# TODO:
return {} if obj.group.is_restricted else obj.raw_request_data
return obj.raw_request_data

View file

@ -149,7 +149,7 @@ class AlertGroupListSerializer(EagerLoadingMixin, AlertGroupFieldsCacheSerialize
"status",
"declare_incident_link",
"team",
"is_restricted",
"grafana_incident_id",
]
@extend_schema_field(

View file

@ -43,15 +43,21 @@ class DirectPagingSerializer(serializers.Serializer):
title = serializers.CharField(required=False, default=None)
message = serializers.CharField(required=False, default=None, allow_null=True)
source_url = serializers.URLField(required=False, default=None, allow_null=True)
grafana_incident_id = serializers.CharField(required=False, default=None, allow_null=True)
def validate(self, attrs):
organization = self.context["organization"]
alert_group_id = attrs["alert_group_id"]
title = attrs["title"]
message = attrs["message"]
source_url = attrs["source_url"]
grafana_incident_id = attrs["grafana_incident_id"]
if alert_group_id and (title or message):
raise serializers.ValidationError("alert_group_id and (title, message) are mutually exclusive")
if alert_group_id and (title or message or source_url or grafana_incident_id):
raise serializers.ValidationError(
"alert_group_id and (title, message, source_url, grafana_incident_id) are mutually exclusive"
)
if alert_group_id:
try:

View file

@ -76,6 +76,27 @@ def test_get_filter_by_integration(
assert len(response.data["results"]) == 4
@pytest.mark.django_db
def test_get_alert_groups_from_deleted_integration(alert_group_internal_api_setup, make_user_auth_headers):
user, token, alert_groups = alert_group_internal_api_setup
alert_receive_channel = alert_groups[0].channel
alert_receive_channel.delete()
client = APIClient()
url = reverse("api-internal:alertgroup-list")
response = client.get(
url,
format="json",
**make_user_auth_headers(user, token),
)
assert response.status_code == status.HTTP_200_OK
# Alert groups from deleted integrations should be returned
assert len(response.data["results"]) == 4
@pytest.mark.django_db
def test_get_filter_started_at(alert_group_internal_api_setup, make_user_auth_headers):
user, token, _ = alert_group_internal_api_setup

View file

@ -192,7 +192,7 @@ def test_labels_feature_false(
make_alert_receive_channel,
settings,
):
setattr(settings, "FEATURE_LABELS_ENABLED", False)
setattr(settings, "FEATURE_LABELS_ENABLED_FOR_ALL", False)
organization, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()

View file

@ -9,6 +9,8 @@ from apps.api.permissions import LegacyAccessControlRole
title = "Custom title"
message = "Testing direct paging with new alert group"
source_url = "https://www.example.com"
grafana_incident_id = "abcd1234"
@pytest.mark.django_db
@ -77,6 +79,43 @@ def test_direct_paging_page_team(
data={
"team": team.public_primary_key,
"message": message,
"source_url": source_url,
"grafana_incident_id": grafana_incident_id,
},
format="json",
**make_user_auth_headers(user, token),
)
assert response.status_code == status.HTTP_200_OK
alert_group = AlertGroup.objects.get(public_primary_key=response.json()["alert_group_id"])
alert = alert_group.alerts.first()
assert alert_group.grafana_incident_id == grafana_incident_id
assert alert.raw_request_data["oncall"]["permalink"] == source_url
@pytest.mark.django_db
def test_direct_paging_page_from_grafana_incident(
make_organization_and_user_with_plugin_token,
make_team,
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR)
team = make_team(organization=organization)
# user must be part of the team
user.teams.add(team)
client = APIClient()
url = reverse("api-internal:direct_paging")
response = client.post(
url,
data={
"team": team.public_primary_key,
"message": message,
"grafana_incident_id": "asdf",
},
format="json",
**make_user_auth_headers(user, token),
@ -185,15 +224,26 @@ def test_direct_paging_no_user_or_team_specified(
assert response.json()["detail"] == DirectPagingUserTeamValidationError.DETAIL
@pytest.mark.parametrize(
"field_name,field_value",
[
("title", title),
("message", message),
("source_url", source_url),
("grafana_incident_id", grafana_incident_id),
],
)
@pytest.mark.django_db
def test_direct_paging_alert_group_id_and_message_or_title_are_mutually_exclusive(
def test_direct_paging_alert_group_id_and_other_fields_are_mutually_exclusive(
make_organization_and_user_with_plugin_token,
make_team,
make_user_auth_headers,
make_alert_receive_channel,
make_alert_group,
field_name,
field_value,
):
error_msg = "alert_group_id and (title, message) are mutually exclusive"
error_msg = "alert_group_id and (title, message, source_url, grafana_incident_id) are mutually exclusive"
organization, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR)
team = make_team(organization=organization)
@ -207,17 +257,15 @@ def test_direct_paging_alert_group_id_and_message_or_title_are_mutually_exclusiv
client = APIClient()
url = reverse("api-internal:direct_paging")
base_data = {"team": team.public_primary_key, "alert_group_id": alert_group.public_primary_key}
response = client.post(
url, data={**base_data, "message": message}, format="json", **make_user_auth_headers(user, token)
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["non_field_errors"] == [error_msg]
response = client.post(
url, data={**base_data, "title": title}, format="json", **make_user_auth_headers(user, token)
url,
data={
"team": team.public_primary_key,
"alert_group_id": alert_group.public_primary_key,
field_name: field_value,
},
format="json",
**make_user_auth_headers(user, token),
)
assert response.status_code == status.HTTP_400_BAD_REQUEST

View file

@ -324,7 +324,7 @@ class AlertGroupView(
def get_queryset(self, ignore_filtering_by_available_teams=False):
# no select_related or prefetch_related is used at this point, it will be done on paginate_queryset.
alert_receive_channels_qs = AlertReceiveChannel.objects.filter(
alert_receive_channels_qs = AlertReceiveChannel.objects_with_deleted.filter(
organization_id=self.request.auth.organization.id
)
if not ignore_filtering_by_available_teams:
@ -423,7 +423,7 @@ class AlertGroupView(
# enrich alert groups with select_related and prefetch_related
alert_group_pks = [alert_group.pk for alert_group in alert_groups]
queryset = AlertGroup.objects.filter(pk__in=alert_group_pks).order_by("-pk")
queryset = AlertGroup.objects.filter(pk__in=alert_group_pks).order_by("-started_at")
queryset = self.get_serializer_class().setup_eager_loading(queryset)
alert_groups = list(queryset)

View file

@ -33,6 +33,8 @@ class DirectPagingAPIView(APIView):
from_user=request.user,
message=validated_data["message"],
title=validated_data["title"],
source_url=validated_data["source_url"],
grafana_incident_id=validated_data["grafana_incident_id"],
team=validated_data["team"],
users=[(user["instance"], user["important"]) for user in validated_data["users"]],
alert_group=validated_data["alert_group"],

View file

@ -2,7 +2,27 @@ import pytest
from apps.alerts.models import AlertReceiveChannel
from apps.labels.models import AlertReceiveChannelAssociatedLabel, AssociatedLabel, LabelValueCache
from apps.labels.utils import get_associating_label_model
from apps.labels.utils import get_associating_label_model, is_labels_feature_enabled
@pytest.mark.django_db
def test_labels_feature_flag(mock_is_labels_feature_enabled_for_org, make_organization, settings):
organization = make_organization()
# returns True if feature flag is enabled
assert settings.FEATURE_LABELS_ENABLED_FOR_ALL
assert organization.id not in settings.FEATURE_LABELS_ENABLED_FOR_GRAFANA_ORGS
assert is_labels_feature_enabled(organization)
mock_is_labels_feature_enabled_for_org(organization.org_id)
# returns True if feature flag is disabled and organization is in the feature list
assert not settings.FEATURE_LABELS_ENABLED_FOR_ALL
assert organization.org_id in settings.FEATURE_LABELS_ENABLED_FOR_GRAFANA_ORGS
assert is_labels_feature_enabled(organization)
mock_is_labels_feature_enabled_for_org(12345)
# returns False if feature flag is disabled and organization is not in the feature list
assert organization.org_id not in settings.FEATURE_LABELS_ENABLED_FOR_GRAFANA_ORGS
assert not is_labels_feature_enabled(organization)
@pytest.mark.django_db

View file

@ -43,12 +43,18 @@ def get_associating_label_model(obj_model_name: str) -> typing.Type["AssociatedL
def is_labels_feature_enabled(organization) -> bool:
# check FEATURE_LABELS_ENABLED in settings
# checking labels feature flag per organization will be added later
"""
Checks if labels feature enabled for all organizations (FEATURE_LABELS_ENABLED_FOR_ALL).
If not, checks if current organization's grafana org_id is in the list of organizations labels feature enabled for
(FEATURE_LABELS_ENABLED_FOR_GRAFANA_ORGS)
"""
logger.info(
"is_labels_feature_enabled: "
f"FEATURE_LABELS_ENABLED={settings.FEATURE_LABELS_ENABLED} "
f"FEATURE_LABELS_ENABLED_FOR_ALL={settings.FEATURE_LABELS_ENABLED_FOR_ALL}, "
f"organization in FEATURE_LABELS_ENABLED_FOR_GRAFANA_ORGS="
f"{organization.id in settings.FEATURE_LABELS_ENABLED_FOR_GRAFANA_ORGS}, "
f"organization={organization.id}"
)
return settings.FEATURE_LABELS_ENABLED
if not settings.FEATURE_LABELS_ENABLED_FOR_ALL:
return organization.org_id in settings.FEATURE_LABELS_ENABLED_FOR_GRAFANA_ORGS
return settings.FEATURE_LABELS_ENABLED_FOR_ALL

View file

@ -21,4 +21,4 @@ class AlertSerializer(EagerLoadingMixin, serializers.ModelSerializer):
]
def get_payload(self, obj):
return {} if obj.group.is_restricted else obj.raw_request_data
return obj.raw_request_data

View file

@ -4,7 +4,6 @@ from rest_framework import serializers
from apps.alerts.models import AlertGroup
from apps.telegram.models.message import TelegramMessage
from common.api_helpers.mixins import EagerLoadingMixin
from common.constants.alert_group_restrictions import IS_RESTRICTED_TITLE
class IncidentSerializer(EagerLoadingMixin, serializers.ModelSerializer):
@ -42,7 +41,7 @@ class IncidentSerializer(EagerLoadingMixin, serializers.ModelSerializer):
]
def get_title(self, obj):
return IS_RESTRICTED_TITLE if obj.is_restricted else obj.web_title_cache
return obj.web_title_cache
def get_alerts_count(self, obj):
return len(obj.alerts.all())

View file

@ -0,0 +1,20 @@
# Generated by Django 4.2.6 on 2023-10-31 19:56
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('schedules', '0016_alter_shiftswaprequest_created_at'),
]
operations = [
migrations.AlterField(
model_name='oncallschedule',
name='polymorphic_ctype',
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'),
),
]

View file

@ -0,0 +1,35 @@
# Generated by Django 4.2.6 on 2023-10-31 19:56
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('phone_notifications', '0001_initial'),
('twilioapp', '0007_delete_twiliologrecord'),
]
operations = [
migrations.AlterField(
model_name='twiliophonecallsender',
name='account',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_account', to='twilioapp.twilioaccount'),
),
migrations.AlterField(
model_name='twiliosms',
name='sms_record',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_related', related_query_name='%(app_label)s_%(class)ss', to='phone_notifications.smsrecord'),
),
migrations.AlterField(
model_name='twiliosmssender',
name='account',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_account', to='twilioapp.twilioaccount'),
),
migrations.AlterField(
model_name='twilioverificationsender',
name='account',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_account', to='twilioapp.twilioaccount'),
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 4.2.6 on 2023-10-31 19:56
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('user_management', '0016_alter_user_role'),
]
operations = [
migrations.AlterField(
model_name='organization',
name='maintenance_author',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_maintenances_created', to='user_management.user'),
),
]

View file

@ -0,0 +1,20 @@
# Generated by Django 4.2.6 on 2023-10-31 19:56
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('phone_notifications', '0001_initial'),
('zvonok', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='zvonokphonecall',
name='phone_call_record',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_related', related_query_name='%(app_label)s_%(class)ss', to='phone_notifications.phonecallrecord'),
),
]

View file

@ -1,2 +0,0 @@
IS_RESTRICTED_TITLE = "UPGRADE TO SEE MORE"
IS_RESTRICTED_MESSAGE = "UPGRADE TO SEE MORE"

View file

@ -1,5 +1,6 @@
import functools
import html
import json
import os
import random
import re
@ -112,6 +113,14 @@ def getenv_integer(variable_name: str, default: int) -> int:
return default
def getenv_list(variable_name: str, default: list) -> list:
value = os.environ.get(variable_name)
if value is None:
return default
return json.loads(value)
def batch_queryset(qs, batch_size=1000):
qs_count = qs.count()
for start in range(0, qs_count, batch_size):

View file

@ -180,7 +180,16 @@ def mock_apply_async(monkeypatch):
@pytest.fixture(autouse=True)
def mock_is_labels_feature_enabled(settings):
setattr(settings, "FEATURE_LABELS_ENABLED", True)
setattr(settings, "FEATURE_LABELS_ENABLED_FOR_ALL", True)
@pytest.fixture
def mock_is_labels_feature_enabled_for_org(settings):
def _mock_is_labels_feature_enabled_for_org(org_id):
setattr(settings, "FEATURE_LABELS_ENABLED_FOR_ALL", False)
setattr(settings, "FEATURE_LABELS_ENABLED_FOR_GRAFANA_ORGS", [org_id])
return _mock_is_labels_feature_enabled_for_org
@pytest.fixture

View file

@ -7,7 +7,7 @@ from random import randrange
from celery.schedules import crontab
from firebase_admin import credentials, initialize_app
from common.utils import getenv_boolean, getenv_integer
from common.utils import getenv_boolean, getenv_integer, getenv_list
VERSION = "dev-oss"
SEND_ANONYMOUS_USAGE_STATS = getenv_boolean("SEND_ANONYMOUS_USAGE_STATS", default=True)
@ -66,9 +66,12 @@ FEATURE_MULTIREGION_ENABLED = getenv_boolean("FEATURE_MULTIREGION_ENABLED", defa
FEATURE_INBOUND_EMAIL_ENABLED = getenv_boolean("FEATURE_INBOUND_EMAIL_ENABLED", default=True)
FEATURE_PROMETHEUS_EXPORTER_ENABLED = getenv_boolean("FEATURE_PROMETHEUS_EXPORTER_ENABLED", default=False)
FEATURE_GRAFANA_ALERTING_V2_ENABLED = getenv_boolean("FEATURE_GRAFANA_ALERTING_V2_ENABLED", default=False)
FEATURE_LABELS_ENABLED = getenv_boolean("FEATURE_LABELS_ENABLED", default=False)
GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED = getenv_boolean("GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", default=True)
GRAFANA_CLOUD_NOTIFICATIONS_ENABLED = getenv_boolean("GRAFANA_CLOUD_NOTIFICATIONS_ENABLED", default=True)
# Enable labels feature fo all organizations. This flag overrides FEATURE_LABELS_ENABLED_FOR_GRAFANA_ORGS
FEATURE_LABELS_ENABLED_FOR_ALL = getenv_boolean("FEATURE_LABELS_ENABLED_FOR_ALL", default=False)
# Enable labels feature for organizations from the list. Use Grafana org_id, not OnCall id, for this flag
FEATURE_LABELS_ENABLED_FOR_GRAFANA_ORGS = getenv_list("FEATURE_LABELS_ENABLED_FOR_GRAFANA_ORGS", default=list())
TWILIO_API_KEY_SID = os.environ.get("TWILIO_API_KEY_SID")
TWILIO_API_KEY_SECRET = os.environ.get("TWILIO_API_KEY_SECRET")

View file

@ -113,7 +113,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.1.0",
"@grafana/labels": "~1.2.1",
"@grafana/runtime": "9.3.0-beta1",
"@grafana/ui": "^9.4.7",
"@opentelemetry/api": "^1.3.0",

View file

@ -1,9 +1,9 @@
import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react';
import '@grafana/labels/dist/theme.css';
import ServiceLabels from '@grafana/labels';
import { Field } from '@grafana/ui';
import cn from 'classnames/bind';
import { isEmpty } from 'lodash-es';
import { observer } from 'mobx-react';
import { LabelKeyValue } from 'models/label/label.types';
@ -57,6 +57,31 @@ const Labels = observer(
};
}, []);
const isValid = () => {
return (
(propsErrors || [])
.map((error: LabelKeyValue, index) => {
// error object is empty => Valid
if (isEmpty(error)) {
return undefined;
}
const matchingValue = value[index]?.value;
// We have a name for the value => Valid
if (error.value && matchingValue?.name) {
return undefined;
}
const matchingKey = value[index]?.key;
// We have a name for the key => Valid
if (error.key && matchingKey?.name) {
return undefined;
}
// Invalid
return error;
})
.filter((er: LabelKeyValue) => er).length === 0
);
};
const cachedOnLoadValuesForKey = useCallback(() => {
let result = undefined;
return async (key: string, search?: string) => {
@ -87,7 +112,7 @@ const Labels = observer(
onUpdateValue={labelsStore.updateKeyValue.bind(labelsStore)}
onRowItemRemoval={(_pair, _index) => {}}
onUpdateError={onUpdateError}
errors={{ ...propsErrors }}
errors={isValid() ? {} : { ...propsErrors }}
onDataUpdate={setValue}
/>
</Field>

View file

@ -85,6 +85,7 @@ export interface Alert {
alert_receive_channel: Partial<AlertReceiveChannel>;
paged_users: PagedUser[];
team: GrafanaTeam['id'];
grafana_incident_id: string | null;
// set by client
loading?: boolean;

View file

@ -177,13 +177,15 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
</div>
<div className={cx('column')}>
<VerticalGroup style={{ display: 'block' }}>
<AddResponders
mode="update"
hideAddResponderButton={incident.resolved}
existingPagedUsers={incident.paged_users}
onAddNewParticipant={this.handleAddUserResponder}
generateRemovePreviouslyPagedUserCallback={this.handlePagedUserRemove}
/>
{(!incident.resolved || incident.paged_users.length > 0) && (
<AddResponders
mode="update"
hideAddResponderButton={incident.resolved}
existingPagedUsers={incident.paged_users}
onAddNewParticipant={this.handleAddUserResponder}
generateRemovePreviouslyPagedUserCallback={this.handlePagedUserRemove}
/>
)}
{this.renderTimeline()}
</VerticalGroup>
</div>
@ -400,13 +402,15 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
onSilence: this.getSilenceClickHandler(incident),
onUnsilence: this.getUnsilenceClickHandler(incident),
})}
<PluginBridge plugin={SupportedPlugin.Incident}>
<a href={incident.declare_incident_link} target="_blank" rel="noreferrer">
<Button variant="secondary" size="md" icon="fire">
Declare incident
</Button>
</a>
</PluginBridge>
{incident.grafana_incident_id === null && (
<PluginBridge plugin={SupportedPlugin.Incident}>
<a href={incident.declare_incident_link} target="_blank" rel="noreferrer">
<Button variant="secondary" size="md" icon="fire">
Declare incident
</Button>
</a>
</PluginBridge>
)}
</HorizontalGroup>
<Button

View file

@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { LabelTag } from '@grafana/labels';
import {
Button,
HorizontalGroup,
@ -11,7 +12,6 @@ import {
ConfirmModal,
Drawer,
Alert,
Tag as GrafanaTag,
} from '@grafana/ui';
import cn from 'classnames/bind';
import { get } from 'lodash-es';
@ -1072,7 +1072,7 @@ const IntegrationHeader: React.FC<IntegrationHeaderProps> = ({
<VerticalGroup spacing="sm">
{alertReceiveChannel.labels.length
? alertReceiveChannel.labels.map((label) => (
<GrafanaTag name={`${label.key.name}:${label.value.name}`} key={label.key.id} />
<LabelTag label={label.key.name} value={label.value.name} key={label.key.id} />
))
: 'No labels attached'}
</VerticalGroup>

View file

@ -1,6 +1,7 @@
import React from 'react';
import { HorizontalGroup, Button, VerticalGroup, Icon, ConfirmModal, Tooltip, Tag } from '@grafana/ui';
import { LabelTag } from '@grafana/labels';
import { HorizontalGroup, Button, VerticalGroup, Icon, ConfirmModal, Tooltip } from '@grafana/ui';
import cn from 'classnames/bind';
import { debounce } from 'lodash-es';
import { observer } from 'mobx-react';
@ -366,7 +367,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
{item.labels?.length
? item.labels.map((label) => (
<HorizontalGroup spacing="sm" key={label.key.id}>
<Tag name={`${label.key.name}:${label.value.name}`} />
<LabelTag label={label.key.name} value={label.value.name} key={label.key.id} />
<Button
size="sm"
icon="filter"

View file

@ -1454,7 +1454,7 @@
"@emotion/sheet" "^1.2.1"
"@emotion/utils" "^1.2.0"
"@emotion/css@11.11.2":
"@emotion/css@11.11.2", "@emotion/css@^11.11.2":
version "11.11.2"
resolved "https://registry.yarnpkg.com/@emotion/css/-/css-11.11.2.tgz#e5fa081d0c6e335352e1bc2b05953b61832dca5a"
integrity sha512-VJxe1ucoMYMS7DkiMdC2T7PWNbrEI0a39YRiyDvK2qq4lXwjRbVP/z4lpG+odCsRzadlR+1ywwrTzhdm5HNdew==
@ -1966,15 +1966,17 @@
"@opentelemetry/sdk-trace-web" "^1.8.0"
"@opentelemetry/semantic-conventions" "^1.8.0"
"@grafana/labels@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@grafana/labels/-/labels-1.1.0.tgz#d41f10d2cfdb7a83a0e86a49009505744574f784"
integrity sha512-LQHJ8KwHReDj6UxHmVsfDO3J128hebow2nmxSXSoc8j1KDeM847Q0FCibOnrcubQi/hI13ahwBDEWST7UBc7og==
"@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==
dependencies:
"@emotion/css" "^11.11.2"
"@grafana/ui" "^10.0.0"
change-case "^4.1.2"
react "^18.0.0"
react-dom "^18.0.0"
tinycolor2 "1.6.0"
"@grafana/runtime@9.3.0-beta1":
version "9.3.0-beta1"