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:
parent
e48349ffcb
commit
10a74e3c21
8 changed files with 78 additions and 169 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 ({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue