Skip to content

Commit 18da310

Browse files
Maxim Mordasovjoeyorlando
Maxim Mordasov
andauthored
Maxim/bring heartbeats back to UI (#2550)
# What this PR does Bring heartbeats back to UI ## Which issue(s) this PR fixes ## Checklist - [ ] Unit, integration, and e2e (if applicable) tests updated - [ ] Documentation added (or `pr:no public docs` PR label added if not required) - [ ] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --------- Co-authored-by: Joey Orlando <[email protected]>
1 parent c843e66 commit 18da310

File tree

6 files changed

+196
-125
lines changed

6 files changed

+196
-125
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { test, Page, expect, Locator } from '../fixtures';
2+
3+
import { generateRandomValue, selectDropdownValue } from '../utils/forms';
4+
import { createIntegration } from '../utils/integrations';
5+
6+
test.describe("updating an integration's heartbeat interval works", async () => {
7+
test.slow();
8+
9+
const _openIntegrationSettingsPopup = async (page: Page): Promise<Locator> => {
10+
const integrationSettingsPopupElement = page.getByTestId('integration-settings-context-menu');
11+
await integrationSettingsPopupElement.click();
12+
return integrationSettingsPopupElement;
13+
};
14+
15+
const _openHeartbeatSettingsForm = async (page: Page) => {
16+
const integrationSettingsPopupElement = await _openIntegrationSettingsPopup(page);
17+
18+
await integrationSettingsPopupElement.click();
19+
20+
await page.getByTestId('integration-heartbeat-settings').click();
21+
};
22+
23+
test('"change heartbeat interval', async ({ adminRolePage: { page } }) => {
24+
const integrationName = generateRandomValue();
25+
await createIntegration(page, integrationName);
26+
27+
await _openHeartbeatSettingsForm(page);
28+
29+
const heartbeatSettingsForm = page.getByTestId('heartbeat-settings-form');
30+
31+
const value = '30 minutes';
32+
33+
await selectDropdownValue({
34+
page,
35+
startingLocator: heartbeatSettingsForm,
36+
selectType: 'grafanaSelect',
37+
value,
38+
optionExactMatch: false,
39+
});
40+
41+
await heartbeatSettingsForm.getByTestId('update-heartbeat').click();
42+
43+
await _openHeartbeatSettingsForm(page);
44+
45+
const heartbeatIntervalValue = await heartbeatSettingsForm
46+
.locator('div[class*="grafana-select-value-container"] > div[class*="-singleValue"]')
47+
.textContent();
48+
49+
expect(heartbeatIntervalValue).toEqual(value);
50+
});
51+
52+
test('"send heartbeat', async ({ adminRolePage: { page } }) => {
53+
const integrationName = generateRandomValue();
54+
await createIntegration(page, integrationName);
55+
56+
await _openHeartbeatSettingsForm(page);
57+
58+
const heartbeatSettingsForm = page.getByTestId('heartbeat-settings-form');
59+
60+
const endpoint = await heartbeatSettingsForm
61+
.getByTestId('input-wrapper')
62+
.locator('input[class*="input-input"]')
63+
.inputValue();
64+
65+
await page.goto(endpoint);
66+
67+
await page.goBack();
68+
69+
const heartbeatBadge = await page.getByTestId('heartbeat-badge');
70+
71+
await expect(heartbeatBadge).toHaveClass(/--success/);
72+
});
73+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.instruction {
2+
ol,
3+
ul {
4+
padding: 0;
5+
margin: 0;
6+
list-style: none;
7+
}
8+
}

src/containers/IntegrationContainers/IntegrationHeartbeatForm/IntegrationHeartbeatForm.tsx

+74-65
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { useEffect, useState } from 'react';
22

33
import { SelectableValue } from '@grafana/data';
4-
import { Button, Drawer, Field, HorizontalGroup, Select, VerticalGroup } from '@grafana/ui';
4+
import { Button, Drawer, Field, HorizontalGroup, Icon, Select, VerticalGroup } from '@grafana/ui';
55
import cn from 'classnames/bind';
66
import { observer } from 'mobx-react';
77

@@ -12,9 +12,12 @@ import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_
1212
import { SelectOption } from 'state/types';
1313
import { useStore } from 'state/useStore';
1414
import { withMobXProviderContext } from 'state/withStore';
15+
import { openNotification } from 'utils';
1516
import { UserActions } from 'utils/authorization';
1617

17-
const cx = cn.bind({});
18+
import styles from './IntegrationHeartbeatForm.module.scss';
19+
20+
const cx = cn.bind(styles);
1821

1922
interface IntegrationHeartbeatFormProps {
2023
alertReceveChannelId: AlertReceiveChannel['id'];
@@ -27,88 +30,94 @@ const IntegrationHeartbeatForm = observer(({ alertReceveChannelId, onClose }: In
2730
const { heartbeatStore, alertReceiveChannelStore } = useStore();
2831

2932
const alertReceiveChannel = alertReceiveChannelStore.items[alertReceveChannelId];
33+
const heartbeatId = alertReceiveChannelStore.alertReceiveChannelToHeartbeat[alertReceiveChannel.id];
34+
const heartbeat = heartbeatStore.items[heartbeatId];
3035

3136
useEffect(() => {
3237
heartbeatStore.updateTimeoutOptions();
33-
}, [heartbeatStore]);
38+
}, []);
3439

3540
useEffect(() => {
36-
if (alertReceiveChannel.heartbeat) {
37-
setInterval(alertReceiveChannel.heartbeat.timeout_seconds);
38-
}
39-
}, [alertReceiveChannel]);
41+
setInterval(heartbeat.timeout_seconds);
42+
}, [heartbeat]);
4043

4144
const timeoutOptions = heartbeatStore.timeoutOptions;
4245

4346
return (
4447
<Drawer width={'640px'} scrollableContent title={'Heartbeat'} onClose={onClose} closeOnMaskClick={false}>
45-
<VerticalGroup spacing={'lg'}>
46-
<Text type="secondary">
47-
A heartbeat acts as a healthcheck for alert group monitoring. You can configure you monitoring to regularly
48-
send alerts to the heartbeat endpoint. If OnCall doen't receive one of these alerts, it will create an new
49-
alert group and escalate it
50-
</Text>
51-
52-
<VerticalGroup spacing="md">
53-
<div className={cx('u-width-100')}>
54-
<Field label={'Setup heartbeat interval'}>
55-
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
56-
<Select
57-
className={cx('select', 'timeout')}
58-
onChange={(value: SelectableValue) => setInterval(value.value)}
59-
placeholder="Heartbeat Timeout"
60-
value={interval}
61-
options={(timeoutOptions || []).map((timeoutOption: SelectOption) => ({
62-
value: timeoutOption.value,
63-
label: timeoutOption.display_name,
64-
}))}
65-
/>
66-
</WithPermissionControlTooltip>
67-
</Field>
68-
</div>
69-
70-
<div className={cx('u-width-100')}>
71-
<Field label="Endpoint" description="Use the following unique Grafana link to send GET and POST requests">
72-
<IntegrationInputField value={alertReceiveChannel?.integration_url} showEye={false} isMasked={false} />
73-
</Field>
74-
</div>
75-
</VerticalGroup>
76-
77-
<VerticalGroup style={{ marginTop: 'auto' }}>
78-
<HorizontalGroup className={cx('buttons')} justify="flex-end">
79-
<Button variant={'secondary'} onClick={onClose}>
80-
Cancel
81-
</Button>
82-
<WithPermissionControlTooltip key="ok" userAction={UserActions.IntegrationsWrite}>
83-
<Button variant="primary" onClick={onSave}>
84-
{alertReceiveChannel.heartbeat ? 'Save' : 'Create'}
48+
<div data-testid="heartbeat-settings-form">
49+
<VerticalGroup spacing={'lg'}>
50+
<Text type="secondary">
51+
A heartbeat acts as a healthcheck for alert group monitoring. You can configure you monitoring to regularly
52+
send alerts to the heartbeat endpoint. If OnCall doen't receive one of these alerts, it will create an new
53+
alert group and escalate it
54+
</Text>
55+
56+
<VerticalGroup spacing="md">
57+
<div className={cx('u-width-100')}>
58+
<Field label={'Setup heartbeat interval'}>
59+
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
60+
<Select
61+
className={cx('select', 'timeout')}
62+
onChange={(value: SelectableValue) => setInterval(value.value)}
63+
placeholder="Heartbeat Timeout"
64+
value={interval}
65+
isLoading={!timeoutOptions}
66+
options={timeoutOptions?.map((timeoutOption: SelectOption) => ({
67+
value: timeoutOption.value,
68+
label: timeoutOption.display_name,
69+
}))}
70+
/>
71+
</WithPermissionControlTooltip>
72+
</Field>
73+
</div>
74+
<div className={cx('u-width-100')}>
75+
<Field label="Endpoint" description="Use the following unique Grafana link to send GET and POST requests">
76+
<IntegrationInputField value={heartbeat?.link} showEye={false} isMasked={false} />
77+
</Field>
78+
</div>
79+
<a
80+
href="https://grafana.com/docs/oncall/latest/integrations/alertmanager/#configuring-oncall-heartbeats-optional"
81+
target="_blank"
82+
rel="noreferrer"
83+
>
84+
<Text type="link" size="small">
85+
<HorizontalGroup>
86+
How to configure heartbeats
87+
<Icon name="external-link-alt" />
88+
</HorizontalGroup>
89+
</Text>
90+
</a>
91+
</VerticalGroup>
92+
93+
<VerticalGroup style={{ marginTop: 'auto' }}>
94+
<HorizontalGroup className={cx('buttons')} justify="flex-end">
95+
<Button variant={'secondary'} onClick={onClose} data-testid="close-heartbeat-form">
96+
Close
8597
</Button>
86-
</WithPermissionControlTooltip>
87-
</HorizontalGroup>
98+
<WithPermissionControlTooltip key="ok" userAction={UserActions.IntegrationsWrite}>
99+
<Button variant="primary" onClick={onSave} data-testid="update-heartbeat">
100+
Update
101+
</Button>
102+
</WithPermissionControlTooltip>
103+
</HorizontalGroup>
104+
</VerticalGroup>
88105
</VerticalGroup>
89-
</VerticalGroup>
106+
</div>
90107
</Drawer>
91108
);
92109

93110
async function onSave() {
94-
const heartbeat = alertReceiveChannel.heartbeat;
95-
96-
if (heartbeat) {
97-
await heartbeatStore.saveHeartbeat(heartbeat.id, {
98-
alert_receive_channel: heartbeat.alert_receive_channel,
99-
timeout_seconds: interval,
100-
});
111+
await heartbeatStore.saveHeartbeat(heartbeat.id, {
112+
alert_receive_channel: heartbeat.alert_receive_channel,
113+
timeout_seconds: interval,
114+
});
101115

102-
onClose();
103-
} else {
104-
await heartbeatStore.createHeartbeat(alertReceveChannelId, {
105-
timeout_seconds: interval,
106-
});
116+
onClose();
107117

108-
onClose();
109-
}
118+
openNotification('Heartbeat settings have been updated');
110119

111-
await alertReceiveChannelStore.updateItem(alertReceveChannelId);
120+
await alertReceiveChannelStore.loadItem(alertReceveChannelId);
112121
}
113122
});
114123

src/models/alert_receive_channel/alert_receive_channel.ts

+24-39
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,14 @@ export class AlertReceiveChannelStore extends BaseStore {
9191
async loadItem(id: AlertReceiveChannel['id'], skipErrorHandling = false): Promise<AlertReceiveChannel> {
9292
const alertReceiveChannel = await this.getById(id, skipErrorHandling);
9393

94+
// @ts-ignore
9495
this.items = {
9596
...this.items,
96-
[id]: alertReceiveChannel,
97+
[id]: omit(alertReceiveChannel, 'heartbeat'),
9798
};
9899

100+
this.populateHearbeats([alertReceiveChannel]);
101+
99102
return alertReceiveChannel;
100103
}
101104

@@ -116,33 +119,9 @@ export class AlertReceiveChannelStore extends BaseStore {
116119
),
117120
};
118121

119-
this.searchResult = results.map((item: AlertReceiveChannel) => item.id);
120-
121-
const heartbeats = results.reduce((acc: any, alertReceiveChannel: AlertReceiveChannel) => {
122-
if (alertReceiveChannel.heartbeat) {
123-
acc[alertReceiveChannel.heartbeat.id] = alertReceiveChannel.heartbeat;
124-
}
122+
this.populateHearbeats(results);
125123

126-
return acc;
127-
}, {});
128-
129-
this.rootStore.heartbeatStore.items = {
130-
...this.rootStore.heartbeatStore.items,
131-
...heartbeats,
132-
};
133-
134-
const alertReceiveChannelToHeartbeat = results.reduce((acc: any, alertReceiveChannel: AlertReceiveChannel) => {
135-
if (alertReceiveChannel.heartbeat) {
136-
acc[alertReceiveChannel.id] = alertReceiveChannel.heartbeat.id;
137-
}
138-
139-
return acc;
140-
}, {});
141-
142-
this.alertReceiveChannelToHeartbeat = {
143-
...this.alertReceiveChannelToHeartbeat,
144-
...alertReceiveChannelToHeartbeat,
145-
};
124+
this.searchResult = results.map((item: AlertReceiveChannel) => item.id);
146125

147126
this.updateCounters();
148127

@@ -164,13 +143,20 @@ export class AlertReceiveChannelStore extends BaseStore {
164143
),
165144
};
166145

167-
this.paginatedSearchResult = results.map((item: AlertReceiveChannel) => item.id);
146+
this.populateHearbeats(results);
147+
168148
this.paginatedSearchResult = {
169149
count,
170150
results: results.map((item: AlertReceiveChannel) => item.id),
171151
};
172152

173-
const heartbeats = results.reduce((acc: any, alertReceiveChannel: AlertReceiveChannel) => {
153+
this.updateCounters();
154+
155+
return results;
156+
}
157+
158+
populateHearbeats(alertReceiveChannels: AlertReceiveChannel[]) {
159+
const heartbeats = alertReceiveChannels.reduce((acc: any, alertReceiveChannel: AlertReceiveChannel) => {
174160
if (alertReceiveChannel.heartbeat) {
175161
acc[alertReceiveChannel.heartbeat.id] = alertReceiveChannel.heartbeat;
176162
}
@@ -183,22 +169,21 @@ export class AlertReceiveChannelStore extends BaseStore {
183169
...heartbeats,
184170
};
185171

186-
const alertReceiveChannelToHeartbeat = results.reduce((acc: any, alertReceiveChannel: AlertReceiveChannel) => {
187-
if (alertReceiveChannel.heartbeat) {
188-
acc[alertReceiveChannel.id] = alertReceiveChannel.heartbeat.id;
189-
}
172+
const alertReceiveChannelToHeartbeat = alertReceiveChannels.reduce(
173+
(acc: any, alertReceiveChannel: AlertReceiveChannel) => {
174+
if (alertReceiveChannel.heartbeat) {
175+
acc[alertReceiveChannel.id] = alertReceiveChannel.heartbeat.id;
176+
}
190177

191-
return acc;
192-
}, {});
178+
return acc;
179+
},
180+
{}
181+
);
193182

194183
this.alertReceiveChannelToHeartbeat = {
195184
...this.alertReceiveChannelToHeartbeat,
196185
...alertReceiveChannelToHeartbeat,
197186
};
198-
199-
this.updateCounters();
200-
201-
return results;
202187
}
203188

204189
@action

0 commit comments

Comments
 (0)