Skip to content

Commit

Permalink
Maxim/bring heartbeats back to UI (#2550)
Browse files Browse the repository at this point in the history
# 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 <joey.orlando@grafana.com>
  • Loading branch information
Maxim Mordasov and joeyorlando authored Jul 25, 2023
1 parent c843e66 commit 18da310
Show file tree
Hide file tree
Showing 6 changed files with 196 additions and 125 deletions.
73 changes: 73 additions & 0 deletions integration-tests/integrations/heartbeat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { test, Page, expect, Locator } from '../fixtures';

import { generateRandomValue, selectDropdownValue } from '../utils/forms';
import { createIntegration } from '../utils/integrations';

test.describe("updating an integration's heartbeat interval works", async () => {
test.slow();

const _openIntegrationSettingsPopup = async (page: Page): Promise<Locator> => {
const integrationSettingsPopupElement = page.getByTestId('integration-settings-context-menu');
await integrationSettingsPopupElement.click();
return integrationSettingsPopupElement;
};

const _openHeartbeatSettingsForm = async (page: Page) => {
const integrationSettingsPopupElement = await _openIntegrationSettingsPopup(page);

await integrationSettingsPopupElement.click();

await page.getByTestId('integration-heartbeat-settings').click();
};

test('"change heartbeat interval', async ({ adminRolePage: { page } }) => {
const integrationName = generateRandomValue();
await createIntegration(page, integrationName);

await _openHeartbeatSettingsForm(page);

const heartbeatSettingsForm = page.getByTestId('heartbeat-settings-form');

const value = '30 minutes';

await selectDropdownValue({
page,
startingLocator: heartbeatSettingsForm,
selectType: 'grafanaSelect',
value,
optionExactMatch: false,
});

await heartbeatSettingsForm.getByTestId('update-heartbeat').click();

await _openHeartbeatSettingsForm(page);

const heartbeatIntervalValue = await heartbeatSettingsForm
.locator('div[class*="grafana-select-value-container"] > div[class*="-singleValue"]')
.textContent();

expect(heartbeatIntervalValue).toEqual(value);
});

test('"send heartbeat', async ({ adminRolePage: { page } }) => {
const integrationName = generateRandomValue();
await createIntegration(page, integrationName);

await _openHeartbeatSettingsForm(page);

const heartbeatSettingsForm = page.getByTestId('heartbeat-settings-form');

const endpoint = await heartbeatSettingsForm
.getByTestId('input-wrapper')
.locator('input[class*="input-input"]')
.inputValue();

await page.goto(endpoint);

await page.goBack();

const heartbeatBadge = await page.getByTestId('heartbeat-badge');

await expect(heartbeatBadge).toHaveClass(/--success/);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.instruction {
ol,
ul {
padding: 0;
margin: 0;
list-style: none;
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';

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

Expand All @@ -12,9 +12,12 @@ import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_
import { SelectOption } from 'state/types';
import { useStore } from 'state/useStore';
import { withMobXProviderContext } from 'state/withStore';
import { openNotification } from 'utils';
import { UserActions } from 'utils/authorization';

const cx = cn.bind({});
import styles from './IntegrationHeartbeatForm.module.scss';

const cx = cn.bind(styles);

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

const alertReceiveChannel = alertReceiveChannelStore.items[alertReceveChannelId];
const heartbeatId = alertReceiveChannelStore.alertReceiveChannelToHeartbeat[alertReceiveChannel.id];
const heartbeat = heartbeatStore.items[heartbeatId];

useEffect(() => {
heartbeatStore.updateTimeoutOptions();
}, [heartbeatStore]);
}, []);

useEffect(() => {
if (alertReceiveChannel.heartbeat) {
setInterval(alertReceiveChannel.heartbeat.timeout_seconds);
}
}, [alertReceiveChannel]);
setInterval(heartbeat.timeout_seconds);
}, [heartbeat]);

const timeoutOptions = heartbeatStore.timeoutOptions;

return (
<Drawer width={'640px'} scrollableContent title={'Heartbeat'} onClose={onClose} closeOnMaskClick={false}>
<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
alert group and escalate it
</Text>

<VerticalGroup spacing="md">
<div className={cx('u-width-100')}>
<Field label={'Setup heartbeat interval'}>
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<Select
className={cx('select', 'timeout')}
onChange={(value: SelectableValue) => setInterval(value.value)}
placeholder="Heartbeat Timeout"
value={interval}
options={(timeoutOptions || []).map((timeoutOption: SelectOption) => ({
value: timeoutOption.value,
label: timeoutOption.display_name,
}))}
/>
</WithPermissionControlTooltip>
</Field>
</div>

<div className={cx('u-width-100')}>
<Field label="Endpoint" description="Use the following unique Grafana link to send GET and POST requests">
<IntegrationInputField value={alertReceiveChannel?.integration_url} showEye={false} isMasked={false} />
</Field>
</div>
</VerticalGroup>

<VerticalGroup style={{ marginTop: 'auto' }}>
<HorizontalGroup className={cx('buttons')} justify="flex-end">
<Button variant={'secondary'} onClick={onClose}>
Cancel
</Button>
<WithPermissionControlTooltip key="ok" userAction={UserActions.IntegrationsWrite}>
<Button variant="primary" onClick={onSave}>
{alertReceiveChannel.heartbeat ? 'Save' : 'Create'}
<div data-testid="heartbeat-settings-form">
<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
alert group and escalate it
</Text>

<VerticalGroup spacing="md">
<div className={cx('u-width-100')}>
<Field label={'Setup heartbeat interval'}>
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<Select
className={cx('select', 'timeout')}
onChange={(value: SelectableValue) => setInterval(value.value)}
placeholder="Heartbeat Timeout"
value={interval}
isLoading={!timeoutOptions}
options={timeoutOptions?.map((timeoutOption: SelectOption) => ({
value: timeoutOption.value,
label: timeoutOption.display_name,
}))}
/>
</WithPermissionControlTooltip>
</Field>
</div>
<div className={cx('u-width-100')}>
<Field label="Endpoint" description="Use the following unique Grafana link to send GET and POST requests">
<IntegrationInputField value={heartbeat?.link} showEye={false} isMasked={false} />
</Field>
</div>
<a
href="https://grafana.com/docs/oncall/latest/integrations/alertmanager/#configuring-oncall-heartbeats-optional"
target="_blank"
rel="noreferrer"
>
<Text type="link" size="small">
<HorizontalGroup>
How to configure heartbeats
<Icon name="external-link-alt" />
</HorizontalGroup>
</Text>
</a>
</VerticalGroup>

<VerticalGroup style={{ marginTop: 'auto' }}>
<HorizontalGroup className={cx('buttons')} justify="flex-end">
<Button variant={'secondary'} onClick={onClose} data-testid="close-heartbeat-form">
Close
</Button>
</WithPermissionControlTooltip>
</HorizontalGroup>
<WithPermissionControlTooltip key="ok" userAction={UserActions.IntegrationsWrite}>
<Button variant="primary" onClick={onSave} data-testid="update-heartbeat">
Update
</Button>
</WithPermissionControlTooltip>
</HorizontalGroup>
</VerticalGroup>
</VerticalGroup>
</VerticalGroup>
</div>
</Drawer>
);

async function onSave() {
const heartbeat = alertReceiveChannel.heartbeat;

if (heartbeat) {
await heartbeatStore.saveHeartbeat(heartbeat.id, {
alert_receive_channel: heartbeat.alert_receive_channel,
timeout_seconds: interval,
});
await heartbeatStore.saveHeartbeat(heartbeat.id, {
alert_receive_channel: heartbeat.alert_receive_channel,
timeout_seconds: interval,
});

onClose();
} else {
await heartbeatStore.createHeartbeat(alertReceveChannelId, {
timeout_seconds: interval,
});
onClose();

onClose();
}
openNotification('Heartbeat settings have been updated');

await alertReceiveChannelStore.updateItem(alertReceveChannelId);
await alertReceiveChannelStore.loadItem(alertReceveChannelId);
}
});

Expand Down
63 changes: 24 additions & 39 deletions src/models/alert_receive_channel/alert_receive_channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,14 @@ export class AlertReceiveChannelStore extends BaseStore {
async loadItem(id: AlertReceiveChannel['id'], skipErrorHandling = false): Promise<AlertReceiveChannel> {
const alertReceiveChannel = await this.getById(id, skipErrorHandling);

// @ts-ignore
this.items = {
...this.items,
[id]: alertReceiveChannel,
[id]: omit(alertReceiveChannel, 'heartbeat'),
};

this.populateHearbeats([alertReceiveChannel]);

return alertReceiveChannel;
}

Expand All @@ -116,33 +119,9 @@ export class AlertReceiveChannelStore extends BaseStore {
),
};

this.searchResult = results.map((item: AlertReceiveChannel) => item.id);

const heartbeats = results.reduce((acc: any, alertReceiveChannel: AlertReceiveChannel) => {
if (alertReceiveChannel.heartbeat) {
acc[alertReceiveChannel.heartbeat.id] = alertReceiveChannel.heartbeat;
}
this.populateHearbeats(results);

return acc;
}, {});

this.rootStore.heartbeatStore.items = {
...this.rootStore.heartbeatStore.items,
...heartbeats,
};

const alertReceiveChannelToHeartbeat = results.reduce((acc: any, alertReceiveChannel: AlertReceiveChannel) => {
if (alertReceiveChannel.heartbeat) {
acc[alertReceiveChannel.id] = alertReceiveChannel.heartbeat.id;
}

return acc;
}, {});

this.alertReceiveChannelToHeartbeat = {
...this.alertReceiveChannelToHeartbeat,
...alertReceiveChannelToHeartbeat,
};
this.searchResult = results.map((item: AlertReceiveChannel) => item.id);

this.updateCounters();

Expand All @@ -164,13 +143,20 @@ export class AlertReceiveChannelStore extends BaseStore {
),
};

this.paginatedSearchResult = results.map((item: AlertReceiveChannel) => item.id);
this.populateHearbeats(results);

this.paginatedSearchResult = {
count,
results: results.map((item: AlertReceiveChannel) => item.id),
};

const heartbeats = results.reduce((acc: any, alertReceiveChannel: AlertReceiveChannel) => {
this.updateCounters();

return results;
}

populateHearbeats(alertReceiveChannels: AlertReceiveChannel[]) {
const heartbeats = alertReceiveChannels.reduce((acc: any, alertReceiveChannel: AlertReceiveChannel) => {
if (alertReceiveChannel.heartbeat) {
acc[alertReceiveChannel.heartbeat.id] = alertReceiveChannel.heartbeat;
}
Expand All @@ -183,22 +169,21 @@ export class AlertReceiveChannelStore extends BaseStore {
...heartbeats,
};

const alertReceiveChannelToHeartbeat = results.reduce((acc: any, alertReceiveChannel: AlertReceiveChannel) => {
if (alertReceiveChannel.heartbeat) {
acc[alertReceiveChannel.id] = alertReceiveChannel.heartbeat.id;
}
const alertReceiveChannelToHeartbeat = alertReceiveChannels.reduce(
(acc: any, alertReceiveChannel: AlertReceiveChannel) => {
if (alertReceiveChannel.heartbeat) {
acc[alertReceiveChannel.id] = alertReceiveChannel.heartbeat.id;
}

return acc;
}, {});
return acc;
},
{}
);

this.alertReceiveChannelToHeartbeat = {
...this.alertReceiveChannelToHeartbeat,
...alertReceiveChannelToHeartbeat,
};

this.updateCounters();

return results;
}

@action
Expand Down
Loading

0 comments on commit 18da310

Please sign in to comment.