Merge pull request #4568 from grafana/dev

v1.7.2
This commit is contained in:
Michael Derynck 2024-06-20 11:43:02 -06:00 committed by GitHub
commit 75698fc5a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 937 additions and 314 deletions

View file

@ -70,10 +70,13 @@ body:
- Alert Flow & Configuration
- Auth
- Chatops
- Helm
- Mobile App
- Schedules
- Terraform
- API
- Metrics
- Terraform/Crossplane
- Helm/Kubernetes/Docker
- CI/CD
- Other
validations:
required: true

View file

@ -35,10 +35,13 @@ body:
- Alert Flow & Configuration
- Auth
- Chatops
- Helm
- Mobile App
- Schedules
- Terraform
- API
- Metrics
- Terraform/Crossplane
- Helm/Kubernetes/Docker
- CI/CD
- Other
validations:
required: true

View file

@ -79,9 +79,12 @@ jobs:
${{ contains(steps.issue-form-values.outputs.issueparser_product_area, 'Alert Flow & Configuration') && 'part:alert flow & configuration' || '' }}
${{ contains(steps.issue-form-values.outputs.issueparser_product_area, 'Auth') && 'part:auth/teams' || '' }}
${{ contains(steps.issue-form-values.outputs.issueparser_product_area, 'Chatops') && 'part:chatops' || '' }}
${{ contains(steps.issue-form-values.outputs.issueparser_product_area, 'Helm') && 'part:deployment/helm' || '' }}
${{ contains(steps.issue-form-values.outputs.issueparser_product_area, 'Mobile App') && 'part:mobile' || '' }}
${{ contains(steps.issue-form-values.outputs.issueparser_product_area, 'Schedules') && 'part:schedules' || '' }}
${{ contains(steps.issue-form-values.outputs.issueparser_product_area, 'Terraform') && 'part:API/Terraform' || '' }}
${{ contains(steps.issue-form-values.outputs.issueparser_product_area, 'API') && 'part:API' || '' }}
${{ contains(steps.issue-form-values.outputs.issueparser_product_area, 'Metrics') && 'part:metrics/logging' || '' }}
${{ contains(steps.issue-form-values.outputs.issueparser_product_area, 'Terraform/Crossplane') && 'part:Terraform/Crossplane' || '' }}
${{ contains(steps.issue-form-values.outputs.issueparser_product_area, 'Helm/Kubernetes/Docker') && 'part:helm/kubernetes/docker' || '' }}
${{ contains(steps.issue-form-values.outputs.issueparser_product_area, 'CI/CD') && 'part:ci/cd' || '' }}
${{ contains(steps.issue-form-values.outputs.issueparser_product_area, 'Other') && 'more info needed' || '' }}
# yamllint enable rule:line-length

View file

@ -11,3 +11,5 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: grafana/writers-toolkit/update-make-docs@update-make-docs/v1
with:
pr_options: --label "release:ignore"

View file

@ -6,7 +6,6 @@
[![License](https://img.shields.io/github/license/grafana/oncall)](https://github.com/grafana/oncall/blob/dev/LICENSE)
[![Docker Pulls](https://img.shields.io/docker/pulls/grafana/oncall)](https://hub.docker.com/r/grafana/oncall/tags)
[![Slack](https://img.shields.io/badge/join%20slack-%23grafana-%2Doncall-brightgreen.svg)](https://slack.grafana.com/)
[![Discussion](https://img.shields.io/badge/discuss-oncall%20forum-orange.svg)](https://github.com/grafana/oncall/discussions)
[![Build Status](https://github.com/grafana/oncall/actions/workflows/on-commits-to-dev.yml/badge.svg)](https://github.com/grafana/oncall/actions/workflows/on-commits-to-dev.yml)
Developer-friendly incident response with brilliant Slack integration.

View file

@ -65,6 +65,11 @@ ifeq ($(origin HUGO_REFLINKSERRORLEVEL), undefined)
export HUGO_REFLINKSERRORLEVEL := WARNING
endif
# Whether to pull the latest container image before running the container.
ifeq ($(origin PULL), undefined)
export PULL := true
endif
.PHONY: docs-rm
docs-rm: ## Remove the docs container.
$(PODMAN) rm -f $(DOCS_CONTAINER)
@ -81,13 +86,12 @@ make-docs:
fi
.PHONY: docs
docs: ## Serve documentation locally, which includes pulling the latest `DOCS_IMAGE` (default: `grafana/docs-base:latest`) container image. See also `docs-no-pull`.
docs: ## Serve documentation locally, which includes pulling the latest `DOCS_IMAGE` (default: `grafana/docs-base:latest`) container image. To not pull the image, set `PULL=false`.
ifeq ($(PULL), true)
docs: docs-pull make-docs
$(CURDIR)/make-docs $(PROJECTS)
.PHONY: docs-no-pull
docs-no-pull: ## Serve documentation locally without pulling the `DOCS_IMAGE` (default: `grafana/docs-base:latest`) container image.
docs-no-pull: make-docs
else
docs: make-docs
endif
$(CURDIR)/make-docs $(PROJECTS)
.PHONY: docs-debug
@ -96,13 +100,19 @@ docs-debug: make-docs
WEBSITE_EXEC='hugo server --bind 0.0.0.0 --port 3002 --debug' $(CURDIR)/make-docs $(PROJECTS)
.PHONY: doc-validator
doc-validator: ## Run doc-validator on the entire docs folder.
doc-validator: ## Run doc-validator on the entire docs folder which includes pulling the latest `DOC_VALIDATOR_IMAGE` (default: `grafana/doc-validator:latest`) container image. To not pull the image, set `PULL=false`.
doc-validator: make-docs
ifeq ($(PULL), true)
$(PODMAN) pull -q $(DOC_VALIDATOR_IMAGE)
endif
DOCS_IMAGE=$(DOC_VALIDATOR_IMAGE) $(CURDIR)/make-docs $(PROJECTS)
.PHONY: vale
vale: ## Run vale on the entire docs folder.
vale: ## Run vale on the entire docs folder which includes pulling the latest `VALE_IMAGE` (default: `grafana/vale:latest`) container image. To not pull the image, set `PULL=false`.
vale: make-docs
ifeq ($(PULL), true)
$(PODMAN) pull -q $(VALE_IMAGE)
endif
DOCS_IMAGE=$(VALE_IMAGE) $(CURDIR)/make-docs $(PROJECTS)
.PHONY: update

View file

@ -6,6 +6,43 @@
# [Semantic versioning](https://semver.org/) is used to help the reader identify the significance of changes.
# Changes are relevant to this script and the support docs.mk GNU Make interface.
#
# ## 8.0.0 (2024-05-28)
#
# ### Changed
#
# - Add environment variable `OUTPUT_FORMAT` to control the output of commands.
#
# The default value is `human` and means the output format is human readable.
# The value `json` is also supported and outputs JSON.
#
# Note that the `json` format isn't supported by `make docs`, only `make doc-validator` and `make vale`.
#
# ## 7.0.0 (2024-05-03)
#
# ### Changed
#
# - Pull images for all recipes that use containers by default.
#
# Use the `PULL=false` variable to disable this behavior.
#
# ### Removed
#
# - The `docs-no-pull` target as it's redundant with the new `PULL=false` variable.
#
# ## 6.1.0 (2024-04-22)
#
# ### Changed
#
# - Mount volumes with SELinux labels.
#
# https://docs.docker.com/storage/bind-mounts/#configure-the-selinux-label
#
# ### Added
#
# - Pseudo project for including only website resources and no website content.
#
# Facilitates testing shortcodes and layout changes with a small documentation set instead of Grafana Cloud or the entire website.
#
# ## 6.0.1 (2024-02-28)
#
# ### Added
@ -229,6 +266,8 @@ readonly HUGO_REFLINKSERRORLEVEL="${HUGO_REFLINKSERRORLEVEL:-WARNING}"
readonly VALE_MINALERTLEVEL="${VALE_MINALERTLEVEL:-error}"
readonly WEBSITE_EXEC="${WEBSITE_EXEC:-make server-docs}"
readonly OUTPUT_FORMAT="${OUTPUT_FORMAT:-human}"
PODMAN="$(if command -v podman >/dev/null 2>&1; then echo podman; else echo docker; fi)"
if ! command -v curl >/dev/null 2>&1; then
@ -300,6 +339,7 @@ SOURCES_helm_charts_tempo_distributed='tempo'
SOURCES_opentelemetry='opentelemetry-docs'
SOURCES_plugins_grafana_datadog_datasource='datadog-datasource'
SOURCES_plugins_grafana_oracle_datasource='oracle-datasource'
SOURCES_resources='website'
VERSIONS_as_code='UNVERSIONED'
VERSIONS_grafana_cloud='UNVERSIONED'
@ -311,6 +351,7 @@ VERSIONS_grafana_cloud_frontend_observability_faro_web_sdk='UNVERSIONED'
VERSIONS_opentelemetry='UNVERSIONED'
VERSIONS_plugins_grafana_datadog_datasource='latest'
VERSIONS_plugins_grafana_oracle_datasource='latest'
VERSIONS_resources='UNVERSIONED'
VERSIONS_technical_documentation='UNVERSIONED'
VERSIONS_website='UNVERSIONED'
VERSIONS_writers_toolkit='UNVERSIONED'
@ -321,6 +362,7 @@ PATHS_helm_charts_tempo_distributed='docs/sources/helm-charts/tempo-distributed'
PATHS_mimir='docs/sources/mimir'
PATHS_plugins_grafana_datadog_datasource='docs/sources'
PATHS_plugins_grafana_oracle_datasource='docs/sources'
PATHS_resources='content'
PATHS_tempo='docs/sources/tempo'
PATHS_website='content'
@ -584,6 +626,11 @@ POSIX_HERESTRING
proj_to_url_src_dst_ver "$(new_proj helm-charts/mimir-distributed "${_version}")"
proj_to_url_src_dst_ver "$(new_proj enterprise-metrics "${_version}")"
;;
resources)
_repo="$(repo_path website)"
echo "arbitrary^${_repo}/config^/hugo/config" "arbitrary^${_repo}/layouts^/hugo/layouts" "arbitrary^${_repo}/scripts^/hugo/scripts"
unset _repo
;;
traces)
proj_to_url_src_dst_ver "$(new_proj tempo "${_version}")"
proj_to_url_src_dst_ver "$(new_proj enterprise-traces "${_version}")"
@ -617,7 +664,7 @@ $x
POSIX_HERESTRING
if [ -n "${url}" ]; then
if [ "${_url}" != "arbitrary" ]; then
if [ "${url}" != arbitrary ]; then
printf '\r %s\r\n' "${url}"
fi
fi
@ -670,9 +717,9 @@ POSIX_HERESTRING
fi
_repo="$(repo_path website)"
volumes="--volume=${_repo}/config:/hugo/config"
volumes="${volumes} --volume=${_repo}/layouts:/hugo/layouts"
volumes="${volumes} --volume=${_repo}/scripts:/hugo/scripts"
volumes="--volume=${_repo}/config:/hugo/config:z"
volumes="${volumes} --volume=${_repo}/layouts:/hugo/layouts:z"
volumes="${volumes} --volume=${_repo}/scripts:/hugo/scripts:z"
fi
unset _project _repo
done
@ -682,7 +729,7 @@ for x in ${url_src_dst_vers}; do
$x
POSIX_HERESTRING
if [ "${_url}" != "arbitrary" ]; then
if [ "${_url}" != arbitrary ]; then
if [ ! -f "${_src}/_index.md" ]; then
errr "Index file '${_src}/_index.md' does not exist."
note "Is '${_src}' the correct source directory?"
@ -693,9 +740,9 @@ POSIX_HERESTRING
debg "Mounting '${_src}' at container path '${_dst}'"
if [ -z "${volumes}" ]; then
volumes="--volume=${_src}:${_dst}"
volumes="--volume=${_src}:${_dst}:z"
else
volumes="${volumes} --volume=${_src}:${_dst}"
volumes="${volumes} --volume=${_src}:${_dst}:z"
fi
if [ -n "${_ver}" ] && [ "${_ver}" != 'UNVERSIONED' ]; then
@ -714,45 +761,74 @@ POSIX_HERESTRING
case "${image}" in
'grafana/doc-validator')
if ! command -v jq >/dev/null 2>&1; then
errr '`jq` must be installed for the `doc-validator` target to work.'
note 'To install `jq`, refer to https://jqlang.github.io/jq/download/,'
exit 1
fi
proj="$(new_proj "$1")"
printf '\r\n'
"${PODMAN}" run \
IFS='' read -r cmd <<EOF
${PODMAN} run \
--init \
--interactive \
--platform linux/amd64 \
--rm \
--tty \
${volumes} \
"${DOCS_IMAGE}" \
"--include=${DOC_VALIDATOR_INCLUDE}" \
"--skip-checks=${DOC_VALIDATOR_SKIP_CHECKS}" \
"/hugo/content$(proj_canonical "${proj}")" \
"$(proj_canonical "${proj}")" \
| sed "s#$(proj_dst "${proj}")#sources#" \
| jq -r '"ERROR: \(.location.path):\(.location.range.start.line // 1):\(.location.range.start.column // 1): \(.message)" + if .suggestions[0].text then "\nSuggestion: \(.suggestions[0].text)" else "" end'
${DOCS_IMAGE} \
--include=${DOC_VALIDATOR_INCLUDE} \
--skip-checks=${DOC_VALIDATOR_SKIP_CHECKS} \
/hugo/content$(proj_canonical "${proj}") \
"$(proj_canonical "${proj}") \
| sed "s#$(proj_dst "${proj}")#sources#"
EOF
case "${OUTPUT_FORMAT}" in
human)
if ! command -v jq >/dev/null 2>&1; then
errr '`jq` must be installed for the `doc-validator` target to work.'
note 'To install `jq`, refer to https://jqlang.github.io/jq/download/,'
exit 1
fi
${cmd} \
| jq -r '"ERROR: \(.location.path):\(.location.range.start.line // 1):\(.location.range.start.column // 1): \(.message)" + if .suggestions[0].text then "\nSuggestion: \(.suggestions[0].text)" else "" end'
;;
json)
${cmd}
;;
*) # default
errr "Invalid output format '${OUTPUT_FORMAT}'"
esac
;;
'grafana/vale')
proj="$(new_proj "$1")"
printf '\r\n'
"${PODMAN}" run \
IFS='' read -r cmd <<EOF
${PODMAN} run \
--init \
--interactive \
--rm \
--workdir /etc/vale \
--tty \
${volumes} \
"${DOCS_IMAGE}" \
"--minAlertLevel=${VALE_MINALERTLEVEL}" \
'--glob=*.md' \
--output=/etc/vale/rdjsonl.tmpl \
/hugo/content/docs | sed "s#$(proj_dst "${proj}")#sources#"
${DOCS_IMAGE} \
--minAlertLevel=${VALE_MINALERTLEVEL} \
--glob=*.md \
/hugo/content/docs
EOF
case "${OUTPUT_FORMAT}" in
human)
${cmd} --output=line \
| sed "s#$(proj_dst "${proj}")#sources#"
;;
json)
${cmd} --output=/etc/vale/rdjsonl.tmpl \
| sed "s#$(proj_dst "${proj}")#sources#"
;;
*)
errr "Invalid output format '${OUTPUT_FORMAT}'"
esac
;;
*)
tempfile="$(mktemp -t make-docs.XXX)"
@ -789,7 +865,7 @@ fi
${WEBSITE_EXEC}
EOF
chmod +x "${tempfile}"
volumes="${volumes} --volume=${tempfile}:/entrypoint"
volumes="${volumes} --volume=${tempfile}:/entrypoint:z"
readonly volumes
IFS='' read -r cmd <<EOF

View file

@ -37,6 +37,8 @@ Grafana OnCall uses API keys to allow access to the API. You can request a new O
An API key is specific to a user and a Grafana stack. If you want to switch to a different stack configuration,
request a different API key.
The endpoint refers to the OnCall Application endpoint and can be found on the OnCall -> Settings page as well.
## Pagination
List endpoints such as List Integrations or List Alert Groups return multiple objects.

View file

@ -4,7 +4,9 @@ title: Alert groups HTTP API
weight: 400
---
# List alert groups
# Alert groups HTTP API
## List alert groups
```shell
curl "{{API_URL}}/api/v1/alert_groups/" \
@ -57,7 +59,7 @@ These available filter parameters should be provided as `GET` arguments:
`GET {{API_URL}}/api/v1/alert_groups/`
# Alert group details
## Alert group details
```shell
curl "{{API_URL}}/api/v1/alert_groups/I68T24C13IFW1" \
@ -69,7 +71,7 @@ curl "{{API_URL}}/api/v1/alert_groups/I68T24C13IFW1" \
`GET {{API_URL}}/api/v1/alert_groups/<ALERT_GROUP_ID>`
# Acknowledge an alert group
## Acknowledge an alert group
```shell
curl "{{API_URL}}/api/v1/alert_groups/I68T24C13IFW1/acknowledge" \
@ -81,7 +83,7 @@ curl "{{API_URL}}/api/v1/alert_groups/I68T24C13IFW1/acknowledge" \
`POST {{API_URL}}/api/v1/alert_groups/<ALERT_GROUP_ID>/acknowledge`
# Unacknowledge an alert group
## Unacknowledge an alert group
```shell
curl "{{API_URL}}/api/v1/alert_groups/I68T24C13IFW1/unacknowledge" \
@ -93,7 +95,7 @@ curl "{{API_URL}}/api/v1/alert_groups/I68T24C13IFW1/unacknowledge" \
`POST {{API_URL}}/api/v1/alert_groups/<ALERT_GROUP_ID>/unacknowledge`
# Resolve an alert group
## Resolve an alert group
```shell
curl "{{API_URL}}/api/v1/alert_groups/I68T24C13IFW1/resolve" \
@ -105,7 +107,7 @@ curl "{{API_URL}}/api/v1/alert_groups/I68T24C13IFW1/resolve" \
`POST {{API_URL}}/api/v1/alert_groups/<ALERT_GROUP_ID>/resolve`
# Unresolve an alert group
## Unresolve an alert group
```shell
curl "{{API_URL}}/api/v1/alert_groups/I68T24C13IFW1/unresolve" \
@ -117,7 +119,7 @@ curl "{{API_URL}}/api/v1/alert_groups/I68T24C13IFW1/unresolve" \
`POST {{API_URL}}/api/v1/alert_groups/<ALERT_GROUP_ID>/unresolve`
# Delete an alert group
## Delete an alert group
```shell
curl "{{API_URL}}/api/v1/alert_groups/I68T24C13IFW1/" \

View file

@ -4,7 +4,9 @@ title: Alerts HTTP API
weight: 100
---
# List Alerts
# Alerts HTTP API
## List Alerts
```shell
curl "{{API_URL}}/api/v1/alerts/" \

View file

@ -1,10 +1,12 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/escalation_chains/
title: Escalation Chains HTTP API
title: Escalation chains HTTP API
weight: 200
---
# Create an escalation chain
# Escalation chains HTTP API
## Create an escalation chain
```shell
curl "{{API_URL}}/api/v1/escalation_chains/" \
@ -35,7 +37,7 @@ The above command returns JSON structured in the following way:
`POST {{API_URL}}/api/v1/escalation_chains/`
# Get an escalation chain
## Get an escalation chain
```shell
curl "{{API_URL}}/api/v1/escalation_chains/F5JU6KJET33FE/" \
@ -58,7 +60,7 @@ The above command returns JSON structured in the following way:
`GET {{API_URL}}/api/v1/escalation_chains/<ESCALATION_CHAIN_ID>/`
# List escalation chains
## List escalation chains
```shell
curl "{{API_URL}}/api/v1/escalation_chains/" \
@ -91,7 +93,7 @@ The above command returns JSON structured in the following way:
`GET {{API_URL}}/api/v1/escalation_chains/`
# Delete an escalation chain
## Delete an escalation chain
```shell
curl "{{API_URL}}/api/v1/escalation_chains/F5JU6KJET33FE/" \

View file

@ -1,10 +1,12 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/escalation_policies/
title: Escalation Policies HTTP API
title: Escalation policies HTTP API
weight: 300
---
# Create an escalation policy
# Escalation policies HTTP API
## Create an escalation policy
```shell
curl "{{API_URL}}/api/v1/escalation_policies/" \
@ -50,7 +52,7 @@ The above command returns JSON structured in the following way:
`POST {{API_URL}}/api/v1/escalation_policies/`
# Get an escalation policy
## Get an escalation policy
```shell
curl "{{API_URL}}/api/v1/escalation_policies/E3GA6SJETWWJS/" \
@ -71,7 +73,7 @@ The above command returns JSON structured in the following way:
}
```
# Update an escalation policy
## Update an escalation policy
```shell
curl "{{API_URL}}/api/v1/escalation_policies/E3GA6SJETWWJS/" \
@ -104,7 +106,7 @@ The above command returns JSON structured in the following way:
`GET {{API_URL}}/api/v1/escalation_policies/<ESCALATION_POLICY_ID>/`
# List escalation policies
## List escalation policies
```shell
curl "{{API_URL}}/api/v1/escalation_policies/" \
@ -150,7 +152,7 @@ The following available filter parameter should be provided as a `GET` argument:
`GET {{API_URL}}/api/v1/escalation_policies/`
# Delete an escalation policy
## Delete an escalation policy
```shell
curl "{{API_URL}}/api/v1/escalation_policies/E3GA6SJETWWJS/" \

View file

@ -4,7 +4,9 @@ title: Integrations HTTP API
weight: 500
---
# Create an integration
# Integrations HTTP API
## Create an integration
```shell
curl "{{API_URL}}/api/v1/integrations/" \
@ -79,7 +81,7 @@ For example, to learn how to integrate Grafana OnCall with Alertmanager refer to
`POST {{API_URL}}/api/v1/integrations/`
# Get integration
## Get integration
```shell
curl "{{API_URL}}/api/v1/integrations/CFRPV98RPR1U8/" \
@ -150,7 +152,7 @@ This endpoint retrieves an integration. Integrations are sources of alerts and a
`GET {{API_URL}}/api/v1/integrations/<INTEGRATION_ID>/`
# List integrations
## List integrations
```shell
curl "{{API_URL}}/api/v1/integrations/" \
@ -229,7 +231,7 @@ The above command returns JSON structured in the following way:
`GET {{API_URL}}/api/v1/integrations/`
# Update integration
## Update integration
```shell
curl "{{API_URL}}/api/v1/integrations/CFRPV98RPR1U8/" \
@ -302,7 +304,7 @@ The above command returns JSON structured in the following way:
`PUT {{API_URL}}/api/v1/integrations/<INTEGRATION_ID>/`
# Delete integration
## Delete integration
Deleted integrations will stop recording new alerts from monitoring. Integration removal won't trigger removal of
related alert groups or alerts.

View file

@ -4,7 +4,9 @@ title: OnCall shifts HTTP API
weight: 600
---
# Create an OnCall shift
# OnCall shifts HTTP API
## Create an OnCall shift
```shell
curl "{{API_URL}}/api/v1/on_call_shifts/" \
@ -67,7 +69,7 @@ For more information about recurrence rules, refer to [RFC 5545](https://tools.i
`POST {{API_URL}}/api/v1/on_call_shifts/`
# Get OnCall shifts
## Get OnCall shifts
```shell
curl "{{API_URL}}/api/v1/on_call_shifts/OH3V5FYQEYJ6M/" \
@ -96,7 +98,7 @@ The above command returns JSON structured in the following way:
`GET {{API_URL}}/api/v1/on_call_shifts/<ON_CALL_SHIFT_ID>/`
# List OnCall shifts
## List OnCall shifts
```shell
curl "{{API_URL}}/api/v1/on_call_shifts/" \
@ -157,7 +159,7 @@ The following available filter parameters should be provided as `GET` arguments:
`GET {{API_URL}}/api/v1/on_call_shifts/`
# Update OnCall shift
## Update OnCall shift
```shell
curl "{{API_URL}}/api/v1/on_call_shifts/OH3V5FYQEYJ6M/" \
@ -196,7 +198,7 @@ The above command returns JSON structured in the following way:
`PUT {{API_URL}}/api/v1/on_call_shifts/<ON_CALL_SHIFT_ID>/`
# Delete OnCall shift
## Delete OnCall shift
```shell
curl "{{API_URL}}/api/v1/on_call_shifts/OH3V5FYQEYJ6M/" \

View file

@ -1,10 +1,10 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/outgoing_webhooks/
title: Outgoing Webhooks HTTP API
title: Outgoing webhooks HTTP API
weight: 700
---
# Outgoing Webhooks
# Outgoing webhooks
> ⚠️ A note about actions: Before version **v1.3.11** webhooks existed as actions within the API, the /actions
> endpoint remains available and is compatible with previous callers but under the hood it will interact with the

View file

@ -1,10 +1,12 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/personal_notification_rules/
title: Personal Notification Rules HTTP API
title: Personal notification rules HTTP API
weight: 800
---
# Post a personal notification rule
# Personal notification rules HTTP API
## Post a personal notification rule
```shell
curl "{{API_URL}}/api/v1/personal_notification_rules/" \
@ -41,7 +43,7 @@ The above command returns JSON structured in the following way:
`POST {{API_URL}}/api/v1/personal_notification_rules/`
# Get personal notification rule
## Get personal notification rule
```shell
curl "{{API_URL}}/api/v1/personal_notification_rules/ND9EHN5LN1DUU/" \
@ -67,7 +69,7 @@ The above command returns JSON structured in the following way:
`GET {{API_URL}}/api/v1/personal_notification_rules/<PERSONAL_NOTIFICATION_RULE_ID>/`
# List personal notification rules
## List personal notification rules
```shell
curl "{{API_URL}}/api/v1/personal_notification_rules/" \
@ -129,7 +131,7 @@ The following available filter parameters should be provided as `GET` arguments:
`GET {{API_URL}}/api/v1/personal_notification_rules/`
# Delete a personal notification rule
## Delete a personal notification rule
```shell
curl "{{API_URL}}/api/v1/personal_notification_rules/NWAL6WFJNWDD8/" \

View file

@ -1,10 +1,12 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/resolution_notes/
title: Resolution Notes HTTP API
title: Resolution notes HTTP API
weight: 900
---
# Create a resolution note
# Resolution notes HTTP API
## Create a resolution note
```shell
curl "{{API_URL}}/api/v1/resolution_notes/" \
@ -39,7 +41,7 @@ The above command returns JSON structured in the following way:
`POST {{API_URL}}/api/v1/resolution_notes/`
# Get a resolution note
## Get a resolution note
```shell
curl "{{API_URL}}/api/v1/resolution_notes/M4BTQUS3PRHYQ/" \
@ -65,7 +67,7 @@ The above command returns JSON structured in the following way:
`GET {{API_URL}}/api/v1/resolution_notes/<RESOLUTION_NOTE_ID>/`
# List resolution notes
## List resolution notes
```shell
curl "{{API_URL}}/api/v1/resolution_notes/" \
@ -105,7 +107,7 @@ The following available filter parameter should be provided as a `GET` argument:
`GET {{API_URL}}/api/v1/resolution_notes/`
# Update a resolution note
## Update a resolution note
```shell
curl "{{API_URL}}/api/v1/resolution_notes/M4BTQUS3PRHYQ/" \
@ -134,7 +136,7 @@ The above command returns JSON structured in the following way:
`PUT {{API_URL}}/api/v1/resolution_notes/<RESOLUTION_NOTE_ID>/`
# Delete a resolution note
## Delete a resolution note
```shell
curl "{{API_URL}}/api/v1/resolution_notes/M4BTQUS3PRHYQ/" \

View file

@ -4,7 +4,9 @@ title: Routes HTTP API
weight: 1100
---
# Create a route
# Routes HTTP API
## Create a route
```shell
curl "{{API_URL}}/api/v1/routes/" \
@ -57,7 +59,7 @@ Routes allow you to direct different alerts to different messenger channels and
`POST {{API_URL}}/api/v1/routes/`
# Get a route
## Get a route
```shell
curl "{{API_URL}}/api/v1/routes/RIYGUJXCPFHXY/" \
@ -86,7 +88,7 @@ The above command returns JSON structured in the following way:
`GET {{API_URL}}/api/v1/routes/<ROUTE_ID>/`
# List routes
## List routes
```shell
curl "{{API_URL}}/api/v1/routes/" \
@ -141,7 +143,7 @@ The following available filter parameters should be provided as `GET` arguments:
`GET {{API_URL}}/api/v1/routes/`
# Update route
## Update route
```shell
curl "{{API_URL}}/api/v1/routes/RIYGUJXCPFHXY/" \
@ -177,7 +179,7 @@ The above command returns JSON structured in the following way:
`PUT {{API_URL}}/api/v1/routes/<ROUTE_ID>/`
# Delete a route
## Delete a route
```shell
curl "{{API_URL}}/api/v1/routes/RIYGUJXCPFHXY/" \

View file

@ -1,10 +1,12 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/schedules/
title: Schedule HTTP API
title: Schedules HTTP API
weight: 1200
---
# Create a schedule
# Schedules HTTP API
## Create a schedule
```shell
curl "{{API_URL}}/api/v1/schedules/" \
@ -55,7 +57,7 @@ The above command returns JSON structured in the following way:
`POST {{API_URL}}/api/v1/schedules/`
# Get a schedule
## Get a schedule
```shell
curl "{{API_URL}}/api/v1/schedules/SBM7DV7BKFUYU/" \
@ -86,7 +88,7 @@ The above command returns JSON structured in the following way:
`GET {{API_URL}}/api/v1/schedules/<SCHEDULE_ID>/`
# List schedules
## List schedules
```shell
curl "{{API_URL}}/api/v1/schedules/" \
@ -145,7 +147,7 @@ The following available filter parameter should be provided as a `GET` argument:
`GET {{API_URL}}/api/v1/schedules/`
# Update a schedule
## Update a schedule
```shell
curl "{{API_URL}}/api/v1/schedules/SBM7DV7BKFUYU/" \
@ -183,7 +185,7 @@ The above command returns JSON structured in the following way:
`PUT {{API_URL}}/api/v1/schedules/<SCHEDULE_ID>/`
# Delete a schedule
## Delete a schedule
```shell
curl "{{API_URL}}/api/v1/schedules/SBM7DV7BKFUYU/" \
@ -196,7 +198,7 @@ curl "{{API_URL}}/api/v1/schedules/SBM7DV7BKFUYU/" \
`DELETE {{API_URL}}/api/v1/schedules/<SCHEDULE_ID>/`
# Export a schedule's final shifts
## Export a schedule's final shifts
**HTTP request**
@ -305,7 +307,7 @@ The above command returns JSON structured in the following way:
}
```
## Caveats
### Caveats
Some notes on the `start_date` and `end_date` query parameters:
@ -318,7 +320,7 @@ change the output you get from this endpoint. To get consistent information abou
you must be sure to avoid updating rotations in-place but apply the changes as new rotations
with the right starting dates.
## Example script to transform data to .csv for all of your schedules
### Example script to transform data to .csv for all of your schedules
The following Python script will generate a `.csv` file, `oncall-report-2023-01-01-to-2023-01-31.csv`. This file will
contain three columns, `user_pk`, `user_email`, and `hours_on_call`, which represents how many hours each user was

View file

@ -1,10 +1,12 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/shift_swaps/
title: Shift swaps HTTP API
title: Shift swap requests HTTP API
weight: 1200
---
# Create a shift swap request
# Shift swap requests HTTP API
## Create a shift swap request
```shell
curl "{{API_URL}}/api/v1/shift_swaps/" \
@ -77,7 +79,7 @@ The above command returns JSON structured in the following way:
`POST {{API_URL}}/api/v1/shift_swaps/`
# Get a shift swap request
## Get a shift swap request
```shell
curl "{{API_URL}}/api/v1/shift_swaps/SSRG1TDNBMJQ1NC/" \
@ -135,7 +137,7 @@ The above command returns JSON structured in the following way:
`GET {{API_URL}}/api/v1/shift_swaps/<SHIFT_SWAP_REQUEST_ID>/`
# List shift swap requests
## List shift swap requests
```shell
curl "{{API_URL}}/api/v1/shift_swaps/" \
@ -195,7 +197,7 @@ The following available filter parameters may be provided as a `GET` arguments:
`GET {{API_URL}}/api/v1/shift_swaps/`
# Update a shift swap request
## Update a shift swap request
```shell
curl "{{API_URL}}/api/v1/shift_swaps/SSRG1TDNBMJQ1NC/" \
@ -259,7 +261,7 @@ The above command returns JSON structured in the following way:
`PUT {{API_URL}}/api/v1/shift_swaps/<SHIFT_SWAP_REQUEST_ID>/`
# Delete a shift swap request
## Delete a shift swap request
```shell
curl "{{API_URL}}/api/v1/shift_swaps/SSRG1TDNBMJQ1NC/" \
@ -272,7 +274,7 @@ curl "{{API_URL}}/api/v1/shift_swaps/SSRG1TDNBMJQ1NC/" \
`DELETE {{API_URL}}/api/v1/shift_swaps/<SHIFT_SWAP_REQUEST_ID>/`
# Take a shift swap request
## Take a shift swap request
```shell
curl "{{API_URL}}/api/v1/shift_swaps/SSRG1TDNBMJQ1NC/take" \

View file

@ -1,10 +1,12 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/slack_channels/
title: Slack Channels HTTP API
title: Slack channels HTTP API
weight: 1300
---
# List Slack Channels
# Slack channels HTTP API
## List Slack Channels
```shell
curl "{{API_URL}}/api/v1/slack_channels/" \

View file

@ -1,12 +1,14 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/user_groups/
title: OnCall User Groups HTTP API
title: OnCall user groups HTTP API
weight: 1400
---
<!--Used in escalation policies with type = `notify_user_group` and in schedules.-->
# List user groups
# OnCall user groups HTTP API
## List user groups
```shell
curl "{{API_URL}}/api/v1/user_groups/" \

View file

@ -1,10 +1,12 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/users/
title: Grafana OnCall Users HTTP API
title: Grafana OnCall users HTTP API
weight: 1500
---
# Get a user
# Grafana OnCall users HTTP API
## Get a user
This endpoint retrieves the user object.
@ -50,7 +52,7 @@ Use `{{API_URL}}/api/v1/users/current` to retrieve the current user.
| `timezone` | No | timezone of the user one of [time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). |
| `teams` | No | List of team IDs the user belongs to |
# List Users
## List Users
```shell
curl "{{API_URL}}/api/v1/users/" \

View file

@ -46,7 +46,7 @@ OnCall is available in Grafana Cloud automatically:
3. Choose **Alerts and IRM** from the left menu
4. Click **OnCall** to access Grafana OnCall
Otherwise, to install Grafaana OnCall, refer to [Install Grafana OnCall OSS][].
Otherwise, to install Grafana OnCall, refer to [Install Grafana OnCall OSS][].
## How to configure Grafana OnCall

View file

@ -302,8 +302,6 @@ The limit can be changed using env variables:
## Mobile application set up
> **Note**: This application is currently in beta
Grafana OnCall OSS users can use the mobile app to receive push notifications from OnCall.
Grafana OnCall OSS relies on Grafana Cloud as on relay for push notifications.
You must first connect your Grafana OnCall OSS to Grafana Cloud for the mobile app to work.

View file

@ -3,10 +3,16 @@ import typing
from abc import ABC, abstractmethod
from apps.chatops_proxy.client import PROVIDER_TYPE_SLACK
from apps.slack.installation import SlackInstallationExc, install_slack_integration
from apps.slack.installation import SlackInstallationExc, install_slack_integration, uninstall_slack_integration
from apps.user_management.models import Organization
from .types import INTEGRATION_INSTALLED_EVENT_TYPE, Event, IntegrationInstalledData
from .types import (
INTEGRATION_INSTALLED_EVENT_TYPE,
INTEGRATION_UNINSTALLED_EVENT_TYPE,
Event,
IntegrationInstalledData,
IntegrationUninstalledData,
)
logger = logging.getLogger(__name__)
@ -23,7 +29,7 @@ class Handler(ABC):
pass
class SlackInstallationHandler(Handler):
class SlackInstallHandler(Handler):
@classmethod
def match(cls, event: Event) -> bool:
return (
@ -48,3 +54,29 @@ class SlackInstallationHandler(Handler):
f'msg="SlackInstallationHandler: Failed to install Slack integration: %s" org_id={organization.id} stack_id={stack_id}',
e,
)
class SlackUninstallHandler(Handler):
@classmethod
def match(cls, event: Event) -> bool:
return (
event.get("event_type") == INTEGRATION_UNINSTALLED_EVENT_TYPE
and event.get("data", {}).get("provider_type") == PROVIDER_TYPE_SLACK
)
@classmethod
def handle(cls, data: dict) -> None:
data = typing.cast(IntegrationUninstalledData, data)
stack_id = data.get("stack_id")
user_id = data.get("grafana_user_id")
organization = Organization.objects.get(stack_id=stack_id)
user = organization.users.get(user_id=user_id)
try:
uninstall_slack_integration(organization, user)
except SlackInstallationExc as e:
logger.exception(
f'msg="SlackInstallationHandler: Failed to uninstall Slack integration: %s" org_id={organization.id} stack_id={stack_id}',
e,
)

View file

@ -1,7 +1,7 @@
import logging
import typing
from .handlers import Handler, SlackInstallationHandler
from .handlers import Handler, SlackInstallHandler, SlackUninstallHandler
from .types import Event
logger = logging.getLogger(__name__)
@ -12,7 +12,7 @@ class ChatopsEventsHandler:
ChatopsEventsHandler is a root handler which receives event from Chatops-Proxy and chooses the handler to process it.
"""
HANDLERS: typing.List[typing.Type[Handler]] = [SlackInstallationHandler]
HANDLERS: typing.List[typing.Type[Handler]] = [SlackInstallHandler, SlackUninstallHandler]
def handle(self, event_data: Event) -> bool:
"""

View file

@ -15,3 +15,9 @@ class IntegrationInstalledData(typing.TypedDict):
stack_id: int
grafana_user_id: int
payload: dict
class IntegrationUninstalledData(typing.TypedDict):
provider_type: str
stack_id: int
grafana_user_id: int

View file

@ -4,10 +4,10 @@ import pytest
from django.test import override_settings
from apps.chatops_proxy.events import ChatopsEventsHandler
from apps.chatops_proxy.events.handlers import SlackInstallationHandler
from apps.chatops_proxy.events.handlers import SlackInstallHandler, SlackUninstallHandler
from common.constants.slack_auth import SLACK_OAUTH_ACCESS_RESPONSE
installation_event = {
install_event = {
"event_type": "integration_installed",
"data": {
"provider_type": "slack",
@ -17,6 +17,15 @@ installation_event = {
},
}
uninstall_event = {
"event_type": "integration_uninstalled",
"data": {
"provider_type": "slack",
"stack_id": "stack_id",
"grafana_user_id": "grafana_user_id",
},
}
unknown_event = {
"event_type": "unknown_event",
"data": {
@ -37,7 +46,8 @@ invalid_schema_event = {
@pytest.mark.parametrize(
"payload,is_handled",
[
(installation_event, True),
(install_event, True),
(uninstall_event, True),
(unknown_event, False),
(invalid_schema_event, False),
],
@ -54,13 +64,30 @@ def test_root_event_handler(mock_exec, payload, is_handled):
def test_slack_installation_handler(mock_install_slack_integration, make_organization_and_user):
organization, user = make_organization_and_user()
installation_event["data"].update({"stack_id": organization.stack_id, "grafana_user_id": user.user_id})
install_event["data"].update({"stack_id": organization.stack_id, "grafana_user_id": user.user_id})
h = SlackInstallationHandler()
h = SlackInstallHandler()
assert h.match(unknown_event) is False
assert h.match(invalid_schema_event) is False
assert h.match(installation_event) is True
h.handle(installation_event["data"])
assert mock_install_slack_integration.call_args.args == (organization, user, installation_event["data"]["payload"])
assert h.match(install_event) is True
h.handle(install_event["data"])
assert mock_install_slack_integration.call_args.args == (organization, user, install_event["data"]["payload"])
@patch("apps.chatops_proxy.events.handlers.uninstall_slack_integration", return_value=None)
@pytest.mark.django_db
def test_slack_uninstall_handler(mock_uninstall_slack_integration, make_organization_and_user):
organization, user = make_organization_and_user()
uninstall_event["data"].update({"stack_id": organization.stack_id, "grafana_user_id": user.user_id})
h = SlackUninstallHandler()
assert h.match(unknown_event) is False
assert h.match(invalid_schema_event) is False
assert h.match(uninstall_event) is True
h.handle(uninstall_event["data"])
assert mock_uninstall_slack_integration.call_args.args == (organization, user)

View file

@ -48,3 +48,7 @@ class CallsLimitExceeded(Exception):
class SMSLimitExceeded(Exception):
pass
class PhoneNumberBanned(Exception):
pass

View file

@ -0,0 +1,22 @@
# Generated by Django 4.2.11 on 2024-06-19 21:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('phone_notifications', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='BannedPhoneNumber',
fields=[
('phone_number', models.CharField(max_length=20, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now=True)),
('reason', models.TextField(default=None, null=True)),
('users', models.JSONField(default=None, null=True)),
],
),
]

View file

@ -0,0 +1,61 @@
import json
import logging
from dataclasses import asdict, dataclass
from typing import List
from django.db import models
from apps.phone_notifications.exceptions import PhoneNumberBanned
logger = logging.getLogger(__name__)
@dataclass
class BannedPhoneUserEntry:
user_id: int
user_name: str
org_id: int
stack_slug: str
org_slug: str
class BannedPhoneNumber(models.Model):
phone_number = models.CharField(primary_key=True, max_length=20)
created_at = models.DateTimeField(auto_now=True)
reason = models.TextField(null=True, default=None)
users = models.JSONField(null=True, default=None)
def get_user_entries(self) -> List[BannedPhoneUserEntry]:
return [BannedPhoneUserEntry(**data) for data in json.loads(self.users)]
def ban_phone_number(phone_number: str, reason: str):
from apps.user_management.models import User
banned_phone_number = BannedPhoneNumber(phone_number=phone_number)
users = User.objects.filter(_verified_phone_number=phone_number)
# Record instances of phone number use
user_entries = [
asdict(
BannedPhoneUserEntry(
user_id=user.id,
user_name=user.username,
org_id=user.organization.org_id,
stack_slug=user.organization.stack_slug,
org_slug=user.organization.org_slug,
)
)
for user in users
]
users.update(_verified_phone_number=None)
banned_phone_number.users = json.dumps(user_entries)
banned_phone_number.reason = reason
banned_phone_number.save()
logger.info(f"ban_phone_number={phone_number}, in use by users={len(user_entries)}, reason={reason}")
def check_banned_phone_number(phone_number: str):
banned_entry = BannedPhoneNumber.objects.filter(phone_number=phone_number).first()
if banned_entry:
raise PhoneNumberBanned

View file

@ -21,6 +21,7 @@ from .exceptions import (
SMSLimitExceeded,
)
from .models import PhoneCallRecord, ProviderPhoneCall, ProviderSMS, SMSRecord
from .models.banned_phone_number import check_banned_phone_number
from .phone_provider import PhoneProvider, get_phone_provider
logger = logging.getLogger(__name__)
@ -316,6 +317,7 @@ class PhoneBackend:
if self._validate_user_number(user):
logger.info(f"PhoneBackend.send_verification_sms: number already verified for user {user.id}")
raise NumberAlreadyVerified
check_banned_phone_number(user.unverified_phone_number)
self.phone_provider.send_verification_sms(user.unverified_phone_number)
def make_verification_call(self, user):
@ -327,6 +329,7 @@ class PhoneBackend:
if self._validate_user_number(user):
logger.info(f"PhoneBackend.make_verification_call: number already verified user_id={user.id}")
raise NumberAlreadyVerified
check_banned_phone_number(user.unverified_phone_number)
self.phone_provider.make_verification_call(user.unverified_phone_number)
def verify_phone_number(self, user, code) -> bool:

View file

@ -0,0 +1,41 @@
import pytest
from apps.phone_notifications.models.banned_phone_number import BannedPhoneNumber, ban_phone_number
@pytest.mark.django_db
def test_ban_phone_number(make_organization, make_user_for_organization):
organization = make_organization()
banned_phone_number = "+1234567890"
unbanned_phone_number = "+0987654321"
banned_user_1 = make_user_for_organization(
organization=organization,
_verified_phone_number=banned_phone_number,
unverified_phone_number=banned_phone_number,
)
banned_user_2 = make_user_for_organization(
organization=organization,
_verified_phone_number=banned_phone_number,
unverified_phone_number=banned_phone_number,
)
unbanned_user = make_user_for_organization(
organization=organization,
_verified_phone_number=unbanned_phone_number,
unverified_phone_number=unbanned_phone_number,
)
reason = "usage too high"
ban_phone_number(banned_phone_number, reason)
banned_user_1.refresh_from_db()
assert banned_user_1._verified_phone_number is None
assert banned_user_1.unverified_phone_number == banned_phone_number
banned_user_2.refresh_from_db()
assert banned_user_2._verified_phone_number is None
assert banned_user_2.unverified_phone_number == banned_phone_number
unbanned_user.refresh_from_db()
assert unbanned_user._verified_phone_number == unbanned_phone_number
assert unbanned_user.unverified_phone_number == unbanned_phone_number
ban_phone_number_entry = BannedPhoneNumber.objects.get(pk=banned_phone_number)
assert ban_phone_number_entry is not None
assert ban_phone_number_entry.reason == reason
user_entries = ban_phone_number_entry.get_user_entries()
assert len(user_entries) == 2

View file

@ -2,7 +2,8 @@ from unittest import mock
import pytest
from apps.phone_notifications.exceptions import NumberAlreadyVerified
from apps.phone_notifications.exceptions import NumberAlreadyVerified, PhoneNumberBanned
from apps.phone_notifications.models.banned_phone_number import ban_phone_number
from apps.phone_notifications.phone_backend import PhoneBackend
from apps.phone_notifications.tests.mock_phone_provider import MockPhoneProvider
@ -67,3 +68,67 @@ def test_make_verification_call_raises_when_number_verified(
user.save_verified_phone_number("+1234567890")
with pytest.raises(NumberAlreadyVerified):
phone_backend.make_verification_call(user)
@pytest.mark.django_db
@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=False)
@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.send_verification_sms")
def test_send_verification_sms_banned_number(
mock_send_verification_sms, mock_validate_user_number, make_organization_and_user
):
_, user = make_organization_and_user()
phone_backend = PhoneBackend()
number_to_verify = "+1234567890"
user.unverified_phone_number = "+1234567890"
ban_phone_number(number_to_verify, "usage too high")
with pytest.raises(PhoneNumberBanned):
phone_backend.send_verification_sms(user)
@pytest.mark.django_db
@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=False)
@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.send_verification_sms")
def test_send_verification_sms_unaffected_by_ban(
mock_send_verification_sms, mock_validate_user_number, make_organization_and_user
):
_, user = make_organization_and_user()
phone_backend = PhoneBackend()
number_to_verify = "+1234567890"
user.unverified_phone_number = "+1234567890"
ban_phone_number("+0987654321", "usage too high")
phone_backend.send_verification_sms(user)
mock_send_verification_sms.assert_called_once_with(number_to_verify)
@pytest.mark.django_db
@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=False)
@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.make_verification_call")
def test_make_verification_call_banned_number(
mock_make_verification_call, mock_validate_user_number, make_organization_and_user
):
_, user = make_organization_and_user()
phone_backend = PhoneBackend()
number_to_verify = "+1234567890"
user.unverified_phone_number = "+1234567890"
ban_phone_number(number_to_verify, "usage too high")
with pytest.raises(PhoneNumberBanned):
phone_backend.make_verification_call(user)
@pytest.mark.django_db
@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=False)
@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.make_verification_call")
def test_make_verification_call_unaffected_by_ban(
mock_make_verification_call, mock_validate_user_number, make_organization_and_user
):
_, user = make_organization_and_user()
phone_backend = PhoneBackend()
number_to_verify = "+1234567890"
user.unverified_phone_number = "+1234567890"
ban_phone_number("+0987654321", "usage too high")
phone_backend.make_verification_call(user)
mock_make_verification_call.assert_called_once_with(number_to_verify)

View file

@ -3,6 +3,7 @@ import json
import logging
import typing
from django.conf import settings
from django.db.models import Q
from django.utils.text import Truncator
@ -76,16 +77,28 @@ class AddToResolutionNoteStep(scenario_step.ScenarioStep):
warning_text = "Unable to add this message to resolution note, this command works only in incident threads."
# thread_ts is only present for thread messages
thread_ts = payload.get("message", {}).get("thread_ts")
if not thread_ts:
if settings.UNIFIED_SLACK_APP_ENABLED:
# Message shortcut events are broadcasted to multiple regions by chatops-proxy
# Do not open a warning window to avoid multiple regions opening the same window multiple times
return
self.open_warning_window(payload, warning_text)
return
try:
slack_message = SlackMessage.objects.get(
slack_id=payload["message"]["thread_ts"],
_slack_team_identity=slack_team_identity,
channel_id=channel_id,
)
except KeyError:
self.open_warning_window(payload, warning_text)
return
except SlackMessage.DoesNotExist:
if settings.UNIFIED_SLACK_APP_ENABLED:
# Message shortcut events are broadcasted to multiple regions by chatops-proxy
# Don't open a warning window as this event could be handled by another region
return
self.open_warning_window(payload, warning_text)
return
@ -99,9 +112,17 @@ class AddToResolutionNoteStep(scenario_step.ScenarioStep):
)
return
if alert_group.channel.organization.deleted_at is not None:
if settings.UNIFIED_SLACK_APP_ENABLED:
# Message shortcut events are broadcasted to multiple regions by chatops-proxy
# Don't open a warning window as this event could be handled by another region
return
self.open_warning_window(payload, warning_text)
return
if payload["message"]["type"] == "message" and "user" in payload["message"]:
message_ts = payload["message_ts"]
thread_ts = payload["message"]["thread_ts"]
result = self._slack_client.chat_getPermalink(channel=channel_id, message_ts=message_ts)
permalink = None

View file

@ -334,3 +334,111 @@ def test_resolution_notes_modal_closed_before_update(
# Check that "views.update" API call was made
call_args, _ = mock_slack_api_call.call_args
assert call_args[0] == "views.update"
@patch.object(SlackClient, "chat_getPermalink", return_value={"permalink": "https://example.com"})
@pytest.mark.django_db
def test_add_to_resolution_note(
_,
make_organization_and_user_with_slack_identities,
make_alert_receive_channel,
make_alert_group,
make_alert,
make_slack_message,
settings,
):
organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities()
alert_receive_channel = make_alert_receive_channel(organization)
alert_group = make_alert_group(alert_receive_channel)
make_alert(alert_group=alert_group, raw_request_data={})
slack_message = make_slack_message(alert_group=alert_group)
payload = {
"channel": {"id": slack_message.channel_id},
"message_ts": "random_ts",
"message": {
"type": "message",
"text": "Test resolution note",
"ts": "random_ts",
"thread_ts": slack_message.slack_id,
"user": slack_user_identity.slack_id,
},
"trigger_id": "random_trigger_id",
}
AddToResolutionNoteStep = ScenarioStep.get_step("resolution_note", "AddToResolutionNoteStep")
step = AddToResolutionNoteStep(organization=organization, user=user, slack_team_identity=slack_team_identity)
with patch.object(SlackClient, "reactions_add") as mock_reactions_add:
step.process_scenario(slack_user_identity, slack_team_identity, payload)
mock_reactions_add.assert_called_once()
assert alert_group.resolution_notes.get().text == "Test resolution note"
@pytest.mark.django_db
def test_add_to_resolution_note_broadcast(make_organization_and_user_with_slack_identities, settings):
settings.UNIFIED_SLACK_APP_ENABLED = True
organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities()
payload = {
"channel": {"id": "TEST"},
"message_ts": "TEST",
"message": {"thread_ts": "TEST"},
"trigger_id": "TEST",
}
AddToResolutionNoteStep = ScenarioStep.get_step("resolution_note", "AddToResolutionNoteStep")
step = AddToResolutionNoteStep(organization=organization, user=user, slack_team_identity=slack_team_identity)
with patch.object(SlackClient, "api_call") as mock_api_call:
step.process_scenario(slack_user_identity, slack_team_identity, payload)
mock_api_call.assert_not_called() # no Slack API calls should be made
@patch.object(SlackClient, "chat_getPermalink", return_value={"permalink": "https://example.com"})
@pytest.mark.django_db
def test_add_to_resolution_note_deleted_org(
_,
make_organization_and_user_with_slack_identities,
make_alert_receive_channel,
make_alert_group,
make_alert,
make_slack_message,
make_organization,
make_user_for_organization,
settings,
):
settings.UNIFIED_SLACK_APP_ENABLED = True
organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities()
alert_receive_channel = make_alert_receive_channel(organization)
alert_group = make_alert_group(alert_receive_channel)
make_alert(alert_group=alert_group, raw_request_data={})
slack_message = make_slack_message(alert_group=alert_group)
organization.delete()
other_organization = make_organization(slack_team_identity=slack_team_identity)
other_user = make_user_for_organization(organization=other_organization, slack_user_identity=slack_user_identity)
payload = {
"channel": {"id": slack_message.channel_id},
"message_ts": "random_ts",
"message": {
"type": "message",
"text": "Test resolution note",
"ts": "random_ts",
"thread_ts": slack_message.slack_id,
"user": slack_user_identity.slack_id,
},
"trigger_id": "random_trigger_id",
}
AddToResolutionNoteStep = ScenarioStep.get_step("resolution_note", "AddToResolutionNoteStep")
step = AddToResolutionNoteStep(
organization=other_organization, user=other_user, slack_team_identity=slack_team_identity
)
with patch.object(SlackClient, "api_call") as mock_api_call:
step.process_scenario(slack_user_identity, slack_team_identity, payload)
mock_api_call.assert_not_called() # no Slack API calls should be made

View file

@ -7,8 +7,8 @@
"lint:fix": "eslint --max-warnings=0 --fix --cache --ext .js,.jsx,.ts,.tsx ./src ./e2e-tests",
"stylelint": "stylelint ./src/**/*.{css,scss,module.css,module.scss}",
"stylelint:fix": "stylelint --fix ./src/**/*.{css,scss,module.css,module.scss}",
"build": "webpack -c ./webpack.config.ts --env production",
"build:dev": "webpack -c ./webpack.config.ts --env development",
"build": "NODE_ENV=production webpack -c ./webpack.config.ts --env production",
"build:dev": "NODE_ENV=development webpack -c ./webpack.config.ts --env development",
"labels:link": "yarn --cwd ../../gops-labels/frontend link && yarn link \"@grafana/labels\" && yarn --cwd ../../gops-labels/frontend watch",
"labels:unlink": "yarn --cwd ../../gops-labels/frontend unlink",
"test-utc": "TZ=UTC jest --verbose --testNamePattern '^((?!@london-tz).)*$'",
@ -23,7 +23,7 @@
"test:e2e:gen": "yarn playwright codegen http://localhost:3000",
"e2e-show-report": "yarn playwright show-report",
"generate-types": "cd ./src/network/oncall-api/types-generator && yarn generate",
"watch": "webpack -w -c ./webpack.config.ts --env development",
"watch": "NODE_ENV=development webpack -w -c ./webpack.config.ts --env development",
"sign": "npx --yes @grafana/sign-plugin@latest",
"start": "yarn watch",
"plop": "plop",

View file

@ -40,7 +40,7 @@ interface RotationProps {
export const Rotation: FC<RotationProps> = observer((props) => {
const {
timezoneStore: { calendarStartDate, getDateInSelectedTimezone },
timezoneStore: { calendarStartDate, getDateInSelectedTimezone, selectedTimezoneOffset },
scheduleStore: { scheduleView: storeScheduleView },
} = useStore();
const {
@ -144,7 +144,7 @@ export const Rotation: FC<RotationProps> = observer((props) => {
const base = 60 * 60 * 24 * days;
return firstShiftOffset / base;
}, [events, startDate]);
}, [events, startDate, selectedTimezoneOffset]);
return (
<div className={cx('root')} onClick={onClick && handleRotationClick}>

View file

@ -2,31 +2,12 @@ import dayjs, { Dayjs } from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import { dayJSAddWithDSTFixed, getDateForDatePicker } from './RotationForm.helpers';
import { dayJSAddWithDSTFixed } from './RotationForm.helpers';
dayjs.extend(timezone);
dayjs.extend(utc);
describe('RotationForm helpers', () => {
describe('getDateForDatePicker()', () => {
it(`should return the same regular JS Date as input dayJsDate
even if selected day of month doesn't exist in current month
(in this case there is no 30th Feb and it should still work ok)`, () => {
jest.useFakeTimers().setSystemTime(new Date('2024-02-01'));
const inputDate = dayjs()
.utcOffset(360)
.set('year', 2024)
.set('month', 3) // 0-indexed so April
.set('date', 30)
.set('hour', 12)
.set('minute', 20);
const result = getDateForDatePicker(inputDate);
expect(result.toString()).toContain('Tue Apr 30 2024');
});
});
describe('dayJSAddWithDSTFixed() @london-tz', () => {
it(`corrects resulting hour to be the same as in input if start date is before London DST (GMT + 0)
and resulting date is within London DST (GMT + 1)`, () => {

View file

@ -1,6 +1,4 @@
import dayjs, { Dayjs, ManipulateType } from 'dayjs';
import { Timezone } from 'models/timezone/timezone.types';
import { Dayjs, ManipulateType } from 'dayjs';
import { RepeatEveryPeriod } from './RotationForm.types';
@ -11,19 +9,6 @@ export const getRepeatShiftsEveryOptions = (repeatEveryPeriod: number) => {
.map((i) => ({ label: String(i), value: i }));
};
export const toDate = (moment: dayjs.Dayjs, timezone: Timezone) => {
const localMoment = moment.tz(timezone);
return new Date(
localMoment.get('year'),
localMoment.get('month'),
localMoment.get('date'),
localMoment.get('hour'),
localMoment.get('minute'),
localMoment.get('second')
);
};
export interface TimeUnit {
unit: RepeatEveryPeriod;
value: number;
@ -171,23 +156,6 @@ export const repeatEveryInSeconds = (repeatEveryPeriod: RepeatEveryPeriod, repea
return repeatEveryPeriodMultiplier[repeatEveryPeriod] * repeatEveryValue;
};
export const getDateForDatePicker = (dayJsDate: Dayjs) => {
const date = new Date();
// Day of the month needs to be set to 1st day at first to prevent incorrect month increment
// when selected day of month doesn't exist in current month
// E.g. selected date is 30th March and current month is Feb, so in this case date.setMonth(2) results in April
date.setDate(1); // temporary selection to prevent incorrect month increment
date.setFullYear(dayJsDate.year());
date.setMonth(dayJsDate.month());
date.setDate(dayJsDate.date());
date.setHours(dayJsDate.hour());
date.setMinutes(dayJsDate.minute());
date.setSeconds(dayJsDate.second());
return date;
};
export const dayJSAddWithDSTFixed = ({
baseDate,
addParams,

View file

@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
Button,
Field,
HorizontalGroup,
@ -52,11 +53,11 @@ import { ApiSchemas } from 'network/oncall-api/api.types';
import {
getDateTime,
getSelectedDays,
getStartOfWeekBasedOnCurrentDate,
getUTCByDay,
getUTCString,
getUTCWeekStart,
getWeekStartString,
toDateWithTimezoneOffset,
} from 'pages/schedule/Schedule.helpers';
import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
import { useStore } from 'state/useStore';
@ -82,8 +83,25 @@ interface RotationFormProps {
onShowRotationForm: (shiftId: Shift['id']) => void;
}
const getStartShift = (start: dayjs.Dayjs, timezoneOffset: number, isNewRotation = false) => {
if (isNewRotation) {
// all new rotations default to midnight in selected timezone offset
return toDateWithTimezoneOffset(start, timezoneOffset)
.set('date', 1)
.set('year', start.year())
.set('month', start.month())
.set('date', start.date())
.set('hour', 0)
.set('minute', 0)
.set('second', 0);
}
return toDateWithTimezoneOffset(start, timezoneOffset);
};
export const RotationForm = observer((props: RotationFormProps) => {
const store = useStore();
const {
onHide,
onCreate,
@ -92,7 +110,7 @@ export const RotationForm = observer((props: RotationFormProps) => {
onDelete,
layerPriority,
shiftId,
shiftStart: propsShiftStart = getStartOfWeekBasedOnCurrentDate(store.timezoneStore.currentDateInSelectedTimezone),
shiftStart: propsShiftStart = store.timezoneStore.calendarStartDate,
shiftEnd: propsShiftEnd,
shiftColor = '#3D71D9',
onShowRotationForm,
@ -101,7 +119,6 @@ export const RotationForm = observer((props: RotationFormProps) => {
const shift = store.scheduleStore.shifts[shiftId];
const [errors, setErrors] = useState<{ [key: string]: string[] }>({});
const [bounds, setDraggableBounds] = useState<{ left: number; right: number; top: number; bottom: number }>(
undefined
);
@ -111,13 +128,19 @@ export const RotationForm = observer((props: RotationFormProps) => {
const [offsetTop, setOffsetTop] = useState<number>(GRAFANA_HEADER_HEIGHT + 10);
const [draggablePosition, setDraggablePosition] = useState<{ x: number; y: number }>(undefined);
const [shiftStart, setShiftStart] = useState<dayjs.Dayjs>(propsShiftStart);
const [shiftEnd, setShiftEnd] = useState<dayjs.Dayjs>(propsShiftEnd || shiftStart.add(1, 'day'));
const [shiftStart, setShiftStart] = useState<dayjs.Dayjs>(
getStartShift(propsShiftStart, store.timezoneStore.selectedTimezoneOffset, shiftId === 'new')
);
const [shiftEnd, setShiftEnd] = useState<dayjs.Dayjs>(
propsShiftEnd?.utcOffset(store.timezoneStore.selectedTimezoneOffset) || shiftStart.add(1, 'day')
);
const [activePeriod, setActivePeriod] = useState<number | undefined>(undefined);
const [shiftPeriodDefaultValue, setShiftPeriodDefaultValue] = useState<number | undefined>(undefined);
const [rotationStart, setRotationStart] = useState<dayjs.Dayjs>(shiftStart);
const [endLess, setEndless] = useState<boolean>(true);
const [endLess, setEndless] = useState<boolean>(shift?.until === undefined ? true : !Boolean(shift.until));
const [rotationEnd, setRotationEnd] = useState<dayjs.Dayjs>(shiftStart.add(1, 'month'));
const [repeatEveryValue, setRepeatEveryValue] = useState<number>(1);
@ -248,10 +271,11 @@ export const RotationForm = observer((props: RotationFormProps) => {
shift,
endLess,
rotationName,
store.timezoneStore.selectedTimezoneOffset,
]
);
useEffect(handleChange, [params, store.timezoneStore.calendarStartDate]);
useEffect(handleChange, [params, store.timezoneStore.calendarStartDate, store.timezoneStore.selectedTimezoneOffset]);
const create = useCallback(async () => {
try {
@ -330,28 +354,22 @@ export const RotationForm = observer((props: RotationFormProps) => {
}
};
const handleRotationStartChange = useCallback(
(value) => {
setRotationStart(value);
setShiftStart(value);
if (showActiveOnSelectedPartOfDay) {
setShiftEnd(
dayJSAddWithDSTFixed({
const handleRotationStartChange = (value: dayjs.Dayjs) => {
setRotationStart(value);
setShiftStart(value);
setShiftEnd(
showActiveOnSelectedPartOfDay
? dayJSAddWithDSTFixed({
baseDate: value,
addParams: [activePeriod, 'seconds'],
})
);
} else {
setShiftEnd(
dayJSAddWithDSTFixed({
: dayJSAddWithDSTFixed({
baseDate: value,
addParams: [repeatEveryValue, repeatEveryPeriodToUnitName[repeatEveryPeriod]],
})
);
}
},
[showActiveOnSelectedPartOfDay, activePeriod, repeatEveryPeriod, repeatEveryValue]
);
);
};
const handleActivePeriodChange = useCallback(
(value) => {
@ -435,15 +453,23 @@ export const RotationForm = observer((props: RotationFormProps) => {
useEffect(() => {
if (shift) {
setRotationName(getShiftName(shift));
const shiftStart = getDateTime(shift.shift_start);
// use shiftStart as rotationStart for existing shifts
// (original rotationStart defaulted to the shift creation timestamp)
const shiftStart = toDateWithTimezoneOffset(dayjs(shift.shift_start), store.timezoneStore.selectedTimezoneOffset);
setRotationStart(shiftStart);
setRotationEnd(shift.until ? getDateTime(shift.until) : getDateTime(shift.shift_start).add(1, 'month'));
setRotationEnd(
toDateWithTimezoneOffset(
// always keep the date offseted
shift.until ? getDateTime(shift.until) : getDateTime(shift.shift_start).add(1, 'month'),
store.timezoneStore.selectedTimezoneOffset
)
);
setShiftStart(shiftStart);
const shiftEnd = getDateTime(shift.shift_end);
const shiftEnd = toDateWithTimezoneOffset(dayjs(shift.shift_end), store.timezoneStore.selectedTimezoneOffset);
setShiftEnd(shiftEnd);
setEndless(!shift.until);
setRepeatEveryValue(shift.interval);
setRepeatEveryPeriod(shift.frequency);
@ -475,6 +501,10 @@ export const RotationForm = observer((props: RotationFormProps) => {
useEffect(() => {
if (shift) {
// for existing rotations
handleRotationStartChange(toDateWithTimezoneOffset(rotationStart, store.timezoneStore.selectedTimezoneOffset));
setRotationEnd(toDateWithTimezoneOffset(rotationEnd, store.timezoneStore.selectedTimezoneOffset));
setSelectedDays(
getSelectedDays({
dayOptions: store.scheduleStore.byDayOptions,
@ -482,6 +512,14 @@ export const RotationForm = observer((props: RotationFormProps) => {
moment: store.timezoneStore.getDateInSelectedTimezone(shiftStart),
})
);
} else {
// for new rotations
handleRotationStartChange(toDateWithTimezoneOffset(rotationStart, store.timezoneStore.selectedTimezoneOffset));
setShiftEnd(toDateWithTimezoneOffset(shiftEnd, store.timezoneStore.selectedTimezoneOffset));
// not behind an "if" such that it will reflect correct value after toggle gets switched
setRotationEnd(toDateWithTimezoneOffset(rotationEnd, store.timezoneStore.selectedTimezoneOffset));
}
}, [store.timezoneStore.selectedTimezoneOffset]);
@ -561,14 +599,11 @@ export const RotationForm = observer((props: RotationFormProps) => {
</Block>
)}
{!hasUpdatedShift && ended && (
<Block bordered className={cx('updated-shift-info')}>
<div className={cx('updated-shift-info')}>
<VerticalGroup>
<HorizontalGroup>
<Icon name="info-circle" size="md"></Icon>
<Text>This rotation is over</Text>
</HorizontalGroup>
<Alert severity="info" title={(<Text>This rotation is over</Text>) as unknown as string} />
</VerticalGroup>
</Block>
</div>
)}
<div className={cx('two-fields')}>
<Field
@ -580,6 +615,7 @@ export const RotationForm = observer((props: RotationFormProps) => {
>
<DateTimePicker
value={rotationStart}
utcOffset={store.timezoneStore.selectedTimezoneOffset}
onChange={handleRotationStartChange}
error={errors.rotation_start}
disabled={disabled}
@ -608,6 +644,7 @@ export const RotationForm = observer((props: RotationFormProps) => {
) : (
<DateTimePicker
value={rotationEnd}
utcOffset={store.timezoneStore.selectedTimezoneOffset}
onChange={setRotationEnd}
error={errors.until}
disabled={disabled}

View file

@ -186,7 +186,7 @@ export const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
const handleChange = useDebouncedCallback(updatePreview, 200);
useEffect(handleChange, [params, store.timezoneStore.calendarStartDate]);
useEffect(handleChange, [params, store.timezoneStore.calendarStartDate, store.timezoneStore.selectedTimezoneOffset]);
const isFormValid = useMemo(() => !Object.keys(errors).length, [errors]);

View file

@ -87,7 +87,7 @@ export const ShiftSwapForm = (props: ShiftSwapFormProps) => {
...shiftSwap,
});
}
}, [shiftSwap, store.timezoneStore.calendarStartDate]);
}, [shiftSwap, store.timezoneStore.calendarStartDate, store.timezoneStore.selectedTimezoneOffset]);
const handleDescriptionChange = useCallback(
(event) => {

View file

@ -8,8 +8,8 @@ import dayjs from 'dayjs';
import { observer } from 'mobx-react';
import { Text } from 'components/Text/Text';
import { getDateForDatePicker } from 'containers/RotationForm/RotationForm.helpers';
import { useStore } from 'state/useStore';
import { toDatePickerDate } from 'containers/Rotations/Rotations.helpers';
import { toDateWithTimezoneOffset } from 'pages/schedule/Schedule.helpers';
import styles from 'containers/RotationForm/RotationForm.module.css';
@ -17,6 +17,7 @@ const cx = cn.bind(styles);
interface DateTimePickerProps {
value: dayjs.Dayjs;
utcOffset?: number;
onChange: (value: dayjs.Dayjs) => void;
disabled?: boolean;
onFocus?: () => void;
@ -25,39 +26,37 @@ interface DateTimePickerProps {
}
export const DateTimePicker = observer(
({ value: propValue, onChange, disabled, onFocus, onBlur, error }: DateTimePickerProps) => {
({ value: propValue, utcOffset, onChange, disabled, onFocus, onBlur, error }: DateTimePickerProps) => {
const styles = useStyles2(getStyles);
const {
timezoneStore: { getDateInSelectedTimezone },
} = useStore();
const valueInSelectedTimezone = getDateInSelectedTimezone(propValue);
const valueAsDate = valueInSelectedTimezone.toDate();
const handleDateChange = (newDate: Date) => {
const localMoment = getDateInSelectedTimezone(dayjs(newDate));
const newValue = localMoment
.set('year', newDate.getFullYear())
.set('month', newDate.getMonth())
.set('date', newDate.getDate())
.set('hour', valueAsDate.getHours())
.set('minute', valueAsDate.getMinutes())
.set('second', valueAsDate.getSeconds());
const handleDateChange = (value: Date) => {
const newDate = toDateWithTimezoneOffset(dayjs(value), utcOffset)
.set('date', 1)
.set('months', value.getMonth())
.set('date', value.getDate())
.set('hours', propValue.hour())
.set('minutes', propValue.minute())
.set('second', 0)
.set('milliseconds', 0);
onChange(newValue);
onChange(newDate);
};
const handleTimeChange = (newMoment: DateTime) => {
const selectedHour = newMoment.hour();
const selectedMinute = newMoment.minute();
const newValue = valueInSelectedTimezone.set('hour', selectedHour).set('minute', selectedMinute);
onChange(newValue);
const handleTimeChange = (timeMoment: DateTime) => {
const newDate = toDateWithTimezoneOffset(propValue, utcOffset)
.set('hour', timeMoment.hour())
.set('minute', timeMoment.minute());
onChange(newDate);
};
const getTimeValueInSelectedTimezone = () => {
const time = dateTime(valueInSelectedTimezone.format());
time.set('hour', valueInSelectedTimezone.hour());
time.set('minute', valueInSelectedTimezone.minute());
time.set('second', valueInSelectedTimezone.second());
const dateInOffset = toDateWithTimezoneOffset(propValue, utcOffset);
const time = dateTime(dateInOffset.format());
time.set('hour', dateInOffset.hour());
time.set('minute', dateInOffset.minute());
time.set('seconds', dateInOffset.second());
return time;
};
@ -73,7 +72,7 @@ export const DateTimePicker = observer(
<DatePickerWithInput
open
disabled={disabled}
value={getDateForDatePicker(valueInSelectedTimezone)}
value={toDatePickerDate(propValue, utcOffset)}
onChange={handleDateChange}
/>
</div>

View file

@ -3,6 +3,38 @@ import dayjs from 'dayjs';
import { getColor, getOverrideColor } from 'models/schedule/schedule.helpers';
import { Layer, Shift } from 'models/schedule/schedule.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { toDateWithTimezoneOffset } from 'pages/schedule/Schedule.helpers';
// DatePickers will convert the date passed to local timezone, instead we want to use the date in the given timezone
export const toDatePickerDate = (value: dayjs.Dayjs, timezoneOffset: number) => {
const date = toDateWithTimezoneOffset(value, timezoneOffset);
return dayjs()
.set('hour', 0)
.set('minute', 0)
.set('second', 0)
.set('millisecond', 0)
.set('date', 1)
.set('month', date.month())
.set('date', date.date())
.set('year', date.year())
.toDate();
};
export const getCalendarStartDateInTimezone = (calendarStartDate: dayjs.Dayjs, utcOffset: number) => {
const offsetedDate = dayjs(calendarStartDate.toDate())
.utcOffset(utcOffset)
.set('date', 1)
.set('months', calendarStartDate.month())
.set('date', calendarStartDate.date())
.set('year', calendarStartDate.year())
.set('hours', 0)
.set('minutes', 0)
.set('second', 0)
.set('milliseconds', 0);
return offsetedDate;
};
export const findColor = (shiftId: Shift['id'], layers: Layer[], overrides?) => {
let color = undefined;

View file

@ -17,14 +17,14 @@ import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/W
import { getColor, getLayersFromStore, scheduleViewToDaysInOneRow } from 'models/schedule/schedule.helpers';
import { Schedule, ScheduleType, Shift, ShiftSwap, Event, Layer } from 'models/schedule/schedule.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { getCurrentTimeX } from 'pages/schedule/Schedule.helpers';
import { getCurrentTimeX, toDateWithTimezoneOffset } from 'pages/schedule/Schedule.helpers';
import { WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import { HTML_ID } from 'utils/DOM';
import { UserActions } from 'utils/authorization/authorization';
import { DEFAULT_TRANSITION_TIMEOUT } from './Rotations.config';
import { findColor } from './Rotations.helpers';
import { findColor, getCalendarStartDateInTimezone } from './Rotations.helpers';
import { getRotationsStyles } from './Rotations.styles';
import animationStyles from './Rotations.module.css';
@ -77,6 +77,8 @@ class _Rotations extends Component<RotationsProps, RotationsState> {
const { shiftStartToShowRotationForm, shiftEndToShowRotationForm } = this.state;
const { selectedTimezoneOffset } = store.timezoneStore;
const currentTimeX = getCurrentTimeX(
store.timezoneStore.currentDateInSelectedTimezone,
store.timezoneStore.calendarStartDate,
@ -140,7 +142,16 @@ class _Rotations extends Component<RotationsProps, RotationsState> {
<Button
variant="secondary"
icon="plus"
onClick={() => this.handleAddLayer(nextPriority, store.timezoneStore.calendarStartDate)}
onClick={() =>
this.handleAddLayer(
nextPriority,
getCalendarStartDateInTimezone(
store.timezoneStore.calendarStartDate,
store.timezoneStore.selectedTimezoneOffset
),
undefined
)
}
>
Add rotation
</Button>
@ -250,8 +261,8 @@ class _Rotations extends Component<RotationsProps, RotationsState> {
shiftColor={findColor(shiftIdToShowRotationForm, layers)}
scheduleId={scheduleId}
layerPriority={layerPriorityToShowRotationForm}
shiftStart={shiftStartToShowRotationForm}
shiftEnd={shiftEndToShowRotationForm}
shiftStart={toDateWithTimezoneOffset(shiftStartToShowRotationForm, selectedTimezoneOffset)}
shiftEnd={toDateWithTimezoneOffset(shiftEndToShowRotationForm, selectedTimezoneOffset)}
onHide={() => {
this.hideRotationForm();
@ -298,9 +309,15 @@ class _Rotations extends Component<RotationsProps, RotationsState> {
return;
}
this.setState({ shiftStartToShowRotationForm: shiftStart, shiftEndToShowRotationForm: shiftEnd }, () => {
this.onShowRotationForm('new', layerPriority);
});
this.setState(
{
shiftStartToShowRotationForm: shiftStart,
shiftEndToShowRotationForm: shiftEnd,
},
() => {
this.onShowRotationForm('new', layerPriority);
}
);
};
handleAddRotation = (option: SelectableValue) => {

View file

@ -56,7 +56,7 @@ const _ScheduleFinal: FC<ScheduleFinalProps> = observer(
scheduleView: propsScheduleView,
}) => {
const {
timezoneStore: { currentDateInSelectedTimezone, calendarStartDate },
timezoneStore: { selectedTimezoneOffset, currentDateInSelectedTimezone, calendarStartDate },
scheduleStore: { scheduleView: storeScheduleView },
} = store;
@ -85,7 +85,7 @@ const _ScheduleFinal: FC<ScheduleFinalProps> = observer(
});
}
return rows;
}, [calendarStartDate, scheduleView]);
}, [calendarStartDate, scheduleView, currentDateInSelectedTimezone, selectedTimezoneOffset]);
return (
<div

View file

@ -3,6 +3,7 @@ import React, { FC, useEffect } from 'react';
import { cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Badge, BadgeColor, Button, HorizontalGroup, Icon, useStyles2, withTheme2 } from '@grafana/ui';
import dayjs from 'dayjs';
import { observer } from 'mobx-react';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import { CSSTransition, TransitionGroup } from 'react-transition-group';
@ -51,7 +52,8 @@ const _SchedulePersonal: FC<SchedulePersonalProps> = observer(({ userPk, onSlotC
};
const handleTodayClick = () => {
timezoneStore.setCalendarStartDate(getStartOfWeekBasedOnCurrentDate(timezoneStore.currentDateInSelectedTimezone));
// TODAY
timezoneStore.setCalendarStartDate(getStartOfWeekBasedOnCurrentDate(dayjs()));
};
const handleLeftClick = () => {

View file

@ -3,7 +3,7 @@ import { observable, action, computed, makeObservable } from 'mobx';
// TODO: move utils from Schedule.helpers to common place
import { ScheduleView } from 'models/schedule/schedule.types';
import { getCalendarStartDate } from 'pages/schedule/Schedule.helpers';
import { getCalendarStartDate, toDateWithTimezoneOffsetAtMidnight } from 'pages/schedule/Schedule.helpers';
import { RootStore } from 'state/rootStore';
import { getOffsetOfCurrentUser, getGMTTimezoneLabelBasedOnOffset } from './timezone.helpers';
@ -20,19 +20,18 @@ export class TimezoneStore {
@observable
selectedTimezoneOffset = getOffsetOfCurrentUser();
/* @observable
calendarStartDate = getStartOfWeekBasedOnCurrentDate(this.currentDateInSelectedTimezone); */
@observable
calendarStartDate = getCalendarStartDate(this.currentDateInSelectedTimezone, ScheduleView.OneWeek);
calendarStartDate = getCalendarStartDate(
this.currentDateInSelectedTimezone,
ScheduleView.OneWeek,
this.selectedTimezoneOffset
);
@action.bound
setSelectedTimezoneOffset(offset: number) {
this.selectedTimezoneOffset = offset;
this.calendarStartDate = getCalendarStartDate(
this.currentDateInSelectedTimezone,
this.rootStore.scheduleStore.scheduleView
);
this.calendarStartDate = toDateWithTimezoneOffsetAtMidnight(this.calendarStartDate, offset);
}
@action.bound

View file

@ -4,8 +4,6 @@ import { InsightsConfig } from './Insights.types';
const DEFAULT_VARIABLE_CONFIG: Partial<ConstructorParameters<typeof QueryVariable>[0]> = {
hide: 0,
includeAll: true,
allValue: `.+`,
isMulti: true,
options: [],
refresh: 1,
@ -33,6 +31,8 @@ const getVariables = ({ isOpenSource, datasource, stack }: InsightsConfig) => ({
label: 'Team',
text: ['All'],
value: ['$__all'],
includeAll: true,
allValue: `.+`,
datasource,
definition: `label_values(\${alert_groups_total}{slug=~"${stack}"},team)`,
query: {
@ -47,6 +47,8 @@ const getVariables = ({ isOpenSource, datasource, stack }: InsightsConfig) => ({
label: 'Integration',
text: ['All'],
value: ['$__all'],
includeAll: true,
allValue: `.+`,
datasource,
definition: `label_values(\${alert_groups_total}{team=~"$team",slug=~"${stack}"},integration)`,
query: {
@ -61,6 +63,7 @@ const getVariables = ({ isOpenSource, datasource, stack }: InsightsConfig) => ({
label: 'Service name',
text: ['All'],
value: ['$__all'],
includeAll: true,
allValue: '($^)|(.+)',
datasource,
definition: `label_values(\${alert_groups_total}{slug=~"${stack}",team=~"$team"},service_name)`,
@ -85,7 +88,6 @@ const getVariables = ({ isOpenSource, datasource, stack }: InsightsConfig) => ({
value: ['oncall_alert_groups_total', 'grafanacloud_oncall_instance_alert_groups_total'],
definition: 'metrics(alert_groups_total)',
hide: 2,
includeAll: false,
}),
userNotified: new QueryVariable({
...DEFAULT_VARIABLE_CONFIG,

View file

@ -1,7 +1,7 @@
import { config } from '@grafana/runtime';
import dayjs from 'dayjs';
import { findColor } from 'containers/Rotations/Rotations.helpers';
import { findColor, getCalendarStartDateInTimezone } from 'containers/Rotations/Rotations.helpers';
import {
getLayersFromStore,
getOverridesFromStore,
@ -40,13 +40,15 @@ export const getStartOfWeekBasedOnCurrentDate = (date: dayjs.Dayjs) => {
return date.startOf('isoWeek'); // it's Monday always
};
export const getCalendarStartDate = (date: dayjs.Dayjs, scheduleView: ScheduleView) => {
export const getCalendarStartDate = (date: dayjs.Dayjs, scheduleView: ScheduleView, timezoneOffset: number) => {
const offsetedDate = getCalendarStartDateInTimezone(date, timezoneOffset);
switch (scheduleView) {
case ScheduleView.OneMonth:
const startOfMonth = date.startOf('month');
const startOfMonth = offsetedDate.startOf('month');
return startOfMonth.startOf('isoWeek');
default:
return date.startOf('isoWeek');
return offsetedDate.startOf('isoWeek');
}
};
@ -69,8 +71,8 @@ export const getCurrentTimeX = (currentDate: dayjs.Dayjs, startDate: dayjs.Dayjs
return diff / baseInMinutes;
};
export const getUTCString = (moment: dayjs.Dayjs) => {
return moment.utc().format('YYYY-MM-DDTHH:mm:ss.000Z');
export const getUTCString = (date: dayjs.Dayjs) => {
return date.utc().format('YYYY-MM-DDTHH:mm:ss.000Z');
};
export const getDateTime = (date: string) => {
@ -204,3 +206,24 @@ export const getColorSchemeMappingForUsers = (
});
}
};
export const toDateWithTimezoneOffset = (date: dayjs.Dayjs, timezoneOffset?: number) => {
if (!date) {
return undefined;
}
if (timezoneOffset === undefined) {
return date;
}
return date.utcOffset() === timezoneOffset ? date : date.tz().utcOffset(timezoneOffset);
};
export const toDateWithTimezoneOffsetAtMidnight = (date: dayjs.Dayjs, timezoneOffset?: number) => {
return toDateWithTimezoneOffset(date, timezoneOffset)
.set('date', 1)
.set('year', date.year())
.set('month', date.month())
.set('date', date.date())
.set('hour', 0)
.set('minute', 0)
.set('second', 0);
};

View file

@ -29,7 +29,7 @@ import { Text } from 'components/Text/Text';
import { WithConfirm } from 'components/WithConfirm/WithConfirm';
import { ShiftSwapForm } from 'containers/RotationForm/ShiftSwapForm';
import { Rotations } from 'containers/Rotations/Rotations';
import { findClosestUserEvent } from 'containers/Rotations/Rotations.helpers';
import { findClosestUserEvent, toDatePickerDate } from 'containers/Rotations/Rotations.helpers';
import { ScheduleFinal } from 'containers/Rotations/ScheduleFinal';
import { ScheduleOverrides } from 'containers/Rotations/ScheduleOverrides';
import { ScheduleForm } from 'containers/ScheduleForm/ScheduleForm';
@ -336,10 +336,17 @@ class _SchedulePage extends React.Component<SchedulePageProps, SchedulePageState
<div className={styles.datePicker}>
<DatePicker
isOpen={calendarStartDatePickerIsOpen}
value={store.timezoneStore.calendarStartDate.toDate()}
value={toDatePickerDate(
store.timezoneStore.calendarStartDate,
store.timezoneStore.selectedTimezoneOffset
)}
onChange={(newDate) => {
store.timezoneStore.setCalendarStartDate(
getCalendarStartDate(dayjs(newDate), scheduleView)
getCalendarStartDate(
dayjs(newDate),
scheduleView,
store.timezoneStore.selectedTimezoneOffset
)
);
this.handleDateRangeUpdate();
this.setState({ calendarStartDatePickerIsOpen: false });
@ -362,7 +369,8 @@ class _SchedulePage extends React.Component<SchedulePageProps, SchedulePageState
timezoneStore.setCalendarStartDate(
getCalendarStartDate(
timezoneStore.calendarStartDate.endOf('isoWeek').startOf('month'),
value
value,
timezoneStore.selectedTimezoneOffset
)
);
}
@ -532,9 +540,13 @@ class _SchedulePage extends React.Component<SchedulePageProps, SchedulePageState
};
handleTodayClick = () => {
const { store } = this.props;
store.timezoneStore.setCalendarStartDate(
getCalendarStartDate(store.timezoneStore.currentDateInSelectedTimezone, store.scheduleStore.scheduleView)
const {
store: { scheduleStore, timezoneStore },
} = this.props;
timezoneStore.setCalendarStartDate(
// TODAY
getCalendarStartDate(dayjs(), scheduleStore.scheduleView, timezoneStore.selectedTimezoneOffset)
);
this.handleDateRangeUpdate();
};

View file

@ -35,12 +35,23 @@ export const ONCALL_PROD = 'https://oncall-prod-us-central-0.grafana.net/oncall'
export const ONCALL_OPS = 'https://oncall-ops-us-east-0.grafana.net/oncall';
export const ONCALL_DEV = 'https://oncall-dev-us-central-0.grafana.net/oncall';
export const getProcessEnvVarSafely = (name: string) => {
try {
return process.env[name];
} catch (error) {
console.error(error);
return undefined;
}
};
export const getIsDevelopmentEnv = () => getProcessEnvVarSafely['NODE_ENV'] === 'development';
// Single source of truth on the frontend for OnCall API URL
export const getOnCallApiUrl = (meta?: OnCallAppPluginMeta) => {
if (meta?.jsonData?.onCallApiUrl) {
return meta?.jsonData?.onCallApiUrl;
} else if (typeof window === 'undefined') {
return process.env.ONCALL_API_URL;
return getProcessEnvVarSafely('ONCALL_API_URL');
}
return undefined;
};

View file

@ -9,6 +9,7 @@ import {
ONCALL_DEV,
ONCALL_OPS,
ONCALL_PROD,
getIsDevelopmentEnv,
} from './consts';
import { safeJSONStringify } from './string';
@ -31,7 +32,7 @@ class BaseFaroHelper {
faro: Faro;
initializeFaro(onCallApiUrl: string) {
if (this.faro) {
if (this.faro || getIsDevelopmentEnv()) {
return undefined;
}
@ -105,19 +106,19 @@ class BaseFaroHelper {
this.faro?.api.pushEvent(name, {
url: res?.config?.url,
status: `${res?.status}`,
statusText: `${res.statusText}`,
method: res.config?.method.toUpperCase(),
statusText: `${res?.statusText}`,
method: res?.config?.method.toUpperCase(),
});
};
pushAxiosNetworkError = (res: AxiosResponse) => {
this.faro?.api.pushError(new Error(`Network error: ${res.status}`), {
pushAxiosNetworkError = (res?: AxiosResponse) => {
this.faro?.api.pushError(new Error(`Network error: ${res?.status}`), {
context: {
url: res.config?.url,
url: res?.config?.url,
type: 'network',
data: `${safeJSONStringify(res.data)}`,
status: `${res.status}`,
statusText: `${res.statusText}`,
status: `${res?.status}`,
statusText: `${res?.statusText}`,
timestamp: new Date().toUTCString(),
},
});

View file

@ -64,6 +64,7 @@ const config = async (env): Promise<Configuration> => {
...(env.development ? [new LiveReloadPlugin({ appendScriptTag: true, useSourceHash: true })] : []),
new EnvironmentPlugin({
ONCALL_API_URL: null,
NODE_ENV: 'development',
}),
new DefinePlugin({
'process.env': JSON.stringify(dotenv.config().parsed),

View file

@ -13459,7 +13459,16 @@ string-template@~0.2.1:
resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add"
integrity sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw==
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -13577,7 +13586,7 @@ stringify-object@^3.3.0:
is-obj "^1.0.1"
is-regexp "^1.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -13598,6 +13607,13 @@ strip-ansi@^5.2.0:
dependencies:
ansi-regex "^4.1.0"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^7.0.1:
version "7.1.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@ -14938,8 +14954,7 @@ wordwrap@^1.0.0:
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
name wrap-ansi-cjs
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@ -14957,6 +14972,15 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
@ -14990,14 +15014,14 @@ write-file-atomic@^4.0.2:
signal-exit "^3.0.7"
ws@^7.3.1:
version "7.5.9"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591"
integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==
version "7.5.10"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9"
integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==
ws@^8.11.0:
version "8.16.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.16.0.tgz#d1cd774f36fbc07165066a60e40323eab6446fd4"
integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==
version "8.17.1"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b"
integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==
xml-name-validator@^4.0.0:
version "4.0.0"

View file

@ -12,8 +12,8 @@ metadata:
{{- end }}
type: Opaque
data:
{{ include "snippet.oncall.secret.secretKey" . }}: {{ randAlphaNum 40 | b64enc | quote }}
{{ include "snippet.oncall.secret.mirageSecretKey" . }}: {{ randAlphaNum 40 | b64enc | quote }}
{{ include "snippet.oncall.secret.secretKey" . }}: {{ (.Values.oncall.secrets.secretKey | default (randAlphaNum 40)) | b64enc | quote }}
{{ include "snippet.oncall.secret.mirageSecretKey" . }}: {{ (.Values.oncall.secrets.mirageSecretKey | default (randAlphaNum 40)) | b64enc | quote }}
---
{{- end }}
{{- if and (eq .Values.database.type "mysql") (not .Values.mariadb.enabled) (not .Values.externalMysql.existingSecret) }}

View file

@ -34,7 +34,7 @@ requests==2.32.0
# pdpyras
tomli==2.0.1
# via pytest
urllib3==2.2.1
urllib3==2.2.2
# via
# pdpyras
# requests