Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Maxim/bring heartbeats back to UI #2550

Merged
merged 10 commits into from
Jul 25, 2023
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions grafana-plugin/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
Expand Up @@ -4,17 +4,22 @@ import { SelectableValue } from '@grafana/data';
import { Button, Drawer, Field, HorizontalGroup, Select, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import Emoji from 'react-emoji-render';

import Collapse from 'components/Collapse/Collapse';
import IntegrationInputField from 'components/IntegrationInputField/IntegrationInputField';
import Text from 'components/Text/Text';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
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 +32,93 @@ 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,
}))}
<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>
<Collapse isOpen={false} label="Instruction">
<p className={cx('instruction')}>
To send periodic heartbeat alerts from <Emoji text={alertReceiveChannel?.verbal_name || ''} /> to
OnCall, do the following:
<span
dangerouslySetInnerHTML={{
__html: heartbeat.instruction,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This instructions are moved to the docs. No need to add them here as they will be removed from the API https://grafana.com/docs/oncall/latest/integrations/alertmanager/#configuring-oncall-heartbeats-optional

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

}}
/>
</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'}
</p>
</Collapse>
</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
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'),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we store heartbeat on the objects within items? why do we need alertReceiveChannelToHeartbeat`? I believe a heartbeat always belongs to a single alert receive channel.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in my mind heartbeat is a separate entity (it even has it's own id and ideally it should have separate endpoint), that's why we have 'models/heartbeat', we need alertReceiveChannelToHeartbeat to save appropriate heartbeat Id for each alert receive channel, it's one to one mapping. Yes we can store heartbeat id just in alert receive channel object, but I did not want to pollute alert receive channel object

};

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