diff --git a/CHANGELOG.md b/CHANGELOG.md index 547d6ae071..89c81f300e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/grafana-plugin/integration-tests/integrations/heartbeat.test.ts b/grafana-plugin/integration-tests/integrations/heartbeat.test.ts new file mode 100644 index 0000000000..ab8086a360 --- /dev/null +++ b/grafana-plugin/integration-tests/integrations/heartbeat.test.ts @@ -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 => { + 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/); + }); +}); diff --git a/grafana-plugin/src/containers/IntegrationContainers/IntegrationHeartbeatForm/IntegrationHeartbeatForm.module.scss b/grafana-plugin/src/containers/IntegrationContainers/IntegrationHeartbeatForm/IntegrationHeartbeatForm.module.scss new file mode 100644 index 0000000000..d5b6e3de14 --- /dev/null +++ b/grafana-plugin/src/containers/IntegrationContainers/IntegrationHeartbeatForm/IntegrationHeartbeatForm.module.scss @@ -0,0 +1,8 @@ +.instruction { + ol, + ul { + padding: 0; + margin: 0; + list-style: none; + } +} diff --git a/grafana-plugin/src/containers/IntegrationContainers/IntegrationHeartbeatForm/IntegrationHeartbeatForm.tsx b/grafana-plugin/src/containers/IntegrationContainers/IntegrationHeartbeatForm/IntegrationHeartbeatForm.tsx index ab9b40d6c9..05e7c17d5c 100644 --- a/grafana-plugin/src/containers/IntegrationContainers/IntegrationHeartbeatForm/IntegrationHeartbeatForm.tsx +++ b/grafana-plugin/src/containers/IntegrationContainers/IntegrationHeartbeatForm/IntegrationHeartbeatForm.tsx @@ -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 ( - - - 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 - - - -
- - - setInterval(value.value)} + placeholder="Heartbeat Timeout" + value={interval} + isLoading={!timeoutOptions} + options={timeoutOptions?.map((timeoutOption: SelectOption) => ({ + value: timeoutOption.value, + label: timeoutOption.display_name, + }))} + /> + + +
+
+ + + +
+ + + + How to configure heartbeats + + + + +
+ + + + - - + + + + +
- +
); 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); } }); diff --git a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts index c7149193dc..9350715b42 100644 --- a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts +++ b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts @@ -91,11 +91,14 @@ export class AlertReceiveChannelStore extends BaseStore { async loadItem(id: AlertReceiveChannel['id'], skipErrorHandling = false): Promise { 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,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(); @@ -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 diff --git a/grafana-plugin/src/pages/integration/Integration.tsx b/grafana-plugin/src/pages/integration/Integration.tsx index 447c11f298..6c88c38cd1 100644 --- a/grafana-plugin/src/pages/integration/Integration.tsx +++ b/grafana-plugin/src/pages/integration/Integration.tsx @@ -725,7 +725,7 @@ const IntegrationActions: React.FC = ({ alertReceiveChannel, changeIsTemplateSettingsOpen, }) => { - const { alertReceiveChannelStore, heartbeatStore } = useStore(); + const { alertReceiveChannelStore } = useStore(); const history = useHistory(); @@ -822,7 +822,11 @@ const IntegrationActions: React.FC = ({ {showHeartbeatSettings() && ( -
setIsHeartbeatFormOpen(true)}> +
setIsHeartbeatFormOpen(true)} + data-testid="integration-heartbeat-settings" + > Heartbeat Settings
@@ -926,9 +930,7 @@ const IntegrationActions: React.FC = ({ ); 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 = ({ 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 ( : } - tooltipTitle={`Last heartbeat: ${alertReceiveChannel.heartbeat?.last_heartbeat_time_verbal}`} + tooltipTitle={`Last heartbeat: ${heartbeat?.last_heartbeat_time_verbal}`} tooltipContent={undefined} /> ); diff --git a/grafana-plugin/src/pages/integrations/Integrations.tsx b/grafana-plugin/src/pages/integrations/Integrations.tsx index 8799b3b8be..f5a243f569 100644 --- a/grafana-plugin/src/pages/integrations/Integrations.tsx +++ b/grafana-plugin/src/pages/integrations/Integrations.tsx @@ -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 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 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 ); } - 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];