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:
parent
14c547fbf9
commit
af9d5c935b
7 changed files with 195 additions and 123 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
.instruction {
|
||||
ol,
|
||||
ul {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue