Merge pull request #2158 from grafana/dev

dev -> main
This commit is contained in:
Joey Orlando 2023-06-12 15:14:33 +02:00 committed by GitHub
commit 130410ccfe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
79 changed files with 1372 additions and 349 deletions

View file

@ -388,7 +388,6 @@ jobs:
--set grafana.env.GF_FEATURE_TOGGLES_ENABLE=topnav \
--set grafana.env.GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS=grafana-oncall-app \
--set-json "grafana.plugins=[]" \
--set-json 'grafana.securityContext={"runAsUser": 0, "runAsGroup": 0, "fsGroup": 0}' \
--set-json 'grafana.extraVolumeMounts=[{"name":"plugins","mountPath":"/var/lib/grafana/plugins/grafana-plugin","hostPath":"/oncall-plugin","readOnly":true}]' \
./helm/oncall

View file

@ -1,7 +1,9 @@
{
"default": true,
"MD013": {
"line_length": "120"
"line_length": "120",
"code_blocks": false,
"tables": false
},
"MD024": {
"siblings_only": true

View file

@ -5,11 +5,18 @@ 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.2.42 (2023-06-12)
### Changed
- Run containers as a non-root user by @alexintech [#2053](https://github.com/grafana/oncall/pull/2053)
- Helm chart: Upgrade helm dependecies, improve local setup [#2144](https://github.com/grafana/oncall/pull/2144)
### Fixed
- Fixed bug on Filters where team param from URL was discarded [#6237](https://github.com/grafana/support-escalations/issues/6237)
- Fix receive channel filter in alert groups API [#2140](https://github.com/grafana/oncall/pull/2140)
- Helm chart: Fix usage of `env` settings as map;
Fix usage of `mariadb.auth.database` and `mariadb.auth.username` for MYSQL env variables by @alexintech [#2146](https://github.com/grafana/oncall/pull/2146)
## v1.2.41 (2023-06-08)
@ -17,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Twilio Provider improvements by @Konstantinov-Innokentii, @mderynck and @joeyorlando
[#2074](https://github.com/grafana/oncall/pull/2074) [#2034](https://github.com/grafana/oncall/pull/2034)
- Run containers as a non-root user by @alexintech [#2053](https://github.com/grafana/oncall/pull/2053)
## v1.2.40 (2023-06-07)

View file

@ -45,17 +45,45 @@ We prepared multiple environments:
```bash
echo "DOMAIN=http://localhost:8080
COMPOSE_PROFILES=with_grafana # Remove this line if you want to use existing grafana
# Remove 'with_grafana' below if you want to use existing grafana
# Add 'with_prometheus' below to optionally enable a local prometheus for oncall metrics
# e.g. COMPOSE_PROFILES=with_grafana,with_prometheus
COMPOSE_PROFILES=with_grafana
# to setup an auth token for prometheus exporter metrics:
# PROMETHEUS_EXPORTER_SECRET=my_random_prometheus_secret
# also, make sure to enable the /metrics endpoint:
# FEATURE_PROMETHEUS_EXPORTER_ENABLED=True
SECRET_KEY=my_random_secret_must_be_more_than_32_characters_long" > .env
```
3. Launch services:
3. (Optional) If you want to enable/setup the prometheus metrics exporter
(besides the changes above), create a `prometheus.yml` file (replacing
`my_random_prometheus_secret` accordingly), next to your `docker-compose.yml`:
```bash
echo "global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: prometheus
metrics_path: /metrics/
authorization:
- credentials: my_random_prometheus_secret
static_configs:
- targets: [\"host.docker.internal:8080\"]" > prometheus.yml
```
NOTE: you will need to setup a Prometheus datasource using `http://prometheus:9090`
as the URL in the Grafana UI.
4. Launch services:
```bash
docker-compose pull && docker-compose up -d
```
4. Go to [OnCall Plugin Configuration](http://localhost:3000/plugins/grafana-oncall-app), using log in credentials
5. Go to [OnCall Plugin Configuration](http://localhost:3000/plugins/grafana-oncall-app), using log in credentials
as defined above: `admin`/`admin` (or find OnCall plugin in configuration->plugins) and connect OnCall _plugin_
with OnCall _backend_:
@ -63,7 +91,7 @@ We prepared multiple environments:
OnCall backend URL: http://engine:8080
```
5. Enjoy! Check our [OSS docs](https://grafana.com/docs/oncall/latest/open-source/) if you want to set up
6. Enjoy! Check our [OSS docs](https://grafana.com/docs/oncall/latest/open-source/) if you want to set up
Slack, Telegram, Twilio or SMS/calls through Grafana Cloud.
## Update version

View file

@ -269,26 +269,18 @@ ERROR: Failed building wheel for cryptography
**Solution:**
<!-- markdownlint-disable MD013 -->
```bash
LDFLAGS="-L$(brew --prefix openssl@1.1)/lib" CFLAGS="-I$(brew --prefix openssl@1.1)/include" pip install `cat engine/requirements.txt | grep cryptography`
```
<!-- markdownlint-enable MD013 -->
### django.db.utils.OperationalError: (1366, "Incorrect string value")
**Problem:**
<!-- markdownlint-disable MD013 -->
```bash
django.db.utils.OperationalError: (1366, "Incorrect string value: '\\xF0\\x9F\\x98\\x8A\\xF0\\x9F...' for column 'cached_name' at row 1")
```
<!-- markdownlint-enable MD013 -->
**Solution:**
Recreate the database with the correct encoding.
@ -321,15 +313,11 @@ $ CDPATH="" make init
When running `make init start`:
<!-- markdownlint-disable MD013 -->
```bash
Error response from daemon: open /var/lib/docker/overlay2/ac57b871108ee1b98ff4455e36d2175eae90cbc7d4c9a54608c0b45cfb7c6da5/committed: is a directory
make: *** [start] Error 1
```
<!-- markdownlint-enable MD013 -->
**Solution:**
clear everything in docker by resetting or:
@ -376,8 +364,6 @@ See solution for "Encountered error while trying to install package - grpcio" [h
This problem seems to occur when running the Celery process, outside of `docker-compose`
(via `make run-backend-celery`), and using a `conda` virtual environment.
<!-- markdownlint-disable MD013 -->
```bash
conda create --name oncall-dev python=3.9.13
conda activate oncall-dev
@ -396,8 +382,6 @@ File "~/oncall/engine/engine/__init__.py", line 5, in <module>
ImportError: dlopen(/opt/homebrew/Caskroom/miniconda/base/envs/oncall-dev/lib/python3.9/site-packages/grpc/_cython/cygrpc.cpython-39-darwin.so, 0x0002): symbol not found in flat namespace '_EVP_DigestSignUpdate'
```
<!-- markdownlint-enable MD013 -->
**Solution:**
[This solution](https://github.com/grpc/grpc/issues/15510#issuecomment-392012594) posted in a GitHub issue thread for

View file

@ -5,6 +5,8 @@ x-environment: &oncall-environment
BROKER_TYPE: redis
BASE_URL: $DOMAIN
SECRET_KEY: $SECRET_KEY
FEATURE_PROMETHEUS_EXPORTER_ENABLED: $FEATURE_PROMETHEUS_EXPORTER_ENABLED
PROMETHEUS_EXPORTER_SECRET: $PROMETHEUS_EXPORTER_SECRET
REDIS_URI: redis://redis:6379/0
DJANGO_SETTINGS_MODULE: settings.hobby
CELERY_WORKER_QUEUE: "default,critical,long,slack,telegram,webhook,retry,celery"
@ -72,6 +74,18 @@ services:
interval: 5s
retries: 10
prometheus:
image: prom/prometheus
hostname: prometheus
restart: always
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus
profiles:
- with_prometheus
grafana:
image: "grafana/${GRAFANA_IMAGE:-grafana:latest}"
restart: always
@ -94,5 +108,6 @@ services:
volumes:
grafana_data:
prometheus_data:
oncall_data:
redis_data:

View file

@ -53,7 +53,7 @@ Rate limited response HTTP status: 429
| Scope | Amount | Time Frame |
| ---------------------------- | :----: | :--------: |
| Alerts from each integration | 300 | 5 minutes |
| Alerts from the whole team | 500 | 5 minutes |
| Alerts from the whole organization | 500 | 5 minutes |
## API rate limits

View file

@ -63,14 +63,10 @@ curl "{{API_URL}}/api/v1/alert_groups/I68T24C13IFW1/" \
}'
```
<!-- markdownlint-disable MD013 -->
| Parameter | Required | Description |
| --------- | :------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode` | No | Default setting is `wipe`. `wipe` will remove the payload of all Grafana OnCall group alerts. This is useful if you sent sensitive data to OnCall. All metadata will remain. `DELETE` will trigger the removal of alert groups, alerts, and all related metadata. It will also remove alert group notifications in Slack and other destinations. |
<!-- markdownlint-enable MD013 -->
> **NOTE:** `DELETE` can take a few moments to delete alert groups because Grafana OnCall interacts with 3rd party APIs
> such as Slack. Please check objects using `GET` to be sure the data is removed.

View file

@ -30,8 +30,6 @@ The above command returns JSON structured in the following way:
}
```
<!-- markdownlint-disable MD013 -->
| Parameter | Required | Description |
| ---------------------------------- | :--------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `escalation_chain_id` | Yes | Each escalation policy is assigned to a specific escalation chain. |
@ -47,8 +45,6 @@ The above command returns JSON structured in the following way:
| `notify_if_time_from` | If type = `notify_if_time_from_to` | UTC time represents the beginning of the time period, for example `09:00:00Z`. |
| `notify_if_time_to` | If type = `notify_if_time_from_to` | UTC time represents the end of the time period, for example `18:00:00Z`. |
<!-- markdownlint-enable MD013 -->
**HTTP request**
`POST {{API_URL}}/api/v1/escalation_policies/`

View file

@ -41,8 +41,6 @@ The above command returns JSON structured in the following way:
}
```
<!-- markdownlint-disable MD013 -->
| Parameter | Unique | Required | Description |
| -------------------------------- | :----: | :--------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | Yes | Yes | On-call shift name. |
@ -53,7 +51,7 @@ The above command returns JSON structured in the following way:
| `start` | No | Yes | Start time of the on-call shift. This parameter takes a date format as `yyyy-MM-dd'T'HH:mm:ss` (for example "2020-09-05T08:00:00"). |
| `duration` | No | Yes | Duration of the event. |
| `frequency` | No | If type = `recurrent_event` or `rolling_users` | One of: `hourly`, `daily`, `weekly`, `monthly`. |
| `interval` | No | Optional | This parameter takes a positive integer that represents the intervals that the recurrence rule repeats. If `frequency` is set, the default assumed value for this will be `1`. |
| `interval` | No | Optional | This parameter takes a positive integer that represents the intervals that the recurrence rule repeats. If `frequency` is set, the default assumed value for this will be `1`. |
| `until` | No | Optional | When the recurrence rule ends (endless if None). This parameter takes a date format as `yyyy-MM-dd'T'HH:mm:ss` (for example "2020-09-05T08:00:00"). |
| `week_start` | No | Optional | Start day of the week in iCal format. One of: `SU` (Sunday), `MO` (Monday), `TU` (Tuesday), `WE` (Wednesday), `TH` (Thursday), `FR` (Friday), `SA` (Saturday). Default: `SU`. |
| `by_day` | No | Optional | List of days in iCal format. Valid values are: `SU`, `MO`, `TU`, `WE`, `TH`, `FR`, `SA`. |
@ -63,8 +61,6 @@ The above command returns JSON structured in the following way:
| `rolling_users` | No | Optional | List of lists with on-call users (for `rolling_users` event type). Grafana OnCall will iterate over lists of users for every time frame specified in `frequency`. For example: there are two lists of users in `rolling_users` : [[Alex, Bob], [Alice]] and `frequency` = `daily` . This means that the first day Alex and Bob will be notified. The next day: Alice. The day after: Alex and Bob again and so on. |
| `start_rotation_from_user_index` | No | Optional | Index of the list of users in `rolling_users`, from which on-call rotation starts. By default, the start index is `0` |
<!-- markdownlint-enable MD013 -->
Please see [RFC 5545](https://tools.ietf.org/html/rfc5545#section-3.3.10) for more information about recurrence rules.
**HTTP request**

View file

@ -29,8 +29,6 @@ The above command returns JSON structured in the following way:
}
```
<!-- markdownlint-disable MD013 -->
| Parameter | Required | Description |
| ----------- | :------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `user_id` | Yes | User ID |
@ -39,8 +37,6 @@ The above command returns JSON structured in the following way:
| `duration` | Optional | A time in secs when type `wait` is chosen for `type`. |
| `important` | Optional | Boolean value indicates if a rule is "important". Default is `false`. |
<!-- markdownlint-enable MD013 -->
**HTTP request**
`POST {{API_URL}}/api/v1/personal_notification_rules/`

View file

@ -44,10 +44,8 @@ Routes allow you to direct different alerts to different messenger channels and
- Alerts for different engineering groups
- Snoozing spam & debugging alerts
<!-- markdownlint-disable MD013 -->
| Parameter | Unique | Required | Description |
|-----------------------| :----: |:--------:|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| --------------------- | :----: | :------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `integration_id` | No | Yes | Each route is assigned to a specific integration. |
| `escalation_chain_id` | No | Yes | Each route is assigned a specific escalation chain. Explicitly pass `null` to create a route without an escalation chain assigned. |
| `routing_type` | Yes | No | Routing type that can be either `jinja2` or `regex`(default value) |
@ -55,8 +53,6 @@ Routes allow you to direct different alerts to different messenger channels and
| `position` | Yes | Optional | Route matching is performed one after another starting from position=`0`. Position=`-1` will put the route to the end of the list before `is_the_last_route`. A new route created with a position of an existing route will move the old route (and all following routes) down in the list. |
| `slack` | Yes | Optional | Dictionary with Slack-specific settings for a route. |
<!-- markdownlint-enable MD013 -->
**HTTP request**
`POST {{API_URL}}/api/v1/routes/`

View file

@ -39,8 +39,6 @@ The above command returns JSON structured in the following way:
}
```
<!-- markdownlint-disable MD013 -->
| Parameter | Unique | Required | Description |
| -------------------- | :----: | :--------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | Yes | Yes | Schedule name. |
@ -52,8 +50,6 @@ The above command returns JSON structured in the following way:
| `slack` | No | Optional | Dictionary with Slack-specific settings for a schedule. Includes `channel_id` and `user_group_id` fields, that take a channel ID and a user group ID from Slack. |
| `shifts` | No | Optional | List of shifts. Used for manually added on-call shifts in Schedules with type `calendar`. |
<!-- markdownlint-enable MD013 -->
**HTTP request**
`POST {{API_URL}}/api/v1/schedules/`

View file

@ -36,16 +36,12 @@ The above command returns JSON structured in the following way:
}
```
<!-- markdownlint-disable MD013 -->
| Parameter | Unique | Description |
| --------- | :----: | :---------------------------------------------------------------------------------------------------- |
| `id` | Yes | User Group ID |
| `type` | No | [Slack-defined user groups](https://slack.com/intl/en-ru/help/articles/212906697-Create-a-user-group) |
| `slack` | No | Metadata retrieved from Slack. |
<!-- markdownlint-enable MD013 -->
**HTTP request**
`GET {{API_URL}}/api/v1/user_groups/`

View file

@ -49,7 +49,7 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializ
heartbeat = serializers.SerializerMethodField()
allow_delete = serializers.SerializerMethodField()
description_short = serializers.CharField(max_length=250, required=False, allow_null=True)
demo_alert_payload = serializers.CharField(source="config.example_payload", read_only=True)
demo_alert_payload = serializers.JSONField(source="config.example_payload", read_only=True)
routes_count = serializers.SerializerMethodField()
connected_escalations_chains_count = serializers.SerializerMethodField()

View file

@ -43,6 +43,33 @@ def alert_group_internal_api_setup(
return user, token, alert_groups
@pytest.mark.django_db
def test_get_filter_by_integration(
alert_group_internal_api_setup, make_alert_receive_channel, make_alert_group, make_user_auth_headers
):
user, token, alert_groups = alert_group_internal_api_setup
ag = alert_groups[0]
# channel filter could be None, but the alert group still belongs to the original integration
ag.channel_filter = None
ag.save()
# make an alert group in other integration
alert_receive_channel = make_alert_receive_channel(user.organization)
make_alert_group(alert_receive_channel)
client = APIClient()
url = reverse("api-internal:alertgroup-list")
response = client.get(
url + f"?integration={ag.channel.public_primary_key}",
format="json",
**make_user_auth_headers(user, token),
)
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 4
@pytest.mark.django_db
def test_get_filter_started_at(alert_group_internal_api_setup, make_user_auth_headers):
user, token, _ = alert_group_internal_api_setup

View file

@ -104,7 +104,7 @@ class AlertGroupFilter(DateRangeFilterMixin, ByTeamModelFieldFilterMixin, ModelF
method=ModelFieldFilterMixin.filter_model_field.__name__,
)
integration = filters.ModelMultipleChoiceFilter(
field_name="channel_filter__alert_receive_channel",
field_name="channel",
queryset=None,
to_field_name="public_primary_key",
method=ModelFieldFilterMixin.filter_model_field.__name__,

View file

@ -0,0 +1,24 @@
import pytest
from django.urls import reverse
from rest_framework.test import APIClient
@pytest.mark.django_db
@pytest.mark.parametrize(
"token,auth,expected",
[
(None, None, 200),
("secret", "invalid", 401),
("secret", "secret", 200),
],
)
def test_metrics_exporter_auth(settings, token, auth, expected):
settings.PROMETHEUS_EXPORTER_SECRET = token
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer {}".format(auth))
url = reverse("metrics-exporter")
response = client.get(url)
assert response.status_code == expected

View file

@ -3,5 +3,5 @@ from django.urls import path
from .views import MetricsExporterView
urlpatterns = [
path("", MetricsExporterView.as_view()),
path("", MetricsExporterView.as_view(), name="metrics-exporter"),
]

View file

@ -1,11 +1,23 @@
import re
from django.conf import settings
from django.http import HttpResponse
from prometheus_client import generate_latest
from rest_framework.views import APIView
from .metrics_collectors import application_metrics_registry
RE_AUTH_TOKEN = re.compile(r"^[Bb]earer\s{1}(.+)$")
class MetricsExporterView(APIView):
def get(self, request):
if settings.PROMETHEUS_EXPORTER_SECRET:
authorization = request.headers.get("Authorization", "")
match = RE_AUTH_TOKEN.match(authorization)
token = match.groups()[0] if match else None
if not token or token != settings.PROMETHEUS_EXPORTER_SECRET:
return HttpResponse(status=401)
result = generate_latest(application_metrics_registry).decode("utf-8")
return HttpResponse(result, content_type="text/plain; version=0.0.4; charset=utf-8")

View file

@ -60,6 +60,7 @@ class PhoneCallRecord(models.Model):
class ProviderPhoneCall(models.Model):
"""
ProviderPhoneCall is an interface between PhoneCallRecord and call data returned from PhoneProvider.
Concrete provider phone call should be inherited from ProviderPhoneCall.
Some phone providers allows to track status of call or gather pressed digits (we use it to ack/resolve alert group).
It is needed to link phone call and alert group without exposing internals of concrete phone provider to PhoneBackend.

View file

@ -67,8 +67,9 @@ class SMSRecord(models.Model):
class ProviderSMS(models.Model):
"""
ProviderSMS is an interface between SMSRecord and call data returned from PhoneProvider.
Concrete provider sms be inherited from ProviderSMS.
The idea is same as for ProviderCall - to save provider specific data without exposing them to ProheBackend.
The idea is same as for ProviderCall - to save provider specific data without exposing them to PhoneBackend.
"""
class Meta:

View file

@ -15,9 +15,16 @@ class ProviderFlags:
"""
ProviderFlags is set of feature flags enabled for concrete provider.
It is needed to show correct buttons in UI.
Attributes:
configured: Indicates if provider LiveSettings are valid. If LiveSettings cannot be validated, return True.
test_sms: Indicates if provider allows to send test_sms
test_call: Indicates if provider allows to make test_call
verification_call: Indicates if provider allows to validate number via call
verification_sms: Indicates if provider allows to validate number via sms
"""
configured: bool # indicates if provider live settings are present and valid
configured: bool
test_sms: bool
test_call: bool
verification_call: bool
@ -29,7 +36,10 @@ class PhoneProvider(ABC):
PhoneProvider is an interface to all phone providers.
It is needed to hide details of external phone providers from core code.
New PhoneProviders should be added to settings.PHONE_PROVIDERS dict.
To implement custom phone provider:
1. Implement your ConcretePhoneProvider inherited from PhoneProvider.
2. Add needed env variables to django settings and to LiveSettings.
3. Add your PhoneProvider to settings.PHONE_PROVIDERS dict.
For reference, you can check:
SimplePhoneProvider as example of tiny, but working provider.

View file

@ -1,9 +1,13 @@
import logging
from random import randint
from django.core.cache import cache
from .exceptions import FailedToSendSMS, FailedToStartVerification
from .phone_provider import PhoneProvider, ProviderFlags
logger = logging.getLogger(__name__)
class SimplePhoneProvider(PhoneProvider):
"""
@ -15,12 +19,22 @@ class SimplePhoneProvider(PhoneProvider):
self.send_sms(number, message)
def send_sms(self, number, text):
print(f'SimplePhoneProvider.send_sms: send message "{text}" to {number}')
try:
self._write_to_stdout(number, text)
except Exception as e:
# example of handling provider exceptions and converting them to exceptions from core OnCall code.
logger.error(f"SimplePhoneProvider.send_sms: failed {e}")
raise FailedToSendSMS
def send_verification_sms(self, number):
code = str(randint(100000, 999999))
cache.set(self._cache_key(number), code, timeout=10 * 60)
self.send_sms(number, f"Your verification code is {code}")
try:
self._write_to_stdout(number, f"Your verification code is {code}")
except Exception as e:
# Example of handling provider exceptions and converting them to exceptions from core OnCall code.
logger.error(f"SimplePhoneProvider.send_verification_sms: failed {e}")
raise FailedToStartVerification
def finish_verification(self, number, code):
has = cache.get(self._cache_key(number))
@ -32,6 +46,11 @@ class SimplePhoneProvider(PhoneProvider):
def _cache_key(self, number):
return f"simple_provider_{number}"
def _write_to_stdout(self, number, text):
# print is just example of sending sms.
# In real-life provider it will be some external api call.
print(f'send message "{text}" to {number}')
@property
def flags(self) -> ProviderFlags:
return ProviderFlags(

View file

@ -92,6 +92,9 @@ ONCALL_GATEWAY_URL = os.environ.get("ONCALL_GATEWAY_URL")
ONCALL_GATEWAY_API_TOKEN = os.environ.get("ONCALL_GATEWAY_API_TOKEN")
ONCALL_BACKEND_REGION = os.environ.get("ONCALL_BACKEND_REGION")
# Prometheus exporter metrics endpoint auth
PROMETHEUS_EXPORTER_SECRET = os.environ.get("PROMETHEUS_EXPORTER_SECRET")
# Database
class DatabaseTypes:

View file

@ -40,3 +40,5 @@ TWILIO_ACCOUNT_SID = "dummy_twilio_account_sid"
TWILIO_AUTH_TOKEN = "dummy_twilio_auth_token"
EXTRA_MESSAGING_BACKENDS = [("apps.base.tests.messaging_backend.TestOnlyBackend", 42)]
FEATURE_PROMETHEUS_EXPORTER_ENABLED = True

View file

@ -1,19 +0,0 @@
.hamburger-menu {
cursor: pointer;
color: var(--primary-text-color);
}
.hamburger-menu-withBackground {
display: inline-flex;
flex-direction: column;
align-items: center;
vertical-align: middle;
justify-content: center;
background-color: rgba(204, 204, 220, 0.16);
border: 1px solid transparent;
height: 32px;
width: 30px;
padding: 4px;
cursor: pointer;
color: var(--primary-text-color);
}

View file

@ -0,0 +1,35 @@
.hamburgerMenu {
cursor: pointer;
color: var(--primary-text-color);
&--withBackground {
display: inline-flex;
flex-direction: column;
align-items: center;
vertical-align: middle;
justify-content: center;
background-color: rgba(204, 204, 220, 0.16);
border: 1px solid transparent;
height: 32px;
width: 30px;
padding: 4px;
cursor: pointer;
color: var(--primary-text-color);
}
&--small {
display: inline-flex;
flex-direction: column;
align-items: center;
vertical-align: middle;
justify-content: center;
background-color: rgba(204, 204, 220, 0.16);
color: var(--secondary-background);
border: 1px solid transparent;
height: 24px;
width: 22px;
padding: 4px;
cursor: pointer;
color: var(--primary-text-color);
}
}

View file

@ -3,12 +3,13 @@ import React, { useRef } from 'react';
import { Icon } from '@grafana/ui';
import cn from 'classnames/bind';
import styles from './HamburgerMenu.module.css';
import styles from './HamburgerMenu.module.scss';
interface HamburgerMenuProps {
openMenu: React.MouseEventHandler<HTMLElement>;
listWidth: number;
listBorder: number;
stopPropagation?: boolean;
withBackground?: boolean;
className?: string;
}
@ -17,12 +18,16 @@ const cx = cn.bind(styles);
const HamburgerMenu: React.FC<HamburgerMenuProps> = (props) => {
const ref = useRef<HTMLDivElement>();
const { openMenu, listBorder, listWidth, withBackground, className } = props;
const { openMenu, listBorder, listWidth, withBackground, className, stopPropagation = false } = props;
return (
<div
ref={ref}
className={withBackground ? cx('hamburger-menu-withBackground') : cx('hamburger-menu', className)}
onClick={() => {
className={withBackground ? cx('hamburgerMenu--withBackground') : cx('hamburgerMenu', className)}
onClick={(e) => {
if (stopPropagation) {
e.stopPropagation();
}
const boundingRect = ref.current.getBoundingClientRect();
openMenu({

View file

@ -2,6 +2,10 @@
width: 100%;
}
.regexp-template-code-error {
border: var(--error-text-color) 1px solid;
}
.regexp-template-editor-modal {
width: 700px;
}

View file

@ -12,6 +12,7 @@ import Text from 'components/Text/Text';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
import { useStore } from 'state/useStore';
import { openErrorNotification } from 'utils';
import styles from './EditRegexpRouteTemplateModal.module.css';
@ -33,6 +34,7 @@ const EditRegexpRouteTemplateModal = observer((props: EditRegexpRouteTemplateMod
const regexpBody = store.alertReceiveChannelStore.channelFilters[channelFilterId]?.filtering_term;
const [regexpTemplateBody, setRegexpTemplateBody] = useState<string>(regexpBody);
const [showErrorTemplate, setShowErrorTemplate] = useState<boolean>(false);
const templateJinja2Body = store.alertReceiveChannelStore.channelFilters[channelFilterId]?.filtering_term_as_jinja2;
@ -40,14 +42,20 @@ const EditRegexpRouteTemplateModal = observer((props: EditRegexpRouteTemplateMod
const handleRegexpBodyChange = () => {
return debounce((value: string) => {
setShowErrorTemplate(false);
setRegexpTemplateBody(value);
}, 1000);
};
const handleSave = useCallback(() => {
onUpdateRoute({ ['route_template']: regexpTemplateBody }, channelFilterId, 0);
if (!regexpTemplateBody) {
setShowErrorTemplate(true);
openErrorNotification('Route template body can not be empty');
} else {
onUpdateRoute({ ['route_template']: regexpTemplateBody }, channelFilterId, 0);
onHide();
onHide();
}
}, [regexpTemplateBody]);
const handleConvertToJinja2 = useCallback(() => {
@ -87,7 +95,7 @@ const EditRegexpRouteTemplateModal = observer((props: EditRegexpRouteTemplateMod
</Tooltip>
</HorizontalGroup>
<div className={cx('regexp-template-code')}>
<div className={cx('regexp-template-code', { 'regexp-template-code-error': showErrorTemplate })}>
<MonacoEditor
value={regexpTemplateBody}
height={'200px'}

View file

@ -29,13 +29,9 @@ const CollapsedIntegrationRouteDisplay: React.FC<CollapsedIntegrationRouteDispla
const store = useStore();
const { escalationChainStore, alertReceiveChannelStore, telegramChannelStore } = store;
const [routeIdForDeletion, setRouteIdForDeletion] = useState<ChannelFilter['id']>(undefined);
const [telegramInfo, setTelegramInfo] = useState<Array<{ id: string; channel_name: string }>>([]);
useEffect(() => {
(async function () {
const telegram = await telegramChannelStore.getAll();
setTelegramInfo(telegram);
})();
telegramChannelStore.updateItems();
}, [channelFilterId]);
const channelFilter = alertReceiveChannelStore.channelFilters[channelFilterId];
@ -70,9 +66,7 @@ const CollapsedIntegrationRouteDisplay: React.FC<CollapsedIntegrationRouteDispla
)}
tooltipContent={undefined}
/>
{routeWording === 'Default' && (
<Text type="primary">All unrouted routes will be served to the default route</Text>
)}
{routeWording === 'Default' && <Text type="secondary">Unmatched alerts routed to default route</Text>}
{routeWording !== 'Default' && channelFilter.filtering_term && (
<Text type="primary" className={cx('heading-container__text')}>
{channelFilter.filtering_term}
@ -93,7 +87,7 @@ const CollapsedIntegrationRouteDisplay: React.FC<CollapsedIntegrationRouteDispla
content={
<div className={cx('spacing')}>
<VerticalGroup>
{IntegrationHelper.getChatOpsChannels(channelFilter, telegramInfo, store)
{IntegrationHelper.getChatOpsChannels(channelFilter, store)
.filter((it) => it)
.map((chatOpsChannel, key) => (
<HorizontalGroup key={key}>

View file

@ -32,19 +32,3 @@
background: var(--gray-9);
}
}
.hamburgerMenu-small {
display: inline-flex;
flex-direction: column;
align-items: center;
vertical-align: middle;
justify-content: center;
background-color: rgba(204, 204, 220, 0.16);
color: var(--secondary-background);
border: 1px solid transparent;
height: 24px;
width: 22px;
padding: 4px;
cursor: pointer;
color: var(--primary-text-color);
}

View file

@ -41,6 +41,9 @@ import { UserActions } from 'utils/authorization';
const cx = cn.bind(styles);
const ACTIONS_LIST_WIDTH = 200;
const ACTIONS_LIST_BORDER = 2;
interface ExpandedIntegrationRouteDisplayProps {
alertReceiveChannelId: AlertReceiveChannel['id'];
channelFilterId: ChannelFilter['id'];
@ -83,7 +86,7 @@ const ExpandedIntegrationRouteDisplay: React.FC<ExpandedIntegrationRouteDisplayP
useEffect(() => {
setIsLoading(true);
Promise.all([escalationChainStore.updateItems(), telegramChannelStore.updateTelegramChannels()]).then(() =>
Promise.all([escalationChainStore.updateItems(), telegramChannelStore.updateItems()]).then(() =>
setIsLoading(false)
);
}, []);
@ -168,12 +171,14 @@ const ExpandedIntegrationRouteDisplay: React.FC<ExpandedIntegrationRouteDisplayP
</IntegrationBlockItem>
)}
<IntegrationBlockItem>
<VerticalGroup spacing="md">
<Text type="primary">Publish to ChatOps</Text>
<ChatOpsConnectors channelFilterId={channelFilterId} showLineNumber={false} />
</VerticalGroup>
</IntegrationBlockItem>
{IntegrationHelper.hasChatopsInstalled(store) && (
<IntegrationBlockItem>
<VerticalGroup spacing="md">
<Text type="primary">Publish to ChatOps</Text>
<ChatOpsConnectors channelFilterId={channelFilterId} showLineNumber={false} />
</VerticalGroup>
</IntegrationBlockItem>
)}
<IntegrationBlockItem>
<VerticalGroup>
@ -363,15 +368,20 @@ export const RouteButtonsDisplay: React.FC<RouteButtonsDisplayProps> = ({
)}
>
{({ openMenu }) => (
<HamburgerMenu openMenu={openMenu} listBorder={2} listWidth={200} className={cx('hamburgerMenu-small')} />
<HamburgerMenu
openMenu={openMenu}
listBorder={ACTIONS_LIST_BORDER}
listWidth={ACTIONS_LIST_WIDTH}
className={'hamburgerMenu--small'}
stopPropagation={true}
/>
)}
</WithContextMenu>
)}
</HorizontalGroup>
);
function onDelete(e: React.SyntheticEvent) {
e.stopPropagation();
function onDelete() {
setRouteIdForDeletion();
}

View file

@ -32,23 +32,32 @@
min-width: min-content;
}
.template-block-list,
.template-block-codeeditor {
overflow-y: hidden;
}
.template-block-list,
.template-block-codeeditor,
.template-block-result,
.result {
height: 100%;
max-height: 100%;
}
.template-block-list {
width: 30%;
height: 100%;
}
.template-block-codeeditor {
width: 40%;
height: 100%;
}
.template-block-result {
width: 30%;
height: 100%;
overflow-y: scroll !important;
}
.result {
padding-left: 16px;
padding-bottom: 60px;
}
.template-block-codeeditor div[aria-label='Code editor container'] {

View file

@ -21,10 +21,11 @@ import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_
import { AlertTemplatesDTO } from 'models/alert_templates';
import { Alert } from 'models/alertgroup/alertgroup.types';
import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
import { openErrorNotification } from 'utils';
import { waitForElement } from 'utils/DOM';
import LocationHelper from 'utils/LocationHelper';
import styles from './IntegrationTemplate.module.css';
import styles from './IntegrationTemplate.module.scss';
const cx = cn.bind(styles);
@ -117,11 +118,17 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
);
const handleSubmit = useCallback(() => {
template.isRoute
? onUpdateRoute({ [template.name]: changedTemplateBody }, channelFilterId)
: onUpdateTemplates({ [template.name]: changedTemplateBody });
onHide();
if (template.isRoute) {
if (changedTemplateBody) {
onUpdateRoute({ [template.name]: changedTemplateBody }, channelFilterId);
onHide();
} else {
openErrorNotification('Route template body can not be empty');
}
} else {
onUpdateTemplates({ [template.name]: changedTemplateBody });
onHide();
}
}, [onUpdateTemplates, changedTemplateBody]);
const getCheatSheet = (templateName) => {

View file

@ -10,12 +10,18 @@ const normalize = (value: any) => {
return value;
};
export function parseFilters(query: { [key: string]: any }, filterOptions: FilterOption[]) {
const filters = filterOptions.filter((filterOption: FilterOption) => filterOption.name in query);
export function parseFilters(
data: { [key: string]: any },
filterOptions: FilterOption[],
query: { [key: string]: any }
) {
const filters = filterOptions.filter((filterOption: FilterOption) => filterOption.name in data);
const values = filters.reduce((memo: any, filterOption: FilterOption) => {
const rawValue = query[filterOption.name];
const rawValue = query[filterOption.name] || data[filterOption.name]; // query takes priority over local storage
let value: any = rawValue;
if (filterOption.type === 'options' || filterOption.type === 'team_select') {
if (!Array.isArray(rawValue)) {
value = [rawValue];

View file

@ -69,17 +69,10 @@ class RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
const filterOptions = await filtersStore.updateOptionsForPage(page);
let { filters, values } = parseFilters({ ...query, ...filtersStore.globalValues }, filterOptions);
let { filters, values } = parseFilters({ ...query, ...filtersStore.globalValues }, filterOptions, query);
if (isEmpty(values)) {
let newQuery = defaultFilters || { team: [] };
/* if (filtersStore.values[page]) {
newQuery = { ...filtersStore.values[page] };
} else {
newQuery = defaultFilters || { team: [] };
} */
({ filters, values } = parseFilters(newQuery, filterOptions));
({ filters, values } = parseFilters(defaultFilters || { team: [] }, filterOptions, query));
}
this.setState({ filterOptions, filters, values }, () => this.onChange(true));
@ -369,17 +362,20 @@ class RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
store.filtersStore.updateValuesForPage(page, values);
Object.keys({ ...store.filtersStore.globalValues }).forEach((key) => {
if (!(key in values)) {
delete store.filtersStore.globalValues[key];
}
});
if (!isOnMount) {
// Skip updating local storage for mounting, this way URL won't overwrite local storage but subsequent actions WILL do
Object.keys({ ...store.filtersStore.globalValues }).forEach((key) => {
if (!(key in values)) {
delete store.filtersStore.globalValues[key];
}
});
const newGlobalValues = pickBy(values, (_, key) =>
filterOptions.some((option) => option.name === key && option.global)
);
const newGlobalValues = pickBy(values, (_, key) =>
filterOptions.some((option) => option.name === key && option.global)
);
store.filtersStore.globalValues = newGlobalValues;
store.filtersStore.globalValues = newGlobalValues;
}
LocationHelper.update({ ...values }, 'partial');
onChange(values, isOnMount);

View file

@ -7,6 +7,8 @@
.template-block-list {
width: 30%;
height: 100%;
max-width: 100%;
overflow-y: hidden;
}
.alert-group-payload-view {
@ -37,7 +39,8 @@
}
.alert-groups-editor {
width: 100%;
width: calc(100% + 16px);
margin-left: -16px;
}
.alert-groups-editor div[aria-label='Code editor container'] {
@ -45,6 +48,13 @@
border-right: none;
}
.alert-groups-editor-withBadge div[aria-label='Code editor container'] {
background-color: rgba(10, 10, 10, 0.4);
border-bottom: none;
border-right: none;
padding-top: 42px;
}
.no-alert-groups-badge {
display: flex;
padding: 8px;
@ -54,3 +64,26 @@
.no-alert-groups-badge > div {
margin-right: 8px;
}
.alert-groups-last-payload-badge {
position: fixed;
z-index: 1;
margin: 16px;
}
.selected-alert-name {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.selected-alert-name-container {
display: flex;
align-items: center;
justify-content: space-between;
}
.title-action-icons {
display: flex;
align-items: center;
}

View file

@ -6,6 +6,7 @@ import { debounce } from 'lodash-es';
import MonacoEditor, { MONACO_LANGUAGE } from 'components/MonacoEditor/MonacoEditor';
import Text from 'components/Text/Text';
import TooltipBadge from 'components/TooltipBadge/TooltipBadge';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertTemplatesDTO } from 'models/alert_templates';
import { Alert } from 'models/alertgroup/alertgroup.types';
@ -15,6 +16,8 @@ import { useStore } from 'state/useStore';
import styles from './TemplatesAlertGroupsList.module.css';
const cx = cn.bind(styles);
const HEADER_OF_CONTAINER_HEIGHT = 59;
const BADGE_WITH_PADDINGS_HEIGHT = 42;
interface TemplatesAlertGroupsListProps {
templates: AlertTemplatesDTO[];
@ -38,8 +41,14 @@ const TemplatesAlertGroupsList = (props: TemplatesAlertGroupsListProps) => {
}, []);
const getCodeEditorHeight = () => {
const mainDiv = document.getElementById('content-container-id');
const height = mainDiv?.getBoundingClientRect().height - 59;
const mainDiv = document.getElementById('alerts-content-container-id');
const height = mainDiv?.getBoundingClientRect().height - HEADER_OF_CONTAINER_HEIGHT;
return `${height}px`;
};
const getCodeEditorHeightWithBadge = () => {
const mainDiv = document.getElementById('alerts-content-container-id');
const height = mainDiv?.getBoundingClientRect().height - HEADER_OF_CONTAINER_HEIGHT - BADGE_WITH_PADDINGS_HEIGHT;
return `${height}px`;
};
@ -71,14 +80,14 @@ const TemplatesAlertGroupsList = (props: TemplatesAlertGroupsListProps) => {
};
return (
<div className={cx('template-block-list')} id="content-container-id">
<div className={cx('template-block-list')} id="alerts-content-container-id">
{selectedAlertPayload ? (
<>
{isEditMode ? (
<>
<div className={cx('template-block-title')}>
<HorizontalGroup justify="space-between">
<Text>Edit {selectedAlertName}</Text>
<Text>Edit custom payload</Text>
<HorizontalGroup>
<IconButton name="times" onClick={() => returnToListView()} />
@ -101,24 +110,31 @@ const TemplatesAlertGroupsList = (props: TemplatesAlertGroupsListProps) => {
) : (
<>
<div className={cx('template-block-title')}>
<HorizontalGroup justify="space-between">
<Text>{selectedAlertName}</Text>
<HorizontalGroup>
<div className={cx('selected-alert-name-container')}>
<div className={cx('selected-alert-name')}>
<Text>{selectedAlertName}</Text>
</div>
<div className={cx('title-action-icons')}>
<IconButton name="edit" onClick={() => setIsEditMode(true)} />
<IconButton name="times" onClick={() => returnToListView()} />
</HorizontalGroup>
</HorizontalGroup>
</div>
</div>
</div>
<div>
<div className={cx('alert-groups-editor')}>
<div className={cx('alert-groups-editor')}>
<TooltipBadge
borderType="primary"
text="Last alert payload"
tooltipTitle=""
tooltipContent=""
className={cx('alert-groups-last-payload-badge')}
/>
<div className={cx('alert-groups-editor-withBadge')}>
<MonacoEditor
value={JSON.stringify(selectedAlertPayload, null, 4)}
data={undefined}
disabled
height={getCodeEditorHeight()}
height={getCodeEditorHeightWithBadge()}
onChange={getChangeHandler()}
showLineNumbers
useAutoCompleteList={false}
language={MONACO_LANGUAGE.json}
monacoOptions={{

View file

@ -69,15 +69,6 @@ export class AlertReceiveChannelStore extends BaseStore {
return this.searchResult.map(
(alertReceiveChannelId: AlertReceiveChannel['id']) => this.items?.[alertReceiveChannelId]
);
// return {
// count: this.searchResult.count,
// results:
// this.searchResult.results &&
// this.searchResult.results.map(
// (alertReceiveChannelId: AlertReceiveChannel['id']) => this.items?.[alertReceiveChannelId]
// ),
// };
}
getPaginatedSearchResult(_query = '') {
@ -159,8 +150,7 @@ export class AlertReceiveChannelStore extends BaseStore {
async updatePaginatedItems(query: any = '', page = 1) {
const filters = typeof query === 'string' ? { search: query } : query;
const { search } = filters;
const { count, results } = await makeRequest(this.path, { params: { search, page } });
const { count, results } = await makeRequest(this.path, { params: { ...filters, page } });
this.items = {
...this.items,

View file

@ -72,23 +72,30 @@ const IntegrationHelper = {
return totalDiffString;
},
getChatOpsChannels(
channelFilter: ChannelFilter,
telegramInfo: Array<{ id: string; channel_name: string }>,
store: RootStore
): Array<{ name: string; icon: IconName }> {
const channels: Array<{ name: string; icon: IconName }> = [];
hasChatopsInstalled(store: RootStore) {
const hasSlack = Boolean(store.teamStore.currentTeam?.slack_team_identity);
const hasTelegram =
store.hasFeature(AppFeature.Telegram) && store.telegramChannelStore.currentTeamToTelegramChannel?.length > 0;
return hasSlack || hasTelegram;
},
if (
store.hasFeature(AppFeature.Slack) &&
channelFilter.notify_in_slack &&
channelFilter.notify_in_slack &&
channelFilter.slack_channel?.display_name
) {
channels.push({ name: channelFilter.slack_channel.display_name, icon: 'slack' });
getChatOpsChannels(channelFilter: ChannelFilter, store: RootStore): Array<{ name: string; icon: IconName }> {
const channels: Array<{ name: string; icon: IconName }> = [];
const telegram = Object.keys(store.telegramChannelStore.items).map((k) => store.telegramChannelStore.items[k]);
if (store.hasFeature(AppFeature.Slack) && channelFilter.notify_in_slack) {
const matchingSlackChannel = store.teamStore.currentTeam?.slack_channel?.id
? store.slackChannelStore.items[store.teamStore.currentTeam.slack_channel?.id]
: undefined;
if (channelFilter.slack_channel?.display_name || matchingSlackChannel?.display_name) {
channels.push({
name: channelFilter.slack_channel?.display_name || matchingSlackChannel?.display_name,
icon: 'slack',
});
}
}
const matchingTelegram = telegramInfo?.find((t) => t.id === channelFilter.telegram_channel);
const matchingTelegram = telegram.find((t) => t.id === channelFilter.telegram_channel);
if (
store.hasFeature(AppFeature.Telegram) &&

View file

@ -66,7 +66,7 @@ import { openNotification, openErrorNotification } from 'utils';
import { getVar } from 'utils/DOM';
import LocationHelper from 'utils/LocationHelper';
import { UserActions } from 'utils/authorization';
import { DATASOURCE_GRAFANA, PLUGIN_ROOT } from 'utils/consts';
import { PLUGIN_ROOT } from 'utils/consts';
import sanitize from 'utils/sanitize';
const cx = cn.bind(styles);
@ -86,7 +86,7 @@ interface Integration2State extends PageBaseState {
const ACTIONS_LIST_WIDTH = 200;
const ACTIONS_LIST_BORDER = 2;
const NEW_ROUTE_DEFAULT = '{{ (payload.severity == "foo" and "bar" in payload.region) or True }}';
const NEW_ROUTE_DEFAULT = '{# (payload.severity == "foo" and "bar" in payload.region) or True #}';
@observer
class Integration2 extends React.Component<Integration2Props, Integration2State> {
@ -346,7 +346,9 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
this.setState({
isEditTemplateModalOpen: undefined,
});
this.setState({ isTemplateSettingsOpen: true });
if (selectedTemplate?.name !== 'route_template') {
this.setState({ isTemplateSettingsOpen: true });
}
LocationHelper.update({ template: undefined, routeId: undefined }, 'partial');
}}
channelFilterId={channelFilterIdForEdit}
@ -601,9 +603,8 @@ const IntegrationSendDemoPayloadModal: React.FC<IntegrationSendDemoPayloadModalP
}) => {
const store = useStore();
const { alertReceiveChannelStore } = store;
const stringifiedJson = JSON.stringify(alertReceiveChannel.demo_alert_payload, null, 2);
const initialDemoJSON = stringifiedJson.substring(1, stringifiedJson.length - 1);
const [demoPayload, setDemoPayload] = useState<string>(alertReceiveChannel.demo_alert_payload);
const initialDemoJSON = JSON.stringify(alertReceiveChannel.demo_alert_payload, null, 2);
const [demoPayload, setDemoPayload] = useState<string>(initialDemoJSON);
let onPayloadChangeDebounced = debounce(100, onPayloadChange);
return (
@ -914,8 +915,6 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({ alertReceiveCha
const HowToConnectComponent: React.FC<{ id: AlertReceiveChannel['id'] }> = ({ id }) => {
const { alertReceiveChannelStore } = useStore();
const alertReceiveChannelCounter = alertReceiveChannelStore.counters[id];
const alertReceiveChannel = alertReceiveChannelStore.items[id];
const isGrafanaDatasource = alertReceiveChannel.integration === DATASOURCE_GRAFANA;
const hasAlerts = !!alertReceiveChannelCounter?.alerts_count;
return (
@ -947,7 +946,7 @@ const HowToConnectComponent: React.FC<{ id: AlertReceiveChannel['id'] }> = ({ id
</a>
</div>
}
content={isGrafanaDatasource || !hasAlerts ? renderContent() : null}
content={hasAlerts ? null : renderContent()}
/>
);
@ -961,20 +960,6 @@ const HowToConnectComponent: React.FC<{ id: AlertReceiveChannel['id'] }> = ({ id
<Text type={'primary'}>No alerts yet; try to send a demo alert</Text>
</HorizontalGroup>
)}
{isGrafanaDatasource && (
<HorizontalGroup spacing={'xs'}>
<Icon name="list-ui-alt" size="md" />
<a href={`/alerting/notifications?alertmanager=grafana`} target="_blank" rel="noreferrer">
<Text type={'link'}>Contact Point</Text>
</a>
<Text type={'secondary'}>and</Text>
<a href="/alerting/routes?alertmanager=grafana" target="_blank">
<Text type={'link'}>Notification Policy</Text>
</a>
<Text type={'secondary'}>created in Grafana Alerting</Text>
</HorizontalGroup>
)}
</VerticalGroup>
</div>
);

View file

@ -414,36 +414,34 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
<div className="thin-line-break" />
<WithPermissionControlTooltip key="delete" userAction={UserActions.IntegrationsWrite}>
<>
<div className={cx('integrations-actionItem')}>
<div
onClick={() => {
this.setState({
confirmationModal: {
isOpen: true,
confirmText: 'Delete',
dismissText: 'Cancel',
onConfirm: () => this.handleDeleteAlertReceiveChannel(item.id),
title: 'Delete integration',
body: (
<Text type="primary">
Are you sure you want to delete <Emoji text={item.verbal_name} /> integration?
</Text>
),
},
});
}}
style={{ width: '100%' }}
>
<Text type="danger">
<HorizontalGroup spacing={'xs'}>
<Icon name="trash-alt" />
<span>Delete Integration</span>
</HorizontalGroup>
</Text>
</div>
<div className={cx('integrations-actionItem')}>
<div
onClick={() => {
this.setState({
confirmationModal: {
isOpen: true,
confirmText: 'Delete',
dismissText: 'Cancel',
onConfirm: () => this.handleDeleteAlertReceiveChannel(item.id),
title: 'Delete integration',
body: (
<Text type="primary">
Are you sure you want to delete <Emoji text={item.verbal_name} /> integration?
</Text>
),
},
});
}}
style={{ width: '100%' }}
>
<Text type="danger">
<HorizontalGroup spacing={'xs'}>
<Icon name="trash-alt" />
<span>Delete Integration</span>
</HorizontalGroup>
</Text>
</div>
</>
</div>
</WithPermissionControlTooltip>
</div>
)}
@ -475,9 +473,12 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
applyFilters = () => {
const { store } = this.props;
const { alertReceiveChannelStore } = store;
const { integrationsFilters, page } = this.state;
const { integrationsFilters } = this.state;
return alertReceiveChannelStore.updatePaginatedItems(integrationsFilters, page);
return alertReceiveChannelStore.updatePaginatedItems(integrationsFilters).then(() => {
this.setState({ page: 1 });
LocationHelper.update({ p: 1 }, 'partial');
});
};
debouncedUpdateIntegrations = debounce(this.applyFilters, FILTERS_DEBOUNCE_MS);

View file

@ -29,7 +29,7 @@ export const FARO_ENDPOINT_PROD =
'https://faro-collector-prod-us-central-0.grafana.net/collect/03a11ed03c3af04dcfc3be9755f2b053';
export const DOCS_SLACK_SETUP = 'https://grafana.com/docs/oncall/latest/open-source/#slack-setup';
export const DOCS_TELEGRAM_SETUP = 'https://grafana.com/docs/oncall/latest/chat-options/configure-telegram/';
export const DOCS_TELEGRAM_SETUP = 'https://grafana.com/docs/oncall/latest/notify/telegram/';
// Make sure if you chage max-width here you also change it in responsive.css
export const TABLE_COLUMN_MAX_WIDTH = 1500;

View file

@ -20,24 +20,17 @@
```bash
helm install helm-testing \
--wait \
--timeout 30m \
--wait-for-jobs \
--values ./simple.yml \
--values ./values-arm64.yml \
./oncall
```
5. Get credentials
<!-- markdownlint-disable MD013 -->
```bash
echo "\n\nOpen Grafana on localhost:30002 with credentials - user: admin, password: $(kubectl get secret --namespace default helm-testing-grafana -o jsonpath="{.data.admin-password}" | base64 --decode ; echo)"
echo "Open Plugins -> Grafana OnCall -> fill form: backend url: http://host.docker.internal:30001"
```
<!-- markdownlint-enable MD013 -->
6. Clean up
```bash

View file

@ -2,6 +2,7 @@ apiVersion: v2
name: oncall
description: Developer-friendly incident response with brilliant Slack integration
type: application
# version and appVersion are handled by CI, no need to change them manually
version: 1.2.41
appVersion: v1.2.41
dependencies:
@ -10,7 +11,7 @@ dependencies:
repository: https://charts.jetstack.io
condition: cert-manager.enabled
- name: mariadb
version: 11.0.10
version: 12.2.5
repository: https://charts.bitnami.com/bitnami
condition: mariadb.enabled
- name: postgresql
@ -18,7 +19,7 @@ dependencies:
repository: https://charts.bitnami.com/bitnami
condition: postgresql.enabled
- name: rabbitmq
version: 10.3.9
version: 12.0.0
repository: https://charts.bitnami.com/bitnami
condition: rabbitmq.enabled
- name: redis
@ -26,7 +27,7 @@ dependencies:
repository: https://charts.bitnami.com/bitnami
condition: redis.enabled
- name: grafana
version: 6.29.6
version: 6.57.1
repository: https://grafana.github.io/helm-charts
condition: grafana.enabled
- name: ingress-nginx

View file

@ -45,8 +45,6 @@ helm install \
Follow the `helm install` output to finish setting up Grafana OnCall backend and Grafana OnCall frontend plugin e.g.
<!-- markdownlint-disable MD013 -->
```bash
👋 Your Grafana OnCall instance has been successfully deployed
@ -74,8 +72,6 @@ Follow the `helm install` output to finish setting up Grafana OnCall backend and
🎉🎉🎉 Done! 🎉🎉🎉
```
<!-- markdownlint-enable MD013 -->
## Configuration
You can edit values.yml to make changes to the helm chart configuration and re-deploy the release with the following command:

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -257,7 +257,7 @@ http://{{ include "oncall.grafana.fullname" . }}
{{- if and (not .Values.mariadb.enabled) .Values.externalMysql.db_name -}}
{{- required "externalMysql.db_name is required if not mariadb.enabled" .Values.externalMysql.db_name | quote}}
{{- else -}}
"oncall"
{{- .Values.mariadb.auth.database | default "oncall" | quote -}}
{{- end -}}
{{- end -}}
@ -265,7 +265,7 @@ http://{{ include "oncall.grafana.fullname" . }}
{{- if and (not .Values.mariadb.enabled) .Values.externalMysql.user -}}
{{- .Values.externalMysql.user | quote }}
{{- else -}}
"root"
{{- .Values.mariadb.auth.username | default "root" | quote -}}
{{- end -}}
{{- end -}}
@ -480,3 +480,19 @@ rabbitmq-password
value: {{ .Values.oncall.smtp.enabled | toString | title | quote }}
{{- end -}}
{{- end }}
{{- define "snippet.oncall.exporter.env" -}}
{{- if .Values.oncall.exporter.enabled -}}
- name: FEATURE_PROMETHEUS_EXPORTER_ENABLED
value: {{ .Values.oncall.exporter.enabled | toString | title | quote }}
- name: PROMETHEUS_EXPORTER_SECRET
valueFrom:
secretKeyRef:
name: {{ include "oncall.fullname" . }}-exporter
key: exporter-secret
optional: true
{{- else -}}
- name: FEATURE_PROMETHEUS_EXPORTER_ENABLED
value: {{ .Values.oncall.exporter.enabled | toString | title | quote }}
{{- end -}}
{{- end }}

View file

@ -97,17 +97,7 @@ Create the name of the service account to use
{{- include "snippet.mysql.env" . | nindent 4 }}
{{- include "snippet.rabbitmq.env" . | nindent 4 }}
{{- include "snippet.redis.env" . | nindent 4 }}
{{- if .Values.env }}
{{- if (kindIs "map" .Values.env) }}
{{- range $key, $value := .Values.env }}
- name: {{ $key }}
value: {{ $value }}
{{- end -}}
{{/* support previous schema */}}
{{- else }}
{{- toYaml .Values.env | nindent 4 }}
{{- end }}
{{- end }}
{{- include "oncall.extraEnvs" . | nindent 4 }}
{{- end }}
{{- define "oncall.postgresql.wait-for-db" }}
@ -122,7 +112,19 @@ Create the name of the service account to use
{{- include "snippet.postgresql.env" . | nindent 4 }}
{{- include "snippet.rabbitmq.env" . | nindent 4 }}
{{- include "snippet.redis.env" . | nindent 4 }}
{{- if .Values.env }}
{{- toYaml .Values.env | nindent 4 }}
{{- end }}
{{- include "oncall.extraEnvs" . | nindent 4 }}
{{- end }}
{{- define "oncall.extraEnvs" -}}
{{- if .Values.env }}
{{- if (kindIs "map" .Values.env) }}
{{- range $key, $value := .Values.env }}
- name: {{ $key }}
value: {{ $value }}
{{- end -}}
{{/* support previous schema */}}
{{- else }}
{{- toYaml .Values.env }}
{{- end }}
{{- end }}
{{- end }}

View file

@ -34,6 +34,10 @@ spec:
{{- if eq .Values.database.type "postgresql" }}
{{- include "oncall.postgresql.wait-for-db" . | indent 8 }}
{{- end }}
{{- with .Values.celery.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: {{ .Chart.Name }}
securityContext:
@ -47,6 +51,7 @@ spec:
{{- include "snippet.oncall.slack.env" . | nindent 12 }}
{{- include "snippet.oncall.telegram.env" . | nindent 12 }}
{{- include "snippet.oncall.smtp.env" . | nindent 12 }}
{{- include "snippet.oncall.exporter.env" . | nindent 12 }}
{{- if eq .Values.database.type "mysql" }}
{{- include "snippet.mysql.env" . | nindent 12 }}
{{- end }}
@ -55,17 +60,7 @@ spec:
{{- end }}
{{- include "snippet.rabbitmq.env" . | nindent 12 }}
{{- include "snippet.redis.env" . | nindent 12 }}
{{- if .Values.env }}
{{- if (kindIs "map" .Values.env) }}
{{- range $key, $value := .Values.env }}
- name: {{ $key }}
value: {{ $value }}
{{- end -}}
{{/* support previous schema */}}
{{- else }}
{{- toYaml .Values.env | nindent 12 }}
{{- end }}
{{- end }}
{{- include "oncall.extraEnvs" . | nindent 12 }}
{{- if .Values.celery.livenessProbe.enabled }}
livenessProbe:
exec:

View file

@ -51,6 +51,7 @@ spec:
{{- include "snippet.oncall.telegram.env" . | nindent 12 }}
{{- include "snippet.oncall.smtp.env" . | nindent 12 }}
{{- include "snippet.oncall.twilio.env" . | nindent 12 }}
{{- include "snippet.oncall.exporter.env" . | nindent 12 }}
{{- if eq .Values.database.type "mysql" }}
{{- include "snippet.mysql.env" . | nindent 12 }}
{{- end }}
@ -59,17 +60,7 @@ spec:
{{- end }}
{{- include "snippet.rabbitmq.env" . | nindent 12 }}
{{- include "snippet.redis.env" . | nindent 12 }}
{{- if .Values.env }}
{{- if (kindIs "map" .Values.env) }}
{{- range $key, $value := .Values.env }}
- name: {{ $key }}
value: {{ $value }}
{{- end -}}
{{/* support previous schema */}}
{{- else }}
{{- toYaml .Values.env | nindent 12 }}
{{- end }}
{{- end }}
{{- include "oncall.extraEnvs" . | nindent 12 }}
livenessProbe:
httpGet:
path: /health/
@ -86,7 +77,7 @@ spec:
httpGet:
path: /startupprobe/
port: http
periodSeconds: 60
periodSeconds: 10
timeoutSeconds: 3
resources:
{{- toYaml .Values.engine.resources | nindent 12 }}

View file

@ -35,6 +35,10 @@ spec:
serviceAccountName: {{ include "oncall.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
{{- with .Values.migrate.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: {{ .Chart.Name }}-migrate
securityContext:
@ -62,6 +66,7 @@ spec:
env:
{{- include "snippet.oncall.env" . | nindent 12 }}
{{- include "snippet.oncall.smtp.env" . | nindent 12 }}
{{- include "snippet.oncall.exporter.env" . | nindent 12 }}
{{- if eq .Values.database.type "mysql" }}
{{- include "snippet.mysql.env" . | nindent 12 }}
{{- end }}
@ -70,9 +75,7 @@ spec:
{{- end }}
{{- include "snippet.rabbitmq.env" . | nindent 12 }}
{{- include "snippet.redis.env" . | nindent 12 }}
{{- if .Values.env }}
{{- toYaml .Values.env | nindent 12 }}
{{- end }}
{{- include "oncall.extraEnvs" . | nindent 12 }}
resources:
{{- toYaml .Values.engine.resources | nindent 12 }}
{{- end }}

View file

@ -51,6 +51,16 @@ data:
smtp-password: {{ .Values.oncall.smtp.password | b64enc | quote }}
{{- end }}
---
{{ if and .Values.oncall.exporter.enabled .Values.oncall.exporter.authToken -}}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "oncall.fullname" . }}-exporter
type: Opaque
data:
exporter-secret: {{ .Values.oncall.exporter.authToken | b64enc | quote }}
{{- end }}
---
{{ if and (not .Values.postgresql.enabled) (eq .Values.database.type "postgresql") (not .Values.externalPostgresql.existingSecret) -}}
apiVersion: v1
kind: Secret

View file

@ -0,0 +1,298 @@
database.type=mysql -> should create initContainer for MySQL database (default):
1: |
- command:
- sh
- -c
- until (python manage.py migrate --check); do echo Waiting for database migrations; sleep 2; done
env:
- name: BASE_URL
value: https://example.com
- name: SECRET_KEY
valueFrom:
secretKeyRef:
key: SECRET_KEY
name: oncall
- name: MIRAGE_SECRET_KEY
valueFrom:
secretKeyRef:
key: MIRAGE_SECRET_KEY
name: oncall
- name: MIRAGE_CIPHER_IV
value: 1234567890abcdef
- name: DJANGO_SETTINGS_MODULE
value: settings.helm
- name: AMIXR_DJANGO_ADMIN_PATH
value: admin
- name: OSS
value: "True"
- name: UWSGI_LISTEN
value: "1024"
- name: BROKER_TYPE
value: rabbitmq
- name: GRAFANA_API_URL
value: http://oncall-grafana
- name: MYSQL_HOST
value: oncall-mariadb
- name: MYSQL_PORT
value: "3306"
- name: MYSQL_DB_NAME
value: oncall
- name: MYSQL_USER
value: root
- name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
key: mariadb-root-password
name: oncall-mariadb
- name: RABBITMQ_USERNAME
value: user
- name: RABBITMQ_PASSWORD
valueFrom:
secretKeyRef:
key: rabbitmq-password
name: oncall-rabbitmq
- name: RABBITMQ_HOST
value: oncall-rabbitmq
- name: RABBITMQ_PORT
value: "5672"
- name: RABBITMQ_PROTOCOL
value: amqp
- name: RABBITMQ_VHOST
value: ""
- name: REDIS_HOST
value: oncall-redis-master
- name: REDIS_PORT
value: "6379"
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
key: redis-password
name: oncall-redis
image: grafana/oncall:v1.2.36
imagePullPolicy: Always
name: wait-for-db
securityContext: {}
2: |
- command:
- sh
- -c
- until (python manage.py migrate --check); do echo Waiting for database migrations; sleep 2; done
env:
- name: BASE_URL
value: https://example.com
- name: SECRET_KEY
valueFrom:
secretKeyRef:
key: SECRET_KEY
name: oncall
- name: MIRAGE_SECRET_KEY
valueFrom:
secretKeyRef:
key: MIRAGE_SECRET_KEY
name: oncall
- name: MIRAGE_CIPHER_IV
value: 1234567890abcdef
- name: DJANGO_SETTINGS_MODULE
value: settings.helm
- name: AMIXR_DJANGO_ADMIN_PATH
value: admin
- name: OSS
value: "True"
- name: UWSGI_LISTEN
value: "1024"
- name: BROKER_TYPE
value: rabbitmq
- name: GRAFANA_API_URL
value: http://oncall-grafana
- name: MYSQL_HOST
value: oncall-mariadb
- name: MYSQL_PORT
value: "3306"
- name: MYSQL_DB_NAME
value: oncall
- name: MYSQL_USER
value: root
- name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
key: mariadb-root-password
name: oncall-mariadb
- name: RABBITMQ_USERNAME
value: user
- name: RABBITMQ_PASSWORD
valueFrom:
secretKeyRef:
key: rabbitmq-password
name: oncall-rabbitmq
- name: RABBITMQ_HOST
value: oncall-rabbitmq
- name: RABBITMQ_PORT
value: "5672"
- name: RABBITMQ_PROTOCOL
value: amqp
- name: RABBITMQ_VHOST
value: ""
- name: REDIS_HOST
value: oncall-redis-master
- name: REDIS_PORT
value: "6379"
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
key: redis-password
name: oncall-redis
image: grafana/oncall:v1.2.36
imagePullPolicy: Always
name: wait-for-db
securityContext: {}
database.type=postgresql -> should create initContainer for PostgreSQL database:
1: |
- command:
- sh
- -c
- until (python manage.py migrate --check); do echo Waiting for database migrations; sleep 2; done
env:
- name: BASE_URL
value: https://example.com
- name: SECRET_KEY
valueFrom:
secretKeyRef:
key: SECRET_KEY
name: oncall
- name: MIRAGE_SECRET_KEY
valueFrom:
secretKeyRef:
key: MIRAGE_SECRET_KEY
name: oncall
- name: MIRAGE_CIPHER_IV
value: 1234567890abcdef
- name: DJANGO_SETTINGS_MODULE
value: settings.helm
- name: AMIXR_DJANGO_ADMIN_PATH
value: admin
- name: OSS
value: "True"
- name: UWSGI_LISTEN
value: "1024"
- name: BROKER_TYPE
value: rabbitmq
- name: GRAFANA_API_URL
value: http://oncall-grafana
- name: DATABASE_TYPE
value: postgresql
- name: DATABASE_HOST
value: oncall-postgresql
- name: DATABASE_PORT
value: "5432"
- name: DATABASE_NAME
value: oncall
- name: DATABASE_USER
value: postgres
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
key: postgres-password
name: oncall-postgresql
- name: RABBITMQ_USERNAME
value: user
- name: RABBITMQ_PASSWORD
valueFrom:
secretKeyRef:
key: rabbitmq-password
name: oncall-rabbitmq
- name: RABBITMQ_HOST
value: oncall-rabbitmq
- name: RABBITMQ_PORT
value: "5672"
- name: RABBITMQ_PROTOCOL
value: amqp
- name: RABBITMQ_VHOST
value: ""
- name: REDIS_HOST
value: oncall-redis-master
- name: REDIS_PORT
value: "6379"
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
key: redis-password
name: oncall-redis
image: grafana/oncall:v1.2.36
imagePullPolicy: Always
name: wait-for-db
securityContext: {}
2: |
- command:
- sh
- -c
- until (python manage.py migrate --check); do echo Waiting for database migrations; sleep 2; done
env:
- name: BASE_URL
value: https://example.com
- name: SECRET_KEY
valueFrom:
secretKeyRef:
key: SECRET_KEY
name: oncall
- name: MIRAGE_SECRET_KEY
valueFrom:
secretKeyRef:
key: MIRAGE_SECRET_KEY
name: oncall
- name: MIRAGE_CIPHER_IV
value: 1234567890abcdef
- name: DJANGO_SETTINGS_MODULE
value: settings.helm
- name: AMIXR_DJANGO_ADMIN_PATH
value: admin
- name: OSS
value: "True"
- name: UWSGI_LISTEN
value: "1024"
- name: BROKER_TYPE
value: rabbitmq
- name: GRAFANA_API_URL
value: http://oncall-grafana
- name: DATABASE_TYPE
value: postgresql
- name: DATABASE_HOST
value: oncall-postgresql
- name: DATABASE_PORT
value: "5432"
- name: DATABASE_NAME
value: oncall
- name: DATABASE_USER
value: postgres
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
key: postgres-password
name: oncall-postgresql
- name: RABBITMQ_USERNAME
value: user
- name: RABBITMQ_PASSWORD
valueFrom:
secretKeyRef:
key: rabbitmq-password
name: oncall-rabbitmq
- name: RABBITMQ_HOST
value: oncall-rabbitmq
- name: RABBITMQ_PORT
value: "5672"
- name: RABBITMQ_PROTOCOL
value: amqp
- name: RABBITMQ_VHOST
value: ""
- name: REDIS_HOST
value: oncall-redis-master
- name: REDIS_PORT
value: "6379"
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
key: redis-password
name: oncall-redis
image: grafana/oncall:v1.2.36
imagePullPolicy: Always
name: wait-for-db
securityContext: {}

View file

@ -0,0 +1,108 @@
suite: test extra envs for deployments
templates:
- engine/deployment.yaml
- engine/job-migrate.yaml
- celery/deployment-celery.yaml
release:
name: oncall
tests:
- it: env=[] -> should support old syntax
set:
env:
- name: SOME_VAR
value: some_value
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: SOME_VAR
value: some_value
- it: env=map[] -> should set multiple envs
set:
env:
SOME_VAR: some_value
another_var: "another_value"
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: SOME_VAR
value: some_value
- contains:
path: spec.template.spec.containers[0].env
content:
name: another_var
value: "another_value"
- it: env=[] -> should add envs into initContainer
templates:
- engine/deployment.yaml
- celery/deployment-celery.yaml
set:
env:
- name: SOME_VAR
value: some_value
asserts:
- contains:
path: spec.template.spec.initContainers[0].env
content:
name: SOME_VAR
value: some_value
- it: env=map[] -> should add envs into initContainer
templates:
- engine/deployment.yaml
- celery/deployment-celery.yaml
set:
env:
SOME_VAR: some_value
another_var: "another_value"
asserts:
- contains:
path: spec.template.spec.initContainers[0].env
content:
name: SOME_VAR
value: some_value
- contains:
path: spec.template.spec.initContainers[0].env
content:
name: another_var
value: "another_value"
- it: database.type=postgresql and env=map[] -> should add envs into initContainer
templates:
- engine/deployment.yaml
- celery/deployment-celery.yaml
set:
database.type: postgresql
env:
SOME_VAR: some_value
another_var: "another_value"
asserts:
- contains:
path: spec.template.spec.initContainers[0].env
content:
name: SOME_VAR
value: some_value
- contains:
path: spec.template.spec.initContainers[0].env
content:
name: another_var
value: "another_value"
- it: database.type=postgresql and env=[] -> should support old style for initContainer
templates:
- engine/deployment.yaml
- celery/deployment-celery.yaml
set:
database.type: postgresql
env:
- name: SOME_VAR
value: some_value
asserts:
- contains:
path: spec.template.spec.initContainers[0].env
content:
name: SOME_VAR
value: some_value

View file

@ -0,0 +1,33 @@
suite: test image and imagePullPolicy for deployments
templates:
- celery/deployment-celery.yaml
- engine/deployment.yaml
- engine/job-migrate.yaml
release:
name: oncall
chart:
appVersion: 1.2.36
tests:
- it: image={} -> should use default image tag
asserts:
- equal:
path: spec.template.spec.containers[0].image
value: grafana/oncall:1.2.36
- equal:
path: spec.template.spec.containers[0].imagePullPolicy
value: Always
- it: image.repository and image.tag -> should use custom image
set:
image:
repository: custom-oncall
tag: 1.2.36-custom
pullPolicy: IfNotPresent
asserts:
- equal:
path: spec.template.spec.containers[0].image
value: custom-oncall:1.2.36-custom
- equal:
path: spec.template.spec.containers[0].imagePullPolicy
value: IfNotPresent

View file

@ -0,0 +1,25 @@
suite: test image pull secrets
templates:
- celery/deployment-celery.yaml
- engine/deployment.yaml
- engine/job-migrate.yaml
release:
name: oncall
tests:
- it: imagePullSecrets=[] -> should not create spec.template.spec.imagePullSecrets
set:
imagePullSecrets: []
asserts:
- notExists:
path: spec.template.spec.imagePullSecrets
- it: imagePullSecrets -> should use custom imagePullSecrets
set:
imagePullSecrets:
- name: regcred
asserts:
- contains:
path: spec.template.spec.imagePullSecrets
content:
name: regcred

View file

@ -0,0 +1,118 @@
suite: test MySQL envs for deployments
templates:
- engine/deployment.yaml
- engine/job-migrate.yaml
- celery/deployment-celery.yaml
release:
name: oncall
tests:
- it: mariadb.enabled=false -> external MySQL default settings
set:
mariadb.enabled: false
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: DATABASE_TYPE
not: true
- contains:
path: spec.template.spec.containers[0].env
content:
name: MYSQL_DB_NAME
value: oncall
- contains:
path: spec.template.spec.containers[0].env
content:
name: MYSQL_PORT
value: "3306"
- contains:
path: spec.template.spec.containers[0].env
content:
name: MYSQL_USER
value: root
- contains:
path: spec.template.spec.containers[0].env
content:
name: MYSQL_HOST
value: oncall-mariadb
- it: externalMysql -> use external MySQL custom settings
set:
mariadb.enabled: false
externalMysql:
host: test-host
port: 5555
db_name: grafana_oncall
user: test_user
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: MYSQL_DB_NAME
value: grafana_oncall
- contains:
path: spec.template.spec.containers[0].env
content:
name: MYSQL_PORT
value: "5555"
- contains:
path: spec.template.spec.containers[0].env
content:
name: MYSQL_USER
value: test_user
- contains:
path: spec.template.spec.containers[0].env
content:
name: MYSQL_HOST
value: test-host
- it: mariadb.enabled=true -> internal MySQL default settings
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: MYSQL_DB_NAME
value: oncall
- contains:
path: spec.template.spec.containers[0].env
content:
name: MYSQL_PORT
value: "3306"
- contains:
path: spec.template.spec.containers[0].env
content:
name: MYSQL_USER
value: root
- contains:
path: spec.template.spec.containers[0].env
content:
name: MYSQL_HOST
value: oncall-mariadb
- it: mariadb.auth -> internal MySQL custom settings
set:
mariadb:
auth:
database: grafana_oncall
username: grafana_oncall
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: MYSQL_DB_NAME
value: grafana_oncall
- contains:
path: spec.template.spec.containers[0].env
content:
name: MYSQL_PORT
value: "3306"
- contains:
path: spec.template.spec.containers[0].env
content:
name: MYSQL_USER
value: grafana_oncall
- contains:
path: spec.template.spec.containers[0].env
content:
name: MYSQL_HOST
value: oncall-mariadb

View file

@ -0,0 +1,46 @@
suite: test MySQL password envs for deployments
release:
name: oncall
templates:
- engine/deployment.yaml
- engine/job-migrate.yaml
- celery/deployment-celery.yaml
- secrets.yaml
tests:
- it: secrets -> should fail if externalMysql.password not set
set:
mariadb.enabled: false
asserts:
- failedTemplate:
errorMessage: externalMysql.password is required if not mariadb.enabled
template: secrets.yaml
- it: externalMySQL.password -> should create a Secret -mariadb-external
templates:
- engine/deployment.yaml
- engine/job-migrate.yaml
- celery/deployment-celery.yaml
set:
mariadb.enabled: false
externalMysql:
password: abcd123
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
name: oncall-mysql-external
key: mariadb-root-password
- containsDocument:
kind: Secret
apiVersion: v1
name: oncall-mysql-external
template: secrets.yaml
- equal:
path: data.mariadb-root-password
value: abcd123
decodeBase64: true
documentIndex: 1
template: secrets.yaml

View file

@ -1,4 +1,4 @@
suite: test postgresql deployment environments
suite: test PostgreSQL envs for deployments
templates:
- engine/deployment.yaml
- engine/job-migrate.yaml
@ -6,7 +6,7 @@ templates:
release:
name: oncall
tests:
- it: external Postgresql default settings
- it: postgresql.enabled=false -> external PostgreSQL default settings
set:
database.type: postgresql
postgresql.enabled: false
@ -37,7 +37,7 @@ tests:
name: DATABASE_HOST
value: oncall-postgresql
- it: external Postgresql custom settings
- it: externalPostgresql -> should use external PostgreSQL custom settings
set:
database.type: postgresql
postgresql.enabled: false
@ -73,7 +73,7 @@ tests:
name: DATABASE_HOST
value: test-host
- it: internal Postgresql default settings
- it: postgresql.enabled=true -> internal PostgreSQL default settings
set:
database.type: postgresql
postgresql.enabled: true
@ -104,7 +104,7 @@ tests:
name: DATABASE_HOST
value: oncall-postgresql
- it: internal Postgresql custom settings
- it: postgresql.auth -> should use internal PostgreSQL custom settings
set:
database.type: postgresql
postgresql:

View file

@ -1,4 +1,4 @@
suite: test postgresql password deployment environments
suite: test PostgreSQL password envs for deployments
release:
name: oncall
templates:
@ -7,7 +7,7 @@ templates:
- celery/deployment-celery.yaml
- secrets.yaml
tests:
- it: should fail if externalPostgresql.password not set
- it: secrets -> should fail if externalPostgresql.password not set
set:
database.type: postgresql
postgresql.enabled: false
@ -16,7 +16,7 @@ tests:
errorMessage: externalPostgresql.password is required if not postgresql.enabled and not externalPostgresql.existingSecret
template: secrets.yaml
- it: externalPostgresql.password should create Secret -postgresql-external
- it: externalPostgresql.password -> should create a Secret -postgresql-external
templates:
- engine/deployment.yaml
- engine/job-migrate.yaml
@ -47,7 +47,7 @@ tests:
documentIndex: 1
template: secrets.yaml
- it: externalPostgresql.existingSecret should use existing secret
- it: externalPostgresql.existingSecret -> should use existing secret
templates:
- engine/deployment.yaml
- engine/job-migrate.yaml
@ -67,7 +67,7 @@ tests:
name: some-postgres-secret
key: postgres-password
- it: externalPostgresql.passwordKey should be used for existing secret
- it: externalPostgresql.passwordKey -> should be used for existing secret
templates:
- engine/deployment.yaml
- engine/job-migrate.yaml
@ -88,7 +88,7 @@ tests:
name: some-postgres-secret
key: postgres.key
- it: internal Postgresql custom settings
- it: postgresql.auth -> should use internal Postgresql custom settings
templates:
- engine/deployment.yaml
- engine/job-migrate.yaml

View file

@ -0,0 +1,39 @@
suite: test security context for deployments
templates:
- celery/deployment-celery.yaml
- engine/deployment.yaml
- engine/job-migrate.yaml
release:
name: oncall
tests:
- it: podSecurityContext={} -> spec.template.spec.securityContext is empty (default)
set:
asserts:
- isNullOrEmpty:
path: spec.template.spec.securityContext
- isNullOrEmpty:
path: spec.template.spec.containers[0].securityContext
- it: podSecurityContext.runAsNonRoot=true -> should fill securityContext
set:
podSecurityContext:
runAsNonRoot: true
runAsUser: 1000
asserts:
- isSubset:
path: spec.template.spec.securityContext
content:
runAsNonRoot: true
runAsUser: 1000
- it: securityContext.runAsNonRoot=true -> should fill securityContext for container
set:
securityContext:
runAsNonRoot: true
runAsUser: 1000
asserts:
- isSubset:
path: spec.template.spec.containers[0].securityContext
content:
runAsNonRoot: true
runAsUser: 1000

View file

@ -0,0 +1,30 @@
suite: test service account deployments
templates:
- celery/deployment-celery.yaml
- engine/deployment.yaml
- engine/job-migrate.yaml
release:
name: oncall
tests:
- it: serviceAccount.create=true -> should use created serviceAccount for deployments (default)
asserts:
- equal:
path: spec.template.spec.serviceAccountName
value: oncall
- it: serviceAccount.create=false -> should use default serviceAccount for deployments
set:
serviceAccount.create: false
asserts:
- equal:
path: spec.template.spec.serviceAccountName
value: default
- it: serviceAccount.name=custom -> should use created custom serviceAccount for deployments
set:
serviceAccount.name: custom
asserts:
- equal:
path: spec.template.spec.serviceAccountName
value: custom

View file

@ -0,0 +1,44 @@
suite: test service account
templates:
- serviceaccount.yaml
release:
name: oncall
tests:
- it: serviceAccount.create=true -> should create serviceAccount (default)
asserts:
- containsDocument:
kind: ServiceAccount
apiVersion: v1
name: oncall
- notExists:
path: metadata.annotations
- isSubset:
path: metadata.labels
content:
app.kubernetes.io/instance: oncall
app.kubernetes.io/name: oncall
- it: serviceAccount.create=false -> should not create serviceAccount
set:
serviceAccount.create: false
asserts:
- hasDocuments:
count: 0
- it: serviceAccount.name=custom -> should create custom serviceAccount
set:
serviceAccount.name: custom
asserts:
- equal:
path: metadata.name
value: custom
- it: serviceAccount.annotations -> should add annotations to serviceAccount
set:
serviceAccount.annotations:
some-annotation: some-value
asserts:
- isSubset:
path: metadata.annotations
content:
some-annotation: some-value

View file

@ -0,0 +1,55 @@
suite: test telegram envs for deployments
templates:
- engine/deployment.yaml
- celery/deployment-celery.yaml
release:
name: oncall
tests:
- it: oncall.telegram.enabled=false -> Telegram integration disabled (default)
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: FEATURE_TELEGRAM_INTEGRATION_ENABLED
value: "False"
- it: oncall.telegram.enabled=true -> should enable Telegram integration
set:
oncall.telegram:
enabled: true
webhookUrl: https://example.com
token: "abcd:123"
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: FEATURE_TELEGRAM_INTEGRATION_ENABLED
value: "True"
- contains:
path: spec.template.spec.containers[0].env
content:
name: TELEGRAM_WEBHOOK_HOST
value: "https://example.com"
- contains:
path: spec.template.spec.containers[0].env
content:
name: TELEGRAM_TOKEN
value: "abcd:123"
- it: oncall.telegram.existingSecret=some-secret -> should prefer existing secret over oncall.telegram.token
set:
oncall.telegram:
enabled: true
token: "abcd:123"
existingSecret: some-secret
tokenKey: token
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: TELEGRAM_TOKEN
valueFrom:
secretKeyRef:
name: some-secret
key: token

View file

@ -6,7 +6,7 @@ templates:
release:
name: oncall
tests:
- it: uwsgi.listen should overwrite UWSGI_LISTEN env
- it: uwsgi.listen -> should overwrite UWSGI_LISTEN env
set:
uwsgi.listen: 128
asserts:
@ -15,7 +15,7 @@ tests:
content:
name: UWSGI_LISTEN
value: "128"
- it: uwsgi.envs should set multiple UWSGI_* envs
- it: uwsgi=map[] -> should set multiple UWSGI_* envs
set:
uwsgi:
processes: 3
@ -36,7 +36,8 @@ tests:
content:
name: UWSGI_MAX_REQUESTS
value: "1000"
- it: uwsgi.null should not set any UWSGI_* variable
- it: uwsgi=null -> should not set any UWSGI_* variable
set:
uwsgi: null
asserts:

View file

@ -0,0 +1,41 @@
suite: test init container wait-for-db in deployments
templates:
- celery/deployment-celery.yaml
- engine/deployment.yaml
release:
name: oncall
chart:
appVersion: v1.2.36
tests:
- it: database.type=mysql -> should create initContainer for MySQL database (default)
asserts:
- contains:
path: spec.template.spec.initContainers
content:
name: wait-for-db
any: true
- contains:
path: spec.template.spec.initContainers[0].env
content:
name: MYSQL_DB_NAME
value: oncall
- matchSnapshot:
path: spec.template.spec.initContainers
- it: database.type=postgresql -> should create initContainer for PostgreSQL database
set:
database.type: postgresql
asserts:
- contains:
path: spec.template.spec.initContainers
content:
name: wait-for-db
any: true
- contains:
path: spec.template.spec.initContainers[0].env
content:
name: DATABASE_TYPE
value: postgresql
- matchSnapshot:
path: spec.template.spec.initContainers

View file

@ -4,6 +4,14 @@
# If you want to install grafana as a part of this release make sure to configure grafana.grafana.ini.server.domain too
base_url: example.com
## Optionally specify an array of imagePullSecrets.
## Secrets must be manually created in the namespace.
## ref: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/
## e.g:
## imagePullSecrets:
## - name: myRegistryKeySecretName
imagePullSecrets: []
image:
# Grafana OnCall docker image repository
repository: grafana/oncall
@ -67,6 +75,9 @@ celery:
initialDelaySeconds: 30
periodSeconds: 300
timeoutSeconds: 10
## Node labels for pod assignment
## ref: https://kubernetes.io/docs/user-guide/node-selection/
nodeSelector: {}
resources: {}
# limits:
# cpu: 100m
@ -130,6 +141,9 @@ oncall:
password: ~
tls: ~
fromEmail: ~
exporter:
enabled: false
authToken: ~
twilio:
# Twilio account SID/username to allow OnCall to send SMSes and make phone calls
accountSid: ""
@ -161,6 +175,9 @@ oncall:
# Whether to run django database migrations automatically
migrate:
enabled: true
## Node labels for pod assignment
## ref: https://kubernetes.io/docs/user-guide/node-selection/
nodeSelector: {}
# TTL can be unset by setting ttlSecondsAfterFinished: ""
ttlSecondsAfterFinished: 20
# use a helm hook to manage the migration job
@ -321,6 +338,9 @@ grafana:
serve_from_sub_path: true
persistence:
enabled: true
# Disable psp as PodSecurityPolicy is deprecated in v1.21+, unavailable in v1.25+
rbac:
pspEnabled: false
plugins:
- grafana-oncall-app

View file

@ -1,16 +0,0 @@
# Substituting bitnami image with official image
# to be able to run Rabbitmq on arm64 (Mac M1)
# Optional for amd64 systems
rabbitmq:
enabled: true
image:
repository: rabbitmq
tag: 3.10.10
auth:
username: user
password: user
extraEnvVars:
- name: RABBITMQ_DEFAULT_USER
value: user
- name: RABBITMQ_DEFAULT_PASS
value: user

View file

@ -107,11 +107,9 @@ webhook integrations to adjust them for incoming payloads.
Configuration is done via environment variables passed to the docker container.
<!-- markdownlint-disable MD013 -->
| Name | Description | Type | Default |
|-----------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------|---------|
| `PAGERDUTY_API_TOKEN` | PagerDuty API **user token**. To create a token, refer to [PagerDuty docs](<https://support.pagerduty.com/docs/api-access-keys#generate-a-user-token-rest-api-key>). | String | N/A |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------- | ------- |
| `PAGERDUTY_API_TOKEN` | PagerDuty API **user token**. To create a token, refer to [PagerDuty docs](https://support.pagerduty.com/docs/api-access-keys#generate-a-user-token-rest-api-key). | String | N/A |
| `ONCALL_API_URL` | Grafana OnCall API URL. This can be found on the "Settings" page of your Grafana OnCall instance. | String | N/A |
| `ONCALL_API_TOKEN` | Grafana OnCall API Token. To create a token, navigate to the "Settings" page of your Grafana OnCall instance. | String | N/A |
| `MODE` | Migration mode (plan vs actual migration). | String (choices: `plan`, `migrate`) | `plan` |
@ -120,8 +118,6 @@ Configuration is done via environment variables passed to the docker container.
| `EXPERIMENTAL_MIGRATE_EVENT_RULES` | Migrate global event rulesets to Grafana OnCall integrations. | Boolean | `false` |
| `EXPERIMENTAL_MIGRATE_EVENT_RULES_LONG_NAMES` | Include service & integrations names from PD in migrated integrations (only effective when `EXPERIMENTAL_MIGRATE_EVENT_RULES` is `true`). | Boolean | `false` |
<!-- markdownlint-enable MD013 -->
## Resources
### User notification rules
@ -144,11 +140,11 @@ The tool is capable of migrating on-call schedules from PagerDuty to Grafana OnC
There are two ways to migrate on-call schedules:
- Migrate on-call shifts as if they were created in Grafana OnCall web UI. Due to scheduling differences between
PagerDuty and Grafana OnCall, it's sometimes impossible to automatically migrate on-call shifts without manual changes
in PD. Pass `SCHEDULE_MIGRATION_MODE=web` to the tool to enable this mode.
PagerDuty and Grafana OnCall, it's sometimes impossible to automatically migrate on-call shifts without manual changes
in PD. Pass `SCHEDULE_MIGRATION_MODE=web` to the tool to enable this mode.
- Using ICalendar file URLs from PagerDuty. This way it's always possible to migrate schedules without any manual
changes in PD, but resulting schedules in Grafana OnCall will be read-only. Pass `SCHEDULE_MIGRATION_MODE=ical` to the tool
to enable this mode.
changes in PD, but resulting schedules in Grafana OnCall will be read-only. Pass `SCHEDULE_MIGRATION_MODE=ical` to
the tool to enable this mode.
On-call schedules will be migrated to new Grafana OnCall schedules with the same name as in PD. Any existing schedules
with the same name will be deleted before migration. Any on-call schedules that reference unmatched users won't be
@ -197,4 +193,4 @@ but it can also make the names of integrations too long.
- Connect integrations (press the "How to connect" button on the integration page)
- Make sure users connect their phone numbers, Slack accounts, etc. in their user settings
- When using `SCHEDULE_MIGRATION_MODE=ical`, at some point you would probably want to recreate schedules using
Google Calendar or Terraform to be able to modify migrated on-call schedules in Grafana OnCall
Google Calendar or Terraform to be able to modify migrated on-call schedules in Grafana OnCall