Integration with grafana incident (#1081)

Check if Grafana Incident is enabled. If it is, add a button with a link
to declare Grafana Incident from Alert group in Slack and on Web.

Co-authored-by: Yulia Shanyrova <yulia.shanyrova@grafana.com>
This commit is contained in:
Yulya Artyukhina 2023-01-17 13:04:50 +01:00 committed by GitHub
parent 5bd8fbdef8
commit 9129a720ef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 202 additions and 49 deletions

View file

@ -311,6 +311,16 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer):
resolution_notes_button["style"] = "primary"
resolution_notes_button["text"]["text"] = "Add Resolution notes"
buttons.append(resolution_notes_button)
# Incident button
if self.alert_group.channel.organization.is_grafana_incident_enabled:
incident_button = {
"type": "button",
"text": {"type": "plain_text", "text": ":fire: Declare Incident", "emoji": True},
"value": "declare_incident",
"url": self.alert_group.declare_incident_link,
}
buttons.append(incident_button)
else:
if not self.alert_group.resolved:
buttons.append(

View file

@ -1,4 +1,5 @@
import logging
import urllib
from collections import namedtuple
from typing import Optional, TypedDict
from urllib.parse import urljoin
@ -453,6 +454,15 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
def web_link(self) -> str:
return urljoin(self.channel.organization.web_link, f"?page=incident&id={self.public_primary_key}")
@property
def declare_incident_link(self) -> str:
"""Generate a link for AlertGroup to declare Grafana Incident by click"""
incident_link = urljoin(self.channel.organization.grafana_url, "a/grafana-incident-app/incidents/declare/")
caption = urllib.parse.quote_plus("OnCall Alert Group")
title = urllib.parse.quote_plus(self.web_title_cache) if self.web_title_cache else DEFAULT_BACKUP_TITLE
link = urllib.parse.quote_plus(self.web_link)
return urljoin(incident_link, f"?caption={caption}&url={link}&title={title}")
@property
def happened_while_maintenance(self):
return self.root_alert_group is not None and self.root_alert_group.maintenance_uuid is not None

View file

@ -83,6 +83,7 @@ class AlertGroupListSerializer(EagerLoadingMixin, serializers.ModelSerializer):
"dependent_alert_groups",
"root_alert_group",
"status",
"declare_incident_link",
]
def get_render_for_web(self, obj):

View file

@ -189,6 +189,9 @@ class GrafanaAPIClient(APIClient):
def update_alerting_config(self, recipient, config):
return self.api_post(f"api/alertmanager/{recipient}/config/api/v1/alerts", config)
def get_grafana_plugin_settings(self, recipient):
return self.api_get(f"api/plugins/{recipient}/settings")
class GcomAPIClient(APIClient):
ACTIVE_INSTANCE_QUERY = "instances?status=active"

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.16 on 2023-01-16 09:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('user_management', '0007_organization_deleted_at'),
]
operations = [
migrations.AddField(
model_name='organization',
name='is_grafana_incident_enabled',
field=models.BooleanField(default=False),
),
]

View file

@ -216,6 +216,7 @@ class Organization(MaintainableObject):
is_amixr_migration_started = models.BooleanField(default=False)
is_rbac_permissions_enabled = models.BooleanField(default=False)
is_grafana_incident_enabled = models.BooleanField(default=False)
class Meta:
unique_together = ("stack_id", "org_id")

View file

@ -35,6 +35,7 @@ def sync_organization(organization):
if api_users:
organization.api_token_status = Organization.API_TOKEN_STATUS_OK
sync_users_and_teams(grafana_api_client, api_users, organization)
organization.is_grafana_incident_enabled = check_grafana_incident_is_enabled(grafana_api_client)
else:
organization.api_token_status = Organization.API_TOKEN_STATUS_FAILED
@ -49,6 +50,7 @@ def sync_organization(organization):
"api_token_status",
"gcom_token_org_last_time_synced",
"is_rbac_permissions_enabled",
"is_grafana_incident_enabled",
]
)
@ -92,6 +94,15 @@ def sync_users_and_teams(client, api_users, organization):
organization.last_time_synced = timezone.now()
def check_grafana_incident_is_enabled(client):
GRAFANA_INCIDENT_PLUGIN = "grafana-incident-app"
grafana_incident_settings, _ = client.get_grafana_plugin_settings(GRAFANA_INCIDENT_PLUGIN)
is_grafana_incident_enabled = False
if isinstance(grafana_incident_settings, dict) and grafana_incident_settings.get("enabled"):
is_grafana_incident_enabled = True
return is_grafana_incident_enabled
def delete_organization_if_needed(organization):
# Organization has a manually set API token, it will not be found within GCOM
# and would need to be deleted manually.

View file

@ -6,7 +6,7 @@ from django.test import override_settings
from apps.grafana_plugin.helpers.client import GcomAPIClient, GrafanaAPIClient
from apps.user_management.models import Team, User
from apps.user_management.sync import cleanup_organization, sync_organization
from apps.user_management.sync import check_grafana_incident_is_enabled, cleanup_organization, sync_organization
@pytest.mark.django_db
@ -138,7 +138,10 @@ def test_sync_organization(make_organization, make_team, make_user_for_organizat
with patch.object(GrafanaAPIClient, "get_users", return_value=api_users_response):
with patch.object(GrafanaAPIClient, "get_teams", return_value=(api_teams_response, None)):
with patch.object(GrafanaAPIClient, "get_team_members", return_value=(api_members_response, None)):
sync_organization(organization)
with patch.object(
GrafanaAPIClient, "get_grafana_plugin_settings", return_value=({"enabled": True}, None)
):
sync_organization(organization)
# check that users are populated
assert organization.users.count() == 1
@ -154,6 +157,9 @@ def test_sync_organization(make_organization, make_team, make_user_for_organizat
assert team.users.count() == 1
assert team.users.get() == user
# check that is_grafana_incident_enabled flag is set
assert organization.is_grafana_incident_enabled is True
@pytest.mark.parametrize("grafana_api_response", [False, True])
@override_settings(LICENSE=settings.OPEN_SOURCE_LICENSE_NAME)
@ -233,3 +239,19 @@ def test_cleanup_organization_deleted(make_organization):
organization.refresh_from_db()
assert organization.deleted_at is not None
@pytest.mark.django_db
@pytest.mark.parametrize(
"response,expected_result",
[
(({"enabled": True}, {}), True),
(({"enabled": False}, {}), False),
((None, {}), False),
],
)
def test_check_grafana_incident_is_enabled(response, expected_result):
client = GrafanaAPIClient("", "")
with patch.object(GrafanaAPIClient, "get_grafana_plugin_settings", return_value=response):
result = check_grafana_incident_is_enabled(client)
assert result == expected_result

View file

@ -0,0 +1,30 @@
import React, { FC, ReactElement } from 'react';
import { useAsync } from 'react-use';
import { getPluginSettings } from './PluginService';
export enum SupportedPlugin {
Incident = 'grafana-incident-app',
MachineLearning = 'grafana-ml-app',
}
export type PluginID = SupportedPlugin | string;
export interface PluginBridgeProps {
plugin: PluginID;
// shows an optional component when the plugin is not installed
children?: ReactElement;
}
export const PluginBridge: FC<PluginBridgeProps> = ({ children, plugin }) => {
const { error, value } = useAsync(() => getPluginSettings(plugin, { showErrorAlert: false }));
const installed = value && !error;
const enabled = value?.enabled;
if (!installed || !enabled) {
return null;
}
return <>{children}</>;
};

View file

@ -0,0 +1,29 @@
import { PluginMeta } from '@grafana/data';
import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime';
import { SupportedPlugin } from './PluginBridge';
type PluginId = SupportedPlugin | string;
const pluginCache = new Map<string, PluginMeta>();
export function getPluginSettings(pluginId: PluginId, options?: Partial<BackendSrvRequest>): Promise<PluginMeta> {
const pluginMetadata = pluginCache.get(pluginId);
if (pluginMetadata) {
return Promise.resolve(pluginMetadata);
}
return (
getBackendSrv()
.get(`/api/plugins/${pluginId}/settings`, undefined, undefined, options)
.then((settings: PluginMeta) => {
pluginCache.set(pluginId, settings);
return settings;
})
// TODO this error handling could be better
.catch((err: unknown) => {
return Promise.reject(new Error('Unknown Plugin' + err));
})
);
}

View file

@ -52,6 +52,7 @@ export interface Alert {
acknowledged_on_source: boolean;
channel: Channel;
slack_permalink?: string;
declare_incident_link?: string;
related_users: User[];
render_after_resolve_report_json?: TimeLineItem[];
render_for_slack: { attachments: any[] };

View file

@ -116,3 +116,7 @@
.timeline-filter {
margin-bottom: 24px;
}
.title-icon {
color: var(--secondary-text-color);
}

View file

@ -29,6 +29,7 @@ import {
getWrongTeamResponseInfo,
initErrorDataState,
} from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
import { PluginBridge, SupportedPlugin } from 'components/PluginBridge/PluginBridge';
import PluginLink from 'components/PluginLink/PluginLink';
import SourceCode from 'components/SourceCode/SourceCode';
import Text from 'components/Text/Text';
@ -204,34 +205,58 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
const integration = store.alertReceiveChannelStore.getIntegration(incident.alert_receive_channel);
const showLinkTo = !incident.dependent_alert_groups.length && !incident.root_alert_group && !incident.resolved;
return (
<Block withBackground className={cx('block')}>
<VerticalGroup>
<HorizontalGroup className={cx('title')}>
<PluginLink query={{ page: 'incidents', cursor, start, perpage }}>
<IconButton name="arrow-left" size="xxl" />
</PluginLink>
{/* @ts-ignore*/}
<HorizontalGroup align="baseline">
<Text.Title level={3}>
{' '}
/ #{incident.inside_organization_number} {incident.render_for_web.title}
</Text.Title>
{incident.root_alert_group && (
<Text type="secondary">
Attached to{' '}
<PluginLink query={{ page: 'incident', id: incident.root_alert_group.pk }}>
#{incident.root_alert_group.inside_organization_number}{' '}
{incident.root_alert_group.render_for_web.title}
</PluginLink>{' '}
<WithPermissionControl userAction={UserActions.AlertGroupsWrite}>
<Button variant="secondary" onClick={this.getUnattachClickHandler(incident.pk)} size="sm">
Unattach
</Button>
</WithPermissionControl>
</Text>
)}
<HorizontalGroup justify="space-between">
<HorizontalGroup className={cx('title')}>
<PluginLink query={{ page: 'incidents', cursor, start, perpage }}>
<IconButton name="arrow-left" size="xxl" />
</PluginLink>
{/* @ts-ignore*/}
<HorizontalGroup align="baseline">
<Text.Title level={3}>
{' '}
/ #{incident.inside_organization_number} {incident.render_for_web.title}
</Text.Title>
{incident.root_alert_group && (
<Text type="secondary">
Attached to{' '}
<PluginLink query={{ page: 'incident', id: incident.root_alert_group.pk }}>
#{incident.root_alert_group.inside_organization_number}{' '}
{incident.root_alert_group.render_for_web.title}
</PluginLink>{' '}
<WithPermissionControl userAction={UserActions.AlertGroupsWrite}>
<Button variant="secondary" onClick={this.getUnattachClickHandler(incident.pk)} size="sm">
Unattach
</Button>
</WithPermissionControl>
</Text>
)}
</HorizontalGroup>
</HorizontalGroup>
<HorizontalGroup align="center">
<Text>
<CopyToClipboard
text={window.location.href}
onCopy={() => {
openNotification('Link copied');
}}
>
<IconButton name="code-branch" tooltip="Copy link" className={cx('title-icon')} />
</CopyToClipboard>
<a href={incident.slack_permalink} target="_blank" rel="noreferrer">
<IconButton name="slack" tooltip="View in Slack" className={cx('title-icon')} />
</a>
{showLinkTo && (
<IconButton
name="share-alt"
onClick={this.showAttachIncidentForm}
tooltip="Attach to another alert group"
className={cx('title-icon')}
/>
)}
</Text>
</HorizontalGroup>
</HorizontalGroup>
<div className={cx('info-row')}>
@ -244,7 +269,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
</HorizontalGroup>
</div>
<HorizontalGroup justify="space-between" className={cx('buttons-row')}>
<div>
<HorizontalGroup>
{getActionButtons(incident, cx, {
onResolve: this.getOnActionButtonClick(incident.pk, AlertAction.Resolve),
onUnacknowledge: this.getOnActionButtonClick(incident.pk, AlertAction.unAcknowledge),
@ -253,28 +278,16 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
onSilence: this.getSilenceClickHandler(incident),
onUnsilence: this.getUnsilenceClickHandler(incident),
})}
</div>
<PluginBridge plugin={SupportedPlugin.Incident}>
<a href={incident.declare_incident_link} target="_blank" rel="noreferrer">
<Button variant="primary" size="sm" icon="fire">
Declare incident
</Button>
</a>
</PluginBridge>
</HorizontalGroup>
<HorizontalGroup>
<CopyToClipboard
text={window.location.href}
onCopy={() => {
openNotification('Link copied');
}}
>
<Button variant="primary" size="sm" icon="copy">
Copy Link
</Button>
</CopyToClipboard>
<a href={incident.slack_permalink} target="_blank" rel="noreferrer">
<Button variant="primary" size="sm" icon="slack">
View in Slack
</Button>
</a>
{showLinkTo && (
<Button variant="primary" size="sm" icon="link" onClick={this.showAttachIncidentForm}>
Attach to another alert group
</Button>
)}
<PluginLink query={{ page: 'integrations', id: incident.alert_receive_channel.id }}>
<Button disabled={incident.alert_receive_channel.deleted} variant="secondary" size="sm" icon="compass">
Go to Integration