diff --git a/.github/ISSUE_TEMPLATE/0-bug-report-template.yml b/.github/ISSUE_TEMPLATE/0-bug-report-template.yml index 9cf697cc..9bfb1c41 100644 --- a/.github/ISSUE_TEMPLATE/0-bug-report-template.yml +++ b/.github/ISSUE_TEMPLATE/0-bug-report-template.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/1-feature-request-template.yml b/.github/ISSUE_TEMPLATE/1-feature-request-template.yml index c33f3009..011a8e4f 100644 --- a/.github/ISSUE_TEMPLATE/1-feature-request-template.yml +++ b/.github/ISSUE_TEMPLATE/1-feature-request-template.yml @@ -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 diff --git a/.github/workflows/on-issue-creation.yml b/.github/workflows/on-issue-creation.yml index b1040e49..23307322 100644 --- a/.github/workflows/on-issue-creation.yml +++ b/.github/workflows/on-issue-creation.yml @@ -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 diff --git a/.github/workflows/update-make-docs.yml b/.github/workflows/update-make-docs.yml index 3e69b710..bde39d31 100644 --- a/.github/workflows/update-make-docs.yml +++ b/.github/workflows/update-make-docs.yml @@ -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" diff --git a/README.md b/README.md index 8a44f55f..e83f0f14 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@ [![License](https://img.shields.io/github/license/grafana/oncall)](https://github.com/grafana/oncall/blob/dev/LICENSE) [![Docker Pulls](https://img.shields.io/docker/pulls/grafana/oncall)](https://hub.docker.com/r/grafana/oncall/tags) [![Slack](https://img.shields.io/badge/join%20slack-%23grafana-%2Doncall-brightgreen.svg)](https://slack.grafana.com/) -[![Discussion](https://img.shields.io/badge/discuss-oncall%20forum-orange.svg)](https://github.com/grafana/oncall/discussions) [![Build Status](https://github.com/grafana/oncall/actions/workflows/on-commits-to-dev.yml/badge.svg)](https://github.com/grafana/oncall/actions/workflows/on-commits-to-dev.yml) Developer-friendly incident response with brilliant Slack integration. diff --git a/docs/docs.mk b/docs/docs.mk index a08830d9..c0aae10b 100644 --- a/docs/docs.mk +++ b/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 diff --git a/docs/make-docs b/docs/make-docs index 43efdb5f..f531df2e 100755 --- a/docs/make-docs +++ b/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 </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 < Settings page as well. + ## Pagination List endpoints such as List Integrations or List Alert Groups return multiple objects. diff --git a/docs/sources/oncall-api-reference/alertgroups.md b/docs/sources/oncall-api-reference/alertgroups.md index e5126fb7..353c9edc 100644 --- a/docs/sources/oncall-api-reference/alertgroups.md +++ b/docs/sources/oncall-api-reference/alertgroups.md @@ -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/` -# 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//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//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//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//unresolve` -# Delete an alert group +## Delete an alert group ```shell curl "{{API_URL}}/api/v1/alert_groups/I68T24C13IFW1/" \ diff --git a/docs/sources/oncall-api-reference/alerts.md b/docs/sources/oncall-api-reference/alerts.md index 7d8935b7..df1c3d65 100644 --- a/docs/sources/oncall-api-reference/alerts.md +++ b/docs/sources/oncall-api-reference/alerts.md @@ -4,7 +4,9 @@ title: Alerts HTTP API weight: 100 --- -# List Alerts +# Alerts HTTP API + +## List Alerts ```shell curl "{{API_URL}}/api/v1/alerts/" \ diff --git a/docs/sources/oncall-api-reference/escalation_chains.md b/docs/sources/oncall-api-reference/escalation_chains.md index 4957402d..6c574700 100644 --- a/docs/sources/oncall-api-reference/escalation_chains.md +++ b/docs/sources/oncall-api-reference/escalation_chains.md @@ -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//` -# 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/" \ diff --git a/docs/sources/oncall-api-reference/escalation_policies.md b/docs/sources/oncall-api-reference/escalation_policies.md index d574a016..3c4b419c 100644 --- a/docs/sources/oncall-api-reference/escalation_policies.md +++ b/docs/sources/oncall-api-reference/escalation_policies.md @@ -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//` -# 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/" \ diff --git a/docs/sources/oncall-api-reference/integrations.md b/docs/sources/oncall-api-reference/integrations.md index 7f663aef..41bec8c6 100644 --- a/docs/sources/oncall-api-reference/integrations.md +++ b/docs/sources/oncall-api-reference/integrations.md @@ -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//` -# 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//` -# 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. diff --git a/docs/sources/oncall-api-reference/on_call_shifts.md b/docs/sources/oncall-api-reference/on_call_shifts.md index 094b2e14..7c991d50 100644 --- a/docs/sources/oncall-api-reference/on_call_shifts.md +++ b/docs/sources/oncall-api-reference/on_call_shifts.md @@ -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//` -# 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//` -# Delete OnCall shift +## Delete OnCall shift ```shell curl "{{API_URL}}/api/v1/on_call_shifts/OH3V5FYQEYJ6M/" \ diff --git a/docs/sources/oncall-api-reference/outgoing_webhooks.md b/docs/sources/oncall-api-reference/outgoing_webhooks.md index 31aaac76..bf8085d5 100644 --- a/docs/sources/oncall-api-reference/outgoing_webhooks.md +++ b/docs/sources/oncall-api-reference/outgoing_webhooks.md @@ -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 diff --git a/docs/sources/oncall-api-reference/personal_notification_rules.md b/docs/sources/oncall-api-reference/personal_notification_rules.md index 22c3d5d5..225ce57f 100644 --- a/docs/sources/oncall-api-reference/personal_notification_rules.md +++ b/docs/sources/oncall-api-reference/personal_notification_rules.md @@ -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//` -# 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/" \ diff --git a/docs/sources/oncall-api-reference/resolution_notes.md b/docs/sources/oncall-api-reference/resolution_notes.md index 24d4eecd..674c7d85 100644 --- a/docs/sources/oncall-api-reference/resolution_notes.md +++ b/docs/sources/oncall-api-reference/resolution_notes.md @@ -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//` -# 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//` -# Delete a resolution note +## Delete a resolution note ```shell curl "{{API_URL}}/api/v1/resolution_notes/M4BTQUS3PRHYQ/" \ diff --git a/docs/sources/oncall-api-reference/routes.md b/docs/sources/oncall-api-reference/routes.md index 81940477..1ecf5d4f 100644 --- a/docs/sources/oncall-api-reference/routes.md +++ b/docs/sources/oncall-api-reference/routes.md @@ -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//` -# 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//` -# Delete a route +## Delete a route ```shell curl "{{API_URL}}/api/v1/routes/RIYGUJXCPFHXY/" \ diff --git a/docs/sources/oncall-api-reference/schedules.md b/docs/sources/oncall-api-reference/schedules.md index 228c8f07..5a2d80c8 100644 --- a/docs/sources/oncall-api-reference/schedules.md +++ b/docs/sources/oncall-api-reference/schedules.md @@ -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//` -# 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//` -# 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//` -# 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 diff --git a/docs/sources/oncall-api-reference/shift_swaps.md b/docs/sources/oncall-api-reference/shift_swaps.md index 7888218a..77ba958f 100644 --- a/docs/sources/oncall-api-reference/shift_swaps.md +++ b/docs/sources/oncall-api-reference/shift_swaps.md @@ -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//` -# 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//` -# 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//` -# Take a shift swap request +## Take a shift swap request ```shell curl "{{API_URL}}/api/v1/shift_swaps/SSRG1TDNBMJQ1NC/take" \ diff --git a/docs/sources/oncall-api-reference/slack_channels.md b/docs/sources/oncall-api-reference/slack_channels.md index e2c39b44..72036d4e 100644 --- a/docs/sources/oncall-api-reference/slack_channels.md +++ b/docs/sources/oncall-api-reference/slack_channels.md @@ -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/" \ diff --git a/docs/sources/oncall-api-reference/user_groups.md b/docs/sources/oncall-api-reference/user_groups.md index 49d58942..9a453251 100644 --- a/docs/sources/oncall-api-reference/user_groups.md +++ b/docs/sources/oncall-api-reference/user_groups.md @@ -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 --- -# List user groups +# OnCall user groups HTTP API + +## List user groups ```shell curl "{{API_URL}}/api/v1/user_groups/" \ diff --git a/docs/sources/oncall-api-reference/users.md b/docs/sources/oncall-api-reference/users.md index 434a2afd..034b575e 100644 --- a/docs/sources/oncall-api-reference/users.md +++ b/docs/sources/oncall-api-reference/users.md @@ -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/" \ diff --git a/docs/sources/set-up/get-started/index.md b/docs/sources/set-up/get-started/index.md index 5b6a1d41..a75a4ca3 100644 --- a/docs/sources/set-up/get-started/index.md +++ b/docs/sources/set-up/get-started/index.md @@ -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 diff --git a/docs/sources/set-up/open-source/index.md b/docs/sources/set-up/open-source/index.md index 1fc1ef0d..375a7137 100644 --- a/docs/sources/set-up/open-source/index.md +++ b/docs/sources/set-up/open-source/index.md @@ -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. diff --git a/engine/apps/chatops_proxy/events/handlers.py b/engine/apps/chatops_proxy/events/handlers.py index 967be6ee..11897e99 100644 --- a/engine/apps/chatops_proxy/events/handlers.py +++ b/engine/apps/chatops_proxy/events/handlers.py @@ -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, + ) diff --git a/engine/apps/chatops_proxy/events/root_handler.py b/engine/apps/chatops_proxy/events/root_handler.py index d9a8c6f3..4e8af9c9 100644 --- a/engine/apps/chatops_proxy/events/root_handler.py +++ b/engine/apps/chatops_proxy/events/root_handler.py @@ -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: """ diff --git a/engine/apps/chatops_proxy/events/types.py b/engine/apps/chatops_proxy/events/types.py index 22ce2310..93843970 100644 --- a/engine/apps/chatops_proxy/events/types.py +++ b/engine/apps/chatops_proxy/events/types.py @@ -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 diff --git a/engine/apps/chatops_proxy/tests/test_events.py b/engine/apps/chatops_proxy/tests/test_events.py index 01891bdb..79bb2700 100644 --- a/engine/apps/chatops_proxy/tests/test_events.py +++ b/engine/apps/chatops_proxy/tests/test_events.py @@ -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) diff --git a/engine/apps/phone_notifications/exceptions.py b/engine/apps/phone_notifications/exceptions.py index a605ac81..c7c38f12 100644 --- a/engine/apps/phone_notifications/exceptions.py +++ b/engine/apps/phone_notifications/exceptions.py @@ -48,3 +48,7 @@ class CallsLimitExceeded(Exception): class SMSLimitExceeded(Exception): pass + + +class PhoneNumberBanned(Exception): + pass diff --git a/engine/apps/phone_notifications/migrations/0002_bannedphonenumber.py b/engine/apps/phone_notifications/migrations/0002_bannedphonenumber.py new file mode 100644 index 00000000..025dbaf2 --- /dev/null +++ b/engine/apps/phone_notifications/migrations/0002_bannedphonenumber.py @@ -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)), + ], + ), + ] diff --git a/engine/apps/phone_notifications/models/banned_phone_number.py b/engine/apps/phone_notifications/models/banned_phone_number.py new file mode 100644 index 00000000..a6d6511c --- /dev/null +++ b/engine/apps/phone_notifications/models/banned_phone_number.py @@ -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 diff --git a/engine/apps/phone_notifications/phone_backend.py b/engine/apps/phone_notifications/phone_backend.py index 26b64399..a8cf50d6 100644 --- a/engine/apps/phone_notifications/phone_backend.py +++ b/engine/apps/phone_notifications/phone_backend.py @@ -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: diff --git a/engine/apps/phone_notifications/tests/test_banned_phone_number.py b/engine/apps/phone_notifications/tests/test_banned_phone_number.py new file mode 100644 index 00000000..8363a790 --- /dev/null +++ b/engine/apps/phone_notifications/tests/test_banned_phone_number.py @@ -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 diff --git a/engine/apps/phone_notifications/tests/test_phone_backend_phone_verification.py b/engine/apps/phone_notifications/tests/test_phone_backend_phone_verification.py index cfb8996c..a12380cb 100644 --- a/engine/apps/phone_notifications/tests/test_phone_backend_phone_verification.py +++ b/engine/apps/phone_notifications/tests/test_phone_backend_phone_verification.py @@ -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) diff --git a/engine/apps/slack/scenarios/resolution_note.py b/engine/apps/slack/scenarios/resolution_note.py index a5bfb279..67ddea66 100644 --- a/engine/apps/slack/scenarios/resolution_note.py +++ b/engine/apps/slack/scenarios/resolution_note.py @@ -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 diff --git a/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py b/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py index 5ed39ecf..e3e9cb02 100644 --- a/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py +++ b/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py @@ -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 diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index e39bb446..e888a52c 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -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", diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx index 6f56d6a0..4210eaf1 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.tsx +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -40,7 +40,7 @@ interface RotationProps { export const Rotation: FC = observer((props) => { const { - timezoneStore: { calendarStartDate, getDateInSelectedTimezone }, + timezoneStore: { calendarStartDate, getDateInSelectedTimezone, selectedTimezoneOffset }, scheduleStore: { scheduleView: storeScheduleView }, } = useStore(); const { @@ -144,7 +144,7 @@ export const Rotation: FC = observer((props) => { const base = 60 * 60 * 24 * days; return firstShiftOffset / base; - }, [events, startDate]); + }, [events, startDate, selectedTimezoneOffset]); return (
diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.helpers.test.ts b/grafana-plugin/src/containers/RotationForm/RotationForm.helpers.test.ts index 6814710f..d36e72c2 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.helpers.test.ts +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.helpers.test.ts @@ -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)`, () => { diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.helpers.ts b/grafana-plugin/src/containers/RotationForm/RotationForm.helpers.ts index b0651d16..da86c3b5 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.helpers.ts +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.helpers.ts @@ -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, diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index c9c6376a..b068f68e 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -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(GRAFANA_HEADER_HEIGHT + 10); const [draggablePosition, setDraggablePosition] = useState<{ x: number; y: number }>(undefined); - const [shiftStart, setShiftStart] = useState(propsShiftStart); - const [shiftEnd, setShiftEnd] = useState(propsShiftEnd || shiftStart.add(1, 'day')); + const [shiftStart, setShiftStart] = useState( + getStartShift(propsShiftStart, store.timezoneStore.selectedTimezoneOffset, shiftId === 'new') + ); + + const [shiftEnd, setShiftEnd] = useState( + propsShiftEnd?.utcOffset(store.timezoneStore.selectedTimezoneOffset) || shiftStart.add(1, 'day') + ); + const [activePeriod, setActivePeriod] = useState(undefined); const [shiftPeriodDefaultValue, setShiftPeriodDefaultValue] = useState(undefined); const [rotationStart, setRotationStart] = useState(shiftStart); - const [endLess, setEndless] = useState(true); + const [endLess, setEndless] = useState(shift?.until === undefined ? true : !Boolean(shift.until)); const [rotationEnd, setRotationEnd] = useState(shiftStart.add(1, 'month')); const [repeatEveryValue, setRepeatEveryValue] = useState(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) => { )} {!hasUpdatedShift && ended && ( - +
- - - This rotation is over - + This rotation is over) as unknown as string} /> - +
)}
{ > { ) : ( = (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]); diff --git a/grafana-plugin/src/containers/RotationForm/ShiftSwapForm.tsx b/grafana-plugin/src/containers/RotationForm/ShiftSwapForm.tsx index 7795b4dd..dee6d5f6 100644 --- a/grafana-plugin/src/containers/RotationForm/ShiftSwapForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/ShiftSwapForm.tsx @@ -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) => { diff --git a/grafana-plugin/src/containers/RotationForm/parts/DateTimePicker.tsx b/grafana-plugin/src/containers/RotationForm/parts/DateTimePicker.tsx index 6dc88a26..3c90b836 100644 --- a/grafana-plugin/src/containers/RotationForm/parts/DateTimePicker.tsx +++ b/grafana-plugin/src/containers/RotationForm/parts/DateTimePicker.tsx @@ -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(
diff --git a/grafana-plugin/src/containers/Rotations/Rotations.helpers.ts b/grafana-plugin/src/containers/Rotations/Rotations.helpers.ts index 9d2a47d2..ac3bc857 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.helpers.ts +++ b/grafana-plugin/src/containers/Rotations/Rotations.helpers.ts @@ -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; diff --git a/grafana-plugin/src/containers/Rotations/Rotations.tsx b/grafana-plugin/src/containers/Rotations/Rotations.tsx index 5ba47b92..81383ffa 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.tsx +++ b/grafana-plugin/src/containers/Rotations/Rotations.tsx @@ -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 { 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 { @@ -250,8 +261,8 @@ class _Rotations extends Component { 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 { return; } - this.setState({ shiftStartToShowRotationForm: shiftStart, shiftEndToShowRotationForm: shiftEnd }, () => { - this.onShowRotationForm('new', layerPriority); - }); + this.setState( + { + shiftStartToShowRotationForm: shiftStart, + shiftEndToShowRotationForm: shiftEnd, + }, + () => { + this.onShowRotationForm('new', layerPriority); + } + ); }; handleAddRotation = (option: SelectableValue) => { diff --git a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx index 723f217b..e169df9d 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx @@ -56,7 +56,7 @@ const _ScheduleFinal: FC = observer( scheduleView: propsScheduleView, }) => { const { - timezoneStore: { currentDateInSelectedTimezone, calendarStartDate }, + timezoneStore: { selectedTimezoneOffset, currentDateInSelectedTimezone, calendarStartDate }, scheduleStore: { scheduleView: storeScheduleView }, } = store; @@ -85,7 +85,7 @@ const _ScheduleFinal: FC = observer( }); } return rows; - }, [calendarStartDate, scheduleView]); + }, [calendarStartDate, scheduleView, currentDateInSelectedTimezone, selectedTimezoneOffset]); return (
= observer(({ userPk, onSlotC }; const handleTodayClick = () => { - timezoneStore.setCalendarStartDate(getStartOfWeekBasedOnCurrentDate(timezoneStore.currentDateInSelectedTimezone)); + // TODAY + timezoneStore.setCalendarStartDate(getStartOfWeekBasedOnCurrentDate(dayjs())); }; const handleLeftClick = () => { diff --git a/grafana-plugin/src/models/timezone/timezone.ts b/grafana-plugin/src/models/timezone/timezone.ts index e8561c79..10995b0b 100644 --- a/grafana-plugin/src/models/timezone/timezone.ts +++ b/grafana-plugin/src/models/timezone/timezone.ts @@ -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 diff --git a/grafana-plugin/src/pages/insights/variables.ts b/grafana-plugin/src/pages/insights/variables.ts index b8dbd6e2..9a5cdb76 100644 --- a/grafana-plugin/src/pages/insights/variables.ts +++ b/grafana-plugin/src/pages/insights/variables.ts @@ -4,8 +4,6 @@ import { InsightsConfig } from './Insights.types'; const DEFAULT_VARIABLE_CONFIG: Partial[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, diff --git a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts index 44096057..0f2a2771 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts +++ b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts @@ -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); +}; diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index 80fb1bdb..3ed6623b 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -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 { 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 { - 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(); }; diff --git a/grafana-plugin/src/utils/consts.ts b/grafana-plugin/src/utils/consts.ts index ee691a70..7e26bae2 100644 --- a/grafana-plugin/src/utils/consts.ts +++ b/grafana-plugin/src/utils/consts.ts @@ -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; }; diff --git a/grafana-plugin/src/utils/faro.ts b/grafana-plugin/src/utils/faro.ts index c9212e95..6b4c9df3 100644 --- a/grafana-plugin/src/utils/faro.ts +++ b/grafana-plugin/src/utils/faro.ts @@ -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(), }, }); diff --git a/grafana-plugin/webpack.config.ts b/grafana-plugin/webpack.config.ts index 49c66ee9..cccf4a11 100644 --- a/grafana-plugin/webpack.config.ts +++ b/grafana-plugin/webpack.config.ts @@ -64,6 +64,7 @@ const config = async (env): Promise => { ...(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), diff --git a/grafana-plugin/yarn.lock b/grafana-plugin/yarn.lock index 499768d6..827b6c98 100644 --- a/grafana-plugin/yarn.lock +++ b/grafana-plugin/yarn.lock @@ -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" diff --git a/helm/oncall/templates/secrets.yaml b/helm/oncall/templates/secrets.yaml index 40a02694..a4c32845 100644 --- a/helm/oncall/templates/secrets.yaml +++ b/helm/oncall/templates/secrets.yaml @@ -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) }} diff --git a/tools/migrators/requirements.txt b/tools/migrators/requirements.txt index e73f413f..ce7823fc 100644 --- a/tools/migrators/requirements.txt +++ b/tools/migrators/requirements.txt @@ -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