Merge branch 'dev' into main
This commit is contained in:
commit
194afe4556
56 changed files with 1156 additions and 770 deletions
119
.github/helm-values.yml
vendored
119
.github/helm-values.yml
vendored
|
|
@ -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
|
||||
19
.github/kind.yml
vendored
19
.github/kind.yml
vendored
|
|
@ -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
|
||||
156
.github/workflows/e2e-tests.yml
vendored
156
.github/workflows/e2e-tests.yml
vendored
|
|
@ -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()
|
||||
|
|
|
|||
21
Tiltfile
21
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)
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -21,3 +21,9 @@
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.button-input-height {
|
||||
input {
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<IntegrationInputFieldProps> = ({
|
|||
showCopy = true,
|
||||
showExternal = true,
|
||||
className,
|
||||
placeholder = '',
|
||||
inputClassName = '',
|
||||
iconsClassName = '',
|
||||
}) => {
|
||||
|
|
@ -47,7 +49,14 @@ export const IntegrationInputField: React.FC<IntegrationInputFieldProps> = ({
|
|||
);
|
||||
|
||||
function renderInputField() {
|
||||
return <Input className={inputClassName} value={isInputMasked ? value?.replace(/./g, '*') : value} disabled />;
|
||||
return (
|
||||
<Input
|
||||
className={cx(inputClassName)}
|
||||
value={isInputMasked ? value?.replace(/./g, '*') : value}
|
||||
placeholder={placeholder}
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function onInputReveal() {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export const WorkingHours: FC<WorkingHoursProps> = (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)'}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export const getIntegrationFormStyles = (theme: GrafanaTheme2) => {
|
|||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
padding-top: 12px;
|
||||
`,
|
||||
|
||||
labels: css`
|
||||
|
|
|
|||
|
|
@ -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<FormFields>({
|
||||
const formMethods = useForm<IntegrationFormFields>({
|
||||
defaultValues: isNew
|
||||
? {
|
||||
// these are the default values for creating an integration
|
||||
|
|
@ -281,7 +283,7 @@ export const IntegrationForm = observer(
|
|||
|
||||
{isTableView && <HowTheIntegrationWorks selectedOption={selectedIntegration} />}
|
||||
|
||||
<RenderConditionally shouldRender={isServiceNow}>
|
||||
<RenderConditionally shouldRender={isServiceNow && isNew}>
|
||||
<div className={styles.serviceNowHeading}>
|
||||
<Text type="primary">ServiceNow configuration</Text>
|
||||
</div>
|
||||
|
|
@ -334,9 +336,7 @@ export const IntegrationForm = observer(
|
|||
)}
|
||||
/>
|
||||
|
||||
<Button className={styles.webhookTest} variant="secondary" onClick={onWebhookTestClick}>
|
||||
Test
|
||||
</Button>
|
||||
<ServiceNowAuthSection />
|
||||
|
||||
<Controller
|
||||
name={'create_default_webhooks'}
|
||||
|
|
@ -382,13 +382,10 @@ export const IntegrationForm = observer(
|
|||
}
|
||||
|
||||
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 onWebhookTestClick(): Promise<void> {}
|
||||
|
||||
async function onFormSubmit(formData: FormFields): Promise<void> {
|
||||
async function onFormSubmit(formData: IntegrationFormFields): Promise<void> {
|
||||
const labels = labelsRef.current?.getValue();
|
||||
|
||||
const data: OmitReadonlyMembers<ApiSchemas['AlertReceiveChannelCreate']> = {
|
||||
|
|
@ -489,7 +486,7 @@ const GrafanaContactPoint = observer(
|
|||
setValue,
|
||||
formState: { errors },
|
||||
register,
|
||||
} = useFormContext<FormFields>();
|
||||
} = useFormContext<IntegrationFormFields>();
|
||||
|
||||
useEffect(() => {
|
||||
(async function () {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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<RemoteFiltersProps, RemoteFiltersState> {
|
|||
|
||||
let { filters, values } = parseFilters({ ...query, ...filtersStore.globalValues }, filterOptions, query);
|
||||
|
||||
if (isEmpty(values)) {
|
||||
if (allFieldsEmpty(values)) {
|
||||
({ filters, values } = parseFilters(defaultFilters, filterOptions, query));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ interface RotationProps {
|
|||
|
||||
export const Rotation: FC<RotationProps> = observer((props) => {
|
||||
const {
|
||||
timezoneStore: { calendarStartDate },
|
||||
timezoneStore: { calendarStartDate, getDateInSelectedTimezone },
|
||||
} = useStore();
|
||||
const {
|
||||
events,
|
||||
|
|
@ -133,11 +133,14 @@ export const Rotation: FC<RotationProps> = 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 (
|
||||
<div className={cx('root')} onClick={onClick && handleRotationClick}>
|
||||
|
|
|
|||
|
|
@ -187,3 +187,24 @@ export const getDateForDatePicker = (dayJsDate: Dayjs) => {
|
|||
date.setSeconds(dayJsDate.second());
|
||||
return date;
|
||||
};
|
||||
|
||||
export const dayJSAddWithDSTFixed = ({
|
||||
baseDate,
|
||||
addParams,
|
||||
}: {
|
||||
baseDate: Dayjs;
|
||||
addParams: Parameters<Dayjs['add']>;
|
||||
}) => {
|
||||
// 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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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]],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<VerticalGroup>
|
||||
<div style={{ display: 'flex', flexWrap: 'nowrap', gap: '8px' }}>
|
||||
<div className={styles.wrapper}>
|
||||
<div
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
|
|
@ -90,3 +92,12 @@ export const DateTimePicker = observer(
|
|||
);
|
||||
}
|
||||
);
|
||||
|
||||
const getStyles = () => ({
|
||||
wrapper: css`
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
z-index: 2;
|
||||
`,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@
|
|||
}
|
||||
|
||||
.details {
|
||||
width: 250px;
|
||||
width: 300px;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
|
|
@ -121,7 +121,7 @@
|
|||
}
|
||||
|
||||
.second-column {
|
||||
width: 102px;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
|
|
|||
|
|
@ -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<ScheduleSlotProps> = observer((props) => {
|
||||
const {
|
||||
timezoneStore: { getDateInSelectedTimezone },
|
||||
} = useStore();
|
||||
const {
|
||||
event,
|
||||
color,
|
||||
|
|
@ -46,14 +50,12 @@ export const ScheduleSlot: FC<ScheduleSlotProps> = 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<ScheduleSlotProps> = 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
|
||||
<br />
|
||||
{currentMoment.tz(user?.timezone).format('DD MMM, HH:mm')}
|
||||
<br />({getTzOffsetString(currentMoment.tz(user?.timezone))})
|
||||
<br />({user?.timezone})
|
||||
</Text>
|
||||
<Text type="secondary" data-testid="schedule-slot-current-timezone">
|
||||
Current timezone
|
||||
|
|
@ -427,9 +429,9 @@ const ScheduleSlotDetails = observer((props: ScheduleSlotDetailsProps) => {
|
|||
<Text type="primary" className={cx('second-column')}>
|
||||
This shift
|
||||
<br />
|
||||
{dayjs(event.start).utcOffset(getOffsetOfCurrentUser()).format('DD MMM, HH:mm')}
|
||||
{dayjs(event.start).tz(user?.timezone).format('DD MMM, HH:mm')}
|
||||
<br />
|
||||
{dayjs(event.end).utcOffset(getOffsetOfCurrentUser()).format('DD MMM, HH:mm')}
|
||||
{dayjs(event.end).tz(user?.timezone).format('DD MMM, HH:mm')}
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
<br />
|
||||
|
|
|
|||
|
|
@ -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<CompleteServiceNowConfigModalProps> = ({ onHide }) => {
|
||||
const { alertReceiveChannelStore } = useStore();
|
||||
const integration = useCurrentIntegration();
|
||||
|
||||
const formMethods = useForm<FormFields>({
|
||||
values: {
|
||||
additional_settings: {
|
||||
...integration.additional_settings,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [isFormActionsDisabled, setIsFormActionsDisabled] = useState(false);
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
const { handleSubmit } = formMethods;
|
||||
|
||||
const { id } = integration;
|
||||
|
||||
return (
|
||||
<Modal closeOnEscape={false} isOpen title={'Complete ServiceNow configuration'} onDismiss={onFormAcknowledge}>
|
||||
<FormProvider {...formMethods}>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<div className={styles.border}>
|
||||
<ServiceNowStatusSection />
|
||||
</div>
|
||||
|
||||
<div className={styles.border}>
|
||||
<ServiceNowTokenSection />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant="secondary" onClick={onFormAcknowledge} disabled={isFormActionsDisabled}>
|
||||
Close
|
||||
</Button>
|
||||
<Button variant="primary" type="submit" disabled={isFormActionsDisabled}>
|
||||
Proceed
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
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<ApiSchemas['AlertReceiveChannel']> = {
|
||||
...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),
|
||||
};
|
||||
};
|
||||
|
|
@ -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;
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
|
@ -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<ServiceNowFormFields | IntegrationFormFields>();
|
||||
|
||||
const currentIntegration = useCurrentIntegration();
|
||||
const [isAuthTestRunning, setIsAuthTestRunning] = useState(false);
|
||||
const [authTestResult, setAuthTestResult] = useState<boolean>(undefined);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<HorizontalGroup>
|
||||
<Button className={''} variant="secondary" onClick={onAuthTest}>
|
||||
Test
|
||||
</Button>
|
||||
<div>
|
||||
<RenderConditionally shouldRender={isAuthTestRunning}>
|
||||
<LoadingPlaceholder text="Loading..." className={styles.loader} />
|
||||
</RenderConditionally>
|
||||
|
||||
<RenderConditionally shouldRender={!isAuthTestRunning && authTestResult !== undefined}>
|
||||
<HorizontalGroup align="center" justify="center">
|
||||
<Text type="primary">{authTestResult ? 'Connection OK' : 'Connection failed'}</Text>
|
||||
<Icon name={authTestResult ? 'check-circle' : 'x'} />
|
||||
</HorizontalGroup>
|
||||
</RenderConditionally>
|
||||
</div>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
async function onAuthTest() {
|
||||
const data: OmitReadonlyMembers<ApiSchemas['AlertReceiveChannel']> = {
|
||||
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),
|
||||
};
|
||||
};
|
||||
|
|
@ -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<ServiceNowFormFields>;
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ServiceNowConfigurationDrawerProps> = 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<StatusMapping>({});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<FormFields>({
|
||||
const formMethods = useForm<FormFields>({
|
||||
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<SelectBaseProps<any>> = {
|
||||
backspaceRemovesValue: true,
|
||||
isClearable: true,
|
||||
placeholder: 'Not Selected',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer title="ServiceNow configuration" onClose={onHide} closeOnMaskClick={false} size="md">
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<div className={styles.border}>
|
||||
<Controller
|
||||
name={'additional_settings.instance_url'}
|
||||
control={control}
|
||||
rules={{ required: 'Instance URL is required', validate: validateURL }}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
key={'InstanceURL'}
|
||||
label={'Instance URL'}
|
||||
invalid={!!errors.additional_settings?.instance_url}
|
||||
error={errors.additional_settings?.instance_url?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<FormProvider {...formMethods}>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<div className={styles.border}>
|
||||
<Controller
|
||||
name={'additional_settings.instance_url'}
|
||||
control={control}
|
||||
rules={{ required: 'Instance URL is required', validate: validateURL }}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
key={'InstanceURL'}
|
||||
label={'Instance URL'}
|
||||
invalid={!!errors.additional_settings?.instance_url}
|
||||
error={errors.additional_settings?.instance_url?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name={'additional_settings.username'}
|
||||
control={control}
|
||||
rules={{ required: 'Username is required' }}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
key={'AuthUsername'}
|
||||
label={'Username'}
|
||||
invalid={!!errors.additional_settings?.username}
|
||||
error={errors.additional_settings?.username?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name={'additional_settings.username'}
|
||||
control={control}
|
||||
rules={{ required: 'Username is required' }}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
key={'AuthUsername'}
|
||||
label={'Username'}
|
||||
invalid={!!errors.additional_settings?.username}
|
||||
error={errors.additional_settings?.username?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name={'additional_settings.password'}
|
||||
control={control}
|
||||
rules={{ required: 'Password is required' }}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
key={'AuthPassword'}
|
||||
label={'Password'}
|
||||
invalid={!!errors.additional_settings?.password}
|
||||
error={errors.additional_settings?.password?.message}
|
||||
>
|
||||
<Input {...field} type="password" />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name={'additional_settings.password'}
|
||||
control={control}
|
||||
rules={{ required: 'Password is required' }}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
key={'AuthPassword'}
|
||||
label={'Password'}
|
||||
invalid={!!errors.additional_settings?.password}
|
||||
error={errors.additional_settings?.password?.message}
|
||||
>
|
||||
<Input {...field} type="password" />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<HorizontalGroup>
|
||||
<Button className={''} variant="secondary" onClick={onAuthTest}>
|
||||
Test
|
||||
</Button>
|
||||
<div>
|
||||
<RenderConditionally shouldRender={isAuthTestRunning}>
|
||||
<LoadingPlaceholder text="Loading" className={styles.loader} />
|
||||
</RenderConditionally>
|
||||
<ServiceNowAuthSection />
|
||||
</div>
|
||||
|
||||
<RenderConditionally shouldRender={!isAuthTestRunning && authTestResult !== undefined}>
|
||||
<HorizontalGroup align="center" justify="center">
|
||||
<Text type="primary">{authTestResult ? 'Connection OK' : 'Connection failed'}</Text>
|
||||
<Icon name={authTestResult ? 'check-circle' : 'x'} />
|
||||
</HorizontalGroup>
|
||||
</RenderConditionally>
|
||||
</div>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<div className={styles.border}>
|
||||
<ServiceNowStatusSection />
|
||||
</div>
|
||||
|
||||
<div className={styles.border}>
|
||||
<VerticalGroup spacing="md">
|
||||
<HorizontalGroup spacing="xs" align="center">
|
||||
<Text type="primary" size="small">
|
||||
Status Mapping
|
||||
<div className={styles.border}>
|
||||
<VerticalGroup>
|
||||
<HorizontalGroup spacing="xs" align="center">
|
||||
<Text type="primary" strong>
|
||||
Labels Mapping
|
||||
</Text>
|
||||
<Icon name="info-circle" />
|
||||
</HorizontalGroup>
|
||||
|
||||
<Text>
|
||||
Description for such object and{' '}
|
||||
<a href={'#'} target="_blank" rel="noreferrer">
|
||||
<Text type="link">link to documentation</Text>
|
||||
</a>
|
||||
</Text>
|
||||
<Icon name="info-circle" />
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
|
||||
<table className={'filter-table'}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>OnCall Alert group status</th>
|
||||
<th>ServiceNow incident status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Firing</td>
|
||||
<div className={styles.border}>
|
||||
<ServiceNowTokenSection />
|
||||
</div>
|
||||
|
||||
<td>
|
||||
<Controller
|
||||
name={'additional_settings.state_mapping.firing'}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
{...field}
|
||||
key="state_mapping.firing"
|
||||
menuShouldPortal
|
||||
className="select control"
|
||||
options={getAvailableStatusOptions(OnCallAGStatus.Firing)}
|
||||
onChange={(option: SelectableValue) => {
|
||||
onStatusSelectChange(option, OnCallAGStatus.Firing);
|
||||
setValue('additional_settings.state_mapping.firing', null);
|
||||
}}
|
||||
{...selectCommonProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Acknowledged</td>
|
||||
|
||||
<td>
|
||||
<Controller
|
||||
name={'additional_settings.state_mapping.acknowledged'}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
{...field}
|
||||
menuShouldPortal
|
||||
className="select control"
|
||||
disabled={false}
|
||||
options={getAvailableStatusOptions(OnCallAGStatus.Acknowledged)}
|
||||
onChange={(option: SelectableValue) => {
|
||||
onStatusSelectChange(option, OnCallAGStatus.Acknowledged);
|
||||
setValue('additional_settings.state_mapping.acknowledged', null);
|
||||
}}
|
||||
{...selectCommonProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Resolved</td>
|
||||
<td>
|
||||
<Controller
|
||||
name={'additional_settings.state_mapping.resolved'}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
{...field}
|
||||
menuShouldPortal
|
||||
className="select control"
|
||||
disabled={false}
|
||||
options={getAvailableStatusOptions(OnCallAGStatus.Resolved)}
|
||||
onChange={(option: SelectableValue) => {
|
||||
onStatusSelectChange(option, OnCallAGStatus.Resolved);
|
||||
setValue('additional_settings.state_mapping.resolved', null);
|
||||
}}
|
||||
{...selectCommonProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Silenced</td>
|
||||
<td>
|
||||
<Controller
|
||||
name={'additional_settings.state_mapping.silenced'}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
{...field}
|
||||
menuShouldPortal
|
||||
className="select control"
|
||||
disabled={false}
|
||||
options={getAvailableStatusOptions(OnCallAGStatus.Silenced)}
|
||||
onChange={(option: SelectableValue) => {
|
||||
onStatusSelectChange(option, OnCallAGStatus.Silenced);
|
||||
setValue('additional_settings.state_mapping.silenced', null);
|
||||
}}
|
||||
{...selectCommonProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
|
||||
<div className={styles.border}>
|
||||
<VerticalGroup>
|
||||
<HorizontalGroup spacing="xs" align="center">
|
||||
<Text type="primary" size="small">
|
||||
Labels Mapping
|
||||
</Text>
|
||||
<Icon name="info-circle" />
|
||||
</HorizontalGroup>
|
||||
|
||||
<Text>
|
||||
Description for such object and{' '}
|
||||
<a href={'#'} target="_blank" rel="noreferrer">
|
||||
<Text type="link">link to documentation</Text>
|
||||
</a>
|
||||
</Text>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
|
||||
<div className={styles.border}>
|
||||
<VerticalGroup>
|
||||
<HorizontalGroup spacing="xs" align="center">
|
||||
<Text type="primary" size="small">
|
||||
ServiceNow API Token
|
||||
</Text>
|
||||
<Icon name="info-circle" />
|
||||
</HorizontalGroup>
|
||||
|
||||
<Text>
|
||||
Description for such object and{' '}
|
||||
<a href={'#'} target="_blank" rel="noreferrer">
|
||||
<Text type="link">link to documentation</Text>
|
||||
</a>
|
||||
</Text>
|
||||
|
||||
<div className={styles.tokenContainer}>
|
||||
<IntegrationInputField
|
||||
inputClassName={styles.tokenInput}
|
||||
iconsClassName={styles.tokenIcons}
|
||||
value={serviceNowAPIToken}
|
||||
showExternal={false}
|
||||
isMasked
|
||||
/>
|
||||
<Button variant="secondary" onClick={onTokenRegenerate}>
|
||||
Regenerate
|
||||
<div className={styles.formButtons}>
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant="secondary" onClick={onHide} disabled={isLoading}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
|
||||
<div className={styles.formButtons}>
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant="secondary" onClick={onHide}>
|
||||
Close
|
||||
</Button>
|
||||
<Button variant="primary" type="submit" disabled={isLoading}>
|
||||
{isLoading ? <LoadingPlaceholder className={styles.loader} text="Loading..." /> : 'Update'}
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
</form>
|
||||
<Button variant="primary" type="submit" disabled={isLoading}>
|
||||
Update
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
|
||||
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<void> {
|
||||
const data: OmitReadonlyMembers<ApiSchemas['AlertReceiveChannel']> = {
|
||||
...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<ServiceNowConfigurationDrawerProps
|
|||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<ServiceNowFormFields>();
|
||||
|
||||
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<SelectBaseProps<any>> = {
|
||||
backspaceRemovesValue: true,
|
||||
isClearable: true,
|
||||
placeholder: 'Not Selected',
|
||||
};
|
||||
|
||||
return (
|
||||
<VerticalGroup spacing="md">
|
||||
<HorizontalGroup spacing="xs" align="center">
|
||||
<Text type="primary" strong>
|
||||
Status Mapping
|
||||
</Text>
|
||||
</HorizontalGroup>
|
||||
|
||||
<table className={'filter-table'}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<Text type="primary">OnCall Alert group status</Text>
|
||||
</th>
|
||||
<th>
|
||||
<Text type="primary">ServiceNow incident status</Text>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Firing</td>
|
||||
|
||||
<td>
|
||||
<Controller
|
||||
name={'additional_settings.state_mapping.firing'}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value?.[1]}
|
||||
key="state_mapping.firing"
|
||||
menuShouldPortal
|
||||
className="select control"
|
||||
options={ServiceNowHelper.getAvailableStatusOptions({
|
||||
getValues,
|
||||
currentAction: OnCallAGStatus.Firing,
|
||||
alertReceiveChannelStore,
|
||||
})}
|
||||
onChange={(option: SelectableValue) => {
|
||||
setValue(
|
||||
'additional_settings.state_mapping.firing',
|
||||
option ? [option.label, option.value] : null
|
||||
);
|
||||
forceUpdate();
|
||||
}}
|
||||
{...selectCommonProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Acknowledged</td>
|
||||
|
||||
<td>
|
||||
<Controller
|
||||
name={'additional_settings.state_mapping.acknowledged'}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value?.[1]}
|
||||
defaultValue={field.value?.[1]}
|
||||
menuShouldPortal
|
||||
className="select control"
|
||||
disabled={false}
|
||||
options={ServiceNowHelper.getAvailableStatusOptions({
|
||||
getValues,
|
||||
currentAction: OnCallAGStatus.Acknowledged,
|
||||
alertReceiveChannelStore,
|
||||
})}
|
||||
onChange={(option: SelectableValue) => {
|
||||
setValue(
|
||||
'additional_settings.state_mapping.acknowledged',
|
||||
option ? [option.label, option.value] : null
|
||||
);
|
||||
forceUpdate();
|
||||
}}
|
||||
{...selectCommonProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Resolved</td>
|
||||
<td>
|
||||
<Controller
|
||||
name={'additional_settings.state_mapping.resolved'}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value?.[1]}
|
||||
defaultValue={field.value?.[1]}
|
||||
menuShouldPortal
|
||||
className="select control"
|
||||
disabled={false}
|
||||
options={ServiceNowHelper.getAvailableStatusOptions({
|
||||
getValues,
|
||||
currentAction: OnCallAGStatus.Resolved,
|
||||
alertReceiveChannelStore,
|
||||
})}
|
||||
onChange={(option: SelectableValue) => {
|
||||
setValue(
|
||||
'additional_settings.state_mapping.resolved',
|
||||
option ? [option.label, option.value] : null
|
||||
);
|
||||
forceUpdate();
|
||||
}}
|
||||
{...selectCommonProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Silenced</td>
|
||||
<td>
|
||||
<Controller
|
||||
name={'additional_settings.state_mapping.silenced'}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value?.[1]}
|
||||
menuShouldPortal
|
||||
className="select control"
|
||||
disabled={false}
|
||||
options={ServiceNowHelper.getAvailableStatusOptions({
|
||||
getValues,
|
||||
currentAction: OnCallAGStatus.Silenced,
|
||||
alertReceiveChannelStore,
|
||||
})}
|
||||
onChange={(option: SelectableValue) => {
|
||||
setValue(
|
||||
'additional_settings.state_mapping.silenced',
|
||||
option ? [option.label, option.value] : null
|
||||
);
|
||||
forceUpdate();
|
||||
}}
|
||||
{...selectCommonProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</VerticalGroup>
|
||||
);
|
||||
});
|
||||
|
|
@ -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<string>(undefined);
|
||||
const isLoading = useIsLoading(ActionKey.UPDATE_SERVICENOW_TOKEN);
|
||||
|
||||
useEffect(() => {
|
||||
(async function () {
|
||||
const hasToken = await AlertReceiveChannelHelper.checkIfServiceNowHasToken({ id });
|
||||
setIsExistingToken(hasToken);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<VerticalGroup>
|
||||
<HorizontalGroup spacing="xs" align="center">
|
||||
<Text type="primary" strong>
|
||||
ServiceNow backsync API token
|
||||
</Text>
|
||||
</HorizontalGroup>
|
||||
|
||||
<Text>
|
||||
Description for such object and{' '}
|
||||
<a href={'#'} target="_blank" rel="noreferrer">
|
||||
<Text type="link">link to documentation</Text>
|
||||
</a>
|
||||
</Text>
|
||||
|
||||
<RenderConditionally shouldRender={isExistingToken === undefined}>
|
||||
<LoadingPlaceholder text="Loading..." />
|
||||
</RenderConditionally>
|
||||
|
||||
<RenderConditionally shouldRender={isExistingToken !== undefined}>
|
||||
<div className={styles.tokenContainer}>
|
||||
<IntegrationInputField
|
||||
placeholder={
|
||||
currentToken
|
||||
? ''
|
||||
: isExistingToken
|
||||
? 'A token had already been generated'
|
||||
: 'Click Generate to create a token'
|
||||
}
|
||||
className={styles.buttonInputHeight}
|
||||
inputClassName={styles.tokenInput}
|
||||
iconsClassName={styles.tokenIcons}
|
||||
value={currentToken}
|
||||
showExternal={false}
|
||||
showCopy={Boolean(currentToken)}
|
||||
showEye={false}
|
||||
isMasked={false}
|
||||
/>
|
||||
<Button variant="secondary" onClick={onTokenGenerate} disabled={isLoading}>
|
||||
{isExistingToken ? 'Regenerate' : 'Generate'}
|
||||
</Button>
|
||||
</div>
|
||||
</RenderConditionally>
|
||||
</VerticalGroup>
|
||||
);
|
||||
|
||||
async function onTokenGenerate() {
|
||||
const res = await AlertReceiveChannelHelper.generateServiceNowToken({ id });
|
||||
|
||||
if (res?.token) {
|
||||
setCurrentToken(res.token);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
...getCommonServiceNowConfigStyles(theme),
|
||||
};
|
||||
};
|
||||
|
|
@ -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<ConnectorsProps> = (props) => {
|
||||
export const Connectors: FC<ConnectorsProps> = observer((props) => {
|
||||
const store = useStore();
|
||||
return (
|
||||
<>
|
||||
|
|
@ -32,4 +33,4 @@ export const Connectors: FC<ConnectorsProps> = (props) => {
|
|||
<ICalConnector {...props} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
|||
)}
|
||||
</InlineField>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
|||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
|||
</InlineField>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<UserTimezoneSelectProps> = observer(({ sched
|
|||
);
|
||||
}, [users, extraOptions]);
|
||||
|
||||
const selectedOption = options.find(({ value }) => value === store.timezoneStore.selectedTimezoneOffset);
|
||||
|
||||
const filterOption = useCallback((item: SelectableValue<number>, searchQuery: string) => {
|
||||
const { data } = item;
|
||||
|
||||
|
|
@ -77,6 +79,12 @@ export const UserTimezoneSelect: FC<UserTimezoneSelectProps> = 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<UserTimezoneSelectProps> = observer(({ sched
|
|||
return (
|
||||
<div className={cx('root')} data-testid="timezone-select">
|
||||
<Select
|
||||
value={options.find(({ value }) => value === store.timezoneStore.selectedTimezoneOffset)}
|
||||
value={selectedOption}
|
||||
onChange={(option) => store.timezoneStore.setSelectedTimezoneOffset(option.value)}
|
||||
width={30}
|
||||
options={options}
|
||||
|
|
|
|||
|
|
@ -35,8 +35,6 @@ export const ScheduleUserDetails: FC<ScheduleUserDetailsProps> = observer((props
|
|||
timezoneStore: { calendarStartDate },
|
||||
} = useStore();
|
||||
const { user, currentMoment, isOncall, scheduleId } = props;
|
||||
const userMoment = currentMoment.tz(user.timezone);
|
||||
const userOffsetHoursStr = getTzOffsetString(userMoment);
|
||||
const isInWH = isInWorkingHours(currentMoment, user.working_hours, user.timezone);
|
||||
|
||||
const store = useStore();
|
||||
|
|
@ -89,7 +87,7 @@ export const ScheduleUserDetails: FC<ScheduleUserDetailsProps> = observer((props
|
|||
<VerticalGroup className={cx('timezone-info')} spacing="none">
|
||||
<Text>User's local time</Text>
|
||||
<Text>{`${getCurrentDateInTimezone(user.timezone).format('DD MMM, HH:mm')}`}</Text>
|
||||
<Text>({userOffsetHoursStr})</Text>
|
||||
<Text>({user.timezone})</Text>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@ import { Dayjs } from 'dayjs';
|
|||
|
||||
export const calculateTimePassedInDayPercentage = (date: Dayjs) => {
|
||||
const midnight = date.startOf('day');
|
||||
return (date.diff(midnight, 'minutes') / 1_440) * 100;
|
||||
return (date.diff(midnight, 'minutes') / (60 * 24)) * 100;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<ApiSchemas['IntegrationTokenPostResponse']> {
|
||||
const result = await onCallApi({ skipErrorHandling }).POST('/alert_receive_channels/{id}/api_token/', {
|
||||
params: { path: { id } },
|
||||
});
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
static async testServiceNowAuthentication({
|
||||
data,
|
||||
}: {
|
||||
data: OmitReadonlyMembers<ApiSchemas['AlertReceiveChannelUpdate']>;
|
||||
}) {
|
||||
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<ApiSchemas['AlertReceiveChannel'] | ApiSchemas['FastAlertReceiveChannel']>
|
||||
|
|
|
|||
|
|
@ -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<ApiSchemas['AlertReceiveChannelIntegrationOptions']> = [];
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 = '') {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export class TimezoneStore {
|
|||
calendarStartDate = getStartOfWeekBasedOnCurrentDate(this.currentDateInSelectedTimezone);
|
||||
|
||||
@action.bound
|
||||
async setSelectedTimezoneOffset(offset: number) {
|
||||
setSelectedTimezoneOffset(offset: number) {
|
||||
this.selectedTimezoneOffset = offset;
|
||||
this.calendarStartDate = getStartOfWeekBasedOnCurrentDate(this.currentDateInSelectedTimezone);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,12 +129,12 @@ export class UserStore {
|
|||
}
|
||||
|
||||
async unlinkSlack(userPk: ApiSchemas['User']['pk']) {
|
||||
await onCallApi().POST('/users/{id}/unlink_slack/', undefined);
|
||||
await onCallApi().POST('/users/{id}/unlink_slack/', { params: { path: { id: userPk } } });
|
||||
await this.fetchItemById({ userPk });
|
||||
}
|
||||
|
||||
async unlinkTelegram(userPk: ApiSchemas['User']['pk']) {
|
||||
await onCallApi().POST('/users/{id}/unlink_telegram/', undefined);
|
||||
await onCallApi().POST('/users/{id}/unlink_telegram/', { params: { path: { id: userPk } } });
|
||||
await this.fetchItemById({ userPk });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -276,6 +276,7 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
const showLinkTo = !incident.dependent_alert_groups.length && !incident.root_alert_group && !incident.resolved;
|
||||
const integrationNameWithEmojies = <Emoji text={incident.alert_receive_channel.verbal_name} />;
|
||||
const sourceLink = incident?.render_for_web?.source_link;
|
||||
const isServiceNow = incident?.alert_receive_channel?.integration === 'servicenow';
|
||||
|
||||
return (
|
||||
<Block className={cx('block')}>
|
||||
|
|
@ -391,6 +392,15 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
</Button>
|
||||
</PluginLink>
|
||||
|
||||
{isServiceNow && (
|
||||
<Button variant="secondary" fill="outline" size="sm" className={cx('label-button')}>
|
||||
<HorizontalGroup spacing="xs">
|
||||
<Icon name="exchange-alt" />
|
||||
<span>Service Now</span>
|
||||
</HorizontalGroup>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Tooltip
|
||||
placement="top"
|
||||
content={
|
||||
|
|
|
|||
|
|
@ -304,6 +304,8 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
|
|||
|
||||
renderIncidentFilters() {
|
||||
const { query, store } = this.props;
|
||||
const defaultStart = moment().subtract(7, 'days');
|
||||
const defaultEnd = moment().add(1, 'days');
|
||||
return (
|
||||
<div className={cx('filters')}>
|
||||
<RemoteFilters
|
||||
|
|
@ -318,6 +320,7 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
|
|||
team: [],
|
||||
status: [IncidentStatus.Firing, IncidentStatus.Acknowledged],
|
||||
mine: false,
|
||||
started_at: `${defaultStart.format('YYYY-MM-DDTHH:mm:ss')}/${defaultEnd.format('YYYY-MM-DDTHH:mm:ss')}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { LabelTag } from '@grafana/labels';
|
||||
import {
|
||||
|
|
@ -48,6 +48,7 @@ import { IntegrationFormContainer } from 'containers/IntegrationForm/Integration
|
|||
import { IntegrationLabelsForm } from 'containers/IntegrationLabelsForm/IntegrationLabelsForm';
|
||||
import { IntegrationTemplate } from 'containers/IntegrationTemplate/IntegrationTemplate';
|
||||
import { MaintenanceForm } from 'containers/MaintenanceForm/MaintenanceForm';
|
||||
import { CompleteServiceNowModal } from 'containers/ServiceNowConfigDrawer/CompleteServiceNowConfigModal';
|
||||
import { ServiceNowConfigDrawer } from 'containers/ServiceNowConfigDrawer/ServiceNowConfigDrawer';
|
||||
import { TeamName } from 'containers/TeamName/TeamName';
|
||||
import { UserDisplayWithAvatar } from 'containers/UserDisplay/UserDisplayWithAvatar';
|
||||
|
|
@ -808,7 +809,7 @@ interface IntegrationActionsProps {
|
|||
changeIsTemplateSettingsOpen: () => void;
|
||||
}
|
||||
|
||||
type IntegrationDrawerKey = 'servicenow';
|
||||
type IntegrationDrawerKey = 'servicenow' | 'completeConfig';
|
||||
|
||||
const IntegrationActions: React.FC<IntegrationActionsProps> = ({
|
||||
alertReceiveChannel,
|
||||
|
|
@ -831,6 +832,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
|
|||
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<IntegrationActionsProps> = ({
|
|||
|
||||
const { id } = alertReceiveChannel;
|
||||
|
||||
useEffect(() => {
|
||||
/* ServiceNow Only */
|
||||
openServiceNowCompleteConfigurationDrawer();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{confirmModal && (
|
||||
|
|
@ -868,7 +875,11 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
{getIsDrawerOpened('servicenow') && <ServiceNowConfigDrawer onHide={() => closeDrawer()} />}
|
||||
{getIsDrawerOpened('servicenow') && <ServiceNowConfigDrawer onHide={closeDrawer} />}
|
||||
|
||||
{isCompleteServiceNowConfigOpen && (
|
||||
<CompleteServiceNowModal onHide={() => setIsCompleteServiceNowConfigOpen(false)} />
|
||||
)}
|
||||
|
||||
{isIntegrationSettingsOpen && (
|
||||
<IntegrationFormContainer
|
||||
|
|
@ -1060,6 +1071,14 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
|
|||
</>
|
||||
);
|
||||
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { AxiosError } from 'axios';
|
|||
import { sentenceCase } from 'change-case';
|
||||
// @ts-ignore
|
||||
import appEvents from 'grafana/app/core/app_events';
|
||||
import { isArray, concat, isPlainObject, flatMap, map, keys } from 'lodash-es';
|
||||
import { isArray, concat, every, isEmpty, isObject, isPlainObject, flatMap, map, keys } from 'lodash-es';
|
||||
|
||||
import { isNetworkError } from 'network/network';
|
||||
import { getGrafanaVersion } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
|
|
@ -107,3 +107,12 @@ export function isUseProfileExtensionPointEnabled(): boolean {
|
|||
|
||||
return isRequiredGrafanaVersion;
|
||||
}
|
||||
|
||||
function isFieldEmpty(value: any): boolean {
|
||||
if (isObject(value)) {
|
||||
return isEmpty(value);
|
||||
}
|
||||
return value === '' || value === null || value === undefined;
|
||||
}
|
||||
|
||||
export const allFieldsEmpty = (obj: any) => every(obj, isFieldEmpty);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue