diff --git a/CHANGELOG.md b/CHANGELOG.md index 3591afec..51ec4c0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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 + +## Added + +- Add reset button to disable integration heartbeat @mderynck ([#3959](https://github.com/grafana/oncall/pull/3959)) + ## v1.3.109 (2024-03-04) ### Fixed diff --git a/docs/sources/integrations/_index.md b/docs/sources/integrations/_index.md index 40a0b5f4..a42a94ce 100644 --- a/docs/sources/integrations/_index.md +++ b/docs/sources/integrations/_index.md @@ -96,6 +96,9 @@ to the heartbeat endpoint. If OnCall doesn't receive one of these alerts, it wil 1. Set **Heartbeat interval** 1. Copy **Endpoint** into you monitoring system. +If you need to disable heartbeat monitoring on an integration use the **Reset** button to return it to the state of being +inactive. To start the heartbeat monitoring again send a request to the **Endpoint**. + More specific instructions can be found in a specific integration's documentation. #### Behaviour and rendering templates example diff --git a/engine/apps/api/tests/test_integration_heartbeat.py b/engine/apps/api/tests/test_integration_heartbeat.py index 9b0cd9c7..d2dbb0d1 100644 --- a/engine/apps/api/tests/test_integration_heartbeat.py +++ b/engine/apps/api/tests/test_integration_heartbeat.py @@ -181,6 +181,29 @@ def test_update_integration_heartbeat( assert updated_instance.timeout_seconds == 600 +@pytest.mark.django_db +def test_reset_integration_heartbeat( + integration_heartbeat_internal_api_setup, + make_user_auth_headers, +): + user, token, alert_receive_channel, integration_heartbeat = integration_heartbeat_internal_api_setup + last_updated = timezone.now() + integration_heartbeat.last_heartbeat_time = last_updated + integration_heartbeat.save() + heartbeat_before_reset = IntegrationHeartBeat.objects.get( + public_primary_key=integration_heartbeat.public_primary_key + ) + assert heartbeat_before_reset.last_heartbeat_time == last_updated + + client = APIClient() + url = reverse("api-internal:integration_heartbeat-reset", kwargs={"pk": integration_heartbeat.public_primary_key}) + + response = client.post(url, **make_user_auth_headers(user, token)) + reset_instance = IntegrationHeartBeat.objects.get(public_primary_key=integration_heartbeat.public_primary_key) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert reset_instance.last_heartbeat_time is None + + @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", diff --git a/engine/apps/api/views/integration_heartbeat.py b/engine/apps/api/views/integration_heartbeat.py index f482f960..1a3f03dd 100644 --- a/engine/apps/api/views/integration_heartbeat.py +++ b/engine/apps/api/views/integration_heartbeat.py @@ -1,4 +1,4 @@ -from rest_framework import mixins, viewsets +from rest_framework import mixins, status, viewsets from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -30,8 +30,7 @@ class IntegrationHeartBeatView( "create": [RBACPermission.Permissions.INTEGRATIONS_WRITE], "update": [RBACPermission.Permissions.INTEGRATIONS_WRITE], "partial_update": [RBACPermission.Permissions.INTEGRATIONS_WRITE], - "activate": [RBACPermission.Permissions.INTEGRATIONS_WRITE], - "deactivate": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "reset": [RBACPermission.Permissions.INTEGRATIONS_WRITE], } model = IntegrationHeartBeat @@ -74,6 +73,13 @@ class IntegrationHeartBeatView( new_state=new_state, ) + @action(detail=True, methods=["post"]) + def reset(self, request, pk): + instance = self.get_object() + instance.last_heartbeat_time = None + instance.save() + return Response(status=status.HTTP_204_NO_CONTENT) + @action(detail=False, methods=["get"]) def timeout_options(self, request): choices = [] diff --git a/grafana-plugin/src/containers/HeartbeatModal/HeartbeatForm.module.css b/grafana-plugin/src/containers/HeartbeatModal/HeartbeatForm.module.css deleted file mode 100644 index 3dbfe9fa..00000000 --- a/grafana-plugin/src/containers/HeartbeatModal/HeartbeatForm.module.css +++ /dev/null @@ -1,29 +0,0 @@ -.select { - width: 200px; -} - -.heartbeat-button { - width: 14px; - height: 14px; - border-radius: 50%; - margin-right: 8px; - display: inline-block; - background-color: grey; -} - -.heartbeat-button_false { - background-color: #ff4d4f; -} - -.heartbeat-button_true { - background-color: #73d13d; -} - -.root .select { - width: 250px; - display: inline-block; -} - -.buttons { - margin-top: 20px; -} diff --git a/grafana-plugin/src/containers/HeartbeatModal/HeartbeatForm.tsx b/grafana-plugin/src/containers/HeartbeatModal/HeartbeatForm.tsx deleted file mode 100644 index d121ba3b..00000000 --- a/grafana-plugin/src/containers/HeartbeatModal/HeartbeatForm.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; - -import { SelectableValue } from '@grafana/data'; -import { Button, HorizontalGroup, Select } from '@grafana/ui'; -import cn from 'classnames/bind'; -import { observer } from 'mobx-react'; -import Emoji from 'react-emoji-render'; - -import { Text } from 'components/Text/Text'; -import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; -import { HeartGreenIcon, HeartRedIcon } from 'icons/Icons'; -import { ApiSchemas } from 'network/oncall-api/api.types'; -import { SelectOption } from 'state/types'; -import { useStore } from 'state/useStore'; -import { withMobXProviderContext } from 'state/withStore'; -import { UserActions } from 'utils/authorization/authorization'; - -import styles from './HeartbeatForm.module.css'; - -const cx = cn.bind(styles); - -interface HeartBeatModalProps { - alertReceveChannelId: ApiSchemas['AlertReceiveChannel']['id']; - onUpdate: () => void; -} - -const HeartbeatForm = observer(({ alertReceveChannelId, onUpdate }: HeartBeatModalProps) => { - const store = useStore(); - const { alertReceiveChannelStore, heartbeatStore } = store; - const [timeout, setTimeoutSeconds] = useState(); - - const alertReceiveChannel = alertReceiveChannelStore.items[alertReceveChannelId]; - - const heartbeatId = alertReceiveChannelStore.alertReceiveChannelToHeartbeat[alertReceveChannelId]; - - const heartbeat = heartbeatStore.items[heartbeatId]; - - useEffect(() => { - if (heartbeat) { - setTimeoutSeconds(heartbeat.timeout_seconds); - } - }, [heartbeat]); - - useEffect(() => { - heartbeatStore.updateTimeoutOptions(); - }, [heartbeatStore]); - - const handleOkClick = useCallback(async () => { - if (heartbeat) { - await heartbeatStore.saveHeartbeat(heartbeat.id, { - alert_receive_channel: heartbeat.alert_receive_channel, - timeout_seconds: timeout, - }); - - onUpdate(); - } else { - await heartbeatStore.createHeartbeat(alertReceveChannelId, { - timeout_seconds: timeout, - }); - - onUpdate(); - } - }, [alertReceveChannelId, heartbeat, heartbeatStore, onUpdate, timeout]); - - const handleTimeoutChange = useCallback((value: SelectableValue) => { - setTimeoutSeconds(value.value); - }, []); - - const heartbeatStatus = Boolean(heartbeat?.status); - - const timeoutOptions = heartbeatStore.timeoutOptions; - - return ( -
- - {heartbeatStatus ? : } - {heartbeat && ( - - {heartbeat.last_heartbeat_time_verbal - ? `Heartbeat received ${heartbeat.last_heartbeat_time_verbal} ago` - : 'A heartbeat has not been received.'} - - )} - -
-
-

- A heartbeat acts as a healthcheck for alert group monitoring. You can configure OnCall to regularly send alerts - to the heartbeat endpoint. If you don't receive one of these alerts, OnCall will issue an alert group. -

-

- OnCall will issue an alert group if no alert is received every - -