Updates to POST /direct_paging internal API endpoint to support Grafana Incident use-cases (#3232)
# What this PR does - Add a new column, `grafana_incident_id`, to the `AlertGroup` model. For now this is really just needed to determine if the Alert Group was created, via a Direct Page, that originated from Grafana Incident. - I understand that these IDs may be cluster specific. For now we will not need to make OnCall backend -> Incident API calls. Should we need to start doing this we will likely need to start syncing the Incident plugin's provisioned API url into the `organization` in OnCall, such that we make the API call to the right Incident backend. - Add two new optional request body parameters to `POST /direct_paging`, `source_url` and `grafana_incident_id` - `source_url` - will easily allow Grafana Incident to specify the URL to the Incident and have this populate the "Source" button - `grafana_incident_id` - Grafana Incident can specify this such that we have a link back to Incident (+ we know that the Alert Group was generated from Incident) - Hide the "Declare Incident" button in the UI if the Alert Group was generated from Grafana Incident ## Checklist - [ ] Unit, integration, and e2e (if applicable) tests updated - [ ] Documentation added (or `pr:no public docs` PR label added if not required) - [ ] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required)
This commit is contained in:
parent
3b02c587f5
commit
f905ac5246
9 changed files with 128 additions and 32 deletions
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -392,6 +392,8 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
|
|||
# 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():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ class AlertGroupListSerializer(EagerLoadingMixin, AlertGroupFieldsCacheSerialize
|
|||
"status",
|
||||
"declare_incident_link",
|
||||
"team",
|
||||
"grafana_incident_id",
|
||||
]
|
||||
|
||||
@extend_schema_field(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue