Merge pull request #1211 from grafana/dev

Merge dev to main
This commit is contained in:
Ildar Iskhakov 2023-01-25 12:16:16 +08:00 committed by GitHub
commit 587ef5ae25
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
81 changed files with 2946 additions and 315 deletions

View file

@ -27,6 +27,7 @@ steps:
- apt-get install zip
- cd grafana-plugin
- yarn sign
- if [ ! -f dist/MANIFEST.txt ]; then echo "Sign failed, MANIFEST.txt not created, aborting." && exit 1; fi
- yarn ci-build:finish
- yarn ci-package
- cd ci/dist
@ -194,6 +195,7 @@ steps:
- apt-get install zip
- cd grafana-plugin
- yarn sign
- if [ ! -f dist/MANIFEST.txt ]; then echo "Sign failed, MANIFEST.txt not created, aborting." && exit 1; fi
- yarn ci-build:finish
- yarn ci-package
- cd ci/dist
@ -415,6 +417,6 @@ kind: secret
name: drone_token
---
kind: signature
hmac: f77d17560f910f1a99ab8230674dc25c226d2b3c73cb90e63e53fb8ba760d57a
hmac: 662c2be2ccdd106ae4f23a557f981ef601d9693b0333e0bcda7189ddf16fb49a
...

23
.github/verify-public-docs-updated.sh vendored Executable file
View file

@ -0,0 +1,23 @@
#!/bin/bash
ENGINE_DIR="engine"
UI_DIR="grafana-plugin"
PUBLIC_DOCS_DIR="docs"
DIRS_CHANGED=$(git diff HEAD~1 --name-only | xargs dirname | sort | uniq) # https://stackoverflow.com/a/73149899/3902555
if [[ $DIRS_CHANGED =~ $ENGINE_DIR ]] || [[ $DIRS_CHANGED =~ $UI_DIR ]]; then
echo "Changes were made to the ${ENGINE_DIR} and/or ${UI_DIR} directories"
# check if we have any changes to the public docs directory as well. If not,
if [[ ! $DIRS_CHANGED =~ $PUBLIC_DOCS_DIR ]]; then
echo "Changes were not made to the public documentation (${PUBLIC_DOCS_DIR} directory). Either update the documentation accordingly with your changes, or add the 'no public docs' label if changes to the public docs are not necessary for your PR."
exit 1
else
echo "Changes were also made to the public documentation. Thank you!"
exit 0
fi
else
echo "Changes were not made to either the ${ENGINE_DIR} or ${UI_DIR} directories"
fi

42
.github/workflows/helm_release_pr.yml vendored Normal file
View file

@ -0,0 +1,42 @@
name: Create PR to release updated oncall Helm chart
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
jobs:
update-helm-chart-versions:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Define app_version and helm version
id: tags
run: |
# Strip git ref prefix from version
APP_VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
# Strip "v" prefix from tag name
[[ "${{ github.ref }}" == "refs/tags/"* ]] && HELM_VERSION=$(echo $APP_VERSION | sed -e 's/^v//')
echo "::set-output name=app_version::$APP_VERSION"
echo "::set-output name=helm_version::$HELM_VERSION"
- name: Update oncall Helm chart Chart.yaml
uses: fjogeleit/yaml-update-action@v0.12.3
with:
valueFile: 'helm/oncall/Chart.yaml'
branch: helm-release/${{ steps.tags.outputs.helm_version }}
targetBranch: main
masterBranchName: main
createPR: 'true'
description: "Merge this PR to `main` branch to start another
[github actions job](https://github.com/grafana/oncall/blob/dev/.github/workflows/helm_release.yml)
that will release the updated version of the chart
(version: ${{ steps.tags.outputs.helm_version }}, appVersion: ${{ steps.tags.outputs.app_version }})
into `grafana/helm-charts` helm repository. \n\n
This PR was created automatically by this
[github action](https://github.com/grafana/oncall/blob/dev/.github/workflows/helm_release_pr.yml)."
message: 'Release oncall Helm chart ${{ steps.tags.outputs.helm_version }}'
changes: |
{
"version": "${{ steps.tags.outputs.helm_version }}",
"appVersion": "${{ steps.tags.outputs.app_version }}"
}

View file

@ -0,0 +1,23 @@
name: Verify CHANGELOG updated
on:
pull_request:
types: [assigned, opened, synchronize, reopened, labeled, unlabeled]
branches:
- main
- dev
jobs:
verfiy-changelog-updated:
name: Verify CHANGELOG updated
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Changelog check
uses: Zomzog/changelog-checker@v1.3.0
with:
fileName: CHANGELOG.md
noChangelogLabel: no changelog
checkNotification: Simple
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -0,0 +1,20 @@
name: Verify public documentation updated
on:
pull_request:
types: [assigned, opened, synchronize, reopened, labeled, unlabeled]
branches:
- main
- dev
jobs:
verify-public-docs-updated:
name: Verify public documentation updated
# Don't run this job if the "no public docs" label is applied to the PR
# https://github.com/orgs/community/discussions/26712#discussioncomment-3253012
if: "!contains(github.event.pull_request.labels.*.name, 'no public docs')"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Public documentation checker
run: ./.github/verify-public-docs-updated.sh

View file

@ -10,6 +10,10 @@ repos:
files: ^tools/pagerduty-migrator
args:
[--settings-file=tools/pagerduty-migrator/.isort.cfg, --filter-files]
- id: isort
name: isort - dev/scripts
files: ^dev/scripts
args: [--settings-file=dev/scripts/.isort.cfg, --filter-files]
- repo: https://github.com/psf/black
rev: 22.3.0
@ -20,6 +24,9 @@ repos:
- id: black
name: black - pd-migrator
files: ^tools/pagerduty-migrator
- id: black
name: black - dev/scripts
files: ^dev/scripts
- repo: https://github.com/pycqa/flake8
rev: 3.9.2
@ -40,6 +47,17 @@ repos:
"--select=C,E,F,W,B,B950",
"--extend-ignore=E203,E501",
]
- id: flake8
name: flake8 - dev/scripts
files: ^dev/scripts
# Make sure config is compatible with black
# https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html#line-length
args:
[
--max-line-length=88,
"--select=C,E,F,W,B,B950",
"--extend-ignore=E203,E501",
]
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.25.0
@ -87,7 +105,7 @@ repos:
hooks:
- id: markdownlint
name: markdownlint
entry: markdownlint --ignore grafana-plugin/node_modules --ignore grafana-plugin/dist --ignore docs **/*.md
entry: markdownlint --fix --ignore grafana-plugin/node_modules --ignore grafana-plugin/dist --ignore docs **/*.md
- id: markdownlint
name: markdownlint - docs
entry: markdownlint --ignore grafana-plugin/node_modules --ignore grafana-plugin/dist -c ./docs/.markdownlint.json ./docs/**/*.md
entry: markdownlint --fix -c ./docs/.markdownlint.json ./docs/**/*.md

View file

@ -5,6 +5,24 @@ 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).
## v1.1.18 (2023-01-25)
### Added
- Add Slack slash command allowing to trigger a direct page via a manually created alert group
- Remove resolved and acknowledged filters as we switched to status ([#1201](https://github.com/grafana/oncall/pull/1201))
- Add sync with grafana on /users and /teams api calls from terraform plugin
### Changed
- Allow users with `viewer` role to fetch cloud connection status using the internal API ([#1181](https://github.com/grafana/oncall/pull/1181))
- When removing the Slack ChatOps integration, make it more explicit to the user what the implications of doing so are
### Fixed
- Removed duplicate API call, in the UI on plugin initial load, to `GET /api/internal/v1/alert_receive_channels`
- Increased plugin startup speed ([#1200](https://github.com/grafana/oncall/pull/1200))
## v1.1.18 (2023-01-18)
### Added
@ -20,7 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Modified how the `Organization.is_rbac_permissions_enabled` flag is set,
based on whether we are dealing with an open-source, or cloud installation
based on whether we are dealing with an open-source, or cloud installation
- Backend implementation to support direct user/schedule paging
- Changed documentation links to open in new window
- Remove helm chart signing

1
dev/.gitignore vendored
View file

@ -1 +1,2 @@
.env.dev
grafana.dev.ini

View file

@ -3,6 +3,8 @@
- [Running the project](#running-the-project)
- [`COMPOSE_PROFILES`](#compose_profiles)
- [`GRAFANA_VERSION`](#grafana_version)
- [Configuring Grafana](#configuring-grafana)
- [Django Silk Profiling](#django-silk-profiling)
- [Running backend services outside Docker](#running-backend-services-outside-docker)
- [Useful `make` commands](#useful-make-commands)
- [Setting environment variables](#setting-environment-variables)
@ -14,6 +16,8 @@
- [django.db.utils.OperationalError: (1366, "Incorrect string value")](#djangodbutilsoperationalerror-1366-incorrect-string-value)
- [/bin/sh: line 0: cd: grafana-plugin: No such file or directory](#binsh-line-0-cd-grafana-plugin-no-such-file-or-directory)
- [Encountered error while trying to install package - grpcio](#encountered-error-while-trying-to-install-package---grpcio)
- [distutils.errors.CompileError: command '/usr/bin/clang' failed with exit code 1](#distutilserrorscompileerror-command-usrbinclang-failed-with-exit-code-1)
- [symbol not found in flat namespace '\_EVP_DigestSignUpdate'](#symbol-not-found-in-flat-namespace-_evp_digestsignupdate)
- [IDE Specific Instructions](#ide-specific-instructions)
- [PyCharm](#pycharm)
@ -80,6 +84,33 @@ If you would like to change the version of Grafana being run, simply pass in a `
to `make start` (or alternatively set it in your `.env.dev` file). The value of this environment variable should be a
valid `grafana/grafana` published Docker [image tag](https://hub.docker.com/r/grafana/grafana/tags).
### Configuring Grafana
This section is applicable for when you are running a Grafana container inside of `docker-compose` and you would like
to modify your Grafana instance's provisioning configuration.
The following commands assume you run them from the root of the project:
```bash
touch ./dev/grafana.dev.ini
# make desired changes to ./dev/grafana.dev.ini then run
touch .env && ./dev/add_env_var.sh GRAFANA_DEV_PROVISIONING ./dev/grafana.dev.ini .env
```
The next time you start the project via `docker-compose`, the `grafana` container will have `./dev/grafana.dev.ini`
volume mounted inside the container.
### Django Silk Profiling
In order to setup [`django-silk`](https://github.com/jazzband/django-silk) for local profiling, perform the following
steps:
1. `make engine-manage CMD="createsuperuser"` - follow CLI prompts to create a Django superuser
2. Visit <http://localhost:8080/django-admin> and login using the credentials you created in step #2
You should now be able to visit <http://localhost:8080/silk/> and see the Django Silk UI.
See the `django-silk` documentation [here](https://github.com/jazzband/django-silk) for more information.
### Running backend services outside Docker
By default everything runs inside Docker. If you would like to run the backend services outside of Docker
@ -306,6 +337,49 @@ Use a `conda` virtualenv, and then run the following when installing the engine
GRPC_PYTHON_BUILD_SYSTEM_OPENSSL=1 GRPC_PYTHON_BUILD_SYSTEM_ZLIB=1 pip install -r requirements.txt
```
### distutils.errors.CompileError: command '/usr/bin/clang' failed with exit code 1
See solution for "Encountered error while trying to install package - grpcio" [here](#encountered-error-while-trying-to-install-package---grpcio)
### symbol not found in flat namespace '\_EVP_DigestSignUpdate'
**Problem:**
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
make backend-bootstrap
make run-backend-celery
File "~/oncall/engine/engine/__init__.py", line 5, in <module>
from .celery import app as celery_app
File "~/oncall/engine/engine/celery.py", line 11, in <module>
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
File "/opt/homebrew/Caskroom/miniconda/base/envs/oncall-dev/lib/python3.9/site-packages/opentelemetry/exporter/otlp/proto/grpc/trace_exporter/__init__.py", line 20, in <module>
from grpc import ChannelCredentials, Compression
File "/opt/homebrew/Caskroom/miniconda/base/envs/oncall-dev/lib/python3.9/site-packages/grpc/__init__.py", line 22, in <module>
from grpc import _compression
File "/opt/homebrew/Caskroom/miniconda/base/envs/oncall-dev/lib/python3.9/site-packages/grpc/_compression.py", line 20, in <module>
from grpc._cython import cygrpc
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
the `grpc/grpc` repository, fixes the issue:
```bash
conda install grpcio
make run-backend-celery
```
## IDE Specific Instructions
### PyCharm

2
dev/scripts/.isort.cfg Normal file
View file

@ -0,0 +1,2 @@
[settings]
profile=black

View file

@ -0,0 +1,45 @@
# Fake Data Generator Script
This script can be used to easily populate fake data into your local Grafana/OnCall setup. Currently the script is
capable of generating the following objects:
- teams
- users
- schedules
- schedule on call shifts
## Prerequisites
1. Create/active a Python 3.9 virtual environment
2. `pip install -r requirements.txt`
3. Must have a local version of Grafana and OnCall up and running
4. Generate an API key inside of Grafana OnCall
## How to run
**Note**: The below flag values assume you are running a `grafana` container locally via the `docker-compose` setup.
The reason why there is a few separate steps involved is that we need to first create teams and users in the Grafana
instance. Later on, in order to create OnCall schedules/oncall-shifts, we need the OnCall user ID to do so. There is
currently no way to trigger a Grafana -> OnCall data sync via the public API, hence the manual step in the middle
to have data synced between Grafana and OnCall.
1. Create teams and users in Grafana. The `teams` and `users` flags represent the number of teams and users you would
like to create respectively:
```bash
# by default this will generate 10 teams and 1000 users
python main.py generate_teams_and_users
```
See `python main.py generate_teams_and_users -h` for more information on how to run the command.
2. Head to your OnCall setup, and trigger a Grafana -> OnCall data sync by visiting the plugin page.
3. Create schedules and on call shifts in OnCall. The `schedules` flag represents the number of OnCall schedules you
would like to generate. **Note** that one on call shift is created for each schedule:
```bash
# by default this will generate 100 schedules
python main.py generate_schedules_and_oncall_shifts --oncall-api-token=<oncall-api-key>
```
See `python main.py generate_schedules_and_oncall_shifts -h` for more information on how to run the command.

View file

@ -0,0 +1,305 @@
import argparse
import asyncio
import math
import random
import typing
import uuid
from datetime import datetime
import aiohttp
from faker import Faker
from tqdm.asyncio import tqdm
fake = Faker()
TEAMS_USERS_COMMAND = "generate_teams_and_users"
SCHEDULES_ONCALL_SHIFTS_COMMAND = "generate_schedules_and_oncall_shifts"
GRAFANA_API_URL = None
ONCALL_API_URL = None
ONCALL_API_TOKEN = None
class OnCallApiUser(typing.TypedDict):
id: str
class OnCallApiOnCallShift(typing.TypedDict):
id: str
class OnCallApiListUsersResponse(typing.TypedDict):
results: typing.List[OnCallApiUser]
class GrafanaAPIUser(typing.TypedDict):
id: int
def _generate_unique_email() -> str:
user = fake.profile()
return f'{uuid.uuid4()}-{user["mail"]}'
async def _grafana_api_request(
http_session: aiohttp.ClientSession, method: str, url: str, **request_kwargs
) -> typing.Awaitable[typing.Dict]:
resp = await http_session.request(
method, f"{GRAFANA_API_URL}{url}", **request_kwargs
)
return await resp.json()
async def _oncall_api_request(
http_session: aiohttp.ClientSession, method: str, url: str, **request_kwargs
) -> typing.Awaitable[typing.Dict]:
resp = await http_session.request(
method,
f"{ONCALL_API_URL}{url}",
headers={"Authorization": ONCALL_API_TOKEN},
**request_kwargs,
)
return await resp.json()
def generate_team(
http_session: aiohttp.ClientSession, org_id: int
) -> typing.Callable[[], typing.Awaitable[typing.Dict]]:
"""
https://grafana.com/docs/grafana/latest/developers/http_api/team/#add-team
"""
def _generate_team() -> typing.Awaitable[typing.Dict]:
return _grafana_api_request(
http_session,
"POST",
"/api/teams",
json={
"name": str(uuid.uuid4()),
"email": _generate_unique_email(),
"orgId": org_id,
},
)
return _generate_team
def generate_user(
http_session: aiohttp.ClientSession, org_id: int
) -> typing.Callable[[], typing.Awaitable[typing.Dict]]:
"""
https://grafana.com/docs/grafana/latest/developers/http_api/admin/#global-users
"""
async def _generate_user() -> typing.Awaitable[typing.Dict]:
user = fake.profile()
# create the user in grafana
grafana_user: GrafanaAPIUser = await _grafana_api_request(
http_session,
"POST",
"/api/admin/users",
json={
"name": user["name"],
"email": _generate_unique_email(),
"login": str(uuid.uuid4()),
"password": fake.password(length=20),
"OrgId": org_id,
},
)
# update the user's basic role in grafana to Admin
# https://grafana.com/docs/grafana/latest/developers/http_api/org/#updates-the-given-user
await _grafana_api_request(
http_session,
"PATCH",
f'/api/org/users/{grafana_user["id"]}',
json={"role": "Admin"},
)
return grafana_user
return _generate_user
def generate_schedule(
http_session: aiohttp.ClientSession, oncall_shift_ids: typing.List[str]
) -> typing.Callable[[], typing.Awaitable[typing.Dict]]:
def _generate_schedule() -> typing.Awaitable[typing.Dict]:
# Create a schedule
# https://grafana.com/docs/oncall/latest/oncall-api-reference/schedules/#create-a-schedule
return _oncall_api_request(
http_session,
"POST",
"/api/v1/schedules",
json={
"name": f"Schedule {uuid.uuid4()}",
"type": "calendar",
"time_zone": "UTC",
"shifts": oncall_shift_ids,
},
)
return _generate_schedule
def _bulk_generate_data(
iterations: int,
data_generator_func: typing.Callable[[], typing.Awaitable[typing.Dict]],
) -> typing.Awaitable[typing.List[typing.Dict]]:
return tqdm.gather(
*[asyncio.ensure_future(data_generator_func()) for _ in range(iterations)]
)
async def _generate_grafana_teams_and_users(
args: argparse.Namespace, http_session: aiohttp.ClientSession
) -> typing.Awaitable[None]:
global GRAFANA_API_URL
GRAFANA_API_URL = args.grafana_api_url
org_id = args.grafana_org_id
print("Generating team(s)")
await _bulk_generate_data(args.teams, generate_team(http_session, org_id))
print("Generating user(s)")
await _bulk_generate_data(args.users, generate_user(http_session, org_id))
print(
f"""
Grafana teams and users generated
Now head to the OnCall plugin and manually visit the plugin to trigger a sync. This will sync grafana
teams/users to OnCall. Once completed, you can run the {SCHEDULES_ONCALL_SHIFTS_COMMAND} command.
"""
)
async def _generate_oncall_schedules_and_oncall_shifts(
args: argparse.Namespace, http_session: aiohttp.ClientSession
) -> typing.Awaitable[None]:
global ONCALL_API_URL, ONCALL_API_TOKEN
ONCALL_API_URL = args.oncall_api_url
ONCALL_API_TOKEN = args.oncall_api_token
today = datetime.now()
print("Fetching users from OnCall API")
# Fetch users from the OnCall API
users: OnCallApiListUsersResponse = await _oncall_api_request(
http_session, "GET", "/api/v1/users"
)
user_ids: typing.List[str] = [u["id"] for u in users["results"]]
num_users = len(user_ids)
print(f"Fetched {num_users} user(s) from the OnCall API")
async def _create_oncall_shift(shift_start_time: str) -> typing.Awaitable[str]:
"""
Creates an eight hour shift.
`shift_start_time` - ex. 09:00:00, 15:00:00
https://grafana.com/docs/oncall/latest/oncall-api-reference/on_call_shifts/#create-an-oncall-shift
"""
new_shift: OnCallApiOnCallShift = await _oncall_api_request(
http_session,
"POST",
"/api/v1/on_call_shifts",
json={
"name": f"On call shift{uuid.uuid4()}",
"type": "rolling_users",
"start": today.strftime(f"%Y-%m-%dT{shift_start_time}"),
"time_zone": "UTC",
"duration": 60 * 60 * 8, # 8 hours
"frequency": "daily",
"week_start": "MO",
"rolling_users": [
[u] for u in random.choices(user_ids, k=math.floor(num_users / 2))
],
"start_rotation_from_user_index": 0,
"team_id": None,
},
)
oncall_shift_id = new_shift["id"]
print(f"Generated OnCall shift w/ ID {oncall_shift_id}")
return oncall_shift_id
print("Creating three 8h on-call shifts")
morning_shift_id = await _create_oncall_shift("00:00:00")
afternoon_shift_id = await _create_oncall_shift("08:00:00")
evening_shift_id = await _create_oncall_shift("16:00:00")
print("Generating schedules(s)")
await _bulk_generate_data(
args.schedules,
generate_schedule(
http_session, [morning_shift_id, afternoon_shift_id, evening_shift_id]
),
)
async def main() -> typing.Awaitable[None]:
parser = argparse.ArgumentParser(
description="Set of commands to help generate fake data in a Grafana OnCall setup."
)
subparsers = parser.add_subparsers(help="sub-command help")
grafana_command_parser = subparsers.add_parser(
TEAMS_USERS_COMMAND,
description="Command to generate teams and users in Grafana",
)
grafana_command_parser.set_defaults(func=_generate_grafana_teams_and_users)
grafana_command_parser.add_argument(
"--grafana-api-url",
help="Grafana API URL. This should include the basic authentication username/password in the URL. ex. http://oncall:oncall@localhost:3000",
default="http://oncall:oncall@localhost:3000",
)
grafana_command_parser.add_argument(
"--grafana-org-id",
help="Org ID, in Grafana, of the org that you would like to generate data for",
type=int,
default=1,
)
grafana_command_parser.add_argument(
"-t", "--teams", help="Number of teams to generate", default=10, type=int
)
grafana_command_parser.add_argument(
"-u", "--users", help="Number of users to generate", default=1_000, type=int
)
oncall_command_parser = subparsers.add_parser(
SCHEDULES_ONCALL_SHIFTS_COMMAND,
description="Command to generate schedules and on-call shifts in OnCall",
)
oncall_command_parser.set_defaults(
func=_generate_oncall_schedules_and_oncall_shifts
)
oncall_command_parser.add_argument(
"--oncall-api-url",
help="OnCall API URL",
default="http://localhost:8080",
)
oncall_command_parser.add_argument(
"--oncall-api-token", help="OnCall API token", required=True
)
oncall_command_parser.add_argument(
"-s",
"--schedules",
help="Number of schedules to generate",
default=100,
type=int,
)
args = parser.parse_args()
async with aiohttp.ClientSession(
connector=aiohttp.TCPConnector(limit=5)
) as session:
await args.func(args, session)
if __name__ == "__main__":
asyncio.run(main())

View file

@ -0,0 +1,3 @@
aiohttp==3.8.3
Faker==16.4.0
tqdm==4.64.1

View file

@ -26,6 +26,7 @@ x-env-vars: &oncall-env-vars
GRAFANA_API_URL: http://localhost:3000
GOOGLE_APPLICATION_CREDENTIALS: /etc/app/gcp_service_account.json
FCM_PROJECT_ID: oncall-mobile-dev
SILK_PROFILER_ENABLED: True
# basically this is needed because the oncall backend containers have been configured to communicate w/ grafana via
# http://localhost:3000 (GRAFANA_API_URL). This URL is used in two scenarios:
@ -296,6 +297,7 @@ services:
volumes:
- grafanadata_dev:/var/lib/grafana
- ./grafana-plugin:/var/lib/grafana/plugins/grafana-plugin
- ${GRAFANA_DEV_PROVISIONING:-/dev/null}:/etc/grafana/grafana.ini
depends_on:
postgres:
condition: service_healthy

View file

@ -1,68 +1,64 @@
---
title: On-call schedules
aliases:
- /docs/oncall/latest/calendar-schedules/
canonical: https://grafana.com/docs/oncall/latest/calendar-schedules/
description: ""
description: "Learn more about on-call schedules"
keywords:
- Grafana
- oncall
- on-call
- schedule
- calendar
title: Configure and manage on-call schedules
weight: 1100
---
# Configure and manage on-call schedules
# On-call schedules
Grafana OnCall allows you to use any calendar service that uses the iCal format to create customized on-call schedules
for team members. Using Grafana OnCall, you can create a primary calendar that acts as a read-only schedule, and an
override calendar that allows all team members to modify schedules as they change.
Grafana OnCall makes it easier to establish consistent and thoughtful on-call coverage while ensuring that alerts dont
go unnoticed. Use Grafana OnCall to:
To learn more about creating on-call calendars, see the following topics:
- Define coverage needs and avoid gaps in coverage
- Automate alert escalation
- Configure on-call shift notifications
## Configure and manage on-call schedules
This section provides conceptual information about Grafana OnCall schedule options.
You can use any calendar with an iCal address to schedule on-call times for users. During these times, notifications
configured in escalation chains with the **Notify users from an on-call schedule** setting will be sent to the the person
scheduled. You can also schedule multiple users for overlapping times, and assign prioritization labels for the user
that you would like to notify.
## About on-call schedules
When you create a schedule, you will be able to select a Slack channel, associated with your OnCall account, that will
notify users when there are errors or notifications regarding the assigned on-call shifts.
An on-call schedule consist of one or more rotations that contain on-call shifts. A schedule must be referenced in the
corresponding escalation chain for alert notifications to be sent to an on-call user.
## Create an on-call schedule calendar
A fully configured on-call schedule consists of three main components:
Create a primary calendar and an optional override calendar to schedule on-call shifts for team members.
- **Rotations**: A recurring schedule containing a set of on-call shifts that users rotate through.
- **On-call shifts**: The period of time that an individual user is on-call for a particular rotation
- **Escalation Chains**: Automated steps that determine who to notify of an alert group.
1. In the **Scheduling** section of Grafana OnCall, click **+ Create schedule**.
1. Give the schedule a name.
1. Create a new calendar in your calendar service and locate the secret iCal URL. For example, in a Google calendar,
this URL can be found in **Settings > Settings for my calendars > Integrate calendar**.
1. Copy the secret iCal URL. In OnCall, paste it into the **Primary schedule for iCal URL** field.
The permissions you set when you create the calendar determine who can modify the calendar.
1. Click **Create Schedule**.
1. Schedule on-call times for team members.
## Types of on-call schedules
Use the Grafana username of team members as the event name to schedule their on-call times. You can take advantage
of all of the features of your calendar service.
On-call schedules look different for different organizations and even teams. Grafana OnCall offers three different
options for managing your on-call schedules, so you can choose the option that best fits your needs.
1. Create overlapping schedules (optional).
### Web-based schedule
When you create schedules that overlap, you can prioritize a schedule by adding a level marker. For example, if users
AliceGrafana and BobGrafana have overlapping schedules, but BobGrafana is the primary contact, you would name his
event `[L1] BobGrafana`, AliceGrafana maintains the default `[L0]` status, and would not receive notifications during
the overlapping time. You can prioritize up to and including a level 9 prioritization, or `[L9]`.
Configure and manage on-call schedules directly in the Grafana OnCall plugin. Easily configure and preview rotations,
see teammates' time zones, and add overrides.
# Create an override calendar (optional)
Learn more about [Web-based schedules]({{< relref "web-schedule" >}})
You can use an override calendar to allow team members to schedule on-call duties that will override the primary schedule.
An override option allows flexibility without modifying the primary schedule. Events scheduled on the override calendar
will always override overlapping events on the primary calendar.
### iCal import
1. Create a new calendar using the same calendar service you used to create the primary calendar.
Use any calendar service that uses the iCal format to manage and customize on-call schedules - Import rotations and
shifts from your calendar app to Grafana OnCall for widely accessible scheduling. iCal imports appear in Grafana
OnCall as read-only schedules but can be leveraged similarly to a web-based schedule.
Be sure to set permissions that allow team members to edit the calendar.
Learn more about [iCal import schedules]({{< relref "ical-schedules" >}})
1. In the scheduling section of Grafana OnCall, select the primary calendar you want to override.
1. Click **Edit**.
1. Enter the secret iCal URL in the **Overrides schedule iCal URL** field and click **Update**.
### Terraform
Use the Grafana OnCall Terraform provider to manage schedules within your “as-code” workflow. Rotations configured
via Terraform are automatically added to your schedules in Grafana OnCall. Similar to the iCal import, these schedules
read-only and cannot be edited from the UI.
To learn more, read our [Get started with Grafana OnCall and Terraform](
https://grafana.com/blog/2022/08/29/get-started-with-grafana-oncall-and-terraform/) blog post.

View file

@ -0,0 +1,97 @@
---
title: Import on-call schedules
aliases:
- /docs/oncall/latest/calendar-schedules/ical-schedules/
canonical: https://grafana.com/docs/oncall/latest/calendar-schedules/ical-schedules/
description: "Learn how to manage on-call schedules with iCal import"
keywords:
- Grafana
- oncall
- on-call
- calendar
weight: 300
---
# Import on-call schedules
Use your existing calendar app with iCal format to manage and customize on-call schedules — import rotations and shifts
from your calendar app to Grafana OnCall for widely accessible scheduling. iCal imported schedules appear in Grafana
OnCall as read-only schedules but can be leveraged similarly to a web-based schedule.
## Before you begin
- Verify that your calendar app supports iCal format
- Ensure you have the proper permissions in Grafana OnCall
## Configure an on-call schedule from iCal import
There are three key parts to configuring on-call schedules using iCal import:
1. Create a primary on-call calendar and an optional override calendar in your calendar app.
1. Import the calendars into Grafana OnCall and configure additional schedule settings.
1. Link your schedule to corresponding escalation chains for alert notifications to be sent to the proper on-call user.
### Create your on-call schedule calendar
Create a dedicated calendar to map out your on-call coverage using calendar events. Be sure to take advantage of the
features of your calendar app to configure event recurrence, duplicate events, etc.
>**Note:** The exact steps in this section will vary based on your calendar.
To create an on-call schedule calendar:
1. Create a new calendar in your calendar app, then review and adjust default settings as needed.
2. In your new calendar, create events that represent on-call shifts. You must use Grafana usernames as the event title
3. to associate users with each shift.
4. Once your on-call calendar is complete, go to your calendar settings to locate the secret iCal URL. For example, in
5. a Google calendar, this URL can be found in **Settings** > **Settings for my calendars** > **Integrate calendar** >
6. **Secret address in iCal format**.
To learn more about how to configure your calendar events, refer to Calendar events.
### Import calendar to Grafana On-Call
Once youve configured on-call schedules in your calendar app, you can import them via iCal URL to your Grafana OnCall
instance.
>**Note:** Use the secret iCal URL to avoid making the calendar public. If you use the public iCal URL, the calendar
> and event details must be public for Grafana OnCall to read your calendar.
To import an on-call schedule:
1. In Grafana OnCall, navigate to the **Schedules** tab and click **+ New schedule**.
2. Navigate to **Import schedule from iCal URL** and click **+ Create**.
3. Copy the secret iCal URL from your calendar and paste it the **Primary schedule iCal URL** field. Repeat this step
4. for the **Override schedule iCal URL** field if you have an override calendar.
5. Provide a name and review available schedule settings.
6. When youre done, click **Create Schedule**.
### Create an override calendar (Optional)
An override calendar allows for on-call flexibility without modifying the primary schedule. You can use an override
calendar to enable users to schedule on-call shifts that will override the primary schedule. Events scheduled on the
override calendar will always override overlapping events on the primary calendar.
1. Create a new calendar using the same calendar service you used to create the primary calendar.
1. Be sure to set permissions that allow team members to edit the calendar.
1. In the **Schedules** tab of Grafana OnCall, select the primary calendar you want to override.Click **Edit**.
1. Enter the secret iCal URL in the **Overrides schedule iCal URL** field and click **Update**.
## Calendar events
Whether your schedule is basic or complex, consider how your on-call coverage is structured before configuring your
calendar events. To minimize the number of calendar events you need to create, try leveraging recurrence settings and
event duplication.
> **Note:** Each calendar event represents one on-call shift for a specific user. For Grafana OnCall to associate a
> calendar event with the intended on-call user, you must use their Grafana username as the event title.
### Create overlapping schedules (optional)
If you create schedules that overlap, you can prioritize a schedule by adding a level marker to the calendar event
title. You can prioritize schedule overlaps using [L0] - [L9] prioritization. Overlapping calendar events that do not
contain a level marker result in all overlapping users receiving notifications.
For example, users AliceGrafana and BobGrafana have overlapping schedules but BobGrafana is the intended primary
contact. The calendar events titles would be `[L1] BobGrafana` and `[L0] AliceGrafana` - In this case AliceGrafana
maintains the default [L0] status, and would not receive notifications during the overlapping time with BobGrafana.

View file

@ -0,0 +1,61 @@
---
title: Web-based schedules
aliases:
- /docs/oncall/latest/calendar-schedules/web-schedule/
canonical: https://grafana.com/docs/oncall/latest/calendar-schedules/web-schedule/
description: "Learn more about Grafana OnCalls built in schedule tool"
keywords:
- Grafana
- oncall
- schedule
- calendar
weight: 100
---
# About web-based schedules
Grafana OnCall allows you to map out recurring on-call coverage and automate the escalation of alert notifications to
on-call users. Configure and manage on-call schedules directly in the Grafana OnCall plugin to easily customize
rotations with a live schedule preview, reference teammates' time zones, and add overrides.
This topic provides an overview of key components and features.
For information on how to create a schedule in Grafana OnCall, refer to
[Create an on-call schedule]({{< relref "create-schedule" >}})
>**Note**: User permissions determine which components of Grafana OnCall are available to you.
## Schedule settings
Schedule settings are initially configured when a new schedule is created and can be updated at any time by clicking
the gear icon next to an existing schedule.
Available schedule settings:
- **Slack channel:** Choose a primary Slack channel to send notifications about on-call shifts, such as unassigned
- on-call shifts.
- **Slack user group:** Choose a Slack user group to receive current on-call updates.
- **Notification frequency:** Specify whether or not to send shift notifications to scheduled team members.
- **Action for slot when no one is on-call:** Define how your team is notified when an empty shift causes a gap in
- on-call coverage.
- **Current shift notification settings:** Select how users are notified when their on-call shift begins.
- **Next shift notification settings:** Specify how users are notified of upcoming shifts.
## Schedule view
The schedule view is a detailed calendar representation of your on-call schedule. It contains three interactive weekly
calendars and a 24-hour on-call status bar for visualizing whos on-call and what time it is for your teammates.
Understand your schedule view:
- **Final schedule:** The final schedule provides a combined view of rotations and overrides
- **Rotations:** The rotations calendar represents all recurring on-call rotations for a given schedule.
- **Overrides:** The override calendar represents temporary adjustments to the recurring on-call schedule. Any events
- on this calendar will take precedence over the rotations calendar.
## Schedule export
Export on-call schedules from Grafana OnCall to your preferred calendar app with a one-time secret iCal URL. The
schedule export allows you to view on-call shifts alongside the rest of your schedule.
For more information, refer to [Export on-call schedules]({{< relref "calendar-export" >}})

View file

@ -0,0 +1,74 @@
---
title: Export on-call schedules
aliases:
- /docs/oncall/latest/calendar-schedules/web-schedule/calendar-export/
canonical: https://grafana.com/docs/oncall/latest/calendar-schedules/web-schedule/calendar-export/
description: "Learn how to export an on-call schedule from Grafana OnCall"
keywords:
- Grafana
- oncall
- on-call
- calendar
- iCal export
weight: 500
---
# Export on-call schedules
Export on-call schedules from Grafana OnCall to your preferred calendar app with a one-time secret iCal URL.
The schedule export allows you to add on-call schedules to your existing calendar to view on-call shifts alongside the
rest of your schedule.
There are two schedule export options available:
- **On-call schedule export** - Exports all on-call shifts for a particular schedule, including rotations, overrides,
- and assigned users.
- **User-specific schedule export** - Exports assigned on-call shifts for a particular user. Use this export option to
- add your assigned on-call shifts to your calendar.
> **Note:** Calendar exports include all scheduled shifts, including those which are lower priority or overridden.
## Export an on-call schedule
Use this export option to add all on-call shifts associated with a schedule to a calendar. Best for a team or shared
calendars.
To export a schedule from Grafana OnCall:
1. In Grafana OnCall, navigate to the **Schedules** tab.
2. Open the schedule youd like to export by clicking on the schedule name.
3. Click **Export** in the upper right corner, then click **+ Create iCal link** to generate a secret iCal URL.
4. Copy the iCal link and store it somewhere youll remember. Once you close the schedule export window, you won't be
5. able to access the iCal link.
6. Open your calendar settings to add a calendar from a URL (This step varies based on your calendar app).
## Export a user on-call schedule
Use this export option to add your assigned on-call shifts to your calendar. Best for personal calendars.
To export your on-call schedule:
1. In Grafana OnCall, navigate to the **Users** tab.
2. Click **View my profile** in the upper right corner.
3. From the **User Info** tab, navigate to the iCal link section.
4. Click **+ Create iCal link** to generate your secret iCal URL.
5. Copy the iCal link and store it somewhere youll remember. Once you close your user profile, you won't be able to
6. access the iCal link again.
7. Open your calendar settings to add a calendar from a URL (This step varies based on your calendar app).
## Revoke an iCal export link
iCal links are displayed upon creation, and users are advised to copy their link and store it for future reference.
To ensure the security of your and your teams' calendar data, after an iCal link is generated, the link is hidden and
cannot be accessed again.
If you need to revoke an iCal link, you can do so anytime. By doing so, any calendar that references the revoked link
will lose access to the calendar data.
To revoke an active iCal link:
1. Navigate to the schedule or user profile associated with the iCal link.
1. For schedules, click **Export** to open the Schedule export window.
1. For users, navigate to the iCal link section of the **User info** tab.
1. If there is an active iCal link, click **Revoke iCal link**.
1. Once revoked, you can generate a new iCal link by clicking **+ Create iCal link**.

View file

@ -0,0 +1,74 @@
---
title: Create on-call schedules
aliases:
- /docs/oncall/latest/calendar-schedules/web-schedule/create-schedule/
canonical: https://grafana.com/docs/oncall/latest/calendar-schedules/web-schedule/create-schedule/
description: "Create on-call schedules with Grafana OnCall"
keywords:
- Grafana
- oncall
- on-call
- schedule
- calendar
weight: 300
---
# Create on-call schedules in Grafana OnCall
Schedules allow you to map out recurring on-call coverage and automate the escalation of alert notifications to
currently on-call users. With Grafana OnCall, you can customize rotations with a live schedule preview to visualize
your schedule, add users, reorder users, and reference teammates' time zones.
To learn more, see [On-call schedules]({{< relref "../../../calendar-schedules" >}}) which provides the fundamental
concepts for this task.
## Before you begin
- Users with Admin or Editor roles can create, edit and delete schedules.
- Users with Viewer role cannot receive alert notifications, therefore, cannot be on-call.
For more information about permissions, refer to
[Manage users and teams for Grafana OnCall]({{< relref "../../../configure-user-settings" >}})
## Create an on-call schedule
To create a new on-call schedule:
1. In Grafana OnCall, navigate to the **Schedules** tab and click **+ New schedule**
1. Navigate to **Set up on-call rotation schedule** and click **+ Create**
1. Provide a name and review available schedule settings
1. When youre done, click **Create Schedule**
>**Note:** You can edit your schedule settings at any time.
### Add a rotation to your on-call schedule
After creating your schedule, you can add rotations to build out your coverage needs.
Think of a rotation as a recurring schedule containing on-call shifts that users rotate through.
To add a rotation to an on-call schedule:
1. From your newly created schedule, click **+ Add rotation** and select **New Layer**.
2. Complete the rotation creation form according to your rotation parameters.
3. Add users to the rotation from the dropdown.
You can separate users into user groups to rotate through individual users per shift. User groups that contain
4. multiple users results in all users in the group being included in corresponding shifts.
5. When youre satisfied with the rotation preview, click **Create**.
### Add an on-call schedule to escalation chains
Now that youve created your schedule, it must be referenced in the steps of an escalation chain for on-call users
to receive alert notifications.
To connect a schedule to an escalation chain:
1. In Grafana OnCall, go to the **Escalation Chains** tab.
1. Navigate to an existing escalation chain or click **+ New Escalation Chain**.
1. Select **Notify users from on-call schedule** from the **Add escalation step** dropdown.
1. Specify which notification policy to use and the appropriate schedule.
1. Click and drag the escalation steps to reorder, if needed.
Escalation chain steps are saved automatically.
For more information about Escalation Chains, refer to
[Configure and manage Escalation Chains]({{< relref "../../../escalation-policies/configure-escalation-chains" >}})

View file

@ -20,7 +20,7 @@ COPY ./ ./
# Collect static files and create an SQLite database
RUN mkdir -p /var/lib/oncall
RUN DJANGO_SETTINGS_MODULE=settings.prod_without_db DATABASE_TYPE=sqlite3 DATABASE_NAME=/var/lib/oncall/oncall.db SECRET_KEY="ThEmUsTSecretKEYforBUILDstage123" python manage.py collectstatic --no-input
RUN DJANGO_SETTINGS_MODULE=settings.prod_without_db DATABASE_TYPE=sqlite3 DATABASE_NAME=/var/lib/oncall/oncall.db SECRET_KEY="ThEmUsTSecretKEYforBUILDstage123" SILK_PROFILER_ENABLED="True" python manage.py collectstatic --no-input
RUN chown -R 1000:2000 /var/lib/oncall
FROM base AS dev

View file

@ -312,13 +312,14 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer):
resolution_notes_button["text"]["text"] = "Add Resolution notes"
buttons.append(resolution_notes_button)
# Incident button
# Declare Incident button
if self.alert_group.channel.organization.is_grafana_incident_enabled:
incident_button = {
"type": "button",
"text": {"type": "plain_text", "text": ":fire: Declare Incident", "emoji": True},
"value": "declare_incident",
"url": self.alert_group.declare_incident_link,
"action_id": ScenarioStep.get_step("declare_incident", "DeclareIncidentStep").routing_uid(),
}
buttons.append(incident_button)
else:

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.16 on 2023-01-19 18:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('alerts', '0007_populate_web_title_cache'),
]
operations = [
migrations.AlterField(
model_name='alertgrouplogrecord',
name='type',
field=models.IntegerField(choices=[(0, 'Acknowledged'), (1, 'Unacknowledged'), (2, 'Invite'), (3, 'Stop invitation'), (4, 'Re-invite'), (5, 'Escalation triggered'), (6, 'Invitation triggered'), (16, 'Escalation finished'), (7, 'Silenced'), (15, 'Unsilenced'), (8, 'Attached'), (9, 'Unattached'), (10, 'Custom button triggered'), (11, 'Unacknowledged by timeout'), (12, 'Failed attachment'), (13, 'Incident resolved'), (14, 'Incident unresolved'), (17, 'Escalation failed'), (18, 'Acknowledge reminder triggered'), (19, 'Wiped'), (20, 'Deleted'), (21, 'Incident registered'), (22, 'A route is assigned to the incident'), (23, 'Trigger direct paging escalation'), (24, 'Unpage a user')]),
),
]

View file

@ -351,19 +351,39 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
@staticmethod
def get_silenced_state_filter():
return Q(silenced=True) & Q(acknowledged=False) & Q(resolved=False)
"""
models.Value(0/1) is used instead of True/False because django translates that into
WHERE bool_field=0/1 instead of WHERE bool_field/NOT bool_field
which works much faster in mysql
"""
return Q(silenced=models.Value("1")) & Q(acknowledged=models.Value("0")) & Q(resolved=models.Value("0"))
@staticmethod
def get_new_state_filter():
return Q(silenced=False) & Q(acknowledged=False) & Q(resolved=False)
"""
models.Value(0/1) is used instead of True/False because django translates that into
WHERE bool_field=0/1 instead of WHERE bool_field/NOT bool_field
which works much faster in mysql
"""
return Q(silenced=models.Value("0")) & Q(acknowledged=models.Value("0")) & Q(resolved=models.Value("0"))
@staticmethod
def get_acknowledged_state_filter():
return Q(acknowledged=True) & Q(resolved=False)
"""
models.Value(0/1) is used instead of True/False because django translates that into
WHERE bool_field=0/1 instead of WHERE bool_field/NOT bool_field
which works much faster in mysql
"""
return Q(acknowledged=models.Value("1")) & Q(resolved=models.Value("0"))
@staticmethod
def get_resolved_state_filter():
return Q(resolved=True)
"""
models.Value(0/1) is used instead of True/False because django translates that into
WHERE bool_field=0/1 instead of WHERE bool_field/NOT bool_field
which works much faster in mysql
"""
return Q(resolved=models.Value("1"))
class Meta:
get_latest_by = "pk"

View file

@ -111,13 +111,12 @@ class CustomButton(models.Model):
alert_payload=self._escape_alert_payload(alert.raw_request_data),
alert_group_id=alert.group.public_primary_key,
)
try:
post_kwargs["json"] = json.loads(rendered_data)
except JSONDecodeError:
post_kwargs["data"] = rendered_data
except (JinjaTemplateError, JinjaTemplateWarning) as e:
post_kwargs["json"] = {"error": e.fallback_message}
try:
post_kwargs["json"] = json.loads(rendered_data)
except JSONDecodeError:
post_kwargs["data"] = rendered_data
return post_kwargs
def _escape_alert_payload(self, payload: dict):

View file

@ -1,6 +1,7 @@
import enum
import typing
from django.conf import settings
from rest_framework import permissions
from rest_framework.authentication import BasicAuthentication, SessionAuthentication
from rest_framework.request import Request
@ -184,6 +185,11 @@ class RBACPermission(permissions.BasePermission):
return view.action if isinstance(view, ViewSetMixin) else request.method.lower()
def has_permission(self, request: Request, view: ViewSetOrAPIView) -> bool:
# the django-debug-toolbar UI makes OPTIONS calls. Without this statement the debug UI can't gather the
# necessary info it needs to work properly
if settings.DEBUG and request.method == "OPTIONS":
return True
action = self._get_view_action(request, view)
rbac_permissions: RBACPermissionsAttribute = getattr(view, RBAC_PERMISSIONS_ATTR, None)

View file

@ -169,7 +169,8 @@ class FastAlertReceiveChannelSerializer(serializers.ModelSerializer):
fields = ["id", "integration", "verbal_name", "deleted"]
def get_deleted(self, obj):
return obj.deleted_at is not None
# 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
class FilterAlertReceiveChannelSerializer(serializers.ModelSerializer):

View file

@ -40,6 +40,9 @@ class DirectPagingSerializer(serializers.Serializer):
users = UserReferenceSerializer(many=True, required=False, default=list)
schedules = ScheduleReferenceSerializer(many=True, required=False, default=list)
escalation_chain_id = serializers.CharField(required=False, default=None)
escalation_chain = serializers.HiddenField(default=None) # set in DirectPagingSerializer.validate
alert_group_id = serializers.CharField(required=False, default=None)
alert_group = serializers.HiddenField(default=None) # set in DirectPagingSerializer.validate
@ -47,19 +50,37 @@ class DirectPagingSerializer(serializers.Serializer):
message = serializers.CharField(required=False, default=None)
def validate(self, attrs):
if len(attrs["users"]) == 0 and len(attrs["schedules"]) == 0:
raise serializers.ValidationError("Provide at least one user or schedule")
organization = self.context["organization"]
if attrs["alert_group_id"] and (attrs["title"] or attrs["message"]):
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")
if attrs["alert_group_id"]:
organization = self.context["organization"]
if alert_group_id and escalation_chain_id:
raise serializers.ValidationError("escalation_chain_id is not supported for existing alert groups")
if alert_group_id:
try:
attrs["alert_group"] = AlertGroup.unarchived_objects.get(
public_primary_key=attrs["alert_group_id"], channel__organization=organization
public_primary_key=alert_group_id, channel__organization=organization
)
except ObjectDoesNotExist:
raise serializers.ValidationError("Alert group {} does not exist".format(attrs["alert_group_id"]))
raise serializers.ValidationError("Alert group {} does not exist".format(alert_group_id))
if escalation_chain_id:
try:
attrs["escalation_chain"] = organization.escalation_chains.get(public_primary_key=escalation_chain_id)
except ObjectDoesNotExist:
raise serializers.ValidationError("Escalation chain {} does not exist".format(escalation_chain_id))
return attrs

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
from apps.alerts.models import AlertGroup, AlertGroupLogRecord, AlertReceiveChannel
from apps.api.permissions import LegacyAccessControlRole
alert_raw_request_data = {
@ -1585,3 +1585,25 @@ def test_alert_group_paged_users(
url = reverse("api-internal:alertgroup-detail", kwargs={"pk": new_alert_group.public_primary_key})
response = client.get(url, format="json", **make_user_auth_headers(user, token))
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

View file

@ -668,3 +668,19 @@ def test_alert_receive_channel_counters_per_integration_permissions(
response = client.get(url, format="json", **make_user_auth_headers(user, token))
assert response.status_code == expected_status
@pytest.mark.django_db
def test_get_alert_receive_channels_direct_paging_hidden(
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() == []

View file

@ -9,7 +9,11 @@ from apps.schedules.models import OnCallScheduleCalendar, OnCallScheduleICal
@pytest.mark.django_db
def test_direct_paging_new_alert_group(
make_organization_and_user_with_plugin_token, make_user, make_schedule, make_user_auth_headers
make_organization_and_user_with_plugin_token,
make_user,
make_schedule,
make_escalation_chain,
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR)
@ -32,6 +36,8 @@ def test_direct_paging_new_alert_group(
},
]
escalation_chain_to_page = make_escalation_chain(organization)
title = "Test Alert Group"
message = "Testing direct paging with new alert group"
@ -40,7 +46,13 @@ def test_direct_paging_new_alert_group(
response = client.post(
url,
data={"users": users_to_page, "schedules": schedules_to_page, "title": title, "message": message},
data={
"users": users_to_page,
"schedules": schedules_to_page,
"escalation_chain_id": escalation_chain_to_page.public_primary_key,
"title": title,
"message": message,
},
format="json",
**make_user_auth_headers(user, token),
)
@ -94,6 +106,38 @@ def test_direct_paging_existing_alert_group(
assert response.status_code == status.HTTP_200_OK
@pytest.mark.django_db
def test_direct_paging_existing_alert_group_and_escalation_chain(
make_organization_and_user_with_plugin_token,
make_user,
make_schedule,
make_escalation_chain,
make_alert_receive_channel,
make_alert_group,
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR)
escalation_chain_to_page = make_escalation_chain(organization)
alert_receive_channel = make_alert_receive_channel(organization)
alert_group = make_alert_group(alert_receive_channel)
client = APIClient()
url = reverse("api-internal:direct_paging")
response = client.post(
url,
data={
"escalation_chain_id": escalation_chain_to_page.public_primary_key,
"alert_group_id": alert_group.public_primary_key,
},
format="json",
**make_user_auth_headers(user, token),
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_direct_paging_no_title(
make_organization_and_user_with_plugin_token,

View file

@ -1,11 +1,13 @@
from datetime import timedelta
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Count, Max, Q
from django.utils import timezone
from django_filters import rest_framework as filters
from django_filters.widgets import RangeWidget
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import NotFound
from rest_framework.filters import SearchFilter
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
@ -15,9 +17,10 @@ from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel
from apps.alerts.paging import unpage_user
from apps.api.permissions import RBACPermission
from apps.api.serializers.alert_group import AlertGroupListSerializer, AlertGroupSerializer
from apps.api.serializers.team import TeamSerializer
from apps.auth_token.auth import PluginAuthentication
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
from apps.user_management.models import User
from apps.user_management.models import Team, User
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.filters import DateRangeFilterMixin, ModelFieldFilterMixin
from common.api_helpers.mixins import PreviewTemplateMixin, PublicPrimaryKeyMixin, TeamFilteringMixin
@ -88,8 +91,6 @@ class AlertGroupFilter(DateRangeFilterMixin, ModelFieldFilterMixin, filters.Filt
model = AlertGroup
fields = [
"id__in",
"resolved",
"acknowledged",
"started_at_gte",
"started_at_lte",
"resolved_at_lte",
@ -148,6 +149,37 @@ class AlertGroupFilter(DateRangeFilterMixin, ModelFieldFilterMixin, filters.Filt
class AlertGroupTeamFilteringMixin(TeamFilteringMixin):
TEAM_LOOKUP = "channel__team"
def retrieve(self, request, *args, **kwargs):
try:
return super().retrieve(request, *args, **kwargs)
except NotFound:
alert_receive_channels_ids = list(
AlertReceiveChannel.objects.filter(
organization_id=self.request.auth.organization.id,
).values_list("id", flat=True)
)
queryset = AlertGroup.unarchived_objects.filter(
channel__in=alert_receive_channels_ids,
).only("public_primary_key")
try:
obj = queryset.get(public_primary_key=self.kwargs["pk"])
except ObjectDoesNotExist:
raise NotFound
obj_team = self._getattr_with_related(obj, self.TEAM_LOOKUP)
if obj_team is None or obj_team in self.request.user.teams.all():
if obj_team is None:
obj_team = Team(public_primary_key=None, name="General", email=None, avatar_url=None)
return Response(
data={"error_code": "wrong_team", "owner_team": TeamSerializer(obj_team).data},
status=status.HTTP_403_FORBIDDEN,
)
return Response(data={"error_code": "wrong_team"}, status=status.HTTP_403_FORBIDDEN)
class AlertGroupView(
PreviewTemplateMixin,
@ -206,8 +238,14 @@ class AlertGroupView(
def get_queryset(self):
# no select_related or prefetch_related is used at this point, it will be done on paginate_queryset.
alert_receive_channels_ids = list(
AlertReceiveChannel.objects.filter(
organization_id=self.request.auth.organization.id,
team_id=self.request.user.current_team,
).values_list("id", flat=True)
)
queryset = AlertGroup.unarchived_objects.filter(
channel__organization=self.request.auth.organization, channel__team=self.request.user.current_team
channel__in=alert_receive_channels_ids,
).only("id")
return queryset

View file

@ -136,6 +136,10 @@ class AlertReceiveChannelView(
)
if eager:
queryset = self.serializer_class.setup_eager_loading(queryset)
# Hide direct paging integrations
queryset = queryset.exclude(integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING)
return queryset
@action(detail=True, methods=["post"], throttle_classes=[DemoAlertThrottler])

View file

@ -38,6 +38,7 @@ class DirectPagingAPIView(APIView):
message=serializer.validated_data["message"],
users=users,
schedules=schedules,
escalation_chain=serializer.validated_data["escalation_chain"],
alert_group=serializer.validated_data["alert_group"],
)

View file

@ -96,6 +96,14 @@ class PluginAuthentication(BaseAuthentication):
logger.debug(f"Could not get user from grafana request. Context {context}")
raise exceptions.AuthenticationFailed("Non-existent or anonymous user.")
@classmethod
def is_user_from_request_present_in_organization(cls, request: Request, organization: Organization) -> User:
try:
cls._get_user(request, organization)
return True
except exceptions.AuthenticationFailed:
return False
class GrafanaIncidentUser(AnonymousUser):
@property

View file

@ -54,16 +54,16 @@ class APIClient:
self.api_url = api_url
self.api_token = api_token
def api_head(self, endpoint: str, body: dict = None) -> Tuple[Optional[Response], dict]:
return self.call_api(endpoint, requests.head, body)
def api_head(self, endpoint: str, body: dict = None, **kwargs) -> Tuple[Optional[Response], dict]:
return self.call_api(endpoint, requests.head, body, **kwargs)
def api_get(self, endpoint: str) -> Tuple[Optional[Response], dict]:
return self.call_api(endpoint, requests.get)
def api_get(self, endpoint: str, **kwargs) -> Tuple[Optional[Response], dict]:
return self.call_api(endpoint, requests.get, **kwargs)
def api_post(self, endpoint: str, body: dict = None) -> Tuple[Optional[Response], dict]:
return self.call_api(endpoint, requests.post, body)
def api_post(self, endpoint: str, body: dict = None, **kwargs) -> Tuple[Optional[Response], dict]:
return self.call_api(endpoint, requests.post, body, **kwargs)
def call_api(self, endpoint: str, http_method, body: dict = None) -> Tuple[Optional[Response], dict]:
def call_api(self, endpoint: str, http_method, body: dict = None, **kwargs) -> Tuple[Optional[Response], dict]:
request_start = time.perf_counter()
call_status = {
"url": urljoin(self.api_url, endpoint),
@ -72,7 +72,7 @@ class APIClient:
"message": "",
}
try:
response = http_method(call_status["url"], json=body, headers=self.request_headers)
response = http_method(call_status["url"], json=body, headers=self.request_headers, **kwargs)
call_status["status_code"] = response.status_code
response.raise_for_status()
@ -82,11 +82,14 @@ class APIClient:
if response.status_code == status.HTTP_204_NO_CONTENT:
return {}, call_status
return response.json(), call_status
# ex. a HEAD call (self.api_head) would have a response.content of b''
# and hence calling response.json() throws a json.JSONDecodeError
return response.json() if response.content else None, call_status
except (
requests.exceptions.ConnectionError,
requests.exceptions.HTTPError,
requests.exceptions.TooManyRedirects,
requests.exceptions.Timeout,
json.JSONDecodeError,
) as e:
logger.warning("Error connecting to api instance " + str(e))
@ -151,8 +154,8 @@ class GrafanaAPIClient(APIClient):
_, resp_status = self.api_head(self.USER_PERMISSION_ENDPOINT)
return resp_status["status_code"] == status.HTTP_200_OK
def get_users(self, rbac_is_enabled_for_org: bool) -> List[GrafanaUserWithPermissions]:
users, _ = self.api_get("api/org/users")
def get_users(self, rbac_is_enabled_for_org: bool, **kwargs) -> List[GrafanaUserWithPermissions]:
users, _ = self.api_get("api/org/users", **kwargs)
if not users:
return []
@ -164,8 +167,8 @@ class GrafanaAPIClient(APIClient):
user["permissions"] = user_permissions.get(str(user["userId"]), [])
return users
def get_teams(self):
return self.api_get("api/teams/search?perpage=1000000")
def get_teams(self, **kwargs):
return self.api_get("api/teams/search?perpage=1000000", **kwargs)
def get_team_members(self, team_id):
return self.api_get(f"api/teams/{team_id}/members")

View file

@ -1 +1,5 @@
from .sync import start_sync_organizations, sync_organization_async # noqa: F401
from .sync import ( # noqa: F401
start_sync_organizations,
sync_organization_async,
sync_team_members_for_organization_async,
)

View file

@ -5,10 +5,11 @@ from django.conf import settings
from django.utils import timezone
from apps.grafana_plugin.helpers import GcomAPIClient
from apps.grafana_plugin.helpers.client import GrafanaAPIClient
from apps.grafana_plugin.helpers.gcom import get_active_instance_ids, get_deleted_instance_ids, get_stack_regions
from apps.user_management.models import Organization
from apps.user_management.models.region import sync_regions
from apps.user_management.sync import cleanup_organization, sync_organization
from apps.user_management.sync import cleanup_organization, sync_organization, sync_team_members
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
logger = get_task_logger(__name__)
@ -117,3 +118,15 @@ def start_sync_regions():
return
sync_regions(regions)
@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), max_retries=1)
def sync_team_members_for_organization_async(organization_pk):
try:
organization = Organization.objects.get(pk=organization_pk)
except Organization.DoesNotExist:
logger.info(f"Organization {organization_pk} was not found")
return
grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token)
sync_team_members(grafana_api_client, organization)

View file

@ -7,6 +7,7 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.auth_token.auth import PluginAuthentication
from apps.grafana_plugin.permissions import PluginTokenVerified
from apps.grafana_plugin.tasks.sync import plugin_sync_organization_async
from apps.user_management.models import Organization
@ -22,26 +23,29 @@ class PluginSyncView(GrafanaHeadersMixin, APIView):
stack_id = self.instance_context["stack_id"]
org_id = self.instance_context["org_id"]
is_installed = False
allow_signup = True
try:
organization = Organization.objects.get(stack_id=stack_id, org_id=org_id)
if organization.api_token_status == Organization.API_TOKEN_STATUS_OK:
is_installed = True
organization.api_token_status = Organization.API_TOKEN_STATUS_PENDING
organization.save(update_fields=["api_token_status"])
user_is_present_in_org = PluginAuthentication.is_user_from_request_present_in_organization(
request, organization
)
if not user_is_present_in_org:
organization.api_token_status = Organization.API_TOKEN_STATUS_PENDING
organization.save(update_fields=["api_token_status"])
if not organization:
DynamicSetting = apps.get_model("base", "DynamicSetting")
allow_signup = DynamicSetting.objects.get_or_create(
name="allow_plugin_organization_signup", defaults={"boolean_value": True}
)[0].boolean_value
plugin_sync_organization_async.apply_async((organization.pk,))
except Organization.DoesNotExist:
logger.info(f"Organization for stack {stack_id} org {org_id} was not found")
allow_signup = True
if not organization:
DynamicSetting = apps.get_model("base", "DynamicSetting")
allow_signup = DynamicSetting.objects.get_or_create(
name="allow_plugin_organization_signup", defaults={"boolean_value": True}
)[0].boolean_value
return Response(
status=status.HTTP_202_ACCEPTED,
data={

View file

@ -53,6 +53,10 @@ class MobileAppBackend(BaseMessagingBackend):
@staticmethod
def is_enabled_for_organization(organization):
# Setting FEATURE_MOBILE_APP_INTEGRATION_ENABLED to True is enough to enable mobile app on OSS instances
if settings.LICENSE == settings.OPEN_SOURCE_LICENSE_NAME:
return True
mobile_app_settings, _ = DynamicSetting.objects.get_or_create(
name="mobile_app_settings", defaults={"json_value": {"org_ids": []}}
)

View file

@ -5,25 +5,37 @@ from django.conf import settings
from fcm_django.models import FCMDevice
from firebase_admin.messaging import APNSConfig, APNSPayload, Aps, ApsAlert, CriticalSound, Message
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.throttling import UserRateThrottle
from rest_framework.views import APIView
from apps.auth_token.auth import ApiTokenAuthentication
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
task_logger = get_task_logger(__name__)
task_logger.setLevel(logging.DEBUG)
class FCMRelayThrottler(UserRateThrottle):
scope = "fcm_relay"
rate = "300/m"
class FCMRelayView(APIView):
# TODO: use public API authentication (then it would be required to connect to a cloud instance to use the app)
authentication_classes = []
permission_classes = []
"""
This view accepts push notifications from OSS instances and forwards these requests to FCM.
Requests to this endpoint come from OSS instances: apps.mobile_app.tasks.send_push_notification_to_fcm_relay.
The view uses public API authentication, so an OSS instance must be connected to cloud to use FCM relay.
"""
authentication_classes = [ApiTokenAuthentication]
permission_classes = [IsAuthenticated]
throttle_classes = [FCMRelayThrottler]
def post(self, request):
"""
This view accepts push notifications from OSS instances and forwards these requests to FCM.
Requests to this endpoint come from OSS instances: apps.mobile_app.tasks.send_push_notification_to_fcm_relay
"""
if not settings.FCM_RELAY_ENABLED:
return Response(status=status.HTTP_404_NOT_FOUND)
try:
token = request.data["token"]

View file

@ -6,8 +6,11 @@ from celery.utils.log import get_task_logger
from django.conf import settings
from fcm_django.models import FCMDevice
from firebase_admin.messaging import APNSConfig, APNSPayload, Aps, ApsAlert, CriticalSound, Message
from requests import HTTPError
from rest_framework import status
from apps.alerts.models import AlertGroup
from apps.base.utils import live_settings
from apps.mobile_app.alert_rendering import get_push_notification_message
from apps.user_management.models import User
from common.api_helpers.utils import create_engine_url
@ -41,10 +44,10 @@ def notify_user_async(user_pk, alert_group_pk, notification_policy_pk, critical)
logger.warning(f"User notification policy {notification_policy_pk} does not exist")
return
device_to_notify = FCMDevice.objects.filter(user=user).first()
# create an error log in case user has no devices set up
if not device_to_notify:
def _create_error_log_record():
"""
Utility method to create a UserNotificationPolicyLogRecord with error
"""
UserNotificationPolicyLogRecord.objects.create(
author=user,
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED,
@ -54,7 +57,13 @@ def notify_user_async(user_pk, alert_group_pk, notification_policy_pk, critical)
notification_step=notification_policy.step,
notification_channel=notification_policy.notify_by,
)
logger.info(f"Error while sending a mobile push notification: user {user_pk} has no device set up")
device_to_notify = FCMDevice.objects.filter(user=user).first()
# create an error log in case user has no devices set up
if not device_to_notify:
_create_error_log_record()
logger.error(f"Error while sending a mobile push notification: user {user_pk} has no device set up")
return
thread_id = f"{alert_group.channel.organization.public_primary_key}:{alert_group.public_primary_key}"
@ -116,8 +125,27 @@ def notify_user_async(user_pk, alert_group_pk, notification_policy_pk, critical)
logger.debug(f"Sending push notification with message: {message}; thread-id: {thread_id};")
if settings.LICENSE == settings.OPEN_SOURCE_LICENSE_NAME:
response = send_push_notification_to_fcm_relay(message)
logger.debug(f"FCM relay response: {response}")
# FCM relay uses cloud connection to send push notifications
from apps.oss_installation.models import CloudConnector
if not CloudConnector.objects.exists():
_create_error_log_record()
logger.error(f"Error while sending a mobile push notification: not connected to cloud")
return
try:
response = send_push_notification_to_fcm_relay(message)
logger.debug(f"FCM relay response: {response}")
except HTTPError as e:
if status.HTTP_400_BAD_REQUEST <= e.response.status_code < status.HTTP_500_INTERNAL_SERVER_ERROR:
# do not retry on HTTP client errors (4xx errors)
_create_error_log_record()
logger.error(
f"Error while sending a mobile push notification: HTTP client error {e.response.status_code}"
)
return
else:
raise
else:
response = device_to_notify.send_message(message)
# NOTE: we may want to further handle the response from FCM, but for now lets simply log it out
@ -131,7 +159,9 @@ def send_push_notification_to_fcm_relay(message):
"""
url = create_engine_url("mobile_app/v1/fcm_relay", override_base=settings.GRAFANA_CLOUD_ONCALL_API_URL)
response = requests.post(url, json=json.loads(str(message)))
response = requests.post(
url, headers={"Authorization": live_settings.GRAFANA_CLOUD_ONCALL_TOKEN}, json=json.loads(str(message))
)
response.raise_for_status()
return response

View file

View file

@ -0,0 +1,81 @@
from unittest.mock import patch
import pytest
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from apps.mobile_app.fcm_relay import FCMRelayThrottler
@pytest.mark.django_db
def test_fcm_relay_disabled(
settings,
load_mobile_app_urls,
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
make_public_api_token,
):
settings.FCM_RELAY_ENABLED = False
organization, user, token = make_organization_and_user_with_plugin_token()
_, token = make_public_api_token(user, organization)
client = APIClient()
url = reverse("mobile_app:fcm_relay")
response = client.post(url, HTTP_AUTHORIZATION=token)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
def test_fcm_relay_post(
settings,
load_mobile_app_urls,
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
make_public_api_token,
):
settings.FCM_RELAY_ENABLED = True
organization, user, token = make_organization_and_user_with_plugin_token()
_, token = make_public_api_token(user, organization)
client = APIClient()
url = reverse("mobile_app:fcm_relay")
data = {
"token": "test_registration_id",
"data": {},
"apns": {},
}
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=token)
assert response.status_code == status.HTTP_200_OK
@pytest.mark.django_db
def test_fcm_relay_ratelimit(
settings,
load_mobile_app_urls,
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
make_public_api_token,
):
settings.FCM_RELAY_ENABLED = True
organization, user, token = make_organization_and_user_with_plugin_token()
_, token = make_public_api_token(user, organization)
client = APIClient()
url = reverse("mobile_app:fcm_relay")
data = {
"token": "test_registration_id",
"data": {},
"apns": {},
}
with patch.object(FCMRelayThrottler, "rate", "0/m"):
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=token)
assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS

View file

@ -0,0 +1,171 @@
from unittest.mock import patch
import pytest
from fcm_django.models import FCMDevice
from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord
from apps.mobile_app.tasks import notify_user_async
from apps.oss_installation.models import CloudConnector
MOBILE_APP_BACKEND_ID = 5
CLOUD_LICENSE_NAME = "Cloud"
OPEN_SOURCE_LICENSE_NAME = "OpenSource"
@pytest.mark.django_db
def test_notify_user_async_cloud(
settings,
make_organization_and_user,
make_user_notification_policy,
make_alert_receive_channel,
make_channel_filter,
make_alert_group,
make_alert,
):
# create a user and connect a mobile device
organization, user = make_organization_and_user()
FCMDevice.objects.create(user=user, registration_id="test_device_id")
# set up notification policy and alert group
notification_policy = make_user_notification_policy(
user,
UserNotificationPolicy.Step.NOTIFY,
notify_by=MOBILE_APP_BACKEND_ID,
)
alert_receive_channel = make_alert_receive_channel(organization=organization)
channel_filter = make_channel_filter(alert_receive_channel)
alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter)
make_alert(alert_group=alert_group, raw_request_data={})
# check FCM is contacted directly when using the cloud license
settings.LICENSE = CLOUD_LICENSE_NAME
with patch.object(FCMDevice, "send_message", return_value="ok") as mock:
notify_user_async(
user_pk=user.pk,
alert_group_pk=alert_group.pk,
notification_policy_pk=notification_policy.pk,
critical=False,
)
mock.assert_called()
@pytest.mark.django_db
def test_notify_user_async_oss(
settings,
make_organization_and_user,
make_user_notification_policy,
make_alert_receive_channel,
make_channel_filter,
make_alert_group,
make_alert,
):
# create a user and connect a mobile device
organization, user = make_organization_and_user()
FCMDevice.objects.create(user=user, registration_id="test_device_id")
# set up notification policy and alert group
notification_policy = make_user_notification_policy(
user,
UserNotificationPolicy.Step.NOTIFY,
notify_by=MOBILE_APP_BACKEND_ID,
)
alert_receive_channel = make_alert_receive_channel(organization=organization)
channel_filter = make_channel_filter(alert_receive_channel)
alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter)
make_alert(alert_group=alert_group, raw_request_data={})
# create cloud connection
CloudConnector.objects.create(cloud_url="test")
# check FCM relay is contacted when using the OSS license
settings.LICENSE = OPEN_SOURCE_LICENSE_NAME
with patch("apps.mobile_app.tasks.send_push_notification_to_fcm_relay", return_value="ok") as mock:
notify_user_async(
user_pk=user.pk,
alert_group_pk=alert_group.pk,
notification_policy_pk=notification_policy.pk,
critical=False,
)
mock.assert_called()
@pytest.mark.django_db
def test_notify_user_async_oss_no_device_connected(
settings,
make_organization_and_user,
make_user_notification_policy,
make_alert_receive_channel,
make_channel_filter,
make_alert_group,
make_alert,
):
# create a user without mobile device
organization, user = make_organization_and_user()
# set up notification policy and alert group
notification_policy = make_user_notification_policy(
user,
UserNotificationPolicy.Step.NOTIFY,
notify_by=MOBILE_APP_BACKEND_ID,
)
alert_receive_channel = make_alert_receive_channel(organization=organization)
channel_filter = make_channel_filter(alert_receive_channel)
alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter)
make_alert(alert_group=alert_group, raw_request_data={})
# create cloud connection
CloudConnector.objects.create(cloud_url="test")
# check FCM relay is contacted when using the OSS license
settings.LICENSE = OPEN_SOURCE_LICENSE_NAME
with patch("apps.mobile_app.tasks.send_push_notification_to_fcm_relay", return_value="ok") as mock:
notify_user_async(
user_pk=user.pk,
alert_group_pk=alert_group.pk,
notification_policy_pk=notification_policy.pk,
critical=False,
)
mock.assert_not_called()
log_record = alert_group.personal_log_records.last()
assert log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED
@pytest.mark.django_db
def test_notify_user_async_oss_no_cloud_connection(
settings,
make_organization_and_user,
make_user_notification_policy,
make_alert_receive_channel,
make_channel_filter,
make_alert_group,
make_alert,
):
# create a user and connect a mobile device
organization, user = make_organization_and_user()
FCMDevice.objects.create(user=user, registration_id="test_device_id")
# set up notification policy and alert group
notification_policy = make_user_notification_policy(
user,
UserNotificationPolicy.Step.NOTIFY,
notify_by=MOBILE_APP_BACKEND_ID,
)
alert_receive_channel = make_alert_receive_channel(organization=organization)
channel_filter = make_channel_filter(alert_receive_channel)
alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter)
make_alert(alert_group=alert_group, raw_request_data={})
# check FCM relay is contacted when using the OSS license
settings.LICENSE = OPEN_SOURCE_LICENSE_NAME
with patch("apps.mobile_app.tasks.send_push_notification_to_fcm_relay", return_value="ok") as mock:
notify_user_async(
user_pk=user.pk,
alert_group_pk=alert_group.pk,
notification_policy_pk=notification_policy.pk,
critical=False,
)
mock.assert_not_called()
log_record = alert_group.personal_log_records.last()
assert log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED

View file

@ -1,5 +1,3 @@
from django.conf import settings
from apps.mobile_app.fcm_relay import FCMRelayView
from apps.mobile_app.views import FCMDeviceAuthorizedViewSet, MobileAppAuthTokenAPIView
from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path
@ -14,7 +12,6 @@ urlpatterns = [
optional_slash_path("auth_token", MobileAppAuthTokenAPIView.as_view(), name="auth_token"),
]
if settings.FCM_RELAY_ENABLED:
urlpatterns += [
optional_slash_path("fcm_relay", FCMRelayView.as_view(), name="fcm_relay"),
]
urlpatterns += [
optional_slash_path("fcm_relay", FCMRelayView.as_view(), name="fcm_relay"),
]

View file

@ -0,0 +1,41 @@
import pytest
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from apps.api.permissions import LegacyAccessControlRole
from apps.oss_installation.models import CloudConnector
@pytest.mark.django_db
def test_cloud_connection_viewer_can_read(
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER)
# create cloud connection
CloudConnector.objects.create(cloud_url="test")
client = APIClient()
url = reverse("oss_installation:cloud-connection-status")
response = client.get(url, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
@pytest.mark.django_db
def test_cloud_connection_viewer_cant_delete(
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER)
# create cloud connection
CloudConnector.objects.create(cloud_url="test")
client = APIClient()
url = reverse("oss_installation:cloud-connection-status")
response = client.delete(url, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_403_FORBIDDEN

View file

@ -4,6 +4,8 @@ from common.api_helpers.optional_slash_router import OptionalSlashRouter, option
from .views import CloudConnectionView, CloudHeartbeatView, CloudUsersView, CloudUserView
app_name = "oss_installation"
router = OptionalSlashRouter()
router.register("cloud_users", CloudUserView, basename="cloud-users")

View file

@ -15,7 +15,7 @@ class CloudConnectionView(APIView):
authentication_classes = (PluginAuthentication,)
permission_classes = (IsAuthenticated, RBACPermission)
rbac_permissions = {
"get": [RBACPermission.Permissions.OTHER_SETTINGS_WRITE],
"get": [RBACPermission.Permissions.OTHER_SETTINGS_READ],
"delete": [RBACPermission.Permissions.OTHER_SETTINGS_WRITE],
}

View file

@ -3,6 +3,7 @@ from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from apps.alerts.models import AlertReceiveChannel
from apps.base.tests.messaging_backend import TestOnlyBackend
TEST_MESSAGING_BACKEND_FIELD = TestOnlyBackend.backend_id.lower()
@ -570,3 +571,22 @@ def test_set_default_template(
response = client.put(url, data=data_for_update, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_200_OK
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"] == []

View file

@ -0,0 +1,35 @@
import logging
from django.core.cache import cache
from apps.grafana_plugin.helpers.client import GrafanaAPIClient
from apps.grafana_plugin.tasks import sync_team_members_for_organization_async
from apps.user_management.sync import sync_teams, sync_users
logger = logging.getLogger(__name__)
SYNC_REQUEST_TIMEOUT = 5
SYNC_PERIOD = 60
def is_request_from_terraform(request) -> bool:
return "terraform-provider-grafana" in request.META.get("HTTP_USER_AGENT", "")
def sync_users_on_tf_request(organization):
cache_key = f"sync_users_on_tf_request_{organization.id}"
if not cache.get(cache_key):
logger.info(f"Start sync_users_on_tf_request organization_id={organization.id}")
client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token)
cache.set(cache_key, True, SYNC_PERIOD)
sync_users(client, organization, timeout=SYNC_REQUEST_TIMEOUT)
def sync_teams_on_tf_request(organization):
cache_key = f"sync_teams_on_tf_request_{organization.id}"
if not cache.get(cache_key):
logger.info(f"Start sync_teams_on_tf_request organization_id={organization.id}")
cache.set(cache_key, True, SYNC_PERIOD)
client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token)
sync_teams(client, organization, timeout=SYNC_REQUEST_TIMEOUT)
sync_team_members_for_organization_async.apply_async((organization.id,))

View file

@ -47,6 +47,10 @@ class IntegrationView(
queryset = self.filter_queryset(queryset)
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

@ -4,6 +4,7 @@ from rest_framework.permissions import IsAuthenticated
from apps.auth_token.auth import ApiTokenAuthentication
from apps.public_api.serializers.teams import TeamSerializer
from apps.public_api.tf_sync import is_request_from_terraform, sync_teams_on_tf_request
from apps.public_api.throttlers.user_throttle import UserThrottle
from apps.user_management.models import Team
from common.api_helpers.mixins import PublicPrimaryKeyMixin
@ -20,6 +21,8 @@ class TeamView(PublicPrimaryKeyMixin, RetrieveModelMixin, ListModelMixin, viewse
throttle_classes = [UserThrottle]
def get_queryset(self):
if is_request_from_terraform(self.request):
sync_teams_on_tf_request(self.request.auth.organization)
name = self.request.query_params.get("name", None)
queryset = self.request.auth.organization.teams.all()
if name:

View file

@ -9,6 +9,7 @@ from apps.api.permissions import LegacyAccessControlRole
from apps.auth_token.auth import ApiTokenAuthentication, UserScheduleExportAuthentication
from apps.public_api.custom_renderers import CalendarRenderer
from apps.public_api.serializers import FastUserSerializer, UserSerializer
from apps.public_api.tf_sync import is_request_from_terraform, sync_users_on_tf_request
from apps.public_api.throttlers.user_throttle import UserThrottle
from apps.schedules.ical_utils import user_ical_export
from apps.schedules.models import OnCallSchedule
@ -48,6 +49,8 @@ class UserView(RateLimitHeadersMixin, ShortSerializerMixin, ReadOnlyModelViewSet
throttle_classes = [UserThrottle]
def get_queryset(self):
if is_request_from_terraform(self.request):
sync_users_on_tf_request(self.request.auth.organization)
is_short_request = self.request.query_params.get("short", "false") == "true"
queryset = self.request.auth.organization.users.all()
if not is_short_request:

View file

@ -0,0 +1,23 @@
from apps.slack.scenarios import scenario_step
class DeclareIncidentStep(scenario_step.ScenarioStep):
tags = [
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
]
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
"""
Slack sends a POST request to the backend upon clicking a button with a redirect link to Incident.
This is a dummy step, that is used to prevent raising 'Step is undefined' exception.
"""
STEPS_ROUTING = [
{
"payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS,
"block_action_type": scenario_step.BLOCK_ACTION_TYPE_BUTTON,
"block_action_id": DeclareIncidentStep.routing_uid(),
"step": DeclareIncidentStep,
},
]

View file

@ -0,0 +1,663 @@
import json
from uuid import uuid4
from django.apps import apps
from django.conf import settings
from apps.alerts.paging import (
USER_HAS_NO_NOTIFICATION_POLICY,
USER_IS_NOT_ON_CALL,
check_user_availability,
direct_paging,
)
from apps.slack.scenarios import scenario_step
from apps.slack.slack_client.exceptions import SlackAPIException
DIRECT_PAGING_TEAM_SELECT_ID = "paging_team_select"
DIRECT_PAGING_ORG_SELECT_ID = "paging_org_select"
DIRECT_PAGING_ROUTE_SELECT_ID = "paging_route_select"
DIRECT_PAGING_USER_SELECT_ID = "paging_user_select"
DIRECT_PAGING_TITLE_INPUT_ID = "paging_title_input"
DIRECT_PAGING_MESSAGE_INPUT_ID = "paging_message_input"
DEFAULT_TEAM_VALUE = "default_team"
# selected user available actions
DEFAULT_POLICY = "default"
IMPORTANT_POLICY = "important"
REMOVE_ACTION = "remove"
USER_ACTIONS = (
(DEFAULT_POLICY, "Set default notification policy"),
(IMPORTANT_POLICY, "Set important notification policy"),
(REMOVE_ACTION, "Remove from escalation"),
)
# helpers to manage current selected users state
def add_or_update_user(payload, user_pk, policy):
metadata = json.loads(payload["view"]["private_metadata"])
metadata["current_users"][user_pk] = policy
payload["view"]["private_metadata"] = json.dumps(metadata)
return payload
def remove_user(payload, user_pk):
metadata = json.loads(payload["view"]["private_metadata"])
if user_pk in metadata["current_users"]:
del metadata["current_users"][user_pk]
payload["view"]["private_metadata"] = json.dumps(metadata)
return payload
def reset_users(payload):
metadata = json.loads(payload["view"]["private_metadata"])
metadata["current_users"] = {}
payload["view"]["private_metadata"] = json.dumps(metadata)
return payload
def get_current_users(payload, organization):
metadata = json.loads(payload["view"]["private_metadata"])
current_users = []
for u, p in metadata["current_users"].items():
user = organization.users.filter(pk=u).first()
current_users.append((user, p))
return current_users
# Slack scenario steps
class StartDirectPaging(scenario_step.ScenarioStep):
"""Handle slash command invocation and show initial dialog."""
command_name = [settings.SLACK_DIRECT_PAGING_SLASH_COMMAND]
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
input_id_prefix = _generate_input_id_prefix()
try:
channel_id = payload["event"]["channel"]
except KeyError:
channel_id = payload["channel_id"]
private_metadata = {
"channel_id": channel_id,
"input_id_prefix": input_id_prefix,
"submit_routing_uid": FinishDirectPaging.routing_uid(),
"current_users": {},
}
blocks = _get_initial_form_fields(slack_team_identity, slack_user_identity, input_id_prefix, payload)
view = _get_form_view(FinishDirectPaging.routing_uid(), blocks, json.dumps(private_metadata))
self._slack_client.api_call(
"views.open",
trigger_id=payload["trigger_id"],
view=view,
)
class FinishDirectPaging(scenario_step.ScenarioStep):
"""Handle page command dialog submit."""
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
title = _get_title_from_payload(payload)
message = _get_message_from_payload(payload)
private_metadata = json.loads(payload["view"]["private_metadata"])
channel_id = private_metadata["channel_id"]
input_id_prefix = private_metadata["input_id_prefix"]
selected_organization = _get_selected_org_from_payload(payload, input_id_prefix)
selected_team = _get_selected_team_from_payload(payload, input_id_prefix)
user = slack_user_identity.get_user(selected_organization)
selected_users = [(u, p == IMPORTANT_POLICY) for u, p in get_current_users(payload, selected_organization)]
# trigger direct paging to selected users
direct_paging(selected_organization, selected_team, user, title, message, selected_users)
try:
self._slack_client.api_call(
"chat.postEphemeral",
channel=channel_id,
user=slack_user_identity.slack_id,
text=":white_check_mark: Alert *{}* successfully submitted".format(title),
)
except SlackAPIException as e:
if e.response["error"] == "channel_not_found":
self._slack_client.api_call(
"chat.postEphemeral",
channel=slack_user_identity.im_channel_id,
user=slack_user_identity.slack_id,
text=":white_check_mark: Alert *{}* successfully submitted".format(title),
)
else:
raise e
# OnChange steps, responsible for rerendering form on changed values
class OnOrgChange(scenario_step.ScenarioStep):
"""Reload form with updated organization."""
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
updated_payload = reset_users(payload)
view = render_dialog(slack_user_identity, slack_team_identity, updated_payload)
self._slack_client.api_call(
"views.update",
trigger_id=payload["trigger_id"],
view=view,
view_id=payload["view"]["id"],
)
class OnTeamChange(OnOrgChange):
"""Reload form with updated team."""
class OnUserChange(scenario_step.ScenarioStep):
"""Add selected to user to the list.
It will perform a user availability check, pushing a new modal for additional confirmation if needed.
"""
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
private_metadata = json.loads(payload["view"]["private_metadata"])
selected_organization = _get_selected_org_from_payload(payload, private_metadata["input_id_prefix"])
selected_team = _get_selected_team_from_payload(payload, private_metadata["input_id_prefix"])
selected_user = _get_selected_user_from_payload(payload, private_metadata["input_id_prefix"])
if selected_user is None:
return
# check availability
availability_warnings = check_user_availability(selected_user, selected_team)
if availability_warnings:
# display warnings and require additional confirmation
view = _display_availability_warnings(payload, availability_warnings, selected_organization, selected_user)
self._slack_client.api_call(
"views.push",
trigger_id=payload["trigger_id"],
view=view,
)
else:
# user is available to be paged
updated_payload = add_or_update_user(payload, selected_user.pk, DEFAULT_POLICY)
view = render_dialog(slack_user_identity, slack_team_identity, updated_payload)
self._slack_client.api_call(
"views.update",
trigger_id=payload["trigger_id"],
view=view,
view_id=payload["view"]["id"],
)
class OnUserActionChange(scenario_step.ScenarioStep):
"""Reload form with updated user details."""
def _parse_action(self, payload):
value = payload["actions"][0]["selected_option"]["value"]
return value.split("|")
def process_scenario(self, slack_user_identity, slack_team_identity, payload, policy=None):
policy, user_pk = self._parse_action(payload)
if policy == REMOVE_ACTION:
updated_payload = remove_user(payload, user_pk)
else:
updated_payload = add_or_update_user(payload, user_pk, policy)
view = render_dialog(slack_user_identity, slack_team_identity, updated_payload)
self._slack_client.api_call(
"views.update",
trigger_id=payload["trigger_id"],
view=view,
view_id=payload["view"]["id"],
)
class OnConfirmUserChange(scenario_step.ScenarioStep):
"""Confirm user selection despite not being available."""
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
metadata = json.loads(payload["view"]["private_metadata"])
# recreate original view state and metadata
private_metadata = {
"channel_id": metadata["channel_id"],
"input_id_prefix": metadata["input_id_prefix"],
"submit_routing_uid": metadata["submit_routing_uid"],
"current_users": metadata["current_users"],
}
previous_view_payload = {
"view": {
"state": metadata["state"],
"private_metadata": json.dumps(private_metadata),
},
}
# add selected user
selected_user = _get_selected_user_from_payload(previous_view_payload, private_metadata["input_id_prefix"])
updated_payload = add_or_update_user(previous_view_payload, selected_user.pk, DEFAULT_POLICY)
view = render_dialog(slack_user_identity, slack_team_identity, updated_payload)
self._slack_client.api_call(
"views.update",
trigger_id=payload["trigger_id"],
view=view,
view_id=payload["view"]["previous_view_id"],
)
# slack view/blocks rendering helpers
def render_dialog(slack_user_identity, slack_team_identity, payload):
# data/state
private_metadata = json.loads(payload["view"]["private_metadata"])
submit_routing_uid = private_metadata.get("submit_routing_uid")
old_input_id_prefix, new_input_id_prefix, new_private_metadata = _get_and_change_input_id_prefix_from_metadata(
private_metadata
)
selected_organization = _get_selected_org_from_payload(payload, old_input_id_prefix)
selected_team = _get_selected_team_from_payload(payload, old_input_id_prefix)
selected_user = _get_selected_user_from_payload(payload, old_input_id_prefix)
# widgets
organization_select = _get_organization_select(
slack_team_identity, slack_user_identity, selected_organization, new_input_id_prefix
)
team_select = _get_team_select(slack_user_identity, selected_organization, selected_team, new_input_id_prefix)
users_select = _get_users_select(
slack_user_identity, selected_organization, selected_team, selected_user, new_input_id_prefix
)
# blocks
blocks = [organization_select, team_select, users_select]
selected_users = get_current_users(payload, selected_organization)
blocks.extend(_get_selected_users_list(new_input_id_prefix, selected_users))
blocks.extend([_get_title_input(payload), _get_message_input(payload)])
view = _get_form_view(submit_routing_uid, blocks, json.dumps(new_private_metadata))
return view
def _get_form_view(routing_uid, blocks, private_metatada):
view = {
"type": "modal",
"callback_id": routing_uid,
"title": {
"type": "plain_text",
"text": "Create alert group",
},
"close": {
"type": "plain_text",
"text": "Cancel",
"emoji": True,
},
"submit": {
"type": "plain_text",
"text": "Submit",
},
"blocks": blocks,
"private_metadata": private_metatada,
}
return view
def _get_initial_form_fields(slack_team_identity, slack_user_identity, input_id_prefix, payload):
initial_organization = (
slack_team_identity.organizations.filter(users__slack_user_identity=slack_user_identity)
.order_by("pk")
.distinct()
.first()
)
organization_select = _get_organization_select(
slack_team_identity, slack_user_identity, initial_organization, input_id_prefix
)
initial_team = None # means default team
initial_user = None # no user
team_select = _get_team_select(slack_user_identity, initial_organization, initial_team, input_id_prefix)
users_select = _get_users_select(
slack_user_identity, initial_organization, initial_team, initial_user, input_id_prefix
)
blocks = [organization_select, team_select, users_select]
title_input = _get_title_input(payload)
message_input = _get_message_input(payload)
blocks.append(title_input)
blocks.append(message_input)
return blocks
def _get_organization_select(slack_team_identity, slack_user_identity, value, input_id_prefix):
organizations = slack_team_identity.organizations.filter(
users__slack_user_identity=slack_user_identity,
).distinct()
organizations_options = []
initial_option_idx = 0
for idx, org in enumerate(organizations):
if org == value:
initial_option_idx = idx
organizations_options.append(
{
"text": {
"type": "plain_text",
"text": f"{org.stack_slug}",
"emoji": True,
},
"value": f"{org.pk}",
}
)
organization_select = {
"type": "section",
"text": {"type": "mrkdwn", "text": "Select an organization"},
"block_id": input_id_prefix + DIRECT_PAGING_ORG_SELECT_ID,
"accessory": {
"type": "static_select",
"placeholder": {"type": "plain_text", "text": "Select an organization", "emoji": True},
"options": organizations_options,
"action_id": OnOrgChange.routing_uid(),
"initial_option": organizations_options[initial_option_idx],
},
}
return organization_select
def _get_selected_org_from_payload(payload, input_id_prefix):
Organization = apps.get_model("user_management", "Organization")
selected_org_id = payload["view"]["state"]["values"][input_id_prefix + DIRECT_PAGING_ORG_SELECT_ID][
OnOrgChange.routing_uid()
]["selected_option"]["value"]
org = Organization.objects.filter(pk=selected_org_id).first()
return org
def _get_team_select(slack_user_identity, organization, value, input_id_prefix):
teams = organization.teams.filter(
users__slack_user_identity=slack_user_identity,
).distinct()
team_options = []
# Adding pseudo option for default team
initial_option_idx = 0
team_options.append(
{
"text": {
"type": "plain_text",
"text": f"General",
"emoji": True,
},
"value": DEFAULT_TEAM_VALUE,
}
)
for idx, team in enumerate(teams, start=1):
if team == value:
initial_option_idx = idx
team_options.append(
{
"text": {
"type": "plain_text",
"text": f"{team.name}",
"emoji": True,
},
"value": f"{team.pk}",
}
)
team_select = {
"type": "section",
"text": {"type": "mrkdwn", "text": "Select a team"},
"block_id": input_id_prefix + DIRECT_PAGING_TEAM_SELECT_ID,
"accessory": {
"type": "static_select",
"placeholder": {"type": "plain_text", "text": "Select a team", "emoji": True},
"options": team_options,
"action_id": OnTeamChange.routing_uid(),
"initial_option": team_options[initial_option_idx],
},
}
return team_select
def _get_users_select(slack_user_identity, organization, team, value, input_id_prefix):
users = organization.users.all()
if team is not None:
users = users.filter(teams=team)
user_options = [
{
"text": {
"type": "plain_text",
"text": f"{user.name or user.username}",
"emoji": True,
},
"value": f"{user.pk}",
}
for user in users
]
user_select = {
"type": "section",
"text": {"type": "mrkdwn", "text": "Add responders"},
"block_id": input_id_prefix + DIRECT_PAGING_USER_SELECT_ID,
"accessory": {
"type": "static_select",
"placeholder": {"type": "plain_text", "text": "Select a user", "emoji": True},
"options": user_options,
"action_id": OnUserChange.routing_uid(),
},
}
return user_select
def _get_selected_users_list(input_id_prefix, users):
user_entries = (
[{"type": "divider"}]
+ [
{
"type": "section",
"block_id": input_id_prefix + f"user_{u.pk}",
"text": {"type": "mrkdwn", "text": f"*{u.name or u.username}* | {p} notifications\n_({u.timezone})_"},
"accessory": {
"type": "overflow",
"options": [
{"text": {"type": "plain_text", "text": f"{label}"}, "value": f"{action}|{u.pk}"}
for (action, label) in USER_ACTIONS
],
"action_id": OnUserActionChange.routing_uid(),
},
}
for u, p in users
]
+ [{"type": "divider"}]
)
return user_entries
def _display_availability_warnings(payload, warnings, organization, user):
metadata = json.loads(payload["view"]["private_metadata"])
messages = []
for w in warnings:
if w["error"] == USER_IS_NOT_ON_CALL:
messages.append(
f":warning: User *{user.name or user.username}* is not on-call.\nWe recommend you to select on-call users first."
)
schedules_available = w["data"].get("schedules", {})
if schedules_available:
messages.append(":information_source: Currently on-call from schedules:")
for schedule, users in schedules_available.items():
oncall_users = organization.users.filter(public_primary_key__in=users)
usernames = ", ".join(f"*{u.name or u.username}*" for u in oncall_users)
messages.append(f":spiral_calendar_pad: {schedule}: {usernames}")
elif w["error"] == USER_HAS_NO_NOTIFICATION_POLICY:
messages.append(f":warning: User *{user.name or user.username}* has no notification policy setup.")
return {
"type": "modal",
"callback_id": OnConfirmUserChange.routing_uid(),
"title": {"type": "plain_text", "text": "Are you sure?"},
"submit": {"type": "plain_text", "text": "Confirm"},
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": message,
},
}
for message in messages
],
"private_metadata": json.dumps(
{
"state": payload["view"]["state"],
"input_id_prefix": metadata["input_id_prefix"],
"channel_id": metadata["channel_id"],
"submit_routing_uid": metadata["submit_routing_uid"],
"current_users": metadata["current_users"],
}
),
}
def _get_selected_team_from_payload(payload, input_id_prefix):
Team = apps.get_model("user_management", "Team")
selected_team_id = payload["view"]["state"]["values"][input_id_prefix + DIRECT_PAGING_TEAM_SELECT_ID][
OnTeamChange.routing_uid()
]["selected_option"]["value"]
if selected_team_id == DEFAULT_TEAM_VALUE:
return None
team = Team.objects.filter(pk=selected_team_id).first()
return team
def _get_selected_user_from_payload(payload, input_id_prefix):
User = apps.get_model("user_management", "User")
selected_option = payload["view"]["state"]["values"][input_id_prefix + DIRECT_PAGING_USER_SELECT_ID][
OnUserChange.routing_uid()
]["selected_option"]
if selected_option is not None:
selected_user_id = selected_option["value"]
user = User.objects.filter(pk=selected_user_id).first()
return user
def _get_and_change_input_id_prefix_from_metadata(metadata):
old_input_id_prefix = metadata["input_id_prefix"]
new_input_id_prefix = _generate_input_id_prefix()
metadata["input_id_prefix"] = new_input_id_prefix
return old_input_id_prefix, new_input_id_prefix, metadata
def _get_title_input(payload):
title_input_block = {
"type": "input",
"block_id": DIRECT_PAGING_TITLE_INPUT_ID,
"label": {
"type": "plain_text",
"text": "Title:",
},
"element": {
"type": "plain_text_input",
"action_id": FinishDirectPaging.routing_uid(),
"placeholder": {
"type": "plain_text",
"text": " ",
},
},
}
if payload.get("text", None) is not None:
title_input_block["element"]["initial_value"] = payload["text"]
return title_input_block
def _get_title_from_payload(payload):
title = payload["view"]["state"]["values"][DIRECT_PAGING_TITLE_INPUT_ID][FinishDirectPaging.routing_uid()]["value"]
return title
def _get_message_input(payload):
message_input_block = {
"type": "input",
"block_id": DIRECT_PAGING_MESSAGE_INPUT_ID,
"label": {
"type": "plain_text",
"text": "Message:",
},
"element": {
"type": "plain_text_input",
"action_id": FinishDirectPaging.routing_uid(),
"multiline": True,
"placeholder": {
"type": "plain_text",
"text": " ",
},
},
"optional": True,
}
if payload.get("message", {}).get("text") is not None:
message_input_block["element"]["initial_value"] = payload["message"]["text"]
return message_input_block
def _get_message_from_payload(payload):
message = (
payload["view"]["state"]["values"][DIRECT_PAGING_MESSAGE_INPUT_ID][FinishDirectPaging.routing_uid()]["value"]
or ""
)
return message
# _generate_input_id_prefix returns uniq str to not to preserve input's values between view update
# https://api.slack.com/methods/views.update#markdown
def _generate_input_id_prefix():
return str(uuid4())
STEPS_ROUTING = [
{
"payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS,
"block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT,
"block_action_id": OnOrgChange.routing_uid(),
"step": OnOrgChange,
},
{
"payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS,
"block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT,
"block_action_id": OnTeamChange.routing_uid(),
"step": OnTeamChange,
},
{
"payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS,
"block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT,
"block_action_id": OnUserChange.routing_uid(),
"step": OnUserChange,
},
{
"payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION,
"view_callback_id": OnConfirmUserChange.routing_uid(),
"step": OnConfirmUserChange,
},
{
"payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS,
"block_action_type": scenario_step.BLOCK_ACTION_TYPE_OVERFLOW,
"block_action_id": OnUserActionChange.routing_uid(),
"step": OnUserActionChange,
},
{
"payload_type": scenario_step.PAYLOAD_TYPE_SLASH_COMMAND,
"command_name": StartDirectPaging.command_name,
"step": StartDirectPaging,
},
{
"payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION,
"view_callback_id": FinishDirectPaging.routing_uid(),
"step": FinishDirectPaging,
},
]

View file

@ -0,0 +1,183 @@
import json
from unittest.mock import patch
import pytest
from django.utils import timezone
from apps.base.models import UserNotificationPolicy
from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb
from apps.slack.scenarios.paging import (
DEFAULT_POLICY,
DIRECT_PAGING_MESSAGE_INPUT_ID,
DIRECT_PAGING_ORG_SELECT_ID,
DIRECT_PAGING_TEAM_SELECT_ID,
DIRECT_PAGING_TITLE_INPUT_ID,
DIRECT_PAGING_USER_SELECT_ID,
IMPORTANT_POLICY,
REMOVE_ACTION,
FinishDirectPaging,
OnOrgChange,
OnTeamChange,
OnUserActionChange,
OnUserChange,
StartDirectPaging,
)
def make_slack_payload(organization, user=None, current_users=None, actions=None):
payload = {
"channel_id": "123",
"trigger_id": "111",
"view": {
"id": "view-id",
"private_metadata": json.dumps(
{
"input_id_prefix": "",
"channel_id": "123",
"submit_routing_uid": "FinishStepUID",
"current_users": current_users or {},
}
),
"state": {
"values": {
DIRECT_PAGING_ORG_SELECT_ID: {
OnOrgChange.routing_uid(): {"selected_option": {"value": organization.pk}}
},
DIRECT_PAGING_TEAM_SELECT_ID: {OnTeamChange.routing_uid(): {"selected_option": {"value": 0}}},
DIRECT_PAGING_USER_SELECT_ID: {
OnUserChange.routing_uid(): {"selected_option": {"value": user.pk} if user else None}
},
DIRECT_PAGING_TITLE_INPUT_ID: {FinishDirectPaging.routing_uid(): {"value": "The Title"}},
DIRECT_PAGING_MESSAGE_INPUT_ID: {FinishDirectPaging.routing_uid(): {"value": "The Message"}},
}
},
},
}
if actions is not None:
payload["actions"] = actions
return payload
@pytest.mark.django_db
def test_initial_users(
make_organization_and_user_with_slack_identities,
):
organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities()
payload = {"channel_id": "123", "trigger_id": "111"}
step = StartDirectPaging(slack_team_identity)
with patch.object(step._slack_client, "api_call") as mock_slack_api_call:
step.process_scenario(slack_user_identity, slack_team_identity, payload)
assert mock_slack_api_call.call_args.args == ("views.open",)
metadata = json.loads(mock_slack_api_call.call_args.kwargs["view"]["private_metadata"])
assert metadata["current_users"] == {}
@pytest.mark.django_db
def test_add_user_no_warning(
make_organization_and_user_with_slack_identities, make_schedule, make_on_call_shift, make_user_notification_policy
):
organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities()
# set up schedule: user is on call
schedule = make_schedule(
organization,
schedule_class=OnCallScheduleWeb,
team=None,
)
now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
start_date = now - timezone.timedelta(days=7)
data = {
"start": start_date,
"rotation_start": start_date,
"duration": timezone.timedelta(hours=23, minutes=59, seconds=59),
"priority_level": 1,
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
"schedule": schedule,
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
)
on_call_shift.add_rolling_users([[user]])
schedule.refresh_ical_file()
# setup notification policy
make_user_notification_policy(
user=user,
step=UserNotificationPolicy.Step.NOTIFY,
notify_by=UserNotificationPolicy.NotificationChannel.SMS,
)
payload = make_slack_payload(organization=organization, user=user)
step = OnUserChange(slack_team_identity)
with patch.object(step._slack_client, "api_call") as mock_slack_api_call:
step.process_scenario(slack_user_identity, slack_team_identity, payload)
assert mock_slack_api_call.call_args.args == ("views.update",)
metadata = json.loads(mock_slack_api_call.call_args.kwargs["view"]["private_metadata"])
assert metadata["current_users"] == {str(user.pk): DEFAULT_POLICY}
@pytest.mark.django_db
def test_add_user_raise_warning(make_organization_and_user_with_slack_identities, make_schedule, make_on_call_shift):
organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities()
# user is not on call
payload = make_slack_payload(organization=organization, user=user)
step = OnUserChange(slack_team_identity)
with patch.object(step._slack_client, "api_call") as mock_slack_api_call:
step.process_scenario(slack_user_identity, slack_team_identity, payload)
assert mock_slack_api_call.call_args.args == ("views.push",)
assert mock_slack_api_call.call_args.kwargs["view"]["callback_id"] == "OnConfirmUserChange"
text_from_blocks = "".join(
b["text"]["text"] for b in mock_slack_api_call.call_args.kwargs["view"]["blocks"] if b["type"] == "section"
)
assert f"*{user.username}* is not on-call" in text_from_blocks
metadata = json.loads(mock_slack_api_call.call_args.kwargs["view"]["private_metadata"])
assert metadata["current_users"] == {}
@pytest.mark.django_db
def test_change_user_policy(make_organization_and_user_with_slack_identities, make_schedule, make_on_call_shift):
organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities()
payload = make_slack_payload(
organization=organization, actions=[{"selected_option": {"value": f"{IMPORTANT_POLICY}|{user.pk}"}}]
)
step = OnUserActionChange(slack_team_identity)
with patch.object(step._slack_client, "api_call") as mock_slack_api_call:
step.process_scenario(slack_user_identity, slack_team_identity, payload)
assert mock_slack_api_call.call_args.args == ("views.update",)
metadata = json.loads(mock_slack_api_call.call_args.kwargs["view"]["private_metadata"])
assert metadata["current_users"] == {str(user.pk): IMPORTANT_POLICY}
@pytest.mark.django_db
def test_remove_user(make_organization_and_user_with_slack_identities, make_schedule, make_on_call_shift):
organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities()
payload = make_slack_payload(
organization=organization, actions=[{"selected_option": {"value": f"{REMOVE_ACTION}|{user.pk}"}}]
)
step = OnUserActionChange(slack_team_identity)
with patch.object(step._slack_client, "api_call") as mock_slack_api_call:
step.process_scenario(slack_user_identity, slack_team_identity, payload)
assert mock_slack_api_call.call_args.args == ("views.update",)
metadata = json.loads(mock_slack_api_call.call_args.kwargs["view"]["private_metadata"])
assert metadata["current_users"] == {}
@pytest.mark.django_db
def test_trigger_paging(make_organization_and_user_with_slack_identities, make_schedule, make_on_call_shift):
organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities()
payload = make_slack_payload(organization=organization, current_users={str(user.pk): IMPORTANT_POLICY})
step = FinishDirectPaging(slack_team_identity)
with patch("apps.slack.scenarios.paging.direct_paging") as mock_direct_paging:
with patch.object(step._slack_client, "api_call"):
step.process_scenario(slack_user_identity, slack_team_identity, payload)
assert mock_direct_paging.called_with(organization, None, user, "The Title", "The Message", [(user, True)])

View file

@ -15,12 +15,14 @@ from apps.api.permissions import RBACPermission
from apps.auth_token.auth import PluginAuthentication
from apps.base.utils import live_settings
from apps.slack.scenarios.alertgroup_appearance import STEPS_ROUTING as ALERTGROUP_APPEARANCE_ROUTING
# Importing routes from scenarios
from apps.slack.scenarios.declare_incident import STEPS_ROUTING as DECLARE_INCIDENT_ROUTING
from apps.slack.scenarios.distribute_alerts import STEPS_ROUTING as DISTRIBUTION_STEPS_ROUTING
from apps.slack.scenarios.invited_to_channel import STEPS_ROUTING as INVITED_TO_CHANNEL_ROUTING
from apps.slack.scenarios.manual_incident import STEPS_ROUTING as MANUAL_INCIDENT_ROUTING
# Importing routes from scenarios
from apps.slack.scenarios.onboarding import STEPS_ROUTING as ONBOARDING_STEPS_ROUTING
from apps.slack.scenarios.paging import STEPS_ROUTING as DIRECT_PAGE_ROUTING
from apps.slack.scenarios.profile_update import STEPS_ROUTING as PROFILE_UPDATE_ROUTING
from apps.slack.scenarios.resolution_note import STEPS_ROUTING as RESOLUTION_NOTE_ROUTING
from apps.slack.scenarios.scenario_step import (
@ -68,6 +70,8 @@ SCENARIOS_ROUTES.extend(SLACK_USERGROUP_UPDATE_ROUTING)
SCENARIOS_ROUTES.extend(CHANNEL_ROUTING)
SCENARIOS_ROUTES.extend(PROFILE_UPDATE_ROUTING)
SCENARIOS_ROUTES.extend(MANUAL_INCIDENT_ROUTING)
SCENARIOS_ROUTES.extend(DIRECT_PAGE_ROUTING)
SCENARIOS_ROUTES.extend(DECLARE_INCIDENT_ROUTING)
logger = logging.getLogger(__name__)
@ -516,8 +520,8 @@ class SlackEventApiEndpointView(APIView):
return
text = (
"The information in workspace is read-only. To be able to intercat with OnCall alert groups you need to connect a personal account.\n"
"Please go to the *Grafana* -> *OnCall* -> *Users*, "
"The information in this workspace is read-only. To interact with OnCall alert groups you need to connect a personal account.\n"
"Please go to *Grafana* -> *OnCall* -> *Users*, "
"choose *your profile* and click the *connect* button.\n"
":rocket: :rocket: :rocket:"
)

View file

@ -30,11 +30,11 @@ def sync_organization(organization):
_sync_instance_info(organization)
api_users = grafana_api_client.get_users(rbac_is_enabled)
if api_users:
_, check_token_call_status = grafana_api_client.check_token()
if check_token_call_status["status_code"] == 200:
organization.api_token_status = Organization.API_TOKEN_STATUS_OK
sync_users_and_teams(grafana_api_client, api_users, organization)
sync_users_and_teams(grafana_api_client, organization)
organization.last_time_synced = timezone.now()
organization.is_grafana_incident_enabled = check_grafana_incident_is_enabled(grafana_api_client)
else:
organization.api_token_status = Organization.API_TOKEN_STATUS_FAILED
@ -71,27 +71,42 @@ def _sync_instance_info(organization):
organization.gcom_token_org_last_time_synced = timezone.now()
def sync_users_and_teams(client, api_users, organization):
def sync_users_and_teams(client: GrafanaAPIClient, organization):
sync_users(client, organization)
sync_teams(client, organization)
sync_team_members(client, organization)
def sync_users(client: GrafanaAPIClient, organization, **kwargs):
api_users = client.get_users(organization.is_rbac_permissions_enabled, **kwargs)
# check if api_users are shaped correctly. e.g. for paused instance, the response is not a list.
if not api_users or not isinstance(api_users, (tuple, list)):
return
User.objects.sync_for_organization(organization=organization, api_users=api_users)
api_teams_result, _ = client.get_teams()
def sync_teams(client: GrafanaAPIClient, organization, **kwargs):
api_teams_result, _ = client.get_teams(**kwargs)
if not api_teams_result:
return
api_teams = api_teams_result["teams"]
Team.objects.sync_for_organization(organization=organization, api_teams=api_teams)
def sync_team_members(client: GrafanaAPIClient, organization):
for team in organization.teams.all():
members, _ = client.get_team_members(team.team_id)
if not members:
continue
User.objects.sync_for_team(team=team, api_members=members)
organization.last_time_synced = timezone.now()
def sync_users_for_teams(client: GrafanaAPIClient, organization, **kwargs):
api_teams_result, _ = client.get_teams(**kwargs)
if not api_teams_result:
return
api_teams = api_teams_result["teams"]
Team.objects.sync_for_organization(organization=organization, api_teams=api_teams)
def check_grafana_incident_is_enabled(client):

View file

@ -134,14 +134,19 @@ def test_sync_organization(make_organization, make_team, make_user_for_organizat
},
)
api_check_token_call_status = {"status_code": 200}
with patch.object(GrafanaAPIClient, "is_rbac_enabled_for_organization", return_value=False):
with patch.object(GrafanaAPIClient, "get_users", return_value=api_users_response):
with patch.object(GrafanaAPIClient, "get_teams", return_value=(api_teams_response, None)):
with patch.object(GrafanaAPIClient, "get_team_members", return_value=(api_members_response, None)):
with patch.object(
GrafanaAPIClient, "get_grafana_plugin_settings", return_value=({"enabled": True}, None)
GrafanaAPIClient, "check_token", return_value=(None, api_check_token_call_status)
):
sync_organization(organization)
with patch.object(
GrafanaAPIClient, "get_grafana_plugin_settings", return_value=({"enabled": True}, None)
):
sync_organization(organization)
# check that users are populated
assert organization.users.count() == 1
@ -167,9 +172,50 @@ def test_sync_organization(make_organization, make_team, make_user_for_organizat
def test_sync_organization_is_rbac_permissions_enabled_open_source(make_organization, grafana_api_response):
organization = make_organization()
api_users_response = (
{
"userId": 1,
"email": "test@test.test",
"name": "Test",
"login": "test",
"role": "admin",
"avatarUrl": "test.test/test",
"permissions": [],
},
)
api_teams_response = {
"totalCount": 1,
"teams": (
{
"id": 1,
"name": "Test",
"email": "test@test.test",
"avatarUrl": "test.test/test",
},
),
}
api_members_response = (
{
"orgId": organization.org_id,
"teamId": 1,
"userId": 1,
},
)
api_check_token_call_status = {"status_code": 200}
with patch.object(GrafanaAPIClient, "is_rbac_enabled_for_organization", return_value=grafana_api_response):
with patch.object(GrafanaAPIClient, "get_users", return_value=[]):
sync_organization(organization)
with patch.object(GrafanaAPIClient, "get_users", return_value=api_users_response):
with patch.object(GrafanaAPIClient, "get_teams", return_value=(api_teams_response, None)):
with patch.object(GrafanaAPIClient, "get_team_members", return_value=(api_members_response, None)):
with patch.object(
GrafanaAPIClient, "check_token", return_value=(None, api_check_token_call_status)
):
with patch.object(
GrafanaAPIClient, "get_grafana_plugin_settings", return_value=({"enabled": True}, None)
):
sync_organization(organization)
organization.refresh_from_db()
assert organization.is_rbac_permissions_enabled == grafana_api_response
@ -184,10 +230,50 @@ def test_sync_organization_is_rbac_permissions_enabled_cloud(mocked_gcom_client,
stack_id = 5
organization = make_organization(stack_id=stack_id)
api_check_token_call_status = {"status_code": 200}
mocked_gcom_client.return_value.is_rbac_enabled_for_stack.return_value = gcom_api_response
with patch.object(GrafanaAPIClient, "get_users", return_value=[]):
sync_organization(organization)
api_users_response = (
{
"userId": 1,
"email": "test@test.test",
"name": "Test",
"login": "test",
"role": "admin",
"avatarUrl": "test.test/test",
"permissions": [],
},
)
api_teams_response = {
"totalCount": 1,
"teams": (
{
"id": 1,
"name": "Test",
"email": "test@test.test",
"avatarUrl": "test.test/test",
},
),
}
api_members_response = (
{
"orgId": organization.org_id,
"teamId": 1,
"userId": 1,
},
)
with patch.object(GrafanaAPIClient, "check_token", return_value=(None, api_check_token_call_status)):
with patch.object(GrafanaAPIClient, "get_users", return_value=api_users_response):
with patch.object(GrafanaAPIClient, "get_teams", return_value=(api_teams_response, None)):
with patch.object(GrafanaAPIClient, "get_team_members", return_value=(api_members_response, None)):
with patch.object(
GrafanaAPIClient, "get_grafana_plugin_settings", return_value=({"enabled": True}, None)
):
sync_organization(organization)
organization.refresh_from_db()

View file

@ -129,11 +129,8 @@ class OnCallGatewayAPIClient:
if response.status_code not in [200, 201, 202, 204]:
err_msg = cls._get_error_msg_from_response(response)
if 400 <= response.status_code < 500:
print(1)
err_msg = "%s Client Error: %s for url: %s" % (response.status_code, err_msg, response.url)
elif 500 <= response.status_code < 600:
print(2)
err_msg = "%s Server Error: %s for url: %s" % (response.status_code, err_msg, response.url)
print(err_msg)
raise requests.exceptions.HTTPError(err_msg, response=response)

View file

@ -736,10 +736,12 @@ def make_integration_heartbeat():
return _make_integration_heartbeat
@pytest.fixture()
def load_slack_urls(settings):
def reload_urls(settings):
"""
Reloads Django URLs, especially useful when testing conditionally registered URLs
"""
clear_url_caches()
settings.FEATURE_SLACK_INTEGRATION_ENABLED = True
urlconf = settings.ROOT_URLCONF
if urlconf in sys.modules:
reload(sys.modules[urlconf])
@ -747,6 +749,18 @@ def load_slack_urls(settings):
import_module(urlconf)
@pytest.fixture()
def load_slack_urls(settings):
settings.FEATURE_SLACK_INTEGRATION_ENABLED = True
reload_urls(settings)
@pytest.fixture()
def load_mobile_app_urls(settings):
settings.FEATURE_MOBILE_APP_INTEGRATION_ENABLED = True
reload_urls(settings)
@pytest.fixture
def make_region():
def _make_region(**kwargs):

View file

@ -60,7 +60,7 @@ if settings.FEATURE_MOBILE_APP_INTEGRATION_ENABLED:
if settings.OSS_INSTALLATION:
urlpatterns += [
path("api/internal/v1/", include("apps.oss_installation.urls")),
path("api/internal/v1/", include("apps.oss_installation.urls", namespace="oss_installation")),
]
if settings.DEBUG:
@ -70,5 +70,7 @@ if settings.DEBUG:
path("__debug__/", include(debug_toolbar.urls)),
] + urlpatterns
if settings.SILK_PROFILER_ENABLED:
urlpatterns += [path(settings.SILK_PATH, include("silk.urls", namespace="silk"))]
admin.site.site_header = settings.ADMIN_SITE_HEADER

View file

@ -13,7 +13,7 @@ django-cors-headers==3.7.0
django-debug-toolbar==3.2.1
django-sns-view==0.1.2
python-telegram-bot==13.13
django-silk==4.1.0
django-silk==5.0.3
django-redis-cache==3.0.0
hiredis==1.0.0
django-ratelimit==2.0.0
@ -23,7 +23,7 @@ recurring-ical-events==0.1.16b0
slack-export-viewer==1.0.0
beautifulsoup4==4.8.1
social-auth-app-django==3.1.0
cryptography==39.0.0
cryptography==38.0.4 # version 39.0.0 introduced an issue - https://stackoverflow.com/a/75053968/3902555
pytest==5.4.3
pytest-django==3.9.0
pytest_factoryboy==2.0.3
@ -45,3 +45,4 @@ opentelemetry-instrumentation-celery==0.36b0
opentelemetry-instrumentation-pymysql==0.36b0
opentelemetry-instrumentation-wsgi==0.36b0
opentelemetry-exporter-otlp-proto-grpc==1.15.0
pyroscope-io==0.8.1

View file

@ -464,14 +464,20 @@ INTERNAL_IPS = ["127.0.0.1"]
SELF_IP = os.environ.get("SELF_IP")
SILK_PATH = os.environ.get("SILK_PATH", "silk/")
SILKY_AUTHENTICATION = True
SILKY_AUTHORISATION = True
SILKY_META = True
SILKY_INTERCEPT_PERCENT = 1
SILKY_MAX_RECORDED_REQUESTS = 10**4
SILK_PROFILER_ENABLED = getenv_boolean("SILK_PROFILER_ENABLED", default=False)
if SILK_PROFILER_ENABLED:
SILK_PATH = os.environ.get("SILK_PATH", "silk/")
SILKY_INTERCEPT_PERCENT = getenv_integer("SILKY_INTERCEPT_PERCENT", 100)
INSTALLED_APPS += ["silk"]
MIDDLEWARE += ["silk.middleware.SilkyMiddleware"]
SILKY_AUTHENTICATION = True
SILKY_AUTHORISATION = True
SILKY_META = True
SILKY_MAX_RECORDED_REQUESTS = 10**4
SILKY_PYTHON_PROFILER = True
INSTALLED_APPS += ["silk"]
# get ONCALL_DJANGO_ADMIN_PATH from env and add trailing / to it
ONCALL_DJANGO_ADMIN_PATH = os.environ.get("ONCALL_DJANGO_ADMIN_PATH", "django-admin") + "/"
@ -495,6 +501,7 @@ SLACK_CLIENT_OAUTH_ID = os.environ.get("SLACK_CLIENT_OAUTH_ID")
SLACK_CLIENT_OAUTH_SECRET = os.environ.get("SLACK_CLIENT_OAUTH_SECRET")
SLACK_SLASH_COMMAND_NAME = os.environ.get("SLACK_SLASH_COMMAND_NAME", "/oncall")
SLACK_DIRECT_PAGING_SLASH_COMMAND = os.environ.get("SLACK_DIRECT_PAGING_SLASH_COMMAND", "/escalate")
SOCIAL_AUTH_SLACK_LOGIN_KEY = SLACK_CLIENT_OAUTH_ID
SOCIAL_AUTH_SLACK_LOGIN_SECRET = SLACK_CLIENT_OAUTH_SECRET
@ -642,3 +649,17 @@ if OSS_INSTALLATION:
"schedule": crontab(hour="*/12"), # noqa
"args": (),
} # noqa
PYROSCOPE_PROFILER_ENABLED = getenv_boolean("PYROSCOPE_PROFILER_ENABLED", default=False)
if PYROSCOPE_PROFILER_ENABLED:
import pyroscope
pyroscope.configure(
application_name=os.getenv("PYROSCOPE_APPLICATION_NAME", "oncall"),
server_address=os.getenv("PYROSCOPE_SERVER_ADDRESS", "http://pyroscope:4040"),
auth_token=os.getenv("PYROSCOPE_AUTH_TOKEN", ""),
detect_subprocesses=True,
tags={
"celery_worker": os.getenv("CELERY_WORKER_QUEUE", None),
},
)

View file

@ -1,5 +1,6 @@
# flake8: noqa
import os
import socket
import sys
from .base import *
@ -25,35 +26,31 @@ MIRAGE_CIPHER_IV = os.environ.get("MIRAGE_CIPHER_IV", "tZZa+60zTZO2NRcS")
TESTING = "pytest" in sys.modules or "unittest" in sys.modules
SILKY_PYTHON_PROFILER = True
# For any requests that come in with that header/value, request.is_secure() will return True.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Uncomment this to view SQL queries
# LOGGING = {
# 'version': 1,
# 'filters': {
# 'require_debug_true': {
# '()': 'django.utils.log.RequireDebugTrue',
# }
# },
# 'handlers': {
# 'console': {
# 'level': 'DEBUG',
# 'filters': ['require_debug_true'],
# 'class': 'logging.StreamHandler',
# }
# },
# 'loggers': {
# 'django.db.backends': {
# 'level': 'DEBUG',
# 'handlers': ['console'],
# }
# }
# }
SILKY_INTERCEPT_PERCENT = 100
if getenv_boolean("DEV_DEBUG_VIEW_SQL_QUERIES", default=False):
LOGGING = {
"version": 1,
"filters": {
"require_debug_true": {
"()": "django.utils.log.RequireDebugTrue",
}
},
"handlers": {
"console": {
"level": "DEBUG",
"filters": ["require_debug_true"],
"class": "logging.StreamHandler",
}
},
"loggers": {
"django.db.backends": {
"level": "DEBUG",
"handlers": ["console"],
}
},
}
SWAGGER_SETTINGS = {
"SECURITY_DEFINITIONS": {
@ -67,3 +64,13 @@ if TESTING:
EXTRA_MESSAGING_BACKENDS = [("apps.base.tests.messaging_backend.TestOnlyBackend", 42)]
TELEGRAM_TOKEN = "0000000000:XXXXXXXXXXXXXXXXXXXXXXXXXXXX-XXXXXX"
TWILIO_AUTH_TOKEN = "twilio_auth_token"
INTERNAL_IPS = [
"127.0.0.1",
]
# the below two lines make it possible to use django-debug-toolbar inside of docker locally
# https://knasmueller.net/fix-djangos-debug-toolbar-not-showing-inside-docker
# https://stackoverflow.com/questions/10517765/django-debug-toolbar-not-showing-up
hostname, _, ips = socket.gethostbyname_ex(socket.gethostname())
INTERNAL_IPS += [".".join(ip.split(".")[:-1] + ["1"]) for ip in ips]

View file

@ -36,6 +36,8 @@ export interface NotificationPolicyProps {
notifyByOptions?: NotifyBy[];
telegramVerified: boolean;
phoneStatus: number;
isMobileAppConnected: boolean;
showCloudConnectionWarning: boolean;
color: string;
number: number;
userAction: UserAction;
@ -132,6 +134,20 @@ export class NotificationPolicy extends React.Component<NotificationPolicyProps,
}
}
_renderMobileAppNote() {
const { isMobileAppConnected, showCloudConnectionWarning } = this.props;
if (showCloudConnectionWarning) {
return <PolicyNote type="danger">Cloud is not connected</PolicyNote>;
}
if (!isMobileAppConnected) {
return <PolicyNote type="danger">Mobile app is not connected</PolicyNote>;
}
return <PolicyNote type="success">Mobile app is connected</PolicyNote>;
}
_renderTelegramNote() {
const { telegramVerified } = this.props;
@ -203,6 +219,12 @@ export class NotificationPolicy extends React.Component<NotificationPolicyProps,
case 3:
return <>{this._renderTelegramNote()}</>;
case 5:
return <>{this._renderMobileAppNote()}</>;
case 6:
return <>{this._renderMobileAppNote()}</>;
default:
return null;
}

View file

@ -1,18 +1,21 @@
import React, { ReactElement, useCallback, useState } from 'react';
import { ConfirmModal } from '@grafana/ui';
import { ConfirmModal, ConfirmModalProps } from '@grafana/ui';
interface WithConfirmProps {
type WithConfirmProps = Partial<ConfirmModalProps> & {
children: ReactElement;
title?: string;
body?: React.ReactNode;
confirmText?: string;
disabled?: boolean;
}
const WithConfirm = (props: WithConfirmProps) => {
const { children, title = 'Are you sure to delete?', body, confirmText = 'Delete', disabled } = props;
};
const WithConfirm: React.FC<WithConfirmProps> = ({
title = 'Are you sure to delete?',
confirmText = 'Delete',
body,
description,
confirmationText,
children,
disabled,
}) => {
const [showConfirmation, setShowConfirmation] = useState<boolean>(false);
const onClickCallback = useCallback((event) => {
@ -39,6 +42,8 @@ const WithConfirm = (props: WithConfirmProps) => {
dismissText="Cancel"
onConfirm={onConfirmCallback}
body={body}
description={description}
confirmationText={confirmationText}
onDismiss={() => {
setShowConfirmation(false);
}}

View file

@ -2,7 +2,9 @@ import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { CloudStore } from 'models/cloud/cloud';
import { UserStore } from 'models/user/user';
import { User } from 'models/user/user.types';
import { RootStore } from 'state';
@ -10,12 +12,33 @@ import { useStore as useStoreOriginal } from 'state/useStore';
import MobileAppConnection from './MobileAppConnection';
jest.mock('plugin/GrafanaPluginRootPage.helpers', () => ({
isTopNavbar: () => false,
}));
jest.mock('@grafana/runtime', () => ({
config: {
featureToggles: {
topNav: false,
},
},
}));
jest.mock('utils/authorization', () => ({
...jest.requireActual('utils/authorization'),
isUserActionAllowed: jest.fn().mockReturnValue(true),
}));
jest.mock('@grafana/runtime', () => ({
getLocationSrv: jest.fn(),
}));
jest.mock('state/useStore');
const useStore = useStoreOriginal as jest.Mock<ReturnType<typeof useStoreOriginal>>;
const loadUserMock = jest.fn().mockReturnValue(undefined);
const mockUseStore = (rest?: any, connected = false) => {
const mockUseStore = (rest?: any, connected = false, cloud_connected = true) => {
const store = {
userStore: {
loadUser: loadUserMock,
@ -26,6 +49,11 @@ const mockUseStore = (rest?: any, connected = false) => {
} as unknown as User,
...(rest ? rest : {}),
} as unknown as UserStore,
cloudStore: {
getCloudConnectionStatus: jest.fn().mockReturnValue({ cloud_connection_status: cloud_connected }),
cloudConnectionStatus: { cloud_connection_status: cloud_connected },
} as unknown as CloudStore,
hasFeature: jest.fn().mockReturnValue(true),
} as unknown as RootStore;
useStore.mockReturnValue(store);
@ -232,4 +260,16 @@ describe('MobileAppConnection', () => {
{ timeout: 6000 }
);
});
test('it shows a warning when cloud is not connected', async () => {
mockUseStore({}, true, false);
// Using MemoryRouter to avoid "Invariant failed: You should not use <Link> outside a <Router>"
const component = render(
<MemoryRouter>
<MobileAppConnection userPk={USER_PK} />
</MemoryRouter>
);
expect(component.container).toMatchSnapshot();
});
});

View file

@ -1,14 +1,17 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Icon, LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
import { Button, Icon, LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import qrCodeImage from 'assets/img/qr-code.png';
import Block from 'components/GBlock/Block';
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import { User } from 'models/user/user.types';
import { AppFeature } from 'state/features';
import { useStore } from 'state/useStore';
import { isUserActionAllowed, UserActions } from 'utils/authorization';
import styles from './MobileAppConnection.module.scss';
import DisconnectButton from './parts/DisconnectButton/DisconnectButton';
@ -29,7 +32,29 @@ const INTERVAL_POLLING = 5000;
const BACKEND = 'MOBILE_APP';
const MobileAppConnection = observer(({ userPk }: Props) => {
const { userStore } = useStore();
const store = useStore();
const { userStore, cloudStore } = store;
// Show link to cloud page for OSS instances with no cloud connection
if (store.hasFeature(AppFeature.CloudConnection) && !cloudStore.cloudConnectionStatus.cloud_connection_status) {
return (
<VerticalGroup spacing="lg">
<Text type="secondary">Please connect Cloud OnCall to use the mobile app</Text>
{isUserActionAllowed(UserActions.OtherSettingsWrite) ? (
<PluginLink query={{ page: 'cloud' }}>
<Button variant="secondary" icon="external-link-alt">
Connect Cloud OnCall
</Button>
</PluginLink>
) : (
<Text type="secondary">
You do not have permission to perform this action. Ask an admin to connect Cloud OnCall or upgrade your
permissions.
</Text>
)}
</VerticalGroup>
);
}
const isMounted = useRef(false);
const [mobileAppIsCurrentlyConnected, setMobileAppIsCurrentlyConnected] = useState<boolean>(isUserConnected());

View file

@ -2774,6 +2774,59 @@ exports[`MobileAppConnection it shows a message when the mobile app is already c
</div>
`;
exports[`MobileAppConnection it shows a warning when cloud is not connected 1`] = `
<div>
<div
class="css-1j7sh2x-vertical-group"
style="width: 100%; height: 100%;"
>
<div
class="css-ztyofd-layoutChildrenWrapper"
>
<span
class="root text text--secondary text--medium"
>
Please connect Cloud OnCall to use the mobile app
</span>
</div>
<div
class="css-ztyofd-layoutChildrenWrapper"
>
<a
class="root"
href="/a/grafana-oncall-app/cloud"
>
<button
class="css-1a8393j-button"
type="button"
>
<div
class="css-wf08df-Icon"
>
<svg
class="css-1gebccs"
height="16"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18,10.82a1,1,0,0,0-1,1V19a1,1,0,0,1-1,1H5a1,1,0,0,1-1-1V8A1,1,0,0,1,5,7h7.18a1,1,0,0,0,0-2H5A3,3,0,0,0,2,8V19a3,3,0,0,0,3,3H16a3,3,0,0,0,3-3V11.82A1,1,0,0,0,18,10.82Zm3.92-8.2a1,1,0,0,0-.54-.54A1,1,0,0,0,21,2H15a1,1,0,0,0,0,2h3.59L8.29,14.29a1,1,0,0,0,0,1.42,1,1,0,0,0,1.42,0L20,5.41V9a1,1,0,0,0,2,0V3A1,1,0,0,0,21.92,2.62Z"
/>
</svg>
</div>
<span
class="css-1mhnkuh"
>
Connect Cloud OnCall
</span>
</button>
</a>
</div>
</div>
</div>
`;
exports[`MobileAppConnection it shows an error message if there was an error disconnecting the mobile app 1`] = `
<div>
<div

View file

@ -113,6 +113,11 @@ const PersonalNotificationSettings = observer((props: PersonalNotificationSettin
return Number(user.verified_phone_number) + 2;
};
// Mobile app related NotificationPolicy props
const isMobileAppConnected = user.messaging_backends['MOBILE_APP']?.connected;
const showCloudConnectionWarning =
store.hasFeature(AppFeature.CloudConnection) && !store.cloudStore.cloudConnectionStatus.cloud_connection_status;
return (
<div className={cx('root')}>
{title}
@ -134,6 +139,8 @@ const PersonalNotificationSettings = observer((props: PersonalNotificationSettin
number={index + 1}
telegramVerified={Boolean(user.telegram_configuration)}
phoneStatus={getPhoneStatus()}
isMobileAppConnected={isMobileAppConnected}
showCloudConnectionWarning={showCloudConnectionWarning}
slackTeamIdentity={store.teamStore.currentTeam?.slack_team_identity}
slackUserIdentity={user.slack_user_identity}
data={notificationPolicy}

View file

@ -1,97 +0,0 @@
import React, { useCallback, useState } from 'react';
import { Button, Modal } from '@grafana/ui';
import { observer } from 'mobx-react';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
import { useStore } from 'state/useStore';
import { UserActions } from 'utils/authorization';
const SlackIntegrationButton = observer((props: { className: string; disabled?: boolean }) => {
const { className, disabled } = props;
const [showModal, setShowModal] = useState<boolean>(false);
const store = useStore();
const onInstallModalCallback = useCallback(() => {
setShowModal(true);
}, []);
const onInstallModalHideCallback = useCallback(() => {
setShowModal(false);
}, []);
const onRemoveClickCallback = useCallback(() => {
store.slackStore.removeSlackIntegration().then(() => {
store.teamStore.loadCurrentTeam();
});
}, []);
const onInstallClickCallback = useCallback(() => {
store.slackStore.installSlackIntegration();
}, []);
if (store.teamStore.currentTeam?.slack_team_identity) {
return (
<WithPermissionControl userAction={UserActions.IntegrationsWrite}>
<WithConfirm title="Are you sure to delete this Slack Integration?">
<Button
variant="destructive"
size="md"
icon="slack"
className={className}
disabled={disabled}
onClick={onRemoveClickCallback}
>
Remove Slack Integration ({store.teamStore.currentTeam.slack_team_identity?.cached_name})
</Button>
</WithConfirm>
</WithPermissionControl>
);
}
return (
<>
<WithPermissionControl userAction={UserActions.IntegrationsWrite}>
<Button
size="lg"
variant="primary"
icon="slack"
className={className}
disabled={disabled}
onClick={onInstallModalCallback}
>
Connect Slack
</Button>
</WithPermissionControl>
{showModal && <SlackModal onHide={onInstallModalHideCallback} onConfirm={onInstallClickCallback} />}
</>
);
});
interface SlackModalProps {
onHide: () => void;
onConfirm: () => void;
}
const SlackModal = (props: SlackModalProps) => {
const { onHide, onConfirm } = props;
return (
<Modal title="Slack connection" closeOnEscape isOpen onDismiss={onHide}>
<div style={{ textAlign: 'left' }}>
You can view your Slack Workspace at the top-right corner after you are redirected. It should be a Workspace
with App Bot installed:
</div>
<img
style={{ height: '350px', display: 'block', margin: '0 auto' }}
src="public/plugins/grafana-oncall-app/img/slack_workspace_choose_attention.png"
/>
<Button onClick={onConfirm}>I'll check! Proceed to Slack...</Button>
</Modal>
);
};
export default SlackIntegrationButton;

View file

@ -122,7 +122,7 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => {
{isUserActionAllowed(UserActions.OtherSettingsWrite) ? (
<VerticalGroup spacing="lg">
<HorizontalGroup justify="space-between">
<Text.Title level={3}>OnCall use Grafana Cloud for SMS and phone call notifications</Text.Title>
<Text.Title level={3}>OnCall uses Grafana Cloud for SMS and phone call notifications</Text.Title>
{syncing ? (
<Button variant="secondary" icon="sync" disabled>
Updating...
@ -137,7 +137,7 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => {
</VerticalGroup>
) : (
<VerticalGroup spacing="lg">
<Text.Title level={3}>OnCall use Grafana Cloud for SMS and phone call notifications</Text.Title>
<Text.Title level={3}>OnCall uses Grafana Cloud for SMS and phone call notifications</Text.Title>
<Text>You do not have permission to perform this action. Ask an admin to upgrade your permissions.</Text>
</VerticalGroup>
)}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

View file

@ -314,8 +314,6 @@ export class AlertGroupStore extends BaseStore {
const result = await makeRequest(`${this.path}stats/`, {
params: {
...this.incidentFilters,
resolved: false,
acknowledged: false,
status: [IncidentStatus.New],
},
});
@ -327,8 +325,6 @@ export class AlertGroupStore extends BaseStore {
const result = await makeRequest(`${this.path}stats/`, {
params: {
...this.incidentFilters,
resolved: false,
acknowledged: true,
status: [IncidentStatus.Acknowledged],
},
});
@ -341,7 +337,6 @@ export class AlertGroupStore extends BaseStore {
const result = await makeRequest(`${this.path}stats/`, {
params: {
...this.incidentFilters,
resolved: true,
status: [IncidentStatus.Resolved],
},
});
@ -354,7 +349,6 @@ export class AlertGroupStore extends BaseStore {
const result = await makeRequest(`${this.path}stats/`, {
params: {
...this.incidentFilters,
silenced: true,
status: [IncidentStatus.Silenced],
},
});

View file

@ -13,6 +13,9 @@ export class CloudStore extends BaseStore {
@observable.shallow
items: { [id: string]: Cloud } = {};
@observable
cloudConnectionStatus: { cloud_connection_status: boolean } = { cloud_connection_status: false };
constructor(rootStore: RootStore) {
super(rootStore);
@ -67,6 +70,10 @@ export class CloudStore extends BaseStore {
return await makeRequest(`${this.path}${id}`, { method: 'GET' });
}
async loadCloudConnectionStatus() {
this.cloudConnectionStatus = await this.getCloudConnectionStatus();
}
async getCloudConnectionStatus() {
return await makeRequest(`/cloud_connection/`, { method: 'GET' });
}

View file

@ -297,7 +297,10 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
</HorizontalGroup>
<HorizontalGroup>
<PluginLink query={{ page: 'integrations', id: incident.alert_receive_channel.id }}>
<PluginLink
disabled={incident.alert_receive_channel.deleted}
query={{ page: 'integrations', id: incident.alert_receive_channel.id }}
>
<Button disabled={incident.alert_receive_channel.deleted} variant="secondary" size="sm" icon="compass">
Go to Integration
</Button>

View file

@ -1,6 +1,6 @@
import React, { Component } from 'react';
import { Field, HorizontalGroup, LoadingPlaceholder, VerticalGroup, Icon, Button } from '@grafana/ui';
import { Alert, Field, HorizontalGroup, LoadingPlaceholder, VerticalGroup, Icon, Button } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
@ -124,8 +124,30 @@ class SlackSettings extends Component<SlackProps, SlackState> {
</WithPermissionControl>
</Field>
</HorizontalGroup>
<WithPermissionControl userAction={UserActions.ChatOpsWrite}>
<WithConfirm title="Are you sure to delete this Slack Integration?">
<WithPermissionControl userAction={UserActions.ChatOpsUpdateSettings}>
<WithConfirm
title="Remove Slack Integration for all of OnCall"
description={
<Alert severity="error" title="WARNING">
<p>Are you sure to delete this Slack Integration?</p>
<p>
Removing the integration will also irreverisbly remove the following data for your OnCall plugin:
</p>
<ul style={{ marginLeft: '20px' }}>
<li>default organization Slack channel</li>
<li>default Slack channels for OnCall Integrations</li>
<li>Slack channels & Slack user groups for OnCall Schedules</li>
<li>linked Slack usernames for OnCall Users</li>
</ul>
<br />
<p>
If you would like to instead remove your linked Slack username, please head{' '}
<PluginLink query={{ page: 'users/me' }}>here</PluginLink>.
</p>
</Alert>
}
confirmationText="DELETE"
>
<Button variant="destructive" size="sm" onClick={() => this.removeSlackIntegration()}>
Disconnect
</Button>
@ -189,16 +211,6 @@ class SlackSettings extends Component<SlackProps, SlackState> {
);
};
renderActionButtons = () => {
<WithPermissionControl userAction={UserActions.ChatOpsUpdateSettings}>
<WithConfirm title="Are you sure to delete this Slack Integration?">
<Button variant="destructive" size="sm" onClick={() => this.removeSlackIntegration()}>
Disconnect
</Button>
</WithConfirm>
</WithPermissionControl>;
};
removeSlackIntegration = () => {
const { store } = this.props;
store.slackStore.removeSlackIntegration().then(() => {

View file

@ -11,6 +11,7 @@ import Text from 'components/Text/Text';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import { CrossCircleIcon, HeartIcon } from 'icons';
import { Cloud } from 'models/cloud/cloud.types';
import { AppFeature } from 'state/features';
import { WithStoreProps } from 'state/types';
import { useStore } from 'state/useStore';
import { withMobXProviderContext } from 'state/withStore';
@ -60,9 +61,10 @@ const CloudPage = observer((props: CloudPageProps) => {
setApiKeyError(false);
}, []);
const disconnectCloudOncall = () => {
const disconnectCloudOncall = async () => {
setCloudIsConnected(false);
store.cloudStore.disconnectToCloud();
await store.cloudStore.disconnectToCloud();
await store.cloudStore.loadCloudConnectionStatus();
};
const connectToCloud = async () => {
@ -81,6 +83,7 @@ const CloudPage = observer((props: CloudPageProps) => {
const heartbeatData: { link: string } = await store.cloudStore.getCloudHeartbeat();
setheartbeatLink(heartbeatData?.link);
}
await store.cloudStore.loadCloudConnectionStatus();
});
};
@ -312,6 +315,19 @@ const CloudPage = observer((props: CloudPageProps) => {
</VerticalGroup>
)}
</Block>
{store.hasFeature(AppFeature.MobileApp) && (
<Block bordered withBackground className={cx('info-block')}>
<VerticalGroup>
<Text.Title level={4}>
<Icon name="mobile-android" className={cx('block-icon')} size="lg" /> Mobile app push notifications
</Text.Title>
<Text type="secondary">
Connecting to Cloud OnCall enables sending push notifications on mobile devices using the Grafana OnCall
mobile app.
</Text>
</VerticalGroup>
</Block>
)}
</VerticalGroup>
);
@ -358,6 +374,19 @@ const CloudPage = observer((props: CloudPageProps) => {
<Text type="secondary">Users matched between OSS and Cloud OnCall currently unavailable.</Text>
</VerticalGroup>
</Block>
{store.hasFeature(AppFeature.MobileApp) && (
<Block bordered withBackground className={cx('info-block')}>
<VerticalGroup>
<Text.Title level={4}>
<Icon name="mobile-android" className={cx('block-icon')} size="lg" /> Mobile app push notifications
</Text.Title>
<Text type="secondary">
Connecting to Cloud OnCall enables sending push notifications on mobile devices using the Grafana OnCall
mobile app.
</Text>
</VerticalGroup>
</Block>
)}
</VerticalGroup>
);

View file

@ -98,14 +98,23 @@ export class RootBaseStore {
// stores
async updateBasicData() {
const updateFeatures = async () => {
await this.updateFeatures();
// Only fetch cloud connection status when cloud connection feature is enabled on OSS instance
// Note that this.hasFeature can only be called after this.updateFeatures()
if (this.hasFeature(AppFeature.CloudConnection)) {
await this.cloudStore.loadCloudConnectionStatus();
}
};
return Promise.all([
this.teamStore.loadCurrentTeam(),
this.grafanaTeamStore.updateItems(),
this.updateFeatures(),
updateFeatures(),
this.userStore.updateNotificationPolicyOptions(),
this.userStore.updateNotifyByOptions(),
this.alertReceiveChannelStore.updateAlertReceiveChannelOptions(),
this.alertReceiveChannelStore.updateAlertReceiveChannelOptions(),
this.escalationPolicyStore.updateWebEscalationPolicyOptions(),
this.escalationPolicyStore.updateEscalationPolicyOptions(),
this.escalationPolicyStore.updateNumMinutesInWindowOptions(),