Add reset button to disable integration heartbeat (#3959)

# What this PR does
Add a button to reset integration heartbeat into the state before it
received its first request. This has the effect of disabling the
heartbeat until a request starts it up again.

## Which issue(s) this PR fixes
#3956 

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
This commit is contained in:
Michael Derynck 2024-03-04 12:20:00 -07:00 committed by GitHub
parent e48349ffcb
commit 10a74e3c21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 78 additions and 169 deletions

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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 = []

View file

@ -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;
}

View file

@ -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<number | undefined>();
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 (
<div className={cx('root')}>
<HorizontalGroup>
{heartbeatStatus ? <HeartGreenIcon /> : <HeartRedIcon />}
{heartbeat && (
<Text>
{heartbeat.last_heartbeat_time_verbal
? `Heartbeat received ${heartbeat.last_heartbeat_time_verbal} ago`
: 'A heartbeat has not been received.'}
</Text>
)}
</HorizontalGroup>
<br />
<br />
<p>
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.
</p>
<p>
<span>OnCall will issue an alert group if no alert is received every</span>
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<Select
className={cx('select', 'timeout')}
onChange={handleTimeoutChange}
placeholder="Heartbeat Timeout"
value={timeout}
options={(timeoutOptions || []).map((timeoutOption: SelectOption) => ({
value: timeoutOption.value,
label: timeoutOption.display_name,
}))}
/>
</WithPermissionControlTooltip>
</p>
{heartbeat && (
<p>
<Text>Use the following unique Grafana link to send GET and POST requests:</Text>
<pre>
<code>{heartbeat?.link}</code>
</pre>
</p>
)}
{heartbeat && (
<p>
To send periodic heartbeat alerts from <Emoji text={alertReceiveChannel?.verbal_name || ''} /> to OnCall, do
the following:
<span
dangerouslySetInnerHTML={{
__html: heartbeat?.instruction,
}}
/>
</p>
)}
<HorizontalGroup className={cx('buttons')}>
<WithPermissionControlTooltip key="ok" userAction={UserActions.IntegrationsWrite}>
<Button variant="primary" onClick={handleOkClick}>
{heartbeat ? 'Save' : 'Create'}
</Button>
</WithPermissionControlTooltip>
</HorizontalGroup>
</div>
);
});
export default withMobXProviderContext(HeartbeatForm);

View file

@ -7,6 +7,7 @@ import { observer } from 'mobx-react';
import { IntegrationInputField } from 'components/IntegrationInputField/IntegrationInputField';
import { Text } from 'components/Text/Text';
import { WithConfirm } from 'components/WithConfirm/WithConfirm';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { SelectOption } from 'state/types';
@ -49,7 +50,7 @@ const _IntegrationHeartbeatForm = observer(({ alertReceveChannelId, onClose }: I
<VerticalGroup spacing={'lg'}>
<Text type="secondary">
A heartbeat acts as a healthcheck for alert group monitoring. You can configure you monitoring to regularly
send alerts to the heartbeat endpoint. If OnCall doen't receive one of these alerts, it will create an new
send alerts to the heartbeat endpoint. If OnCall doesn't receive one of these alerts, it will create an new
alert group and escalate it
</Text>
@ -100,6 +101,13 @@ const _IntegrationHeartbeatForm = observer(({ alertReceveChannelId, onClose }: I
Update
</Button>
</WithPermissionControlTooltip>
<WithPermissionControlTooltip key="reset" userAction={UserActions.IntegrationsWrite}>
<WithConfirm title="Are you sure to reset integration heartbeat?" confirmText="Reset">
<Button variant="destructive" onClick={onReset} data-testid="reset-heartbeat">
Reset
</Button>
</WithConfirm>
</WithPermissionControlTooltip>
</HorizontalGroup>
</VerticalGroup>
</VerticalGroup>
@ -119,6 +127,11 @@ const _IntegrationHeartbeatForm = observer(({ alertReceveChannelId, onClose }: I
await alertReceiveChannelStore.fetchItemById(alertReceveChannelId);
}
async function onReset() {
await heartbeatStore.resetHeartbeatAndRefetchIntegration(heartbeatId, alertReceveChannelId);
onClose();
}
});
export const IntegrationHeartbeatForm = withMobXProviderContext(_IntegrationHeartbeatForm) as ({

View file

@ -4,6 +4,7 @@ import { BaseStore } from 'models/base_store';
import { makeRequest } from 'network/network';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { RootStore } from 'state/rootStore';
import { WithGlobalNotification } from 'utils/decorators';
import { Heartbeat } from './heartbeat.types';
@ -70,4 +71,26 @@ export class HeartbeatStore extends BaseStore {
};
});
}
@WithGlobalNotification({ success: 'Heartbeat has been reset' })
@action.bound
async resetHeartbeatAndRefetchIntegration(
heartbeatId: Heartbeat['id'],
integrationId: ApiSchemas['AlertReceiveChannel']['id']
) {
const response = await makeRequest(`${this.path}${heartbeatId}/reset`, { method: 'POST' });
if (!response) {
return;
}
runInAction(() => {
this.items = {
...this.items,
[response.id]: response,
};
});
await this.rootStore.alertReceiveChannelStore.fetchItemById(integrationId);
}
}