This commit is contained in:
Joey Orlando 2023-07-13 11:57:24 +02:00 committed by GitHub
commit 83cb13e3bb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
106 changed files with 1122 additions and 3132 deletions

2
.github/CODEOWNERS vendored
View file

@ -4,4 +4,4 @@
CHANGELOG.md
/grafana-plugin @grafana/grafana-oncall-frontend
/docs @grafana/docs-oncall
/docs @grafana/docs-gops

View file

@ -5,17 +5,40 @@ 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.10 (2023-07-13)
### Added
- [Helm] Added ability to specify `resources` definition within the `wait-for-db` init container by @Shelestov7
([#2501](https://github.com/grafana/oncall/pull/2501))
- Added index on `started_at` column in `alerts_alertgroup` table. This substantially speeds up query used by the `check_escalation_finished_task`
task. By @joeyorlando and @Konstantinov-Innokentii ([#2516](https://github.com/grafana/oncall/pull/2516)).
### Changed
- Deprecated `/maintenance` web UI page. Maintenance is now handled at the integration level and can be performed
within a single integration's page. by @Ukochka ([#2497](https://github.com/grafana/oncall/issues/2497))
### Fixed
- Fixed a bug in the integration maintenance mode workflow where a user could not start/stop an integration's
maintenance mode by @joeyorlando ([#2511](https://github.com/grafana/oncall/issues/2511))
- Schedules: Long popup does not fit screen & buttons unreachable & objects outside of the popup [#1002](https://github.com/grafana/oncall/issues/1002)
- New schedules white theme issues [#2356](https://github.com/grafana/oncall/issues/2356)
## v1.3.9 (2023-07-12)
### Added
- Bring new Jinja editor to webhooks ([2344](https://github.com/grafana/oncall/issues/2344))
- Bring new Jinja editor to webhooks ([#2344](https://github.com/grafana/oncall/issues/2344))
### Fixed
- Add debounce on Select UI components to avoid making API search requests on each key-down event by
@maskin25 ([#2466](https://github.com/grafana/oncall/pull/2466))
- Fixed schedules slack notifications for deleted organizations ([#2493](https://github.com/grafana/oncall/pull/2493))
- Make Direct paging integration configurable ([2483](https://github.com/grafana/oncall/pull/2483))
## v1.3.8 (2023-07-11)
@ -67,7 +90,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- UI drawer updates for webhooks2 ([#2419](https://github.com/grafana/oncall/pull/2419))
- Removed url from sms notification, changed format ([2317](https://github.com/grafana/oncall/pull/2317))
- Removed url from sms notification, changed format ([#2317](https://github.com/grafana/oncall/pull/2317))
## v1.3.3 (2023-06-29)

View file

@ -222,12 +222,12 @@ Zvonok.com, complete the following steps:
2. Create a public API key on the Profile->Settings page, and assign its value to `ZVONOK_API_KEY`.
3. Create campaign and assign its ID value to `ZVONOK_CAMPAIGN_ID`.
4. If you are planning to use pre-recorded audio instead of a speech synthesizer, you can copy the ID of the audio clip
to the variable `ZVONOK_AUDIO_ID` (optional step).
to the variable `ZVONOK_AUDIO_ID` (optional step).
5. To make a call with a specific voice, you can set the `ZVONOK_SPEAKER_ID`.
By default, the ID used is `Salli` (optional step).
By default, the ID used is `Salli` (optional step).
6. To process the call status, it is required to add a postback with the GET/POST method on the side of the zvonok.com
service with the following format (optional step):
`${ONCALL_BASE_URL}/zvonok/call_status_events?campaign_id={ct_campaign_id}&call_id={ct_call_id}&status={ct_status}&user_choice={ct_user_choice}`
service with the following format (optional step):
`${ONCALL_BASE_URL}/zvonok/call_status_events?campaign_id={ct_campaign_id}&call_id={ct_call_id}&status={ct_status}&user_choice={ct_user_choice}`
The names of the transmitted parameters can be redefined through environment variables:
@ -312,3 +312,6 @@ To configure this feature as such:
task runs every 13 minutes so we therefore recommend setting the heartbeat's expected time interval to 15 minutes. If you
would like to modify this, we recommend configuring this env variable to 1 or 2 minutes less than the value set for the
integration's heartbeat expected time interval.
Additionally, if you prefer to disable this feature, you can set the `ESCALATION_AUDITOR_ENABLED` environment variable
to `False`.

View file

@ -231,9 +231,7 @@ class EscalationSnapshotMixin:
"""
AlertGroup = apps.get_model("alerts", "AlertGroup")
is_on_maintenace_or_debug_mode = (
self.channel.maintenance_mode is not None or self.channel.organization.maintenance_mode is not None
)
is_on_maintenace_or_debug_mode = self.channel.maintenance_mode is not None
if (
self.is_restricted

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.20 on 2023-07-13 06:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('alerts', '0020_auto_20230711_1532'),
]
operations = [
migrations.AlterField(
model_name='alertgroup',
name='started_at',
field=models.DateTimeField(auto_now_add=True, db_index=True),
),
]

View file

@ -128,10 +128,8 @@ class Alert(models.Model):
if group_created:
# all code below related to maintenance mode
maintenance_uuid = None
if alert_receive_channel.organization.maintenance_mode == AlertReceiveChannel.MAINTENANCE:
maintenance_uuid = alert_receive_channel.organization.maintenance_uuid
elif alert_receive_channel.maintenance_mode == AlertReceiveChannel.MAINTENANCE:
if alert_receive_channel.maintenance_mode == AlertReceiveChannel.MAINTENANCE:
maintenance_uuid = alert_receive_channel.maintenance_uuid
if maintenance_uuid is not None:

View file

@ -242,7 +242,7 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
acknowledged_by_confirmed = models.DateTimeField(null=True, default=None)
is_escalation_finished = models.BooleanField(default=False)
started_at = models.DateTimeField(auto_now_add=True)
started_at = models.DateTimeField(auto_now_add=True, db_index=True)
slack_message_sent = models.BooleanField(default=False)
@ -426,7 +426,6 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
return self.maintenance_uuid is not None
def stop_maintenance(self, user: User) -> None:
Organization = apps.get_model("user_management", "Organization")
AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel")
try:
@ -436,13 +435,6 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
except AlertReceiveChannel.DoesNotExist:
pass
try:
organization_on_maintenance = Organization.objects.get(maintenance_uuid=self.maintenance_uuid)
organization_on_maintenance.force_disable_maintenance(user)
return
except Organization.DoesNotExist:
pass
self.resolve_by_disable_maintenance()
@property

View file

@ -42,7 +42,7 @@ def _trigger_alert(
deleted_at=None,
defaults={
"author": from_user,
"verbal_name": f"Direct paging ({team.name if team else 'General'} team)",
"verbal_name": "Direct paging",
},
)
if alert_receive_channel.default_channel_filter is None:
@ -149,8 +149,6 @@ def direct_paging(
Otherwise, create a new alert using given title and message.
"""
if not users and not schedules and not escalation_chain:
return
if users is None:
users = []

View file

@ -1,7 +1,6 @@
import datetime
import typing
import pytz
import requests
from celery import shared_task
from django.apps import apps
@ -96,38 +95,27 @@ def audit_alert_group_escalation(alert_group: "AlertGroup") -> None:
task_logger.info(f"{base_msg} passed the audit checks")
def get_auditable_alert_groups_started_at_range() -> typing.Tuple[datetime.datetime, datetime.datetime]:
"""
NOTE: this started_at__range is a bit of a hack..
we wanted to avoid performing a migration on the alerts_alertgroup table to update
alert groups where raw_escalation_snapshot was None. raw_escalation_snapshot being None is a legitimate case,
where the alert group's integration does not have an escalation chain associated with it.
However, we wanted a way to be able to differentiate between "actually None" and "there was an error writing to
raw_escalation_snapshot" (as this is performed async by a celery task).
This field was updated, in the commit that added this comment, to no longer be set to None by default.
As part of this celery task we do a check that this field is in fact not None, so if we were to check older
alert groups, whose integration did not have an escalation chain at the time the alert group was created
we would raise errors
"""
return (datetime.datetime(2023, 3, 25, tzinfo=pytz.UTC), timezone.now() - datetime.timedelta(days=2))
# don't retry this task as the AlertGroup DB query is rather expensive
@shared_task
def check_escalation_finished_task() -> None:
"""
don't retry this task, the idea is to be alerted of failures
"""
AlertGroup = apps.get_model("alerts", "AlertGroup")
AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel")
now = timezone.now()
two_days_ago = now - datetime.timedelta(days=2)
alert_groups = AlertGroup.all_objects.using(get_random_readonly_database_key_if_present_otherwise_default()).filter(
~Q(channel__integration=AlertReceiveChannel.INTEGRATION_MAINTENANCE),
~Q(silenced=True, silenced_until__isnull=True), # filter silenced forever alert_groups
# here we should query maintenance_uuid rather than joining on channel__integration
# and checking for something like ~Q(channel__integration=AlertReceiveChannel.INTEGRATION_MAINTENANCE)
# this avoids an unnecessary join
maintenance_uuid__isnull=True,
is_escalation_finished=False,
resolved=False,
acknowledged=False,
root_alert_group=None,
started_at__range=get_auditable_alert_groups_started_at_range(),
started_at__range=(two_days_ago, now),
)
if not alert_groups.exists():

View file

@ -46,11 +46,8 @@ def send_alert_create_signal(alert_id):
task_logger.debug(f"Started send_alert_create_signal task for alert {alert_id}")
alert = Alert.objects.get(pk=alert_id)
is_on_maintenace_mode = (
alert.group.channel.maintenance_mode == AlertReceiveChannel.MAINTENANCE
or alert.group.channel.organization.maintenance_mode == AlertReceiveChannel.MAINTENANCE
)
if not is_on_maintenace_mode:
if alert.group.channel.maintenance_mode != AlertReceiveChannel.MAINTENANCE:
alert_create_signal.send(
sender=send_alert_create_signal,
alert=alert_id,

View file

@ -16,7 +16,6 @@ from .task_logger import task_logger
def disable_maintenance(*args, **kwargs):
AlertGroup = apps.get_model("alerts", "AlertGroup")
User = apps.get_model("user_management", "User")
Organization = apps.get_model("user_management", "Organization")
user = None
object_under_maintenance = None
user_id = kwargs.get("user_id")
@ -37,12 +36,6 @@ def disable_maintenance(*args, **kwargs):
task_logger.info(
f"AlertReceiveChannel for disable_maintenance does not exists. Id: {alert_receive_channel_id}"
)
elif "organization_id" in kwargs:
organization_id = kwargs["organization_id"]
try:
object_under_maintenance = Organization.objects.select_for_update().get(pk=organization_id)
except Organization.DoesNotExist:
task_logger.info(f"Organization for disable_maintenance does not exists. Id: {organization_id}")
else:
task_logger.info(f"Invalid instance id passed in disable_maintenance. Got: {kwargs}")
@ -90,7 +83,6 @@ def disable_maintenance(*args, **kwargs):
)
def check_maintenance_finished(*args, **kwargs):
AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel")
Organization = apps.get_model("user_management", "Organization")
now = timezone.now()
maintenance_finish_at = ExpressionWrapper(
(F("maintenance_started_at") + F("maintenance_duration")), output_field=fields.DateTimeField()
@ -107,15 +99,3 @@ def check_maintenance_finished(*args, **kwargs):
args=(),
kwargs={"alert_receive_channel_id": id, "force": True},
)
organization_with_expired_maintenance_ids = (
Organization.objects.filter(maintenance_started_at__isnull=False)
.annotate(maintenance_finish_at=maintenance_finish_at)
.filter(maintenance_finish_at__lt=now)
.values_list("pk", flat=True)
)
for id in organization_with_expired_maintenance_ids:
disable_maintenance.apply_async(
args=(),
kwargs={"organization_id": id, "force": True},
)

View file

@ -21,11 +21,7 @@ def send_update_log_report_signal(log_record_pk=None, alert_group_pk=None):
)
return
is_on_maintenace_mode = (
alert_group.channel.maintenance_mode == AlertReceiveChannel.MAINTENANCE
or alert_group.channel.organization.maintenance_mode == AlertReceiveChannel.MAINTENANCE
)
if is_on_maintenace_mode:
if alert_group.channel.maintenance_mode == AlertReceiveChannel.MAINTENANCE:
task_logger.debug(
f'send_update_log_report_signal: alert_group={alert_group_pk} msg="skip alert_group_update_log_report_signal due to maintenace"'
)

View file

@ -1,11 +1,10 @@
from unittest.mock import Mock, PropertyMock, patch
from unittest.mock import Mock, PropertyMock, call, patch
import pytest
import requests
from django.test import override_settings
from django.utils import timezone
from apps.alerts.models import AlertGroup
from apps.alerts.tasks.check_escalation_finished import (
AlertGroupEscalationPolicyExecutionAuditException,
audit_alert_group_escalation,
@ -15,40 +14,70 @@ from apps.alerts.tasks.check_escalation_finished import (
MOCKED_HEARTBEAT_URL = "https://hello.com/lsdjjkf"
# def _get_relevant_log_record_type() -> int:
# return random.choice([AlertGroupLogRecord.TYPE_ESCALATION_TRIGGERED, AlertGroupLogRecord.TYPE_ESCALATION_FAILED])
now = timezone.now()
yesterday = now - timezone.timedelta(days=1)
def test_send_alert_group_escalation_auditor_task_heartbeat_does_not_call_the_heartbeat_url_if_one_is_not_configured():
with patch("apps.alerts.tasks.check_escalation_finished.requests") as mock_requests:
send_alert_group_escalation_auditor_task_heartbeat()
mock_requests.get.assert_not_called()
@pytest.fixture
def make_alert_group_that_started_at_specific_date(make_alert_group):
def _make_alert_group_that_started_at_specific_date(alert_receive_channel, started_at=yesterday, **kwargs):
# we can't simply pass started_at to the fixture because started_at is being "auto-set" on the Model
alert_group = make_alert_group(alert_receive_channel, **kwargs)
alert_group.started_at = started_at
alert_group.save()
return alert_group
return _make_alert_group_that_started_at_specific_date
def assert_not_called_with(self, *args, **kwargs):
"""
https://stackoverflow.com/a/54838760
"""
try:
self.assert_called_with(*args, **kwargs)
except AssertionError:
return
raise AssertionError("Expected %s to not have been called." % self._format_mock_call_signature(args, kwargs))
Mock.assert_not_called_with = assert_not_called_with
@patch("apps.alerts.tasks.check_escalation_finished.requests")
def test_send_alert_group_escalation_auditor_task_heartbeat_does_not_call_the_heartbeat_url_if_one_is_not_configured(
mock_requests,
):
send_alert_group_escalation_auditor_task_heartbeat()
mock_requests.get.assert_not_called()
@patch("apps.alerts.tasks.check_escalation_finished.requests")
@override_settings(ALERT_GROUP_ESCALATION_AUDITOR_CELERY_TASK_HEARTBEAT_URL=MOCKED_HEARTBEAT_URL)
def test_send_alert_group_escalation_auditor_task_heartbeat_calls_the_heartbeat_url_if_one_is_configured():
with patch("apps.alerts.tasks.check_escalation_finished.requests") as mock_requests:
def test_send_alert_group_escalation_auditor_task_heartbeat_calls_the_heartbeat_url_if_one_is_configured(mock_requests):
send_alert_group_escalation_auditor_task_heartbeat()
mock_requests.get.assert_called_once_with(MOCKED_HEARTBEAT_URL)
mock_requests.get.return_value.raise_for_status.assert_called_once_with()
@patch("apps.alerts.tasks.check_escalation_finished.requests")
@override_settings(ALERT_GROUP_ESCALATION_AUDITOR_CELERY_TASK_HEARTBEAT_URL=MOCKED_HEARTBEAT_URL)
def test_send_alert_group_escalation_auditor_task_heartbeat_raises_an_exception_if_the_heartbeat_url_request_fails(
mock_requests,
):
mock_response = Mock()
mock_response.status_code = 500
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError
mock_requests.get.return_value = mock_response
with pytest.raises(requests.exceptions.HTTPError):
send_alert_group_escalation_auditor_task_heartbeat()
mock_requests.get.assert_called_once_with(MOCKED_HEARTBEAT_URL)
mock_requests.get.return_value.raise_for_status.assert_called_once_with()
@override_settings(ALERT_GROUP_ESCALATION_AUDITOR_CELERY_TASK_HEARTBEAT_URL=MOCKED_HEARTBEAT_URL)
def test_send_alert_group_escalation_auditor_task_heartbeat_raises_an_exception_if_the_heartbeat_url_request_fails():
with patch("apps.alerts.tasks.check_escalation_finished.requests") as mock_requests:
mock_response = Mock()
mock_response.status_code = 500
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError
mock_requests.get.return_value = mock_response
with pytest.raises(requests.exceptions.HTTPError):
send_alert_group_escalation_auditor_task_heartbeat()
mock_requests.get.assert_called_once_with(MOCKED_HEARTBEAT_URL)
mock_requests.get.return_value.raise_for_status.assert_called_once_with()
mock_requests.get.assert_called_once_with(MOCKED_HEARTBEAT_URL)
mock_requests.get.return_value.raise_for_status.assert_called_once_with()
@pytest.mark.django_db
@ -75,6 +104,7 @@ def test_audit_alert_group_escalation_skips_further_validation_if_the_escalation
audit_alert_group_escalation(alert_group)
@patch("apps.alerts.escalation_snapshot.snapshot_classes.escalation_snapshot.EscalationSnapshot.next_step_eta_is_valid")
@pytest.mark.django_db
@pytest.mark.parametrize(
"next_step_eta_is_valid_return_value,raises_exception",
@ -85,38 +115,36 @@ def test_audit_alert_group_escalation_skips_further_validation_if_the_escalation
],
)
def test_audit_alert_group_escalation_next_step_eta_validation(
escalation_snapshot_test_setup, next_step_eta_is_valid_return_value, raises_exception
mock_next_step_eta_is_valid, escalation_snapshot_test_setup, next_step_eta_is_valid_return_value, raises_exception
):
mock_next_step_eta_is_valid.return_value = next_step_eta_is_valid_return_value
alert_group, _, _, _ = escalation_snapshot_test_setup
if raises_exception:
with pytest.raises(AlertGroupEscalationPolicyExecutionAuditException):
audit_alert_group_escalation(alert_group)
else:
try:
audit_alert_group_escalation(alert_group)
except AlertGroupEscalationPolicyExecutionAuditException:
pytest.fail()
mock_next_step_eta_is_valid.assert_called_once_with()
@patch(
"apps.alerts.escalation_snapshot.snapshot_classes.escalation_snapshot.EscalationSnapshot.executed_escalation_policy_snapshots",
new_callable=PropertyMock,
)
@pytest.mark.django_db
def test_audit_alert_group_escalation_no_executed_escalation_policy_snapshots(
mock_executed_escalation_policy_snapshots, escalation_snapshot_test_setup
):
alert_group, _, _, _ = escalation_snapshot_test_setup
with patch(
"apps.alerts.escalation_snapshot.snapshot_classes.escalation_snapshot.EscalationSnapshot.next_step_eta_is_valid"
) as mock_next_step_eta_is_valid:
mock_next_step_eta_is_valid.return_value = next_step_eta_is_valid_return_value
if raises_exception:
with pytest.raises(AlertGroupEscalationPolicyExecutionAuditException):
audit_alert_group_escalation(alert_group)
else:
try:
audit_alert_group_escalation(alert_group)
except AlertGroupEscalationPolicyExecutionAuditException:
pytest.fail()
mock_next_step_eta_is_valid.assert_called_once_with()
@pytest.mark.django_db
def test_audit_alert_group_escalation_no_executed_escalation_policy_snapshots(escalation_snapshot_test_setup):
alert_group, _, _, _ = escalation_snapshot_test_setup
with patch(
"apps.alerts.escalation_snapshot.snapshot_classes.escalation_snapshot.EscalationSnapshot.executed_escalation_policy_snapshots",
new_callable=PropertyMock,
) as mock_executed_escalation_policy_snapshots:
mock_executed_escalation_policy_snapshots.return_value = []
audit_alert_group_escalation(alert_group)
mock_executed_escalation_policy_snapshots.assert_called_once_with()
mock_executed_escalation_policy_snapshots.return_value = []
audit_alert_group_escalation(alert_group)
mock_executed_escalation_policy_snapshots.assert_called_once_with()
# # see TODO: comment in engine/apps/alerts/tasks/check_escalation_finished.py
@ -174,156 +202,151 @@ def test_audit_alert_group_escalation_no_executed_escalation_policy_snapshots(es
# mock_executed_escalation_policy_snapshots.assert_called_once_with()
@patch("apps.alerts.tasks.check_escalation_finished.audit_alert_group_escalation")
@patch("apps.alerts.tasks.check_escalation_finished.send_alert_group_escalation_auditor_task_heartbeat")
@pytest.mark.django_db
def test_check_escalation_finished_task_queries_doesnt_grab_alert_groups_outside_of_date_range(
mocked_send_alert_group_escalation_auditor_task_heartbeat,
mocked_audit_alert_group_escalation,
make_organization_and_user,
make_alert_receive_channel,
make_alert_group,
make_alert_group_that_started_at_specific_date,
):
organization, _ = make_organization_and_user()
alert_receive_channel = make_alert_receive_channel(organization)
now = timezone.now()
two_days_ago = now - timezone.timedelta(days=2)
two_days_in_future = now + timezone.timedelta(days=2)
alert_group1 = make_alert_group_that_started_at_specific_date(alert_receive_channel)
make_alert_group_that_started_at_specific_date(alert_receive_channel, now - timezone.timedelta(days=5))
make_alert_group_that_started_at_specific_date(alert_receive_channel, now + timezone.timedelta(days=5))
# we can't simply pass started_at to the fixture because started_at is being "auto-set" on the Model
alert_group1 = make_alert_group(alert_receive_channel)
alert_group1.started_at = now
check_escalation_finished_task()
alert_group2 = make_alert_group(alert_receive_channel)
alert_group2.started_at = now - timezone.timedelta(days=5)
alert_group3 = make_alert_group(alert_receive_channel)
alert_group3.started_at = now + timezone.timedelta(days=5)
AlertGroup.all_objects.bulk_update([alert_group1, alert_group2, alert_group3], ["started_at"])
with patch(
"apps.alerts.tasks.check_escalation_finished.get_auditable_alert_groups_started_at_range"
) as mocked_get_auditable_alert_groups_started_at_range:
with patch(
"apps.alerts.tasks.check_escalation_finished.audit_alert_group_escalation"
) as mocked_audit_alert_group_escalation:
with patch(
"apps.alerts.tasks.check_escalation_finished.send_alert_group_escalation_auditor_task_heartbeat"
) as mocked_send_alert_group_escalation_auditor_task_heartbeat:
mocked_get_auditable_alert_groups_started_at_range.return_value = (two_days_ago, two_days_in_future)
check_escalation_finished_task()
mocked_audit_alert_group_escalation.assert_called_once_with(alert_group1)
mocked_send_alert_group_escalation_auditor_task_heartbeat.assert_called_once_with()
mocked_audit_alert_group_escalation.assert_called_once_with(alert_group1)
mocked_send_alert_group_escalation_auditor_task_heartbeat.assert_called_once_with()
@patch("apps.alerts.tasks.check_escalation_finished.audit_alert_group_escalation")
@patch("apps.alerts.tasks.check_escalation_finished.send_alert_group_escalation_auditor_task_heartbeat")
@pytest.mark.django_db
def test_check_escalation_finished_task_calls_audit_alert_group_escalation_for_every_alert_group(
mocked_send_alert_group_escalation_auditor_task_heartbeat,
mocked_audit_alert_group_escalation,
make_organization_and_user,
make_alert_receive_channel,
make_alert_group,
make_alert_group_that_started_at_specific_date,
):
organization, _ = make_organization_and_user()
alert_receive_channel = make_alert_receive_channel(organization)
now = timezone.now()
two_days_ago = now - timezone.timedelta(days=2)
two_days_in_future = now + timezone.timedelta(days=2)
alert_group1 = make_alert_group_that_started_at_specific_date(alert_receive_channel)
alert_group2 = make_alert_group_that_started_at_specific_date(alert_receive_channel)
alert_group3 = make_alert_group_that_started_at_specific_date(alert_receive_channel)
# we can't simply pass started_at to the fixture because started_at is being "auto-set" on the Model
alert_group1 = make_alert_group(alert_receive_channel)
alert_group1.started_at = now
check_escalation_finished_task()
alert_group2 = make_alert_group(alert_receive_channel)
alert_group2.started_at = now
mocked_audit_alert_group_escalation.assert_any_call(alert_group1)
mocked_audit_alert_group_escalation.assert_any_call(alert_group2)
mocked_audit_alert_group_escalation.assert_any_call(alert_group3)
alert_group3 = make_alert_group(alert_receive_channel)
alert_group3.started_at = now
AlertGroup.all_objects.bulk_update([alert_group1, alert_group2, alert_group3], ["started_at"])
with patch(
"apps.alerts.tasks.check_escalation_finished.get_auditable_alert_groups_started_at_range"
) as mocked_get_auditable_alert_groups_started_at_range:
with patch(
"apps.alerts.tasks.check_escalation_finished.audit_alert_group_escalation"
) as mocked_audit_alert_group_escalation:
with patch(
"apps.alerts.tasks.check_escalation_finished.send_alert_group_escalation_auditor_task_heartbeat"
) as mocked_send_alert_group_escalation_auditor_task_heartbeat:
mocked_get_auditable_alert_groups_started_at_range.return_value = (two_days_ago, two_days_in_future)
check_escalation_finished_task()
mocked_audit_alert_group_escalation.assert_any_call(alert_group1)
mocked_audit_alert_group_escalation.assert_any_call(alert_group2)
mocked_audit_alert_group_escalation.assert_any_call(alert_group3)
mocked_send_alert_group_escalation_auditor_task_heartbeat.assert_called_once_with()
mocked_send_alert_group_escalation_auditor_task_heartbeat.assert_called_once_with()
@patch("apps.alerts.tasks.check_escalation_finished.audit_alert_group_escalation")
@patch("apps.alerts.tasks.check_escalation_finished.send_alert_group_escalation_auditor_task_heartbeat")
@pytest.mark.django_db
def test_check_escalation_finished_task_simply_calls_heartbeat_when_no_alert_groups_found():
with patch(
"apps.alerts.tasks.check_escalation_finished.audit_alert_group_escalation"
) as mocked_audit_alert_group_escalation:
with patch(
"apps.alerts.tasks.check_escalation_finished.send_alert_group_escalation_auditor_task_heartbeat"
) as mocked_send_alert_group_escalation_auditor_task_heartbeat:
check_escalation_finished_task()
mocked_audit_alert_group_escalation.assert_not_called()
mocked_send_alert_group_escalation_auditor_task_heartbeat.assert_called_once_with()
def test_check_escalation_finished_task_filters_the_right_alert_groups(
mocked_send_alert_group_escalation_auditor_task_heartbeat,
mocked_audit_alert_group_escalation,
make_organization_and_user,
make_alert_receive_channel,
make_alert_group_that_started_at_specific_date,
):
organization, _ = make_organization_and_user()
alert_receive_channel = make_alert_receive_channel(organization)
alert_group1 = make_alert_group_that_started_at_specific_date(alert_receive_channel)
silenced_for_one_hour_alert_group = make_alert_group_that_started_at_specific_date(
alert_receive_channel, silenced=True, silenced_until=(now + timezone.timedelta(hours=1))
)
silenced_forever = make_alert_group_that_started_at_specific_date(
alert_receive_channel, silenced=True, silenced_until=None
)
in_maintenance = make_alert_group_that_started_at_specific_date(alert_receive_channel, maintenance_uuid="asdfasdf")
escalation_finished = make_alert_group_that_started_at_specific_date(
alert_receive_channel, is_escalation_finished=True
)
resolved = make_alert_group_that_started_at_specific_date(alert_receive_channel, is_escalation_finished=True)
acknowledged = make_alert_group_that_started_at_specific_date(alert_receive_channel, is_escalation_finished=True)
root_alert_group = make_alert_group_that_started_at_specific_date(
alert_receive_channel, root_alert_group=alert_group1
)
check_escalation_finished_task()
mocked_audit_alert_group_escalation.assert_has_calls(
[
call(alert_group1),
call(silenced_for_one_hour_alert_group),
],
any_order=True,
)
mocked_audit_alert_group_escalation.assert_not_called_with(in_maintenance)
mocked_audit_alert_group_escalation.assert_not_called_with(escalation_finished)
mocked_audit_alert_group_escalation.assert_not_called_with(silenced_forever)
mocked_audit_alert_group_escalation.assert_not_called_with(resolved)
mocked_audit_alert_group_escalation.assert_not_called_with(acknowledged)
mocked_audit_alert_group_escalation.assert_not_called_with(root_alert_group)
@patch("apps.alerts.tasks.check_escalation_finished.audit_alert_group_escalation")
@patch("apps.alerts.tasks.check_escalation_finished.send_alert_group_escalation_auditor_task_heartbeat")
@pytest.mark.django_db
def test_check_escalation_finished_task_simply_calls_heartbeat_when_no_alert_groups_found(
mocked_send_alert_group_escalation_auditor_task_heartbeat,
mocked_audit_alert_group_escalation,
):
check_escalation_finished_task()
mocked_audit_alert_group_escalation.assert_not_called()
mocked_send_alert_group_escalation_auditor_task_heartbeat.assert_called_once_with()
@patch("apps.alerts.tasks.check_escalation_finished.audit_alert_group_escalation")
@patch("apps.alerts.tasks.check_escalation_finished.send_alert_group_escalation_auditor_task_heartbeat")
@pytest.mark.django_db
def test_check_escalation_finished_task_calls_audit_alert_group_escalation_for_every_alert_group_even_if_one_fails(
mocked_send_alert_group_escalation_auditor_task_heartbeat,
mocked_audit_alert_group_escalation,
make_organization_and_user,
make_alert_receive_channel,
make_alert_group,
make_alert_group_that_started_at_specific_date,
):
organization, _ = make_organization_and_user()
alert_receive_channel = make_alert_receive_channel(organization)
now = timezone.now()
two_days_ago = now - timezone.timedelta(days=2)
two_days_in_future = now + timezone.timedelta(days=2)
# we can't simply pass started_at to the fixture because started_at is being "auto-set" on the Model
alert_group1 = make_alert_group(alert_receive_channel)
alert_group1.started_at = now
alert_group2 = make_alert_group(alert_receive_channel)
alert_group2.started_at = now
alert_group3 = make_alert_group(alert_receive_channel)
alert_group3.started_at = now
AlertGroup.all_objects.bulk_update([alert_group1, alert_group2, alert_group3], ["started_at"])
alert_group1 = make_alert_group_that_started_at_specific_date(alert_receive_channel)
alert_group2 = make_alert_group_that_started_at_specific_date(alert_receive_channel)
alert_group3 = make_alert_group_that_started_at_specific_date(alert_receive_channel)
def _mocked_audit_alert_group_escalation(alert_group):
if not alert_group.id == alert_group3.id:
raise AlertGroupEscalationPolicyExecutionAuditException("asdfasdf")
with patch(
"apps.alerts.tasks.check_escalation_finished.get_auditable_alert_groups_started_at_range"
) as mocked_get_auditable_alert_groups_started_at_range:
with patch(
"apps.alerts.tasks.check_escalation_finished.audit_alert_group_escalation"
) as mocked_audit_alert_group_escalation:
with patch(
"apps.alerts.tasks.check_escalation_finished.send_alert_group_escalation_auditor_task_heartbeat"
) as mocked_send_alert_group_escalation_auditor_task_heartbeat:
mocked_get_auditable_alert_groups_started_at_range.return_value = (two_days_ago, two_days_in_future)
mocked_audit_alert_group_escalation.side_effect = _mocked_audit_alert_group_escalation
mocked_audit_alert_group_escalation.side_effect = _mocked_audit_alert_group_escalation
with pytest.raises(AlertGroupEscalationPolicyExecutionAuditException) as exc:
check_escalation_finished_task()
with pytest.raises(AlertGroupEscalationPolicyExecutionAuditException) as exc:
check_escalation_finished_task()
assert (
str(exc.value)
== f"The following alert group id(s) failed auditing: {alert_group1.id}, {alert_group2.id}"
)
assert str(exc.value) == f"The following alert group id(s) failed auditing: {alert_group1.id}, {alert_group2.id}"
mocked_audit_alert_group_escalation.assert_any_call(alert_group1)
mocked_audit_alert_group_escalation.assert_any_call(alert_group2)
mocked_audit_alert_group_escalation.assert_any_call(alert_group3)
mocked_audit_alert_group_escalation.assert_any_call(alert_group1)
mocked_audit_alert_group_escalation.assert_any_call(alert_group2)
mocked_audit_alert_group_escalation.assert_any_call(alert_group3)
mocked_send_alert_group_escalation_auditor_task_heartbeat.assert_not_called()
mocked_send_alert_group_escalation_auditor_task_heartbeat.assert_not_called()

View file

@ -86,40 +86,6 @@ def test_maintenance_integration_will_not_start_twice(
assert alert_receive_channel.maintenance_author == user
@pytest.mark.django_db
def test_start_maintenance_team(maintenance_test_setup, mock_start_disable_maintenance_task):
organization, user = maintenance_test_setup
mode = AlertReceiveChannel.MAINTENANCE
duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds
organization.start_maintenance(mode, duration, user)
assert organization.maintenance_mode == mode
assert organization.maintenance_duration == AlertReceiveChannel.DURATION_ONE_HOUR
assert organization.maintenance_uuid is not None
assert organization.maintenance_started_at is not None
assert organization.maintenance_author == user
@pytest.mark.django_db
def test_maintenance_team_will_not_start_twice(maintenance_test_setup, mock_start_disable_maintenance_task):
organization, user = maintenance_test_setup
mode = AlertReceiveChannel.MAINTENANCE
duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds
organization.start_maintenance(mode, duration, user)
with pytest.raises(MaintenanceCouldNotBeStartedError):
organization.start_maintenance(mode, duration, user)
assert organization.maintenance_mode == mode
assert organization.maintenance_duration == AlertReceiveChannel.DURATION_ONE_HOUR
assert organization.maintenance_uuid is not None
assert organization.maintenance_started_at is not None
assert organization.maintenance_author == user
@pytest.mark.django_db
def test_alert_attached_to_maintenance_incident_integration(
maintenance_test_setup,
@ -151,38 +117,6 @@ def test_alert_attached_to_maintenance_incident_integration(
assert alert.group.root_alert_group == maintenance_incident
@pytest.mark.django_db
def test_alert_attached_to_maintenance_incident_team(
maintenance_test_setup,
make_alert_receive_channel,
make_alert_with_custom_create_method,
mock_start_disable_maintenance_task,
):
organization, user = maintenance_test_setup
alert_receive_channel = make_alert_receive_channel(
organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA
)
mode = AlertReceiveChannel.MAINTENANCE
duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds
organization.start_maintenance(mode, duration, user)
maintenance_incident = AlertGroup.all_objects.get(maintenance_uuid=organization.maintenance_uuid)
alert = make_alert_with_custom_create_method(
title="test_title",
message="test_message",
image_url="test_img_url",
link_to_upstream_details=None,
alert_receive_channel=alert_receive_channel,
raw_request_data={"message": "test"},
integration_unique_data={},
)
assert alert.group.root_alert_group == maintenance_incident
@pytest.mark.django_db(transaction=True)
def test_stop_maintenance(
maintenance_test_setup,
@ -214,9 +148,3 @@ def test_stop_maintenance(
alert.refresh_from_db()
assert maintenance_incident.resolved_by == AlertGroup.DISABLE_MAINTENANCE
assert alert.group.resolved_by == AlertGroup.DISABLE_MAINTENANCE
assert organization.maintenance_mode is None
assert organization.maintenance_duration is None
assert organization.maintenance_uuid is None
assert organization.maintenance_started_at is None
assert organization.maintenance_author is None

View file

@ -154,20 +154,6 @@ def test_check_user_availability_on_call(
assert warnings == []
@pytest.mark.django_db
def test_direct_paging_no_one(make_organization, make_user_for_organization):
organization = make_organization()
from_user = make_user_for_organization(organization)
with patch("apps.alerts.paging.notify_user_task") as notify_task:
direct_paging(organization, None, from_user)
# no alert group
assert AlertGroup.all_objects.count() == 0
# no notifications
assert not notify_task.apply_async.called
@pytest.mark.django_db
def test_direct_paging_user(make_organization, make_user_for_organization):
organization = make_organization()

View file

@ -117,8 +117,15 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializ
connection_error = GrafanaAlertingSyncManager.check_for_connection_errors(organization)
if connection_error:
raise BadRequest(detail=connection_error)
for _integration in AlertReceiveChannel._config:
if _integration.slug == integration:
is_able_to_autoresolve = _integration.is_able_to_autoresolve
instance = AlertReceiveChannel.create(
**validated_data, organization=organization, author=self.context["request"].user
**validated_data,
organization=organization,
author=self.context["request"].user,
allow_source_based_resolving=is_able_to_autoresolve,
)
return instance
@ -172,8 +179,11 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializ
return obj.channel_filters.count()
def get_connected_escalations_chains_count(self, obj) -> int:
return len(
set(ChannelFilter.objects.filter(alert_receive_channel=obj).values_list("escalation_chain", flat=True))
return (
ChannelFilter.objects.filter(alert_receive_channel=obj, escalation_chain__isnull=False)
.values("escalation_chain")
.distinct()
.count()
)
@ -192,8 +202,7 @@ class FastAlertReceiveChannelSerializer(serializers.ModelSerializer):
fields = ["id", "integration", "verbal_name", "deleted"]
def get_deleted(self, obj):
# Treat direct paging integrations as deleted, so integration settings are disabled on the frontend
return obj.deleted_at is not None or obj.integration == AlertReceiveChannel.INTEGRATION_DIRECT_PAGING
return obj.deleted_at is not None
class FilterAlertReceiveChannelSerializer(serializers.ModelSerializer):

View file

@ -21,7 +21,6 @@ class OrganizationSerializer(EagerLoadingMixin, serializers.ModelSerializer):
slack_team_identity = FastSlackTeamIdentitySerializer(read_only=True)
name = serializers.CharField(required=False, allow_null=True, allow_blank=True, source="org_title")
maintenance_till = serializers.ReadOnlyField(source="till_maintenance_timestamp")
slack_channel = serializers.SerializerMethodField()
SELECT_RELATED = ["slack_team_identity"]
@ -32,14 +31,10 @@ class OrganizationSerializer(EagerLoadingMixin, serializers.ModelSerializer):
"pk",
"name",
"slack_team_identity",
"maintenance_mode",
"maintenance_till",
"slack_channel",
]
read_only_fields = [
"slack_team_identity",
"maintenance_mode",
"maintenance_till",
]
def get_slack_channel(self, obj):

View file

@ -56,17 +56,12 @@ class DirectPagingSerializer(serializers.Serializer):
def validate(self, attrs):
organization = self.context["organization"]
users = attrs["users"]
schedules = attrs["schedules"]
escalation_chain_id = attrs["escalation_chain_id"]
alert_group_id = attrs["alert_group_id"]
title = attrs["title"]
message = attrs["message"]
if len(users) == 0 and len(schedules) == 0 and not escalation_chain_id:
raise serializers.ValidationError("Provide users, schedules, or an escalation chain")
if alert_group_id and (title or message):
raise serializers.ValidationError("alert_group_id and (title, message) are mutually exclusive")

View file

@ -8,7 +8,7 @@ from rest_framework import status
from rest_framework.response import Response
from rest_framework.test import APIClient
from apps.alerts.models import AlertGroup, AlertGroupLogRecord, AlertReceiveChannel
from apps.alerts.models import AlertGroup, AlertGroupLogRecord
from apps.api.errors import AlertGroupAPIError
from apps.api.permissions import LegacyAccessControlRole
from apps.base.models import UserNotificationPolicyLogRecord
@ -1813,28 +1813,6 @@ def test_alert_group_paged_users(
assert response.json()["paged_users"] == [user2.short()]
@pytest.mark.django_db
def test_direct_paging_integration_treated_as_deleted(
make_organization_and_user_with_plugin_token,
make_alert_receive_channel,
alert_group_internal_api_setup,
make_channel_filter,
make_alert_group,
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token()
alert_receive_channel = make_alert_receive_channel(
organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING
)
alert_group = make_alert_group(alert_receive_channel)
client = APIClient()
url = reverse("api-internal:alertgroup-detail", kwargs={"pk": alert_group.public_primary_key})
response = client.get(url, format="json", **make_user_auth_headers(user, token))
assert response.json()["alert_receive_channel"]["deleted"] is True
@pytest.mark.django_db
def test_alert_group_resolve_resolution_note(
make_organization_and_user_with_plugin_token,

View file

@ -675,23 +675,6 @@ def test_alert_receive_channel_counters_per_integration_permissions(
assert response.status_code == expected_status
@pytest.mark.django_db
def test_get_alert_receive_channels_direct_paging_hidden_from_list(
make_organization_and_user_with_plugin_token, make_alert_receive_channel, make_user_auth_headers
):
organization, user, token = make_organization_and_user_with_plugin_token()
make_alert_receive_channel(user.organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING)
client = APIClient()
url = reverse("api-internal:alert_receive_channel-list")
response = client.get(url, format="json", **make_user_auth_headers(user, token))
# Check no direct paging integrations in the response
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 0
assert len(response.json()["results"]) == 0
@pytest.mark.django_db
def test_get_alert_receive_channels_direct_paging_present_for_filters(
make_organization_and_user_with_plugin_token, make_alert_receive_channel, make_user_auth_headers

View file

@ -96,7 +96,9 @@ def test_update_notify_multiple_users_step(escalation_policy_internal_api_setup,
assert response.status_code == status.HTTP_200_OK
assert response.json()["step"] == EscalationPolicy.STEP_NOTIFY_MULTIPLE_USERS
assert response.json()["notify_to_users_queue"] == [first_user.public_primary_key, second_user.public_primary_key]
assert sorted(response.json()["notify_to_users_queue"]) == sorted(
[first_user.public_primary_key, second_user.public_primary_key]
)
@pytest.mark.django_db

View file

@ -1,272 +0,0 @@
import pytest
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from apps.alerts.models import AlertReceiveChannel
from apps.user_management.models import Organization
# TODO: should probably modify these tests to take into account new rbac permissions
@pytest.fixture()
def maintenance_internal_api_setup(
make_organization_and_user_with_plugin_token,
make_escalation_chain,
make_alert_receive_channel,
):
organization, user, token = make_organization_and_user_with_plugin_token()
make_escalation_chain(organization)
alert_receive_channel = make_alert_receive_channel(organization)
return token, organization, user, alert_receive_channel
@pytest.mark.django_db
def test_start_maintenance_integration(
maintenance_internal_api_setup, mock_start_disable_maintenance_task, make_user_auth_headers
):
token, _, user, alert_receive_channel = maintenance_internal_api_setup
client = APIClient()
url = reverse("api-internal:start_maintenance")
data = {
"mode": AlertReceiveChannel.MAINTENANCE,
"duration": AlertReceiveChannel.DURATION_ONE_HOUR.total_seconds(),
"type": "alert_receive_channel",
"alert_receive_channel_id": alert_receive_channel.public_primary_key,
}
response = client.post(url, data=data, format="json", **make_user_auth_headers(user, token))
alert_receive_channel.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert alert_receive_channel.maintenance_mode == AlertReceiveChannel.MAINTENANCE
assert alert_receive_channel.maintenance_duration == AlertReceiveChannel.DURATION_ONE_HOUR
assert alert_receive_channel.maintenance_uuid is not None
assert alert_receive_channel.maintenance_started_at is not None
assert alert_receive_channel.maintenance_author is not None
@pytest.mark.django_db
def test_start_maintenance_integration_user_team(
maintenance_internal_api_setup, mock_start_disable_maintenance_task, make_user_auth_headers, make_team
):
token, organization, user, alert_receive_channel = maintenance_internal_api_setup
another_team = make_team(organization)
user.current_team = another_team
user.save()
client = APIClient()
url = reverse("api-internal:start_maintenance")
data = {
"mode": AlertReceiveChannel.MAINTENANCE,
"duration": AlertReceiveChannel.DURATION_ONE_HOUR.total_seconds(),
"type": "alert_receive_channel",
"alert_receive_channel_id": alert_receive_channel.public_primary_key,
}
response = client.post(url, data=data, format="json", **make_user_auth_headers(user, token))
alert_receive_channel.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert alert_receive_channel.maintenance_mode == AlertReceiveChannel.MAINTENANCE
assert alert_receive_channel.maintenance_duration == AlertReceiveChannel.DURATION_ONE_HOUR
assert alert_receive_channel.maintenance_uuid is not None
assert alert_receive_channel.maintenance_started_at is not None
assert alert_receive_channel.maintenance_author is not None
@pytest.mark.django_db
def test_start_maintenance_integration_different_team(
maintenance_internal_api_setup, mock_start_disable_maintenance_task, make_user_auth_headers, make_team
):
token, organization, user, alert_receive_channel = maintenance_internal_api_setup
another_team = make_team(organization)
other_team = make_team(organization)
user.current_team = another_team
user.save()
# integration belongs to non-general team, != user current team
alert_receive_channel.team = other_team
alert_receive_channel.save()
client = APIClient()
url = reverse("api-internal:start_maintenance")
data = {
"mode": AlertReceiveChannel.MAINTENANCE,
"duration": AlertReceiveChannel.DURATION_ONE_HOUR.total_seconds(),
"type": "alert_receive_channel",
"alert_receive_channel_id": alert_receive_channel.public_primary_key,
}
response = client.post(url, data=data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
alert_receive_channel.refresh_from_db()
assert alert_receive_channel.maintenance_mode is None
@pytest.mark.django_db
def test_stop_maintenance_integration(
maintenance_internal_api_setup,
mock_start_disable_maintenance_task,
make_user_auth_headers,
):
token, _, user, alert_receive_channel = maintenance_internal_api_setup
client = APIClient()
mode = AlertReceiveChannel.MAINTENANCE
duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds
alert_receive_channel.start_maintenance(mode, duration, user)
url = reverse("api-internal:stop_maintenance")
data = {
"type": "alert_receive_channel",
"alert_receive_channel_id": alert_receive_channel.public_primary_key,
}
response = client.post(url, data=data, format="json", **make_user_auth_headers(user, token))
alert_receive_channel.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert alert_receive_channel.maintenance_mode is None
assert alert_receive_channel.maintenance_duration is None
assert alert_receive_channel.maintenance_uuid is None
assert alert_receive_channel.maintenance_started_at is None
assert alert_receive_channel.maintenance_author is None
@pytest.mark.django_db
def test_start_maintenance_organization(
maintenance_internal_api_setup,
mock_start_disable_maintenance_task,
make_user_auth_headers,
):
token, organization, user, _ = maintenance_internal_api_setup
client = APIClient()
url = reverse("api-internal:start_maintenance")
data = {
"mode": Organization.MAINTENANCE,
"duration": Organization.DURATION_ONE_HOUR.total_seconds(),
"type": "organization",
}
response = client.post(url, data=data, format="json", **make_user_auth_headers(user, token))
organization.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert organization.maintenance_mode == Organization.MAINTENANCE
assert organization.maintenance_duration == Organization.DURATION_ONE_HOUR
assert organization.maintenance_uuid is not None
assert organization.maintenance_started_at is not None
assert organization.maintenance_author is not None
@pytest.mark.django_db
def test_stop_maintenance_team(
maintenance_internal_api_setup,
mock_start_disable_maintenance_task,
make_user_auth_headers,
):
token, organization, user, _ = maintenance_internal_api_setup
client = APIClient()
mode = Organization.MAINTENANCE
duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds
organization.start_maintenance(mode, duration, user)
url = reverse("api-internal:stop_maintenance")
data = {
"type": "organization",
}
response = client.post(url, data=data, format="json", **make_user_auth_headers(user, token))
organization.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert organization.maintenance_mode is None
assert organization.maintenance_duration is None
assert organization.maintenance_uuid is None
assert organization.maintenance_started_at is None
assert organization.maintenance_author is None
@pytest.mark.django_db
def test_maintenances_list(
maintenance_internal_api_setup,
mock_start_disable_maintenance_task,
make_user_auth_headers,
):
token, organization, user, alert_receive_channel = maintenance_internal_api_setup
client = APIClient()
mode = AlertReceiveChannel.MAINTENANCE
duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds
alert_receive_channel.start_maintenance(mode, duration, user)
organization.start_maintenance(mode, duration, user)
url = reverse("api-internal:maintenance")
response = client.get(url, format="json", **make_user_auth_headers(user, token))
expected_payload = [
{
"organization_id": organization.public_primary_key,
"type": "organization",
"maintenance_mode": 1,
"maintenance_till_timestamp": organization.till_maintenance_timestamp,
"started_at_timestamp": organization.started_at_timestamp,
},
{
"alert_receive_channel_id": alert_receive_channel.public_primary_key,
"type": "alert_receive_channel",
"maintenance_mode": 1,
"maintenance_till_timestamp": alert_receive_channel.till_maintenance_timestamp,
"started_at_timestamp": alert_receive_channel.started_at_timestamp,
},
]
assert response.status_code == status.HTTP_200_OK
assert response.json() == expected_payload
@pytest.mark.django_db
def test_maintenances_list_include_all_user_teams(
maintenance_internal_api_setup,
mock_start_disable_maintenance_task,
make_user_auth_headers,
make_team,
):
token, organization, user, alert_receive_channel = maintenance_internal_api_setup
another_team = make_team(organization)
other_team = make_team(organization)
# setup user teams
user.teams.add(another_team)
user.teams.add(other_team)
user.current_team = other_team
user.save()
# integration belongs to non-general team, != user current team
alert_receive_channel.team = another_team
alert_receive_channel.save()
client = APIClient()
mode = AlertReceiveChannel.MAINTENANCE
duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds
alert_receive_channel.start_maintenance(mode, duration, user)
url = reverse("api-internal:maintenance")
response = client.get(url, format="json", **make_user_auth_headers(user, token))
expected_payload = [
{
"alert_receive_channel_id": alert_receive_channel.public_primary_key,
"type": "alert_receive_channel",
"maintenance_mode": 1,
"maintenance_till_timestamp": alert_receive_channel.till_maintenance_timestamp,
"started_at_timestamp": alert_receive_channel.started_at_timestamp,
},
]
assert response.status_code == status.HTTP_200_OK
assert response.json() == expected_payload
@pytest.mark.django_db
def test_empty_maintenances_list(
maintenance_internal_api_setup, mock_start_disable_maintenance_task, make_user_auth_headers
):
token, _, user, alert_receive_channel = maintenance_internal_api_setup
client = APIClient()
url = reverse("api-internal:maintenance")
response = client.get(url, format="json", **make_user_auth_headers(user, token))
expected_payload = []
alert_receive_channel.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert response.data == expected_payload

View file

@ -15,7 +15,6 @@ from .views.features import FeaturesAPIView
from .views.gitops import TerraformGitOpsView, TerraformStateView
from .views.integration_heartbeat import IntegrationHeartBeatView
from .views.live_setting import LiveSettingViewSet
from .views.maintenance import MaintenanceAPIView, MaintenanceStartAPIView, MaintenanceStopAPIView
from .views.on_call_shifts import OnCallShiftView
from .views.organization import (
CurrentOrganizationView,
@ -84,9 +83,6 @@ urlpatterns = [
),
optional_slash_path("terraform_file", TerraformGitOpsView.as_view(), name="terraform_file"),
optional_slash_path("terraform_imports", TerraformStateView.as_view(), name="terraform_imports"),
optional_slash_path("maintenance", MaintenanceAPIView.as_view(), name="maintenance"),
optional_slash_path("start_maintenance", MaintenanceStartAPIView.as_view(), name="start_maintenance"),
optional_slash_path("stop_maintenance", MaintenanceStopAPIView.as_view(), name="stop_maintenance"),
optional_slash_path("slack_settings", SlackTeamSettingsAPIView.as_view(), name="slack-settings"),
optional_slash_path(
"slack_settings/acknowledge_remind_options",

View file

@ -18,6 +18,7 @@ from apps.api.serializers.alert_receive_channel import (
)
from apps.api.throttlers import DemoAlertThrottler
from apps.auth_token.auth import PluginAuthentication
from apps.user_management.models.team import Team
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, TeamModelMultipleChoiceFilter
from common.api_helpers.mixins import (
@ -104,10 +105,39 @@ class AlertReceiveChannelView(
}
def create(self, request, *args, **kwargs):
if request.data["integration"] is not None and (
request.data["integration"] in AlertReceiveChannel.WEB_INTEGRATION_CHOICES
):
return super().create(request, *args, **kwargs)
organization = request.auth.organization
user = request.user
team_lookup = {}
if "team" in request.data:
team_public_pk = request.data.get("team", None)
if team_public_pk is not None:
try:
team = user.available_teams.get(public_primary_key=team_public_pk)
team_lookup = {"team": team}
except Team.DoesNotExist:
return Response(data="invalid team", status=status.HTTP_400_BAD_REQUEST)
else:
team_lookup = {"team__isnull": True}
if request.data["integration"] is not None:
if request.data["integration"] in AlertReceiveChannel.WEB_INTEGRATION_CHOICES:
# Don't allow multiple Direct Paging integrations
if request.data["integration"] == AlertReceiveChannel.INTEGRATION_DIRECT_PAGING:
try:
AlertReceiveChannel.objects.get(
organization=organization,
integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING,
deleted_at=None,
**team_lookup,
)
return Response(
data="Direct paging integration already exists for this team",
status=status.HTTP_400_BAD_REQUEST,
)
except AlertReceiveChannel.DoesNotExist:
pass
return super().create(request, *args, **kwargs)
return Response(data="invalid integration", status=status.HTTP_400_BAD_REQUEST)
def perform_update(self, serializer):
@ -147,10 +177,6 @@ class AlertReceiveChannelView(
if not ignore_filtering_by_available_teams:
queryset = queryset.filter(*self.available_teams_lookup_args).distinct()
# Hide direct paging integrations from the list view, but not from the filters
if not is_filters_request:
queryset = queryset.exclude(integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING)
return queryset
@action(detail=True, methods=["post"], throttle_classes=[DemoAlertThrottler])

View file

@ -1,161 +0,0 @@
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.alerts.models import AlertReceiveChannel
from apps.alerts.models.maintainable_object import MaintainableObject
from apps.api.permissions import RBACPermission
from apps.auth_token.auth import PluginAuthentication
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.mixins import TeamFilteringMixin
from common.exceptions import MaintenanceCouldNotBeStartedError
class GetObjectMixin:
def get_object(self, request):
organization = request.auth.organization
type = request.data.get("type", None)
if type == "organization":
instance = organization
elif type == "alert_receive_channel":
pk = request.data.get("alert_receive_channel_id", None)
if pk is not None:
try:
instance = AlertReceiveChannel.objects.get(
public_primary_key=pk,
organization=organization,
)
if instance.team is not None and instance.team not in self.request.user.teams.all():
raise BadRequest(detail={"alert_receive_channel_id": ["unknown id"]})
except AlertReceiveChannel.DoesNotExist:
raise BadRequest(detail={"alert_receive_channel_id": ["unknown id"]})
else:
raise BadRequest(detail={"alert_receive_channel_id": ["id is required"]})
else:
raise BadRequest(detail={"type": ["Unknown type"]})
return instance
class MaintenanceAPIView(APIView, TeamFilteringMixin):
"""Deprecated. Maintenance management is now performed on integrations page (alert_receive_channel/ endpoint))"""
authentication_classes = (PluginAuthentication,)
permission_classes = (IsAuthenticated, RBACPermission)
rbac_permissions = {
"get": [RBACPermission.Permissions.MAINTENANCE_READ],
"filters": [RBACPermission.Permissions.MAINTENANCE_READ],
}
def get(self, request):
organization = self.request.auth.organization
response = []
integrations_under_maintenance = (
AlertReceiveChannel.objects.filter(
maintenance_mode__isnull=False, organization=organization, *self.available_teams_lookup_args
)
.distinct()
.order_by("maintenance_started_at")
)
if organization.maintenance_mode is not None:
response.append(
{
"organization_id": organization.public_primary_key,
"type": "organization",
"maintenance_mode": organization.maintenance_mode,
"maintenance_till_timestamp": organization.till_maintenance_timestamp,
"started_at_timestamp": organization.started_at_timestamp,
}
)
for i in integrations_under_maintenance:
response.append(
{
"alert_receive_channel_id": i.public_primary_key,
"type": "alert_receive_channel",
"maintenance_mode": i.maintenance_mode,
"maintenance_till_timestamp": i.till_maintenance_timestamp,
"started_at_timestamp": i.started_at_timestamp,
}
)
return Response(response, status=200)
@action(methods=["get"], detail=False)
def filters(self, request):
filter_name = request.query_params.get("search", None)
api_root = "/api/internal/v1/"
filter_options = [
{
"name": "team",
"type": "team_select",
"href": api_root + "teams/",
"global": True,
},
]
if filter_name is not None:
filter_options = list(filter(lambda f: filter_name in f["name"], filter_options))
return Response(filter_options)
class MaintenanceStartAPIView(GetObjectMixin, APIView):
"""Deprecated. Maintenance management is now performed on integrations page (alert_receive_channel/ endpoint))"""
authentication_classes = (PluginAuthentication,)
permission_classes = (IsAuthenticated, RBACPermission)
rbac_permissions = {
"post": [RBACPermission.Permissions.MAINTENANCE_WRITE],
}
def post(self, request):
mode = request.data.get("mode", None)
duration = request.data.get("duration", None)
try:
mode = int(mode)
except (ValueError, TypeError):
raise BadRequest(detail={"mode": ["Invalid mode"]})
if mode not in [MaintainableObject.DEBUG_MAINTENANCE, MaintainableObject.MAINTENANCE]:
raise BadRequest(detail={"mode": ["Unknown mode"]})
try:
duration = int(duration)
except (ValueError, TypeError):
raise BadRequest(detail={"duration": ["Invalid duration"]})
if duration not in MaintainableObject.maintenance_duration_options_in_seconds():
raise BadRequest(detail={"mode": ["Unknown duration"]})
instance = self.get_object(request)
try:
instance.start_maintenance(mode, duration, request.user)
except MaintenanceCouldNotBeStartedError as e:
if type(instance) == AlertReceiveChannel:
detail = {"alert_receive_channel_id": ["Already on maintenance"]}
else:
detail = str(e)
raise BadRequest(detail=detail)
return Response(status=status.HTTP_200_OK)
class MaintenanceStopAPIView(GetObjectMixin, APIView):
"""Deprecated. Maintenance management is now performed on integrations page (alert_receive_channel/ endpoint))"""
authentication_classes = (PluginAuthentication,)
permission_classes = (IsAuthenticated, RBACPermission)
rbac_permissions = {
"post": [RBACPermission.Permissions.MAINTENANCE_WRITE],
}
def post(self, request):
instance = self.get_object(request)
user = request.user
instance.force_disable_maintenance(user)
return Response(status=status.HTTP_200_OK)

View file

@ -2,17 +2,11 @@ from rest_framework import serializers
from apps.user_management.models import Organization
from .maintenance import MaintainableObjectSerializerMixin
class OrganizationSerializer(serializers.ModelSerializer, MaintainableObjectSerializerMixin):
class OrganizationSerializer(serializers.ModelSerializer):
id = serializers.ReadOnlyField(read_only=True, source="public_primary_key")
class Meta:
model = Organization
fields = MaintainableObjectSerializerMixin.Meta.fields + [
"id",
]
read_only_fields = MaintainableObjectSerializerMixin.Meta.fields + [
"id",
]
fields = ["id"]
read_only_fields = ["id"]

View file

@ -726,25 +726,6 @@ def test_set_default_messaging_backend_template(
assert response.data == expected_response
@pytest.mark.django_db
def test_get_list_integrations_direct_paging_hidden(
make_organization_and_user_with_token,
make_alert_receive_channel,
make_channel_filter,
make_integration_heartbeat,
):
organization, user, token = make_organization_and_user_with_token()
make_alert_receive_channel(organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING)
client = APIClient()
url = reverse("api-public:integrations-list")
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
# Check no direct paging integrations in the response
assert response.status_code == status.HTTP_200_OK
assert response.json()["results"] == []
@pytest.mark.django_db
def test_get_list_integrations_link_and_inbound_email(
make_organization_and_user_with_token,

View file

@ -48,9 +48,6 @@ class IntegrationView(
queryset = self.serializer_class.setup_eager_loading(queryset)
queryset = queryset.annotate(alert_groups_count_annotated=Count("alert_groups", distinct=True))
# Hide direct paging integrations
queryset = queryset.exclude(integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING)
return queryset
def get_object(self):

View file

@ -66,12 +66,7 @@ class AlertShootingStep(scenario_step.ScenarioStep):
AlertGroup.all_objects.filter(pk=alert.group.pk).update(slack_message_sent=False)
raise e
is_on_debug_mode = (
alert.group.channel.maintenance_mode == AlertReceiveChannel.DEBUG_MAINTENANCE
or alert.group.channel.organization.maintenance_mode == AlertReceiveChannel.DEBUG_MAINTENANCE
)
if is_on_debug_mode:
if alert.group.channel.maintenance_mode == AlertReceiveChannel.DEBUG_MAINTENANCE:
self._send_debug_mode_notice(alert.group, channel_id)
if alert.group.is_maintenance_incident:

View file

@ -11,8 +11,6 @@ from django.utils import timezone
from mirage import fields as mirage_fields
from apps.alerts.models import MaintainableObject
from apps.alerts.tasks import disable_maintenance
from apps.slack.utils import post_message_to_channel
from apps.user_management.subscription_strategy import FreePublicBetaSubscriptionStrategy
from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
from common.oncall_gateway import create_oncall_connector, delete_oncall_connector, delete_slack_connector
@ -75,6 +73,9 @@ class OrganizationManager(models.Manager):
return OrganizationQuerySet(self.model, using=self._db).filter(deleted_at__isnull=True)
# TODO: in a subsequent PR, remove the inheritance from MaintainableObject (plus generate the database migration file)
# this will remove the maintenance related columns that're no longer used on the organization object
# class Organization(models.Model):
class Organization(MaintainableObject):
auth_tokens: "RelatedManager['ApiAuthToken']"
mobile_app_auth_tokens: "RelatedManager['MobileAppAuthToken']"
@ -255,39 +256,6 @@ class Organization(MaintainableObject):
token_model = apps.get_model("auth_token", "PluginAuthToken")
token_model.objects.filter(organization=self).delete()
"""
Following methods: start_disable_maintenance_task, force_disable_maintenance, get_organization, get_verbal serve for
MaintainableObject.
"""
def start_disable_maintenance_task(self, countdown):
maintenance_uuid = disable_maintenance.apply_async(
args=(),
kwargs={
"organization_id": self.pk,
},
countdown=countdown,
)
return maintenance_uuid
def force_disable_maintenance(self, user):
disable_maintenance(organization_id=self.pk, force=True, user_id=user.pk)
def get_organization(self):
return self
def get_team(self):
return None
def get_verbal(self):
return self.org_title
def notify_about_maintenance_action(self, text, send_to_general_log_channel=True):
# TODO: this method should be refactored.
# It's binded to slack and sending maintenance notification only there.
if send_to_general_log_channel:
post_message_to_channel(self, self.general_log_channel_id, text)
"""
Following methods:
phone_calls_left, sms_left, emails_left

View file

@ -4,7 +4,7 @@ title = "Direct paging"
slug = "direct_paging"
short_description = None
description = None
is_displayed_on_web = False
is_displayed_on_web = True
is_featured = False
is_able_to_autoresolve = False
is_demo_alert_enabled = False

View file

@ -408,6 +408,7 @@ CELERY_MAX_TASKS_PER_CHILD = 1
CELERY_WORKER_SEND_TASK_EVENTS = True
CELERY_TASK_SEND_SENT_EVENT = True
ESCALATION_AUDITOR_ENABLED = getenv_boolean("ESCALATION_AUDITOR_ENABLED", default=True)
ALERT_GROUP_ESCALATION_AUDITOR_CELERY_TASK_HEARTBEAT_URL = os.getenv(
"ALERT_GROUP_ESCALATION_AUDITOR_CELERY_TASK_HEARTBEAT_URL", None
)
@ -418,15 +419,6 @@ CELERY_BEAT_SCHEDULE = {
"schedule": 10 * 60,
"args": (),
},
"check_escalations": {
"task": "apps.alerts.tasks.check_escalation_finished.check_escalation_finished_task",
# the task should be executed a minute or two less than the integration's configured interval
#
# ex. if the integration is configured to expect a heartbeat every 15 minutes then this value should be set
# to something like 13 * 60 (every 13 minutes)
"schedule": getenv_integer("ALERT_GROUP_ESCALATION_AUDITOR_CELERY_TASK_HEARTBEAT_INTERVAL", 13 * 60),
"args": (),
},
"start_refresh_ical_final_schedules": {
"task": "apps.schedules.tasks.refresh_ical_files.start_refresh_ical_final_schedules",
"schedule": crontab(minute=15, hour=0),
@ -498,6 +490,17 @@ CELERY_BEAT_SCHEDULE = {
},
}
if ESCALATION_AUDITOR_ENABLED:
CELERY_BEAT_SCHEDULE["check_escalations"] = {
"task": "apps.alerts.tasks.check_escalation_finished.check_escalation_finished_task",
# the task should be executed a minute or two less than the integration's configured interval
#
# ex. if the integration is configured to expect a heartbeat every 15 minutes then this value should be set
# to something like 13 * 60 (every 13 minutes)
"schedule": getenv_integer("ALERT_GROUP_ESCALATION_AUDITOR_CELERY_TASK_HEARTBEAT_INTERVAL", 13 * 60),
"args": (),
}
INTERNAL_IPS = ["127.0.0.1"]
SELF_IP = os.environ.get("SELF_IP")

View file

@ -6,7 +6,7 @@ module.exports = {
plugins: ['rulesdir', 'import'],
settings: {
'import/internal-regex':
'^assets|^components|^containers|^declare|^icons|^img|^interceptors|^models|^network|^pages|^services|^state|^utils|^plugin',
'^assets|^components|^containers|^icons|^img|^models|^network|^pages|^services|^state|^utils|^plugin',
},
rules: {
eqeqeq: 'warn',

View file

@ -0,0 +1,144 @@
import { test, expect, Page, Locator } from '../fixtures';
import { verifyThatAlertGroupIsRoutedCorrectlyButNotEscalated } from '../utils/alertGroup';
import { EscalationStep, createEscalationChain } from '../utils/escalationChain';
import { clickButton, generateRandomValue, selectDropdownValue } from '../utils/forms';
import {
assignEscalationChainToIntegration,
createIntegration,
filterIntegrationsTableAndGoToDetailPage,
sendDemoAlert,
} from '../utils/integrations';
import { goToOnCallPage } from '../utils/navigation';
type MaintenanceModeType = 'Debug' | 'Maintenance';
test.describe('maintenance mode works', () => {
test.slow(); // this test is doing a good amount of work, give it time
const MAINTENANCE_DURATION = '1 hour';
const REMAINING_TIME_TEXT = '59m left';
const REMAINING_TIME_TOOLTIP_TEST_ID = 'maintenance-mode-remaining-time-tooltip';
const createRoutedText = (escalationChainName: string): string =>
`alert group assigned to route "default" with escalation chain "${escalationChainName}"`;
const _openIntegrationSettingsPopup = async (page: Page): Promise<Locator> => {
const integrationSettingsPopupElement = page.getByTestId('integration-settings-context-menu');
await integrationSettingsPopupElement.click();
return integrationSettingsPopupElement;
};
const getRemainingTimeTooltip = (page: Page): Locator => page.getByTestId(REMAINING_TIME_TOOLTIP_TEST_ID);
const enableMaintenanceMode = async (page: Page, mode: MaintenanceModeType): Promise<void> => {
const integrationSettingsPopupElement = await _openIntegrationSettingsPopup(page);
/**
* we need to click twice here, because adding the escalation chain route
* doesn't unfocus out of the select element after selecting an option
*/
await integrationSettingsPopupElement.click();
// open the maintenance mode settings drawer + fill in the maintenance details
await page.getByTestId('integration-start-maintenance').click();
// fill in the form
const maintenanceModeDrawer = page.getByTestId('maintenance-mode-drawer');
await selectDropdownValue({
page,
startingLocator: maintenanceModeDrawer,
selectType: 'grafanaSelect',
placeholderText: 'Choose mode',
value: mode,
optionExactMatch: false,
});
await selectDropdownValue({
page,
startingLocator: maintenanceModeDrawer,
selectType: 'grafanaSelect',
placeholderText: 'Choose duration',
value: MAINTENANCE_DURATION,
optionExactMatch: false,
});
await maintenanceModeDrawer.getByTestId('create-maintenance-button').click();
const maintenanceModeRemainingTimeTooltip = getRemainingTimeTooltip(page);
await maintenanceModeRemainingTimeTooltip.waitFor({ state: 'visible' });
expect(await page.getByTestId(`${REMAINING_TIME_TOOLTIP_TEST_ID}-text`).innerText()).toContain(REMAINING_TIME_TEXT);
};
const disableMaintenanceMode = async (page: Page, integrationName: string): Promise<void> => {
await goToOnCallPage(page, 'integrations');
await filterIntegrationsTableAndGoToDetailPage(page, integrationName);
await _openIntegrationSettingsPopup(page);
// click the stop maintenance button
await page.getByTestId('integration-stop-maintenance').click();
// in the modal popup, confirm that we want to stop it
await clickButton({
page,
buttonText: 'Stop',
startingLocator: page.getByRole('dialog'),
});
await getRemainingTimeTooltip(page).waitFor({ state: 'hidden' });
};
const createIntegrationAndEscalationChainAndEnableMaintenanceMode = async (
page: Page,
userName: string,
maintenanceModeType: MaintenanceModeType
): Promise<{
escalationChainName: string;
integrationName: string;
}> => {
const escalationChainName = generateRandomValue();
const integrationName = generateRandomValue();
await createEscalationChain(page, escalationChainName, EscalationStep.NotifyUsers, userName);
await createIntegration(page, integrationName);
await assignEscalationChainToIntegration(page, escalationChainName);
await enableMaintenanceMode(page, maintenanceModeType);
return { escalationChainName, integrationName };
};
test('debug mode', async ({ adminRolePage: { page, userName } }) => {
const { escalationChainName, integrationName } = await createIntegrationAndEscalationChainAndEnableMaintenanceMode(
page,
userName,
'Debug'
);
await sendDemoAlert(page);
await verifyThatAlertGroupIsRoutedCorrectlyButNotEscalated(
page,
integrationName,
createRoutedText(escalationChainName)
);
await disableMaintenanceMode(page, integrationName);
});
test('"maintenance" mode', async ({ adminRolePage: { page, userName } }) => {
const { integrationName } = await createIntegrationAndEscalationChainAndEnableMaintenanceMode(
page,
userName,
'Maintenance'
);
await sendDemoAlert(page);
// TODO: there seems to be a bug here where "maintenance" mode alert groups don't show up in the UI
// await verifyThatAlertGroupIsRoutedCorrectlyButNotEscalated(
// page,
// integrationName,
// createRoutedText(escalationChainName)
// );
await disableMaintenanceMode(page, integrationName);
});
});

View file

@ -1,10 +1,15 @@
import { Page, expect } from '@playwright/test';
import { Locator, Page, expect } from '@playwright/test';
import { selectDropdownValue, selectValuePickerValue } from './forms';
import { goToOnCallPage } from './navigation';
const MAX_RETRIES = 5;
const ALERT_GROUP_REGISTERED_TEXT = 'alert group registered';
// const sleep = async (seconds: number) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
const getIncidentTimelineList = async (page: Page): Promise<Locator> => {
const incidentTimelineList = page.getByTestId('incident-timeline-list');
await incidentTimelineList.waitFor({ state: 'visible' });
return incidentTimelineList;
};
/**
* recursively refreshes the page waiting for the background celery workers to have done their job of
@ -15,18 +20,28 @@ const incidentTimelineContainsStep = async (page: Page, triggeredStepText: strin
return Promise.resolve(false);
}
if (!page.getByTestId('incident-timeline-list').getByText(triggeredStepText)) {
const incidentTimelineList = await getIncidentTimelineList(page);
if (!incidentTimelineList.getByText(triggeredStepText)) {
await page.reload({ waitUntil: 'networkidle' });
return incidentTimelineContainsStep(page, triggeredStepText, (retryNum += 1));
}
return true;
};
export const verifyThatAlertGroupIsTriggered = async (
/**
* recursively refreshes the page waiting for the background celery workers to have done their job of
* creating the alert group
*/
export const filterAlertGroupsTableByIntegrationAndGoToDetailPage = async (
page: Page,
integrationName: string,
triggeredStepText: string
retryNum = 0
): Promise<void> => {
if (retryNum > MAX_RETRIES) {
throw new Error('we were not able to properly filter the alert groups table by integration');
}
await goToOnCallPage(page, 'incidents');
// filter by integration
@ -40,10 +55,48 @@ export const verifyThatAlertGroupIsTriggered = async (
await selectValuePickerValue(page, integrationName, false);
/**
* wait for the alert groups to be filtered then
* click on the alert group and go to the individual alert group page
* wait for the alert groups to be filtered then by this particular integration (toBeVisible assertion),
* then click on the alert group and go to the individual alert group page
*/
await (await page.waitForSelector('table > tbody > tr > td:nth-child(4) a')).click();
const firstTableRow = page.locator('table > tbody > tr:first-child');
try {
/**
* wait for up to 5 seconds for the alert groups to be filtered, if the first row does not correspond
* to `integrationName` assume that the background workers have not created it yet and lets
* recursively retry this function
*/
await firstTableRow.getByText(integrationName).waitFor({ state: 'visible', timeout: 5000 });
await firstTableRow.locator('td:nth-child(4) a').click();
} catch (err) {
return filterAlertGroupsTableByIntegrationAndGoToDetailPage(page, integrationName, (retryNum += 1));
}
};
export const verifyThatAlertGroupIsRoutedCorrectlyButNotEscalated = async (
page: Page,
integrationName: string,
routedText: string
): Promise<void> => {
await filterAlertGroupsTableByIntegrationAndGoToDetailPage(page, integrationName);
/**
* incidentTimelineContainsStep recursively reloads the alert group page until the engine
* background workers have processed/escalated the alert group
*/
expect(await incidentTimelineContainsStep(page, ALERT_GROUP_REGISTERED_TEXT)).toBe(true);
const incidentTimelineList = await getIncidentTimelineList(page);
expect(incidentTimelineList).toContainText(routedText);
expect(incidentTimelineList).not.toContainText('triggered step');
};
export const verifyThatAlertGroupIsTriggered = async (
page: Page,
integrationName: string,
triggeredStepText: string
): Promise<void> => {
await filterAlertGroupsTableByIntegrationAndGoToDetailPage(page, integrationName);
expect(await incidentTimelineContainsStep(page, triggeredStepText)).toBe(true);
};

View file

@ -13,6 +13,10 @@ type SelectDropdownValueArgs = {
startingLocator?: Locator;
// if true, when selecting the dropdown option, use an exact match, otherwise use a substring contains match
optionExactMatch?: boolean;
// if true, will press enter in the select dropdown. Some dropdowns don't show a list of options
// and instead the user must press enter to trigger the search
pressEnterInsteadOfSelectingOption?: boolean;
};
type ClickButtonArgs = {
@ -87,9 +91,16 @@ const chooseDropdownValue = async ({ page, value, optionExactMatch = true }: Sel
page.locator(`div[id^="react-select-"][id$="-listbox"] >> ${textMatchSelector(optionExactMatch, value)}`).click();
export const selectDropdownValue = async (args: SelectDropdownValueArgs): Promise<Locator> => {
const { page, value, pressEnterInsteadOfSelectingOption } = args;
const selectElement = await openSelect(args);
await selectElement.type(args.value);
await chooseDropdownValue(args);
await selectElement.type(value);
if (pressEnterInsteadOfSelectingOption) {
await page.keyboard.press('Enter');
} else {
await chooseDropdownValue(args);
}
return selectElement;
};

View file

@ -1,5 +1,5 @@
import { Page } from '@playwright/test';
import { clickButton } from './forms';
import { clickButton, selectDropdownValue } from './forms';
import { goToOnCallPage } from './navigation';
const CREATE_INTEGRATION_MODAL_TEST_ID_SELECTOR = 'div[data-testid="create-integration-modal"]';
@ -15,11 +15,7 @@ export const openCreateIntegrationModal = async (page: Page): Promise<void> => {
await page.waitForSelector(CREATE_INTEGRATION_MODAL_TEST_ID_SELECTOR);
};
export const createIntegrationAndSendDemoAlert = async (
page: Page,
integrationName: string,
_escalationChainName: string
): Promise<void> => {
export const createIntegration = async (page: Page, integrationName: string): Promise<void> => {
await openCreateIntegrationModal(page);
// create a webhook integration
@ -27,25 +23,56 @@ export const createIntegrationAndSendDemoAlert = async (
// fill in the required inputs
(await page.waitForSelector('input[name="verbal_name"]', { state: 'attached' })).fill(integrationName);
(await page.waitForSelector('textarea[name="description_short"]', { state: 'attached' })).fill("Here goes your integration description");
(await page.waitForSelector('textarea[name="description_short"]', { state: 'attached' })).fill(
'Here goes your integration description'
);
const grafanaUpdateBtn = page.getByTestId("update-integration-button");
const grafanaUpdateBtn = page.getByTestId('update-integration-button');
await grafanaUpdateBtn.click();
/*
* TODO: This is slightly more complicated now, change this in next iteration */
// const integrationSettingsElement = page.getByTestId('integration-settings');
// // assign the escalation chain to the integration
// await selectDropdownValue({
// page,
// selectType: 'grafanaSelect',
// placeholderText: 'Select Escalation Chain',
// value: escalationChainName,
// startingLocator: integrationSettingsElement,
// });
// send demo alert
await clickButton({ page, buttonText: 'Send demo alert', dataTestId: 'send-demo-alert' });
await clickButton({ page, buttonText: 'Send Alert', dataTestId: "submit-send-alert" })
};
export const assignEscalationChainToIntegration = async (page: Page, escalationChainName: string): Promise<void> => {
await page.getByTestId('integration-escalation-chain-not-selected').click();
// assign the escalation chain to the integration
await selectDropdownValue({
page,
selectType: 'grafanaSelect',
placeholderText: 'Select Escalation Chain',
value: escalationChainName,
startingLocator: page.getByTestId('integration-block-item'),
});
};
export const sendDemoAlert = async (page: Page): Promise<void> => {
await clickButton({ page, buttonText: 'Send demo alert', dataTestId: 'send-demo-alert' });
await clickButton({ page, buttonText: 'Send Alert', dataTestId: 'submit-send-alert' });
await page.getByTestId('demo-alert-sent-notification').waitFor({ state: 'visible' });
};
export const createIntegrationAndSendDemoAlert = async (
page: Page,
integrationName: string,
escalationChainName: string
): Promise<void> => {
await createIntegration(page, integrationName);
await assignEscalationChainToIntegration(page, escalationChainName);
await sendDemoAlert(page);
};
export const filterIntegrationsTableAndGoToDetailPage = async (page: Page, integrationName: string): Promise<void> => {
// filter the integrations page by the integration in question, then go to its detail page
await selectDropdownValue({
page,
selectType: 'grafanaSelect',
placeholderText: 'Search or filter results...',
value: integrationName,
pressEnterInsteadOfSelectingOption: true,
});
await (
await page.waitForSelector(
`div[data-testid="integrations-table"] table > tbody > tr > td:first-child a >> text=${integrationName}`
)
).click();
};

View file

@ -18,6 +18,10 @@ export const ADMIN_USER_STORAGE_STATE = path.join(__dirname, 'integration-tests/
*/
const config: PlaywrightTestConfig = {
testDir: './integration-tests',
/* Maximum time all the tests can run for. */
globalTimeout: 20 * 60 * 1000, // 20 minutes
/* Maximum time one test can run for. */
timeout: 60 * 1000,
expect: {

View file

@ -12,7 +12,7 @@ interface IntegrationBlockItemProps {
const IntegrationBlockItem: React.FC<IntegrationBlockItemProps> = (props) => {
return (
<div className={cx('blockItem')}>
<div className={cx('blockItem')} data-testid="integration-block-item">
<div className={cx('blockItem__leftDelimitator')} />
<div className={cx('blockItem__content')}>{props.children}</div>
</div>

View file

@ -15,19 +15,5 @@ export const manualAlertFormConfig: { name: string; fields: FormItem[] } = {
label: 'Description',
validation: { required: true },
},
{
name: 'team',
label: 'Assign to team',
description:
'Assigning to the teams allows you to filter Alert Groups and configure their visibility. Go to OnCall -> Settings -> Team and Access Settings for more details',
type: FormItemType.GSelect,
extra: {
modelName: 'grafanaTeamStore',
displayField: 'name',
valueField: 'id',
showSearch: true,
allowClear: true,
},
},
],
};

View file

@ -6,3 +6,23 @@
background: var(--secondary-background);
width: 100%;
}
.responders-list {
list-style-type: none;
margin-bottom: 20px;
width: 100%;
background: var(--background-secondary);
& > li .hover-button {
display: none;
}
& > li:hover .hover-button {
display: inline-flex;
}
& > li {
padding: 10px 12px;
width: 100%;
}
}

View file

@ -1,15 +1,35 @@
import React, { FC, useCallback, useState } from 'react';
import { Button, Drawer, HorizontalGroup, Icon, VerticalGroup } from '@grafana/ui';
import {
Alert,
Button,
Drawer,
Field,
HorizontalGroup,
Icon,
IconButton,
IconName,
Label,
LoadingPlaceholder,
Tooltip,
VerticalGroup,
} from '@grafana/ui';
import cn from 'classnames/bind';
import Block from 'components/GBlock/Block';
import GForm from 'components/GForm/GForm';
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import EscalationVariants from 'containers/EscalationVariants/EscalationVariants';
import { prepareForUpdate } from 'containers/EscalationVariants/EscalationVariants.helpers';
import { Alert } from 'models/alertgroup/alertgroup.types';
import GrafanaTeamSelect from 'containers/GrafanaTeamSelect/GrafanaTeamSelect';
import TeamName from 'containers/TeamName/TeamName';
import { AlertReceiveChannelStore } from 'models/alert_receive_channel/alert_receive_channel';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { Alert as AlertType } from 'models/alertgroup/alertgroup.types';
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
import IntegrationHelper from 'pages/integration/Integration.helper';
import { useStore } from 'state/useStore';
import { openWarningNotification } from 'utils';
import { manualAlertFormConfig } from './ManualAlertGroup.config';
@ -17,7 +37,8 @@ import styles from './ManualAlertGroup.module.css';
interface ManualAlertGroupProps {
onHide: () => void;
onCreate: (id: Alert['pk']) => void;
onCreate: (id: AlertType['pk']) => void;
alertReceiveChannelStore: AlertReceiveChannelStore;
}
const cx = cn.bind(styles);
@ -26,13 +47,24 @@ const ManualAlertGroup: FC<ManualAlertGroupProps> = (props) => {
const store = useStore();
const [userResponders, setUserResponders] = useState([]);
const [scheduleResponders, setScheduleResponders] = useState([]);
const { onHide, onCreate } = props;
const data = { team: store.userStore.currentUser?.current_team };
const { onHide, onCreate, alertReceiveChannelStore } = props;
const [selectedTeamId, setSelectedTeam] = useState<GrafanaTeam['id']>();
const [selectedTeamDirectPaging, setSelectedTeamDirectPaging] = useState<AlertReceiveChannel>();
const [directPagingLoading, setdirectPagingLoading] = useState<boolean>();
const [chatOpsAvailableChannels, setChatopsAvailableChannels] = useState<any>();
const data = {};
const handleFormSubmit = async (data) => {
if (selectedTeamId === undefined) {
openWarningNotification('Select team first');
return;
}
store.directPagingStore
.createManualAlertRule(prepareForUpdate(userResponders, scheduleResponders, data))
.then(({ alert_group_id: id }: { alert_group_id: Alert['pk'] }) => {
.createManualAlertRule(prepareForUpdate(userResponders, scheduleResponders, { team: selectedTeamId, ...data }))
.then(({ alert_group_id: id }: { alert_group_id: AlertType['pk'] }) => {
onCreate(id);
})
.finally(() => {
@ -40,48 +72,174 @@ const ManualAlertGroup: FC<ManualAlertGroupProps> = (props) => {
});
};
const onUpdateSelectedTeam = async (selectedTeamId: GrafanaTeam['id']) => {
setdirectPagingLoading(true);
setSelectedTeamDirectPaging(null);
setSelectedTeam(selectedTeamId);
await alertReceiveChannelStore.updateItems({ team: selectedTeamId, integration: 'direct_paging' });
const directPagingAlertReceiveChannel =
alertReceiveChannelStore.getSearchResult() && alertReceiveChannelStore.getSearchResult()[0];
if (directPagingAlertReceiveChannel) {
setSelectedTeamDirectPaging(directPagingAlertReceiveChannel);
await alertReceiveChannelStore.updateChannelFilters(directPagingAlertReceiveChannel.id);
await store.slackChannelStore.updateItems();
// The code below is used to get the unique available chotops channels for all routes in integraion
// This is the workaround for IntegrationHelper.getChatOpsChannels, it should be moved to the helper
const filterIds = alertReceiveChannelStore.channelFilterIds[directPagingAlertReceiveChannel.id];
let availableChannels = [];
let channelKeys = new Set();
filterIds.map((channelFilterId) => {
IntegrationHelper.getChatOpsChannels(alertReceiveChannelStore.channelFilters[channelFilterId], store)
.filter((channel) => channel)
.map((channel) => {
if (!channelKeys.has(channel.name + channel.icon)) {
availableChannels.push(channel);
channelKeys.add(channel.name + channel.icon);
}
});
});
setChatopsAvailableChannels(Array.from(availableChannels));
}
setdirectPagingLoading(false);
};
const onUpdateEscalationVariants = useCallback(
(value) => {
setUserResponders(value.userResponders);
setScheduleResponders(value.scheduleResponders);
},
[userResponders, scheduleResponders]
);
const DirectPagingIntegrationVariants = ({ selectedTeamId, selectedTeamDirectPaging, chatOpsAvailableChannels }) => {
const escalationChainsExist = selectedTeamDirectPaging?.connected_escalations_chains_count === 0;
return (
<VerticalGroup>
{selectedTeamId &&
(directPagingLoading ? (
<LoadingPlaceholder text="Loading..." />
) : selectedTeamDirectPaging ? (
<VerticalGroup>
<Label>Team will be notified according to the integration settings:</Label>
<ul className={cx('responders-list')}>
<li>
<HorizontalGroup justify="space-between">
<HorizontalGroup>
{escalationChainsExist && (
<Tooltip content="Integration doesn't have connected escalation policies">
<Icon name="exclamation-triangle" style={{ color: 'var(--warning-text-color)' }} />
</Tooltip>
)}
<Text>{selectedTeamDirectPaging.verbal_name}</Text>
</HorizontalGroup>
<HorizontalGroup>
<Text type="secondary">Team:</Text>
<TeamName team={store.grafanaTeamStore.items[selectedTeamId]} />
</HorizontalGroup>
<HorizontalGroup>
{chatOpsAvailableChannels && (
<>
<Text type="secondary">ChatOps:</Text>{' '}
{chatOpsAvailableChannels.map(
(chatOpsChannel: { name: string; icon: IconName }, chatOpsIndex) => (
<div
key={`${chatOpsChannel.name}-${chatOpsIndex}`}
className={cx({
'u-margin-right-xs': chatOpsIndex !== chatOpsAvailableChannels.length,
})}
>
{chatOpsChannel.icon && <Icon name={chatOpsChannel.icon} className={cx('icon')} />}
<Text type="primary">{chatOpsChannel.name || ''}</Text>
</div>
)
)}
</>
)}
</HorizontalGroup>
<HorizontalGroup>
<PluginLink target="_blank" query={{ page: 'integrations', id: selectedTeamDirectPaging.id }}>
<IconButton
tooltip="Open integration in new tab"
style={{ color: 'var(--always-gray)' }}
name="external-link-alt"
/>
</PluginLink>
</HorizontalGroup>
</HorizontalGroup>
</li>
</ul>
{(escalationChainsExist || !chatOpsAvailableChannels) && (
<Alert severity="warning" title="Possible notification miss">
<VerticalGroup>
{escalationChainsExist && (
<Text>
Integration doesn't have connected escalation policies. Consider adding responders manually by
user or by email
</Text>
)}
{!chatOpsAvailableChannels && (
<Text>Integration doesn't have connected ChatOps channels in messengers.</Text>
)}
</VerticalGroup>
</Alert>
)}
</VerticalGroup>
) : (
<Alert severity="warning" title={"This team doesn't have the the Direct Paging integration yet"}>
<HorizontalGroup>
<Text>
Empty integration for this team will be created automatically. Consider selecting responders by
schedule or user below
</Text>
</HorizontalGroup>
</Alert>
))}
</VerticalGroup>
);
};
const submitButtonDisabled = !(
selectedTeamId &&
(selectedTeamDirectPaging || userResponders.length || scheduleResponders.length)
);
return (
<>
<Drawer scrollableContent title="Create manual alert group" onClose={onHide} closeOnMaskClick={false}>
<VerticalGroup spacing="lg">
<EscalationVariants
value={{ userResponders, scheduleResponders }}
onUpdateEscalationVariants={onUpdateEscalationVariants}
/>
<GForm form={manualAlertFormConfig} data={data} onSubmit={handleFormSubmit} />
{store.teamStore.currentTeam.slack_team_identity && (
<Block className={cx('info-block')}>
<Icon name="info-circle" />{' '}
<Text type="secondary">
The alert group will also be posted to #{store.teamStore.currentTeam?.slack_channel?.display_name} Slack
channel.
</Text>
</Block>
)}
<HorizontalGroup justify="flex-end">
<Button variant="secondary" onClick={onHide}>
Cancel
</Button>
<Button
type="submit"
form={manualAlertFormConfig.name}
disabled={!userResponders.length && !scheduleResponders.length}
>
Create
</Button>
</HorizontalGroup>
</VerticalGroup>
</Drawer>
</>
<Drawer
scrollableContent
title="Create manual alert group (Direct Paging)"
onClose={onHide}
closeOnMaskClick={false}
width="70%"
>
<VerticalGroup>
<GForm form={manualAlertFormConfig} data={data} onSubmit={handleFormSubmit} />
<Field label="Select team you want to notify">
<GrafanaTeamSelect withoutModal onSelect={onUpdateSelectedTeam} />
</Field>
<DirectPagingIntegrationVariants
selectedTeamId={selectedTeamId}
selectedTeamDirectPaging={selectedTeamDirectPaging}
chatOpsAvailableChannels={chatOpsAvailableChannels}
/>
<EscalationVariants
value={{ userResponders, scheduleResponders }}
onUpdateEscalationVariants={onUpdateEscalationVariants}
variant={'secondary'}
withLabels={true}
/>
<HorizontalGroup justify="flex-end">
<Button variant="secondary" onClick={onHide}>
Cancel
</Button>
<Button type="submit" form={manualAlertFormConfig.name} disabled={submitButtonDisabled}>
Create
</Button>
</HorizontalGroup>
</VerticalGroup>
</Drawer>
);
};

View file

@ -1,7 +1,3 @@
$score-primary: rgba(27, 133, 94, 0.15);
$score-warning: rgba(245, 183, 61, 0.18);
$score-danger: rgba(209, 14, 92, 0.15);
.root {
display: flex;
flex-direction: row;
@ -18,17 +14,17 @@ $score-danger: rgba(209, 14, 92, 0.15);
padding: 4px 10px 3px 10px;
&--danger {
background-color: $score-danger;
background-color: var(--tag-background-danger);
color: var(--tag-text-danger);
border: 1px solid var(--tag-border-danger);
}
&--warning {
background-color: $score-warning;
background-color: var(--tag-background-warning);
color: var(--tag-text-warning);
border: 1px solid var(--tag-border-warning);
}
&--primary {
background-color: $score-primary;
background-color: var(--tag-background-success);
color: var(--tag-text-success);
border: 1px solid var(--tag-border-success);
}

View file

@ -24,7 +24,10 @@ interface TooltipBadgeProps {
const cx = cn.bind(styles);
const TooltipBadge: FC<TooltipBadgeProps> = (props) => {
const { borderType, text, tooltipTitle, tooltipContent, onHover, addPadding, icon, customIcon, className } = props;
const { borderType, text, tooltipTitle, tooltipContent, onHover, addPadding, icon, customIcon, className, ...rest } =
props;
const testId = rest['data-testid'];
return (
<Tooltip
@ -48,10 +51,18 @@ const TooltipBadge: FC<TooltipBadgeProps> = (props) => {
className
)}
onMouseEnter={onHover}
{...(testId ? { 'data-testid': testId } : {})}
>
<HorizontalGroup spacing="xs">
{renderIcon()}
{text && <Text className={cx('element__text', { [`element__text--${borderType}`]: true })}>{text}</Text>}
{text && (
<Text
className={cx('element__text', { [`element__text--${borderType}`]: true })}
{...(testId ? { 'data-testid': `${testId}-text` } : {})}
>
{text}
</Text>
)}
</HorizontalGroup>
</div>
</Tooltip>

View file

@ -51,6 +51,8 @@
display: flex;
flex-direction: column;
gap: 1px;
max-height: calc(100vh - 600px);
overflow: scroll;
}
.user {

View file

@ -33,8 +33,6 @@ const SortableHandleHoc = SortableHandle(DragHandle);
const UserGroups = (props: UserGroupsProps) => {
const { value, onChange, isMultipleGroups, renderUser, showError, disabled } = props;
const rootRef = useRef<HTMLDivElement>();
const handleAddUserGroup = useCallback(() => {
onChange([...value, []]);
}, [value]);
@ -97,17 +95,6 @@ const UserGroups = (props: UserGroupsProps) => {
};
};
useEffect(() => {
const container = rootRef.current.parentElement.parentElement.parentElement;
const containerParent = container.parentElement;
containerParent.scroll({
left: 0,
top: container.scrollHeight,
behavior: 'smooth',
});
}, [value]);
const renderItem = (item: Item, index: number) => (
<li className={cx('user')}>
{renderUser(item.data)}
@ -123,7 +110,7 @@ const UserGroups = (props: UserGroupsProps) => {
);
return (
<div className={cx('root')} ref={rootRef}>
<div className={cx('root')}>
<VerticalGroup>
{!disabled && (
<RemoteSelect
@ -173,8 +160,20 @@ interface SortableListProps {
const SortableList = SortableContainer<SortableListProps>(
({ items, handleAddGroup, isMultipleGroups, renderItem, allowCreate }) => {
const listRef = useRef<HTMLUListElement>();
useEffect(() => {
const container = listRef.current;
container.scroll({
left: 0,
top: container.scrollHeight,
behavior: 'smooth',
});
}, [items]);
return (
<ul className={cx('groups')}>
<ul className={cx('groups')} ref={listRef}>
{items.map((item, index) =>
item.type === 'item' ? (
<SortableItem key={item.key} index={index}>

View file

@ -5,8 +5,8 @@ import { Select } from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import { getTzOffsetString } from 'models/timezone/timezone.helpers';
import { Timezone, tzs } from 'models/timezone/timezone.types';
import { getTzOffsetString, allTimezones } from 'models/timezone/timezone.helpers';
import { Timezone } from 'models/timezone/timezone.types';
import { User } from 'models/user/user.types';
import styles from './UserTimezoneSelect.module.css';
@ -111,7 +111,7 @@ const UserTimezoneSelect: FC<UserTimezoneSelectProps> = (props) => {
const handleCreateOption = useCallback(
(value: string) => {
const matched = tzs.find((tz) => tz.toLowerCase().includes(value.toLowerCase()));
const matched = allTimezones.find((tz) => tz.toLowerCase().includes(value.toLowerCase()));
if (matched) {
const now = dayjs().tz(matched);
const utcOffset = now.utcOffset();
@ -150,7 +150,7 @@ const UserTimezoneSelect: FC<UserTimezoneSelectProps> = (props) => {
allowCustomValue
onCreateOption={handleCreateOption}
formatCreateLabel={(input: string) => {
const matched = tzs.find((tz) => tz.toLowerCase().includes(input.toLowerCase()));
const matched = allTimezones.find((tz) => tz.toLowerCase().includes(input.toLowerCase()));
const now = dayjs().tz(matched);
if (matched) {
return `Select ${getTzOffsetString(now)} (${matched})`;

View file

@ -16,6 +16,7 @@ export const WithContextMenu: React.FC<WithContextMenuProps> = ({
renderMenuItems,
forceIsOpen = false,
focusOnOpen = true,
...rest
}) => {
const [isMenuOpen, setIsMenuOpen] = useState(false || forceIsOpen);
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
@ -36,7 +37,7 @@ export const WithContextMenu: React.FC<WithContextMenuProps> = ({
}, []);
return (
<>
<div {...rest}>
{children({
openMenu: (e) => {
setIsMenuOpen(true);
@ -56,6 +57,6 @@ export const WithContextMenu: React.FC<WithContextMenuProps> = ({
focusOnOpen={focusOnOpen}
/>
)}
</>
</div>
);
};

View file

@ -16,13 +16,13 @@ interface WorkingHoursProps {
startMoment: dayjs.Dayjs;
duration: number; // in seconds
className: string;
strong?: boolean;
light?: boolean;
}
const cx = cn.bind(styles);
const WorkingHours: FC<WorkingHoursProps> = (props) => {
const { timezone, workingHours = default_working_hours, startMoment, duration, className, strong = false } = props;
const { timezone, workingHours = default_working_hours, startMoment, duration, className, light } = props;
const endMoment = startMoment.add(duration, 'seconds');
@ -38,10 +38,10 @@ const WorkingHours: FC<WorkingHoursProps> = (props) => {
<svg version="1.1" width="100%" height="28px" xmlns="http://www.w3.org/2000/svg" className={className}>
<defs>
<pattern id="stripes" patternUnits="userSpaceOnUse" width="10" height="10" patternTransform="rotate(45)">
<line x1="0" y="0" x2="0" y2="10" stroke="rgba(17, 18, 23, 0.15)" strokeWidth="10" />
<line x1="0" y="0" x2="0" y2="10" stroke="var(--working-hours-shades-color)" strokeWidth="10" />
</pattern>
<pattern id="stripes_strong" patternUnits="userSpaceOnUse" width="10" height="10" patternTransform="rotate(45)">
<line x1="0" y="0" x2="0" y2="10" stroke="rgba(17, 18, 23, 0.2)" strokeWidth="10" />
<pattern id="stripes_light" patternUnits="userSpaceOnUse" width="10" height="10" patternTransform="rotate(45)">
<line x1="0" y="0" x2="0" y2="10" stroke="var(--working-hours-shades-color-light)" strokeWidth="10" />
</pattern>
</defs>
{nonWorkingMoments &&
@ -56,7 +56,7 @@ const WorkingHours: FC<WorkingHoursProps> = (props) => {
y={0}
width={`${(diff * 100) / duration}%`}
height="100%"
fill={`${strong ? 'url(#stripes_strong)' : 'url(#stripes)'}`}
fill={light ? 'url(#stripes_light)' : 'url(#stripes)'}
/>
);
})}

View file

@ -68,7 +68,7 @@ const CreateAlertReceiveChannelContainer = observer((props: CreateAlertReceiveCh
label="Assign to team"
description="OnCall teams allow you to organize integrations so you can filter and set up access. "
>
<GrafanaTeamSelect withoutModal onSelect={setSelectedTeam} />
<GrafanaTeamSelect withoutModal onSelect={setSelectedTeam} defaultValue={user.current_team} />
</Field>
</div>
<hr />

View file

@ -26,6 +26,7 @@ export interface EscalationVariantsProps {
variant?: 'secondary' | 'primary';
hideSelected?: boolean;
disabled?: boolean;
withLabels?: boolean;
}
const EscalationVariants = observer(
@ -35,6 +36,7 @@ const EscalationVariants = observer(
variant = 'primary',
hideSelected = false,
disabled,
withLabels = false,
}: EscalationVariantsProps) => {
const [showEscalationVariants, setShowEscalationVariants] = useState(false);
@ -103,7 +105,7 @@ const EscalationVariants = observer(
<div className={cx('body')}>
{!hideSelected && Boolean(value.userResponders.length || value.scheduleResponders.length) && (
<>
<Label>Responders:</Label>
<Label>Additional Responders will be notified immediately:</Label>
<ul className={cx('responders-list')}>
{value.userResponders.map((responder, index) => (
<UserResponder
@ -125,6 +127,7 @@ const EscalationVariants = observer(
</>
)}
<div className={cx('assign-responders-button')}>
{withLabels && <Label>Assign additional responders from other teams (by user or by schedule)</Label>}
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
<Button
icon="users-alt"
@ -134,7 +137,7 @@ const EscalationVariants = observer(
setShowEscalationVariants(true);
}}
>
Add responders
Invite additional responders
</Button>
</WithPermissionControlTooltip>
</div>
@ -230,11 +233,11 @@ const UserResponder = ({ important, data, onImportantChange, handleDelete }) =>
}}
onChange={onImportantChange}
/>
<Text type="secondary">notification chain</Text>
<Text type="secondary">notification policies</Text>
</HorizontalGroup>
) : (
<HorizontalGroup>
<Tooltip content="User doesn't have configured notification chains">
<Tooltip content="User doesn't have configured notification policies">
<Icon name="exclamation-triangle" style={{ color: 'var(--error-text-color)' }} />
</Tooltip>
</HorizontalGroup>

View file

@ -18,15 +18,16 @@ interface GrafanaTeamSelectProps {
onSelect: (id: GrafanaTeam['id']) => void;
onHide?: () => void;
withoutModal?: boolean;
defaultValue?: GrafanaTeam['id'];
}
const GrafanaTeamSelect = observer(({ onSelect, onHide, withoutModal }: GrafanaTeamSelectProps) => {
const GrafanaTeamSelect = observer(({ onSelect, onHide, withoutModal, defaultValue }: GrafanaTeamSelectProps) => {
const store = useStore();
const { userStore, grafanaTeamStore } = store;
const user = userStore.currentUser;
const [selectedTeam, setSelectedTeam] = useState<GrafanaTeam['id']>(user.current_team);
const [selectedTeam, setSelectedTeam] = useState<GrafanaTeam['id']>(defaultValue);
const grafanaTeams = grafanaTeamStore.getSearchResult();

View file

@ -145,7 +145,9 @@ const CollapsedIntegrationRouteDisplay: React.FC<CollapsedIntegrationRouteDispla
<div className={cx('icon-exclamation')}>
<Icon name="exclamation-triangle" />
</div>
<Text type="primary">Not selected</Text>
<Text type="primary" data-testid="integration-escalation-chain-not-selected">
Not selected
</Text>
</div>
)}
</div>

View file

@ -62,8 +62,12 @@ const IntegrationForm = observer((props: IntegrationFormProps) => {
.then((response) => {
history.push(`${PLUGIN_ROOT}/integrations/${response.id}`);
})
.catch(() => {
openErrorNotification('Something went wrong, please try again later.');
.catch((err) => {
if (err.response?.data?.length > 0) {
openErrorNotification(err.response.data);
} else {
openErrorNotification('Something went wrong, please try again later.');
}
})
: alertReceiveChannelStore.update(id, data)
).then(() => {

View file

@ -4,7 +4,7 @@ import { SelectableValue } from '@grafana/data';
import Emoji from 'react-emoji-render';
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
import { MaintenanceMode } from 'models/maintenance/maintenance.types';
import { MaintenanceMode } from 'models/alert_receive_channel/alert_receive_channel.types';
export const form: { name: string; fields: FormItem[] } = {
name: 'Maintenance',
@ -31,6 +31,7 @@ export const form: { name: string; fields: FormItem[] } = {
validation: { required: true },
normalize: (value) => value,
extra: {
placeholder: 'Choose mode',
options: [
{
value: MaintenanceMode.Debug,
@ -50,6 +51,7 @@ export const form: { name: string; fields: FormItem[] } = {
type: FormItemType.Select,
validation: { required: true },
extra: {
placeholder: 'Choose duration',
options: [
{
value: 3600,

View file

@ -8,7 +8,6 @@ import { observer } from 'mobx-react';
import GForm from 'components/GForm/GForm';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { MaintenanceType } from 'models/maintenance/maintenance.types';
import { useStore } from 'state/useStore';
import { openNotification, showApiError } from 'utils';
import { UserActions } from 'utils/authorization';
@ -21,7 +20,6 @@ const cx = cn.bind(styles);
interface MaintenanceFormProps {
initialData: {
type?: MaintenanceType;
alert_receive_channel_id?: AlertReceiveChannel['id'];
disabled?: boolean;
};
@ -35,23 +33,22 @@ const MaintenanceForm = observer((props: MaintenanceFormProps) => {
const store = useStore();
const { maintenanceStore } = store;
const { alertReceiveChannelStore } = store;
const handleSubmit = useCallback((data) => {
maintenanceStore
.startMaintenanceMode(
MaintenanceType.alert_receive_channel,
const handleSubmit = useCallback(async (data) => {
try {
await alertReceiveChannelStore.startMaintenanceMode(
initialData.alert_receive_channel_id,
data.mode,
data.duration,
data.alert_receive_channel_id
)
.then(() => {
onHide();
onUpdate();
data.duration
);
openNotification('Maintenance has been started');
})
.catch(showApiError);
onHide();
onUpdate();
openNotification('Maintenance has been started');
} catch (err) {
showApiError(err);
}
}, []);
if (initialData.disabled) {
@ -65,7 +62,7 @@ const MaintenanceForm = observer((props: MaintenanceFormProps) => {
return (
<Drawer width="640px" scrollableContent title="Start Maintenance Mode" onClose={onHide} closeOnMaskClick={false}>
<div className={cx('content')}>
<div className={cx('content')} data-testid="maintenance-mode-drawer">
<VerticalGroup>
Start maintenance mode when performing scheduled maintenance or updates on the infrastructure, which may
trigger false alarms.
@ -75,7 +72,7 @@ const MaintenanceForm = observer((props: MaintenanceFormProps) => {
Cancel
</Button>
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
<Button form={form.name} type="submit">
<Button form={form.name} type="submit" data-testid="create-maintenance-button">
Start
</Button>
</WithPermissionControlTooltip>

View file

@ -1,19 +0,0 @@
.root {
display: block;
}
.root > * {
margin-bottom: 10px !important;
}
.root > *:not(:last-child) {
margin-right: 10px !important;
}
.root .search {
width: 400px;
}
.select {
min-width: 300px;
}

View file

@ -1,91 +0,0 @@
import React, { ChangeEvent, useCallback, useMemo, useState } from 'react';
import { RawTimeRange } from '@grafana/data';
import { HorizontalGroup, Input, TimeRangeInput } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import RemoteSelect from 'containers/RemoteSelect/RemoteSelect';
import styles from './OrganizationLogFilters.module.css';
const cx = cn.bind(styles);
interface OrganizationLogFiltersProps {
value: any;
onChange: (filters: any) => void;
className?: string;
}
const OrganizationLogFilters = observer((props: OrganizationLogFiltersProps) => {
const { value, onChange } = props;
const [createAtRaw, setCreateAtRaw] = useState<RawTimeRange>();
const onSearchTermChangeCallback = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const filters = {
...value,
search: e.currentTarget.value,
};
onChange(filters);
},
[onChange, value]
);
const getChangeHandler = (field: string) => {
return (newValue: any) => {
onChange({
...value,
[field]: newValue,
});
};
};
const handleChangeCreatedAt = useCallback(
(filter) => {
onChange({
...value,
created_at: filter.from._isValid && filter.to._isValid ? [filter.from, filter.to] : undefined,
});
setCreateAtRaw(filter.raw);
},
[value]
);
const createdAtValue = useMemo(() => {
if (value['created_at']) {
return { from: value['created_at'][0].toDate(), to: value['created_at'][1].toDate(), raw: createAtRaw };
}
return { from: undefined, to: undefined, raw: undefined };
}, [value]);
return (
<div className={cx('root')}>
<HorizontalGroup wrap>
<Input
className={cx('search')}
placeholder="Search..."
value={value['search']}
onChange={onSearchTermChangeCallback}
/>
<TimeRangeInput value={createdAtValue} onChange={handleChangeCreatedAt} hideTimeZone clearable />
<RemoteSelect
allowClear
isMulti
showSearch={false}
className={cx('select')}
value={value['labels']}
onChange={getChangeHandler('labels')}
href={'/organization_logs/label_options/'}
fieldToShow="display_name"
placeholder="Select labels..."
/>
</HorizontalGroup>
</div>
);
});
export default OrganizationLogFilters;

View file

@ -1,6 +1,4 @@
.body {
max-height: calc(100vh - 300px);
overflow: scroll;
margin: 15px -15px;
padding: 15px 0;
border-top: var(--border-medium);

View file

@ -77,7 +77,7 @@ const UsersTimezones: FC<UsersTimezonesProps> = (props) => {
return (
<div className={cx('root')}>
<WorkingHours
strong
light
startMoment={currentMoment.startOf('day')}
duration={24 * 60 * 60}
timezone={userStore.currentUser.timezone}

View file

@ -1 +0,0 @@
declare module 'slack-markdown';

View file

@ -1,8 +0,0 @@
<svg width="15px" height="15px" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0.877075 7.49988C0.877075 3.84219 3.84222 0.877045 7.49991 0.877045C11.1576 0.877045 14.1227 3.84219 14.1227 7.49988C14.1227 11.1575 11.1576 14.1227 7.49991 14.1227C3.84222 14.1227 0.877075 11.1575 0.877075 7.49988ZM7.49991 1.82704C4.36689 1.82704 1.82708 4.36686 1.82708 7.49988C1.82708 10.6329 4.36689 13.1727 7.49991 13.1727C10.6329 13.1727 13.1727 10.6329 13.1727 7.49988C13.1727 4.36686 10.6329 1.82704 7.49991 1.82704ZM9.85358 5.14644C10.0488 5.3417 10.0488 5.65829 9.85358 5.85355L8.20713 7.49999L9.85358 9.14644C10.0488 9.3417 10.0488 9.65829 9.85358 9.85355C9.65832 10.0488 9.34173 10.0488 9.14647 9.85355L7.50002 8.2071L5.85358 9.85355C5.65832 10.0488 5.34173 10.0488 5.14647 9.85355C4.95121 9.65829 4.95121 9.3417 5.14647 9.14644L6.79292 7.49999L5.14647 5.85355C4.95121 5.65829 4.95121 5.3417 5.14647 5.14644C5.34173 4.95118 5.65832 4.95118 5.85358 5.14644L7.50002 6.79289L9.14647 5.14644C9.34173 4.95118 9.65832 4.95118 9.85358 5.14644Z"
fill="currentColor"
/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.6 KiB

View file

@ -1,3 +0,0 @@
<svg width="16" height="14" viewBox="0 0 16 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.9963 1.71183C13.2187 0.932253 12.188 0.456443 11.0903 0.370349C9.99262 0.284256 8.90033 0.593556 8.01074 1.24238C7.07745 0.548204 5.91581 0.233431 4.75973 0.36145C3.60365 0.489468 2.53901 1.05077 1.78021 1.93232C1.02141 2.81387 0.624803 3.95018 0.670266 5.11244C0.715728 6.2747 1.19988 7.37656 2.02522 8.19615L6.58038 12.7586C6.96182 13.134 7.47556 13.3444 8.01074 13.3444C8.54593 13.3444 9.05966 13.134 9.44111 12.7586L13.9963 8.19615C14.8527 7.33446 15.3334 6.1689 15.3334 4.95399C15.3334 3.73908 14.8527 2.57352 13.9963 1.71183Z" fill="#6CCF8E"/>
</svg>

Before

Width:  |  Height:  |  Size: 666 B

View file

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
version="1.1"
width="16"
height="16"
id="Capa_1"
xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
fill="currentColor"
viewBox="0 0 490.4 490.4"
>
<g>
<path
d="M222.5,453.7c6.1,6.1,14.3,9.5,22.9,9.5c8.5,0,16.9-3.5,22.9-9.5L448,274c27.3-27.3,42.3-63.6,42.4-102.1
c0-38.6-15-74.9-42.3-102.2S384.6,27.4,346,27.4c-37.9,0-73.6,14.5-100.7,40.9c-27.2-26.5-63-41.1-101-41.1
c-38.5,0-74.7,15-102,42.2C15,96.7,0,133,0,171.6c0,38.5,15.1,74.8,42.4,102.1L222.5,453.7z M59.7,86.8
c22.6-22.6,52.7-35.1,84.7-35.1s62.2,12.5,84.9,35.2l7.4,7.4c2.3,2.3,5.4,3.6,8.7,3.6l0,0c3.2,0,6.4-1.3,8.7-3.6l7.2-7.2
c22.7-22.7,52.8-35.2,84.9-35.2c32,0,62.1,12.5,84.7,35.1c22.7,22.7,35.1,52.8,35.1,84.8s-12.5,62.1-35.2,84.8L251,436.4
c-2.9,2.9-8.2,2.9-11.2,0l-180-180c-22.7-22.7-35.2-52.8-35.2-84.8C24.6,139.6,37.1,109.5,59.7,86.8z"
/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1 KiB

View file

@ -1,3 +0,0 @@
<svg width="16" height="14" viewBox="0 0 16 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.9863 1.68881C13.2081 0.910402 12.1764 0.437137 11.0788 0.355084C9.98115 0.273032 8.89053 0.587642 8.00525 1.2417C7.07266 0.548043 5.91188 0.233506 4.75667 0.361429C3.60145 0.489352 2.53761 1.05023 1.77937 1.93112C1.02114 2.81201 0.624834 3.94748 0.670263 5.10887C0.715692 6.27026 1.19948 7.3713 2.02421 8.19027L7.48484 13.6582C7.55298 13.7269 7.63405 13.7815 7.72337 13.8187C7.81269 13.8559 7.90849 13.875 8.00525 13.875C8.10202 13.875 8.19782 13.8559 8.28714 13.8187C8.37646 13.7815 8.45752 13.7269 8.52566 13.6582L13.9863 8.19027C14.4134 7.76348 14.7522 7.25672 14.9833 6.69894C15.2144 6.14117 15.3334 5.54331 15.3334 4.93954C15.3334 4.33577 15.2144 3.73792 14.9833 3.18014C14.7522 2.62237 14.4134 2.1156 13.9863 1.68881ZM12.9528 7.14945L8.00525 12.097L3.0577 7.14945C2.48995 6.59497 2.15463 5.84511 2.11989 5.05227C2.08516 4.25944 2.35361 3.48313 2.87069 2.88111C3.38777 2.2791 4.11467 1.89656 4.90367 1.81124C5.69266 1.72592 6.48454 1.94422 7.11836 2.42178L5.86498 5.35367C5.82142 5.44916 5.79888 5.5529 5.79888 5.65785C5.79888 5.76281 5.82142 5.86654 5.86498 5.96204C5.91153 6.05742 5.97824 6.14156 6.0605 6.20863C6.14276 6.27571 6.2386 6.32412 6.34141 6.35051L8.37174 6.86359L7.34558 8.98188C7.30305 9.06848 7.27803 9.16262 7.27197 9.2589C7.2659 9.35519 7.2789 9.45173 7.31023 9.54298C7.34155 9.63423 7.39058 9.7184 7.45451 9.79066C7.51844 9.86292 7.596 9.92184 7.68275 9.96406C7.78326 10.0127 7.89357 10.0378 8.00525 10.0374C8.14263 10.0376 8.27731 9.9993 8.39395 9.92672C8.51059 9.85415 8.60448 9.75026 8.66493 9.62689L10.1309 6.69501C10.1778 6.59656 10.2021 6.48888 10.2021 6.37983C10.2021 6.27078 10.1778 6.16311 10.1309 6.06465C10.0824 5.96637 10.0136 5.87957 9.92887 5.81004C9.84418 5.74052 9.74562 5.68988 9.63978 5.66152L7.56547 5.14111L8.62828 2.65634C9.2241 2.08092 10.0241 1.76577 10.8523 1.7802C11.6805 1.79463 12.469 2.13747 13.0444 2.7333C13.6198 3.32912 13.935 4.12912 13.9206 4.95731C13.9061 5.7855 13.5633 6.57404 12.9675 7.14945H12.9528Z" fill="#FF5286"/>
</svg>

Before

Width:  |  Height:  |  Size: 2 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 330 KiB

View file

@ -1,19 +0,0 @@
import axios from 'axios';
import qs from 'query-string';
import plugin from '../../package.json'; // eslint-disable-line
// Send version header to all requests
axios.defaults.headers.common['X-OnCall-Plugin-Version'] = plugin?.version;
axios.interceptors.request.use(function (config) {
// Do something before request is sent
config.paramsSerializer = (params) => {
return qs.stringify(params, { arrayFormat: 'none' });
};
return {
...config,
withCredentials: true,
};
});

View file

@ -20,6 +20,7 @@ import {
AlertReceiveChannel,
AlertReceiveChannelOption,
AlertReceiveChannelCounters,
MaintenanceMode,
} from './alert_receive_channel.types';
export class AlertReceiveChannelStore extends BaseStore {
@ -456,4 +457,18 @@ export class AlertReceiveChannelStore extends BaseStore {
this.counters = counters;
}
startMaintenanceMode = (id: AlertReceiveChannel['id'], mode: MaintenanceMode, duration: number): Promise<void> =>
makeRequest<null>(`${this.path}${id}/start_maintenance/`, {
method: 'POST',
data: {
mode,
duration,
},
});
stopMaintenanceMode = (id: AlertReceiveChannel['id']) =>
makeRequest<null>(`${this.path}${id}/stop_maintenance/`, {
method: 'POST',
});
}

View file

@ -1,14 +0,0 @@
import { ReactElement } from 'react';
export interface CardData {
title: string;
subtitle?: string;
value: number;
icon: ReactElement;
rate?: string;
selectable?: boolean;
selected?: boolean;
id?: any;
lg?: string;
xl?: string;
}

View file

@ -1,111 +0,0 @@
import { action, observable } from 'mobx';
import BaseStore from 'models/base_store';
import { makeRequest } from 'network';
import { RootStore } from 'state';
import { CurlerCheck, CurlerCheckStats, CurlerCheckPing } from './curler.types';
export class CurlerStore extends BaseStore {
@observable.shallow
items: { [uuid: string]: CurlerCheck } = {};
@observable.shallow
searchResult: { [key: string]: Array<CurlerCheck['uuid']> } = {};
@observable.shallow
stats: { [uuid: string]: CurlerCheckStats } = {};
@observable.shallow
pings: {
[uuid: string]: { [date: string]: CurlerCheckPing[] };
} = {};
constructor(rootStore: RootStore) {
super(rootStore);
this.path = '/curler/checks/';
}
@action
async updateById(uuid: CurlerCheck['uuid']) {
const response = await this.getById(uuid);
this.items = {
...this.items,
[uuid]: response,
};
}
@action
async updateItems(query = '', tzOffset: number) {
const results = await makeRequest(`${this.path}`, {
params: { search: query, offset: tzOffset },
});
this.items = {
...this.items,
...results.reduce(
(acc: { [key: string]: CurlerCheck }, item: CurlerCheck) => ({
...acc,
[item.uuid]: item,
}),
{}
),
};
this.searchResult = {
...this.searchResult,
[query]: results.map((item: CurlerCheck) => item.uuid),
};
}
getSearchResult(query = '') {
if (!this.searchResult[query]) {
return undefined;
}
return this.searchResult[query].map((checkId: CurlerCheck['uuid']) => this.items[checkId]);
}
@action
async updateStats(uuid: CurlerCheck['uuid'], tzOffset: number) {
const response = await makeRequest(`${this.path}${uuid}/stats/`, {
params: { offset: tzOffset },
});
this.stats = {
...this.stats,
[uuid]: response,
};
}
@action
async updatePings(uuid: CurlerCheck['uuid'], date: string, tzOffset: number) {
const response = await makeRequest(`${this.path}${uuid}/pings/`, {
params: { created_at__date: date, offset: tzOffset },
});
this.pings = {
...this.pings,
[uuid]: {
...this.pings[uuid],
[date]: response,
},
};
}
@action
async pause(uuid: CurlerCheck['uuid']) {
return await makeRequest(`${this.path}${uuid}/pause/`, {
method: 'PUT',
}).catch(this.onApiError);
}
@action
async unpause(uuid: CurlerCheck['uuid']) {
return await makeRequest(`${this.path}${uuid}/unpause/`, {
method: 'PUT',
}).catch(this.onApiError);
}
}

View file

@ -1,26 +0,0 @@
export interface CurlerCheck {
uuid: string;
created_at: string;
alert_receive_channel: number;
url: string;
frequency: number;
last_pings: CurlerCheckPing[];
paused: boolean;
}
export interface CurlerCheckPing {
id: number;
url: string;
created_at: string;
http_status: number;
latency_ms: number;
successful: boolean;
exception_reason: string | null;
}
export interface CurlerCheckStats {
max_latency: number | null;
min_latency: number | null;
uptime: number;
last_downtime: string;
}

View file

@ -1,57 +0,0 @@
export interface CurrentSubscriptionDTO {
uuid: string;
created_at: string;
activation_expire_at: any;
charges: string;
stats: {
result_credit: string;
result_active_users_number: string;
month: string;
};
subscription_plan: string;
users_limit: string;
current_stats: {
// users_on_call: User['pk'][];
// users_1_weeks_ago: User['pk'][];
// users_2_weeks_ago: User['pk'][];
// users_3_weeks_ago: User['pk'][];
users_on_call: any[];
users_1_weeks_ago: any[];
users_2_weeks_ago: any[];
users_3_weeks_ago: any[];
active_users_count: number;
estimate_credit: number;
is_billing_exists: boolean;
active_plan: string;
expires_at: string;
paid_up_users: number;
active_users: number;
admins: number;
users: number;
active_users_history: Array<{
month: string;
active_users_amount: number;
}>;
billing_history: Array<{
date: string;
plan: number;
paid_up_users_amount: number;
charges: string;
active_users: number;
billing_statement: string;
planned_next_period: boolean;
}>;
usage_statistics: {
users_on_call: string[];
users_1_weeks_ago: string[];
users_2_weeks_ago: string[];
users_3_weeks_ago: string[];
active_users_count: number;
estimate_credit: number;
};
};
}

View file

@ -1,23 +0,0 @@
import { action, observable } from 'mobx';
import BaseStore from 'models/base_store';
import { makeRequest } from 'network';
import { RootStore } from 'state';
import { Subscription } from './current_subscription.types';
export class CurrentSubscriptionStore extends BaseStore {
@observable
currentSubscription?: Subscription;
constructor(rootStore: RootStore) {
super(rootStore);
this.path = '/current_subscription/';
}
@action
async updateCurrentSubscription() {
this.currentSubscription = await makeRequest(this.path, {});
}
}

View file

@ -1,86 +0,0 @@
interface UpdateLicenseOption {
per_month: {
price: number;
product_id: number;
};
per_year: {
price: number;
product_id: number;
};
}
export interface Subscription {
uuid: string;
created_at: string;
activation_expire_at: any;
charges: string;
stats: {
result_credit: string;
result_active_users_number: string;
month: string;
};
subscription_plan: string;
active_plan: string;
expires_at: string;
active_users: string[];
stakeholders: string[];
active_users_count: number;
users_limit: number;
stakeholders_count: number;
stakeholders_limit: number;
show_stakeholders_in_violation_message: boolean;
update_licence_options: {
business: UpdateLicenseOption;
team: UpdateLicenseOption;
};
current_team_primary_key: number;
current_user_primary_key: number;
trial_days_left: number;
current_stats: {
users_on_call: any[];
users_1_weeks_ago: any[];
users_2_weeks_ago: any[];
users_3_weeks_ago: any[];
active_users_count: number;
estimate_credit: number;
is_billing_exists: boolean;
active_plan: string;
expires_at: string;
paid_up_users: number;
active_users: number;
admins: number;
users: number;
active_users_history: Array<{
month: string;
active_users_amount: number;
}>;
billing_history: Array<{
date: string;
plan: number;
paid_up_users_amount: number;
charges: string;
active_users: number;
billing_statement: string;
planned_next_period: boolean;
}>;
usage_statistics: {
users_on_call: string[];
users_1_weeks_ago: string[];
users_2_weeks_ago: string[];
users_3_weeks_ago: string[];
active_users_count: number;
estimate_credit: number;
};
};
}

View file

@ -1,6 +0,0 @@
export interface IntegrationsListDTO {
docs_url: string;
logo_url: string;
id: string;
name: string;
}

View file

@ -1,10 +0,0 @@
export interface Leader {
acknowledged_count: number;
avatar: string;
average_response_time: string;
average_response_time_verbal: string;
invited_count: number;
resolved_count: number;
user: string;
username: string;
}

View file

@ -1,55 +0,0 @@
import { action, observable } from 'mobx';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import BaseStore from 'models/base_store';
import { makeRequest } from 'network';
import { RootStore } from 'state';
import { Maintenance, MaintenanceMode, MaintenanceType } from './maintenance.types';
export class MaintenanceStore extends BaseStore {
@observable.shallow
maintenances?: Maintenance[];
constructor(rootStore: RootStore) {
super(rootStore);
this.path = '/maintenance/';
}
@action
async updateMaintenances() {
this.maintenances = await this.getAll();
}
@action
async startMaintenanceMode(
type: MaintenanceType,
mode: MaintenanceMode,
duration: number,
alertReceiveChannelId?: AlertReceiveChannel['id']
) {
return await makeRequest(`/start_maintenance/`, {
method: 'POST',
data: {
type,
mode,
duration,
alert_receive_channel_id: alertReceiveChannelId,
},
withCredentials: true,
});
}
@action
async stopMaintenanceMode(type: MaintenanceType, alertReceiveChannelId: AlertReceiveChannel['id']) {
return await makeRequest(`/stop_maintenance/`, {
method: 'POST',
data: {
type,
alert_receive_channel_id: alertReceiveChannelId,
},
withCredentials: true,
});
}
}

View file

@ -1,19 +0,0 @@
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
export enum MaintenanceType {
alert_receive_channel = 'alert_receive_channel',
organization = 'organization',
}
export enum MaintenanceMode {
Debug,
Maintenance,
}
export interface Maintenance {
alert_receive_channel_id: AlertReceiveChannel['id'];
type: MaintenanceType;
maintenance_mode: MaintenanceMode;
maintenance_till_timestamp: number;
started_at_timestamp: number;
}

View file

@ -1,60 +0,0 @@
import { action, observable } from 'mobx';
import BaseStore from 'models/base_store';
import { makeRequest } from 'network';
import { RootStore } from 'state';
import { OrganizationLog } from './organization_log.types';
export class OrganizationLogStore extends BaseStore {
@observable.shallow
items: { [id: string]: OrganizationLog } = {};
@observable.shallow
searchResult?: {
total: number;
page: number;
results: Array<OrganizationLog['id']>;
};
constructor(rootStore: RootStore) {
super(rootStore);
this.path = '/organization_logs/';
}
@action
async updateItems(query = '', page: number, filters?: any) {
const { results, count } = await makeRequest(`${this.path}`, {
params: { search: query, page, ...filters },
});
this.items = {
...this.items,
...results.reduce(
(acc: { [key: string]: OrganizationLog }, item: OrganizationLog) => ({
...acc,
[item.id]: item,
}),
{}
),
};
this.searchResult = {
total: count,
page,
results: results.map((item: OrganizationLog) => item.id),
};
}
getSearchResult() {
if (!this.searchResult) {
return undefined;
}
return {
...this.searchResult,
results: this.searchResult.results.map((id: OrganizationLog['id']) => this.items[id]),
};
}
}

View file

@ -1,10 +0,0 @@
import { User } from 'models/user/user.types';
export interface OrganizationLog {
id: string;
author: Partial<User>;
type: number;
created_at: string;
description: string;
labels: string[];
}

View file

@ -1,6 +1,6 @@
import dayjs from 'dayjs';
const tzs = [
export const allTimezones = [
'Africa/Abidjan',
'Africa/Accra',
'Africa/Addis_Ababa',
@ -595,10 +595,6 @@ const tzs = [
'Zulu',
];
export const getRandomTimezone = () => {
return tzs[Math.floor(Math.random() * tzs.length)];
};
export const getTzOffsetString = (moment: dayjs.Dayjs) => {
const userOffset = moment.utcOffset();
const userOffsetHours = userOffset / 60;

View file

@ -1,596 +1,3 @@
export const tzs: string[] = [
'Africa/Abidjan',
'Africa/Accra',
'Africa/Addis_Ababa',
'Africa/Algiers',
'Africa/Asmara',
'Africa/Asmera',
'Africa/Bamako',
'Africa/Bangui',
'Africa/Banjul',
'Africa/Bissau',
'Africa/Blantyre',
'Africa/Brazzaville',
'Africa/Bujumbura',
'Africa/Cairo',
'Africa/Casablanca',
'Africa/Ceuta',
'Africa/Conakry',
'Africa/Dakar',
'Africa/Dar_es_Salaam',
'Africa/Djibouti',
'Africa/Douala',
'Africa/El_Aaiun',
'Africa/Freetown',
'Africa/Gaborone',
'Africa/Harare',
'Africa/Johannesburg',
'Africa/Juba',
'Africa/Kampala',
'Africa/Khartoum',
'Africa/Kigali',
'Africa/Kinshasa',
'Africa/Lagos',
'Africa/Libreville',
'Africa/Lome',
'Africa/Luanda',
'Africa/Lubumbashi',
'Africa/Lusaka',
'Africa/Malabo',
'Africa/Maputo',
'Africa/Maseru',
'Africa/Mbabane',
'Africa/Mogadishu',
'Africa/Monrovia',
'Africa/Nairobi',
'Africa/Ndjamena',
'Africa/Niamey',
'Africa/Nouakchott',
'Africa/Ouagadougou',
'Africa/Porto-Novo',
'Africa/Sao_Tome',
'Africa/Timbuktu',
'Africa/Tripoli',
'Africa/Tunis',
'Africa/Windhoek',
'America/Adak',
'America/Anchorage',
'America/Anguilla',
'America/Antigua',
'America/Araguaina',
'America/Argentina/Buenos_Aires',
'America/Argentina/Catamarca',
'America/Argentina/ComodRivadavia',
'America/Argentina/Cordoba',
'America/Argentina/Jujuy',
'America/Argentina/La_Rioja',
'America/Argentina/Mendoza',
'America/Argentina/Rio_Gallegos',
'America/Argentina/Salta',
'America/Argentina/San_Juan',
'America/Argentina/San_Luis',
'America/Argentina/Tucuman',
'America/Argentina/Ushuaia',
'America/Aruba',
'America/Asuncion',
'America/Atikokan',
'America/Atka',
'America/Bahia',
'America/Bahia_Banderas',
'America/Barbados',
'America/Belem',
'America/Belize',
'America/Blanc-Sablon',
'America/Boa_Vista',
'America/Bogota',
'America/Boise',
'America/Buenos_Aires',
'America/Cambridge_Bay',
'America/Campo_Grande',
'America/Cancun',
'America/Caracas',
'America/Catamarca',
'America/Cayenne',
'America/Cayman',
'America/Chicago',
'America/Chihuahua',
'America/Coral_Harbour',
'America/Cordoba',
'America/Costa_Rica',
'America/Creston',
'America/Cuiaba',
'America/Curacao',
'America/Danmarkshavn',
'America/Dawson',
'America/Dawson_Creek',
'America/Denver',
'America/Detroit',
'America/Dominica',
'America/Edmonton',
'America/Eirunepe',
'America/El_Salvador',
'America/Ensenada',
'America/Fort_Nelson',
'America/Fort_Wayne',
'America/Fortaleza',
'America/Glace_Bay',
'America/Godthab',
'America/Goose_Bay',
'America/Grand_Turk',
'America/Grenada',
'America/Guadeloupe',
'America/Guatemala',
'America/Guayaquil',
'America/Guyana',
'America/Halifax',
'America/Havana',
'America/Hermosillo',
'America/Indiana/Indianapolis',
'America/Indiana/Knox',
'America/Indiana/Marengo',
'America/Indiana/Petersburg',
'America/Indiana/Tell_City',
'America/Indiana/Vevay',
'America/Indiana/Vincennes',
'America/Indiana/Winamac',
'America/Indianapolis',
'America/Inuvik',
'America/Iqaluit',
'America/Jamaica',
'America/Jujuy',
'America/Juneau',
'America/Kentucky/Louisville',
'America/Kentucky/Monticello',
'America/Knox_IN',
'America/Kralendijk',
'America/La_Paz',
'America/Lima',
'America/Los_Angeles',
'America/Louisville',
'America/Lower_Princes',
'America/Maceio',
'America/Managua',
'America/Manaus',
'America/Marigot',
'America/Martinique',
'America/Matamoros',
'America/Mazatlan',
'America/Mendoza',
'America/Menominee',
'America/Merida',
'America/Metlakatla',
'America/Mexico_City',
'America/Miquelon',
'America/Moncton',
'America/Monterrey',
'America/Montevideo',
'America/Montreal',
'America/Montserrat',
'America/Nassau',
'America/New_York',
'America/Nipigon',
'America/Nome',
'America/Noronha',
'America/North_Dakota/Beulah',
'America/North_Dakota/Center',
'America/North_Dakota/New_Salem',
'America/Ojinaga',
'America/Panama',
'America/Pangnirtung',
'America/Paramaribo',
'America/Phoenix',
'America/Port-au-Prince',
'America/Port_of_Spain',
'America/Porto_Acre',
'America/Porto_Velho',
'America/Puerto_Rico',
'America/Punta_Arenas',
'America/Rainy_River',
'America/Rankin_Inlet',
'America/Recife',
'America/Regina',
'America/Resolute',
'America/Rio_Branco',
'America/Rosario',
'America/Santa_Isabel',
'America/Santarem',
'America/Santiago',
'America/Santo_Domingo',
'America/Sao_Paulo',
'America/Scoresbysund',
'America/Shiprock',
'America/Sitka',
'America/St_Barthelemy',
'America/St_Johns',
'America/St_Kitts',
'America/St_Lucia',
'America/St_Thomas',
'America/St_Vincent',
'America/Swift_Current',
'America/Tegucigalpa',
'America/Thule',
'America/Thunder_Bay',
'America/Tijuana',
'America/Toronto',
'America/Tortola',
'America/Vancouver',
'America/Virgin',
'America/Whitehorse',
'America/Winnipeg',
'America/Yakutat',
'America/Yellowknife',
'Antarctica/Casey',
'Antarctica/Davis',
'Antarctica/DumontDUrville',
'Antarctica/Macquarie',
'Antarctica/Mawson',
'Antarctica/McMurdo',
'Antarctica/Palmer',
'Antarctica/Rothera',
'Antarctica/South_Pole',
'Antarctica/Syowa',
'Antarctica/Troll',
'Antarctica/Vostok',
'Arctic/Longyearbyen',
'Asia/Aden',
'Asia/Almaty',
'Asia/Amman',
'Asia/Anadyr',
'Asia/Aqtau',
'Asia/Aqtobe',
'Asia/Ashgabat',
'Asia/Ashkhabad',
'Asia/Atyrau',
'Asia/Baghdad',
'Asia/Bahrain',
'Asia/Baku',
'Asia/Bangkok',
'Asia/Barnaul',
'Asia/Beirut',
'Asia/Bishkek',
'Asia/Brunei',
'Asia/Calcutta',
'Asia/Chita',
'Asia/Choibalsan',
'Asia/Chongqing',
'Asia/Chungking',
'Asia/Colombo',
'Asia/Dacca',
'Asia/Damascus',
'Asia/Dhaka',
'Asia/Dili',
'Asia/Dubai',
'Asia/Dushanbe',
'Asia/Famagusta',
'Asia/Gaza',
'Asia/Harbin',
'Asia/Hebron',
'Asia/Ho_Chi_Minh',
'Asia/Hong_Kong',
'Asia/Hovd',
'Asia/Irkutsk',
'Asia/Istanbul',
'Asia/Jakarta',
'Asia/Jayapura',
'Asia/Jerusalem',
'Asia/Kabul',
'Asia/Kamchatka',
'Asia/Karachi',
'Asia/Kashgar',
'Asia/Kathmandu',
'Asia/Katmandu',
'Asia/Khandyga',
'Asia/Kolkata',
'Asia/Krasnoyarsk',
'Asia/Kuala_Lumpur',
'Asia/Kuching',
'Asia/Kuwait',
'Asia/Macao',
'Asia/Macau',
'Asia/Magadan',
'Asia/Makassar',
'Asia/Manila',
'Asia/Muscat',
'Asia/Nicosia',
'Asia/Novokuznetsk',
'Asia/Novosibirsk',
'Asia/Omsk',
'Asia/Oral',
'Asia/Phnom_Penh',
'Asia/Pontianak',
'Asia/Pyongyang',
'Asia/Qatar',
'Asia/Qyzylorda',
'Asia/Rangoon',
'Asia/Riyadh',
'Asia/Saigon',
'Asia/Sakhalin',
'Asia/Samarkand',
'Asia/Seoul',
'Asia/Shanghai',
'Asia/Singapore',
'Asia/Srednekolymsk',
'Asia/Taipei',
'Asia/Tashkent',
'Asia/Tbilisi',
'Asia/Tehran',
'Asia/Tel_Aviv',
'Asia/Thimbu',
'Asia/Thimphu',
'Asia/Tokyo',
'Asia/Tomsk',
'Asia/Ujung_Pandang',
'Asia/Ulaanbaatar',
'Asia/Ulan_Bator',
'Asia/Urumqi',
'Asia/Ust-Nera',
'Asia/Vientiane',
'Asia/Vladivostok',
'Asia/Yakutsk',
'Asia/Yangon',
'Asia/Yekaterinburg',
'Asia/Yerevan',
'Atlantic/Azores',
'Atlantic/Bermuda',
'Atlantic/Canary',
'Atlantic/Cape_Verde',
'Atlantic/Faeroe',
'Atlantic/Faroe',
'Atlantic/Jan_Mayen',
'Atlantic/Madeira',
'Atlantic/Reykjavik',
'Atlantic/South_Georgia',
'Atlantic/St_Helena',
'Atlantic/Stanley',
'Australia/ACT',
'Australia/Adelaide',
'Australia/Brisbane',
'Australia/Broken_Hill',
'Australia/Canberra',
'Australia/Currie',
'Australia/Darwin',
'Australia/Eucla',
'Australia/Hobart',
'Australia/LHI',
'Australia/Lindeman',
'Australia/Lord_Howe',
'Australia/Melbourne',
'Australia/NSW',
'Australia/North',
'Australia/Perth',
'Australia/Queensland',
'Australia/South',
'Australia/Sydney',
'Australia/Tasmania',
'Australia/Victoria',
'Australia/West',
'Australia/Yancowinna',
'Brazil/Acre',
'Brazil/DeNoronha',
'Brazil/East',
'Brazil/West',
'CET',
'CST6CDT',
'Canada/Atlantic',
'Canada/Central',
'Canada/Eastern',
'Canada/Mountain',
'Canada/Newfoundland',
'Canada/Pacific',
'Canada/Saskatchewan',
'Canada/Yukon',
'Chile/Continental',
'Chile/EasterIsland',
'Cuba',
'EET',
'EST',
'EST5EDT',
'Egypt',
'Eire',
'Etc/GMT',
'Etc/GMT+0',
'Etc/GMT+1',
'Etc/GMT+10',
'Etc/GMT+11',
'Etc/GMT+12',
'Etc/GMT+2',
'Etc/GMT+3',
'Etc/GMT+4',
'Etc/GMT+5',
'Etc/GMT+6',
'Etc/GMT+7',
'Etc/GMT+8',
'Etc/GMT+9',
'Etc/GMT-0',
'Etc/GMT-1',
'Etc/GMT-10',
'Etc/GMT-11',
'Etc/GMT-12',
'Etc/GMT-13',
'Etc/GMT-14',
'Etc/GMT-2',
'Etc/GMT-3',
'Etc/GMT-4',
'Etc/GMT-5',
'Etc/GMT-6',
'Etc/GMT-7',
'Etc/GMT-8',
'Etc/GMT-9',
'Etc/GMT0',
'Etc/Greenwich',
'Etc/UCT',
'Etc/UTC',
'Etc/Universal',
'Etc/Zulu',
'Europe/Amsterdam',
'Europe/Andorra',
'Europe/Astrakhan',
'Europe/Athens',
'Europe/Belfast',
'Europe/Belgrade',
'Europe/Berlin',
'Europe/Bratislava',
'Europe/Brussels',
'Europe/Bucharest',
'Europe/Budapest',
'Europe/Busingen',
'Europe/Chisinau',
'Europe/Copenhagen',
'Europe/Dublin',
'Europe/Gibraltar',
'Europe/Guernsey',
'Europe/Helsinki',
'Europe/Isle_of_Man',
'Europe/Istanbul',
'Europe/Jersey',
'Europe/Kaliningrad',
'Europe/Kiev',
'Europe/Kirov',
'Europe/Lisbon',
'Europe/Ljubljana',
'Europe/London',
'Europe/Luxembourg',
'Europe/Madrid',
'Europe/Malta',
'Europe/Mariehamn',
'Europe/Minsk',
'Europe/Monaco',
'Europe/Moscow',
'Europe/Nicosia',
'Europe/Oslo',
'Europe/Paris',
'Europe/Podgorica',
'Europe/Prague',
'Europe/Riga',
'Europe/Rome',
'Europe/Samara',
'Europe/San_Marino',
'Europe/Sarajevo',
'Europe/Saratov',
'Europe/Simferopol',
'Europe/Skopje',
'Europe/Sofia',
'Europe/Stockholm',
'Europe/Tallinn',
'Europe/Tirane',
'Europe/Tiraspol',
'Europe/Ulyanovsk',
'Europe/Uzhgorod',
'Europe/Vaduz',
'Europe/Vatican',
'Europe/Vienna',
'Europe/Vilnius',
'Europe/Volgograd',
'Europe/Warsaw',
'Europe/Zagreb',
'Europe/Zaporozhye',
'Europe/Zurich',
'GB',
'GB-Eire',
'GMT',
'GMT+0',
'GMT-0',
'GMT0',
'Greenwich',
'HST',
'Hongkong',
'Iceland',
'Indian/Antananarivo',
'Indian/Chagos',
'Indian/Christmas',
'Indian/Cocos',
'Indian/Comoro',
'Indian/Kerguelen',
'Indian/Mahe',
'Indian/Maldives',
'Indian/Mauritius',
'Indian/Mayotte',
'Indian/Reunion',
'Iran',
'Israel',
'Jamaica',
'Japan',
'Kwajalein',
'Libya',
'MET',
'MST',
'MST7MDT',
'Mexico/BajaNorte',
'Mexico/BajaSur',
'Mexico/General',
'NZ',
'NZ-CHAT',
'Navajo',
'PRC',
'PST8PDT',
'Pacific/Apia',
'Pacific/Auckland',
'Pacific/Bougainville',
'Pacific/Chatham',
'Pacific/Chuuk',
'Pacific/Easter',
'Pacific/Efate',
'Pacific/Enderbury',
'Pacific/Fakaofo',
'Pacific/Fiji',
'Pacific/Funafuti',
'Pacific/Galapagos',
'Pacific/Gambier',
'Pacific/Guadalcanal',
'Pacific/Guam',
'Pacific/Honolulu',
'Pacific/Johnston',
'Pacific/Kiritimati',
'Pacific/Kosrae',
'Pacific/Kwajalein',
'Pacific/Majuro',
'Pacific/Marquesas',
'Pacific/Midway',
'Pacific/Nauru',
'Pacific/Niue',
'Pacific/Norfolk',
'Pacific/Noumea',
'Pacific/Pago_Pago',
'Pacific/Palau',
'Pacific/Pitcairn',
'Pacific/Pohnpei',
'Pacific/Ponape',
'Pacific/Port_Moresby',
'Pacific/Rarotonga',
'Pacific/Saipan',
'Pacific/Samoa',
'Pacific/Tahiti',
'Pacific/Tarawa',
'Pacific/Tongatapu',
'Pacific/Truk',
'Pacific/Wake',
'Pacific/Wallis',
'Pacific/Yap',
'Poland',
'Portugal',
'ROC',
'ROK',
'Singapore',
'Turkey',
'UCT',
'US/Alaska',
'US/Aleutian',
'US/Arizona',
'US/Central',
'US/East-Indiana',
'US/Eastern',
'US/Hawaii',
'US/Indiana-Starke',
'US/Michigan',
'US/Mountain',
'US/Pacific',
'US/Pacific-New',
'US/Samoa',
'UTC',
'Universal',
'W-SU',
'WET',
'Zulu',
];
import { allTimezones } from './timezone.helpers';
export type Timezone = (typeof tzs)[number];
export type Timezone = (typeof allTimezones)[number];

View file

@ -1,67 +0,0 @@
import { action, observable } from 'mobx';
import moment from 'moment-timezone';
import BaseStore from 'models/base_store';
import { makeRequest } from 'network';
import { RootStore } from 'state';
import { Webinar } from './webinar.types';
export class WebinarStore extends BaseStore {
@observable.shallow
searchResult?: { [key: string]: Array<Webinar['id']> };
@observable.shallow
items?: { [id: string]: Webinar };
constructor(rootStore: RootStore) {
super(rootStore);
this.path = '/webinars/';
}
@action
async subscribe(id: Webinar['id']) {
return await makeRequest(`/webinars/${id}/subscribe/`, {
method: 'POST',
withCredentials: true,
});
}
async updateItems(query = '') {
const result = await this.getAll();
this.items = {
...this.items,
...result.reduce(
(acc: { [key: number]: Webinar }, item: Webinar) => ({
...acc,
[item.id]: item,
}),
{}
),
};
this.searchResult = {
...(this.searchResult || {}),
[query]: result.map((item: Webinar) => item.id),
};
}
getSearchResult(query = '') {
if (!this.searchResult || !this.items) {
return undefined;
}
return this.searchResult[query].map((scheduleId: Webinar['id']) => this.items?.[scheduleId]);
}
getFutureWebinarsCount(): number {
const items = this.getSearchResult();
if (!items) {
return 0;
}
return items.filter((webinar?: Webinar) => moment(webinar?.datetime).isAfter() && !webinar?.subscribed).length;
}
}

View file

@ -1,13 +0,0 @@
import { UserDTO } from 'models/user';
export interface Webinar {
id: string;
title: string;
additional_emails: string[];
datetime: string;
description: string;
image: string;
link: string;
registered_users: Array<UserDTO['pk']>;
subscribed: boolean;
}

View file

@ -111,7 +111,7 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
const { showAddAlertGroupForm } = this.state;
const {
store,
store: { alertGroupStore },
store: { alertGroupStore, alertReceiveChannelStore },
} = this.props;
if (!alertGroupStore.irmPlan && !store.isOpenSource()) {
@ -126,7 +126,7 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
<Text.Title level={3}>Alert Groups</Text.Title>
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
<Button icon="plus" onClick={this.handleOnClickEscalateTo}>
New alert group
New manual alert group
</Button>
</WithPermissionControlTooltip>
</HorizontalGroup>
@ -142,6 +142,7 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
onCreate={(id: Alert['pk']) => {
history.push(`${PLUGIN_ROOT}/alert-groups/${id}`);
}}
alertReceiveChannelStore={alertReceiveChannelStore}
/>
)}
</>

View file

@ -144,13 +144,6 @@ export const pages: { [id: string]: PageDefinition } = [
path: getPath('cloud'),
action: UserActions.OtherSettingsWrite,
},
{
icon: 'gf-logs',
id: 'organization-logs',
text: 'Org Logs',
hideFromTabs: true,
path: getPath('organization-logs'),
},
{
icon: 'cog',
id: 'test',
@ -188,7 +181,6 @@ export const ROUTES = {
outgoing_webhooks_2: ['outgoing_webhooks_2', 'outgoing_webhooks_2/:id', 'outgoing_webhooks_2/:action/:id'],
maintenance: ['maintenance'],
settings: ['settings'],
'organization-logs': ['organization-logs'],
'chat-ops': ['chat-ops'],
'live-settings': ['live-settings'],
cloud: ['cloud'],

View file

@ -58,7 +58,6 @@ import {
} from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertTemplatesDTO } from 'models/alert_templates';
import { ChannelFilter } from 'models/channel_filter';
import { MaintenanceType } from 'models/maintenance/maintenance.types';
import { INTEGRATION_TEMPLATES_LIST } from 'pages/integration/Integration.config';
import IntegrationHelper from 'pages/integration/Integration.helper';
import styles from 'pages/integration/Integration.module.scss';
@ -327,7 +326,7 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
Autoresolve:
</Text>
<Text type="primary">
{IntegrationHelper.truncateLine(templates['resolve_condition_template'] || '')}
{IntegrationHelper.truncateLine(templates['resolve_condition_template'] || 'disabled')}
</Text>
</div>
@ -606,7 +605,7 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
const DemoNotification: React.FC = () => {
return (
<div>
<div data-testid="demo-alert-sent-notification">
Demo alert was generated. Find it on the
<PluginLink query={{ page: 'alert-groups' }}> "Alert Groups" </PluginLink>
page and make sure it didn't freak out your colleagues 😉
@ -727,7 +726,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
alertReceiveChannel,
changeIsTemplateSettingsOpen,
}) => {
const { maintenanceStore, alertReceiveChannelStore, heartbeatStore } = useStore();
const { alertReceiveChannelStore, heartbeatStore } = useStore();
const history = useHistory();
@ -815,6 +814,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
</WithPermissionControlTooltip>
<WithContextMenu
data-testid="integration-settings-context-menu"
renderMenuItems={() => (
<div className={cx('integration__actionsList')} id="integration-menu-options">
<div className={cx('integration__actionItem')} onClick={() => openIntegrationSettings()}>
@ -831,7 +831,11 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
{!alertReceiveChannel.maintenance_till && (
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
<div className={cx('integration__actionItem')} onClick={openStartMaintenance}>
<div
className={cx('integration__actionItem')}
onClick={openStartMaintenance}
data-testid="integration-start-maintenance"
>
<Text type="primary">Start Maintenance</Text>
</div>
</WithPermissionControlTooltip>
@ -862,6 +866,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
),
});
}}
data-testid="integration-stop-maintenance"
>
<Text type="primary">Stop Maintenance</Text>
</div>
@ -941,14 +946,13 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
setMaintenanceData({ disabled: true, alert_receive_channel_id: alertReceiveChannel.id });
}
function onStopMaintenance() {
async function onStopMaintenance() {
setConfirmModal(undefined);
maintenanceStore
.stopMaintenanceMode(MaintenanceType.alert_receive_channel, id)
.then(() => maintenanceStore.updateMaintenances())
.then(() => openNotification('Maintenance has been stopped'))
.then(() => alertReceiveChannelStore.updateItem(alertReceiveChannel.id));
await alertReceiveChannelStore.stopMaintenanceMode(id);
openNotification('Maintenance has been stopped');
await alertReceiveChannelStore.updateItem(id);
}
};
@ -960,6 +964,17 @@ const HowToConnectComponent: React.FC<{ id: AlertReceiveChannel['id'] }> = ({ id
const item = alertReceiveChannelStore.items[id];
const url = item?.integration_url || item?.inbound_email;
const howToConnectTagName = (integration: string) => {
switch (integration) {
case 'direct_paging':
return 'Manual';
case 'email':
return 'Inbound Email';
default:
return 'HTTP Endpoint';
}
};
return (
<IntegrationBlock
noContent={hasAlerts}
@ -972,29 +987,50 @@ const HowToConnectComponent: React.FC<{ id: AlertReceiveChannel['id'] }> = ({ id
className={cx('how-to-connect__tag')}
>
<Text type="primary" size="small" className={cx('radius')}>
{item?.inbound_email ? 'Inbound Email' : 'HTTP Endpoint'}
{howToConnectTagName(item?.integration)}
</Text>
</Tag>
{url && (
<IntegrationInputField
value={url}
className={cx('integration__input-field')}
showExternal={!!item?.integration_url}
/>
{item?.integration === 'direct_paging' ? (
<>
<Text type="secondary">Alert Groups raised manually via Web or ChatOps</Text>
<a
href="https://grafana.com/docs/oncall/latest/integrations/manual"
target="_blank"
rel="noreferrer"
className={cx('u-pull-right')}
>
<Text type="link" size="small">
<HorizontalGroup>
How it works
<Icon name="external-link-alt" />
</HorizontalGroup>
</Text>
</a>
</>
) : (
<>
{url && (
<IntegrationInputField
value={url}
className={cx('integration__input-field')}
showExternal={!!item?.integration_url}
/>
)}
<a
href="https://grafana.com/docs/oncall/latest/integrations/"
target="_blank"
rel="noreferrer"
className={cx('u-pull-right')}
>
<Text type="link" size="small">
<HorizontalGroup>
How to connect
<Icon name="external-link-alt" />
</HorizontalGroup>
</Text>
</a>
</>
)}
<a
href="https://grafana.com/docs/oncall/latest/integrations/"
target="_blank"
rel="noreferrer"
className={cx('u-pull-right')}
>
<Text type="link" size="small">
<HorizontalGroup>
How to connect
<Icon name="external-link-alt" />
</HorizontalGroup>
</Text>
</a>
</div>
}
content={hasAlerts ? null : renderContent()}
@ -1002,12 +1038,20 @@ const HowToConnectComponent: React.FC<{ id: AlertReceiveChannel['id'] }> = ({ id
);
function renderContent() {
const callToAction = () => {
if (item?.integration === 'direct_paging') {
return <Text type={'primary'}>try to raise a demo alert group via Web or Chatops</Text>;
} else {
return item.demo_alert_enabled && <Text type={'primary'}>; try to send a demo alert</Text>;
}
};
return (
<VerticalGroup justify={'flex-start'} spacing={'xs'}>
{!hasAlerts && (
<HorizontalGroup spacing={'xs'}>
<Icon name="fa fa-spinner" size="md" className={cx('loadingPlaceholder')} />
<Text type={'primary'}>No alerts yet; try to send a demo alert</Text>
<Text type={'primary'}>No alerts yet;</Text> {callToAction()}
</HorizontalGroup>
)}
</VerticalGroup>
@ -1063,6 +1107,7 @@ const IntegrationHeader: React.FC<IntegrationHeaderProps> = ({
{alertReceiveChannel.maintenance_till && (
<TooltipBadge
data-testid="maintenance-mode-remaining-time-tooltip"
borderType="primary"
icon="pause"
text={IntegrationHelper.getMaintenanceText(alertReceiveChannel.maintenance_till)}

View file

@ -208,6 +208,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
/>
<GTable
emptyText={this.renderNotFound()}
data-testid="integrations-table"
rowKey="id"
data={results}
columns={columns}

View file

@ -10,3 +10,7 @@
.title {
margin-bottom: var(--title-marginBottom);
}
.info-box {
width: 100%;
}

View file

@ -1,220 +1,37 @@
import React from 'react';
import { Button, VerticalGroup } from '@grafana/ui';
import { Alert } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import moment from 'moment-timezone';
import LegacyNavHeading from 'navbar/LegacyNavHeading';
import Emoji from 'react-emoji-render';
import GTable from 'components/GTable/GTable';
import Text from 'components/Text/Text';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import MaintenanceForm from 'containers/MaintenanceForm/MaintenanceForm';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { getAlertReceiveChannelDisplayName } from 'models/alert_receive_channel/alert_receive_channel.helpers';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { Maintenance, MaintenanceMode, MaintenanceType } from 'models/maintenance/maintenance.types';
import { PageProps, WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import { UserActions } from 'utils/authorization';
import PluginLink from 'components/PluginLink/PluginLink';
import styles from './Maintenance.module.css';
const cx = cn.bind(styles);
interface MaintenancePageProps extends PageProps, WithStoreProps {}
interface MaintenancePageState {
maintenanceData?: {
type?: MaintenanceType;
alert_receive_channel_id?: AlertReceiveChannel['id'];
disabled?: boolean;
};
}
interface MaintenancePageProps {}
@observer
class MaintenancePage extends React.Component<MaintenancePageProps, MaintenancePageState> {
state: MaintenancePageState = {};
async componentDidMount() {
const {
store: { alertReceiveChannelStore },
} = this.props;
this.update().then(this.parseQueryParams);
alertReceiveChannelStore.updateItems().then(() => {
this.forceUpdate();
});
}
componentDidUpdate(prevProps: MaintenancePageProps) {
if (this.props.query.maintenance_type !== prevProps.query.maintenance_type) {
this.parseQueryParams();
}
}
parseQueryParams = () => {
const { query } = this.props;
if ('maintenance_type' in query) {
const preselectedMaintenanceType = query.maintenance_type as MaintenanceType;
const preselectedAlertReceiveChannel = query.alert_receive_channel as AlertReceiveChannel['id'];
this.setState({
maintenanceData: {
type: preselectedMaintenanceType,
alert_receive_channel_id: preselectedAlertReceiveChannel,
},
});
}
};
update = () => {
const { store } = this.props;
const { maintenanceStore } = store;
return maintenanceStore.updateMaintenances();
};
class MaintenancePage extends React.Component<MaintenancePageProps> {
render() {
const { store } = this.props;
const { maintenanceStore } = store;
const { maintenanceData } = this.state;
const data = maintenanceStore?.maintenances;
const columns = [
{
width: 300,
title: 'Integration',
render: this.renderTitle,
key: 'Title',
},
{
width: 200,
title: 'Mode',
render: this.renderMode,
key: 'mode',
},
{
title: 'Progress',
render: this.renderDuration,
key: 'progress',
},
{
title: 'Time limit',
render: this.renderTimer,
key: 'timer',
},
{
width: 100,
key: 'action',
render: this.renderActionButtons,
},
];
return (
<>
<div className={cx('root')}>
<GTable
emptyText={data ? 'No maintenances found' : 'Loading...'}
title={() => (
<div className={cx('header')}>
<div style={{ display: 'flex', alignItems: 'baseline' }}>
<VerticalGroup>
<LegacyNavHeading>
<Text.Title level={3}>Maintenance</Text.Title>
</LegacyNavHeading>
<Text type="secondary" className={cx('title')}>
Mute noisy sources or use for debugging and avoid bothering your colleagues.
</Text>
</VerticalGroup>
</div>
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
<Button
onClick={() => {
this.setState({ maintenanceData: {} });
}}
variant="primary"
icon="plus"
>
New maintenance
</Button>
</WithPermissionControlTooltip>
</div>
)}
rowKey="id"
columns={columns}
data={data}
/>
</div>
{maintenanceData && (
<MaintenanceForm
initialData={maintenanceData}
onUpdate={this.update}
onHide={() => {
this.setState({ maintenanceData: undefined });
}}
/>
)}
<Alert
severity="info"
className={cx('info-box')}
// @ts-ignore
title={
<>
Maintenance mode is now controlled at the{' '}
<PluginLink query={{ page: 'integrations' }}> Integration</PluginLink> level. This page will soon be
removed.
</>
}
/>
</>
);
}
renderTitle = (maintenance: Maintenance) => {
const { store } = this.props;
const { alertReceiveChannelStore } = store;
const alertReceiveChannel = alertReceiveChannelStore.items
? alertReceiveChannelStore.items[maintenance.alert_receive_channel_id]
: undefined;
switch (maintenance.type) {
case MaintenanceType.alert_receive_channel:
return <Emoji text={getAlertReceiveChannelDisplayName(alertReceiveChannel)} />;
case MaintenanceType.organization:
return `${store.teamStore.currentTeam?.name} Team`;
}
};
renderMode = (maintenance: Maintenance) => {
return maintenance.maintenance_mode === MaintenanceMode.Debug ? 'Debug' : 'Maintenance';
};
renderActionButtons = (maintenance: Maintenance) => {
return (
<div className={cx('buttons')}>
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
<WithConfirm title="Are you sure to stop?" confirmText="Stop">
<Button variant="destructive" fill="text" onClick={this.getStopMaintenanceHandler(maintenance)}>
Stop
</Button>
</WithConfirm>
</WithPermissionControlTooltip>
</div>
);
};
renderDuration = (maintenance: Maintenance) => {
const started = moment(maintenance.started_at_timestamp * 1000);
const ended = moment(maintenance.maintenance_till_timestamp * 1000);
return `${started.format('MMM DD, YYYY HH:mm')} - ${ended.format('MMM DD, YYYY hh:mm')}`;
};
renderTimer = (maintenance: Maintenance) => {
return `ends ${moment(maintenance.maintenance_till_timestamp * 1000).fromNow()}`;
};
getStopMaintenanceHandler = (maintenance: Maintenance) => {
const { store } = this.props;
const { maintenanceStore } = store;
return () => {
maintenanceStore.stopMaintenanceMode(maintenance.type, maintenance.alert_receive_channel_id).then(this.update);
};
};
}
export default withMobXProviderContext(MaintenancePage);
export default MaintenancePage;

View file

@ -1,23 +0,0 @@
.header {
display: flex;
justify-content: space-between;
}
.align-top {
vertical-align: top;
}
.no-author {
background: #fff7e6;
}
.root .no-background {
background: none;
}
.root .short-description {
overflow: hidden;
text-overflow: ellipsis;
width: 400px;
white-space: nowrap;
}

View file

@ -1,199 +0,0 @@
import React from 'react';
import { Button, HorizontalGroup, Tag, Tooltip } from '@grafana/ui';
import cn from 'classnames/bind';
import { debounce } from 'lodash-es';
import { observer } from 'mobx-react';
import moment, { Moment } from 'moment-timezone';
import { RouteComponentProps } from 'react-router-dom';
import Avatar from 'components/Avatar/Avatar';
import GTable from 'components/GTable/GTable';
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import OrganizationLogFilters from 'containers/OrganizationLogFilters/OrganizationLogFilters';
import logo from 'img/logo.svg';
import { OrganizationLog } from 'models/organization_log/organization_log.types';
import { WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import sanitize from 'utils/sanitize';
import styles from './OrganizationLog.module.css';
const cx = cn.bind(styles);
interface OrganizationLogProps extends WithStoreProps, RouteComponentProps {}
interface OrganizationLogState {
filters: { [key: string]: any };
page: number;
expandedLogsKeys: string[];
}
const INITIAL_FILTERS = {};
const ITEMS_PER_PAGE = 50;
@observer
class OrganizationLogPage extends React.Component<OrganizationLogProps, OrganizationLogState> {
state: OrganizationLogState = { filters: { ...INITIAL_FILTERS }, page: 1, expandedLogsKeys: [] };
componentDidMount() {
this.refresh();
}
refresh = () => {
const { store } = this.props;
const { filters, page } = this.state;
store.OrganizationLogStore.updateItems('', page, {
...filters,
created_at: filters.created_at
? filters.created_at.map((m: Moment) => m.utc().format('YYYY-MM-DDTHH:mm:ss')).join('/')
: undefined,
});
};
debouncedRefresh = debounce(this.refresh, 500);
render() {
const { filters, expandedLogsKeys } = this.state;
const { store } = this.props;
const { OrganizationLogStore } = store;
const columns = [
{
width: '40%',
title: 'Action',
render: this.renderShortDescription,
key: 'action',
},
{
width: '10%',
title: 'User',
render: this.renderUser,
key: 'user',
},
{
width: '30%',
title: 'Labels',
render: this.renderLabels,
key: 'labels',
},
{
width: '20%',
title: 'Time',
render: this.renderCreatedAt,
key: 'created_at',
},
];
const searchResult: any = OrganizationLogStore.getSearchResult() || {};
const { total, page, results } = searchResult;
const loading = !results;
return (
<div className={cx('root')}>
<OrganizationLogFilters value={filters} onChange={this.handleChangeOrganizationLogFilters} />
<GTable
rowKey="id"
title={() => (
<div className={cx('header')}>
<Text.Title className={cx('users-title')} level={3}>
Organization Logs
</Text.Title>
<Button onClick={this.refresh} icon={loading ? 'fa fa-spinner' : 'sync'}>
Refresh
</Button>
</div>
)}
showHeader={true}
data={results}
loading={loading}
emptyText={results ? 'No logs found' : 'Loading...'}
columns={columns}
pagination={{
page,
total: Math.ceil((total || 0) / ITEMS_PER_PAGE),
onChange: this.handleChangePage,
}}
rowClassName={cx('align-top')}
expandable={{
expandedRowRender: this.renderFullDescription,
expandRowByClick: true,
expandedRowKeys: expandedLogsKeys,
onExpandedRowsChange: this.handleExpandedRowsChange,
}}
/>
</div>
);
}
handleExpandedRowsChange = (expandedRows: string[]) => {
this.setState({ expandedLogsKeys: expandedRows });
};
handleChangePage = (page: number) => {
this.setState({ page }, this.refresh);
};
handleChangeOrganizationLogFilters = (filters: any) => {
this.setState({ filters, page: 1 }, this.debouncedRefresh);
};
renderShortDescription = (item: OrganizationLog) => {
return <div className={cx('short-description')}>{item.description}</div>;
};
renderFullDescription = (item: OrganizationLog) => {
return (
<div
dangerouslySetInnerHTML={{
__html: sanitize(item.description),
}}
/>
);
};
renderUser = (item: OrganizationLog) => {
if (!item.author) {
return (
<Tooltip content="System event">
<Avatar size="large" className={cx('no-background')} src={logo} />
</Tooltip>
);
}
return (
<PluginLink query={{ page: 'users', id: item.author.pk }}>
<Tooltip placement="top" key={item.author.pk} content={item.author.username}>
<span>
<Avatar size="large" src={item.author.avatar} />
</span>
</Tooltip>
</PluginLink>
);
};
renderLabels = (item: OrganizationLog) => {
if (!item.labels) {
return null;
}
return (
<HorizontalGroup wrap>
{item.labels.map((label) => (
<Tag key={label} name={label} />
))}
</HorizontalGroup>
);
};
renderCreatedAt = (item: OrganizationLog) => {
return moment(item.created_at).toString();
};
}
export default withMobXProviderContext(OrganizationLogPage);

View file

@ -2,7 +2,6 @@ import EscalationsChainsPage from 'pages/escalation-chains/EscalationChains';
import IncidentPage from 'pages/incident/Incident';
import IncidentsPage from 'pages/incidents/Incidents';
import MaintenancePage from 'pages/maintenance/Maintenance';
import OrganizationLogPage from 'pages/organization-logs/OrganizationLog';
import OutgoingWebhooks from 'pages/outgoing_webhooks/OutgoingWebhooks';
import OutgoingWebhooks2 from 'pages/outgoing_webhooks_2/OutgoingWebhooks2';
import SchedulePage from 'pages/schedule/Schedule';
@ -73,10 +72,6 @@ export const routes: { [id: string]: NavRoute } = [
component: LiveSettingsPage,
id: 'live-settings',
},
{
component: OrganizationLogPage,
id: 'organization-logs',
},
{
component: CloudPage,
id: 'cloud',

View file

@ -27,7 +27,6 @@ import Incidents from 'pages/incidents/Incidents';
import Integration from 'pages/integration/Integration';
import Integrations from 'pages/integrations/Integrations';
import Maintenance from 'pages/maintenance/Maintenance';
import OrganizationLogPage from 'pages/organization-logs/OrganizationLog';
import OutgoingWebhooks from 'pages/outgoing_webhooks/OutgoingWebhooks';
import OutgoingWebhooks2 from 'pages/outgoing_webhooks_2/OutgoingWebhooks2';
import Schedule from 'pages/schedule/Schedule';
@ -37,7 +36,6 @@ import ChatOps from 'pages/settings/tabs/ChatOps/ChatOps';
import CloudPage from 'pages/settings/tabs/Cloud/CloudPage';
import LiveSettings from 'pages/settings/tabs/LiveSettings/LiveSettingsPage';
import Users from 'pages/users/Users';
import 'interceptors';
import { rootStore } from 'state';
import { useStore } from 'state/useStore';
import { isUserActionAllowed } from 'utils/authorization';
@ -171,14 +169,11 @@ export const Root = observer((props: AppRootProps) => {
<OutgoingWebhooks2 query={query} />
</Route>
<Route path={getRoutesForPage('maintenance')} exact>
<Maintenance query={query} />
<Maintenance />
</Route>
<Route path={getRoutesForPage('settings')} exact>
<SettingsPage />
</Route>
<Route path={getRoutesForPage('organization-logs')} exact>
<OrganizationLogPage />
</Route>
<Route path={getRoutesForPage('chat-ops')} exact>
<ChatOps query={query} />
</Route>

View file

@ -1,16 +0,0 @@
import Cookies from 'js-cookie';
import qs from 'query-string';
const query = qs.parse(window.location.search);
if (query.version === 'nf') {
Cookies.set('nf', '1', { expires: 31, path: '/' });
}
export function getABTestVariant() {
return Cookies.get('ab_test_variant');
}
export function getIsNoFreeExperiment() {
return Cookies.get('nf') === '1';
}

View file

@ -1,5 +0,0 @@
export function pushConvertion(message: any) {
if (window.dataLayer) {
window.dataLayer.push(message);
}
}

View file

@ -1,24 +0,0 @@
import qs from 'query-string';
export function updateQueryParams(params: any, isReplace = false) {
const query = qs.stringify(params);
if (isReplace) {
window.history.replaceState(null, '', `${window.location.pathname}?${query}`);
return;
}
window.history.pushState(null, '', `${window.location.pathname}?${query}`);
}
export function mergeQueryParams(params: any) {
const currentParams = qs.parse(window.location.search);
const newParams = {
...currentParams,
...params,
};
const query = qs.stringify(newParams);
return query;
}

View file

@ -18,8 +18,6 @@ import { FiltersStore } from 'models/filters/filters';
import { GlobalSettingStore } from 'models/global_setting/global_setting';
import { GrafanaTeamStore } from 'models/grafana_team/grafana_team';
import { HeartbeatStore } from 'models/heartbeat/heartbeat';
import { MaintenanceStore } from 'models/maintenance/maintenance';
import { OrganizationLogStore } from 'models/organization_log/organization_log';
import { OutgoingWebhookStore } from 'models/outgoing_webhook/outgoing_webhook';
import { OutgoingWebhook2Store } from 'models/outgoing_webhook_2/outgoing_webhook_2';
import { ResolutionNotesStore } from 'models/resolution_note/resolution_note';
@ -98,13 +96,11 @@ export class RootBaseStore {
slackStore: SlackStore = new SlackStore(this);
slackChannelStore: SlackChannelStore = new SlackChannelStore(this);
heartbeatStore: HeartbeatStore = new HeartbeatStore(this);
maintenanceStore: MaintenanceStore = new MaintenanceStore(this);
scheduleStore: ScheduleStore = new ScheduleStore(this);
userGroupStore: UserGroupStore = new UserGroupStore(this);
alertGroupStore: AlertGroupStore = new AlertGroupStore(this);
resolutionNotesStore: ResolutionNotesStore = new ResolutionNotesStore(this);
apiTokenStore: ApiTokenStore = new ApiTokenStore(this);
OrganizationLogStore: OrganizationLogStore = new OrganizationLogStore(this);
globalSettingStore: GlobalSettingStore = new GlobalSettingStore(this);
filtersStore: FiltersStore = new FiltersStore(this);
// stores

Some files were not shown because too many files have changed in this diff Show more