commit
75698fc5a3
59 changed files with 937 additions and 314 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
7
.github/workflows/on-issue-creation.yml
vendored
7
.github/workflows/on-issue-creation.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/update-make-docs.yml
vendored
2
.github/workflows/update-make-docs.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
[](https://github.com/grafana/oncall/blob/dev/LICENSE)
|
||||
[](https://hub.docker.com/r/grafana/oncall/tags)
|
||||
[](https://slack.grafana.com/)
|
||||
[](https://github.com/grafana/oncall/discussions)
|
||||
[](https://github.com/grafana/oncall/actions/workflows/on-commits-to-dev.yml)
|
||||
|
||||
Developer-friendly incident response with brilliant Slack integration.
|
||||
|
|
|
|||
26
docs/docs.mk
26
docs/docs.mk
|
|
@ -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
|
||||
|
|
|
|||
134
docs/make-docs
134
docs/make-docs
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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/" \
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ title: Alerts HTTP API
|
|||
weight: 100
|
||||
---
|
||||
|
||||
# List Alerts
|
||||
# Alerts HTTP API
|
||||
|
||||
## List Alerts
|
||||
|
||||
```shell
|
||||
curl "{{API_URL}}/api/v1/alerts/" \
|
||||
|
|
|
|||
|
|
@ -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/" \
|
||||
|
|
|
|||
|
|
@ -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/" \
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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/" \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/" \
|
||||
|
|
|
|||
|
|
@ -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/" \
|
||||
|
|
|
|||
|
|
@ -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/" \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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/" \
|
||||
|
|
|
|||
|
|
@ -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/" \
|
||||
|
|
|
|||
|
|
@ -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/" \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -48,3 +48,7 @@ class CallsLimitExceeded(Exception):
|
|||
|
||||
class SMSLimitExceeded(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PhoneNumberBanned(Exception):
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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)`, () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue