commit
2c384ef709
96 changed files with 2222 additions and 2272 deletions
21
Makefile
21
Makefile
|
|
@ -16,6 +16,8 @@ DEV_ENV_FILE = $(DEV_ENV_DIR)/.env.dev
|
|||
DEV_ENV_EXAMPLE_FILE = $(DEV_ENV_FILE).example
|
||||
|
||||
ENGINE_DIR = ./engine
|
||||
REQUIREMENTS_TXT = $(ENGINE_DIR)/requirements.txt
|
||||
REQUIREMENTS_ENTERPRISE_TXT = $(ENGINE_DIR)/requirements-enterprise.txt
|
||||
SQLITE_DB_FILE = $(ENGINE_DIR)/oncall.db
|
||||
|
||||
# -n flag only copies DEV_ENV_EXAMPLE_FILE-> DEV_ENV_FILE if it doesn't already exist
|
||||
|
|
@ -45,12 +47,18 @@ else
|
|||
BROKER_TYPE=$(REDIS_PROFILE)
|
||||
endif
|
||||
|
||||
define run_engine_docker_command
|
||||
DB=$(DB) BROKER_TYPE=$(BROKER_TYPE) docker-compose -f $(DOCKER_COMPOSE_FILE) run --rm oncall_engine_commands $(1)
|
||||
endef
|
||||
# SQLITE_DB_FiLE is set to properly mount the sqlite db file
|
||||
DOCKER_COMPOSE_ENV_VARS := COMPOSE_PROFILES=$(COMPOSE_PROFILES) DB=$(DB) BROKER_TYPE=$(BROKER_TYPE)
|
||||
ifeq ($(DB),$(SQLITE_PROFILE))
|
||||
DOCKER_COMPOSE_ENV_VARS += SQLITE_DB_FILE=$(SQLITE_DB_FILE)
|
||||
endif
|
||||
|
||||
define run_docker_compose_command
|
||||
COMPOSE_PROFILES=$(COMPOSE_PROFILES) DB=$(DB) BROKER_TYPE=$(BROKER_TYPE) docker-compose -f $(DOCKER_COMPOSE_FILE) $(1)
|
||||
$(DOCKER_COMPOSE_ENV_VARS) docker compose -f $(DOCKER_COMPOSE_FILE) $(1)
|
||||
endef
|
||||
|
||||
define run_engine_docker_command
|
||||
$(call run_docker_compose_command,run --rm oncall_engine_commands $(1))
|
||||
endef
|
||||
|
||||
# touch SQLITE_DB_FILE if it does not exist and DB is eqaul to SQLITE_PROFILE
|
||||
|
|
@ -128,7 +136,10 @@ endef
|
|||
|
||||
backend-bootstrap:
|
||||
pip install -U pip wheel
|
||||
cd engine && pip install -r requirements.txt
|
||||
pip install -r $(REQUIREMENTS_TXT)
|
||||
@if [ -f $(REQUIREMENTS_ENTERPRISE_TXT) ]; then \
|
||||
pip install -r $(REQUIREMENTS_ENTERPRISE_TXT); \
|
||||
fi
|
||||
|
||||
backend-migrate:
|
||||
$(call backend_command,python manage.py migrate)
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ See [Grafana docs](https://grafana.com/docs/grafana/latest/administration/plugin
|
|||
|
||||
## Further Reading
|
||||
|
||||
- _Migration from the PagerDuty_ - [Migrator](https://github.com/grafana/oncall/tree/dev/tools/pagerduty-migrator)
|
||||
- _Migration from PagerDuty_ - [Migrator](https://github.com/grafana/oncall/tree/dev/tools/pagerduty-migrator)
|
||||
- _Documentation_ - [Grafana OnCall](https://grafana.com/docs/grafana-cloud/oncall/)
|
||||
- _Overview Webinar_ - [YouTube](https://www.youtube.com/watch?v=7uSe1pulgs8)
|
||||
- _How To Add Integration_ - [How to Add Integration](https://github.com/grafana/oncall/tree/dev/engine/config_integrations/README.md)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
- [ld: library not found for -lssl](#ld-library-not-found-for--lssl)
|
||||
- [Could not build wheels for cryptography which use PEP 517 and cannot be installed directly](#could-not-build-wheels-for-cryptography-which-use-pep-517-and-cannot-be-installed-directly)
|
||||
- [django.db.utils.OperationalError: (1366, "Incorrect string value ...")](#djangodbutilsoperationalerror-1366-incorrect-string-value)
|
||||
- [/bin/sh: line 0: cd: grafana-plugin: No such file or directory](#binsh-line-0-cd-grafana-plugin-no-such-file-or-directory)
|
||||
- [IDE Specific Instructions](#ide-specific-instructions)
|
||||
- [PyCharm](#pycharm-professional-edition)
|
||||
|
||||
|
|
@ -21,7 +22,7 @@ Related: [How to develop integrations](/engine/config_integrations/README.md)
|
|||
|
||||
By default everything runs inside Docker. These options can be modified via the [`COMPOSE_PROFILES`](#compose_profiles) environment variable.
|
||||
|
||||
1. Firstly, ensure that you have `docker` [installed](https://docs.docker.com/get-docker/) and running on your machine. **NOTE**: the `docker-compose-developer.yml` file uses some syntax/features that are only supported by Docker Compose v2. For insturctions on how to enable this (if you haven't already done so), see [here](https://www.docker.com/blog/announcing-compose-v2-general-availability/).
|
||||
1. Firstly, ensure that you have `docker` [installed](https://docs.docker.com/get-docker/) and running on your machine. **NOTE**: the `docker-compose-developer.yml` file uses some syntax/features that are only supported by Docker Compose v2. For instructions on how to enable this (if you haven't already done so), see [here](https://www.docker.com/blog/announcing-compose-v2-general-availability/).
|
||||
2. Run `make init start`. By default this will run everything in Docker, using SQLite as the database and Redis as the message broker/cache. See [Running in Docker](#running-in-docker) below for more details on how to swap out/disable which components are run in Docker.
|
||||
3. Open Grafana in a browser [here](http://localhost:3000/plugins/grafana-oncall-app) (login: `oncall`, password: `oncall`).
|
||||
4. You should now see the OnCall plugin configuration page. Fill out the configuration options as follows:
|
||||
|
|
@ -41,7 +42,7 @@ This configuration option represents a comma-separated list of [`docker-compose`
|
|||
|
||||
This option can be configured in two ways:
|
||||
|
||||
1. Setting a `COMPOSE_PROFILE` environment variable in `.env.dev`. This allows you to avoid having to set `COMPOSE_PROFILE` for each `make` command you execute afterwards.
|
||||
1. Setting a `COMPOSE_PROFILES` environment variable in `dev/.env.dev`. This allows you to avoid having to set `COMPOSE_PROFILES` for each `make` command you execute afterwards.
|
||||
2. Passing in a `COMPOSE_PROFILES` argument when running `make` commands. For example:
|
||||
|
||||
```bash
|
||||
|
|
@ -191,6 +192,29 @@ django.db.utils.OperationalError: (1366, "Incorrect string value: '\\xF0\\x9F\\x
|
|||
|
||||
Recreate the database with the correct encoding.
|
||||
|
||||
### /bin/sh: line 0: cd: grafana-plugin: No such file or directory
|
||||
|
||||
**Problem:**
|
||||
|
||||
When running `make init`:
|
||||
|
||||
```
|
||||
/bin/sh: line 0: cd: grafana-plugin: No such file or directory
|
||||
make: *** [init] Error 1
|
||||
```
|
||||
|
||||
This arises when the environment variable `[CDPATH](https://www.theunixschool.com/2012/04/what-is-cdpath.html)` is set _and_ when the current path (`.`) is not explicitly part of `CDPATH`.
|
||||
|
||||
**Solution:**
|
||||
|
||||
Either make `.` part of `CDPATH` in your .rc file setup, or temporarily override the variable when running `make` commands:
|
||||
|
||||
```
|
||||
$ CDPATH="." make init
|
||||
# Setting CDPATH to empty seems to also work - only tested on zsh, YMMV
|
||||
$ CDPATH="" make init
|
||||
```
|
||||
|
||||
## IDE Specific Instructions
|
||||
|
||||
### PyCharm
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ x-oncall-volumes: &oncall-volumes
|
|||
- ./engine:/etc/app
|
||||
# https://stackoverflow.com/a/60456034
|
||||
- ${ENTERPRISE_ENGINE:-/dev/null}:/etc/app/extensions/engine_enterprise
|
||||
- ./engine/oncall.db:/var/lib/oncall/oncall.db
|
||||
- ${SQLITE_DB_FILE:-/dev/null}:/var/lib/oncall/oncall.db
|
||||
|
||||
x-env-files: &oncall-env-files
|
||||
- ./dev/.env.dev
|
||||
|
|
@ -235,7 +235,7 @@ services:
|
|||
grafana:
|
||||
container_name: grafana
|
||||
labels: *oncall-labels
|
||||
image: "grafana/grafana:${GRAFANA_VERSION:-main}"
|
||||
image: "grafana/grafana:${GRAFANA_VERSION:-latest}"
|
||||
restart: always
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: oncall
|
||||
|
|
|
|||
2
engine/.gitignore
vendored
2
engine/.gitignore
vendored
|
|
@ -1,4 +1,4 @@
|
|||
requirements-enterprise.txt
|
||||
requirements-enterprise*.txt
|
||||
extensions/
|
||||
uwsgi-local.ini
|
||||
celerybeat-schedule
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ FROM base AS dev
|
|||
RUN apk add sqlite mysql-client postgresql-client
|
||||
|
||||
FROM dev AS dev-enterprise
|
||||
RUN pip install -r requirements-enterprise.txt
|
||||
RUN pip install -r requirements-enterprise-docker.txt
|
||||
|
||||
FROM base AS prod
|
||||
|
||||
|
|
|
|||
|
|
@ -159,9 +159,7 @@ class ChannelFilter(OrderedModel):
|
|||
"order": self.order,
|
||||
"slack_notification_enabled": self.notify_in_slack,
|
||||
"telegram_notification_enabled": self.notify_in_telegram,
|
||||
# TODO: use names instead of pks, it's needed to rework messaging backends for that
|
||||
}
|
||||
# TODO: use names instead of pks, it's needed to rework messaging backends for that
|
||||
if self.slack_channel_id:
|
||||
if self.slack_channel_id:
|
||||
SlackChannel = apps.get_model("slack", "SlackChannel")
|
||||
|
|
@ -169,7 +167,11 @@ class ChannelFilter(OrderedModel):
|
|||
slack_channel = SlackChannel.objects.filter(
|
||||
slack_team_identity=sti, slack_id=self.slack_channel_id
|
||||
).first()
|
||||
result["slack_channel"] = slack_channel.name
|
||||
if slack_channel is not None:
|
||||
# Case when slack channel was deleted, but channel filter still has it's id
|
||||
result["slack_channel"] = slack_channel.name
|
||||
# TODO: use names instead of pks for telegram and other notifications backends.
|
||||
# It's needed to rework messaging backends for that
|
||||
if self.telegram_channel:
|
||||
result["telegram_channel"] = self.telegram_channel.public_primary_key
|
||||
if self.escalation_chain:
|
||||
|
|
|
|||
|
|
@ -163,15 +163,16 @@ class ChannelFilterSerializer(BaseChannelFilterSerializer):
|
|||
def create(self, validated_data):
|
||||
validated_data = self._correct_validated_data(validated_data)
|
||||
manual_order = validated_data.pop("manual_order")
|
||||
if not manual_order:
|
||||
if manual_order:
|
||||
self._validate_manual_order(validated_data.get("order", None))
|
||||
instance = super().create(validated_data)
|
||||
else:
|
||||
order = validated_data.pop("order", None)
|
||||
alert_receive_channel_id = validated_data.get("alert_receive_channel")
|
||||
# validate 'order' value before creation
|
||||
self._validate_order(order, {"alert_receive_channel_id": alert_receive_channel_id, "is_default": False})
|
||||
instance = super().create(validated_data)
|
||||
self._change_position(order, instance)
|
||||
else:
|
||||
instance = super().create(validated_data)
|
||||
|
||||
return instance
|
||||
|
||||
|
|
@ -206,10 +207,13 @@ class ChannelFilterUpdateSerializer(ChannelFilterSerializer):
|
|||
validated_data = self._correct_validated_data(validated_data)
|
||||
|
||||
manual_order = validated_data.pop("manual_order")
|
||||
if not manual_order:
|
||||
if manual_order:
|
||||
self._validate_manual_order(validated_data.get("order", None))
|
||||
else:
|
||||
order = validated_data.pop("order", None)
|
||||
self._validate_order(
|
||||
order, {"alert_receive_channel_id": instance.alert_receive_channel_id, "is_default": False}
|
||||
order,
|
||||
{"alert_receive_channel_id": instance.alert_receive_channel_id, "is_default": instance.is_default},
|
||||
)
|
||||
self._change_position(order, instance)
|
||||
|
||||
|
|
|
|||
|
|
@ -381,3 +381,33 @@ def test_update_route_with_messaging_backend(
|
|||
assert new_channel_filter.notify_in_slack == data_to_update["slack"]["enabled"]
|
||||
assert new_channel_filter.notify_in_telegram == data_to_update["telegram"]["enabled"]
|
||||
assert new_channel_filter.notification_backends == {TestOnlyBackend.backend_id: {"channel": None, "enabled": True}}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_route_with_manual_ordering(
|
||||
make_organization_and_user_with_token,
|
||||
make_alert_receive_channel,
|
||||
make_channel_filter,
|
||||
):
|
||||
organization, _, token = make_organization_and_user_with_token()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
channel_filter = make_channel_filter(
|
||||
alert_receive_channel,
|
||||
is_default=False,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-public:routes-detail", kwargs={"pk": channel_filter.public_primary_key})
|
||||
|
||||
# Test negative value. Note, that for "manual_order"=False, -1 is valud option (It will move route to the bottom)
|
||||
data_to_update = {"position": -1, "manual_order": True}
|
||||
|
||||
response = client.put(url, format="json", HTTP_AUTHORIZATION=token, data=data_to_update)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# Test value bigger then PositiveIntegerField can hold
|
||||
data_to_update = {"position": 9223372036854775807, "manual_order": True}
|
||||
|
||||
response = client.put(url, format="json", HTTP_AUTHORIZATION=token, data=data_to_update)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ class SlackChannelView(RateLimitHeadersMixin, mixins.ListModelMixin, GenericView
|
|||
channel_name = self.request.query_params.get("channel_name", None)
|
||||
|
||||
queryset = SlackChannel.objects.filter(
|
||||
slack_team_identity__organizations=self.request.auth.organization
|
||||
slack_team_identity__organizations=self.request.auth.organization,
|
||||
is_archived=False,
|
||||
).distinct()
|
||||
|
||||
if channel_name:
|
||||
|
|
|
|||
|
|
@ -175,7 +175,6 @@ class AlertShootingStep(scenario_step.ScenarioStep):
|
|||
def _send_first_alert(self, alert, channel_id):
|
||||
attachments = alert.group.render_slack_attachments()
|
||||
blocks = alert.group.render_slack_blocks()
|
||||
|
||||
self.publish_slack_messages(
|
||||
slack_team_identity=self.slack_team_identity,
|
||||
alert_group=alert.group,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from django.apps import apps
|
|||
from django.utils import timezone
|
||||
|
||||
from apps.slack.scenarios import scenario_step
|
||||
from apps.slack.tasks import clean_slack_channel_leftovers
|
||||
|
||||
|
||||
class SlackChannelCreatedOrRenamedEventStep(scenario_step.ScenarioStep):
|
||||
|
|
@ -53,6 +54,8 @@ class SlackChannelDeletedEventStep(scenario_step.ScenarioStep):
|
|||
slack_id=slack_id,
|
||||
slack_team_identity=slack_team_identity,
|
||||
).delete()
|
||||
# even if channel is deteletd run the task to clean possible leftowers
|
||||
clean_slack_channel_leftovers.apply_async((slack_team_identity.id, slack_id))
|
||||
|
||||
|
||||
class SlackChannelArchivedEventStep(scenario_step.ScenarioStep):
|
||||
|
|
@ -75,6 +78,7 @@ class SlackChannelArchivedEventStep(scenario_step.ScenarioStep):
|
|||
slack_id=slack_id,
|
||||
slack_team_identity=slack_team_identity,
|
||||
).update(is_archived=True)
|
||||
clean_slack_channel_leftovers.apply_async((slack_team_identity.id, slack_id))
|
||||
|
||||
|
||||
class SlackChannelUnArchivedEventStep(scenario_step.ScenarioStep):
|
||||
|
|
|
|||
|
|
@ -771,3 +771,35 @@ def clean_slack_integration_leftovers(organization_id, *args, **kwargs):
|
|||
OnCallSchedule.objects.filter(organization_id=organization_id).update(channel=None)
|
||||
logger.info(f"Cleaned OnCallSchedule slack_channel_id for organization {organization_id}")
|
||||
logger.info(f"Finish clean slack leftovers for organization {organization_id}")
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True, max_retries=10)
|
||||
def clean_slack_channel_leftovers(slack_team_identity_id, slack_channel_id):
|
||||
"""
|
||||
This task removes binding to slack channel after channel arcived or deleted in slack.
|
||||
"""
|
||||
SlackTeamIdentity = apps.get_model("slack", "SlackTeamIdentity")
|
||||
ChannelFilter = apps.get_model("alerts", "ChannelFilter")
|
||||
Organization = apps.get_model("user_management", "Organization")
|
||||
|
||||
try:
|
||||
sti = SlackTeamIdentity.objects.get(id=slack_team_identity_id)
|
||||
except SlackTeamIdentity.DoesNotExist:
|
||||
logger.info(
|
||||
f"Failed to clean_slack_channel_leftovers slack_channel_id={slack_channel_id} slack_team_identity_id={slack_team_identity_id} : Invalid slack_team_identity_id"
|
||||
)
|
||||
return
|
||||
|
||||
orgs_to_clean_general_log_channel_id = []
|
||||
for org in sti.organizations.all():
|
||||
if org.general_log_channel_id == slack_channel_id:
|
||||
logger.info(
|
||||
f"Set general_log_channel_id to None for org_id={org.id} slack_channel_id={slack_channel_id} since slack_channel is arcived or deleted"
|
||||
)
|
||||
org.general_log_channel_id = None
|
||||
orgs_to_clean_general_log_channel_id.append(org)
|
||||
ChannelFilter.objects.filter(alert_receive_channel__organization=org, slack_channel_id=slack_channel_id).update(
|
||||
slack_channel_id=None
|
||||
)
|
||||
|
||||
Organization.objects.bulk_update(orgs_to_clean_general_log_channel_id, ["general_log_channel_id"], batch_size=5000)
|
||||
|
|
|
|||
|
|
@ -19,12 +19,17 @@ class AllowOnlyTwilio(BasePermission):
|
|||
def has_permission(self, request, view):
|
||||
# https://www.twilio.com/docs/usage/tutorials/how-to-secure-your-django-project-by-validating-incoming-twilio-requests
|
||||
# https://www.django-rest-framework.org/api-guide/permissions/
|
||||
validator = RequestValidator(live_settings.TWILIO_AUTH_TOKEN)
|
||||
location = create_engine_url(request.get_full_path())
|
||||
request_valid = validator.validate(
|
||||
request.build_absolute_uri(location=location), request.POST, request.META.get("HTTP_X_TWILIO_SIGNATURE", "")
|
||||
)
|
||||
return request_valid
|
||||
if live_settings.TWILIO_AUTH_TOKEN:
|
||||
validator = RequestValidator(live_settings.TWILIO_AUTH_TOKEN)
|
||||
location = create_engine_url(request.get_full_path())
|
||||
request_valid = validator.validate(
|
||||
request.build_absolute_uri(location=location),
|
||||
request.POST,
|
||||
request.META.get("HTTP_X_TWILIO_SIGNATURE", ""),
|
||||
)
|
||||
return request_valid
|
||||
else:
|
||||
return live_settings.TWILIO_ACCOUNT_SID == request.data["AccountSid"]
|
||||
|
||||
|
||||
class HealthCheckView(APIView):
|
||||
|
|
|
|||
|
|
@ -159,6 +159,18 @@ class OrderedModelSerializerMixin:
|
|||
if order > max_order:
|
||||
raise BadRequest(detail="Invalid value for position field")
|
||||
|
||||
def _validate_manual_order(self, order):
|
||||
"""
|
||||
For manual ordering validate just that order is valid PositiveIntegrer.
|
||||
User of manual ordering is responsible for correct ordering.
|
||||
However, manual ordering not intended for use somewhere, except terraform provider.
|
||||
"""
|
||||
|
||||
# https://docs.djangoproject.com/en/4.1/ref/models/fields/#positiveintegerfield
|
||||
MAX_POSITIVE_INTEGER = 2147483647
|
||||
if order is not None and order < 0 or order > MAX_POSITIVE_INTEGER:
|
||||
raise BadRequest(detail="Invalid value for position field")
|
||||
|
||||
|
||||
class PublicPrimaryKeyMixin:
|
||||
def get_object(self):
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ module.exports = {
|
|||
plugins: ['rulesdir', 'import'],
|
||||
settings: {
|
||||
'import/internal-regex':
|
||||
'^assets|^components|^containers|^declare|^icons|^img|^interceptors|^models|^network|^pages|^services|^state|^utils',
|
||||
'^assets|^components|^containers|^declare|^icons|^img|^interceptors|^models|^network|^pages|^services|^state|^utils|^plugin',
|
||||
},
|
||||
rules: {
|
||||
eqeqeq: 'warn',
|
||||
|
|
|
|||
|
|
@ -15,5 +15,6 @@ module.exports = {
|
|||
'^jest$': '<rootDir>/src/jest',
|
||||
'^.+\\.(css|scss)$': '<rootDir>/src/jest/styleMock.ts',
|
||||
'^lodash-es$': 'lodash',
|
||||
"^.+\\.svg$": "<rootDir>/src/jest/svgTransform.ts"
|
||||
},
|
||||
};
|
||||
|
|
@ -54,12 +54,12 @@
|
|||
"@babel/preset-env": "^7.18.10",
|
||||
"@babel/preset-react": "^7.18.6",
|
||||
"@babel/preset-typescript": "^7.18.6",
|
||||
"@grafana/data": "9.1.1",
|
||||
"@grafana/data": "^9.2.4",
|
||||
"@grafana/eslint-config": "^5.0.0",
|
||||
"@grafana/runtime": "9.1.1",
|
||||
"@grafana/toolkit": "9.1.1",
|
||||
"@grafana/ui": "9.1.1",
|
||||
"@jest/globals": "27.5.1",
|
||||
"@grafana/runtime": "^9.2.4",
|
||||
"@grafana/toolkit": "^9.2.4",
|
||||
"@grafana/ui": "^9.2.4",
|
||||
"@jest/globals": "^27.5.1",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "12",
|
||||
"@types/dompurify": "^2.3.4",
|
||||
|
|
|
|||
28
grafana-plugin/src/PluginPage.tsx
Normal file
28
grafana-plugin/src/PluginPage.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import React from 'react';
|
||||
|
||||
import { PluginPageProps, PluginPage as RealPluginPage } from '@grafana/runtime';
|
||||
import Header from 'navbar/Header/Header';
|
||||
|
||||
import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { useQueryParams } from 'utils/hooks';
|
||||
|
||||
export const PluginPage = (isTopNavbar() ? RealPlugin : PluginPageFallback) as React.ComponentType<PluginPageProps>;
|
||||
|
||||
function RealPlugin(props: PluginPageProps): React.ReactNode {
|
||||
const store = useStore();
|
||||
|
||||
const queryParams = useQueryParams();
|
||||
const page = queryParams.get('page');
|
||||
|
||||
return (
|
||||
<RealPluginPage {...props}>
|
||||
<Header page={page} backendLicense={store.backendLicense} />
|
||||
{props.children}
|
||||
</RealPluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginPageFallback(props: PluginPageProps): React.ReactNode {
|
||||
return props.children;
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Card } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
|
||||
import gitHubStarSVG from 'assets/img/github_star.svg';
|
||||
import { APP_SUBTITLE, GRAFANA_LICENSE_OSS } from 'utils/consts';
|
||||
|
||||
import styles from './NavBarSubtitle.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
function NavBarSubtitle({ backendLicense }: { backendLicense: string }) {
|
||||
if (backendLicense === GRAFANA_LICENSE_OSS) {
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
{APP_SUBTITLE}
|
||||
<Card heading={undefined} className={cx('navbar-heading')}>
|
||||
<a href="https://github.com/grafana/oncall" className={cx('navbar-link')} target="_blank" rel="noreferrer">
|
||||
<img src={gitHubStarSVG} className={cx('navbar-star-icon')} alt="" /> Star us on GitHub
|
||||
</a>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{APP_SUBTITLE}</>;
|
||||
}
|
||||
|
||||
export default NavBarSubtitle;
|
||||
|
|
@ -32,23 +32,26 @@ export default function PageErrorHandlingWrapper({
|
|||
itemNotFoundMessage,
|
||||
children,
|
||||
}: {
|
||||
errorData: PageErrorData;
|
||||
objectName: string;
|
||||
errorData?: PageErrorData;
|
||||
objectName?: string;
|
||||
pageName: string;
|
||||
itemNotFoundMessage?: string;
|
||||
children: () => JSX.Element;
|
||||
}) {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
useEffect(() => {
|
||||
if (!errorData) {
|
||||
return;
|
||||
}
|
||||
const { isWrongTeamError, isNotFoundError } = errorData;
|
||||
if (!isWrongTeamError && isNotFoundError && itemNotFoundMessage) {
|
||||
openWarningNotification(itemNotFoundMessage);
|
||||
}
|
||||
}, [errorData.isNotFoundError]);
|
||||
}, [errorData?.isNotFoundError]);
|
||||
|
||||
const store = useStore();
|
||||
|
||||
if (!errorData.isWrongTeamError) {
|
||||
return children();
|
||||
if (!errorData || !errorData.isWrongTeamError) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const currentTeamId = store.userStore.currentUser?.current_team;
|
||||
|
|
|
|||
|
|
@ -1,25 +1,29 @@
|
|||
import React, { useCallback, FC } from 'react';
|
||||
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import { LocationUpdate } from '@grafana/runtime/services/LocationSrv';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import cn from 'classnames/bind';
|
||||
import qs from 'query-string';
|
||||
|
||||
import { PLUGIN_URL_PATH } from 'pages';
|
||||
|
||||
import styles from './PluginLink.module.css';
|
||||
|
||||
interface PluginLinkProps extends LocationUpdate {
|
||||
interface PluginLinkProps {
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
wrap?: boolean;
|
||||
children: any;
|
||||
partial?: boolean;
|
||||
path?: string;
|
||||
query?: Record<string, any>;
|
||||
}
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
const PluginLink: FC<PluginLinkProps> = (props) => {
|
||||
const { children, partial = false, path = '/a/grafana-oncall-app/', query, disabled, className, wrap = true } = props;
|
||||
const { children, partial = false, path = PLUGIN_URL_PATH, query, disabled, className, wrap = true } = props;
|
||||
|
||||
const href = `${path}?${qs.stringify(query)}`;
|
||||
const href = `${path}/?${qs.stringify(query)}`;
|
||||
|
||||
const onClickCallback = useCallback(
|
||||
(event) => {
|
||||
|
|
@ -30,7 +34,15 @@ const PluginLink: FC<PluginLinkProps> = (props) => {
|
|||
return;
|
||||
}
|
||||
|
||||
!disabled && getLocationSrv().update({ partial, path, query });
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (partial) {
|
||||
locationService.partial(query);
|
||||
} else {
|
||||
locationService.push(href);
|
||||
}
|
||||
},
|
||||
[children]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
.root {
|
||||
margin-top: -24px;
|
||||
}
|
||||
|
||||
.root .alerts_horizontal {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
flex-direction: column;
|
||||
|
||||
.root .alert {
|
||||
margin: 24px 0;
|
||||
.alerts_horizontal {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin: 24px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- GRAFANA UI TUNINGS --- */
|
||||
|
|
@ -18,10 +18,9 @@ import { getItem, setItem } from 'utils/localStorage';
|
|||
import sanitize from 'utils/sanitize';
|
||||
|
||||
import { getSlackMessage } from './DefaultPageLayout.helpers';
|
||||
import styles from './DefaultPageLayout.module.scss';
|
||||
import { SlackError } from './DefaultPageLayout.types';
|
||||
|
||||
import styles from './DefaultPageLayout.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface DefaultPageLayoutProps extends AppRootProps {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,15 @@
|
|||
position: absolute;
|
||||
padding: 16px 0;
|
||||
margin-right: 24px;
|
||||
|
||||
&--topRight {
|
||||
right: 14px;
|
||||
top: 12px;
|
||||
}
|
||||
&--topRightIncident {
|
||||
right: 32px;
|
||||
top: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.teamSelectLabel {
|
||||
|
|
@ -11,8 +20,7 @@
|
|||
}
|
||||
|
||||
.teamSelectLink {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.teamSelectInfo {
|
||||
|
|
@ -9,10 +9,11 @@ import PluginLink from 'components/PluginLink/PluginLink';
|
|||
import GSelect from 'containers/GSelect/GSelect';
|
||||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
|
||||
import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { UserAction } from 'state/userAction';
|
||||
|
||||
import styles from './GrafanaTeamSelect.module.css';
|
||||
import styles from './GrafanaTeamSelect.module.scss';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
|
|
@ -47,34 +48,37 @@ const GrafanaTeamSelect = observer((props: GrafanaTeamSelectProps) => {
|
|||
}
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div className={cx('teamSelect', { 'teamSelect--topRight': isTopNavbar() })}>
|
||||
<div className={cx('teamSelectLabel')}>
|
||||
<Label>
|
||||
Select Team{' '}
|
||||
<Tooltip content="The objects on this page are filtered by team and you can only view the objects that belong to your team. Note that filtering within Grafana OnCall is meant for usability, not access management.">
|
||||
<Icon name="info-circle" size="md" className={cx('teamSelectInfo')}></Icon>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
<WithPermissionControl userAction={UserAction.UpdateTeams}>
|
||||
<PluginLink path="/org/teams" className={cx('teamSelectLink')}>
|
||||
Edit teams
|
||||
</PluginLink>
|
||||
</WithPermissionControl>
|
||||
</div>
|
||||
<GSelect
|
||||
modelName="grafanaTeamStore"
|
||||
displayField="name"
|
||||
valueField="id"
|
||||
placeholder="Select Team"
|
||||
className={cx('select', 'control')}
|
||||
value={user.current_team}
|
||||
onChange={onTeamChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return document.getElementsByClassName('page-header__inner')[0]
|
||||
? ReactDOM.createPortal(
|
||||
<div className={cx('teamSelect')}>
|
||||
<div className={cx('teamSelectLabel')}>
|
||||
<Label>
|
||||
Select Team{' '}
|
||||
<Tooltip content="The objects on this page are filtered by team and you can only view the objects that belong to your team. Note that filtering within Grafana OnCall is meant for usability, not access management.">
|
||||
<Icon name="info-circle" size="md" className={cx('teamSelectInfo')}></Icon>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
<WithPermissionControl userAction={UserAction.UpdateTeams}>
|
||||
<PluginLink path="/org/teams" className={cx('teamSelectLink')}>
|
||||
Edit teams
|
||||
</PluginLink>
|
||||
</WithPermissionControl>
|
||||
</div>
|
||||
<GSelect
|
||||
modelName="grafanaTeamStore"
|
||||
displayField="name"
|
||||
valueField="id"
|
||||
placeholder="Select Team"
|
||||
className={cx('select', 'control')}
|
||||
value={user.current_team}
|
||||
onChange={onTeamChange}
|
||||
/>
|
||||
</div>,
|
||||
document.getElementsByClassName('page-header__inner')[0]
|
||||
)
|
||||
? ReactDOM.createPortal(content, document.getElementsByClassName('page-header__inner')[0])
|
||||
: isTopNavbar()
|
||||
? content
|
||||
: null;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,35 @@
|
|||
/* Navigation/Layout */
|
||||
|
||||
.page-body {
|
||||
max-width: unset !important;
|
||||
}
|
||||
|
||||
.oncall-header {
|
||||
padding-top: 0;
|
||||
padding-bottom: 36px;
|
||||
}
|
||||
|
||||
.scrollbar-view [class*='-page-header'] {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.page-container.page-body {
|
||||
flex-grow: 1 !important;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
max-width: unset !important;
|
||||
flex-grow: unset !important;
|
||||
flex-basis: unset !important;
|
||||
}
|
||||
|
||||
.page-scrollbar-content > div:first-child {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.page-header__title {
|
||||
padding-top: 0 !important;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* This is for Grafana 8, remove later */
|
||||
|
|
|
|||
8
grafana-plugin/src/jest/svgTransform.ts
Normal file
8
grafana-plugin/src/jest/svgTransform.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
module.exports = {
|
||||
process() {
|
||||
return { code: 'module.exports = {};' };
|
||||
},
|
||||
getCacheKey() {
|
||||
return 'svgTransform';
|
||||
},
|
||||
};
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import { ComponentClass } from 'react';
|
||||
|
||||
import { AppPlugin, AppPluginMeta, AppRootProps, PluginConfigPageProps } from '@grafana/data';
|
||||
import { GrafanaPluginRootPage } from 'GrafanaPluginRootPage';
|
||||
|
||||
import { PluginConfigPage } from 'containers/PluginConfigPage/PluginConfigPage';
|
||||
import { GrafanaPluginRootPage } from 'plugin/GrafanaPluginRootPage';
|
||||
|
||||
import { OnCallAppSettings } from './types';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-star-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
|
@ -13,9 +8,11 @@
|
|||
border: 1px solid var(--gray-9);
|
||||
width: initial;
|
||||
font-size: 12px;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.navbar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 6px;
|
||||
}
|
||||
63
grafana-plugin/src/navbar/Header/Header.tsx
Normal file
63
grafana-plugin/src/navbar/Header/Header.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Card } from '@grafana/ui';
|
||||
import classnames from 'classnames';
|
||||
import cn from 'classnames/bind';
|
||||
|
||||
import gitHubStarSVG from 'assets/img/github_star.svg';
|
||||
import GrafanaTeamSelect from 'containers/GrafanaTeamSelect/GrafanaTeamSelect';
|
||||
import logo from 'img/logo.svg';
|
||||
import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
import { APP_SUBTITLE, GRAFANA_LICENSE_OSS } from 'utils/consts';
|
||||
|
||||
import styles from './Header.module.scss';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
export default function Header({ page, backendLicense }: { page: string; backendLicense: string }) {
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<div className={classnames('page-header__inner', { 'oncall-header': isTopNavbar() })}>
|
||||
<span className="page-header__logo">
|
||||
<img className="page-header__img" src={logo} alt="Grafana OnCall" />
|
||||
</span>
|
||||
|
||||
<div className="page-header__info-block">{renderHeading()}</div>
|
||||
|
||||
<GrafanaTeamSelect currentPage={page} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function renderHeading() {
|
||||
if (backendLicense === GRAFANA_LICENSE_OSS) {
|
||||
return (
|
||||
<div className={cx('heading')}>
|
||||
<h1 className={cx('page-header__title')}>Grafana OnCall</h1>
|
||||
<div className="u-flex u-align-items-center">
|
||||
<div className={cx('page-header__sub-title')}>{APP_SUBTITLE}</div>
|
||||
<Card heading={undefined} className={cx('navbar-heading')}>
|
||||
<a
|
||||
href="https://github.com/grafana/oncall"
|
||||
className={cx('navbar-link')}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<img src={gitHubStarSVG} className={cx('navbar-star-icon')} alt="" /> Star us on GitHub
|
||||
</a>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className={cx('page-header__title')}>Grafana OnCall</h1>
|
||||
<div className={cx('page-header__sub-title')}>{APP_SUBTITLE}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
11
grafana-plugin/src/navbar/LegacyNavHeading.tsx
Normal file
11
grafana-plugin/src/navbar/LegacyNavHeading.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
|
||||
interface LegacyNavHeadingProps {
|
||||
children: JSX.Element;
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
export default function LegacyNavHeading(props: LegacyNavHeadingProps): JSX.Element {
|
||||
const { show = !isTopNavbar(), children } = props;
|
||||
return show ? children : null;
|
||||
}
|
||||
29
grafana-plugin/src/navbar/LegacyNavTabsBar.tsx
Normal file
29
grafana-plugin/src/navbar/LegacyNavTabsBar.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
|
||||
import { IconName } from '@grafana/data';
|
||||
import { Tab, TabsBar } from '@grafana/ui';
|
||||
|
||||
import { pages } from 'pages';
|
||||
import { useStore } from 'state/useStore';
|
||||
|
||||
export default function LegacyNavTabsBar({ currentPage }: { currentPage: string }): JSX.Element {
|
||||
const store = useStore();
|
||||
|
||||
const navigationPages = Object.keys(pages)
|
||||
.map((page) => pages[page])
|
||||
.filter((page) => (page.hideFromTabsFn ? !page.hideFromTabsFn(store) : !page.hideFromTabs));
|
||||
|
||||
return (
|
||||
<TabsBar>
|
||||
{navigationPages.map((page, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
icon={page.icon as IconName}
|
||||
label={page.text}
|
||||
href={page.path}
|
||||
active={currentPage === page.id}
|
||||
/>
|
||||
))}
|
||||
</TabsBar>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import { Tabs, TabsContent } from 'pages/chat-ops/parts';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
||||
import { ChatOpsTab } from './ChatOps.types';
|
||||
|
||||
import styles from './ChatOps.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface MessengersPageProps extends WithStoreProps {}
|
||||
|
||||
interface MessengersPageState {
|
||||
activeTab: ChatOpsTab;
|
||||
}
|
||||
|
||||
@observer
|
||||
class ChatOpsPage extends React.Component<MessengersPageProps, MessengersPageState> {
|
||||
state: MessengersPageState = {
|
||||
activeTab: ChatOpsTab.Slack,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { activeTab } = this.state;
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('tabs')}>
|
||||
<Tabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={(tab: ChatOpsTab) => {
|
||||
this.setState({ activeTab: tab });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={cx('content')}>
|
||||
<TabsContent activeTab={activeTab} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withMobXProviderContext(ChatOpsPage);
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export enum ChatOpsTab {
|
||||
Slack = 'Slack',
|
||||
Telegram = 'Telegram',
|
||||
}
|
||||
|
|
@ -1,7 +1,3 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import React from 'react';
|
|||
import { AppRootProps } from '@grafana/data';
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import { Button, HorizontalGroup, Icon, IconButton, LoadingPlaceholder, Tooltip, VerticalGroup } from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
|
|
@ -26,6 +27,7 @@ import EscalationChainForm from 'containers/EscalationChainForm/EscalationChainF
|
|||
import EscalationChainSteps from 'containers/EscalationChainSteps/EscalationChainSteps';
|
||||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { EscalationChain } from 'models/escalation_chain/escalation_chain.types';
|
||||
import { pages } from 'pages';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
|
@ -134,13 +136,13 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
|
|||
const searchResult = escalationChainStore.getSearchResult(escalationChainsFilters.searchTerm);
|
||||
|
||||
return (
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
objectName="escalation"
|
||||
pageName="escalations"
|
||||
itemNotFoundMessage={`Escalation chain with id=${query?.id} is not found. Please select escalation chain from the list.`}
|
||||
>
|
||||
{() => (
|
||||
<PluginPage pageNav={pages['escalations'].getPageNav()}>
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
objectName="escalation"
|
||||
pageName="escalations"
|
||||
itemNotFoundMessage={`Escalation chain with id=${query?.id} is not found. Please select escalation chain from the list.`}
|
||||
>
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('filters')}>
|
||||
|
|
@ -214,8 +216,8 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
|
|||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageErrorHandlingWrapper>
|
||||
</PageErrorHandlingWrapper>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.incident-row {
|
||||
display: flex;
|
||||
}
|
||||
|
|
@ -11,7 +7,7 @@
|
|||
}
|
||||
|
||||
.payload-subtitle {
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: var(--title-marginBottom);
|
||||
}
|
||||
|
||||
.info-row {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
Modal,
|
||||
Tooltip,
|
||||
} from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
import moment from 'moment-timezone';
|
||||
|
|
@ -47,6 +48,8 @@ import {
|
|||
GroupedAlert,
|
||||
} from 'models/alertgroup/alertgroup.types';
|
||||
import { ResolutionNoteSourceTypesToDisplayName } from 'models/resolution_note/resolution_note.types';
|
||||
import { pages } from 'pages';
|
||||
import { getQueryParams } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { UserAction } from 'state/userAction';
|
||||
|
|
@ -94,10 +97,8 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
update = () => {
|
||||
this.setState({ errorData: initErrorDataState() }); // reset wrong team error to false
|
||||
|
||||
const {
|
||||
store,
|
||||
query: { id },
|
||||
} = this.props;
|
||||
const { store } = this.props;
|
||||
const { id } = getQueryParams();
|
||||
|
||||
store.alertGroupStore
|
||||
.getAlert(id)
|
||||
|
|
@ -105,10 +106,8 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
store,
|
||||
query: { id, cursor, start, perpage },
|
||||
} = this.props;
|
||||
const { store } = this.props;
|
||||
const { id, cursor, start, perpage } = getQueryParams();
|
||||
|
||||
const { errorData, showIntegrationSettings, showAttachIncidentForm } = this.state;
|
||||
const { isNotFoundError, isWrongTeamError } = errorData;
|
||||
|
|
@ -126,10 +125,10 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
}
|
||||
|
||||
return (
|
||||
<PageErrorHandlingWrapper errorData={errorData} objectName="alert group" pageName="incidents">
|
||||
{() =>
|
||||
errorData.isNotFoundError ? (
|
||||
<div className={cx('root')}>
|
||||
<PluginPage pageNav={pages['incident'].getPageNav()}>
|
||||
<PageErrorHandlingWrapper errorData={errorData} objectName="alert group" pageName="incidents">
|
||||
<div className={cx('root')}>
|
||||
{errorData.isNotFoundError ? (
|
||||
<div className={cx('not-found')}>
|
||||
<VerticalGroup spacing="lg" align="center">
|
||||
<Text.Title level={1}>404</Text.Title>
|
||||
|
|
@ -141,10 +140,8 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
</PluginLink>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
) : (
|
||||
<>
|
||||
{this.renderHeader()}
|
||||
<div className={cx('content')}>
|
||||
<div className={cx('column')}>
|
||||
|
|
@ -157,49 +154,47 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
</div>
|
||||
<div className={cx('column')}>{this.renderTimeline()}</div>
|
||||
</div>
|
||||
</div>
|
||||
{showIntegrationSettings && (
|
||||
<IntegrationSettings
|
||||
alertGroupId={incident.pk}
|
||||
onUpdate={() => {
|
||||
alertReceiveChannelStore.updateItem(incident.alert_receive_channel.id);
|
||||
}}
|
||||
onUpdateTemplates={() => {
|
||||
store.alertGroupStore.getAlert(id);
|
||||
}}
|
||||
startTab={IntegrationSettingsTab.Templates}
|
||||
id={incident.alert_receive_channel.id}
|
||||
onHide={() =>
|
||||
this.setState({
|
||||
showIntegrationSettings: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showAttachIncidentForm && (
|
||||
<AttachIncidentForm
|
||||
id={id}
|
||||
onHide={() => {
|
||||
this.setState({
|
||||
showAttachIncidentForm: false,
|
||||
});
|
||||
}}
|
||||
onUpdate={this.update}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</PageErrorHandlingWrapper>
|
||||
{showIntegrationSettings && (
|
||||
<IntegrationSettings
|
||||
alertGroupId={incident.pk}
|
||||
onUpdate={() => {
|
||||
alertReceiveChannelStore.updateItem(incident.alert_receive_channel.id);
|
||||
}}
|
||||
onUpdateTemplates={() => {
|
||||
store.alertGroupStore.getAlert(id);
|
||||
}}
|
||||
startTab={IntegrationSettingsTab.Templates}
|
||||
id={incident.alert_receive_channel.id}
|
||||
onHide={() =>
|
||||
this.setState({
|
||||
showIntegrationSettings: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showAttachIncidentForm && (
|
||||
<AttachIncidentForm
|
||||
id={id}
|
||||
onHide={() => {
|
||||
this.setState({
|
||||
showAttachIncidentForm: false,
|
||||
});
|
||||
}}
|
||||
onUpdate={this.update}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PageErrorHandlingWrapper>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
renderHeader = () => {
|
||||
const {
|
||||
store,
|
||||
query: { id, cursor, start, perpage },
|
||||
} = this.props;
|
||||
const { store } = this.props;
|
||||
|
||||
const { id, cursor, start, perpage } = getQueryParams();
|
||||
const { alerts } = store.alertGroupStore;
|
||||
|
||||
const incident = alerts.get(id);
|
||||
|
|
@ -316,11 +311,9 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
};
|
||||
|
||||
renderTimeline = () => {
|
||||
const {
|
||||
store,
|
||||
query: { id },
|
||||
} = this.props;
|
||||
const { store } = this.props;
|
||||
|
||||
const { id } = getQueryParams();
|
||||
const incident = store.alertGroupStore.alerts.get(id);
|
||||
|
||||
if (!incident.render_after_resolve_report_json) {
|
||||
|
|
@ -408,11 +401,9 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
};
|
||||
|
||||
handleCreateResolutionNote = () => {
|
||||
const {
|
||||
store,
|
||||
query: { id },
|
||||
} = this.props;
|
||||
const { store } = this.props;
|
||||
|
||||
const { id } = getQueryParams();
|
||||
const { resolutionNoteText } = this.state;
|
||||
store.resolutionNotesStore
|
||||
.createResolutionNote(id, resolutionNoteText)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.select {
|
||||
width: 400px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import React, { ReactElement, SyntheticEvent } from 'react';
|
|||
import { AppRootProps } from '@grafana/data';
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import { Button, Icon, Tooltip, VerticalGroup, LoadingPlaceholder, HorizontalGroup } from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import { get } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
|
|
@ -12,6 +13,7 @@ import Emoji from 'react-emoji-render';
|
|||
import CursorPagination from 'components/CursorPagination/CursorPagination';
|
||||
import GTable from 'components/GTable/GTable';
|
||||
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
|
||||
import PageErrorHandlingWrapper from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import Text from 'components/Text/Text';
|
||||
import Tutorial from 'components/Tutorial/Tutorial';
|
||||
|
|
@ -21,7 +23,9 @@ import IncidentsFilters from 'containers/IncidentsFilters/IncidentsFilters';
|
|||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { Alert, Alert as AlertType, AlertAction } from 'models/alertgroup/alertgroup.types';
|
||||
import { User } from 'models/user/user.types';
|
||||
import { pages } from 'pages';
|
||||
import { getActionButtons, getIncidentStatusTag, renderRelatedUsers } from 'pages/incident/Incident.helpers';
|
||||
import { getQueryParams } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
import { move } from 'state/helpers';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
|
|
@ -67,10 +71,8 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
|
|||
constructor(props: IncidentsPageProps) {
|
||||
super(props);
|
||||
|
||||
const {
|
||||
store,
|
||||
query: { cursor: cursorQuery, start: startQuery, perpage: perpageQuery },
|
||||
} = props;
|
||||
const { store } = props;
|
||||
const { cursor: cursorQuery, start: startQuery, perpage: perpageQuery } = getQueryParams();
|
||||
|
||||
const cursor = cursorQuery || undefined;
|
||||
const start = !isNaN(startQuery) ? Number(startQuery) : 1;
|
||||
|
|
@ -100,10 +102,14 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
|
|||
|
||||
render() {
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
{this.renderIncidentFilters()}
|
||||
{this.renderTable()}
|
||||
</div>
|
||||
<PluginPage pageNav={pages['incidents'].getPageNav()}>
|
||||
<PageErrorHandlingWrapper pageName="incidents">
|
||||
<div className={cx('root')}>
|
||||
{this.renderIncidentFilters()}
|
||||
{this.renderTable()}
|
||||
</div>
|
||||
</PageErrorHandlingWrapper>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React, { useCallback } from 'react';
|
||||
|
||||
import { ButtonCascader } from '@grafana/ui';
|
||||
import { ComponentSize } from '@grafana/ui/types/size';
|
||||
import { ButtonCascader, ComponentSize } from '@grafana/ui';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
|
|
|
|||
|
|
@ -1,142 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { AppRootProps } from '@grafana/data';
|
||||
|
||||
import ChatOpsPage from 'pages/chat-ops/ChatOps';
|
||||
import CloudPage from 'pages/cloud/CloudPage';
|
||||
import EscalationsChainsPage from 'pages/escalation-chains/EscalationChains';
|
||||
import IncidentPage2 from 'pages/incident/Incident';
|
||||
import IncidentsPage2 from 'pages/incidents/Incidents';
|
||||
import IntegrationsPage2 from 'pages/integrations/Integrations';
|
||||
import LiveSettingsPage from 'pages/livesettings/LiveSettingsPage';
|
||||
import MaintenancePage2 from 'pages/maintenance/Maintenance';
|
||||
import OrganizationLogPage2 from 'pages/organization-logs/OrganizationLog';
|
||||
import OutgoingWebhooks2 from 'pages/outgoing_webhooks/OutgoingWebhooks';
|
||||
import SchedulePage from 'pages/schedule/Schedule';
|
||||
import SchedulesPage from 'pages/schedules/Schedules';
|
||||
import SchedulesPageOld from 'pages/schedules_OLD/Schedules';
|
||||
import SettingsPage2 from 'pages/settings/SettingsPage';
|
||||
import Test from 'pages/test/Test';
|
||||
import UsersPage2 from 'pages/users/Users';
|
||||
|
||||
export type PageDefinition = {
|
||||
component: React.ComponentType<AppRootProps>;
|
||||
icon: string;
|
||||
id: string;
|
||||
text: string;
|
||||
hideFromTabs?: boolean;
|
||||
role?: 'Viewer' | 'Editor' | 'Admin';
|
||||
};
|
||||
|
||||
export const pages: PageDefinition[] = [
|
||||
{
|
||||
component: IncidentsPage2,
|
||||
icon: 'bell',
|
||||
id: 'incidents',
|
||||
text: 'Alert Groups',
|
||||
},
|
||||
{
|
||||
component: IncidentPage2,
|
||||
icon: 'bell',
|
||||
id: 'incident',
|
||||
text: 'Incident',
|
||||
hideFromTabs: true,
|
||||
},
|
||||
{
|
||||
component: UsersPage2,
|
||||
icon: 'users-alt',
|
||||
id: 'users',
|
||||
text: 'Users',
|
||||
},
|
||||
{
|
||||
component: IntegrationsPage2,
|
||||
icon: 'plug',
|
||||
id: 'integrations',
|
||||
text: 'Integrations',
|
||||
},
|
||||
{
|
||||
component: EscalationsChainsPage,
|
||||
icon: 'list-ul',
|
||||
id: 'escalations',
|
||||
text: 'Escalation Chains',
|
||||
},
|
||||
{
|
||||
component: SchedulesPageOld,
|
||||
icon: 'calendar-alt',
|
||||
id: 'schedules-old',
|
||||
text: 'Schedules OLD',
|
||||
hideFromTabs: true,
|
||||
},
|
||||
{
|
||||
component: SchedulesPage,
|
||||
icon: 'calendar-alt',
|
||||
id: 'schedules',
|
||||
text: 'Schedules',
|
||||
},
|
||||
{
|
||||
component: SchedulePage,
|
||||
icon: 'calendar-alt',
|
||||
id: 'schedule',
|
||||
text: 'Schedule',
|
||||
hideFromTabs: true,
|
||||
},
|
||||
{
|
||||
component: ChatOpsPage,
|
||||
icon: 'comments-alt',
|
||||
id: 'chat-ops',
|
||||
text: 'ChatOps',
|
||||
},
|
||||
{
|
||||
component: ChatOpsPage,
|
||||
icon: 'comments-alt',
|
||||
id: 'slack',
|
||||
text: 'ChatOps',
|
||||
hideFromTabs: true,
|
||||
},
|
||||
{
|
||||
component: OutgoingWebhooks2,
|
||||
icon: 'link',
|
||||
id: 'outgoing_webhooks',
|
||||
text: 'Outgoing Webhooks',
|
||||
},
|
||||
{
|
||||
component: MaintenancePage2,
|
||||
icon: 'wrench',
|
||||
id: 'maintenance',
|
||||
text: 'Maintenance',
|
||||
},
|
||||
{
|
||||
component: SettingsPage2,
|
||||
icon: 'cog',
|
||||
id: 'settings',
|
||||
text: 'Settings',
|
||||
},
|
||||
{
|
||||
component: LiveSettingsPage,
|
||||
icon: 'table',
|
||||
id: 'live-settings',
|
||||
text: 'Env Variables',
|
||||
role: 'Admin',
|
||||
},
|
||||
{
|
||||
component: OrganizationLogPage2,
|
||||
icon: 'gf-logs',
|
||||
id: 'organization-logs',
|
||||
text: 'Org Logs',
|
||||
hideFromTabs: true,
|
||||
},
|
||||
{
|
||||
component: CloudPage,
|
||||
icon: 'cloud',
|
||||
id: 'cloud',
|
||||
text: 'Cloud',
|
||||
role: 'Admin',
|
||||
},
|
||||
{
|
||||
component: Test,
|
||||
icon: 'cog',
|
||||
id: 'test',
|
||||
text: 'Test',
|
||||
hideFromTabs: true,
|
||||
},
|
||||
];
|
||||
157
grafana-plugin/src/pages/index.tsx
Normal file
157
grafana-plugin/src/pages/index.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { NavModelItem } from '@grafana/data';
|
||||
|
||||
import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
import { AppFeature } from 'state/features';
|
||||
import { RootBaseStore } from 'state/rootBaseStore';
|
||||
|
||||
export const PLUGIN_URL_PATH = '/a/grafana-oncall-app';
|
||||
|
||||
export type PageDefinition = {
|
||||
path: string;
|
||||
icon: string;
|
||||
id: string;
|
||||
text: string;
|
||||
hideFromTabsFn?: (store: RootBaseStore) => boolean;
|
||||
hideFromTabs?: boolean;
|
||||
role?: 'Viewer' | 'Editor' | 'Admin';
|
||||
|
||||
getPageNav(): { text: string; description: string };
|
||||
};
|
||||
|
||||
function getPath(name = '') {
|
||||
return `${PLUGIN_URL_PATH}/?page=${name}`;
|
||||
}
|
||||
|
||||
export const pages: { [id: string]: PageDefinition } = [
|
||||
{
|
||||
icon: 'bell',
|
||||
id: 'incidents',
|
||||
hideFromBreadcrumbs: true,
|
||||
text: 'Alert Groups',
|
||||
path: getPath('incidents'),
|
||||
},
|
||||
{
|
||||
icon: 'bell',
|
||||
id: 'incident',
|
||||
text: '',
|
||||
hideFromTabs: true,
|
||||
hideFromBreadcrumbs: true,
|
||||
parentItem: { text: 'Incident' },
|
||||
path: getPath('incident/:id?'),
|
||||
},
|
||||
{
|
||||
icon: 'users-alt',
|
||||
id: 'users',
|
||||
hideFromBreadcrumbs: true,
|
||||
text: 'Users',
|
||||
path: getPath('users'),
|
||||
},
|
||||
{
|
||||
icon: 'plug',
|
||||
id: 'integrations',
|
||||
path: getPath('integrations'),
|
||||
hideFromBreadcrumbs: true,
|
||||
text: 'Integrations',
|
||||
},
|
||||
{
|
||||
icon: 'list-ul',
|
||||
id: 'escalations',
|
||||
text: 'Escalation Chains',
|
||||
hideFromBreadcrumbs: true,
|
||||
path: getPath('escalations'),
|
||||
},
|
||||
{
|
||||
icon: 'calendar-alt',
|
||||
id: 'schedules',
|
||||
text: 'Schedules',
|
||||
hideFromBreadcrumbs: true,
|
||||
path: getPath('schedules'),
|
||||
},
|
||||
{
|
||||
icon: 'calendar-alt',
|
||||
id: 'schedule',
|
||||
text: '',
|
||||
parentItem: { text: 'Schedule' },
|
||||
hideFromBreadcrumbs: true,
|
||||
hideFromTabs: true,
|
||||
path: getPath('schedule/:id?'),
|
||||
},
|
||||
{
|
||||
icon: 'comments-alt',
|
||||
id: 'chat-ops',
|
||||
text: 'ChatOps',
|
||||
path: getPath('chat-ops'),
|
||||
hideFromBreadcrumbs: true,
|
||||
hideFromTabs: isTopNavbar(),
|
||||
},
|
||||
{
|
||||
icon: 'link',
|
||||
id: 'outgoing_webhooks',
|
||||
text: 'Outgoing Webhooks',
|
||||
path: getPath('outgoing_webhooks'),
|
||||
hideFromBreadcrumbs: true,
|
||||
},
|
||||
{
|
||||
icon: 'wrench',
|
||||
id: 'maintenance',
|
||||
text: 'Maintenance',
|
||||
hideFromBreadcrumbs: true,
|
||||
path: getPath('maintenance'),
|
||||
},
|
||||
{
|
||||
icon: 'cog',
|
||||
id: 'settings',
|
||||
text: 'Organization Settings',
|
||||
hideFromBreadcrumbs: true,
|
||||
path: getPath('settings'),
|
||||
},
|
||||
{
|
||||
icon: 'table',
|
||||
id: 'live-settings',
|
||||
text: 'Env Variables',
|
||||
role: 'Admin',
|
||||
hideFromTabsFn: (store: RootBaseStore) => {
|
||||
const hasLiveSettings = store.hasFeature(AppFeature.LiveSettings);
|
||||
return isTopNavbar() || window.grafanaBootData.user.orgRole !== 'Admin' || !hasLiveSettings;
|
||||
},
|
||||
path: getPath('live-settings'),
|
||||
},
|
||||
{
|
||||
icon: 'cloud',
|
||||
id: 'cloud',
|
||||
text: 'Cloud',
|
||||
role: 'Admin',
|
||||
hideFromTabsFn: (store: RootBaseStore) => {
|
||||
const hasCloudFeature = store.hasFeature(AppFeature.CloudConnection);
|
||||
return isTopNavbar() || window.grafanaBootData.user.orgRole !== 'Admin' || !hasCloudFeature;
|
||||
},
|
||||
path: getPath('cloud'),
|
||||
},
|
||||
{
|
||||
icon: 'gf-logs',
|
||||
id: 'organization-logs',
|
||||
text: 'Org Logs',
|
||||
hideFromTabs: true,
|
||||
path: getPath('organization-logs'),
|
||||
},
|
||||
{
|
||||
icon: 'cog',
|
||||
id: 'test',
|
||||
text: 'Test',
|
||||
hideFromTabs: true,
|
||||
path: getPath('test'),
|
||||
},
|
||||
].reduce((prev, current) => {
|
||||
prev[current.id] = {
|
||||
...current,
|
||||
getPageNav: () =>
|
||||
({
|
||||
text: isTopNavbar() ? '' : current.text,
|
||||
parentItem: current.parentItem,
|
||||
hideFromBreadcrumbs: current.hideFromBreadcrumbs,
|
||||
hideFromTabs: current.hideFromTabs,
|
||||
} as NavModelItem),
|
||||
};
|
||||
|
||||
return prev;
|
||||
}, {});
|
||||
|
|
@ -1,7 +1,3 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import React from 'react';
|
|||
import { AppRootProps } from '@grafana/data';
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import { Button, LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
|
|
@ -25,6 +26,7 @@ import { IntegrationSettingsTab } from 'containers/IntegrationSettings/Integrati
|
|||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { AlertReceiveChannel } from 'models/alert_receive_channel';
|
||||
import { AlertReceiveChannelOption } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
import { pages } from 'pages';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
|
@ -130,13 +132,13 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
const searchResult = alertReceiveChannelStore.getSearchResult();
|
||||
|
||||
return (
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
objectName="integration"
|
||||
pageName="integrations"
|
||||
itemNotFoundMessage={`Integration with id=${query?.id} is not found. Please select integration from the list.`}
|
||||
>
|
||||
{() => (
|
||||
<PluginPage pageNav={pages['integrations'].getPageNav()}>
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
objectName="integration"
|
||||
pageName="integrations"
|
||||
itemNotFoundMessage={`Integration with id=${query?.id} is not found. Please select integration from the list.`}
|
||||
>
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('filters')}>
|
||||
|
|
@ -241,8 +243,8 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageErrorHandlingWrapper>
|
||||
</PageErrorHandlingWrapper>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.select {
|
||||
width: 400px;
|
||||
}
|
||||
|
|
@ -10,3 +6,7 @@
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: var(--title-marginBottom);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import React from 'react';
|
||||
|
||||
import { AppRootProps } from '@grafana/data';
|
||||
import { Button, HorizontalGroup } from '@grafana/ui';
|
||||
import { Button, VerticalGroup } from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
import moment from 'moment-timezone';
|
||||
import LegacyNavHeading from 'navbar/LegacyNavHeading';
|
||||
import Emoji from 'react-emoji-render';
|
||||
|
||||
import GTable from 'components/GTable/GTable';
|
||||
|
|
@ -15,6 +17,7 @@ import { WithPermissionControl } from 'containers/WithPermissionControl/WithPerm
|
|||
import { getAlertReceiveChannelDisplayName } from 'models/alert_receive_channel/alert_receive_channel.helpers';
|
||||
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
import { Maintenance, MaintenanceMode, MaintenanceType } from 'models/maintenance/maintenance.types';
|
||||
import { pages } from 'pages';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
|
@ -115,19 +118,21 @@ class MaintenancePage extends React.Component<MaintenancePageProps, MaintenanceP
|
|||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PluginPage pageNav={pages['maintenance'].getPageNav()}>
|
||||
<div className={cx('root')}>
|
||||
<GTable
|
||||
emptyText={data ? 'No maintenances found' : 'Loading...'}
|
||||
title={() => (
|
||||
<div className={cx('header')}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline' }}>
|
||||
<HorizontalGroup>
|
||||
<Text.Title level={3}>Maintenance</Text.Title>
|
||||
<Text type="secondary">
|
||||
<VerticalGroup>
|
||||
<LegacyNavHeading>
|
||||
<Text.Title level={3}>Maintenance</Text.Title>
|
||||
</LegacyNavHeading>
|
||||
<Text type="secondary" className={cx('title')}>
|
||||
Mute noisy sources or use for debugging and avoid bothering your colleagues.
|
||||
</Text>
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
<WithPermissionControl userAction={UserAction.UpdateMaintenances}>
|
||||
<Button
|
||||
|
|
@ -156,7 +161,7 @@ class MaintenancePage extends React.Component<MaintenancePageProps, MaintenanceP
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Button, HorizontalGroup, Tag, Tooltip } from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
|
|
@ -95,39 +96,41 @@ class OrganizationLogPage extends React.Component<OrganizationLogProps, Organiza
|
|||
const loading = !results;
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<OrganizationLogFilters value={filters} onChange={this.handleChangeOrganizationLogFilters} />
|
||||
<GTable
|
||||
rowKey="id"
|
||||
title={() => (
|
||||
<div className={cx('header')}>
|
||||
<Text.Title className={cx('users-title')} level={3}>
|
||||
Organization Logs
|
||||
</Text.Title>
|
||||
<Button onClick={this.refresh} icon={loading ? 'fa fa-spinner' : 'sync'}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
showHeader={true}
|
||||
data={results}
|
||||
loading={loading}
|
||||
emptyText={results ? 'No logs found' : 'Loading...'}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
page,
|
||||
total: Math.ceil((total || 0) / ITEMS_PER_PAGE),
|
||||
onChange: this.handleChangePage,
|
||||
}}
|
||||
rowClassName={cx('align-top')}
|
||||
expandable={{
|
||||
expandedRowRender: this.renderFullDescription,
|
||||
expandRowByClick: true,
|
||||
expandedRowKeys: expandedLogsKeys,
|
||||
onExpandedRowsChange: this.handleExpandedRowsChange,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<PluginPage>
|
||||
<div className={cx('root')}>
|
||||
<OrganizationLogFilters value={filters} onChange={this.handleChangeOrganizationLogFilters} />
|
||||
<GTable
|
||||
rowKey="id"
|
||||
title={() => (
|
||||
<div className={cx('header')}>
|
||||
<Text.Title className={cx('users-title')} level={3}>
|
||||
Organization Logs
|
||||
</Text.Title>
|
||||
<Button onClick={this.refresh} icon={loading ? 'fa fa-spinner' : 'sync'}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
showHeader={true}
|
||||
data={results}
|
||||
loading={loading}
|
||||
emptyText={results ? 'No logs found' : 'Loading...'}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
page,
|
||||
total: Math.ceil((total || 0) / ITEMS_PER_PAGE),
|
||||
onChange: this.handleChangePage,
|
||||
}}
|
||||
rowClassName={cx('align-top')}
|
||||
expandable={{
|
||||
expandedRowRender: this.renderFullDescription,
|
||||
expandRowByClick: true,
|
||||
expandedRowKeys: expandedLogsKeys,
|
||||
onExpandedRowsChange: this.handleExpandedRowsChange,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,82 +0,0 @@
|
|||
import 'jest/matchMedia.ts';
|
||||
import React from 'react';
|
||||
|
||||
import { describe, expect, test } from '@jest/globals';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import outgoingWebhooksStub from 'jest/outgoingWebhooksStub';
|
||||
|
||||
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
|
||||
import { OutgoingWebhooks } from 'pages/outgoing_webhooks/OutgoingWebhooks';
|
||||
|
||||
const outgoingWebhooks = outgoingWebhooksStub as OutgoingWebhook[];
|
||||
const outgoingWebhookStore = () => ({
|
||||
loadItem: () => Promise.resolve(outgoingWebhooks[0]),
|
||||
updateItems: () => Promise.resolve(),
|
||||
getSearchResult: () => outgoingWebhooks,
|
||||
items: outgoingWebhooks.reduce((prev, current) => {
|
||||
prev[current.id] = current;
|
||||
return prev;
|
||||
}, {}),
|
||||
});
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
config: {
|
||||
featureToggles: {
|
||||
topNav: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('state/useStore', () => ({
|
||||
useStore: () => ({
|
||||
outgoingWebhookStore: outgoingWebhookStore(),
|
||||
isUserActionAllowed: jest.fn().mockReturnValue(true),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
getLocationSrv: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('OutgoingWebhooks', () => {
|
||||
const storeMock = {
|
||||
isUserActionAllowed: jest.fn().mockReturnValue(true),
|
||||
outgoingWebhookStore: outgoingWebhookStore(),
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
console.warn = () => {};
|
||||
console.error = () => {};
|
||||
});
|
||||
|
||||
test('It renders all retrieved webhooks', async () => {
|
||||
render(<OutgoingWebhooks {...getProps()} />);
|
||||
|
||||
const gTable = screen.queryByTestId('test__gTable');
|
||||
const rows = gTable.querySelectorAll('tbody tr');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(() => queryEditForm()).toThrow(); // edit doesn't show for [id=undefined]
|
||||
expect(rows.length).toBe(outgoingWebhooks.length);
|
||||
});
|
||||
});
|
||||
|
||||
test('It opens Edit View if [id] is supplied', async () => {
|
||||
const id = outgoingWebhooks[0].id;
|
||||
render(<OutgoingWebhooks {...getProps(id)} />);
|
||||
|
||||
expect(() => queryEditForm()).toThrow(); // before updates kick in
|
||||
await waitFor(() => {
|
||||
expect(queryEditForm()).toBeDefined(); // edit shows for [id=?]
|
||||
});
|
||||
});
|
||||
|
||||
function getProps(id: OutgoingWebhook['id'] = undefined): any {
|
||||
return { store: storeMock, query: { id } };
|
||||
}
|
||||
|
||||
function queryEditForm(): HTMLElement {
|
||||
return screen.getByTestId<HTMLElement>('test__outgoingWebhookEditForm');
|
||||
}
|
||||
});
|
||||
|
|
@ -3,8 +3,10 @@ import React from 'react';
|
|||
import { AppRootProps } from '@grafana/data';
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import { Button, HorizontalGroup } from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
import LegacyNavHeading from 'navbar/LegacyNavHeading';
|
||||
|
||||
import GTable from 'components/GTable/GTable';
|
||||
import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
|
||||
|
|
@ -19,6 +21,8 @@ import OutgoingWebhookForm from 'containers/OutgoingWebhookForm/OutgoingWebhookF
|
|||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { ActionDTO } from 'models/action';
|
||||
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
|
||||
import { pages } from 'pages';
|
||||
import { getQueryParams } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
|
@ -39,12 +43,14 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
errorData: initErrorDataState(),
|
||||
};
|
||||
|
||||
private outgoingWebhookId: string;
|
||||
|
||||
async componentDidMount() {
|
||||
this.update().then(this.parseQueryParams);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: OutgoingWebhooksProps) {
|
||||
if (this.props.query.id !== prevProps.query.id) {
|
||||
componentDidUpdate() {
|
||||
if (this.outgoingWebhookId !== getQueryParams()['id']) {
|
||||
this.parseQueryParams();
|
||||
}
|
||||
}
|
||||
|
|
@ -55,10 +61,10 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
outgoingWebhookIdToEdit: undefined,
|
||||
})); // reset state on query parse
|
||||
|
||||
const {
|
||||
store,
|
||||
query: { id },
|
||||
} = this.props;
|
||||
const { store } = this.props;
|
||||
const { id } = getQueryParams();
|
||||
|
||||
this.outgoingWebhookId = id;
|
||||
|
||||
if (!id) {
|
||||
return;
|
||||
|
|
@ -109,31 +115,35 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
];
|
||||
|
||||
return (
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
objectName="outgoing webhook"
|
||||
pageName="outgoing_webhooks"
|
||||
itemNotFoundMessage={`Outgoing webhook with id=${query?.id} is not found. Please select outgoing webhook from the list.`}
|
||||
>
|
||||
{() => (
|
||||
<PluginPage pageNav={pages['outgoing_webhooks'].getPageNav()}>
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
objectName="outgoing webhook"
|
||||
pageName="outgoing_webhooks"
|
||||
itemNotFoundMessage={`Outgoing webhook with id=${query?.id} is not found. Please select outgoing webhook from the list.`}
|
||||
>
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<GTable
|
||||
emptyText={webhooks ? 'No outgoing webhooks found' : 'Loading...'}
|
||||
title={() => (
|
||||
<div className={cx('header')}>
|
||||
<Text.Title level={3}>Outgoing Webhooks</Text.Title>
|
||||
<PluginLink
|
||||
partial
|
||||
query={{ id: 'new' }}
|
||||
disabled={!store.isUserActionAllowed(UserAction.UpdateCustomActions)}
|
||||
>
|
||||
<WithPermissionControl userAction={UserAction.UpdateCustomActions}>
|
||||
<Button variant="primary" icon="plus">
|
||||
Create
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
</PluginLink>
|
||||
<LegacyNavHeading>
|
||||
<Text.Title level={3}>Outgoing Webhooks</Text.Title>
|
||||
</LegacyNavHeading>
|
||||
<div className="u-pull-right">
|
||||
<PluginLink
|
||||
partial
|
||||
query={{ id: 'new' }}
|
||||
disabled={!store.isUserActionAllowed(UserAction.UpdateCustomActions)}
|
||||
>
|
||||
<WithPermissionControl userAction={UserAction.UpdateCustomActions}>
|
||||
<Button variant="primary" icon="plus">
|
||||
Create
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
</PluginLink>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
rowKey="id"
|
||||
|
|
@ -149,8 +159,8 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageErrorHandlingWrapper>
|
||||
</PageErrorHandlingWrapper>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
108
grafana-plugin/src/pages/routes.tsx
Normal file
108
grafana-plugin/src/pages/routes.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { AppRootProps } from '@grafana/data';
|
||||
|
||||
import { PageDefinition } from 'pages';
|
||||
import EscalationsChainsPage from 'pages/escalation-chains/EscalationChains';
|
||||
import IncidentPage from 'pages/incident/Incident';
|
||||
import IncidentsPage from 'pages/incidents/Incidents';
|
||||
import IntegrationsPage from 'pages/integrations/Integrations';
|
||||
import MaintenancePage from 'pages/maintenance/Maintenance';
|
||||
import OrganizationLogPage from 'pages/organization-logs/OrganizationLog';
|
||||
import OutgoingWebhooks from 'pages/outgoing_webhooks/OutgoingWebhooks';
|
||||
import SchedulePage from 'pages/schedule/Schedule';
|
||||
import SchedulesPage from 'pages/schedules/Schedules';
|
||||
import SettingsPage from 'pages/settings/SettingsPage';
|
||||
import ChatOpsPage from 'pages/settings/tabs/ChatOps/ChatOps';
|
||||
import CloudPage from 'pages/settings/tabs/Cloud/CloudPage';
|
||||
import LiveSettingsPage from 'pages/settings/tabs/LiveSettings/LiveSettingsPage';
|
||||
import Test from 'pages/test/Test';
|
||||
import UsersPage from 'pages/users/Users';
|
||||
|
||||
export interface NavMenuItem {
|
||||
meta: AppRootProps['meta'];
|
||||
pages: { [id: string]: PageDefinition };
|
||||
path: string;
|
||||
page: string;
|
||||
grafanaUser: {
|
||||
orgRole: 'Viewer' | 'Editor' | 'Admin';
|
||||
};
|
||||
enableLiveSettings: boolean;
|
||||
enableCloudPage: boolean;
|
||||
enableNewSchedulesPage: boolean;
|
||||
backendLicense: string;
|
||||
onNavChanged: any;
|
||||
}
|
||||
|
||||
export interface NavRoute {
|
||||
id: string;
|
||||
component: (props?: any) => JSX.Element;
|
||||
}
|
||||
|
||||
export const routes: { [id: string]: NavRoute } = [
|
||||
{
|
||||
component: IncidentsPage,
|
||||
id: 'incidents',
|
||||
},
|
||||
{
|
||||
component: IncidentPage,
|
||||
id: 'incident',
|
||||
},
|
||||
{
|
||||
component: UsersPage,
|
||||
id: 'users',
|
||||
},
|
||||
{
|
||||
component: IntegrationsPage,
|
||||
id: 'integrations',
|
||||
},
|
||||
{
|
||||
component: EscalationsChainsPage,
|
||||
id: 'escalations',
|
||||
},
|
||||
{
|
||||
component: SchedulesPage,
|
||||
id: 'schedules',
|
||||
},
|
||||
{
|
||||
component: SchedulePage,
|
||||
id: 'schedule',
|
||||
},
|
||||
{
|
||||
component: ChatOpsPage,
|
||||
id: 'chat-ops',
|
||||
},
|
||||
{
|
||||
component: OutgoingWebhooks,
|
||||
id: 'outgoing_webhooks',
|
||||
},
|
||||
{
|
||||
component: MaintenancePage,
|
||||
id: 'maintenance',
|
||||
},
|
||||
{
|
||||
component: SettingsPage,
|
||||
id: 'settings',
|
||||
},
|
||||
{
|
||||
component: LiveSettingsPage,
|
||||
id: 'live-settings',
|
||||
},
|
||||
{
|
||||
component: OrganizationLogPage,
|
||||
id: 'organization-logs',
|
||||
},
|
||||
{
|
||||
component: CloudPage,
|
||||
id: 'cloud',
|
||||
},
|
||||
{
|
||||
component: Test,
|
||||
id: 'test',
|
||||
},
|
||||
].reduce((prev, current) => {
|
||||
prev[current.id] = {
|
||||
id: current.id,
|
||||
component: current.component,
|
||||
};
|
||||
|
||||
return prev;
|
||||
}, {});
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
.root {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
margin-top: 24px;
|
||||
|
||||
--rotations-border: var(--border-weak);
|
||||
--rotations-background: var(--background-secondary);
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ import React from 'react';
|
|||
|
||||
import { AppRootProps } from '@grafana/data';
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import { Button, HorizontalGroup, Icon, IconButton, Modal, ToolbarButton, VerticalGroup } from '@grafana/ui';
|
||||
import { Button, HorizontalGroup, VerticalGroup, IconButton, ToolbarButton, Icon, Modal } from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import dayjs from 'dayjs';
|
||||
import { omit } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import PageErrorHandlingWrapper from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import ScheduleWarning from 'components/ScheduleWarning/ScheduleWarning';
|
||||
import Text from 'components/Text/Text';
|
||||
|
|
@ -21,6 +23,8 @@ import ScheduleICalSettings from 'containers/ScheduleIcalLink/ScheduleIcalLink';
|
|||
import UsersTimezones from 'containers/UsersTimezones/UsersTimezones';
|
||||
import { Schedule, ScheduleType, Shift } from 'models/schedule/schedule.types';
|
||||
import { Timezone } from 'models/timezone/timezone.types';
|
||||
import { pages } from 'pages';
|
||||
import { getQueryParams } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
|
@ -64,9 +68,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
|
||||
async componentDidMount() {
|
||||
const { store } = this.props;
|
||||
const {
|
||||
query: { id },
|
||||
} = this.props;
|
||||
const { id } = getQueryParams();
|
||||
|
||||
store.userStore.updateItems();
|
||||
|
||||
|
|
@ -85,10 +87,9 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
query: { id: scheduleId },
|
||||
store,
|
||||
} = this.props;
|
||||
const { store } = this.props;
|
||||
const { id: scheduleId } = getQueryParams();
|
||||
|
||||
const {
|
||||
startMoment,
|
||||
|
||||
|
|
@ -110,143 +111,149 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
shiftIdToShowOverridesForm;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<VerticalGroup spacing="lg">
|
||||
<div className={cx('header')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<PluginLink query={{ page: 'schedules' }}>
|
||||
<IconButton style={{ marginTop: '5px' }} name="arrow-left" size="xl" />
|
||||
</PluginLink>
|
||||
<Text.Title editable editModalTitle="Schedule name" level={2} onTextChange={this.handleNameChange}>
|
||||
{schedule?.name}
|
||||
</Text.Title>
|
||||
{schedule && <ScheduleWarning item={schedule} />}
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup spacing="lg">
|
||||
{users && (
|
||||
<HorizontalGroup>
|
||||
<Text type="secondary">Current timezone:</Text>
|
||||
<UserTimezoneSelect value={currentTimezone} users={users} onChange={this.handleTimezoneChange} />
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
<HorizontalGroup>
|
||||
{schedule?.type === ScheduleType.Ical && (
|
||||
<HorizontalGroup>
|
||||
<Button variant="secondary" onClick={this.handleExportClick()}>
|
||||
Export
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={this.handleReloadClick(scheduleId)}>
|
||||
Reload
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
<ToolbarButton
|
||||
icon="cog"
|
||||
tooltip="Settings"
|
||||
onClick={() => {
|
||||
this.setState({ showEditForm: true });
|
||||
}}
|
||||
/>
|
||||
<WithConfirm>
|
||||
<ToolbarButton icon="trash-alt" tooltip="Delete" onClick={this.handleDelete} />
|
||||
</WithConfirm>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
{schedule?.type !== ScheduleType.API && (
|
||||
<Text className={cx('desc')} type="secondary">
|
||||
Ical and API/Terraform schedules are read-only
|
||||
</Text>
|
||||
)}
|
||||
<div className={cx('users-timezones')}>
|
||||
<UsersTimezones
|
||||
scheduleId={scheduleId}
|
||||
startMoment={startMoment}
|
||||
onCallNow={schedule?.on_call_now || []}
|
||||
userIds={
|
||||
scheduleStore.relatedUsers[scheduleId] ? Object.keys(scheduleStore.relatedUsers[scheduleId]) : []
|
||||
}
|
||||
tz={currentTimezone}
|
||||
onTzChange={this.handleTimezoneChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={cx('rotations')}>
|
||||
<div className={cx('controls')}>
|
||||
<PluginPage pageNav={pages['schedule'].getPageNav()}>
|
||||
<PageErrorHandlingWrapper pageName="schedules">
|
||||
<div className={cx('root')}>
|
||||
<VerticalGroup spacing="lg">
|
||||
<div className={cx('header')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<Button variant="secondary" onClick={this.handleTodayClick}>
|
||||
Today
|
||||
</Button>
|
||||
<HorizontalGroup spacing="xs">
|
||||
<Button variant="secondary" onClick={this.handleLeftClick}>
|
||||
<Icon name="angle-left" />
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={this.handleRightClick}>
|
||||
<Icon name="angle-right" />
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
<Text.Title style={{ marginLeft: '8px' }} level={4} type="primary">
|
||||
{startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')}
|
||||
<PluginLink query={{ page: 'schedules' }}>
|
||||
<IconButton style={{ marginTop: '5px' }} name="arrow-left" size="xl" />
|
||||
</PluginLink>
|
||||
<Text.Title editable editModalTitle="Schedule name" level={2} onTextChange={this.handleNameChange}>
|
||||
{schedule?.name}
|
||||
</Text.Title>
|
||||
{schedule && <ScheduleWarning item={schedule} />}
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup spacing="lg">
|
||||
{users && (
|
||||
<HorizontalGroup>
|
||||
<Text type="secondary">Current timezone:</Text>
|
||||
<UserTimezoneSelect
|
||||
value={currentTimezone}
|
||||
users={users}
|
||||
onChange={this.handleTimezoneChange}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
<HorizontalGroup>
|
||||
{schedule?.type === ScheduleType.Ical && (
|
||||
<HorizontalGroup>
|
||||
<Button variant="secondary" onClick={this.handleExportClick()}>
|
||||
Export
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={this.handleReloadClick(scheduleId)}>
|
||||
Reload
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
<ToolbarButton
|
||||
icon="cog"
|
||||
tooltip="Settings"
|
||||
onClick={() => {
|
||||
this.setState({ showEditForm: true });
|
||||
}}
|
||||
/>
|
||||
<WithConfirm>
|
||||
<ToolbarButton icon="trash-alt" tooltip="Delete" onClick={this.handleDelete} />
|
||||
</WithConfirm>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<ScheduleFinal
|
||||
scheduleId={scheduleId}
|
||||
currentTimezone={currentTimezone}
|
||||
startMoment={startMoment}
|
||||
onClick={this.handleShowForm}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Rotations
|
||||
scheduleId={scheduleId}
|
||||
currentTimezone={currentTimezone}
|
||||
startMoment={startMoment}
|
||||
onCreate={this.handleCreateRotation}
|
||||
onUpdate={this.handleUpdateRotation}
|
||||
onDelete={this.handleDeleteRotation}
|
||||
shiftIdToShowRotationForm={shiftIdToShowRotationForm}
|
||||
onShowRotationForm={this.handleShowRotationForm}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ScheduleOverrides
|
||||
scheduleId={scheduleId}
|
||||
currentTimezone={currentTimezone}
|
||||
startMoment={startMoment}
|
||||
onCreate={this.handleCreateOverride}
|
||||
onUpdate={this.handleUpdateOverride}
|
||||
onDelete={this.handleDeleteOverride}
|
||||
shiftIdToShowRotationForm={shiftIdToShowOverridesForm}
|
||||
onShowRotationForm={this.handleShowOverridesForm}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
{showEditForm && (
|
||||
<ScheduleForm
|
||||
id={schedule.id}
|
||||
onUpdate={this.update}
|
||||
onHide={() => {
|
||||
this.setState({ showEditForm: false });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showScheduleICalSettings && (
|
||||
<Modal
|
||||
isOpen
|
||||
title="Schedule export"
|
||||
closeOnEscape
|
||||
onDismiss={() => this.setState({ showScheduleICalSettings: false })}
|
||||
>
|
||||
<ScheduleICalSettings id={scheduleId} />
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
{schedule?.type !== ScheduleType.API && (
|
||||
<Text className={cx('desc')} type="secondary">
|
||||
Ical and API/Terraform schedules are read-only
|
||||
</Text>
|
||||
)}
|
||||
<div className={cx('users-timezones')}>
|
||||
<UsersTimezones
|
||||
scheduleId={scheduleId}
|
||||
startMoment={startMoment}
|
||||
onCallNow={schedule?.on_call_now || []}
|
||||
userIds={
|
||||
scheduleStore.relatedUsers[scheduleId] ? Object.keys(scheduleStore.relatedUsers[scheduleId]) : []
|
||||
}
|
||||
tz={currentTimezone}
|
||||
onTzChange={this.handleTimezoneChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={cx('rotations')}>
|
||||
<div className={cx('controls')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<Button variant="secondary" onClick={this.handleTodayClick}>
|
||||
Today
|
||||
</Button>
|
||||
<HorizontalGroup spacing="xs">
|
||||
<Button variant="secondary" onClick={this.handleLeftClick}>
|
||||
<Icon name="angle-left" />
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={this.handleRightClick}>
|
||||
<Icon name="angle-right" />
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
<Text.Title style={{ marginLeft: '8px' }} level={4} type="primary">
|
||||
{startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')}
|
||||
</Text.Title>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<ScheduleFinal
|
||||
scheduleId={scheduleId}
|
||||
currentTimezone={currentTimezone}
|
||||
startMoment={startMoment}
|
||||
onClick={this.handleShowForm}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Rotations
|
||||
scheduleId={scheduleId}
|
||||
currentTimezone={currentTimezone}
|
||||
startMoment={startMoment}
|
||||
onCreate={this.handleCreateRotation}
|
||||
onUpdate={this.handleUpdateRotation}
|
||||
onDelete={this.handleDeleteRotation}
|
||||
shiftIdToShowRotationForm={shiftIdToShowRotationForm}
|
||||
onShowRotationForm={this.handleShowRotationForm}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ScheduleOverrides
|
||||
scheduleId={scheduleId}
|
||||
currentTimezone={currentTimezone}
|
||||
startMoment={startMoment}
|
||||
onCreate={this.handleCreateOverride}
|
||||
onUpdate={this.handleUpdateOverride}
|
||||
onDelete={this.handleDeleteOverride}
|
||||
shiftIdToShowRotationForm={shiftIdToShowOverridesForm}
|
||||
onShowRotationForm={this.handleShowOverridesForm}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
{showEditForm && (
|
||||
<ScheduleForm
|
||||
id={schedule.id}
|
||||
onUpdate={this.update}
|
||||
onHide={() => {
|
||||
this.setState({ showEditForm: false });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showScheduleICalSettings && (
|
||||
<Modal
|
||||
isOpen
|
||||
title="Schedule export"
|
||||
closeOnEscape
|
||||
onDismiss={() => this.setState({ showScheduleICalSettings: false })}
|
||||
>
|
||||
<ScheduleICalSettings id={scheduleId} />
|
||||
</Modal>
|
||||
)}
|
||||
</PageErrorHandlingWrapper>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -292,10 +299,8 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
};
|
||||
|
||||
updateEvents = () => {
|
||||
const {
|
||||
store,
|
||||
query: { id: scheduleId },
|
||||
} = this.props;
|
||||
const { store } = this.props;
|
||||
const { id: scheduleId } = getQueryParams();
|
||||
|
||||
const { startMoment } = this.state;
|
||||
|
||||
|
|
@ -419,10 +424,8 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
};
|
||||
|
||||
updateEventsFor = async (scheduleId: Schedule['id'], withEmpty = true, with_gap = true) => {
|
||||
const {
|
||||
store,
|
||||
query: { id },
|
||||
} = this.props;
|
||||
const { store } = this.props;
|
||||
const { id } = getQueryParams();
|
||||
|
||||
const { scheduleStore } = store;
|
||||
|
||||
|
|
@ -441,10 +444,8 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
};
|
||||
|
||||
handleDelete = () => {
|
||||
const {
|
||||
store,
|
||||
query: { id: scheduleId },
|
||||
} = this.props;
|
||||
const { store } = this.props;
|
||||
const { id: scheduleId } = getQueryParams();
|
||||
|
||||
store.scheduleStore.delete(scheduleId).then(() => {
|
||||
getLocationSrv().update({ query: { page: 'schedules' } });
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.schedule {
|
||||
position: relative;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: var(--title-marginBottom);
|
||||
}
|
||||
|
||||
.root .buttons {
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import { Button, HorizontalGroup, IconButton, LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import dayjs from 'dayjs';
|
||||
import { debounce } from 'lodash-es';
|
||||
|
|
@ -25,6 +26,7 @@ import { WithPermissionControl } from 'containers/WithPermissionControl/WithPerm
|
|||
import { Schedule, ScheduleType } from 'models/schedule/schedule.types';
|
||||
import { getSlackChannelName } from 'models/slack_channel/slack_channel.helpers';
|
||||
import { Timezone } from 'models/timezone/timezone.types';
|
||||
import { pages } from 'pages';
|
||||
import { getStartOfWeek } from 'pages/schedule/Schedule.helpers';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
|
|
@ -133,7 +135,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
|
|||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PluginPage pageNav={pages['schedules'].getPageNav()}>
|
||||
<div className={cx('root')}>
|
||||
<VerticalGroup>
|
||||
<HorizontalGroup justify="space-between">
|
||||
|
|
@ -190,7 +192,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
import moment from 'moment-timezone';
|
||||
|
||||
import { Schedule } from 'models/schedule/schedule.types';
|
||||
|
||||
const DATE_FORMAT = 'HH:mm YYYY-MM-DD';
|
||||
|
||||
function isToday(m: moment.Moment) {
|
||||
return m.isSame('day');
|
||||
}
|
||||
|
||||
function isYesterday(m: moment.Moment, currentMoment: moment.Moment) {
|
||||
return m.diff(currentMoment, 'days') === -1;
|
||||
}
|
||||
|
||||
function isTomorrow(m: moment.Moment, currentMoment: moment.Moment) {
|
||||
return m.diff(currentMoment, 'days') === 1;
|
||||
}
|
||||
|
||||
export function prepareForEdit(schedule: Schedule) {
|
||||
return {
|
||||
...schedule,
|
||||
slack_channel_id: schedule.slack_channel?.id,
|
||||
user_group: schedule.user_group?.id,
|
||||
};
|
||||
}
|
||||
|
||||
function humanize(m: moment.Moment, currentMoment: moment.Moment) {
|
||||
if (isToday(m)) {
|
||||
return 'Today';
|
||||
}
|
||||
if (isYesterday(m, currentMoment)) {
|
||||
return 'Yesterday';
|
||||
}
|
||||
|
||||
if (isTomorrow(m, currentMoment)) {
|
||||
return 'Tomorrow';
|
||||
}
|
||||
|
||||
return m.format(DATE_FORMAT);
|
||||
}
|
||||
|
||||
export function getDatesString(start: string, end: string, allDay: boolean) {
|
||||
const startMoment = moment(start);
|
||||
const endMoment = moment(end);
|
||||
const currentMoment = moment();
|
||||
|
||||
if (allDay) {
|
||||
if (startMoment.isSame(endMoment, 'day')) {
|
||||
return 'All-day';
|
||||
}
|
||||
|
||||
return `${startMoment.format(DATE_FORMAT)} — ${endMoment.format(DATE_FORMAT)}`;
|
||||
}
|
||||
|
||||
if (startMoment.isSame(endMoment, 'day')) {
|
||||
return `${startMoment.format('LT')} — ${endMoment.format('LT')}`;
|
||||
}
|
||||
|
||||
let startString = humanize(startMoment, currentMoment);
|
||||
|
||||
let endString = humanize(endMoment, currentMoment);
|
||||
|
||||
return `${startString} — ${endString}`;
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
width: 50%;
|
||||
margin: 20px auto;
|
||||
white-space: break-spaces;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.events {
|
||||
margin: 16px 32px;
|
||||
}
|
||||
|
||||
.events-list {
|
||||
margin: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.events-list-item {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.priority-icon {
|
||||
width: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--secondary-background);
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gap-between-shifts {
|
||||
width: 520px;
|
||||
padding: 5px 5px 5px 24px;
|
||||
background-color: rgba(209, 14, 92, 0.15);
|
||||
border: 1px solid rgba(209, 14, 92, 0.15);
|
||||
border-radius: 50px;
|
||||
color: #ff5286;
|
||||
font-weight: 400;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
|
@ -1,555 +0,0 @@
|
|||
import React, { SyntheticEvent } from 'react';
|
||||
|
||||
import { AppRootProps } from '@grafana/data';
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import {
|
||||
Button,
|
||||
ConfirmModal,
|
||||
HorizontalGroup,
|
||||
Icon,
|
||||
LoadingPlaceholder,
|
||||
Modal,
|
||||
PENDING_COLOR,
|
||||
Tooltip,
|
||||
VerticalGroup,
|
||||
} from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { omit } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
import instructionsImage from 'assets/img/events_instructions.png';
|
||||
import Avatar from 'components/Avatar/Avatar';
|
||||
import GTable from 'components/GTable/GTable';
|
||||
import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
|
||||
import {
|
||||
getWrongTeamResponseInfo,
|
||||
initErrorDataState,
|
||||
} from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import SchedulesFilters from 'components/SchedulesFilters/SchedulesFilters';
|
||||
import { SchedulesFiltersType } from 'components/SchedulesFilters/SchedulesFilters.types';
|
||||
import Text from 'components/Text/Text';
|
||||
import Tutorial from 'components/Tutorial/Tutorial';
|
||||
import { TutorialStep } from 'components/Tutorial/Tutorial.types';
|
||||
import ScheduleForm from 'containers/ScheduleForm/ScheduleForm';
|
||||
import ScheduleICalSettings from 'containers/ScheduleIcalLink/ScheduleIcalLink';
|
||||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { Schedule, ScheduleEvent, ScheduleType } from 'models/schedule/schedule.types';
|
||||
import { getSlackChannelName } from 'models/slack_channel/slack_channel.helpers';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
import { openErrorNotification } from 'utils';
|
||||
|
||||
import { getDatesString } from './Schedules.helpers';
|
||||
|
||||
import styles from './Schedules.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface SchedulesPageProps extends WithStoreProps, AppRootProps {}
|
||||
interface SchedulesPageState extends PageBaseState {
|
||||
scheduleIdToEdit?: Schedule['id'];
|
||||
scheduleIdToDelete?: Schedule['id'];
|
||||
scheduleIdToExport?: Schedule['id'];
|
||||
filters: SchedulesFiltersType;
|
||||
expandedSchedulesKeys: Array<Schedule['id']>;
|
||||
}
|
||||
|
||||
@observer
|
||||
class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageState> {
|
||||
state: SchedulesPageState = {
|
||||
filters: {
|
||||
selectedDate: moment().startOf('day').format('YYYY-MM-DD'),
|
||||
},
|
||||
expandedSchedulesKeys: [],
|
||||
errorData: initErrorDataState(),
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.update().then(this.parseQueryParams);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: SchedulesPageProps) {
|
||||
if (this.props.query.id !== prevProps.query.id) {
|
||||
this.parseQueryParams();
|
||||
}
|
||||
}
|
||||
|
||||
parseQueryParams = async () => {
|
||||
this.setState({ errorData: initErrorDataState() }); // reset wrong team error to false on query parse
|
||||
|
||||
const {
|
||||
store,
|
||||
query: { id },
|
||||
} = this.props;
|
||||
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
let scheduleId: string = undefined;
|
||||
const isNewSchedule = id === 'new';
|
||||
|
||||
if (!isNewSchedule) {
|
||||
// load schedule only for valid id
|
||||
const schedule = await store.scheduleStore
|
||||
.loadItem(id, true)
|
||||
.catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } }));
|
||||
if (!schedule) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleId = schedule.id;
|
||||
}
|
||||
|
||||
if (scheduleId || isNewSchedule) {
|
||||
this.setState({ scheduleIdToEdit: id });
|
||||
} else {
|
||||
openErrorNotification(`Schedule with id=${id} is not found. Please select schedule from the list.`);
|
||||
}
|
||||
};
|
||||
|
||||
update = () => {
|
||||
const { store } = this.props;
|
||||
const { scheduleStore } = store;
|
||||
|
||||
return scheduleStore.updateItems();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { store, query } = this.props;
|
||||
const { expandedSchedulesKeys, scheduleIdToDelete, scheduleIdToEdit, scheduleIdToExport } = this.state;
|
||||
const { filters, errorData } = this.state;
|
||||
const { scheduleStore } = store;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
width: '10%',
|
||||
title: 'Type',
|
||||
dataIndex: 'type',
|
||||
render: this.renderType,
|
||||
},
|
||||
{
|
||||
width: '20%',
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
width: '20%',
|
||||
title: 'OnCall now',
|
||||
render: this.renderOncallNow,
|
||||
},
|
||||
{
|
||||
width: '10%',
|
||||
title: 'Slack channel',
|
||||
render: this.renderChannelName,
|
||||
},
|
||||
{
|
||||
width: '10%',
|
||||
title: 'Slack user group',
|
||||
render: this.renderUserGroup,
|
||||
},
|
||||
{
|
||||
width: '10%',
|
||||
key: 'warning',
|
||||
render: this.renderWarning,
|
||||
},
|
||||
{
|
||||
width: '20%',
|
||||
key: 'action',
|
||||
render: this.renderActionButtons,
|
||||
},
|
||||
];
|
||||
|
||||
const schedules = scheduleStore.getSearchResult();
|
||||
|
||||
const timezoneStr = moment.tz.guess();
|
||||
const offset = moment().tz(timezoneStr).format('Z');
|
||||
|
||||
return (
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
objectName="schedule"
|
||||
pageName="schedules"
|
||||
itemNotFoundMessage={`Schedule with id=${query?.id} is not found. Please select schedule from the list.`}
|
||||
>
|
||||
{() => (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('title')}>
|
||||
<HorizontalGroup align="flex-end">
|
||||
<Text.Title level={3}>On-call Schedules</Text.Title>
|
||||
<Text type="secondary">
|
||||
Use this to distribute notifications among team members you specified in the "Notify Users from
|
||||
on-call schedule" step in{' '}
|
||||
<PluginLink query={{ page: 'integrations' }}>escalation chains</PluginLink>.
|
||||
</Text>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
|
||||
{!schedules || schedules.length ? (
|
||||
<GTable
|
||||
emptyText={schedules ? 'No schedules found' : 'Loading...'}
|
||||
title={() => (
|
||||
<div className={cx('header')}>
|
||||
<HorizontalGroup className={cx('filters')} spacing="md">
|
||||
<SchedulesFilters value={filters} onChange={this.handleChangeFilters} />
|
||||
<Text type="secondary">
|
||||
<Icon name="info-circle" /> Your timezone is {timezoneStr} UTC{offset}
|
||||
</Text>
|
||||
</HorizontalGroup>
|
||||
<PluginLink
|
||||
partial
|
||||
query={{ id: 'new' }}
|
||||
disabled={!store.isUserActionAllowed(UserAction.UpdateSchedules)}
|
||||
>
|
||||
<WithPermissionControl userAction={UserAction.UpdateSchedules}>
|
||||
<Button variant="primary" icon="plus">
|
||||
New schedule
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
</PluginLink>
|
||||
</div>
|
||||
)}
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
data={schedules}
|
||||
expandable={{
|
||||
expandedRowRender: this.renderEvents,
|
||||
expandRowByClick: true,
|
||||
onExpand: this.onRowExpand,
|
||||
expandedRowKeys: expandedSchedulesKeys,
|
||||
onExpandedRowsChange: this.handleExpandedRowsChange,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Tutorial
|
||||
step={TutorialStep.Schedules}
|
||||
title={
|
||||
<VerticalGroup align="center" spacing="lg">
|
||||
<Text type="secondary">You haven’t added a schedule yet.</Text>
|
||||
<PluginLink partial query={{ id: 'new' }}>
|
||||
<Button icon="plus" variant="primary" size="lg">
|
||||
Add team schedule for on-call rotation
|
||||
</Button>
|
||||
</PluginLink>
|
||||
</VerticalGroup>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{scheduleIdToEdit && (
|
||||
<ScheduleForm
|
||||
id={scheduleIdToEdit}
|
||||
type={ScheduleType.Ical}
|
||||
onUpdate={this.update}
|
||||
onHide={() => {
|
||||
this.setState({ scheduleIdToEdit: undefined });
|
||||
getLocationSrv().update({ partial: true, query: { id: undefined } });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{scheduleIdToDelete && (
|
||||
<ConfirmModal
|
||||
isOpen
|
||||
title="Are you sure to delete?"
|
||||
confirmText="Delete"
|
||||
dismissText="Cancel"
|
||||
onConfirm={this.handleDelete}
|
||||
body={null}
|
||||
onDismiss={() => {
|
||||
this.setState({ scheduleIdToDelete: undefined });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{scheduleIdToExport && (
|
||||
<Modal
|
||||
isOpen
|
||||
title="Schedule export"
|
||||
closeOnEscape
|
||||
onDismiss={() => this.setState({ scheduleIdToExport: undefined })}
|
||||
>
|
||||
<ScheduleICalSettings id={scheduleIdToExport} />
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageErrorHandlingWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
onRowExpand = (expanded: boolean, schedule: Schedule) => {
|
||||
if (expanded) {
|
||||
this.updateEventsFor(schedule.id);
|
||||
}
|
||||
};
|
||||
|
||||
handleExpandedRowsChange = (expandedRows: string[]) => {
|
||||
this.setState({ expandedSchedulesKeys: expandedRows });
|
||||
};
|
||||
|
||||
renderEvents = (schedule: Schedule) => {
|
||||
const { store } = this.props;
|
||||
const { scheduleStore } = store;
|
||||
const { scheduleToScheduleEvents } = scheduleStore;
|
||||
|
||||
const events = scheduleToScheduleEvents[schedule.id];
|
||||
|
||||
return events ? (
|
||||
events.length ? (
|
||||
<div className={cx('events')}>
|
||||
<Text.Title type="secondary" level={3}>
|
||||
Events
|
||||
</Text.Title>
|
||||
<ul className={cx('events-list')}>
|
||||
{(events || []).map((event, idx) => (
|
||||
<li key={idx} className={cx('events-list-item')}>
|
||||
<Event event={event} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
this.renderInstruction()
|
||||
)
|
||||
) : (
|
||||
<LoadingPlaceholder text="Loading events..." />
|
||||
);
|
||||
};
|
||||
|
||||
renderInstruction = () => {
|
||||
const { store } = this.props;
|
||||
const { userStore } = store;
|
||||
|
||||
return (
|
||||
<div className={cx('instructions')}>
|
||||
<Text type="secondary">
|
||||
There are no active slots here. To add an event, enter a username, for example “
|
||||
{userStore.currentUser?.username}“, and click the “Reload” button. OnCall will download this calendar and set
|
||||
up an on-call schedule based on event names. OnCall will refresh the calendar every 10 minutes after the
|
||||
intial setup.
|
||||
</Text>
|
||||
<img style={{ width: '400px' }} src={instructionsImage} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
handleChangeFilters = (filters: SchedulesFiltersType) => {
|
||||
this.setState({ filters }, () => {
|
||||
const { filters, expandedSchedulesKeys } = this.state;
|
||||
|
||||
if (!filters.selectedDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
expandedSchedulesKeys.forEach((id) => this.updateEventsFor(id));
|
||||
});
|
||||
};
|
||||
|
||||
renderChannelName = (value: Schedule) => {
|
||||
return getSlackChannelName(value.slack_channel) || '-';
|
||||
};
|
||||
|
||||
renderUserGroup = (value: Schedule) => {
|
||||
return value.user_group?.handle || '-';
|
||||
};
|
||||
|
||||
renderOncallNow = (item: Schedule, _index: number) => {
|
||||
if (item.on_call_now?.length > 0) {
|
||||
return item.on_call_now.map((user, _index) => {
|
||||
return (
|
||||
<PluginLink key={user.pk} query={{ page: 'users', id: user.pk }}>
|
||||
<div>
|
||||
<Avatar size="small" src={user.avatar} />
|
||||
<Text type="secondary"> {user.username}</Text>
|
||||
</div>
|
||||
</PluginLink>
|
||||
);
|
||||
});
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
renderType = (value: number) => {
|
||||
type tTypeToVerbal = {
|
||||
[key: number]: string;
|
||||
};
|
||||
const typeToVerbal: tTypeToVerbal = { 0: 'API/Terraform', 1: 'Ical', 2: 'Web' };
|
||||
return typeToVerbal[value];
|
||||
};
|
||||
|
||||
renderWarning = (item: Schedule) => {
|
||||
if (item.warnings.length > 0) {
|
||||
const tooltipContent = (
|
||||
<div>
|
||||
{item.warnings.map((warning: string) => (
|
||||
<p key={warning}>{warning}</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Tooltip placement="top" content={tooltipContent}>
|
||||
<Icon style={{ color: PENDING_COLOR }} name="exclamation-triangle" />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
renderActionButtons = (record: Schedule) => {
|
||||
return (
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<WithPermissionControl key="edit" userAction={UserAction.UpdateSchedules}>
|
||||
<Button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
this.setState({ scheduleIdToEdit: record.id });
|
||||
|
||||
getLocationSrv().update({ partial: true, query: { id: record.id } });
|
||||
}}
|
||||
fill="text"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
<WithPermissionControl key="reload" userAction={UserAction.UpdateSchedules}>
|
||||
<Button onClick={this.getReloadScheduleClickHandler(record.id)} fill="text">
|
||||
Reload
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
<WithPermissionControl key="export" userAction={UserAction.UpdateSchedules}>
|
||||
<Button onClick={this.getExportScheduleClickHandler(record.id)} fill="text">
|
||||
Export
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
<WithPermissionControl key="delete" userAction={UserAction.UpdateSchedules}>
|
||||
<Button onClick={this.getDeleteScheduleClickHandler(record.id)} fill="text" variant="destructive">
|
||||
Delete
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
</HorizontalGroup>
|
||||
);
|
||||
};
|
||||
|
||||
updateEventsFor = async (scheduleId: Schedule['id'], withEmpty = true, with_gap = true) => {
|
||||
const { store } = this.props;
|
||||
|
||||
const { scheduleStore } = store;
|
||||
const {
|
||||
filters: { selectedDate },
|
||||
} = this.state;
|
||||
|
||||
store.scheduleStore.scheduleToScheduleEvents = omit(store.scheduleStore.scheduleToScheduleEvents, [scheduleId]);
|
||||
|
||||
this.forceUpdate();
|
||||
|
||||
await scheduleStore.updateScheduleEvents(scheduleId, withEmpty, with_gap, selectedDate, moment.tz.guess());
|
||||
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
getReloadScheduleClickHandler = (scheduleId: Schedule['id']) => {
|
||||
const { store } = this.props;
|
||||
|
||||
const { scheduleStore } = store;
|
||||
|
||||
return async (event: SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
|
||||
await scheduleStore.reloadIcal(scheduleId);
|
||||
|
||||
scheduleStore.updateItem(scheduleId);
|
||||
this.updateEventsFor(scheduleId);
|
||||
};
|
||||
};
|
||||
|
||||
getDeleteScheduleClickHandler = (scheduleId: Schedule['id']) => {
|
||||
return (event: SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
this.setState({ scheduleIdToDelete: scheduleId });
|
||||
};
|
||||
};
|
||||
|
||||
getExportScheduleClickHandler = (scheduleId: Schedule['id']) => {
|
||||
return (event: SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
this.setState({ scheduleIdToExport: scheduleId });
|
||||
};
|
||||
};
|
||||
|
||||
handleDelete = async () => {
|
||||
const { scheduleIdToDelete } = this.state;
|
||||
const { store } = this.props;
|
||||
|
||||
this.setState({ scheduleIdToDelete: undefined });
|
||||
|
||||
const { scheduleStore } = store;
|
||||
|
||||
await scheduleStore.delete(scheduleIdToDelete);
|
||||
|
||||
this.update();
|
||||
};
|
||||
}
|
||||
|
||||
interface EventProps {
|
||||
event: ScheduleEvent;
|
||||
}
|
||||
|
||||
const Event = ({ event }: EventProps) => {
|
||||
const dates = getDatesString(event.start, event.end, event.all_day);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!event.is_gap ? (
|
||||
<HorizontalGroup align="flex-start" spacing="sm">
|
||||
<div className={cx('priority-icon')}>
|
||||
<Text wrap type="secondary">{`L${event.priority_level || '0'}`}</Text>
|
||||
</div>
|
||||
<VerticalGroup>
|
||||
<div>
|
||||
{!event.is_empty ? (
|
||||
event.users.map((user: any, index: number) => (
|
||||
<span key={user.pk}>
|
||||
{index ? ', ' : ''}
|
||||
<PluginLink query={{ page: 'users', id: user.pk }}>{user.display_name}</PluginLink>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<HorizontalGroup spacing="sm">
|
||||
<Icon style={{ color: PENDING_COLOR }} name="exclamation-triangle" />
|
||||
<Text type="secondary">Empty shift</Text>
|
||||
{event.missing_users[0] && (
|
||||
<Text type="secondary">
|
||||
(check if {event.missing_users[0].includes(',') ? 'some of these users -' : 'user -'}{' '}
|
||||
<Text type="secondary">"{event.missing_users[0]}"</Text>{' '}
|
||||
{event.missing_users[0].includes(',') ? 'are' : 'is'} existing in OnCall or{' '}
|
||||
{event.missing_users[0].includes(',') ? 'have' : 'has'} Viewer role)
|
||||
</Text>
|
||||
)}
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
{event.source && <span> — source: {event.source}</span>}
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary"> {dates}</Text>
|
||||
</div>
|
||||
</VerticalGroup>
|
||||
</HorizontalGroup>
|
||||
) : (
|
||||
<div className={cx('gap-between-shifts')}>
|
||||
<Icon name="exclamation-triangle" className={cx('gap-between-shifts-icon')} />
|
||||
<Text> Gap! Nobody On-Call...</Text>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default withMobXProviderContext(SchedulesPage);
|
||||
|
|
@ -1,11 +1,3 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.settings {
|
||||
width: fit-content;
|
||||
.tabs__content {
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,77 +1,158 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Field, Input, Switch } from '@grafana/ui';
|
||||
import { Tab, TabsBar } from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import Text from 'components/Text/Text';
|
||||
import ApiTokenSettings from 'containers/ApiTokenSettings/ApiTokenSettings';
|
||||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
import { pages } from 'pages';
|
||||
import ChatOpsPage from 'pages/settings/tabs/ChatOps/ChatOps';
|
||||
import MainSettings from 'pages/settings/tabs/MainSettings/MainSettings';
|
||||
import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
import { AppFeature } from 'state/features';
|
||||
import { RootBaseStore } from 'state/rootBaseStore';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
||||
import { SettingsPageTab } from './SettingsPage.types';
|
||||
import CloudPage from './tabs/Cloud/CloudPage';
|
||||
import LiveSettingsPage from './tabs/LiveSettings/LiveSettingsPage';
|
||||
|
||||
import styles from './SettingsPage.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface SettingsPageProps extends WithStoreProps {}
|
||||
|
||||
interface SettingsPageProps {
|
||||
store: RootBaseStore;
|
||||
}
|
||||
interface SettingsPageState {
|
||||
apiUrl?: string;
|
||||
activeTab: string;
|
||||
}
|
||||
|
||||
@observer
|
||||
class SettingsPage extends React.Component<SettingsPageProps, SettingsPageState> {
|
||||
state: SettingsPageState = {
|
||||
apiUrl: '',
|
||||
activeTab: SettingsPageTab.MainSettings.key, // should read from route instead
|
||||
};
|
||||
async componentDidMount() {
|
||||
const { store } = this.props;
|
||||
const url = await store.getApiUrlForSettings();
|
||||
this.setState({ apiUrl: url });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { store } = this.props;
|
||||
const { teamStore } = store;
|
||||
const { apiUrl } = this.state;
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<Text.Title level={3} className={cx('title')}>
|
||||
Organization settings
|
||||
</Text.Title>
|
||||
<div className={cx('settings')}>
|
||||
<Field
|
||||
loading={!teamStore.currentTeam}
|
||||
label="Require resolution note when resolve incident"
|
||||
description="Once user clicks “Resolve” for an incident they are require to fill a resolution note about the incident"
|
||||
>
|
||||
<WithPermissionControl userAction={UserAction.UpdateGlobalSettings}>
|
||||
<Switch
|
||||
value={teamStore.currentTeam?.is_resolution_note_required}
|
||||
onChange={(event) => {
|
||||
teamStore.saveCurrentTeam({
|
||||
is_resolution_note_required: event.currentTarget.checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</WithPermissionControl>
|
||||
</Field>
|
||||
</div>
|
||||
<Text.Title level={3} className={cx('title')}>
|
||||
API URL
|
||||
</Text.Title>
|
||||
<div>
|
||||
<Field>
|
||||
<Input value={apiUrl} disabled />
|
||||
</Field>
|
||||
</div>
|
||||
<ApiTokenSettings />
|
||||
</div>
|
||||
<PluginPage pageNav={this.getMatchingPageNav()}>
|
||||
<div className={cx('root')}>{this.renderContent()}</div>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
const { activeTab } = this.state;
|
||||
const { store } = this.props;
|
||||
|
||||
const onTabChange = (tab: string) => {
|
||||
this.setState({ activeTab: tab });
|
||||
};
|
||||
|
||||
const grafanaUser = window.grafanaBootData.user;
|
||||
const hasLiveSettings = store.hasFeature(AppFeature.LiveSettings);
|
||||
const hasCloudPage = store.hasFeature(AppFeature.CloudConnection);
|
||||
const showCloudPage =
|
||||
hasCloudPage && (pages['cloud'].role === 'Admin' ? pages['cloud'].role === grafanaUser.orgRole : true);
|
||||
const showLiveSettings =
|
||||
hasLiveSettings && (pages['cloud'].role === 'Admin' ? pages['cloud'].role === grafanaUser.orgRole : true);
|
||||
|
||||
if (isTopNavbar()) {
|
||||
return (
|
||||
<>
|
||||
<TabsBar>
|
||||
<Tab
|
||||
key={SettingsPageTab.MainSettings.key}
|
||||
onChangeTab={() => onTabChange(SettingsPageTab.MainSettings.key)}
|
||||
active={activeTab === SettingsPageTab.MainSettings.key}
|
||||
label={SettingsPageTab.MainSettings.value}
|
||||
/>
|
||||
<Tab
|
||||
key={SettingsPageTab.ChatOps.key}
|
||||
onChangeTab={() => onTabChange(SettingsPageTab.ChatOps.key)}
|
||||
active={activeTab === SettingsPageTab.ChatOps.key}
|
||||
label={SettingsPageTab.ChatOps.value}
|
||||
/>
|
||||
{showLiveSettings && (
|
||||
<Tab
|
||||
key={SettingsPageTab.EnvVariables.key}
|
||||
onChangeTab={() => onTabChange(SettingsPageTab.EnvVariables.key)}
|
||||
active={activeTab === SettingsPageTab.EnvVariables.key}
|
||||
label={SettingsPageTab.EnvVariables.value}
|
||||
/>
|
||||
)}
|
||||
{showCloudPage && (
|
||||
<Tab
|
||||
key={SettingsPageTab.Cloud.key}
|
||||
onChangeTab={() => onTabChange(SettingsPageTab.Cloud.key)}
|
||||
active={activeTab === SettingsPageTab.Cloud.key}
|
||||
label={SettingsPageTab.Cloud.value}
|
||||
/>
|
||||
)}
|
||||
</TabsBar>
|
||||
|
||||
<TabsContent activeTab={activeTab} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <MainSettings />;
|
||||
}
|
||||
|
||||
getMatchingPageNav() {
|
||||
return {
|
||||
parentItem: {
|
||||
text: getTabText(this.state.activeTab),
|
||||
},
|
||||
text: '',
|
||||
hideFromBreadcrumbs: true,
|
||||
};
|
||||
|
||||
function getTabText(activeTab: string) {
|
||||
let result: string;
|
||||
Object.keys(SettingsPageTab).forEach((tab) => {
|
||||
if (activeTab === SettingsPageTab[tab].key) {
|
||||
result = SettingsPageTab[tab].value;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface TabsContentProps {
|
||||
activeTab: string;
|
||||
}
|
||||
|
||||
const TabsContent = (props: TabsContentProps) => {
|
||||
const { activeTab } = props;
|
||||
|
||||
return (
|
||||
<div className={cx('tabs__content')}>
|
||||
{activeTab === SettingsPageTab.MainSettings.key && (
|
||||
<div className={cx('tab__page')}>
|
||||
<MainSettings />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === SettingsPageTab.ChatOps.key && (
|
||||
<div className={cx('tab__page')}>
|
||||
<ChatOpsPage />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === SettingsPageTab.EnvVariables.key && (
|
||||
<div className={cx('tab__page')}>
|
||||
<LiveSettingsPage />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === SettingsPageTab.Cloud.key && (
|
||||
<div className={cx('tab__page')}>
|
||||
<CloudPage />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default withMobXProviderContext(SettingsPage);
|
||||
|
|
|
|||
8
grafana-plugin/src/pages/settings/SettingsPage.types.ts
Normal file
8
grafana-plugin/src/pages/settings/SettingsPage.types.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { KeyValuePair } from 'utils';
|
||||
|
||||
export const SettingsPageTab = {
|
||||
MainSettings: new KeyValuePair('MainSettings', 'Organization Settings'),
|
||||
ChatOps: new KeyValuePair('ChatOps', 'Chat Ops'),
|
||||
EnvVariables: new KeyValuePair('EnvVariables', 'Env Variables'),
|
||||
Cloud: new KeyValuePair('Cloud', 'Cloud'),
|
||||
};
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
|
|
@ -2,23 +2,61 @@ import React from 'react';
|
|||
|
||||
import { HorizontalGroup, Icon } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import VerticalTabsBar, { VerticalTab } from 'components/VerticalTabsBar/VerticalTabsBar';
|
||||
import { ChatOpsTab } from 'pages/chat-ops/ChatOps.types';
|
||||
import SlackSettings from 'pages/settings/tabs/ChatOps/tabs/SlackSettings/SlackSettings';
|
||||
import TelegramSettings from 'pages/settings/tabs/ChatOps/tabs/TelegramSettings/TelegramSettings';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
||||
import SlackSettings from './tabs/SlackSettings/SlackSettings';
|
||||
import TelegramSettings from './tabs/TelegramSettings/TelegramSettings';
|
||||
|
||||
import styles from 'containers/UserSettings/parts/index.module.css';
|
||||
import styles from './ChatOps.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
export enum ChatOpsTab {
|
||||
Slack = 'Slack',
|
||||
Telegram = 'Telegram',
|
||||
}
|
||||
|
||||
interface ChatOpsState {
|
||||
activeTab: ChatOpsTab;
|
||||
}
|
||||
|
||||
@observer
|
||||
class ChatOpsPage extends React.Component<{}, ChatOpsState> {
|
||||
state: ChatOpsState = {
|
||||
activeTab: ChatOpsTab.Slack,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { activeTab } = this.state;
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('tabs')}>
|
||||
<Tabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={(tab: ChatOpsTab) => {
|
||||
this.setState({ activeTab: tab });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={cx('content')}>
|
||||
<TabsContent activeTab={activeTab} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withMobXProviderContext(ChatOpsPage);
|
||||
|
||||
interface TabsProps {
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
}
|
||||
|
||||
export const Tabs = (props: TabsProps) => {
|
||||
const Tabs = (props: TabsProps) => {
|
||||
const { activeTab, onTabChange } = props;
|
||||
|
||||
return (
|
||||
|
|
@ -43,7 +81,7 @@ interface TabsContentProps {
|
|||
activeTab: string;
|
||||
}
|
||||
|
||||
export const TabsContent = (props: TabsContentProps) => {
|
||||
const TabsContent = (props: TabsContentProps) => {
|
||||
const { activeTab } = props;
|
||||
|
||||
return (
|
||||
|
|
@ -25,7 +25,6 @@
|
|||
height: 32px;
|
||||
}
|
||||
|
||||
.cloud-page-title,
|
||||
.heartbit-button {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
|
@ -59,7 +58,7 @@
|
|||
}
|
||||
|
||||
.table-title {
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: var(--title-marginBottom);
|
||||
}
|
||||
|
||||
.table-button {
|
||||
|
|
@ -1,7 +1,3 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.align-top {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
.title {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.settings {
|
||||
width: fit-content;
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Field, Input, Switch } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
import LegacyNavHeading from 'navbar/LegacyNavHeading';
|
||||
|
||||
import Text from 'components/Text/Text';
|
||||
import ApiTokenSettings from 'containers/ApiTokenSettings/ApiTokenSettings';
|
||||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
||||
import styles from './MainSettings.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface SettingsPageProps extends WithStoreProps {}
|
||||
|
||||
interface SettingsPageState {
|
||||
apiUrl?: string;
|
||||
}
|
||||
|
||||
@observer
|
||||
class SettingsPage extends React.Component<SettingsPageProps, SettingsPageState> {
|
||||
state: SettingsPageState = {
|
||||
apiUrl: '',
|
||||
};
|
||||
|
||||
async componentDidMount() {
|
||||
const { store } = this.props;
|
||||
const url = await store.getApiUrlForSettings();
|
||||
this.setState({ apiUrl: url });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { store } = this.props;
|
||||
const { teamStore } = store;
|
||||
const { apiUrl } = this.state;
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<LegacyNavHeading>
|
||||
<Text.Title level={3} className={cx('title')}>
|
||||
Organization settings
|
||||
</Text.Title>
|
||||
</LegacyNavHeading>
|
||||
|
||||
<div className={cx('settings')}>
|
||||
<Field
|
||||
loading={!teamStore.currentTeam}
|
||||
label="Require resolution note when resolve incident"
|
||||
description={`Once user clicks "Resolve" for an incident they are require to fill a resolution note about the incident`}
|
||||
>
|
||||
<WithPermissionControl userAction={UserAction.UpdateGlobalSettings}>
|
||||
<Switch
|
||||
value={teamStore.currentTeam?.is_resolution_note_required}
|
||||
onChange={(event) => {
|
||||
teamStore.saveCurrentTeam({
|
||||
is_resolution_note_required: event.currentTarget.checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</WithPermissionControl>
|
||||
</Field>
|
||||
</div>
|
||||
<Text.Title level={3} className={cx('title')}>
|
||||
API URL
|
||||
</Text.Title>
|
||||
<div>
|
||||
<Field>
|
||||
<Input value={apiUrl} disabled />
|
||||
</Field>
|
||||
</div>
|
||||
<ApiTokenSettings />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withMobXProviderContext(SettingsPage);
|
||||
|
|
@ -1,7 +1,3 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.select {
|
||||
width: 400px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Button } from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
|
|
@ -16,11 +17,13 @@ const cx = cn.bind(styles);
|
|||
class Test extends React.Component<any, any> {
|
||||
render() {
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<WithPermissionControl userAction={UserAction.UpdateSchedules}>
|
||||
{(disabled) => <Button disabled={disabled}>Click me!</Button>}
|
||||
</WithPermissionControl>
|
||||
</div>
|
||||
<PluginPage>
|
||||
<div className={cx('root')}>
|
||||
<WithPermissionControl userAction={UserAction.UpdateSchedules}>
|
||||
{(disabled) => <Button disabled={disabled}>Click me!</Button>}
|
||||
</WithPermissionControl>
|
||||
</div>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.users-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -19,7 +15,7 @@
|
|||
.users-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: var(--title-marginBottom);
|
||||
}
|
||||
|
||||
.users-header-left {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ import React from 'react';
|
|||
import { AppRootProps } from '@grafana/data';
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import { Alert, Button, HorizontalGroup, Icon, VerticalGroup } from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
import LegacyNavHeading from 'navbar/LegacyNavHeading';
|
||||
|
||||
import Avatar from 'components/Avatar/Avatar';
|
||||
import GTable from 'components/GTable/GTable';
|
||||
|
|
@ -21,6 +23,8 @@ import UserSettings from 'containers/UserSettings/UserSettings';
|
|||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { getRole } from 'models/user/user.helpers';
|
||||
import { User as UserType, UserRole } from 'models/user/user.types';
|
||||
import { pages } from 'pages';
|
||||
import { getQueryParams } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
|
@ -61,10 +65,10 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
|
||||
initialUsersLoaded = false;
|
||||
|
||||
private userId: string;
|
||||
|
||||
async componentDidMount() {
|
||||
const {
|
||||
query: { p },
|
||||
} = this.props;
|
||||
const { p } = getQueryParams();
|
||||
this.setState({ page: p ? Number(p) : 1 }, this.updateUsers);
|
||||
|
||||
this.parseParams();
|
||||
|
|
@ -83,7 +87,7 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
return await userStore.updateItems(getRealFilters(usersFilters), page);
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps: Readonly<UsersProps>, _prevState: Readonly<UsersState>, _snapshot?: any) {
|
||||
componentDidUpdate() {
|
||||
const { store } = this.props;
|
||||
|
||||
if (!this.initialUsersLoaded && store.isUserActionAllowed(UserAction.ViewOtherUsers)) {
|
||||
|
|
@ -91,7 +95,7 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
this.initialUsersLoaded = true;
|
||||
}
|
||||
|
||||
if (this.props.query.id !== prevProps.query.id) {
|
||||
if (this.userId !== getQueryParams()['id']) {
|
||||
this.parseParams();
|
||||
}
|
||||
}
|
||||
|
|
@ -99,10 +103,10 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
parseParams = async () => {
|
||||
this.setState({ errorData: initErrorDataState() }); // reset wrong team error to false on query parse
|
||||
|
||||
const {
|
||||
store,
|
||||
query: { id },
|
||||
} = this.props;
|
||||
const { store } = this.props;
|
||||
const { id } = getQueryParams();
|
||||
|
||||
this.userId = id;
|
||||
|
||||
if (id) {
|
||||
await (id === 'me' ? store.userStore.loadCurrentUser() : store.userStore.loadUser(String(id), true)).catch(
|
||||
|
|
@ -171,20 +175,22 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
const { count, results } = userStore.getSearchResult();
|
||||
|
||||
return (
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
objectName="user"
|
||||
pageName="users"
|
||||
itemNotFoundMessage={`User with id=${query?.id} is not found. Please select user from the list.`}
|
||||
>
|
||||
{() => (
|
||||
<PluginPage pageNav={pages['users'].getPageNav()}>
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
objectName="user"
|
||||
pageName="users"
|
||||
itemNotFoundMessage={`User with id=${query?.id} is not found. Please select user from the list.`}
|
||||
>
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('root', 'TEST-users-page')}>
|
||||
<div className={cx('users-header')}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline' }}>
|
||||
<div>
|
||||
<Text.Title level={3}>Users</Text.Title>
|
||||
<LegacyNavHeading>
|
||||
<Text.Title level={3}>Users</Text.Title>
|
||||
</LegacyNavHeading>
|
||||
<Text type="secondary">
|
||||
To manage permissions or add users, please visit{' '}
|
||||
<a href="/org/users">Grafana user management</a>
|
||||
|
|
@ -244,8 +250,8 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
{userPkToEdit && <UserSettings id={userPkToEdit} onHide={this.handleHideUserSettings} />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PageErrorHandlingWrapper>
|
||||
</PageErrorHandlingWrapper>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,8 +34,7 @@
|
|||
"name": "Alert Groups",
|
||||
"path": "/a/grafana-oncall-app/?page=incidents",
|
||||
"role": "Viewer",
|
||||
"addToNav": true,
|
||||
"defaultNav": true
|
||||
"addToNav": true
|
||||
},
|
||||
{
|
||||
"type": "page",
|
||||
|
|
@ -65,13 +64,6 @@
|
|||
"role": "Viewer",
|
||||
"addToNav": true
|
||||
},
|
||||
{
|
||||
"type": "page",
|
||||
"name": "ChatOps",
|
||||
"path": "/a/grafana-oncall-app/?page=chat-ops",
|
||||
"role": "Viewer",
|
||||
"addToNav": true
|
||||
},
|
||||
{
|
||||
"type": "page",
|
||||
"name": "Outgoing Webhooks",
|
||||
|
|
|
|||
14
grafana-plugin/src/plugin/GrafanaPluginRootPage.helpers.tsx
Normal file
14
grafana-plugin/src/plugin/GrafanaPluginRootPage.helpers.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { config } from '@grafana/runtime';
|
||||
|
||||
export function isTopNavbar(): boolean {
|
||||
return !!config.featureToggles.topnav;
|
||||
}
|
||||
|
||||
export function getQueryParams(): any {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const result = {};
|
||||
for (const [key, value] of searchParams) {
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import React, { useEffect, useMemo } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { AppRootProps } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { Button, HorizontalGroup, LinkButton } from '@grafana/ui';
|
||||
import classnames from 'classnames';
|
||||
import dayjs from 'dayjs';
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
||||
|
|
@ -10,17 +12,18 @@ import localeData from 'dayjs/plugin/localeData';
|
|||
import timezone from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import weekday from 'dayjs/plugin/weekday';
|
||||
import { observer, Provider } from 'mobx-react';
|
||||
|
||||
import 'interceptors';
|
||||
import { observer, Provider } from 'mobx-react';
|
||||
import Header from 'navbar/Header/Header';
|
||||
import LegacyNavTabsBar from 'navbar/LegacyNavTabsBar';
|
||||
|
||||
import DefaultPageLayout from 'containers/DefaultPageLayout/DefaultPageLayout';
|
||||
import GrafanaTeamSelect from 'containers/GrafanaTeamSelect/GrafanaTeamSelect';
|
||||
import logo from 'img/logo.svg';
|
||||
import { pages } from 'pages';
|
||||
import { routes } from 'pages/routes';
|
||||
import { rootStore } from 'state';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { useNavModel } from 'utils/hooks';
|
||||
import { useQueryParams, useQueryPath } from 'utils/hooks';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
|
@ -30,10 +33,11 @@ dayjs.extend(isSameOrBefore);
|
|||
dayjs.extend(isSameOrAfter);
|
||||
dayjs.extend(isoWeek);
|
||||
|
||||
import './style/vars.css';
|
||||
import './style/index.css';
|
||||
import 'style/vars.css';
|
||||
import 'style/global.css';
|
||||
import 'style/utils.css';
|
||||
|
||||
import { AppFeature } from './state/features';
|
||||
import { isTopNavbar } from './GrafanaPluginRootPage.helpers';
|
||||
|
||||
export const GrafanaPluginRootPage = (props: AppRootProps) => (
|
||||
<Provider store={rootStore}>
|
||||
|
|
@ -96,21 +100,18 @@ const RootWithLoader = observer((props: AppRootProps) => {
|
|||
});
|
||||
|
||||
export const Root = observer((props: AppRootProps) => {
|
||||
const {
|
||||
path,
|
||||
onNavChanged,
|
||||
query: { page },
|
||||
meta,
|
||||
} = props;
|
||||
const [didFinishLoading, setDidFinishLoading] = useState(false);
|
||||
const queryParams = useQueryParams();
|
||||
const page = queryParams.get('page');
|
||||
const path = useQueryPath();
|
||||
|
||||
// Required to support grafana instances that use a custom `root_url`.
|
||||
const pathWithoutLeadingSlash = path.replace(/^\//, '');
|
||||
|
||||
const store = useStore();
|
||||
const { backendLicense } = store;
|
||||
|
||||
useEffect(() => {
|
||||
store.updateBasicData();
|
||||
updateBasicData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -126,33 +127,48 @@ export const Root = observer((props: AppRootProps) => {
|
|||
};
|
||||
}, []);
|
||||
|
||||
// Update the navigation when the page or path changes
|
||||
const navModel = useNavModel(
|
||||
useMemo(
|
||||
() => ({
|
||||
page,
|
||||
pages,
|
||||
path: pathWithoutLeadingSlash,
|
||||
meta,
|
||||
grafanaUser: window.grafanaBootData.user,
|
||||
enableLiveSettings: store.hasFeature(AppFeature.LiveSettings),
|
||||
enableCloudPage: store.hasFeature(AppFeature.CloudConnection),
|
||||
backendLicense,
|
||||
}),
|
||||
[meta, pathWithoutLeadingSlash, page, store.features, backendLicense]
|
||||
)
|
||||
);
|
||||
useEffect(() => {
|
||||
/* @ts-ignore */
|
||||
onNavChanged(navModel);
|
||||
}, [navModel, onNavChanged]);
|
||||
const updateBasicData = async () => {
|
||||
await store.updateBasicData();
|
||||
setDidFinishLoading(true);
|
||||
};
|
||||
|
||||
const Page = pages.find(({ id }) => id === page)?.component || pages[0].component;
|
||||
const Page = useMemo(() => getPageMatchingComponent(page), [page]);
|
||||
|
||||
if (!didFinishLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DefaultPageLayout {...props}>
|
||||
<GrafanaTeamSelect currentPage={page} />
|
||||
<Page {...props} path={pathWithoutLeadingSlash} />
|
||||
{!isTopNavbar() && (
|
||||
<>
|
||||
<Header page={page} backendLicense={store.backendLicense} />
|
||||
<nav className="page-container">
|
||||
<LegacyNavTabsBar currentPage={page} />
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={classnames(
|
||||
{ 'page-container': !isTopNavbar() },
|
||||
{ 'page-body': !isTopNavbar() },
|
||||
'u-position-relative'
|
||||
)}
|
||||
>
|
||||
<Page {...props} path={pathWithoutLeadingSlash} store={store} />
|
||||
</div>
|
||||
</DefaultPageLayout>
|
||||
);
|
||||
});
|
||||
|
||||
function getPageMatchingComponent(pageId: string): (props?: any) => JSX.Element {
|
||||
let matchingPage = routes[pageId];
|
||||
if (!matchingPage) {
|
||||
const defaultPageId = pages['incidents'].id;
|
||||
matchingPage = routes[defaultPageId];
|
||||
locationService.replace(pages[defaultPageId].path);
|
||||
}
|
||||
|
||||
return matchingPage.component;
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ import { Timezone } from 'models/timezone/timezone.types';
|
|||
import { UserStore } from 'models/user/user';
|
||||
import { UserGroupStore } from 'models/user_group/user_group';
|
||||
import { makeRequest } from 'network';
|
||||
import { NavMenuItem } from 'pages/routes';
|
||||
|
||||
import { AppFeature } from './features';
|
||||
import {
|
||||
|
|
@ -99,6 +100,9 @@ export class RootBaseStore {
|
|||
@observable
|
||||
onCallApiUrl: string;
|
||||
|
||||
@observable
|
||||
navMenuItem: NavMenuItem;
|
||||
|
||||
// --------------------------
|
||||
|
||||
userStore: UserStore = new UserStore(this);
|
||||
|
|
@ -125,16 +129,18 @@ export class RootBaseStore {
|
|||
// stores
|
||||
|
||||
async updateBasicData() {
|
||||
this.teamStore.loadCurrentTeam();
|
||||
this.grafanaTeamStore.updateItems();
|
||||
this.updateFeatures();
|
||||
this.userStore.updateNotificationPolicyOptions();
|
||||
this.userStore.updateNotifyByOptions();
|
||||
this.alertReceiveChannelStore.updateAlertReceiveChannelOptions();
|
||||
this.alertReceiveChannelStore.updateAlertReceiveChannelOptions();
|
||||
this.escalationPolicyStore.updateWebEscalationPolicyOptions();
|
||||
this.escalationPolicyStore.updateEscalationPolicyOptions();
|
||||
this.escalationPolicyStore.updateNumMinutesInWindowOptions();
|
||||
return Promise.all([
|
||||
this.teamStore.loadCurrentTeam(),
|
||||
this.grafanaTeamStore.updateItems(),
|
||||
this.updateFeatures(),
|
||||
this.userStore.updateNotificationPolicyOptions(),
|
||||
this.userStore.updateNotifyByOptions(),
|
||||
this.alertReceiveChannelStore.updateAlertReceiveChannelOptions(),
|
||||
this.alertReceiveChannelStore.updateAlertReceiveChannelOptions(),
|
||||
this.escalationPolicyStore.updateWebEscalationPolicyOptions(),
|
||||
this.escalationPolicyStore.updateEscalationPolicyOptions(),
|
||||
this.escalationPolicyStore.updateNumMinutesInWindowOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
async getUserRole() {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,3 @@
|
|||
.spin {
|
||||
width: 100%;
|
||||
margin-top: 200px;
|
||||
margin-bottom: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
/* animation: fadeIn 1s infinite alternate; */
|
||||
}
|
||||
|
||||
.spin-text {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.configure-plugin {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
|
@ -24,6 +8,24 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
|
||||
.spin {
|
||||
width: 100%;
|
||||
margin-top: 200px;
|
||||
margin-bottom: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spin-text {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
|
||||
.disabled-row {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
|
@ -31,3 +33,9 @@
|
|||
.highlighted-row {
|
||||
background: var(--highlighted-row-bg);
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
|
||||
.navbarRootFallback {
|
||||
margin-top: 24px;
|
||||
}
|
||||
20
grafana-plugin/src/style/utils.css
Normal file
20
grafana-plugin/src/style/utils.css
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
.u-flex {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.u-align-items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.u-position-relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.u-pull-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.u-pull-left {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
--gradient-brandHorizontal: linear-gradient(90deg, #f83 0%, #f53e4c 100%);
|
||||
--gradient-brandVertical: linear-gradient(0.01deg, #f53e4c -31.2%, #f83 113.07%);
|
||||
--always-gray: #ccccdc;
|
||||
--title-marginBottom: 16px;
|
||||
}
|
||||
|
||||
.theme-light {
|
||||
|
|
|
|||
|
|
@ -1,90 +1,12 @@
|
|||
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { AppRootProps, NavModelItem } from '@grafana/data';
|
||||
|
||||
import NavBarSubtitle from 'components/NavBar/NavBarSubtitle';
|
||||
import { PageDefinition } from 'pages';
|
||||
|
||||
import { APP_TITLE } from './consts';
|
||||
|
||||
type Args = {
|
||||
meta: AppRootProps['meta'];
|
||||
pages: PageDefinition[];
|
||||
path: string;
|
||||
page: string;
|
||||
grafanaUser: {
|
||||
orgRole: 'Viewer' | 'Editor' | 'Admin';
|
||||
};
|
||||
enableLiveSettings: boolean;
|
||||
enableCloudPage: boolean;
|
||||
backendLicense: string;
|
||||
};
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
export function useForceUpdate() {
|
||||
const [, setValue] = useState(0);
|
||||
return () => setValue((value) => value + 1);
|
||||
}
|
||||
|
||||
export function useNavModel({
|
||||
meta,
|
||||
pages,
|
||||
path,
|
||||
page,
|
||||
grafanaUser,
|
||||
enableLiveSettings,
|
||||
enableCloudPage,
|
||||
backendLicense,
|
||||
}: Args) {
|
||||
return useMemo(() => {
|
||||
const tabs: NavModelItem[] = [];
|
||||
|
||||
pages.forEach(({ text, icon, id, role, hideFromTabs }) => {
|
||||
tabs.push({
|
||||
text,
|
||||
icon,
|
||||
id,
|
||||
url: `${path}?page=${id}`,
|
||||
hideFromTabs:
|
||||
hideFromTabs ||
|
||||
(role === 'Admin' && grafanaUser.orgRole !== role) ||
|
||||
(id === 'live-settings' && !enableLiveSettings) ||
|
||||
(id === 'cloud' && !enableCloudPage),
|
||||
});
|
||||
|
||||
if (page === id) {
|
||||
tabs[tabs.length - 1].active = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback if current `tab` doesn't match any page
|
||||
if (!tabs.some(({ active }) => active)) {
|
||||
tabs[0].active = true;
|
||||
}
|
||||
|
||||
const node = {
|
||||
text: APP_TITLE,
|
||||
img: meta.info.logos.large,
|
||||
subTitle: <NavBarSubtitle backendLicense={backendLicense} />,
|
||||
url: path,
|
||||
children: tabs,
|
||||
};
|
||||
|
||||
return {
|
||||
node,
|
||||
main: node,
|
||||
};
|
||||
}, [
|
||||
meta.info.logos.large,
|
||||
pages,
|
||||
path,
|
||||
page,
|
||||
enableLiveSettings,
|
||||
enableCloudPage,
|
||||
backendLicense,
|
||||
grafanaUser.orgRole,
|
||||
]);
|
||||
}
|
||||
|
||||
export function usePrevious(value: any) {
|
||||
const ref = useRef();
|
||||
useEffect(() => {
|
||||
|
|
@ -93,6 +15,17 @@ export function usePrevious(value: any) {
|
|||
return ref.current;
|
||||
}
|
||||
|
||||
export function useQueryParams() {
|
||||
const { search } = useLocation();
|
||||
|
||||
return React.useMemo(() => new URLSearchParams(search), [search]);
|
||||
}
|
||||
|
||||
export function useQueryPath() {
|
||||
const location = useLocation();
|
||||
return React.useMemo(() => location.pathname, [location]);
|
||||
}
|
||||
|
||||
export function useDebouncedCallback<A extends any[]>(callback: (...args: A) => void, wait: number) {
|
||||
// track args & timeout handle between calls
|
||||
const argsRef = useRef<A>();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,16 @@ import appEvents from 'grafana/app/core/app_events';
|
|||
import { isArray, concat, isPlainObject, flatMap, map, keys } from 'lodash-es';
|
||||
import qs from 'query-string';
|
||||
|
||||
export class KeyValuePair {
|
||||
key: string;
|
||||
value: string;
|
||||
|
||||
constructor(key: string, value: string) {
|
||||
this.key = key;
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
export const TZ_OFFSET = new Date().getTimezoneOffset();
|
||||
|
||||
export const getTzOffsetHours = (): number => {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -12,7 +12,6 @@ Resources that can be migrated using this tool:
|
|||
## Limitations
|
||||
|
||||
* Not all integration types are supported (e.g. inbound email is not supported)
|
||||
* Not all notification methods are supported (e.g. emails are not supported)
|
||||
* Migrated on-call schedules in Grafana OnCall will use ICalendar files from PagerDuty
|
||||
* Delays between migrated notification/escalation rules could be slightly different from original. E.g. if you have a 4-minute delay between rules in PagerDuty, the resulting delay in Grafana OnCall will be 5 minutes
|
||||
|
||||
|
|
@ -78,7 +77,7 @@ docker run --rm \
|
|||
pd-oncall-migrator
|
||||
```
|
||||
|
||||
It's possible to specify a default contact method type for user notification rules that cannot be migrated as-is by changing the `ONCALL_DEFAULT_CONTACT_METHOD` env variable. Options are: `sms`, `phone_call`, `slack`, `telegram` (default is `sms`).
|
||||
It's possible to specify a default contact method type for user notification rules that cannot be migrated as-is by changing the `ONCALL_DEFAULT_CONTACT_METHOD` env variable. Options are: `email`, `sms`, `phone_call`, `slack`, `telegram` (default is `email`).
|
||||
|
||||
### After migration
|
||||
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@ ONCALL_API_URL = urljoin(
|
|||
|
||||
ONCALL_DELAY_OPTIONS = [1, 5, 15, 30, 60]
|
||||
ONCALL_DEFAULT_CONTACT_METHOD = "notify_by_" + os.getenv(
|
||||
"ONCALL_DEFAULT_CONTACT_METHOD", default="sms"
|
||||
"ONCALL_DEFAULT_CONTACT_METHOD", default="email"
|
||||
)
|
||||
PAGERDUTY_TO_ONCALL_CONTACT_METHOD_MAP = {
|
||||
"sms_contact_method": "notify_by_sms",
|
||||
"phone_contact_method": "notify_by_phone_call",
|
||||
"email_contact_method": ONCALL_DEFAULT_CONTACT_METHOD,
|
||||
"email_contact_method": "notify_by_email",
|
||||
"push_notification_contact_method": ONCALL_DEFAULT_CONTACT_METHOD,
|
||||
}
|
||||
PAGERDUTY_TO_ONCALL_VENDOR_MAP = {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import copy
|
||||
|
||||
from migrator import oncall_api_client
|
||||
from migrator.config import PAGERDUTY_TO_ONCALL_CONTACT_METHOD_MAP
|
||||
from migrator.config import (
|
||||
ONCALL_DEFAULT_CONTACT_METHOD,
|
||||
PAGERDUTY_TO_ONCALL_CONTACT_METHOD_MAP,
|
||||
)
|
||||
from migrator.utils import remove_duplicates, transform_wait_delay
|
||||
|
||||
|
||||
|
|
@ -74,7 +77,9 @@ def transform_notification_rule(
|
|||
) -> list[dict]:
|
||||
contact_method_type = notification_rule["contact_method"]["type"]
|
||||
|
||||
oncall_type = PAGERDUTY_TO_ONCALL_CONTACT_METHOD_MAP[contact_method_type]
|
||||
oncall_type = PAGERDUTY_TO_ONCALL_CONTACT_METHOD_MAP.get(
|
||||
contact_method_type, ONCALL_DEFAULT_CONTACT_METHOD
|
||||
)
|
||||
notify_rule = {"user_id": user_id, "type": oncall_type, "important": False}
|
||||
|
||||
if not delay:
|
||||
|
|
|
|||
|
|
@ -1233,6 +1233,7 @@ expected_integrations_result = [
|
|||
"self": "https://api.pagerduty.com/vendors/TESTVENDOR1",
|
||||
"html_url": None,
|
||||
},
|
||||
"vendor_name": "Datadog",
|
||||
"service": {
|
||||
"id": "TESTSERVICE1",
|
||||
"summary": "Service",
|
||||
|
|
@ -1294,6 +1295,7 @@ expected_integrations_result = [
|
|||
"self": "https://api.pagerduty.com/vendors/TESTVENDOR2",
|
||||
"html_url": None,
|
||||
},
|
||||
"vendor_name": "Amazon CloudWatch",
|
||||
"service": {
|
||||
"id": "TESTSERVICE1",
|
||||
"summary": "Service",
|
||||
|
|
@ -1338,6 +1340,7 @@ expected_integrations_result = [
|
|||
"self": "https://api.pagerduty.com/vendors/TESTVENDOR1",
|
||||
"html_url": None,
|
||||
},
|
||||
"vendor_name": "Datadog",
|
||||
"service": {
|
||||
"id": "TESTSERVICE2",
|
||||
"summary": "My Application Service",
|
||||
|
|
@ -1382,6 +1385,7 @@ expected_integrations_result = [
|
|||
"self": "https://api.pagerduty.com/vendors/TESTVENDOR2",
|
||||
"html_url": None,
|
||||
},
|
||||
"vendor_name": "Amazon CloudWatch",
|
||||
"service": {
|
||||
"id": "TESTSERVICE2",
|
||||
"summary": "My Application Service",
|
||||
|
|
@ -1426,6 +1430,7 @@ expected_integrations_result = [
|
|||
"self": "https://api.pagerduty.com/vendors/TESTVENDOR1",
|
||||
"html_url": None,
|
||||
},
|
||||
"vendor_name": "Datadog",
|
||||
"service": {
|
||||
"id": "TESTSERVICE2",
|
||||
"summary": "My Application Service",
|
||||
|
|
@ -1470,6 +1475,7 @@ expected_integrations_result = [
|
|||
"self": "https://api.pagerduty.com/vendors/TESTVENDOR2",
|
||||
"html_url": None,
|
||||
},
|
||||
"vendor_name": "Amazon CloudWatch",
|
||||
"service": {
|
||||
"id": "TESTSERVICE2",
|
||||
"summary": "My Application Service",
|
||||
|
|
@ -1514,6 +1520,7 @@ expected_integrations_result = [
|
|||
"self": "https://api.pagerduty.com/vendors/TESTVENDOR3",
|
||||
"html_url": None,
|
||||
},
|
||||
"vendor_name": "Email",
|
||||
"service": {
|
||||
"id": "TESTSERVICE1",
|
||||
"summary": "My Application Service",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue