Merge pull request #3464 from grafana/dev

v1.3.66
This commit is contained in:
Vadim Stepanov 2023-11-30 11:53:07 +00:00 committed by GitHub
commit bfeacf4fc6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 237 additions and 112 deletions

View file

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

View file

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

View file

@ -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'),
),
]

View file

@ -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}"

View file

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

View file

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

View file

@ -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",

View file

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

View file

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

View file

@ -22,7 +22,7 @@ export const WebhookTriggerType = {
};
export function createForm(
presets: OutgoingWebhookPreset[],
presets: OutgoingWebhookPreset[] = [],
hasLabelsFeature?: boolean
): {
name: string;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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 },
]);
});
});

View 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 })));

View file

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