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:
parent
5bd8fbdef8
commit
9129a720ef
13 changed files with 202 additions and 49 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
30
grafana-plugin/src/components/PluginBridge/PluginBridge.tsx
Normal file
30
grafana-plugin/src/components/PluginBridge/PluginBridge.tsx
Normal 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}</>;
|
||||
};
|
||||
29
grafana-plugin/src/components/PluginBridge/PluginService.ts
Normal file
29
grafana-plugin/src/components/PluginBridge/PluginService.ts
Normal 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));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -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[] };
|
||||
|
|
|
|||
|
|
@ -116,3 +116,7 @@
|
|||
.timeline-filter {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue