diff --git a/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py b/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py index 71925009..ce10a167 100644 --- a/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py +++ b/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py @@ -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( diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index 48ba98c8..ee5b6a8d 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -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 diff --git a/engine/apps/api/serializers/alert_group.py b/engine/apps/api/serializers/alert_group.py index 5c2e9517..1f866368 100644 --- a/engine/apps/api/serializers/alert_group.py +++ b/engine/apps/api/serializers/alert_group.py @@ -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): diff --git a/engine/apps/grafana_plugin/helpers/client.py b/engine/apps/grafana_plugin/helpers/client.py index 7a49d077..071c7162 100644 --- a/engine/apps/grafana_plugin/helpers/client.py +++ b/engine/apps/grafana_plugin/helpers/client.py @@ -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" diff --git a/engine/apps/user_management/migrations/0008_organization_is_grafana_incident_enabled.py b/engine/apps/user_management/migrations/0008_organization_is_grafana_incident_enabled.py new file mode 100644 index 00000000..3c8598e9 --- /dev/null +++ b/engine/apps/user_management/migrations/0008_organization_is_grafana_incident_enabled.py @@ -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), + ), + ] diff --git a/engine/apps/user_management/models/organization.py b/engine/apps/user_management/models/organization.py index b65b1837..ba11ed26 100644 --- a/engine/apps/user_management/models/organization.py +++ b/engine/apps/user_management/models/organization.py @@ -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") diff --git a/engine/apps/user_management/sync.py b/engine/apps/user_management/sync.py index c9636cc6..9d2b907d 100644 --- a/engine/apps/user_management/sync.py +++ b/engine/apps/user_management/sync.py @@ -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. diff --git a/engine/apps/user_management/tests/test_sync.py b/engine/apps/user_management/tests/test_sync.py index e35739ed..40fc1223 100644 --- a/engine/apps/user_management/tests/test_sync.py +++ b/engine/apps/user_management/tests/test_sync.py @@ -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 diff --git a/grafana-plugin/src/components/PluginBridge/PluginBridge.tsx b/grafana-plugin/src/components/PluginBridge/PluginBridge.tsx new file mode 100644 index 00000000..65298cac --- /dev/null +++ b/grafana-plugin/src/components/PluginBridge/PluginBridge.tsx @@ -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 = ({ 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}; +}; diff --git a/grafana-plugin/src/components/PluginBridge/PluginService.ts b/grafana-plugin/src/components/PluginBridge/PluginService.ts new file mode 100644 index 00000000..a7b86e1d --- /dev/null +++ b/grafana-plugin/src/components/PluginBridge/PluginService.ts @@ -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(); + +export function getPluginSettings(pluginId: PluginId, options?: Partial): Promise { + 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)); + }) + ); +} diff --git a/grafana-plugin/src/models/alertgroup/alertgroup.types.ts b/grafana-plugin/src/models/alertgroup/alertgroup.types.ts index 0a435c59..b165048b 100644 --- a/grafana-plugin/src/models/alertgroup/alertgroup.types.ts +++ b/grafana-plugin/src/models/alertgroup/alertgroup.types.ts @@ -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[] }; diff --git a/grafana-plugin/src/pages/incident/Incident.module.css b/grafana-plugin/src/pages/incident/Incident.module.css index eda3f4b5..eaa5826a 100644 --- a/grafana-plugin/src/pages/incident/Incident.module.css +++ b/grafana-plugin/src/pages/incident/Incident.module.css @@ -116,3 +116,7 @@ .timeline-filter { margin-bottom: 24px; } + +.title-icon { + color: var(--secondary-text-color); +} diff --git a/grafana-plugin/src/pages/incident/Incident.tsx b/grafana-plugin/src/pages/incident/Incident.tsx index af34dadc..02e64647 100644 --- a/grafana-plugin/src/pages/incident/Incident.tsx +++ b/grafana-plugin/src/pages/incident/Incident.tsx @@ -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 const integration = store.alertReceiveChannelStore.getIntegration(incident.alert_receive_channel); const showLinkTo = !incident.dependent_alert_groups.length && !incident.root_alert_group && !incident.resolved; - return ( - - - - - {/* @ts-ignore*/} - - - {' '} - / #{incident.inside_organization_number} {incident.render_for_web.title} - - {incident.root_alert_group && ( - - Attached to{' '} - - #{incident.root_alert_group.inside_organization_number}{' '} - {incident.root_alert_group.render_for_web.title} - {' '} - - - - - )} + + + + + + {/* @ts-ignore*/} + + + {' '} + / #{incident.inside_organization_number} {incident.render_for_web.title} + + {incident.root_alert_group && ( + + Attached to{' '} + + #{incident.root_alert_group.inside_organization_number}{' '} + {incident.root_alert_group.render_for_web.title} + {' '} + + + + + )} + + + + + { + openNotification('Link copied'); + }} + > + + + + + + {showLinkTo && ( + + )} +
@@ -244,7 +269,7 @@ class IncidentPage extends React.Component
-
+ {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 onSilence: this.getSilenceClickHandler(incident), onUnsilence: this.getUnsilenceClickHandler(incident), })} -
+ + + + + +
+ - { - openNotification('Link copied'); - }} - > - - - - - - {showLinkTo && ( - - )}