Merge pull request #858 from grafana/dev

Release v1.1.1
This commit is contained in:
Innokentii Konstantinov 2022-11-16 19:09:15 +08:00 committed by GitHub
commit 2c384ef709
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
96 changed files with 2222 additions and 2272 deletions

View file

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

View file

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

View file

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

View file

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

@ -1,4 +1,4 @@
requirements-enterprise.txt
requirements-enterprise*.txt
extensions/
uwsgi-local.ini
celerybeat-schedule

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,8 @@
module.exports = {
process() {
return { code: 'module.exports = {};' };
},
getCacheKey() {
return 'svgTransform';
},
};

View file

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

View file

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

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

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

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

View file

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

View file

@ -1,4 +0,0 @@
export enum ChatOpsTab {
Slack = 'Slack',
Telegram = 'Telegram',
}

View file

@ -1,7 +1,3 @@
.root {
margin-top: 24px;
}
.filters {
margin-bottom: 20px;
}

View file

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

View file

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

View file

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

View file

@ -1,7 +1,3 @@
.root {
margin-top: 24px;
}
.select {
width: 400px;
}

View file

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

View file

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

View file

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

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

View file

@ -1,7 +1,3 @@
.root {
margin-top: 24px;
}
.filters {
margin-bottom: 20px;
}

View file

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

View file

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

View file

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

View file

@ -1,7 +1,3 @@
.root {
margin-top: 24px;
}
.header {
display: flex;
justify-content: space-between;

View file

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

View file

@ -1,8 +1,5 @@
.root {
margin-top: 24px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}

View file

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

View file

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

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

View file

@ -1,7 +1,6 @@
.root {
max-width: 1600px;
margin: 0 auto;
margin-top: 24px;
--rotations-border: var(--border-weak);
--rotations-background: var(--background-secondary);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,11 +1,3 @@
.root {
margin-top: 24px;
}
.title {
margin-bottom: 20px;
}
.settings {
width: fit-content;
.tabs__content {
padding-top: 24px;
}

View file

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

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

View file

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

View file

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

View file

@ -1,7 +1,3 @@
.root {
margin-top: 24px;
}
.align-top {
vertical-align: top;
}

View file

@ -0,0 +1,7 @@
.title {
margin-bottom: 20px;
}
.settings {
width: fit-content;
}

View file

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

View file

@ -1,7 +1,3 @@
.root {
margin-top: 24px;
}
.select {
width: 400px;
}

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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