From 9ff486078f8edf256d21812ed8c0d8eeba8fcf2f Mon Sep 17 00:00:00 2001 From: Dominik Broj Date: Fri, 22 Mar 2024 13:29:22 +0100 Subject: [PATCH 1/9] Use Tilt CI to run e2e tests on Github workflows (#3842) # What this PR does - Reuse Tiltfile from local environment and use `tilt ci` to run e2e tests on Github - Use Playwright Docker image to get rid of installing Playwright browsers and system dependencies - Use ubuntu-latest-16-cores runner for e2e tests job on CI ## Which issue(s) this PR fixes Closes https://github.com/grafana/oncall/issues/4018 ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes. --- .github/helm-values.yml | 119 ------------- .github/kind.yml | 19 --- .github/workflows/e2e-tests.yml | 156 ++++-------------- Tiltfile | 21 ++- dev/helm-local.yml | 14 +- .../e2e-tests/insights/insights.test.ts | 40 +++-- grafana-plugin/e2e-tests/utils/navigation.ts | 3 +- grafana-plugin/package.json | 1 + grafana-plugin/playwright.config.ts | 6 +- 9 files changed, 79 insertions(+), 300 deletions(-) delete mode 100644 .github/helm-values.yml delete mode 100644 .github/kind.yml diff --git a/.github/helm-values.yml b/.github/helm-values.yml deleted file mode 100644 index 31ac441f..00000000 --- a/.github/helm-values.yml +++ /dev/null @@ -1,119 +0,0 @@ -base_url: 172.17.0.1:30001 -base_url_protocol: http - -env: - - name: GRAFANA_CLOUD_NOTIFICATIONS_ENABLED - value: "False" - - name: FEATURE_PROMETHEUS_EXPORTER_ENABLED - value: "True" -image: - repository: oncall/engine - tag: latest - pullPolicy: IfNotPresent -oncall: - devMode: true -broker: - type: redis -redis: - architecture: standalone # don't run replicas, just eats up resources -rabbitmq: - enabled: false -engine: - replicaCount: 1 -celery: - replicaCount: 1 - worker_beat_enabled: false - -grafana: - replicas: 1 - extraInitContainers: - - name: create-db-if-not-exists - image: mysql:8.0.32 - command: - # yamllint disable rule:line-length - [ - "bash", - "-c", - 'while ! mysqladmin ping -h "$DATABASE_HOST" --silent; do echo ''awaiting mysql db to be available'' && sleep 1; done && mysql -h "$DATABASE_HOST" -u "$DATABASE_USER" -p"$DATABASE_PASSWORD" -e ''CREATE DATABASE IF NOT EXISTS grafana CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;''', - ] - # yamllint enable rule:line-length - env: - - name: DATABASE_HOST - value: oncall-ci-mariadb - - name: DATABASE_USER - value: root - - name: DATABASE_PASSWORD - valueFrom: - secretKeyRef: - name: oncall-ci-mariadb - key: mariadb-root-password - env: - GF_FEATURE_TOGGLES_ENABLE: topnav - GF_SECURITY_ADMIN_PASSWORD: oncall - GF_SECURITY_ADMIN_USER: oncall - GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: grafana-oncall-app - GF_DATABASE_TYPE: mysql - GF_DATABASE_HOST: oncall-ci-mariadb:3306 - GF_DATABASE_USER: root - GF_DATABASE_SSL_MODE: disable - envValueFrom: - GF_DATABASE_PASSWORD: - secretKeyRef: - name: oncall-ci-mariadb - key: mariadb-root-password - # by settings grafana.plugins to [] and configuring grafana.extraVolumeMounts we are using the locally built - # OnCall plugin rather than the latest published version - plugins: [] - extraVolumeMounts: - - name: plugins - mountPath: /var/lib/grafana/plugins/grafana-plugin - # hostPath is defined in .github/kind.yml - hostPath: /oncall-plugin - readOnly: true - service: - type: NodePort - nodePort: 30002 - -database: - type: mysql -mariadb: - enabled: true - primary: - service: - type: NodePort - nodePort: 30003 - extraEnvVars: - # See "Passing extra command line flags to mysqld startup" section - # https://hub.docker.com/r/bitnami/mariadb - # - # max_allowed_packet is set to 128mb in bytes - # - # this avoids "Got an error reading communication packets" errors that arise from the grafana container - # apparently sending too much data to mariadb at once - # https://mariadb.com/docs/skysql-dbaas/ref/mdb/system-variables/max_allowed_packet/ - - name: MARIADB_EXTRA_FLAGS - value: "--max_allowed_packet=134217728 --max_connections=1024" - - name: MARIADB_CHARACTER_SET - value: utf8mb4 - - name: MARIADB_COLLATE - value: utf8mb4_unicode_ci - -ingress: - enabled: false -ingress-nginx: - enabled: false -cert-manager: - enabled: false -service: - enabled: true - type: NodePort - port: 8080 - nodePort: 30001 -prometheus: - enabled: true - extraScrapeConfigs: | - - job_name: 'oncall-exporter' - metrics_path: /metrics/ - static_configs: - - targets: - - oncall-dev-engine.default.svc.cluster.local:8080 diff --git a/.github/kind.yml b/.github/kind.yml deleted file mode 100644 index c61ac446..00000000 --- a/.github/kind.yml +++ /dev/null @@ -1,19 +0,0 @@ -kind: Cluster -apiVersion: kind.x-k8s.io/v1alpha4 -nodes: - - role: control-plane - extraPortMappings: - - containerPort: 30001 - hostPort: 30001 - - containerPort: 30002 - hostPort: 30002 - # https://stackoverflow.com/a/62695918 - extraMounts: - # this basically mounts our local ./grafana-plugin (frontend) directory into the kind node - # so that we can later use a volumeMount to mount from the kind-control-plane Docker container -> grafana - # k8s pod. This will allow us to mount the current frontend source code - # - # NOTE: this is a bit hacky and implies that kind create is run from the root of the project - # but for now it works... alternative would be to use something like $(pwd)/grafana-plugin - - hostPath: ./grafana-plugin - containerPath: /oncall-plugin diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index f881e152..4aa5df57 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -33,7 +33,7 @@ jobs: # default "ubuntu-latest" runners only provide 2 CPU cores + 7GB of RAM. this seems to lead to HTTP 504s from # the oncall backend, and hence, flaky tests. Let's use CI runners w/ more resources to avoid this (plus # this will allow us to run more backend containers and parralelize the tests) - runs-on: ubuntu-latest-8-cores + runs-on: ubuntu-latest-16-cores name: "Grafana: ${{ inputs.grafana-image-tag }}" environment: name: github-pages @@ -44,15 +44,6 @@ jobs: - name: Checkout uses: actions/checkout@v3 - # TODO: re-enable this when we get the docker build build-context caching working.. see other TODO comment below - # - uses: actions/setup-python@v4 - # with: - # python-version: "3.11.4" - # cache: "pip" - # cache-dependency-path: | - # engine/requirements.txt - # engine/requirements-dev.txt - - name: Collect Workflow Telemetry uses: runforesight/workflow-telemetry-action@v1 with: @@ -60,10 +51,11 @@ jobs: proc_trace_chart_show: false proc_trace_table_show: false - - name: Create k8s Kind Cluster + - name: Install Kind uses: helm/kind-action@v1.3.0 with: - config: ./.github/kind.yml + config: ./dev/kind.yml + install_only: true - uses: actions/setup-node@v3 with: @@ -71,6 +63,17 @@ jobs: cache: "yarn" cache-dependency-path: grafana-plugin/yarn.lock + - name: Install Tilt + run: | + curl -fsSL https://raw.githubusercontent.com/tilt-dev/tilt/master/scripts/install.sh | bash + + - name: Install ctlptl + run: | + CTLPTL_VERSION="0.8.20" + CTLPTL_FILE_NAME="ctlptl.$CTLPTL_VERSION.linux.x86_64.tar.gz" + curl -fsSL https://github.com/tilt-dev/ctlptl/releases/download/v$CTLPTL_VERSION/$CTLPTL_FILE_NAME | \ + tar -xzv -C /usr/local/bin ctlptl + - name: Use cached frontend dependencies id: cache-frontend-dependencies uses: actions/cache@v3 @@ -95,42 +98,6 @@ jobs: working-directory: grafana-plugin run: yarn build:dev - - name: Set up Docker Buildx # We need this step for docker caching - uses: docker/setup-buildx-action@v2 - - - name: Build engine Docker image locally - uses: docker/build-push-action@v4 - with: - context: ./engine - file: ./engine/Dockerfile - push: false - tags: oncall/engine:latest - outputs: type=docker,dest=/tmp/oncall-engine.tar - # TODO: figure out how to get this to work.. this will substantially speed up building our docker image here - # because right now most time is spent building wheels for python dependencies - # (even though they rarely change).. this portion "should" work however I haven't yet figured out how to - # get the cache bind mount in engine/Dockerfile to work optionally (ie. when we don't specify - # the --build-context flag to docker build.. otherwise it fails if pip_cache is not available) - # - # references - # https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md#run---mounttypecache - # https://stackoverflow.com/a/71846527 - # build-contexts: pip_cache=/home/runner/.cache/pip - - - name: Load engine Docker image on the nodes of the cluster - run: kind load image-archive --name=chart-testing /tmp/oncall-engine.tar - - - name: Install helm chart - run: | - helm install oncall-ci \ - --values ./.github/helm-values.yml \ - --set oncall.twilio.accountSid="${{ secrets.TWILIO_ACCOUNT_SID }}" \ - --set oncall.twilio.authToken="${{ secrets.TWILIO_AUTH_TOKEN }}" \ - --set oncall.twilio.phoneNumber="\"${{ secrets.TWILIO_PHONE_NUMBER }}"\" \ - --set oncall.twilio.verifySid="${{ secrets.TWILIO_VERIFY_SID }}" \ - --set grafana.image.tag=${{ inputs.grafana-image-tag }} \ - ./helm/oncall - # helpful reference for properly caching the playwright binaries/dependencies # https://playwrightsolutions.com/playwright-github-action-to-cache-the-browser-binaries/ - name: Get installed Playwright version @@ -147,87 +114,34 @@ jobs: path: "~/.cache/ms-playwright" key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}-${{ inputs.browsers }} - # For the next two steps, use the binary directly from node_modules/.bin as opposed to npx playwright - # due to this bug (https://github.com/microsoft/playwright/issues/13188) - - name: Install Playwright Browsers - if: steps.playwright-cache.outputs.cache-hit != 'true' - working-directory: grafana-plugin - run: ./node_modules/.bin/playwright install --with-deps ${{ inputs.browsers }} - - # use the cached browsers, but we still need to install the necessary system dependencies - # (system deps are installed in the cache-miss step above by the --with-deps flag) - - name: Install Playwright System Dependencies - if: steps.playwright-cache.outputs.cache-hit == 'true' - working-directory: grafana-plugin - run: ./node_modules/.bin/playwright install-deps ${{ inputs.browsers }} - - # we could instead use the --wait flag for the helm install command above - # but there's no reason to block on that step - # instead we can let the k8s resources start up behind the scenes and do other - # setup tasks (ex. install playwright + its dependencies) - - name: Wait until k8s resources are ready + - name: Create cluster run: | - kubectl rollout status deployment/oncall-ci-grafana --timeout=300s - kubectl rollout status deployment/oncall-ci-engine --timeout=300s - kubectl rollout status deployment/oncall-ci-celery --timeout=300s + make cluster/up - - name: Run e2e Tests + - name: Install Playwright deps + uses: docker://mcr.microsoft.com/playwright:next-jammy + + - name: Tilt CI + shell: bash env: - # BASE_URL represents what is accessed via a browser - BASE_URL: http://localhost:30002/grafana - # ONCALL_API_URL is what is configured in the plugin configuration form - # it is what the grafana container uses to communicate with the OnCall backend - # - # 172.17.0.1 is the docker bridge network default gateway. Requests originate in the grafana container - # hit 172.17.0.1 which proxies the request onto the host where port 30001 is the node port that is mapped - # to the OnCall API - ONCALL_API_URL: http://172.17.0.1:30001 - GRAFANA_ADMIN_USERNAME: oncall - GRAFANA_ADMIN_PASSWORD: oncall - GRAFANA_EDITOR_USERNAME: editor - GRAFANA_EDITOR_PASSWORD: editor - GRAFANA_VIEWER_USERNAME: viewer - GRAFANA_VIEWER_PASSWORD: viewer - MAILSLURP_API_KEY: ${{ secrets.MAILSLURP_API_KEY }} + GRAFANA_IMAGE_TAG: ${{ inputs.grafana-image-tag }} BROWSERS: ${{ inputs.browsers }} - working-directory: ./grafana-plugin - run: yarn test:e2e + run: tilt ci - - name: Run expensive e2e Tests + - name: Tilt CI - expensive E2E tests if: inputs.run-expensive-tests + shell: bash env: - BASE_URL: http://localhost:30002/grafana - ONCALL_API_URL: http://172.17.0.1:30001 - GRAFANA_ADMIN_USERNAME: oncall - GRAFANA_ADMIN_PASSWORD: oncall - GRAFANA_EDITOR_USERNAME: editor - GRAFANA_EDITOR_PASSWORD: editor - GRAFANA_VIEWER_USERNAME: viewer - GRAFANA_VIEWER_PASSWORD: viewer + E2E_TESTS_CMD: "cd grafana-plugin && yarn test:e2e-expensive" + GRAFANA_IMAGE_TAG: ${{ inputs.grafana-image-tag }} + BROWSERS: ${{ inputs.browsers }} MAILSLURP_API_KEY: ${{ secrets.MAILSLURP_API_KEY }} - working-directory: ./grafana-plugin - run: yarn test:e2e-expensive - - # spit out the engine, celery, and grafana logs, if the the e2e tests have failed (or were flaky) - # can be helpful for debugging these tests - # GitHub Action reference: https://github.com/jupyterhub/action-k8s-namespace-report - - name: oncall-engine logs - if: always() - uses: jupyterhub/action-k8s-namespace-report@v1 - with: - important-workloads: deploy/oncall-ci-engine - - - name: oncall-celery logs - if: always() - uses: jupyterhub/action-k8s-namespace-report@v1 - with: - important-workloads: deploy/oncall-ci-celery - - - name: grafana logs - if: always() - uses: jupyterhub/action-k8s-namespace-report@v1 - with: - important-workloads: deploy/oncall-ci-grafana + TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_ACCOUNT_SID }} + TWILIO_AUTH_TOKEN: ${{ secrets.TWILIO_AUTH_TOKEN }} + # wrapping single quotes are required to prevent stripping leading "+" from the number + TWILIO_PHONE_NUMBER: '"${{ secrets.TWILIO_PHONE_NUMBER }}"' + TWILIO_VERIFY_SID: ${{ secrets.TWILIO_VERIFY_SID }} + run: tilt ci - name: Setup Pages if: failure() diff --git a/Tiltfile b/Tiltfile index 9cfeb003..a366862f 100644 --- a/Tiltfile +++ b/Tiltfile @@ -2,6 +2,15 @@ load('ext://uibutton', 'cmd_button', 'location', 'text_input', 'bool_input') running_under_parent_tiltfile = os.getenv("TILT_PARENT", "false") == "true" # The user/pass that you will login to Grafana with grafana_admin_user_pass = os.getenv("GRAFANA_ADMIN_USER_PASS", "oncall") +grafana_image_tag = os.getenv("GRAFANA_IMAGE_TAG", "latest") +e2e_tests_cmd=os.getenv("E2E_TESTS_CMD", "cd grafana-plugin && yarn test:e2e") +twilio_values=[ + "oncall.twilio.accountSid=" + os.getenv("TWILIO_ACCOUNT_SID", ""), + "oncall.twilio.authToken=" + os.getenv("TWILIO_AUTH_TOKEN", ""), + "oncall.twilio.phoneNumber=" + os.getenv("TWILIO_PHONE_NUMBER", ""), + "oncall.twilio.verifySid=" + os.getenv("TWILIO_VERIFY_SID", ""), +] +is_ci=config.tilt_subcommand == "ci" # HELM_PREFIX must be "oncall-dev" as it is hardcoded in dev/helm-local.yml HELM_PREFIX = "oncall-dev" # Use docker registery generated by ctlptl (dev/kind-config.yaml) @@ -54,7 +63,6 @@ docker_build_sub( local_resource( "build-ui", labels=["OnCallUI"], - cmd="cd grafana-plugin && yarn install && yarn build:dev", serve_cmd="cd grafana-plugin && yarn watch", allow_parallel=True, ) @@ -62,10 +70,10 @@ local_resource( local_resource( "e2e-tests", labels=["E2eTests"], - cmd="cd grafana-plugin && yarn test:e2e", + cmd=e2e_tests_cmd, trigger_mode=TRIGGER_MODE_MANUAL, - auto_init=False, - resource_deps=["build-ui", "grafana", "grafana-oncall-app-provisioning-configmap", "engine"] + auto_init=is_ci, + resource_deps=["build-ui", "grafana", "grafana-oncall-app-provisioning-configmap", "engine", "celery"] ) cmd_button( @@ -77,7 +85,6 @@ cmd_button( inputs=[ text_input("BROWSERS", "Browsers (e.g. \"chromium,firefox,webkit\")", "chromium", "chromium,firefox,webkit"), text_input("TESTS_FILTER", "Test filter (e.g. \"timezones.test quality.test\")", "", "Test file names to run"), - bool_input("REPORTER", "Use HTML reporter", True, 'html', 'line'), bool_input("STOP_ON_FIRST_FAILURE", "Stop on first failure", True, "-x", ""), ] ) @@ -106,7 +113,7 @@ cmd_button( icon_name="dangerous", ) -yaml = helm("helm/oncall", name=HELM_PREFIX, values=["./dev/helm-local.yml", "./dev/helm-local.dev.yml"]) +yaml = helm("helm/oncall", name=HELM_PREFIX, values=["./dev/helm-local.yml", "./dev/helm-local.dev.yml"], set=twilio_values) k8s_yaml(yaml) @@ -127,6 +134,7 @@ k8s_resource( # Use separate grafana helm chart if not running_under_parent_tiltfile: grafana( + grafana_version=grafana_image_tag, context="grafana-plugin", plugin_files=["grafana-plugin/src/plugin.json"], namespace="default", @@ -161,5 +169,4 @@ k8s_resource( def resource_name(id): return id.name.replace(HELM_PREFIX + "-", "") - workload_to_resource_function(resource_name) diff --git a/dev/helm-local.yml b/dev/helm-local.yml index d33f216c..938b387a 100644 --- a/dev/helm-local.yml +++ b/dev/helm-local.yml @@ -28,14 +28,7 @@ engine: replicaCount: 1 celery: replicaCount: 1 - -ui: - enabled: false - image: - repository: localhost:63628/oncall/ui - env: - ONCALL_API_URL: http://oncall-dev-engine:8080 - MOBILE_APP_QR_INTERVAL_QUEUE: 290000 # 4 minutes and 50 seconds + worker_beat_enabled: false externalGrafana: url: http://grafana:3000 @@ -47,8 +40,6 @@ grafana: domain: localhost:3000 root_url: "%(protocol)s://%(domain)s" replicas: 1 - image: - tag: 10.0.2 extraInitContainers: - name: create-db-if-not-exists image: mysql:8.0.32 @@ -137,6 +128,9 @@ service: nodePort: 30001 prometheus: enabled: true + server: + global: + scrape_interval: 10s extraScrapeConfigs: | - job_name: 'oncall-exporter' metrics_path: /metrics/ diff --git a/grafana-plugin/e2e-tests/insights/insights.test.ts b/grafana-plugin/e2e-tests/insights/insights.test.ts index cf29ee22..9320d290 100644 --- a/grafana-plugin/e2e-tests/insights/insights.test.ts +++ b/grafana-plugin/e2e-tests/insights/insights.test.ts @@ -21,7 +21,7 @@ test.skip( ); test.describe('Insights', () => { - test.beforeAll(async ({ adminRolePage: { page, userName } }) => { + test.beforeAll(async ({ adminRolePage: { page } }) => { const DATASOURCE_NAME = 'OnCall Prometheus'; const DATASOURCE_URL = 'http://oncall-dev-prometheus-server.default.svc.cluster.local'; @@ -37,21 +37,6 @@ test.describe('Insights', () => { await page.getByPlaceholder('http://localhost:9090').fill(DATASOURCE_URL); await clickButton({ page, buttonText: 'Save & test' }); } - - // send alert and resolve to get some values in insights - const escalationChainName = generateRandomValue(); - const integrationName = generateRandomValue(); - const onCallScheduleName = generateRandomValue(); - await createOnCallScheduleWithRotation(page, onCallScheduleName, userName); - await createEscalationChain( - page, - escalationChainName, - EscalationStep.NotifyUsersFromOnCallSchedule, - onCallScheduleName - ); - await createIntegrationAndSendDemoAlert(page, integrationName, escalationChainName); - await resolveFiringAlert(page); - await page.waitForTimeout(5000); }); test('Viewer can see all the panels in OnCall insights', async ({ viewerRolePage: { page } }) => { @@ -69,11 +54,30 @@ test.describe('Insights', () => { }); }); - test('There is no panel that misses data', async ({ adminRolePage: { page } }) => { + test('There is no panel that misses data', async ({ adminRolePage: { page, userName } }) => { + test.setTimeout(90_000); + + // send alert and resolve to get some values in insights + const escalationChainName = generateRandomValue(); + const integrationName = generateRandomValue(); + const onCallScheduleName = generateRandomValue(); + await createOnCallScheduleWithRotation(page, onCallScheduleName, userName); + await createEscalationChain( + page, + escalationChainName, + EscalationStep.NotifyUsersFromOnCallSchedule, + onCallScheduleName + ); + await createIntegrationAndSendDemoAlert(page, integrationName, escalationChainName); + await resolveFiringAlert(page); + // wait for Prometheus to scrape the data + await page.waitForTimeout(5000); + + // check that we have data in insights panels await goToOnCallPage(page, 'insights'); await page.getByText('Last 24 hours').click(); await page.getByText('Last 1 hour').click(); - await page.waitForTimeout(2000); + await page.waitForTimeout(3000); await expect(page.getByText('No data')).toBeHidden(); }); }); diff --git a/grafana-plugin/e2e-tests/utils/navigation.ts b/grafana-plugin/e2e-tests/utils/navigation.ts index 44c042b8..af9435df 100644 --- a/grafana-plugin/e2e-tests/utils/navigation.ts +++ b/grafana-plugin/e2e-tests/utils/navigation.ts @@ -10,7 +10,8 @@ type OnCallPage = | 'outgoing_webhooks' | 'users' | 'users/me' - | 'insights'; + | 'insights' + | 'settings'; const _goToPage = async (page: Page, url = '') => page.goto(`${BASE_URL}${url}`); diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index 3bc45f0a..ff70411c 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -17,6 +17,7 @@ "test:e2e": "yarn playwright test --grep-invert @expensive", "test:e2e-expensive": "yarn playwright test --grep @expensive", "test:e2e:watch": "yarn test:e2e --ui", + "test:e2e-expensive:watch": "yarn test:e2e-expensive --ui", "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", diff --git a/grafana-plugin/playwright.config.ts b/grafana-plugin/playwright.config.ts index 3d3dff3e..a4bb0316 100644 --- a/grafana-plugin/playwright.config.ts +++ b/grafana-plugin/playwright.config.ts @@ -13,10 +13,6 @@ export const ADMIN_USER_STORAGE_STATE = path.join(__dirname, 'e2e-tests/.auth/ad const IS_CI = !!process.env.CI; const BROWSERS = process.env.BROWSERS || 'chromium'; -const REPORTER_WITH_DEFAULT = process.env.REPORTER || 'html'; -const REPORTER = ( - process.env.REPORTER === 'html' ? [['html', { open: 'never' }]] : REPORTER_WITH_DEFAULT -) as PlaywrightTestConfig['reporter']; const SETUP_PROJECT_NAME = 'setup'; const getEnabledBrowsers = (browsers: PlaywrightTestProject[]) => @@ -31,7 +27,7 @@ export default defineConfig({ /* Maximum time all the tests can run for. */ globalTimeout: 20 * 60 * 1_000, // 20 minutes - reporter: REPORTER, + reporter: [['html', { open: IS_CI ? 'never' : 'always' }]], /* Maximum time one test can run for. */ timeout: 60_000, From 3eca7a6a89b940bba774757180de0a6e3aef9ae2 Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Fri, 22 Mar 2024 20:31:44 +0800 Subject: [PATCH 2/9] Add permalinks field to the alert groups list view in internal api (#4100) # What this PR does closes https://github.com/grafana/oncall/issues/4099 ## Which issue(s) this PR closes Closes [issue link here] ## Checklist - [ ] Unit, integration, and e2e (if applicable) tests updated - [ ] Documentation added (or `pr:no public docs` PR label added if not required) - [ ] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes. --- engine/apps/api/serializers/alert_group.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/apps/api/serializers/alert_group.py b/engine/apps/api/serializers/alert_group.py index 71d7cbf6..ea3892ca 100644 --- a/engine/apps/api/serializers/alert_group.py +++ b/engine/apps/api/serializers/alert_group.py @@ -168,6 +168,7 @@ class AlertGroupListSerializer( "team", "grafana_incident_id", "labels", + "permalinks", ] def get_render_for_web(self, obj: "AlertGroup") -> RenderForWeb | EmptyRenderForWeb: @@ -218,7 +219,6 @@ class AlertGroupSerializer(AlertGroupListSerializer): "alerts", "render_after_resolve_report_json", "slack_permalink", # TODO: make plugin frontend use "permalinks" field to get Slack link - "permalinks", "last_alert_at", "paged_users", ] From b64366231dca90faec58ea12ba0179d4afe6f6c8 Mon Sep 17 00:00:00 2001 From: Dominik Broj Date: Mon, 25 Mar 2024 17:20:10 +0100 Subject: [PATCH 3/9] Fix schedule gaps when DST happens between rotation start and end date (#4103) # What this PR does 1) IF in rotation form user selects start date before DST change AND according to recurrence period / selected end date it's after DST change AND there is a difference in hours identified because of DST change THEN set hour of end date to the same as start date so that there are no gaps in the schedule 2) Fix showing current system time info in schedule slot ## Which issue(s) this PR closes https://github.com/grafana/oncall/issues/3261 ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes. --- .../schedules/scheduleDetails.test.ts | 5 +- .../e2e-tests/schedules/timezones.test.ts | 10 +-- .../src/containers/Rotation/Rotation.tsx | 9 ++- .../RotationForm/RotationForm.helpers.ts | 21 ++++++ .../containers/RotationForm/RotationForm.tsx | 71 ++++++++++++++++--- .../RotationForm/parts/DateTimePicker.tsx | 15 +++- .../ScheduleSlot/ScheduleSlot.module.css | 4 +- .../containers/ScheduleSlot/ScheduleSlot.tsx | 24 ++++--- .../UserTimezoneSelect/UserTimezoneSelect.tsx | 12 +++- .../ScheduleUserDetails.tsx | 4 +- .../UsersTimezones/UsersTimezones.helpers.ts | 2 +- .../src/models/timezone/timezone.ts | 2 +- 12 files changed, 137 insertions(+), 42 deletions(-) diff --git a/grafana-plugin/e2e-tests/schedules/scheduleDetails.test.ts b/grafana-plugin/e2e-tests/schedules/scheduleDetails.test.ts index 5afabeea..e1bd7f32 100644 --- a/grafana-plugin/e2e-tests/schedules/scheduleDetails.test.ts +++ b/grafana-plugin/e2e-tests/schedules/scheduleDetails.test.ts @@ -10,9 +10,8 @@ test(`user can see the other user's details`, async ({ adminRolePage, editorRole await createOnCallScheduleWithRotation(page, onCallScheduleName, adminUserName); await createRotation(page, editorUserName, false); + await page.waitForTimeout(1_000); + await page.getByTestId('user-avatar-in-schedule').first().hover(); await expect(page.getByTestId('schedule-user-details')).toHaveText(new RegExp(editorUserName)); - - await page.getByTestId('user-avatar-in-schedule').nth(1).hover(); - await expect(page.getByTestId('schedule-user-details')).toHaveText(new RegExp(adminUserName)); }); diff --git a/grafana-plugin/e2e-tests/schedules/timezones.test.ts b/grafana-plugin/e2e-tests/schedules/timezones.test.ts index f9d098bd..32f5689d 100644 --- a/grafana-plugin/e2e-tests/schedules/timezones.test.ts +++ b/grafana-plugin/e2e-tests/schedules/timezones.test.ts @@ -11,7 +11,9 @@ import { createOnCallScheduleWithRotation } from '../utils/schedule'; dayjs.extend(utc); dayjs.extend(isoWeek); -test.use({ timezoneId: 'Europe/Moscow' }); // GMT+3 the whole year +const MOSCOW_TIMEZONE = 'Europe/Moscow'; + +test.use({ timezoneId: MOSCOW_TIMEZONE }); // GMT+3 the whole year const currentUtcTimeHour = dayjs().utc().format('HH'); const currentUtcDate = dayjs().utc().format('DD MMM'); const currentMoscowTimeHour = dayjs().utcOffset(180).format('HH'); @@ -20,7 +22,7 @@ const currentMoscowDate = dayjs().utcOffset(180).format('DD MMM'); test('dates in schedule are correct according to selected current timezone', async ({ adminRolePage }) => { const { page, userName } = adminRolePage; - await setTimezoneInProfile(page, 'Europe/Moscow'); + await setTimezoneInProfile(page, MOSCOW_TIMEZONE); const onCallScheduleName = generateRandomValue(); await createOnCallScheduleWithRotation(page, onCallScheduleName, userName); @@ -41,7 +43,7 @@ test('dates in schedule are correct according to selected current timezone', asy await expect(page.getByTestId('schedule-user-details_your-current-time')).toHaveText( new RegExp(currentMoscowTimeHour) ); - await expect(page.getByTestId('schedule-user-details_user-local-time')).toHaveText(/GMT\+3/); + await expect(page.getByTestId('schedule-user-details_user-local-time')).toHaveText(new RegExp(MOSCOW_TIMEZONE)); await expect(page.getByTestId('schedule-user-details_user-local-time')).toHaveText(new RegExp(currentMoscowTimeHour)); // Schedule slot shows correct times and timezones @@ -50,7 +52,7 @@ test('dates in schedule are correct according to selected current timezone', asy await expect(page.getByTestId('schedule-slot-user-local-time')).toHaveText( new RegExp(`${currentMoscowDate}, ${currentMoscowTimeHour}`) ); - await expect(page.getByTestId('schedule-slot-user-local-time')).toHaveText(/\(GMT\+3\)/); + await expect(page.getByTestId('schedule-slot-user-local-time')).toHaveText(new RegExp(MOSCOW_TIMEZONE)); await expect(page.getByTestId('schedule-slot-current-timezone')).toHaveText( new RegExp(`${currentUtcDate}, ${currentUtcTimeHour}`) ); diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx index 51ed0b7b..f75e738a 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.tsx +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -41,7 +41,7 @@ interface RotationProps { export const Rotation: FC = observer((props) => { const { - timezoneStore: { calendarStartDate }, + timezoneStore: { calendarStartDate, getDateInSelectedTimezone }, } = useStore(); const { events, @@ -133,11 +133,14 @@ export const Rotation: FC = observer((props) => { } const firstShift = events[0]; - const firstShiftOffset = dayjs(firstShift.start).diff(calendarStartDate, 'seconds'); + const firstShiftOffset = getDateInSelectedTimezone(firstShift.start).diff( + getDateInSelectedTimezone(calendarStartDate), + 'seconds' + ); const base = 60 * 60 * 24 * days; return firstShiftOffset / base; - }, [events]); + }, [events, calendarStartDate]); return (
diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.helpers.ts b/grafana-plugin/src/containers/RotationForm/RotationForm.helpers.ts index 4876f8ad..c8aee870 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.helpers.ts +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.helpers.ts @@ -187,3 +187,24 @@ export const getDateForDatePicker = (dayJsDate: Dayjs) => { date.setSeconds(dayJsDate.second()); return date; }; + +export const dayJSAddWithDSTFixed = ({ + baseDate, + addParams, +}: { + baseDate: Dayjs; + addParams: Parameters; +}) => { + // At first we add time as usual + let newDateCandidate = baseDate.add(...addParams); + + const differenceInHoursInLocalTimezone = newDateCandidate.hour() - baseDate.hour(); + const differenceInHoursInUTC = newDateCandidate.utc().hour() - baseDate.utc().hour(); + + // But if we identify that there was a DST change before base date and the result candidate + if (differenceInHoursInLocalTimezone !== differenceInHoursInUTC) { + // then we make the resulting date to ignore DST change + newDateCandidate = newDateCandidate.subtract(differenceInHoursInUTC, 'hours'); + } + return newDateCandidate; +}; diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index d7e5450f..c7f42b68 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -24,6 +24,7 @@ import { Text } from 'components/Text/Text'; import { UserGroups } from 'components/UserGroups/UserGroups'; import { RemoteSelect } from 'containers/RemoteSelect/RemoteSelect'; import { + dayJSAddWithDSTFixed, getRepeatShiftsEveryOptions, putDownMaxValues, reduceTheLastUnitValue, @@ -279,9 +280,19 @@ export const RotationForm = observer((props: RotationFormProps) => { if (!showActiveOnSelectedPartOfDay) { if (showActiveOnSelectedDays) { - setShiftEnd(shiftStart.add(24, 'hours')); + setShiftEnd( + dayJSAddWithDSTFixed({ + baseDate: shiftStart, + addParams: [24, 'hours'], + }) + ); } else { - setShiftEnd(shiftStart.add(repeatEveryValue, repeatEveryPeriodToUnitName[value])); + setShiftEnd( + dayJSAddWithDSTFixed({ + baseDate: shiftStart, + addParams: [repeatEveryValue, repeatEveryPeriodToUnitName[value]], + }) + ); } } }, @@ -298,7 +309,12 @@ export const RotationForm = observer((props: RotationFormProps) => { setRepeatEveryValue(value); if (!showActiveOnSelectedPartOfDay) { - setShiftEnd(rotationStart.add(value, repeatEveryPeriodToUnitName[repeatEveryPeriod])); + setShiftEnd( + dayJSAddWithDSTFixed({ + baseDate: rotationStart, + addParams: [value, repeatEveryPeriodToUnitName[repeatEveryPeriod]], + }) + ); } }; @@ -307,9 +323,19 @@ export const RotationForm = observer((props: RotationFormProps) => { setRotationStart(value); setShiftStart(value); if (showActiveOnSelectedPartOfDay) { - setShiftEnd(value.add(activePeriod, 'seconds')); + setShiftEnd( + dayJSAddWithDSTFixed({ + baseDate: value, + addParams: [activePeriod, 'seconds'], + }) + ); } else { - setShiftEnd(value.add(repeatEveryValue, repeatEveryPeriodToUnitName[repeatEveryPeriod])); + setShiftEnd( + dayJSAddWithDSTFixed({ + baseDate: value, + addParams: [repeatEveryValue, repeatEveryPeriodToUnitName[repeatEveryPeriod]], + }) + ); } }, [showActiveOnSelectedPartOfDay, activePeriod, repeatEveryPeriod, repeatEveryValue] @@ -318,7 +344,12 @@ export const RotationForm = observer((props: RotationFormProps) => { const handleActivePeriodChange = useCallback( (value) => { setActivePeriod(value); - setShiftEnd(shiftStart.add(value, 'seconds')); + setShiftEnd( + dayJSAddWithDSTFixed({ + baseDate: shiftStart, + addParams: [value, 'seconds'], + }) + ); }, [shiftStart] ); @@ -337,10 +368,20 @@ export const RotationForm = observer((props: RotationFormProps) => { setShowActiveOnSelectedDays(value); if (value) { - setShiftEnd(shiftStart.add(24, 'hours')); + setShiftEnd( + dayJSAddWithDSTFixed({ + baseDate: shiftStart, + addParams: [24, 'hours'], + }) + ); } else { if (!showActiveOnSelectedPartOfDay) { - setShiftEnd(shiftStart.add(repeatEveryValue, repeatEveryPeriodToUnitName[repeatEveryPeriod])); + setShiftEnd( + dayJSAddWithDSTFixed({ + baseDate: shiftStart, + addParams: [repeatEveryValue, repeatEveryPeriodToUnitName[repeatEveryPeriod]], + }) + ); } } }, @@ -354,9 +395,19 @@ export const RotationForm = observer((props: RotationFormProps) => { if (!value) { if (showActiveOnSelectedPartOfDay) { - setShiftEnd(shiftStart.add(24, 'hours')); + setShiftEnd( + dayJSAddWithDSTFixed({ + baseDate: shiftStart, + addParams: [24, 'hours'], + }) + ); } else { - setShiftEnd(shiftStart.add(repeatEveryValue, repeatEveryPeriodToUnitName[repeatEveryPeriod])); + setShiftEnd( + dayJSAddWithDSTFixed({ + baseDate: shiftStart, + addParams: [repeatEveryValue, repeatEveryPeriodToUnitName[repeatEveryPeriod]], + }) + ); } } }, diff --git a/grafana-plugin/src/containers/RotationForm/parts/DateTimePicker.tsx b/grafana-plugin/src/containers/RotationForm/parts/DateTimePicker.tsx index 1f69c3d1..6dc88a26 100644 --- a/grafana-plugin/src/containers/RotationForm/parts/DateTimePicker.tsx +++ b/grafana-plugin/src/containers/RotationForm/parts/DateTimePicker.tsx @@ -1,7 +1,8 @@ import React from 'react'; +import { css } from '@emotion/css'; import { DateTime, dateTime } from '@grafana/data'; -import { DatePickerWithInput, TimeOfDayPicker, VerticalGroup } from '@grafana/ui'; +import { DatePickerWithInput, TimeOfDayPicker, useStyles2, VerticalGroup } from '@grafana/ui'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; import { observer } from 'mobx-react'; @@ -25,6 +26,7 @@ interface DateTimePickerProps { export const DateTimePicker = observer( ({ value: propValue, onChange, disabled, onFocus, onBlur, error }: DateTimePickerProps) => { + const styles = useStyles2(getStyles); const { timezoneStore: { getDateInSelectedTimezone }, } = useStore(); @@ -61,7 +63,7 @@ export const DateTimePicker = observer( return ( -
+
({ + wrapper: css` + display: flex; + flex-wrap: nowrap; + gap: 8px; + z-index: 2; + `, +}); diff --git a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.module.css b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.module.css index 52184f56..7369f56c 100644 --- a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.module.css +++ b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.module.css @@ -76,7 +76,7 @@ } .details { - width: 250px; + width: 300px; padding: 5px 0; } @@ -121,7 +121,7 @@ } .second-column { - width: 102px; + width: 120px; } .icon { diff --git a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx index 72313d18..43cffa8a 100644 --- a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx +++ b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx @@ -11,7 +11,7 @@ import { Text } from 'components/Text/Text'; import { WorkingHours } from 'components/WorkingHours/WorkingHours'; import { getShiftName, SHIFT_SWAP_COLOR } from 'models/schedule/schedule.helpers'; import { Event, ShiftSwap } from 'models/schedule/schedule.types'; -import { getOffsetOfCurrentUser, getTzOffsetString } from 'models/timezone/timezone.helpers'; +import { getTzOffsetString } from 'models/timezone/timezone.helpers'; import { ApiSchemas } from 'network/oncall-api/api.types'; import { useStore } from 'state/useStore'; @@ -32,8 +32,12 @@ interface ScheduleSlotProps { } const cx = cn.bind(styles); +const ONE_WEEK_IN_SECONDS = 7 * 24 * 60 * 60; export const ScheduleSlot: FC = observer((props) => { + const { + timezoneStore: { getDateInSelectedTimezone }, + } = useStore(); const { event, color, @@ -46,14 +50,12 @@ export const ScheduleSlot: FC = observer((props) => { showScheduleNameAsSlotTitle, } = props; - const start = dayjs(event.start); - const end = dayjs(event.end); + const start = getDateInSelectedTimezone(event.start); + const end = getDateInSelectedTimezone(event.end); - const duration = end.diff(start, 'seconds'); + const durationInSeconds = end.diff(start, 'seconds'); - const base = 60 * 60 * 24 * 7; - - const width = Math.max(duration / base, 0); + const width = Math.max(durationInSeconds / ONE_WEEK_IN_SECONDS, 0); const currentMoment = useMemo(() => dayjs(), []); @@ -90,7 +92,7 @@ export const ScheduleSlot: FC = observer((props) => { onShiftSwapClick={onShiftSwapClick} filters={filters} start={start} - duration={duration} + duration={durationInSeconds} color={color} currentMoment={currentMoment} showScheduleNameAsSlotTitle={showScheduleNameAsSlotTitle} @@ -411,7 +413,7 @@ const ScheduleSlotDetails = observer((props: ScheduleSlotDetailsProps) => { User's local time
{currentMoment.tz(user?.timezone).format('DD MMM, HH:mm')} -
({getTzOffsetString(currentMoment.tz(user?.timezone))}) +
({user?.timezone}) Current timezone @@ -427,9 +429,9 @@ const ScheduleSlotDetails = observer((props: ScheduleSlotDetailsProps) => { This shift
- {dayjs(event.start).utcOffset(getOffsetOfCurrentUser()).format('DD MMM, HH:mm')} + {dayjs(event.start).tz(user?.timezone).format('DD MMM, HH:mm')}
- {dayjs(event.end).utcOffset(getOffsetOfCurrentUser()).format('DD MMM, HH:mm')} + {dayjs(event.end).tz(user?.timezone).format('DD MMM, HH:mm')}
 
diff --git a/grafana-plugin/src/containers/UserTimezoneSelect/UserTimezoneSelect.tsx b/grafana-plugin/src/containers/UserTimezoneSelect/UserTimezoneSelect.tsx index be30eff6..b4aef832 100644 --- a/grafana-plugin/src/containers/UserTimezoneSelect/UserTimezoneSelect.tsx +++ b/grafana-plugin/src/containers/UserTimezoneSelect/UserTimezoneSelect.tsx @@ -1,4 +1,4 @@ -import React, { FC, useCallback, useMemo, useState } from 'react'; +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { SelectableValue } from '@grafana/data'; import { Select } from '@grafana/ui'; @@ -66,6 +66,8 @@ export const UserTimezoneSelect: FC = observer(({ sched ); }, [users, extraOptions]); + const selectedOption = options.find(({ value }) => value === store.timezoneStore.selectedTimezoneOffset); + const filterOption = useCallback((item: SelectableValue, searchQuery: string) => { const { data } = item; @@ -77,6 +79,12 @@ export const UserTimezoneSelect: FC = observer(({ sched }); }, []); + useEffect(() => { + if (selectedOption?.value) { + store.timezoneStore.setSelectedTimezoneOffset(selectedOption.value); + } + }, [options]); + const handleCreateOption = useCallback( (value: string) => { const matched = allTimezones.find((tz) => tz.toLowerCase().includes(value.toLowerCase())); @@ -110,7 +118,7 @@ export const UserTimezoneSelect: FC = observer(({ sched return (
; + return ( + + ); } function onInputReveal() { diff --git a/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.styles.ts b/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.styles.ts index 47fa1089..1ae8d33e 100644 --- a/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.styles.ts +++ b/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.styles.ts @@ -52,6 +52,7 @@ export const getIntegrationFormStyles = (theme: GrafanaTheme2) => { align-items: center; gap: 8px; margin-bottom: 24px; + padding-top: 12px; `, labels: css` diff --git a/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.tsx b/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.tsx index 063d891c..af89748d 100644 --- a/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.tsx +++ b/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.tsx @@ -18,6 +18,7 @@ import { useStyles2, } from '@grafana/ui'; import { observer } from 'mobx-react'; +import { parseUrl } from 'query-string'; import { Controller, useForm, useFormContext, FormProvider } from 'react-hook-form'; import { useHistory } from 'react-router-dom'; @@ -27,6 +28,7 @@ import { RenderConditionally } from 'components/RenderConditionally/RenderCondit import { Text } from 'components/Text/Text'; import { GSelect } from 'containers/GSelect/GSelect'; import { Labels } from 'containers/Labels/Labels'; +import { ServiceNowAuthSection } from 'containers/ServiceNowConfigDrawer/ServiceNowAuthSection'; import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers'; import { GrafanaTeam } from 'models/grafana_team/grafana_team.types'; @@ -36,14 +38,14 @@ import { IntegrationHelper, getIsBidirectionalIntegration } from 'pages/integrat import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; import { UserActions } from 'utils/authorization/authorization'; -import { PLUGIN_ROOT, URL_REGEX, generateAssignToTeamInputDescription } from 'utils/consts'; +import { PLUGIN_ROOT, generateAssignToTeamInputDescription } from 'utils/consts'; import { useIsLoading } from 'utils/hooks'; import { OmitReadonlyMembers } from 'utils/types'; import { prepareForEdit } from './IntegrationForm.helpers'; import { getIntegrationFormStyles } from './IntegrationForm.styles'; -interface FormFields { +export interface IntegrationFormFields { verbal_name?: string; description_short?: string; team?: string; @@ -115,7 +117,7 @@ export const IntegrationForm = observer( const { integration } = data; - const formMethods = useForm({ + const formMethods = useForm({ defaultValues: isNew ? { // these are the default values for creating an integration @@ -281,7 +283,7 @@ export const IntegrationForm = observer( {isTableView && } - +
ServiceNow configuration
@@ -334,9 +336,7 @@ export const IntegrationForm = observer( )} /> - + {} - - async function onFormSubmit(formData: FormFields): Promise { + async function onFormSubmit(formData: IntegrationFormFields): Promise { const labels = labelsRef.current?.getValue(); const data: OmitReadonlyMembers = { @@ -489,7 +486,7 @@ const GrafanaContactPoint = observer( setValue, formState: { errors }, register, - } = useFormContext(); + } = useFormContext(); useEffect(() => { (async function () { diff --git a/grafana-plugin/src/containers/ServiceNowConfigDrawer/CompleteServiceNowConfigModal.tsx b/grafana-plugin/src/containers/ServiceNowConfigDrawer/CompleteServiceNowConfigModal.tsx new file mode 100644 index 00000000..542682b9 --- /dev/null +++ b/grafana-plugin/src/containers/ServiceNowConfigDrawer/CompleteServiceNowConfigModal.tsx @@ -0,0 +1,124 @@ +import React, { useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, HorizontalGroup, Modal, useStyles2 } from '@grafana/ui'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { ApiSchemas } from 'network/oncall-api/api.types'; +import { useCurrentIntegration } from 'pages/integration/OutgoingTab/OutgoingTab.hooks'; +import { useStore } from 'state/useStore'; +import { OmitReadonlyMembers } from 'utils/types'; +import { openNotification } from 'utils/utils'; + +import { getCommonServiceNowConfigStyles } from './ServiceNow.styles'; +import { ServiceNowStatusSection } from './ServiceNowStatusSection'; +import { ServiceNowTokenSection } from './ServiceNowTokenSection'; + +interface CompleteServiceNowConfigModalProps { + onHide: () => void; +} + +interface FormFields { + additional_settings: ApiSchemas['AlertReceiveChannel']['additional_settings']; +} + +export const CompleteServiceNowModal: React.FC = ({ onHide }) => { + const { alertReceiveChannelStore } = useStore(); + const integration = useCurrentIntegration(); + + const formMethods = useForm({ + values: { + additional_settings: { + ...integration.additional_settings, + }, + }, + }); + + const [isFormActionsDisabled, setIsFormActionsDisabled] = useState(false); + + const styles = useStyles2(getStyles); + const { handleSubmit } = formMethods; + + const { id } = integration; + + return ( + + +
+
+ +
+ +
+ +
+ +
+ + + + +
+
+
+
+ ); + + async function onFormAcknowledge() { + setIsFormActionsDisabled(true); + + try { + await alertReceiveChannelStore.update({ + id, + data: { + ...integration, + additional_settings: { + // use existing fields + ...integration.additional_settings, + is_configured: true, + }, + }, + }); + + onHide(); + } catch (ex) { + setIsFormActionsDisabled(false); + } + } + + async function onFormSubmit(formData: FormFields) { + setIsFormActionsDisabled(true); + + const data: OmitReadonlyMembers = { + ...integration, + ...formData, + + additional_settings: { + ...integration.additional_settings, + ...formData.additional_settings, + state_mapping: { + ...formData.additional_settings.state_mapping, + }, + is_configured: true, + }, + }; + + try { + await alertReceiveChannelStore.update({ id, data }); + openNotification('You successfully completed your ServiceNow configuration'); + onHide(); + } finally { + setIsFormActionsDisabled(false); + } + } +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + ...getCommonServiceNowConfigStyles(theme), + }; +}; diff --git a/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNow.styles.tsx b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNow.styles.tsx new file mode 100644 index 00000000..b65d12c1 --- /dev/null +++ b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNow.styles.tsx @@ -0,0 +1,37 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; + +export const getCommonServiceNowConfigStyles = (theme: GrafanaTheme2) => { + return { + border: css` + padding: 12px; + margin-bottom: 24px; + border: 1px solid ${theme.colors.border.weak}; + border-radius: ${theme.shape.radius.default}; + `, + + tokenContainer: css` + display: flex; + width: 100%; + gap: 8px; + `, + + tokenInput: css` + height: 32px !important; + `, + + buttonInputHeight: css` + input { + height: 32px !important; + } + `, + + tokenIcons: css` + top: 10px !important; + `, + + loader: css` + margin-bottom: 0; + `, + }; +}; diff --git a/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowAuthSection.tsx b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowAuthSection.tsx new file mode 100644 index 00000000..b1566589 --- /dev/null +++ b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowAuthSection.tsx @@ -0,0 +1,66 @@ +import React, { useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, HorizontalGroup, Icon, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; +import { observer } from 'mobx-react'; +import { useFormContext } from 'react-hook-form'; + +import { RenderConditionally } from 'components/RenderConditionally/RenderConditionally'; +import { Text } from 'components/Text/Text'; +import { IntegrationFormFields } from 'containers/IntegrationForm/IntegrationForm'; +import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers'; +import { ApiSchemas } from 'network/oncall-api/api.types'; +import { useCurrentIntegration } from 'pages/integration/OutgoingTab/OutgoingTab.hooks'; +import { OmitReadonlyMembers } from 'utils/types'; + +import { getCommonServiceNowConfigStyles } from './ServiceNow.styles'; +import { ServiceNowFormFields } from './ServiceNowStatusSection'; + +export const ServiceNowAuthSection: React.FC = observer(() => { + const { getValues } = useFormContext(); + + const currentIntegration = useCurrentIntegration(); + const [isAuthTestRunning, setIsAuthTestRunning] = useState(false); + const [authTestResult, setAuthTestResult] = useState(undefined); + const styles = useStyles2(getStyles); + + return ( +
+ + +
+ + + + + + + {authTestResult ? 'Connection OK' : 'Connection failed'} + + + +
+
+
+ ); + + async function onAuthTest() { + const data: OmitReadonlyMembers = { + integration: currentIntegration ? currentIntegration.integration : 'servicenow', + ...getValues(), + }; + + setIsAuthTestRunning(true); + const result = await AlertReceiveChannelHelper.testServiceNowAuthentication({ data }); + setAuthTestResult(result); + setIsAuthTestRunning(false); + } +}); + +const getStyles = (theme: GrafanaTheme2) => { + return { + ...getCommonServiceNowConfigStyles(theme), + }; +}; diff --git a/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowConfig.helpers.ts b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowConfig.helpers.ts new file mode 100644 index 00000000..d479942e --- /dev/null +++ b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowConfig.helpers.ts @@ -0,0 +1,36 @@ +import { SelectableValue } from '@grafana/data'; +import { UseFormGetValues } from 'react-hook-form'; + +import { AlertReceiveChannelStore } from 'models/alert_receive_channel/alert_receive_channel'; +import { OnCallAGStatus } from 'utils/consts'; + +import { ServiceNowFormFields } from './ServiceNowStatusSection'; + +export class ServiceNowHelper { + static getAvailableStatusOptions({ + getValues, + currentAction, + alertReceiveChannelStore, + }: { + currentAction: OnCallAGStatus; + getValues: UseFormGetValues; + alertReceiveChannelStore: AlertReceiveChannelStore; + }): SelectableValue[] { + const stateMapping = getValues()?.additional_settings?.state_mapping || {}; + const keys = Object.keys(stateMapping); + + // values are list of array-like values [label, id] + const values = keys + .map((k) => stateMapping[k]) + .filter(Boolean) + .map((arr) => arr[1]); + const statusList = (alertReceiveChannelStore.serviceNowStatusList || []).map(([name, id]) => ({ id, name })); + + return statusList + .filter((status) => values.indexOf(status.id) === -1 || stateMapping?.[currentAction]?.[0] === status.name) + .map((status) => ({ + value: status.id, + label: status.name, + })); + } +} diff --git a/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowConfigDrawer.tsx b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowConfigDrawer.tsx index 43c64a8b..da306241 100644 --- a/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowConfigDrawer.tsx +++ b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowConfigDrawer.tsx @@ -1,394 +1,165 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { css } from '@emotion/css'; -import { GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { - Drawer, - Field, - HorizontalGroup, - Input, - VerticalGroup, - Icon, - useStyles2, - Button, - LoadingPlaceholder, - Select, - SelectBaseProps, -} from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Drawer, Field, HorizontalGroup, Input, VerticalGroup, Icon, useStyles2, Button } from '@grafana/ui'; import { observer } from 'mobx-react'; -import { Controller, useForm } from 'react-hook-form'; +import { parseUrl } from 'query-string'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; -import { IntegrationInputField } from 'components/IntegrationInputField/IntegrationInputField'; -import { RenderConditionally } from 'components/RenderConditionally/RenderConditionally'; import { Text } from 'components/Text/Text'; import { ActionKey } from 'models/loader/action-keys'; import { ApiSchemas } from 'network/oncall-api/api.types'; import { useCurrentIntegration } from 'pages/integration/OutgoingTab/OutgoingTab.hooks'; import { useStore } from 'state/useStore'; -import { URL_REGEX } from 'utils/consts'; import { useIsLoading } from 'utils/hooks'; import { OmitReadonlyMembers } from 'utils/types'; import { openNotification } from 'utils/utils'; +import { getCommonServiceNowConfigStyles } from './ServiceNow.styles'; +import { ServiceNowAuthSection } from './ServiceNowAuthSection'; +import { ServiceNowStatusSection } from './ServiceNowStatusSection'; +import { ServiceNowTokenSection } from './ServiceNowTokenSection'; + interface ServiceNowConfigurationDrawerProps { onHide(): void; } -enum OnCallAGStatus { - Firing = 'Firing', - Resolved = 'Resolved', - Silenced = 'Silenced', - Acknowledged = 'Acknowledged', -} - interface FormFields { additional_settings: ApiSchemas['AlertReceiveChannel']['additional_settings']; } -interface StatusMapping { - [OnCallAGStatus.Firing]?: string; - [OnCallAGStatus.Resolved]?: string; - [OnCallAGStatus.Silenced]?: string; - [OnCallAGStatus.Acknowledged]?: string; -} - export const ServiceNowConfigDrawer: React.FC = observer(({ onHide }) => { const styles = useStyles2(getStyles); const { alertReceiveChannelStore } = useStore(); - const integration = useCurrentIntegration(); + const currentIntegration = useCurrentIntegration(); - const [isAuthTestRunning, setIsAuthTestRunning] = useState(false); - const [authTestResult, setAuthTestResult] = useState(undefined); - const [statusMapping, setStatusMapping] = useState({}); - - const { - control, - handleSubmit, - setValue, - formState: { errors }, - } = useForm({ + const formMethods = useForm({ defaultValues: { - additional_settings: { ...integration.additional_settings }, + additional_settings: { ...currentIntegration.additional_settings }, }, mode: 'onChange', }); - const serviceNowAPIToken = 'http://url.com'; + const { + control, + handleSubmit, + formState: { errors }, + } = formMethods; + const isLoading = useIsLoading(ActionKey.UPDATE_INTEGRATION); - useEffect(() => { - (async () => { - await alertReceiveChannelStore.fetchServiceNowListOfStatus(); - })(); - }, []); - - const selectCommonProps: Partial> = { - backspaceRemovesValue: true, - isClearable: true, - placeholder: 'Not Selected', - }; - return ( <> -
-
- ( - - - - )} - /> + + +
+ ( + + + + )} + /> - ( - - - - )} - /> + ( + + + + )} + /> - ( - - - - )} - /> + ( + + + + )} + /> - - -
- - - + +
- - - {authTestResult ? 'Connection OK' : 'Connection failed'} - - - -
- -
+
+ +
-
- - - - Status Mapping +
+ + + + Labels Mapping + + + + + + Description for such object and{' '} + + link to documentation + - - + +
- - - - - - - - - - +
+ +
- - - - - - - - - - - - - - - - - - - -
OnCall Alert group statusServiceNow incident status
Firing - ( -
Acknowledged - ( -
Resolved - ( -
Silenced - ( -
-
-
- -
- - - - Labels Mapping - - - - - - Description for such object and{' '} - - link to documentation - - - -
- -
- - - - ServiceNow API Token - - - - - - Description for such object and{' '} - - link to documentation - - - -
- - -
-
-
- -
- - - - -
-
+ + +
+ + ); - function onTokenRegenerate() { - // Call API and reset token - } - - function getAvailableStatusOptions(currentAction: OnCallAGStatus) { - const keys = Object.keys(statusMapping); - const values = keys.map((k) => statusMapping[k]).filter(Boolean); - - return (alertReceiveChannelStore.serviceNowStatusList || []) - .filter((status) => values.indexOf(status.name) === -1 || statusMapping[currentAction] === status.name) - .map((status) => ({ - value: status.id, - label: status.name, - })); - } - - function onStatusSelectChange(option: SelectableValue, action: OnCallAGStatus) { - setStatusMapping({ - ...statusMapping, - [action]: option?.label, - }); - } - - function onAuthTest() { - return new Promise(() => { - setIsAuthTestRunning(true); - setTimeout(() => { - setIsAuthTestRunning(false); - setAuthTestResult(true); - }, 500); - }); - } - function validateURL(urlFieldValue: string): string | boolean { - const regex = new RegExp(URL_REGEX, 'i'); - return !regex.test(urlFieldValue) ? 'Instance URL is invalid' : true; + return !parseUrl(urlFieldValue) ? 'Instance URL is invalid' : true; } async function onFormSubmit(formData: FormFields): Promise { const data: OmitReadonlyMembers = { - ...integration, + ...currentIntegration, ...formData, }; - await alertReceiveChannelStore.update({ id: integration.id, data }); + await alertReceiveChannelStore.update({ id: currentIntegration.id, data }); openNotification('ServiceNow configuration has been updated'); @@ -398,27 +169,7 @@ export const ServiceNowConfigDrawer: React.FC { return { - tokenContainer: css` - display: flex; - width: 100%; - gap: 8px; - `, - - tokenInput: css` - height: 32px !important; - padding-top: 4px !important; - `, - - tokenIcons: css` - top: 10px !important; - `, - - border: css` - padding: 12px; - margin-bottom: 24px; - border: 1px solid ${theme.colors.border.weak}; - border-radius: ${theme.shape.radius.default}; - `, + ...getCommonServiceNowConfigStyles(theme), loader: css` margin-bottom: 0; diff --git a/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowStatusSection.tsx b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowStatusSection.tsx new file mode 100644 index 00000000..9bf749ea --- /dev/null +++ b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowStatusSection.tsx @@ -0,0 +1,204 @@ +import React, { useEffect, useReducer } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { HorizontalGroup, Select, SelectBaseProps, VerticalGroup } from '@grafana/ui'; +import { observer } from 'mobx-react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { Text } from 'components/Text/Text'; +import { ApiSchemas } from 'network/oncall-api/api.types'; +import { useCurrentIntegration } from 'pages/integration/OutgoingTab/OutgoingTab.hooks'; +import { useStore } from 'state/useStore'; +import { OnCallAGStatus } from 'utils/consts'; + +import { ServiceNowHelper } from './ServiceNowConfig.helpers'; + +export interface ServiceNowStatusMapping { + [OnCallAGStatus.Firing]?: string; + [OnCallAGStatus.Resolved]?: string; + [OnCallAGStatus.Silenced]?: string; + [OnCallAGStatus.Acknowledged]?: string; +} + +export interface ServiceNowFormFields { + additional_settings: ApiSchemas['AlertReceiveChannel']['additional_settings']; +} + +export const ServiceNowStatusSection: React.FC = observer(() => { + const { control, setValue, getValues } = useFormContext(); + + const [, forceUpdate] = useReducer((x) => x + 1, 0); + + const { alertReceiveChannelStore } = useStore(); + const currentIntegration = useCurrentIntegration(); + const { id } = currentIntegration; + + useEffect(() => { + (async () => { + await alertReceiveChannelStore.fetchServiceNowStatusList({ id }); + forceUpdate(); + })(); + }, []); + + const selectCommonProps: Partial> = { + backspaceRemovesValue: true, + isClearable: true, + placeholder: 'Not Selected', + }; + + return ( + + + + Status Mapping + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ OnCall Alert group status + + ServiceNow incident status +
Firing + ( +
Acknowledged + ( +
Resolved + ( +
Silenced + ( +
+
+ ); +}); diff --git a/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowTokenSection.tsx b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowTokenSection.tsx new file mode 100644 index 00000000..011ac846 --- /dev/null +++ b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowTokenSection.tsx @@ -0,0 +1,90 @@ +import React, { useEffect, useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, HorizontalGroup, LoadingPlaceholder, VerticalGroup, useStyles2 } from '@grafana/ui'; +import { observer } from 'mobx-react'; + +import { IntegrationInputField } from 'components/IntegrationInputField/IntegrationInputField'; +import { RenderConditionally } from 'components/RenderConditionally/RenderConditionally'; +import { Text } from 'components/Text/Text'; +import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers'; +import { ActionKey } from 'models/loader/action-keys'; +import { useCurrentIntegration } from 'pages/integration/OutgoingTab/OutgoingTab.hooks'; +import { useIsLoading } from 'utils/hooks'; + +import { getCommonServiceNowConfigStyles } from './ServiceNow.styles'; + +export const ServiceNowTokenSection: React.FC = observer(() => { + const styles = useStyles2(getStyles); + const { id } = useCurrentIntegration(); + const [isExistingToken, setIsExistingToken] = useState(undefined); + const [currentToken, setCurrentToken] = useState(undefined); + const isLoading = useIsLoading(ActionKey.UPDATE_SERVICENOW_TOKEN); + + useEffect(() => { + (async function () { + const hasToken = await AlertReceiveChannelHelper.checkIfServiceNowHasToken({ id }); + setIsExistingToken(hasToken); + })(); + }, []); + + return ( + + + + ServiceNow backsync API token + + + + + Description for such object and{' '} + + link to documentation + + + + + + + + +
+ + +
+
+
+ ); + + async function onTokenGenerate() { + const res = await AlertReceiveChannelHelper.generateServiceNowToken({ id }); + + if (res?.token) { + setCurrentToken(res.token); + } + } +}); + +const getStyles = (theme: GrafanaTheme2) => { + return { + ...getCommonServiceNowConfigStyles(theme), + }; +}; diff --git a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.helpers.ts b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.helpers.ts index 9de5be3d..4e131d03 100644 --- a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.helpers.ts +++ b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.helpers.ts @@ -1,9 +1,12 @@ import { ChannelFilter } from 'models/channel_filter/channel_filter.types'; import { GrafanaTeam } from 'models/grafana_team/grafana_team.types'; +import { ActionKey } from 'models/loader/action-keys'; import { makeRequest } from 'network/network'; import { ApiSchemas } from 'network/oncall-api/api.types'; import { onCallApi } from 'network/oncall-api/http-client'; import { SelectOption } from 'state/types'; +import { AutoLoadingState, WithGlobalNotification } from 'utils/decorators'; +import { OmitReadonlyMembers } from 'utils/types'; import { showApiError } from 'utils/utils'; import { AlertReceiveChannelStore } from './alert_receive_channel'; @@ -43,6 +46,49 @@ export class AlertReceiveChannelHelper { : undefined; } + static async checkIfServiceNowHasToken({ id }: { id: ApiSchemas['AlertReceiveChannel']['id'] }) { + try { + const response = await onCallApi({ skipErrorHandling: true }).GET('/alert_receive_channels/{id}/api_token/', { + params: { path: { id } }, + }); + return response?.response.status === 200; + } catch (ex) { + return false; + } + } + + @AutoLoadingState(ActionKey.UPDATE_SERVICENOW_TOKEN) + @WithGlobalNotification({ failure: 'There was an error generating the token. Please try again' }) + static async generateServiceNowToken({ + id, + skipErrorHandling, + }: { + id: ApiSchemas['AlertReceiveChannel']['id']; + skipErrorHandling?: boolean; + }): Promise { + const result = await onCallApi({ skipErrorHandling }).POST('/alert_receive_channels/{id}/api_token/', { + params: { path: { id } }, + }); + + return result.data; + } + + static async testServiceNowAuthentication({ + data, + }: { + data: OmitReadonlyMembers; + }) { + try { + const result = await onCallApi({ skipErrorHandling: false }).POST('/alert_receive_channels/test_connection/', { + body: data as ApiSchemas['AlertReceiveChannelUpdate'], + params: {}, + }); + return result?.response.status === 200; + } catch (ex) { + return false; + } + } + static getIntegrationSelectOption( store: AlertReceiveChannelStore, alertReceiveChannel: Partial diff --git a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts index 5717c998..2c79c5f2 100644 --- a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts +++ b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts @@ -14,7 +14,7 @@ import { RootBaseStore } from 'state/rootBaseStore/RootBaseStore'; import { AutoLoadingState, WithGlobalNotification } from 'utils/decorators'; import { OmitReadonlyMembers } from 'utils/types'; -import { AlertReceiveChannelCounters, ContactPoint, ServiceNowStatus } from './alert_receive_channel.types'; +import { AlertReceiveChannelCounters, ContactPoint } from './alert_receive_channel.types'; export class AlertReceiveChannelStore { rootStore: RootBaseStore; @@ -37,7 +37,7 @@ export class AlertReceiveChannelStore { alertReceiveChannelOptions: Array = []; templates: { [id: string]: AlertTemplatesDTO[] } = {}; connectedContactPoints: { [id: string]: ContactPoint[] } = {}; - serviceNowStatusList: ServiceNowStatus[]; + serviceNowStatusList: Array<[string, string]>; constructor(rootStore: RootBaseStore) { makeAutoObservable(this, undefined, { autoBind: true }); @@ -105,23 +105,21 @@ export class AlertReceiveChannelStore { return alertReceiveChannel.data; } - async fetchServiceNowListOfStatus(): Promise { - this.serviceNowStatusList = [ - { - id: 1, - name: 'Resolved', - }, - { - id: 2, - name: 'In Progress', - }, - { - id: 3, - name: 'New', - }, - ]; + async fetchServiceNowStatusList({ + id, + skipErrorHandling, + }: { + id: ApiSchemas['AlertReceiveChannel']['id']; + skipErrorHandling?: boolean; + }): Promise { + const statusList = await onCallApi({ skipErrorHandling }).GET('/alert_receive_channels/{id}/status_options/', { + params: { path: { id } }, + }); - return Promise.resolve(); + runInAction(() => { + // @ts-ignore // looks like wrong schema + this.serviceNowStatusList = statusList.data; + }); } async fetchItems(query: any = '') { diff --git a/grafana-plugin/src/models/loader/action-keys.ts b/grafana-plugin/src/models/loader/action-keys.ts index 270a5969..0b093adb 100644 --- a/grafana-plugin/src/models/loader/action-keys.ts +++ b/grafana-plugin/src/models/loader/action-keys.ts @@ -9,6 +9,7 @@ export enum ActionKey { FETCH_INCIDENTS_POLLING = 'FETCH_INCIDENTS_POLLING', FETCH_INCIDENTS_AND_STATS = 'FETCH_INCIDENTS_AND_STATS', UPDATE_FILTERS_AND_FETCH_INCIDENTS = 'UPDATE_FILTERS_AND_FETCH_INCIDENTS', + UPDATE_SERVICENOW_TOKEN = 'UPDATE_SERVICENOW_TOKEN', FETCH_INTEGRATIONS = 'FETCH_INTEGRATIONS', TEST_CALL_OR_SMS = 'TEST_CALL_OR_SMS', FETCH_INTEGRATION_CHANNELS = 'FETCH_INTEGRATION_CHANNELS', diff --git a/grafana-plugin/src/network/oncall-api/autogenerated-api.types.d.ts b/grafana-plugin/src/network/oncall-api/autogenerated-api.types.d.ts index 7b468869..93df5a6d 100644 --- a/grafana-plugin/src/network/oncall-api/autogenerated-api.types.d.ts +++ b/grafana-plugin/src/network/oncall-api/autogenerated-api.types.d.ts @@ -297,6 +297,23 @@ export interface paths { patch?: never; trace?: never; }; + '/alert_receive_channels/{id}/test_connection/': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Internal API endpoints for alert receive channels (integrations). */ + post: operations['alert_receive_channels_test_connection_create_2']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/alert_receive_channels/{id}/webhooks/': { parameters: { query?: never; @@ -2944,6 +2961,33 @@ export interface operations { }; }; }; + alert_receive_channels_test_connection_create_2: { + parameters: { + query?: never; + header?: never; + path: { + /** @description A string identifying this alert receive channel. */ + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': components['schemas']['AlertReceiveChannelUpdate']; + 'application/x-www-form-urlencoded': components['schemas']['AlertReceiveChannelUpdate']; + 'multipart/form-data': components['schemas']['AlertReceiveChannelUpdate']; + }; + }; + responses: { + /** @description No response body */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; alert_receive_channels_webhooks_list: { parameters: { query?: never; diff --git a/grafana-plugin/src/pages/incident/Incident.tsx b/grafana-plugin/src/pages/incident/Incident.tsx index 185a90ee..8294cbdc 100644 --- a/grafana-plugin/src/pages/incident/Incident.tsx +++ b/grafana-plugin/src/pages/incident/Incident.tsx @@ -276,6 +276,7 @@ class _IncidentPage extends React.Component; const sourceLink = incident?.render_for_web?.source_link; + const isServiceNow = incident?.alert_receive_channel?.integration === 'servicenow'; return ( @@ -391,6 +392,15 @@ class _IncidentPage extends React.Component + {isServiceNow && ( + + )} + void; } -type IntegrationDrawerKey = 'servicenow'; +type IntegrationDrawerKey = 'servicenow' | 'completeConfig'; const IntegrationActions: React.FC = ({ alertReceiveChannel, @@ -831,6 +832,7 @@ const IntegrationActions: React.FC = ({ onConfirm: () => void; }>(undefined); + const [isCompleteServiceNowConfigOpen, setIsCompleteServiceNowConfigOpen] = useState(false); const [isIntegrationSettingsOpen, setIsIntegrationSettingsOpen] = useState(false); const [isLabelsFormOpen, setLabelsFormOpen] = useState(false); const [isHeartbeatFormOpen, setIsHeartbeatFormOpen] = useState(false); @@ -844,6 +846,11 @@ const IntegrationActions: React.FC = ({ const { id } = alertReceiveChannel; + useEffect(() => { + /* ServiceNow Only */ + openServiceNowCompleteConfigurationDrawer(); + }, []); + return ( <> {confirmModal && ( @@ -868,7 +875,11 @@ const IntegrationActions: React.FC = ({ /> )} - {getIsDrawerOpened('servicenow') && closeDrawer()} />} + {getIsDrawerOpened('servicenow') && } + + {isCompleteServiceNowConfigOpen && ( + setIsCompleteServiceNowConfigOpen(false)} /> + )} {isIntegrationSettingsOpen && ( = ({ ); + function openServiceNowCompleteConfigurationDrawer() { + const isServiceNow = getIsBidirectionalIntegration(alertReceiveChannel); + const isConfigured = alertReceiveChannel.additional_settings?.is_configured; + if (isServiceNow && !isConfigured) { + setIsCompleteServiceNowConfigOpen(true); + } + } + function getMigrationDisplayName() { const name = alertReceiveChannel.integration.toLowerCase().replace('legacy_', ''); switch (name) { diff --git a/grafana-plugin/src/utils/consts.ts b/grafana-plugin/src/utils/consts.ts index b4fe7caf..2f607f35 100644 --- a/grafana-plugin/src/utils/consts.ts +++ b/grafana-plugin/src/utils/consts.ts @@ -77,4 +77,9 @@ export const TEXT_ELLIPSIS_CLASS = 'overflow-child'; export const INCIDENT_HORIZONTAL_SCROLLING_STORAGE = 'isIncidentalTableHorizontalScrolling'; export const IRM_TAB = 'IRM'; -export const URL_REGEX = /^((https?|ftp|smtp):\/\/)?(www.)?[a-z0-9]+\.[a-z]+(\/[a-zA-Z0-9#]+\/?)*$/; +export enum OnCallAGStatus { + Firing = 'firing', + Resolved = 'resolved', + Silenced = 'silenced', + Acknowledged = 'acknowledged', +} diff --git a/grafana-plugin/src/utils/decorators.ts b/grafana-plugin/src/utils/decorators.ts index 1e2d826c..933d7b64 100644 --- a/grafana-plugin/src/utils/decorators.ts +++ b/grafana-plugin/src/utils/decorators.ts @@ -9,7 +9,7 @@ export function AutoLoadingState(actionKey: string) { LoaderStore.setLoadingAction(actionKey, true); nbOfPendingActions++; try { - await originalFunction.apply(this, args); + return await originalFunction.apply(this, args); } finally { nbOfPendingActions--; // if there are other pending actions with the same key, wait till the last one is done From 95063250ff5005126da2e3207670b0948717498e Mon Sep 17 00:00:00 2001 From: Maxim Mordasov Date: Tue, 26 Mar 2024 16:05:33 +0000 Subject: [PATCH 8/9] Fix working hours over the schedule events rendering (#4113) # What this PR does Fixes working hours rendering ## Which issue(s) this PR closes ## Checklist - [ ] Unit, integration, and e2e (if applicable) tests updated - [ ] Documentation added (or `pr:no public docs` PR label added if not required) - [ ] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes. --- grafana-plugin/src/components/WorkingHours/WorkingHours.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx b/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx index aafe6952..4ba916e1 100644 --- a/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx +++ b/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx @@ -54,7 +54,7 @@ export const WorkingHours: FC = (props) => { key={index} x={`${duration > 0 ? (start * 100) / duration : 0}%`} // x/0 is NaN y={0} - width={`${duration > 0 ? (diff * 100) / duration : 0} %`} // x/0 is NaN + width={`${duration > 0 ? (diff * 100) / duration : 0}%`} // x/0 is NaN height="100%" fill={light ? 'url(#stripes_light)' : 'url(#stripes)'} /> From b7e2dc14f8149a581a7576029ba5ee140b646180 Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Tue, 26 Mar 2024 17:20:05 +0000 Subject: [PATCH 9/9] Fix ratelimit bug (#4108) # What this PR does Fixes a bug in the ratelimit logic when integration-specific ratelimit 429s are still counted towards the organization-wide ratelimit. ## Which issue(s) this PR closes Related to https://github.com/grafana/support-escalations/issues/9579 ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes. --- .../integrations/mixins/ratelimit_mixin.py | 19 ++++--- .../apps/integrations/tests/test_ratelimit.py | 54 +++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/engine/apps/integrations/mixins/ratelimit_mixin.py b/engine/apps/integrations/mixins/ratelimit_mixin.py index 808b4276..93edbb67 100644 --- a/engine/apps/integrations/mixins/ratelimit_mixin.py +++ b/engine/apps/integrations/mixins/ratelimit_mixin.py @@ -14,8 +14,8 @@ from apps.integrations.tasks import start_notify_about_integration_ratelimit logger = logging.getLogger(__name__) -RATELIMIT_INTEGRATION = 300 -RATELIMIT_TEAM = 900 +RATELIMIT_INTEGRATION = "300/5m" +RATELIMIT_TEAM = "900/5m" RATELIMIT_REASON_INTEGRATION = "channel" RATELIMIT_REASON_TEAM = "team" @@ -124,7 +124,10 @@ class RateLimitMixin(ABC, View): raise NotImplementedError def execute_rate_limit_with_notification_logic(self, *args, **kwargs): - self.execute_rate_limit(self.request) + try: + self.execute_rate_limit(self.request) + except Ratelimited: + pass self.notify() @property @@ -155,12 +158,13 @@ class IntegrationHeartBeatRateLimitMixin(RateLimitMixin, View): @ratelimit( key=get_rate_limit_per_channel_key, - rate=str(RATELIMIT_INTEGRATION) + "/5m", + rate=RATELIMIT_INTEGRATION, group="integration", reason=RATELIMIT_REASON_INTEGRATION, + block=True, # use block=True so integration rate limit 429s are not counted towards the team rate limit ) @ratelimit( - key=get_rate_limit_per_team_key, rate=str(RATELIMIT_TEAM) + "/5m", group="team", reason=RATELIMIT_REASON_TEAM + key=get_rate_limit_per_team_key, rate=RATELIMIT_TEAM, group="team", reason=RATELIMIT_REASON_TEAM, block=True ) def execute_rate_limit(self, *args, **kwargs): pass @@ -190,12 +194,13 @@ class IntegrationRateLimitMixin(RateLimitMixin, View): @ratelimit( key=get_rate_limit_per_channel_key, - rate=str(RATELIMIT_INTEGRATION) + "/5m", + rate=RATELIMIT_INTEGRATION, group="integration", reason=RATELIMIT_REASON_INTEGRATION, + block=True, # use block=True so integration rate limit 429s are not counted towards the team rate limit ) @ratelimit( - key=get_rate_limit_per_team_key, rate=str(RATELIMIT_TEAM) + "/5m", group="team", reason=RATELIMIT_REASON_TEAM + key=get_rate_limit_per_team_key, rate=RATELIMIT_TEAM, group="team", reason=RATELIMIT_REASON_TEAM, block=True ) def execute_rate_limit(self, *args, **kwargs): pass diff --git a/engine/apps/integrations/tests/test_ratelimit.py b/engine/apps/integrations/tests/test_ratelimit.py index 5598c4f7..c38edec2 100644 --- a/engine/apps/integrations/tests/test_ratelimit.py +++ b/engine/apps/integrations/tests/test_ratelimit.py @@ -4,8 +4,11 @@ import pytest from django.core.cache import cache from django.test import Client from django.urls import reverse +from rest_framework import status from apps.alerts.models import AlertReceiveChannel +from apps.integrations.mixins import IntegrationRateLimitMixin +from apps.integrations.mixins.ratelimit_mixin import RATELIMIT_INTEGRATION @pytest.fixture(autouse=True) @@ -96,3 +99,54 @@ def test_ratelimit_integration_heartbeats( response = c.get(url) assert response.status_code == 429 + + +# mocking rate limits to 1/m per integration and 3/m per organization +@mock.patch("ratelimit.utils._split_rate", new=lambda rate: (1, 60) if rate == RATELIMIT_INTEGRATION else (3, 60)) +@pytest.mark.django_db +def test_ratelimit_integration_and_organization( + make_organization, + make_alert_receive_channel, +): + organization = make_organization() + + integrations = [ + make_alert_receive_channel(organization, integration=AlertReceiveChannel.INTEGRATION_WEBHOOK) for _ in range(4) + ] + urls = [ + reverse( + "integrations:universal", + kwargs={ + "integration_type": AlertReceiveChannel.INTEGRATION_WEBHOOK, + "alert_channel_key": integration.token, + }, + ) + for integration in integrations + ] + + client = Client() + + response = client.post(urls[0]) + assert response.status_code == status.HTTP_200_OK + + response = client.post(urls[0]) + assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS + assert response.content.decode() == IntegrationRateLimitMixin.TEXT_INTEGRATION.format( + integration=integrations[0].verbal_name + ) + + response = client.post(urls[1]) + assert response.status_code == status.HTTP_200_OK + + response = client.post(urls[1]) + assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS + assert response.content.decode() == IntegrationRateLimitMixin.TEXT_INTEGRATION.format( + integration=integrations[1].verbal_name + ) + + response = client.post(urls[2]) + assert response.status_code == status.HTTP_200_OK + + response = client.post(urls[3]) + assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS + assert response.content.decode() == IntegrationRateLimitMixin.TEXT_WORKSPACE