Merge branch 'dev' into main

This commit is contained in:
Michael Derynck 2024-03-26 13:24:22 -06:00 committed by GitHub
commit 194afe4556
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1156 additions and 770 deletions

View file

@ -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
View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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/

View file

@ -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",
]

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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();
});
});

View file

@ -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));
});

View file

@ -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}`)
);

View file

@ -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();
};

View file

@ -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}`);

View file

@ -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",

View file

@ -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,

View file

@ -21,3 +21,9 @@
text-overflow: ellipsis;
}
}
.button-input-height {
input {
height: 32px;
}
}

View file

@ -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() {

View file

@ -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)'}
/>

View file

@ -52,6 +52,7 @@ export const getIntegrationFormStyles = (theme: GrafanaTheme2) => {
align-items: center;
gap: 8px;
margin-bottom: 24px;
padding-top: 12px;
`,
labels: css`

View file

@ -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 () {

View file

@ -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) => {

View file

@ -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));
}

View file

@ -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}>

View file

@ -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;
};

View file

@ -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]],
})
);
}
}
},

View file

@ -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;
`,
});

View file

@ -76,7 +76,7 @@
}
.details {
width: 250px;
width: 300px;
padding: 5px 0;
}
@ -121,7 +121,7 @@
}
.second-column {
width: 102px;
width: 120px;
}
.icon {

View file

@ -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">
&nbsp; <br />

View file

@ -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),
};
};

View file

@ -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;
`,
};
};

View file

@ -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),
};
};

View file

@ -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,
}));
}
}

View file

@ -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;

View file

@ -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>
);
});

View file

@ -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),
};
};

View file

@ -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} />
</>
);
};
});

View file

@ -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>
);
};
});

View file

@ -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>
);
};
});

View file

@ -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) => {
)}
</>
);
};
});

View file

@ -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>
);
};
});

View file

@ -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}

View file

@ -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>

View file

@ -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;
};

View file

@ -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']>

View file

@ -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 = '') {

View file

@ -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',

View file

@ -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);
}

View file

@ -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 });
}

View file

@ -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;

View file

@ -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={

View file

@ -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>

View file

@ -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) {

View file

@ -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',
}

View file

@ -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

View file

@ -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);