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/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", ] diff --git a/engine/apps/api/views/alert_receive_channel.py b/engine/apps/api/views/alert_receive_channel.py index c9b341b1..871353bb 100644 --- a/engine/apps/api/views/alert_receive_channel.py +++ b/engine/apps/api/views/alert_receive_channel.py @@ -338,16 +338,7 @@ class AlertReceiveChannelView( def test_connection(self, request, pk): return self._test_connection(request, pk=pk) - @extend_schema( - responses=inline_serializer( - name="AlertReceiveChannelBacksyncStatusOptions", - fields={ - "value": serializers.CharField(), - "display_name": serializers.CharField(), - }, - many=True, - ), - ) + @extend_schema(responses={status.HTTP_200_OK: resolve_type_hint(list[tuple[str, str]])}) @action(detail=True, methods=["get"]) def status_options(self, request, pk): instance = self.get_object() 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 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/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/e2e-tests/utils/alertGroup.ts b/grafana-plugin/e2e-tests/utils/alertGroup.ts index 2711c896..77696e55 100644 --- a/grafana-plugin/e2e-tests/utils/alertGroup.ts +++ b/grafana-plugin/e2e-tests/utils/alertGroup.ts @@ -101,6 +101,6 @@ export const verifyThatAlertGroupIsTriggered = async ( export const resolveFiringAlert = async (page: Page) => { await goToOnCallPage(page, 'alert-groups'); - await page.getByText('Firing').nth(1).click(); + await page.getByText('Firing').nth(2).click({force: true}); await page.getByLabel('Context menu').getByText('Resolve').click(); }; 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, diff --git a/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.module.scss b/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.module.scss index c134cce0..bda031a3 100644 --- a/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.module.scss +++ b/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.module.scss @@ -21,3 +21,9 @@ text-overflow: ellipsis; } } + +.button-input-height { + input { + height: 32px; + } +} \ No newline at end of file diff --git a/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.tsx b/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.tsx index 0ef46bee..4ea0aed9 100644 --- a/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.tsx +++ b/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.tsx @@ -16,6 +16,7 @@ interface IntegrationInputFieldProps { className?: string; inputClassName?: string; iconsClassName?: string; + placeholder?: string; } const cx = cn.bind(styles); @@ -27,6 +28,7 @@ export const IntegrationInputField: React.FC = ({ showCopy = true, showExternal = true, className, + placeholder = '', inputClassName = '', iconsClassName = '', }) => { @@ -47,7 +49,14 @@ export const IntegrationInputField: React.FC = ({ ); function renderInputField() { - return ; + return ( + + ); } function onInputReveal() { 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)'} /> 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/RemoteFilters/RemoteFilters.helpers.ts b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.helpers.ts index 619ac98d..8969bd20 100644 --- a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.helpers.ts +++ b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.helpers.ts @@ -15,7 +15,7 @@ export function parseFilters( filterOptions: FilterOption[], query: { [key: string]: any } ) { - const dataWithPredefinedTeams = { ...data, team: data.team || [] }; + const dataWithPredefinedTeams = { ...data, team: data?.team || [] }; const filters = filterOptions.filter((filterOption: FilterOption) => filterOption.name in dataWithPredefinedTeams); const values = filters.reduce((memo: any, filterOption: FilterOption) => { diff --git a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx index 86a28f54..dfccafda 100644 --- a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx +++ b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx @@ -14,7 +14,7 @@ import { } from '@grafana/ui'; import { capitalCase } from 'change-case'; import cn from 'classnames/bind'; -import { debounce, isEmpty, isUndefined, omitBy, pickBy } from 'lodash-es'; +import { debounce, isUndefined, omitBy, pickBy } from 'lodash-es'; import { observer } from 'mobx-react'; import moment from 'moment-timezone'; import Emoji from 'react-emoji-render'; @@ -29,6 +29,7 @@ import { SelectOption, WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; import { LocationHelper } from 'utils/LocationHelper'; import { PAGE } from 'utils/consts'; +import { allFieldsEmpty } from 'utils/utils'; import { parseFilters } from './RemoteFilters.helpers'; import { FilterOption } from './RemoteFilters.types'; @@ -102,7 +103,7 @@ class _RemoteFilters extends Component { let { filters, values } = parseFilters({ ...query, ...filtersStore.globalValues }, filterOptions, query); - if (isEmpty(values)) { + if (allFieldsEmpty(values)) { ({ filters, values } = parseFilters(defaultFilters, filterOptions, query)); } 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/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/containers/UserSettings/parts/connectors/Connectors.tsx b/grafana-plugin/src/containers/UserSettings/parts/connectors/Connectors.tsx index 5f249fb4..570c58dc 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/connectors/Connectors.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/connectors/Connectors.tsx @@ -1,6 +1,7 @@ import React, { FC } from 'react'; import { Legend } from '@grafana/ui'; +import { observer } from 'mobx-react'; import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types'; import { ApiSchemas } from 'network/oncall-api/api.types'; @@ -19,7 +20,7 @@ interface ConnectorsProps { onTabChange: (tab: UserSettingsTab) => void; } -export const Connectors: FC = (props) => { +export const Connectors: FC = observer((props) => { const store = useStore(); return ( <> @@ -32,4 +33,4 @@ export const Connectors: FC = (props) => { ); -}; +}); diff --git a/grafana-plugin/src/containers/UserSettings/parts/connectors/MobileAppConnector.tsx b/grafana-plugin/src/containers/UserSettings/parts/connectors/MobileAppConnector.tsx index f769af7e..4460c2ad 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/connectors/MobileAppConnector.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/connectors/MobileAppConnector.tsx @@ -1,6 +1,7 @@ import React, { useCallback } from 'react'; import { Button, InlineField } from '@grafana/ui'; +import { observer } from 'mobx-react'; import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types'; import { ApiSchemas } from 'network/oncall-api/api.types'; @@ -11,7 +12,7 @@ interface MobileAppConnectorProps { onTabChange: (tab: UserSettingsTab) => void; } -export const MobileAppConnector = (props: MobileAppConnectorProps) => { +export const MobileAppConnector = observer((props: MobileAppConnectorProps) => { const { onTabChange, id } = props; const store = useStore(); const { userStore } = store; @@ -35,4 +36,4 @@ export const MobileAppConnector = (props: MobileAppConnectorProps) => { )} ); -}; +}); diff --git a/grafana-plugin/src/containers/UserSettings/parts/connectors/PhoneConnector.tsx b/grafana-plugin/src/containers/UserSettings/parts/connectors/PhoneConnector.tsx index 31508264..11f09349 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/connectors/PhoneConnector.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/connectors/PhoneConnector.tsx @@ -2,6 +2,7 @@ import React, { useCallback } from 'react'; import { Alert, Button, HorizontalGroup, InlineField, Input, VerticalGroup } from '@grafana/ui'; import cn from 'classnames/bind'; +import { observer } from 'mobx-react'; import { Tag } from 'components/Tag/Tag'; import { Text } from 'components/Text/Text'; @@ -21,7 +22,7 @@ interface PhoneConnectorProps { onTabChange: (tab: UserSettingsTab) => void; } -export const PhoneConnector = (props: PhoneConnectorProps) => { +export const PhoneConnector = observer((props: PhoneConnectorProps) => { const { id, onTabChange } = props; const store = useStore(); @@ -174,4 +175,4 @@ export const PhoneConnector = (props: PhoneConnectorProps) => { )}
); -}; +}); diff --git a/grafana-plugin/src/containers/UserSettings/parts/connectors/SlackConnector.tsx b/grafana-plugin/src/containers/UserSettings/parts/connectors/SlackConnector.tsx index 2985ea13..c0592ad3 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/connectors/SlackConnector.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/connectors/SlackConnector.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useMemo } from 'react'; import { Button, HorizontalGroup, InlineField, Input } from '@grafana/ui'; +import { observer } from 'mobx-react'; import { WithConfirm } from 'components/WithConfirm/WithConfirm'; import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types'; @@ -13,7 +14,7 @@ interface SlackConnectorProps { onTabChange: (tab: UserSettingsTab) => void; } -export const SlackConnector = (props: SlackConnectorProps) => { +export const SlackConnector = observer((props: SlackConnectorProps) => { const { id, onTabChange } = props; const store = useStore(); @@ -93,4 +94,4 @@ export const SlackConnector = (props: SlackConnectorProps) => { )} ); -}; +}); diff --git a/grafana-plugin/src/containers/UserSettings/parts/connectors/TelegramConnector.tsx b/grafana-plugin/src/containers/UserSettings/parts/connectors/TelegramConnector.tsx index cf326b67..1a37b4a4 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/connectors/TelegramConnector.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/connectors/TelegramConnector.tsx @@ -1,6 +1,7 @@ import React, { useCallback } from 'react'; import { Button, HorizontalGroup, InlineField, Input } from '@grafana/ui'; +import { observer } from 'mobx-react'; import { WithConfirm } from 'components/WithConfirm/WithConfirm'; import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types'; @@ -12,7 +13,7 @@ interface TelegramConnectorProps { onTabChange: (tab: UserSettingsTab) => void; } -export const TelegramConnector = (props: TelegramConnectorProps) => { +export const TelegramConnector = observer((props: TelegramConnectorProps) => { const { id, onTabChange } = props; const store = useStore(); @@ -58,4 +59,4 @@ export const TelegramConnector = (props: TelegramConnectorProps) => {
); -}; +}); 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 (