diff --git a/CHANGELOG.md b/CHANGELOG.md index 11355f97..bbfeea49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +## v1.3.66 (2023-11-30) + +### Fixed + +- Delete duplicate direct paging integrations by @vadimkerr ([#3412](https://github.com/grafana/oncall/pull/3412)) + ## v1.3.65 (2023-11-29) ### Added diff --git a/docs/make-docs b/docs/make-docs index 751e22f4..25176a37 100755 --- a/docs/make-docs +++ b/docs/make-docs @@ -6,6 +6,14 @@ # [Semantic versioning](https://semver.org/) is used to help the reader identify the significance of changes. # Changes are relevant to this script and the support docs.mk GNU Make interface. # + +# ## 5.1.2 (2023-11-08) +# +# ### Added +# +# - Hide manual_mount warning messages from non-debug output. +# Set the DEBUG environment variable to see all hidden messages. +# # ## 5.1.1 (2023-10-30) # # ### Added @@ -779,7 +787,8 @@ EOF -e '/website-proxy/ d' \ -e '/rm -rf dist*/ d' \ -e '/Press Ctrl+C to stop/ d' \ - -e '/make/ d' + -e '/make/ d' \ + -e '/WARNING: The manual_mount source directory/ d' fi ;; esac diff --git a/engine/apps/alerts/migrations/0041_alertreceivechannel_unique_direct_paging_integration_per_team.py b/engine/apps/alerts/migrations/0041_alertreceivechannel_unique_direct_paging_integration_per_team.py new file mode 100644 index 00000000..a61c424c --- /dev/null +++ b/engine/apps/alerts/migrations/0041_alertreceivechannel_unique_direct_paging_integration_per_team.py @@ -0,0 +1,56 @@ +# Generated by Django 4.2.7 on 2023-11-28 10:45 +import logging + +from django.db import migrations, models +from django.utils import timezone + +logger = logging.getLogger(__name__) + + +def delete_duplicate_direct_paging_integrations(apps, schema_editor): + AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel") + + # get (organization_id, team_id) pairs for teams that have more than one direct paging integration + duplicate_rows = AlertReceiveChannel.objects.values_list( + "organization_id", "team_id" + ).annotate(count=models.Count("id")).filter(integration="direct_paging", deleted_at__isnull=True, count__gt=1) + + for organization_id, team_id, _ in duplicate_rows: + # get the first direct paging integration for the team (the one we want to keep) + first_direct_paging_integration = AlertReceiveChannel.objects.filter( + organization_id=organization_id, + team_id=team_id, + integration="direct_paging", + deleted_at__isnull=True, + ).order_by("id").first() + + if first_direct_paging_integration is None: + continue + + # delete all other direct paging integrations for the team (except the first one) + num_deleted = AlertReceiveChannel.objects.filter( + organization_id=organization_id, + team_id=team_id, + integration="direct_paging", + deleted_at__isnull=True, + ).exclude(id=first_direct_paging_integration.id).update(deleted_at=timezone.now()) + + logger.info( + f"Deleted {num_deleted} duplicate direct paging integrations for team ({organization_id}, {team_id}), " + f"keeping only one direct paging integration for team: {first_direct_paging_integration.id}." + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('alerts', '0040_alertreceivechannel_alert_group_labels_custom_and_more'), + ] + + operations = [ + migrations.RunPython(delete_duplicate_direct_paging_integrations, migrations.RunPython.noop), + migrations.AddConstraint( + model_name='alertreceivechannel', + constraint=models.UniqueConstraint(models.F('organization'), models.Case(models.When(team=None, then=0), default=models.F('team'), output_field=models.BigIntegerField()), models.Case(models.When(deleted_at__isnull=True, then=True), default=None), models.Case(models.When(integration='direct_paging', then=True), default=None), name='unique_direct_paging_integration_per_team'), + ), + ] diff --git a/engine/apps/alerts/models/alert_receive_channel.py b/engine/apps/alerts/models/alert_receive_channel.py index d1335cc7..22b7c6f2 100644 --- a/engine/apps/alerts/models/alert_receive_channel.py +++ b/engine/apps/alerts/models/alert_receive_channel.py @@ -8,7 +8,7 @@ from celery import uuid as celery_uuid from django.conf import settings from django.core.validators import MinLengthValidator from django.db import models, transaction -from django.db.models import Q +from django.db.models import BigIntegerField, Case, F, Q, When from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone @@ -215,6 +215,21 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject): alert_group_labels_template: str | None = models.TextField(null=True, default=None) """Stores a Jinja2 template for "advanced label templating" for alert group labels.""" + class Meta: + constraints = [ + # This constraint ensures that there's at most one active direct paging integration per team + # This should work with SQLite, PostgreSQL and MySQL >= 8.0.13. + # From the docs: Functional indexes are ignored with MySQL < 8.0.13 and MariaDB as neither supports them. + # https://docs.djangoproject.com/en/4.2/ref/models/constraints/#expressions + models.UniqueConstraint( + F("organization"), + Case(When(team=None, then=0), default=F("team"), output_field=BigIntegerField()), + Case(When(deleted_at__isnull=True, then=True), default=None), + Case(When(integration="direct_paging", then=True), default=None), + name="unique_direct_paging_integration_per_team", + ) + ] + def __str__(self): short_name_with_emojis = emojize(self.short_name, language="alias") return f"{self.pk}: {short_name_with_emojis}" diff --git a/engine/apps/alerts/tests/test_alert_receiver_channel.py b/engine/apps/alerts/tests/test_alert_receiver_channel.py index 7e38766a..d53513bc 100644 --- a/engine/apps/alerts/tests/test_alert_receiver_channel.py +++ b/engine/apps/alerts/tests/test_alert_receiver_channel.py @@ -2,6 +2,7 @@ from unittest import mock from unittest.mock import patch import pytest +from django.db import IntegrityError from django.urls import reverse from apps.alerts.models import AlertReceiveChannel @@ -229,3 +230,20 @@ def test_delete_duplicate_names(make_organization, make_alert_receive_channel): for _ in range(2): make_alert_receive_channel(organization, verbal_name="duplicate") organization.alert_receive_channels.all().delete() + + +@pytest.mark.django_db +def test_create_duplicate_direct_paging_integrations(make_organization, make_team, make_alert_receive_channel): + """Check that it's not possible to have more than one active direct paging integration per team.""" + + organization = make_organization() + team = make_team(organization) + make_alert_receive_channel(organization, team=team, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING) + + with pytest.raises(IntegrityError): + arc = AlertReceiveChannel( + organization=organization, + team=team, + integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, + ) + super(AlertReceiveChannel, arc).save() # bypass the custom save method, so that IntegrityError is raised diff --git a/engine/apps/api/tests/test_alert_receive_channel.py b/engine/apps/api/tests/test_alert_receive_channel.py index 4855a87c..8dac1b4b 100644 --- a/engine/apps/api/tests/test_alert_receive_channel.py +++ b/engine/apps/api/tests/test_alert_receive_channel.py @@ -852,35 +852,6 @@ def test_update_alert_receive_channels_direct_paging( assert response.json()["detail"] == AlertReceiveChannel.DuplicateDirectPagingError.DETAIL -@pytest.mark.django_db -def test_delete_alert_receive_channel_direct_paging_duplicate( - make_organization_and_user_with_plugin_token, make_team, make_alert_receive_channel, make_user_auth_headers -): - """Check that it's possible to delete direct paging integration even if there is a duplicate for the team.""" - organization, user, token = make_organization_and_user_with_plugin_token() - integration = make_alert_receive_channel( - organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, team=None - ) - - # Create a team, add direct paging integration to it, then delete the team. - # There will be 2 direct paging integrations for the team "No team" as a result. - team = make_team(organization) - make_alert_receive_channel(organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, team=team) - team.delete() - assert ( - organization.alert_receive_channels.filter( - integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, team=None - ).count() - == 2 - ) - - client = APIClient() - url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": integration.public_primary_key}) - response = client.delete(url, **make_user_auth_headers(user, token)) - - assert response.status_code == status.HTTP_204_NO_CONTENT - - @pytest.mark.django_db def test_start_maintenance_integration( make_user_auth_headers, diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index 5219ad4a..65a9aa65 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -125,6 +125,7 @@ "@grafana/labels": "~1.3.5", "@grafana/runtime": "9.3.0-beta1", "@grafana/ui": "^10.2.0", + "@lifeomic/attempt": "^3.0.3", "@opentelemetry/api": "^1.3.0", "array-move": "^4.0.0", "change-case": "^4.1.1", diff --git a/grafana-plugin/src/components/RenderConditionally/RenderConditionally.tsx b/grafana-plugin/src/components/RenderConditionally/RenderConditionally.tsx index e6a5d8b3..40b4a0d8 100644 --- a/grafana-plugin/src/components/RenderConditionally/RenderConditionally.tsx +++ b/grafana-plugin/src/components/RenderConditionally/RenderConditionally.tsx @@ -3,9 +3,10 @@ import React, { FC, ReactNode } from 'react'; interface RenderConditionallyProps { shouldRender?: boolean; children: ReactNode; + backupChildren?: ReactNode; } -const RenderConditionally: FC = ({ shouldRender, children }) => - shouldRender ? <>{children} : null; +const RenderConditionally: FC = ({ shouldRender, children, backupChildren = null }) => + shouldRender ? <>{children} : <>{backupChildren}; export default RenderConditionally; diff --git a/grafana-plugin/src/containers/EscalationChainSteps/EscalationChainSteps.tsx b/grafana-plugin/src/containers/EscalationChainSteps/EscalationChainSteps.tsx index fe00a744..134a91c9 100644 --- a/grafana-plugin/src/containers/EscalationChainSteps/EscalationChainSteps.tsx +++ b/grafana-plugin/src/containers/EscalationChainSteps/EscalationChainSteps.tsx @@ -37,6 +37,12 @@ const EscalationChainSteps = observer((props: EscalationChainStepsProps) => { escalationPolicyStore.updateEscalationPolicies(id); }, [id]); + useEffect(() => { + escalationPolicyStore.updateWebEscalationPolicyOptions(); + escalationPolicyStore.updateEscalationPolicyOptions(); + escalationPolicyStore.updateNumMinutesInWindowOptions(); + }, []); + const handleSortEnd = useCallback( ({ oldIndex, newIndex }: any) => { escalationPolicyStore.moveEscalationPolicyToPosition(oldIndex, newIndex, id); diff --git a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx index 32a110c5..c3d7bb25 100644 --- a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx +++ b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx @@ -22,7 +22,7 @@ export const WebhookTriggerType = { }; export function createForm( - presets: OutgoingWebhookPreset[], + presets: OutgoingWebhookPreset[] = [], hasLabelsFeature?: boolean ): { name: string; diff --git a/grafana-plugin/src/containers/PersonalNotificationSettings/PersonalNotificationSettings.tsx b/grafana-plugin/src/containers/PersonalNotificationSettings/PersonalNotificationSettings.tsx index 6133730f..fef69a4f 100644 --- a/grafana-plugin/src/containers/PersonalNotificationSettings/PersonalNotificationSettings.tsx +++ b/grafana-plugin/src/containers/PersonalNotificationSettings/PersonalNotificationSettings.tsx @@ -63,7 +63,6 @@ const PersonalNotificationSettings = observer((props: PersonalNotificationSettin ); const allNotificationPolicies = userStore.notificationPolicies[userPk]; - const title = ( @@ -91,11 +90,9 @@ const PersonalNotificationSettings = observer((props: PersonalNotificationSettin ); } - const notificationPolicies = - allNotificationPolicies && - allNotificationPolicies.filter( - (notificationPolicy: NotificationPolicyType) => notificationPolicy.important === isImportant - ); + const notificationPolicies = allNotificationPolicies?.filter( + (notificationPolicy: NotificationPolicyType) => notificationPolicy.important === isImportant + ); const offset = isImportant ? allNotificationPolicies.findIndex((notificationPolicy: NotificationPolicyType) => notificationPolicy.important) 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 73c31cff..e2792a61 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 @@ -317,10 +317,9 @@ export class AlertReceiveChannelStore extends BaseStore { return this.updateChannelFilters(channelFilter.alert_receive_channel, true); } - @action + @action.bound async updateAlertReceiveChannelOptions() { const response = await makeRequest(`/alert_receive_channels/integration_options/`, {}); - this.alertReceiveChannelOptions = response; } diff --git a/grafana-plugin/src/models/cloud/cloud.ts b/grafana-plugin/src/models/cloud/cloud.ts index 24e84e55..5c8f5e99 100644 --- a/grafana-plugin/src/models/cloud/cloud.ts +++ b/grafana-plugin/src/models/cloud/cloud.ts @@ -68,6 +68,7 @@ export class CloudStore extends BaseStore { return await makeRequest(`${this.path}${id}`, { method: 'GET' }); } + @action.bound async loadCloudConnectionStatus() { this.cloudConnectionStatus = await this.getCloudConnectionStatus(); } diff --git a/grafana-plugin/src/models/escalation_policy/escalation_policy.ts b/grafana-plugin/src/models/escalation_policy/escalation_policy.ts index 3d140fe5..3872f8af 100644 --- a/grafana-plugin/src/models/escalation_policy/escalation_policy.ts +++ b/grafana-plugin/src/models/escalation_policy/escalation_policy.ts @@ -34,26 +34,23 @@ export class EscalationPolicyStore extends BaseStore { this.path = '/escalation_policies/'; } - @action + @action.bound async updateWebEscalationPolicyOptions() { const response = await makeRequest('/escalation_policies/escalation_options/', {}); - this.webEscalationChoices = response; } - @action + @action.bound async updateEscalationPolicyOptions() { const response = await makeRequest('/escalation_policies/', { method: 'OPTIONS', }); - this.escalationChoices = get(response, 'actions.POST', []); } - @action + @action.bound async updateNumMinutesInWindowOptions() { const response = await makeRequest('/escalation_policies/num_minutes_in_window_options/', {}); - this.numMinutesInWindowOptions = response; } diff --git a/grafana-plugin/src/models/grafana_team/grafana_team.ts b/grafana-plugin/src/models/grafana_team/grafana_team.ts index 023150be..04dd55a8 100644 --- a/grafana-plugin/src/models/grafana_team/grafana_team.ts +++ b/grafana-plugin/src/models/grafana_team/grafana_team.ts @@ -30,7 +30,7 @@ export class GrafanaTeamStore extends BaseStore { }; } - @action + @action.bound async updateItems(query = '', includeNoTeam = true, onlyIncludeNotifiableTeams = false, short = true) { const result = await makeRequest(`${this.path}`, { params: { @@ -40,7 +40,6 @@ export class GrafanaTeamStore extends BaseStore { only_include_notifiable_teams: onlyIncludeNotifiableTeams ? 'true' : 'false', }, }); - this.items = { ...this.items, ...result.reduce( diff --git a/grafana-plugin/src/models/label/label.ts b/grafana-plugin/src/models/label/label.ts index b9638879..1138550e 100644 --- a/grafana-plugin/src/models/label/label.ts +++ b/grafana-plugin/src/models/label/label.ts @@ -1,4 +1,4 @@ -import { action, observable, runInAction } from 'mobx'; +import { action, observable } from 'mobx'; import BaseStore from 'models/base_store'; import { makeRequest } from 'network'; @@ -23,10 +23,7 @@ export class LabelStore extends BaseStore { @action.bound public async loadKeys() { const { data } = await onCallApi.GET('/labels/keys/', undefined); - - runInAction(() => { - this.keys = data; - }); + this.keys = data; return data; } diff --git a/grafana-plugin/src/models/organization/organization.ts b/grafana-plugin/src/models/organization/organization.ts index 0c2ac3dd..024cf392 100644 --- a/grafana-plugin/src/models/organization/organization.ts +++ b/grafana-plugin/src/models/organization/organization.ts @@ -15,9 +15,10 @@ export class OrganizationStore extends BaseStore { this.path = '/organization/'; } - @action + @action.bound async loadCurrentOrganization() { - this.currentOrganization = await makeRequest(this.path, {}); + const organization = await makeRequest(this.path, {}); + this.currentOrganization = organization; } @action diff --git a/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.ts b/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.ts index 296771de..9e869374 100644 --- a/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.ts +++ b/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.ts @@ -101,8 +101,8 @@ export class OutgoingWebhookStore extends BaseStore { }); } - @action - async updateOutgoingWebhookPresets() { + @action.bound + async updateOutgoingWebhookPresetsOptions() { const response = await makeRequest(`/webhooks/preset_options/`, {}); this.outgoingWebhookPresets = response; } diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index a3d10505..1d6f18f2 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -62,14 +62,12 @@ export class UserStore extends BaseStore { @action async loadCurrentUser() { const response = await makeRequest('/user/', {}); - const timezone = await this.refreshTimezone(response.pk); this.items = { ...this.items, [response.pk]: { ...response, timezone }, }; - this.currentUserPk = response.pk; } @@ -164,7 +162,7 @@ export class UserStore extends BaseStore { return { page_size: this.searchResult.page_size, count: this.searchResult.count, - results: this.searchResult.results && this.searchResult.results.map((userPk: User['pk']) => this.items?.[userPk]), + results: this.searchResult.results?.map((userPk: User['pk']) => this.items?.[userPk]), }; } @@ -371,12 +369,11 @@ export class UserStore extends BaseStore { this.updateItem(userPk); // to update notification_chain_verbal } - @action + @action.bound async updateNotificationPolicyOptions() { const response = await makeRequest('/notification_policies/', { method: 'OPTIONS', }); - this.notificationChoices = get(response, 'actions.POST', []); } @@ -390,10 +387,9 @@ export class UserStore extends BaseStore { }); } - @action + @action.bound async updateNotifyByOptions() { const response = await makeRequest('/notification_policies/notify_by_options/', {}); - this.notifyByOptions = response; } diff --git a/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx b/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx index 448f3406..99afa405 100644 --- a/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx +++ b/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx @@ -64,6 +64,10 @@ class OutgoingWebhooks extends React.Component { }; export const Root = observer((props: AppRootProps) => { - const store = useStore(); - - const [basicDataLoaded, setBasicDataLoaded] = useState(false); + const { isBasicDataLoaded, loadBasicData, loadMasterData } = useStore(); const [pageTitle, setPageTitle] = useState(''); - useEffect(() => { - runQueuedUpdateData(0); - }, []); - const location = useLocation(); + useEffect(() => { + loadBasicData(); + // defer loading master data as it's not used in first sec by user in order to prioritize fetching base data + const timeout = setTimeout(() => { + loadMasterData(); + }, 1000); + + return () => clearTimeout(timeout); + }, []); + useEffect(() => { let link = document.createElement('link'); link.type = 'text/css'; @@ -109,6 +114,10 @@ export const Root = observer((props: AppRootProps) => { return (pages[page] || pages[DEFAULT_PAGE]).getPageNav(pageTitle); }; + if (!userHasAccess) { + return ; + } + return ( {!isTopNavbar() && ( @@ -124,11 +133,14 @@ export const Root = observer((props: AppRootProps) => { 'page-body': !isTopNavbar(), })} > - {userHasAccess ? ( - // Otherwise we'll run into concurrency issues - !basicDataLoaded ? ( - - ) : ( + } + > + } + > @@ -182,7 +194,7 @@ export const Root = observer((props: AppRootProps) => { }} > )} - > + /> { }} > )} - > - + /> - ) - ) : ( - - )} + + ); - - async function runQueuedUpdateData(attemptCount: number) { - if (attemptCount === 10) { - return; - } - - try { - await store.updateBasicData(); - setBasicDataLoaded(true); - } catch { - setTimeout(() => runQueuedUpdateData(attemptCount + 1), 1000); - } - } }); diff --git a/grafana-plugin/src/state/rootBaseStore/index.ts b/grafana-plugin/src/state/rootBaseStore/index.ts index 44cc0f91..76d20346 100644 --- a/grafana-plugin/src/state/rootBaseStore/index.ts +++ b/grafana-plugin/src/state/rootBaseStore/index.ts @@ -33,6 +33,7 @@ import { UserGroupStore } from 'models/user_group/user_group'; import { makeRequest } from 'network'; import { AppFeature } from 'state/features'; import PluginState from 'state/plugin'; +import { retryFailingPromises } from 'utils/async'; import { APP_VERSION, CLOUD_VERSION_REGEX, @@ -45,6 +46,9 @@ import FaroHelper from 'utils/faro'; // ------ Dashboard ------ // export class RootBaseStore { + @observable + isBasicDataLoaded = false; + @observable currentTimezone: Timezone = moment.tz.guess() as Timezone; @@ -83,7 +87,7 @@ export class RootBaseStore { @observable onCallApiUrl: string; - // -------------------------- + // stores userStore = new UserStore(this); cloudStore = new CloudStore(this); directPagingStore = new DirectPagingStore(this); @@ -108,9 +112,8 @@ export class RootBaseStore { labelsStore = new LabelStore(this); loaderStore = LoaderStore; - // stores - - async updateBasicData() { + @action.bound + async loadBasicData() { const updateFeatures = async () => { await this.updateFeatures(); @@ -121,18 +124,21 @@ export class RootBaseStore { } }; - return Promise.all([ - this.userStore.loadCurrentUser(), - this.organizationStore.loadCurrentOrganization(), - this.grafanaTeamStore.updateItems(), - updateFeatures(), + await retryFailingPromises([ + this.userStore.loadCurrentUser, + this.organizationStore.loadCurrentOrganization, + this.grafanaTeamStore.updateItems, + updateFeatures, + ]); + this.isBasicDataLoaded = true; + } + + @action.bound + async loadMasterData() { + Promise.all([ this.userStore.updateNotificationPolicyOptions(), this.userStore.updateNotifyByOptions(), this.alertReceiveChannelStore.updateAlertReceiveChannelOptions(), - this.outgoingWebhookStore.updateOutgoingWebhookPresets(), - this.escalationPolicyStore.updateWebEscalationPolicyOptions(), - this.escalationPolicyStore.updateEscalationPolicyOptions(), - this.escalationPolicyStore.updateNumMinutesInWindowOptions(), ]); } @@ -282,7 +288,7 @@ export class RootBaseStore { return this.license === GRAFANA_LICENSE_OSS; } - @observable + @action.bound async updateFeatures() { const response = await makeRequest('/features/', {}); this.features = response.reduce( diff --git a/grafana-plugin/src/utils/async.test.ts b/grafana-plugin/src/utils/async.test.ts new file mode 100644 index 00000000..5c943f66 --- /dev/null +++ b/grafana-plugin/src/utils/async.test.ts @@ -0,0 +1,33 @@ +import { retryFailingPromises } from './async'; + +describe('retryFailingPromises', () => { + it('should retry only failing promises X times and return correct result', async () => { + const MAX_ATTEMPTS = 5; + + // We mimic that fetch1 always resolves, fetch2 always rejects and fetch3 resolves only on 2nd attempt + let attempts1 = 0; + let attempts2 = 0; + let attempts3 = 0; + const fetch1 = async () => Promise.resolve(++attempts1); + const fetch2 = async () => Promise.reject(++attempts2); + const fetch3 = async () => + new Promise((resolve, reject) => { + attempts3++; + if (attempts3 === 2) { + resolve(attempts3); + } + reject(attempts3); + }); + + const result = await retryFailingPromises([fetch1, fetch2, fetch3], { maxAttempts: MAX_ATTEMPTS, delayInMs: 50 }); + + expect(attempts1).toBe(1); + expect(attempts2).toBe(MAX_ATTEMPTS); + expect(attempts3).toBe(2); + expect(result).toEqual([ + { status: 'fulfilled', value: 1 }, + { status: 'rejected', reason: 5 }, + { status: 'fulfilled', value: 2 }, + ]); + }); +}); diff --git a/grafana-plugin/src/utils/async.ts b/grafana-plugin/src/utils/async.ts new file mode 100644 index 00000000..0ebafde1 --- /dev/null +++ b/grafana-plugin/src/utils/async.ts @@ -0,0 +1,9 @@ +import { retry } from '@lifeomic/attempt'; + +export const retryFailingPromises = async ( + asyncActions: Array<() => Promise>, + { maxAttempts = 3, delayInMs = 500 }: { maxAttempts?: number; delayInMs?: number } = {} +) => + maxAttempts === 0 + ? Promise.allSettled(asyncActions) + : Promise.allSettled(asyncActions.map((asyncAction) => retry(asyncAction, { maxAttempts, delay: delayInMs }))); diff --git a/grafana-plugin/yarn.lock b/grafana-plugin/yarn.lock index c2da9267..3aa546d3 100644 --- a/grafana-plugin/yarn.lock +++ b/grafana-plugin/yarn.lock @@ -2863,6 +2863,11 @@ resolved "https://registry.yarnpkg.com/@leeoniya/ufuzzy/-/ufuzzy-1.0.8.tgz#6a01b561749df84ff28637051865fdde3cbfc3a9" integrity sha512-HQ6aJlYpWLq1f9AiApJl0aOIXlJUtuhBOYfSfv5rt3XNYkCBveojtnL6FvOVpJ2gEJ2wqgMW8xOHkLVYAbXghg== +"@lifeomic/attempt@^3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@lifeomic/attempt/-/attempt-3.0.3.tgz#e742a5b85eb673e2f1746b0f39cb932cbc6145bb" + integrity sha512-GlM2AbzrErd/TmLL3E8hAHmb5Q7VhDJp35vIbyPVA5Rz55LZuRr8pwL3qrwwkVNo05gMX1J44gURKb4MHQZo7w== + "@mapbox/jsonlint-lines-primitives@~2.0.2": version "2.0.2" resolved "https://registry.yarnpkg.com/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz#ce56e539f83552b58d10d672ea4d6fc9adc7b234"