commit
bfeacf4fc6
25 changed files with 237 additions and 112 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ import React, { FC, ReactNode } from 'react';
|
|||
interface RenderConditionallyProps {
|
||||
shouldRender?: boolean;
|
||||
children: ReactNode;
|
||||
backupChildren?: ReactNode;
|
||||
}
|
||||
|
||||
const RenderConditionally: FC<RenderConditionallyProps> = ({ shouldRender, children }) =>
|
||||
shouldRender ? <>{children}</> : null;
|
||||
const RenderConditionally: FC<RenderConditionallyProps> = ({ shouldRender, children, backupChildren = null }) =>
|
||||
shouldRender ? <>{children}</> : <>{backupChildren}</>;
|
||||
|
||||
export default RenderConditionally;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export const WebhookTriggerType = {
|
|||
};
|
||||
|
||||
export function createForm(
|
||||
presets: OutgoingWebhookPreset[],
|
||||
presets: OutgoingWebhookPreset[] = [],
|
||||
hasLabelsFeature?: boolean
|
||||
): {
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -63,7 +63,6 @@ const PersonalNotificationSettings = observer((props: PersonalNotificationSettin
|
|||
);
|
||||
|
||||
const allNotificationPolicies = userStore.notificationPolicies[userPk];
|
||||
|
||||
const title = (
|
||||
<Text.Title level={5}>
|
||||
<HorizontalGroup>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<GrafanaTeam[]>(`${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<TeamItems>(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,10 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
confirmationModal: undefined,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.store.outgoingWebhookStore.updateOutgoingWebhookPresetsOptions();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: OutgoingWebhooksProps) {
|
||||
if (prevProps.match.params.id !== this.props.match.params.id && !this.state.outgoingWebhookAction) {
|
||||
this.parseQueryParams();
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import LegacyNavTabsBar from 'navbar/LegacyNavTabsBar';
|
|||
import { Redirect, Route, Switch, useLocation } from 'react-router-dom';
|
||||
import { AppRootProps } from 'types';
|
||||
|
||||
import RenderConditionally from 'components/RenderConditionally/RenderConditionally';
|
||||
import Unauthorized from 'components/Unauthorized';
|
||||
import DefaultPageLayout from 'containers/DefaultPageLayout/DefaultPageLayout';
|
||||
import { getMatchedPage, getRoutesForPage, pages } from 'pages';
|
||||
|
|
@ -68,18 +69,22 @@ export const GrafanaPluginRootPage = (props: AppRootProps) => {
|
|||
};
|
||||
|
||||
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 <Unauthorized requiredUserAction={pagePermissionAction} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DefaultPageLayout {...props} page={page} pageNav={getPageNav()}>
|
||||
{!isTopNavbar() && (
|
||||
|
|
@ -124,11 +133,14 @@ export const Root = observer((props: AppRootProps) => {
|
|||
'page-body': !isTopNavbar(),
|
||||
})}
|
||||
>
|
||||
{userHasAccess ? (
|
||||
// Otherwise we'll run into concurrency issues
|
||||
!basicDataLoaded ? (
|
||||
<LoadingPlaceholder text="Loading..." />
|
||||
) : (
|
||||
<RenderConditionally
|
||||
shouldRender={userHasAccess}
|
||||
backupChildren={<Unauthorized requiredUserAction={pagePermissionAction} />}
|
||||
>
|
||||
<RenderConditionally
|
||||
shouldRender={isBasicDataLoaded}
|
||||
backupChildren={<LoadingPlaceholder text="Loading..." />}
|
||||
>
|
||||
<Switch>
|
||||
<Route path={getRoutesForPage('alert-groups')} exact>
|
||||
<Incidents query={query} />
|
||||
|
|
@ -182,7 +194,7 @@ export const Root = observer((props: AppRootProps) => {
|
|||
}}
|
||||
></Redirect>
|
||||
)}
|
||||
></Route>
|
||||
/>
|
||||
<Route
|
||||
path={getRoutesForPage('incidents')}
|
||||
exact
|
||||
|
|
@ -194,30 +206,14 @@ export const Root = observer((props: AppRootProps) => {
|
|||
}}
|
||||
></Redirect>
|
||||
)}
|
||||
></Route>
|
||||
|
||||
/>
|
||||
<Route path="*">
|
||||
<NoMatch />
|
||||
</Route>
|
||||
</Switch>
|
||||
)
|
||||
) : (
|
||||
<Unauthorized requiredUserAction={pagePermissionAction} />
|
||||
)}
|
||||
</RenderConditionally>
|
||||
</RenderConditionally>
|
||||
</div>
|
||||
</DefaultPageLayout>
|
||||
);
|
||||
|
||||
async function runQueuedUpdateData(attemptCount: number) {
|
||||
if (attemptCount === 10) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await store.updateBasicData();
|
||||
setBasicDataLoaded(true);
|
||||
} catch {
|
||||
setTimeout(() => runQueuedUpdateData(attemptCount + 1), 1000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
33
grafana-plugin/src/utils/async.test.ts
Normal file
33
grafana-plugin/src/utils/async.test.ts
Normal file
|
|
@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
9
grafana-plugin/src/utils/async.ts
Normal file
9
grafana-plugin/src/utils/async.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { retry } from '@lifeomic/attempt';
|
||||
|
||||
export const retryFailingPromises = async (
|
||||
asyncActions: Array<() => Promise<unknown>>,
|
||||
{ maxAttempts = 3, delayInMs = 500 }: { maxAttempts?: number; delayInMs?: number } = {}
|
||||
) =>
|
||||
maxAttempts === 0
|
||||
? Promise.allSettled(asyncActions)
|
||||
: Promise.allSettled(asyncActions.map((asyncAction) => retry(asyncAction, { maxAttempts, delay: delayInMs })));
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue