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 all 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Bring heartbeats back to UI
- Address issue when Grafana feature flags which were enabled via the `feature_flags.enabled` were only properly being
parsed, when they were space-delimited. This fix allows them to be _either_ space or comma-delimited.
by @joeyorlando ([#2623](https://github.com/grafana/oncall/pull/2623))
Expand Down
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
@@ -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
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