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 <joey.orlando@grafana.com>
This commit is contained in:
Maxim Mordasov 2023-07-25 12:24:54 +03:00 committed by GitHub
parent 14c547fbf9
commit af9d5c935b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 195 additions and 123 deletions

View file

@ -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))

View file

@ -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/);
});
});

View file

@ -0,0 +1,8 @@
.instruction {
ol,
ul {
padding: 0;
margin: 0;
list-style: none;
}
}

View file

@ -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';
@ -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'];
@ -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>
<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}
options={(timeoutOptions || []).map((timeoutOption: SelectOption) => ({
value: timeoutOption.value,
label: timeoutOption.display_name,
}))}
/>
</WithPermissionControlTooltip>
</Field>
</div>
<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>
<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'}
<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;
await heartbeatStore.saveHeartbeat(heartbeat.id, {
alert_receive_channel: heartbeat.alert_receive_channel,
timeout_seconds: interval,
});
if (heartbeat) {
await heartbeatStore.saveHeartbeat(heartbeat.id, {
alert_receive_channel: heartbeat.alert_receive_channel,
timeout_seconds: interval,
});
onClose();
onClose();
} else {
await heartbeatStore.createHeartbeat(alertReceveChannelId, {
timeout_seconds: interval,
});
openNotification('Heartbeat settings have been updated');
onClose();
}
await alertReceiveChannelStore.updateItem(alertReceveChannelId);
await alertReceiveChannelStore.loadItem(alertReceveChannelId);
}
});

View file

@ -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;
}
@ -116,34 +119,10 @@ export class AlertReceiveChannelStore extends BaseStore {
),
};
this.populateHearbeats(results);
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;
}
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.updateCounters();
return results;
@ -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;
}
@ -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

View file

@ -725,7 +725,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
alertReceiveChannel,
changeIsTemplateSettingsOpen,
}) => {
const { alertReceiveChannelStore, heartbeatStore } = useStore();
const { alertReceiveChannelStore } = useStore();
const history = useHistory();
@ -822,7 +822,11 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
{showHeartbeatSettings() && (
<WithPermissionControlTooltip key="ok" userAction={UserActions.IntegrationsWrite}>
<div className={cx('integration__actionItem')} onClick={() => setIsHeartbeatFormOpen(true)}>
<div
className={cx('integration__actionItem')}
onClick={() => setIsHeartbeatFormOpen(true)}
data-testid="integration-heartbeat-settings"
>
Heartbeat Settings
</div>
</WithPermissionControlTooltip>
@ -926,9 +930,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
);
function showHeartbeatSettings() {
const heartbeatId = alertReceiveChannelStore.alertReceiveChannelToHeartbeat[alertReceiveChannel.id];
const heartbeat = heartbeatStore.items[heartbeatId];
return !!heartbeat?.last_heartbeat_time_verbal;
return alertReceiveChannel.is_available_for_integration_heartbeat;
}
function deleteIntegration() {
@ -1158,22 +1160,20 @@ const IntegrationHeader: React.FC<IntegrationHeaderProps> = ({
const heartbeatId = alertReceiveChannelStore.alertReceiveChannelToHeartbeat[alertReceiveChannel.id];
const heartbeat = heartbeatStore.items[heartbeatId];
const heartbeatStatus = Boolean(heartbeat?.status);
if (
!alertReceiveChannel.is_available_for_integration_heartbeat ||
!alertReceiveChannel.heartbeat?.last_heartbeat_time_verbal
) {
if (!alertReceiveChannel.is_available_for_integration_heartbeat || !heartbeat?.last_heartbeat_time_verbal) {
return null;
}
const heartbeatStatus = Boolean(heartbeat?.status);
return (
<TooltipBadge
data-testid="heartbeat-badge"
text={undefined}
className={cx('heartbeat-badge')}
borderType={heartbeatStatus ? 'success' : 'danger'}
customIcon={heartbeatStatus ? <HeartIcon /> : <HeartRedIcon />}
tooltipTitle={`Last heartbeat: ${alertReceiveChannel.heartbeat?.last_heartbeat_time_verbal}`}
tooltipTitle={`Last heartbeat: ${heartbeat?.last_heartbeat_time_verbal}`}
tooltipContent={undefined}
/>
);

View file

@ -26,9 +26,7 @@ import RemoteFilters from 'containers/RemoteFilters/RemoteFilters';
import TeamName from 'containers/TeamName/TeamName';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { HeartIcon, HeartRedIcon } from 'icons';
import { AlertReceiveChannelStore } from 'models/alert_receive_channel/alert_receive_channel';
import { AlertReceiveChannel, MaintenanceMode } from 'models/alert_receive_channel/alert_receive_channel.types';
import { HeartbeatStore } from 'models/heartbeat/heartbeat';
import IntegrationHelper from 'pages/integration/Integration.helper';
import { PageProps, WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
@ -128,7 +126,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
render() {
const { store, query } = this.props;
const { alertReceiveChannelId, page, confirmationModal } = this.state;
const { grafanaTeamStore, alertReceiveChannelStore, heartbeatStore } = store;
const { grafanaTeamStore, alertReceiveChannelStore } = store;
const { count, results } = alertReceiveChannelStore.getPaginatedSearchResult();
@ -162,7 +160,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
width: '5%',
title: 'Heartbeat',
key: 'heartbeat',
render: (item: AlertReceiveChannel) => this.renderHeartbeat(item, alertReceiveChannelStore, heartbeatStore),
render: (item: AlertReceiveChannel) => this.renderHeartbeat(item),
},
{
width: '15%',
@ -345,11 +343,9 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
);
}
renderHeartbeat(
item: AlertReceiveChannel,
alertReceiveChannelStore: AlertReceiveChannelStore,
heartbeatStore: HeartbeatStore
) {
renderHeartbeat(item: AlertReceiveChannel) {
const { store } = this.props;
const { alertReceiveChannelStore, heartbeatStore } = store;
const alertReceiveChannel = alertReceiveChannelStore.items[item.id];
const heartbeatId = alertReceiveChannelStore.alertReceiveChannelToHeartbeat[alertReceiveChannel.id];