commit
37fa91697e
39 changed files with 541 additions and 227 deletions
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
|
|
@ -1,3 +1,6 @@
|
|||
* @grafana/grafana-oncall-backend
|
||||
|
||||
# don't tag @grafana/grafana-oncall-backend on changes to CHANGELOG.md
|
||||
CHANGELOG.md
|
||||
|
||||
/grafana-plugin @grafana/grafana-oncall-frontend
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
repos:
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.9.3
|
||||
rev: 5.12.0
|
||||
hooks:
|
||||
- id: isort
|
||||
files: ^engine
|
||||
|
|
|
|||
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -5,7 +5,11 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## Unreleased
|
||||
## v1.1.20 (2023-01-30)
|
||||
|
||||
### Added
|
||||
|
||||
- Add involved users filter to alert groups listing page (+ mine shortcut)
|
||||
|
||||
### Changed
|
||||
|
||||
|
|
@ -14,11 +18,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Fixed
|
||||
|
||||
- Fix bugs related to creating contact point for Grafana Alerting integration
|
||||
- Fix minor UI bug on OnCall users page where it would idefinitely show a "Loading..." message
|
||||
- Only show OnCall user's table to users that are authorized
|
||||
- Fixed NPE in ScheduleUserDetails component ([#1229](https://github.com/grafana/oncall/issues/1229))
|
||||
|
||||
## v1.1.19 (2023-01-25)
|
||||
|
||||
### Added
|
||||
|
||||
- Add Server URL below QR code for OSS for debugging purposes
|
||||
- Add Slack slash command allowing to trigger a direct page via a manually created alert group
|
||||
- Remove resolved and acknowledged filters as we switched to status ([#1201](https://github.com/grafana/oncall/pull/1201))
|
||||
- Add sync with grafana on /users and /teams api calls from terraform plugin
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"extends": "../.markdownlint.json",
|
||||
"MD013": {
|
||||
"line_length": "140"
|
||||
"line_length": "160"
|
||||
},
|
||||
"MD025": false,
|
||||
"MD036": false
|
||||
|
|
|
|||
|
|
@ -81,3 +81,26 @@ teams of their on-call shifts. Admins can configure shift notification behavior
|
|||
1. When an on-call shift notification is sent to a person or channel, click the gear icon to
|
||||
access **Notifications preferences**.
|
||||
2. Configure on-call notifications for future shift notifications.
|
||||
|
||||
## Slack commands and message shortcuts
|
||||
|
||||
The Grafana OnCall Slack app includes helpful message shortcuts and slash commands.
|
||||
|
||||
### Slack commands
|
||||
|
||||
Use the `/oncall` Slack command to create a new alert group directly from Slack.
|
||||
|
||||
1. Type `/oncall` in the message box of the desired Slack channel then click **Send**.
|
||||
1. Fill out the **Start New Escalation** creation form then click **Submit**.
|
||||
1. Once the Grafana OnCall app sends a Slack message with the newly created alert, the alert group is open and firing.
|
||||
|
||||
### Message shortcuts
|
||||
|
||||
Use message shortcuts to add resolution notes directly from Slack. Message shortcuts are available in the More actions menu from any message.
|
||||
|
||||
>**Note:** In order to associate the resolution note to an alert group, this message shortcut can only be applied to messages in the thread of an alert group.
|
||||
|
||||
1. From an alert group thread, navigate to the Slack message that you wish to add as a resolution note.
|
||||
1. Hover over the message and select **More actions** from the menu options.
|
||||
1. Select **Add as resolution note**.
|
||||
1. The Grafana OnCall app will react to the message in Slack with the memo emoji and add the message to the alert group timeline.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.2.16 on 2023-01-27 07:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('alerts', '0008_alter_alertgrouplogrecord_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='alertreceivechannel',
|
||||
name='web_templates_modified_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -160,6 +160,7 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
|
|||
web_title_template = models.TextField(null=True, default=None)
|
||||
web_message_template = models.TextField(null=True, default=None)
|
||||
web_image_url_template = models.TextField(null=True, default=None)
|
||||
web_templates_modified_at = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
# email related fields are deprecated in favour of messaging backend based templates
|
||||
# these templates are stored in the messaging_backends_templates field
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from django.db import transaction
|
||||
|
|
@ -142,7 +142,7 @@ def direct_paging(
|
|||
schedules: ScheduleNotifications = None,
|
||||
escalation_chain: EscalationChain = None,
|
||||
alert_group: AlertGroup = None,
|
||||
) -> None:
|
||||
) -> Optional[AlertGroup]:
|
||||
"""Trigger escalation targeting given users/schedules.
|
||||
|
||||
If an alert group is given, update escalation to include the specified users.
|
||||
|
|
@ -185,6 +185,8 @@ def direct_paging(
|
|||
)
|
||||
notify_user_task.apply_async((u.pk, alert_group.pk), {"important": important})
|
||||
|
||||
return alert_group
|
||||
|
||||
|
||||
def unpage_user(alert_group: AlertGroup, user: User, from_user: User) -> None:
|
||||
"""Remove user from alert group escalation."""
|
||||
|
|
|
|||
|
|
@ -279,6 +279,19 @@ def test_direct_paging_custom_chain(
|
|||
assert ag.escalation_chain_with_respect_to_escalation_snapshot == custom_chain
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_direct_paging_returns_alert_group(make_organization, make_user_for_organization):
|
||||
organization = make_organization()
|
||||
user = make_user_for_organization(organization)
|
||||
from_user = make_user_for_organization(organization)
|
||||
|
||||
with patch("apps.alerts.paging.notify_user_task"):
|
||||
alert_group = direct_paging(organization, None, from_user, title="Help!", message="Fire", users=[(user, False)])
|
||||
|
||||
# check alert group returned by direct paging is the same as the one created
|
||||
assert alert_group == AlertGroup.all_objects.get()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unpage_user_not_exists(
|
||||
make_organization, make_user_for_organization, make_alert_receive_channel, make_alert_group
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import logging
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.alerts.incident_appearance.renderers.classic_markdown_renderer import AlertGroupClassicMarkdownRenderer
|
||||
|
|
@ -13,6 +15,7 @@ from .alert_receive_channel import FastAlertReceiveChannelSerializer
|
|||
from .user import FastUserSerializer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
class ShortAlertGroupSerializer(serializers.ModelSerializer):
|
||||
|
|
@ -92,10 +95,33 @@ class AlertGroupListSerializer(EagerLoadingMixin, serializers.ModelSerializer):
|
|||
if not obj.last_alert:
|
||||
return {}
|
||||
|
||||
return AlertGroupWebRenderer(obj, obj.last_alert).render()
|
||||
web_templates_modified_at = obj.channel.web_templates_modified_at
|
||||
last_alert_created_at = obj.last_alert.created_at
|
||||
|
||||
CACHE_KEY = f"render_for_web_alert_group_{obj.id}"
|
||||
CACHE_LIFEIME = 60 * 60 * 24
|
||||
cached_render_for_web = cache.get(CACHE_KEY, None)
|
||||
|
||||
# use cache only if cache exists
|
||||
# and cache was created after the last alert created
|
||||
# and either web templates never modified
|
||||
# or cache was created after templates were modified
|
||||
if (
|
||||
cached_render_for_web is not None
|
||||
and cached_render_for_web.get("cache_created_at") > last_alert_created_at
|
||||
and (
|
||||
web_templates_modified_at is None
|
||||
or cached_render_for_web.get("cache_created_at") > web_templates_modified_at
|
||||
)
|
||||
):
|
||||
render_for_web = cached_render_for_web.get("render_for_web")
|
||||
else:
|
||||
render_for_web = AlertGroupWebRenderer(obj, obj.last_alert).render()
|
||||
cache.set(CACHE_KEY, {"cache_created_at": timezone.now(), "render_for_web": render_for_web}, CACHE_LIFEIME)
|
||||
|
||||
return render_for_web
|
||||
|
||||
def get_render_for_classic_markdown(self, obj):
|
||||
# alert group has no alerts
|
||||
if not obj.last_alert:
|
||||
return {}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from django.core.exceptions import ObjectDoesNotExist
|
|||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.core.validators import URLValidator
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
from jinja2 import TemplateSyntaxError
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
|
@ -379,6 +380,7 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode
|
|||
self.instance.web_title_template = value.strip()
|
||||
elif default_template is not None and default_template.strip() == value.strip():
|
||||
self.instance.web_title_template = None
|
||||
self.instance.web_templates_modified_at = timezone.now()
|
||||
|
||||
def get_web_message_template(self, obj):
|
||||
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_WEB_MESSAGE_TEMPLATE[obj.integration]
|
||||
|
|
@ -390,6 +392,7 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode
|
|||
self.instance.web_message_template = value.strip()
|
||||
elif default_template is not None and default_template.strip() == value.strip():
|
||||
self.instance.web_message_template = None
|
||||
self.instance.web_templates_modified_at = timezone.now()
|
||||
|
||||
def get_web_image_url_template(self, obj):
|
||||
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_WEB_IMAGE_URL_TEMPLATE[obj.integration]
|
||||
|
|
@ -401,6 +404,7 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode
|
|||
self.instance.web_image_url_template = value.strip()
|
||||
elif default_template is not None and default_template.strip() == value.strip():
|
||||
self.instance.web_image_url_template = None
|
||||
self.instance.web_templates_modified_at = timezone.now()
|
||||
|
||||
def get_telegram_title_template(self, obj):
|
||||
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_TELEGRAM_TITLE_TEMPLATE[obj.integration]
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ def test_direct_paging_new_alert_group(
|
|||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "alert_group_id" in response.json()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -104,6 +105,7 @@ def test_direct_paging_existing_alert_group(
|
|||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["alert_group_id"] == alert_group.public_primary_key
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
|
|||
|
|
@ -85,7 +85,11 @@ class AlertGroupFilter(DateRangeFilterMixin, ModelFieldFilterMixin, filters.Filt
|
|||
invitees_are = filters.ModelMultipleChoiceFilter(
|
||||
queryset=get_user_queryset, to_field_name="public_primary_key", method="filter_invitees_are"
|
||||
)
|
||||
involved_users_are = filters.ModelMultipleChoiceFilter(
|
||||
queryset=get_user_queryset, to_field_name="public_primary_key", method="filter_by_involved_users"
|
||||
)
|
||||
with_resolution_note = filters.BooleanFilter(method="filter_with_resolution_note")
|
||||
mine = filters.BooleanFilter(method="filter_mine")
|
||||
|
||||
class Meta:
|
||||
model = AlertGroup
|
||||
|
|
@ -132,10 +136,27 @@ class AlertGroupFilter(DateRangeFilterMixin, ModelFieldFilterMixin, filters.Filt
|
|||
if not users:
|
||||
return queryset
|
||||
|
||||
queryset = queryset.filter(acknowledged=False, resolved=False, log_records__author__in=users).distinct()
|
||||
queryset = queryset.filter(log_records__author__in=users).distinct()
|
||||
|
||||
return queryset
|
||||
|
||||
def filter_by_involved_users(self, queryset, name, value):
|
||||
users = value
|
||||
|
||||
if not users:
|
||||
return queryset
|
||||
|
||||
queryset = queryset.filter(
|
||||
Q(personal_log_records__author__in=users) | Q(log_records__author__in=users)
|
||||
).distinct()
|
||||
|
||||
return queryset
|
||||
|
||||
def filter_mine(self, queryset, name, value):
|
||||
if value:
|
||||
return self.filter_by_involved_users(queryset, "users", [self.request.user.pk])
|
||||
return queryset
|
||||
|
||||
def filter_with_resolution_note(self, queryset, name, value):
|
||||
if value is True:
|
||||
queryset = queryset.filter(Q(resolution_notes__isnull=False, resolution_notes__deleted_at=None)).distinct()
|
||||
|
|
@ -522,6 +543,12 @@ class AlertGroupView(
|
|||
"type": "options",
|
||||
"href": api_root + "users/?filters=true&roles=0&roles=1&roles=2",
|
||||
},
|
||||
{
|
||||
"name": "involved_users_are",
|
||||
"type": "options",
|
||||
"href": api_root + "users/?filters=true&roles=0&roles=1&roles=2",
|
||||
"default": {"display_name": self.request.user.username, "value": self.request.user.public_primary_key},
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"type": "options",
|
||||
|
|
@ -548,6 +575,11 @@ class AlertGroupView(
|
|||
"type": "boolean",
|
||||
"default": "true",
|
||||
},
|
||||
{
|
||||
"name": "mine",
|
||||
"type": "boolean",
|
||||
"default": "true",
|
||||
},
|
||||
]
|
||||
|
||||
if filter_name is not None:
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class DirectPagingAPIView(APIView):
|
|||
(schedule["instance"], schedule["important"]) for schedule in serializer.validated_data["schedules"]
|
||||
]
|
||||
|
||||
direct_paging(
|
||||
alert_group = direct_paging(
|
||||
organization=organization,
|
||||
team=team,
|
||||
from_user=from_user,
|
||||
|
|
@ -42,4 +42,4 @@ class DirectPagingAPIView(APIView):
|
|||
alert_group=serializer.validated_data["alert_group"],
|
||||
)
|
||||
|
||||
return Response(status=status.HTTP_200_OK)
|
||||
return Response(data={"alert_group_id": alert_group.public_primary_key}, status=status.HTTP_200_OK)
|
||||
|
|
|
|||
|
|
@ -559,20 +559,37 @@ def _get_users_select(organization, team, input_id_prefix):
|
|||
}
|
||||
for user in users
|
||||
]
|
||||
|
||||
if not user_options:
|
||||
user_select = {"type": "context", "elements": [{"type": "mrkdwn", "text": "No users available"}]}
|
||||
return {"type": "context", "elements": [{"type": "mrkdwn", "text": "No users available"}]}
|
||||
|
||||
user_select = {
|
||||
"type": "section",
|
||||
"text": {"type": "mrkdwn", "text": "Add responders"},
|
||||
"block_id": input_id_prefix + DIRECT_PAGING_USER_SELECT_ID,
|
||||
"accessory": {
|
||||
"type": "static_select",
|
||||
"placeholder": {"type": "plain_text", "text": "Select a user", "emoji": True},
|
||||
"action_id": OnPagingUserChange.routing_uid(),
|
||||
},
|
||||
}
|
||||
|
||||
if len(user_options) > scenario_step.MAX_STATIC_SELECT_OPTIONS:
|
||||
# paginate user options in groups
|
||||
max_length = scenario_step.MAX_STATIC_SELECT_OPTIONS
|
||||
chunks = [user_options[x : x + max_length] for x in range(0, len(user_options), max_length)]
|
||||
option_groups = [
|
||||
{
|
||||
"label": {"type": "plain_text", "text": f"({(i * max_length)+1}-{(i * max_length)+max_length})"},
|
||||
"options": group,
|
||||
}
|
||||
for i, group in enumerate(chunks)
|
||||
]
|
||||
user_select["accessory"]["option_groups"] = option_groups
|
||||
|
||||
else:
|
||||
user_select = {
|
||||
"type": "section",
|
||||
"text": {"type": "mrkdwn", "text": "Add responders"},
|
||||
"block_id": input_id_prefix + DIRECT_PAGING_USER_SELECT_ID,
|
||||
"accessory": {
|
||||
"type": "static_select",
|
||||
"placeholder": {"type": "plain_text", "text": "Select a user", "emoji": True},
|
||||
"options": user_options,
|
||||
"action_id": OnPagingUserChange.routing_uid(),
|
||||
},
|
||||
}
|
||||
user_select["accessory"]["options"] = user_options
|
||||
|
||||
return user_select
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"build": "grafana-toolkit plugin:build",
|
||||
"build:dev": "grafana-toolkit plugin:build --skipTest --skipLint",
|
||||
"test": "jest --verbose",
|
||||
"test:silent": "jest --silent",
|
||||
"dev": "grafana-toolkit plugin:dev",
|
||||
"watch": "grafana-toolkit plugin:dev --watch",
|
||||
"sign": "grafana-toolkit plugin:sign",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import React, { FC, useMemo } from 'react';
|
||||
import React, { FC, useCallback, useMemo } from 'react';
|
||||
|
||||
import cn from 'classnames/bind';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import Text from 'components/Text/Text';
|
||||
import { getPathFromQueryParams } from 'utils/url';
|
||||
|
||||
import styles from './PluginLink.module.css';
|
||||
|
|
@ -23,12 +22,23 @@ const PluginLink: FC<PluginLinkProps> = (props) => {
|
|||
|
||||
const newPath = useMemo(() => getPathFromQueryParams(query), [query]);
|
||||
|
||||
return disabled ? (
|
||||
<Text className={cx('root', className, { 'no-wrap': !wrap })} type="disabled">
|
||||
{children}
|
||||
</Text>
|
||||
) : (
|
||||
<Link className={cx('root', className, { 'no-wrap': !wrap })} to={newPath}>
|
||||
const handleClick = useCallback(
|
||||
(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (disabled) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
[disabled]
|
||||
);
|
||||
|
||||
return (
|
||||
<Link
|
||||
onClick={handleClick}
|
||||
className={cx('root', className, { 'no-wrap': !wrap, root_disabled: disabled })}
|
||||
to={newPath}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ const ScheduleUserDetails: FC<ScheduleUserDetailsProps> = (props) => {
|
|||
const store = useStore();
|
||||
|
||||
const { teamStore } = store;
|
||||
let slackWorkspaceNameOrigin = teamStore.currentTeam.slack_team_identity.cached_name;
|
||||
const slackWorkspaceName = slackWorkspaceNameOrigin.replace(/[^0-9a-z]/gi, '');
|
||||
|
||||
const slackWorkspaceName = teamStore.currentTeam.slack_team_identity?.cached_name?.replace(/[^0-9a-z]/gi, '') || '';
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<VerticalGroup spacing="md">
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { SortableContainer, SortableElement, SortableHandle } from 'react-sortab
|
|||
import Text from 'components/Text/Text';
|
||||
import RemoteSelect from 'containers/RemoteSelect/RemoteSelect';
|
||||
import { User } from 'models/user/user.types';
|
||||
import { UserActions } from 'utils/authorization';
|
||||
|
||||
import { fromPlainArray, toPlainArray } from './UserGroups.helpers';
|
||||
import { Item } from './UserGroups.types';
|
||||
|
|
@ -114,6 +115,7 @@ const UserGroups = (props: UserGroupsProps) => {
|
|||
onChange={handleUserAdd}
|
||||
showError={showError}
|
||||
maxMenuHeight={150}
|
||||
requiredUserAction={UserActions.UserSettingsWrite}
|
||||
/>
|
||||
<SortableList
|
||||
renderItem={renderItem}
|
||||
|
|
|
|||
|
|
@ -86,12 +86,16 @@ export const getNonWorkingMoments = (startMoment, endMoment, workingHours) => {
|
|||
export const isInWorkingHours = (currentMoment: dayjs.Dayjs, workingHours, timezone) => {
|
||||
const timeFormat = 'HH:mm:ss';
|
||||
const currentDayOfTheWeek = currentMoment.format('dddd').toLowerCase();
|
||||
const workingHourStart = workingHours[currentDayOfTheWeek][0].start;
|
||||
const workingHourEnd = workingHours[currentDayOfTheWeek][0].end;
|
||||
const workingHourStart = workingHours[currentDayOfTheWeek][0]?.start;
|
||||
const workingHourEnd = workingHours[currentDayOfTheWeek][0]?.end;
|
||||
|
||||
const startTime = dayjs(workingHourStart, timeFormat).tz(timezone).format(timeFormat);
|
||||
const endTime = dayjs(workingHourEnd, timeFormat).tz(timezone).format(timeFormat);
|
||||
const currentTime = dayjs(currentMoment, timeFormat).format(timeFormat);
|
||||
if (workingHourStart && workingHourEnd) {
|
||||
const startTime = dayjs(workingHourStart, timeFormat).tz(timezone).format(timeFormat);
|
||||
const endTime = dayjs(workingHourEnd, timeFormat).tz(timezone).format(timeFormat);
|
||||
const currentTime = dayjs(currentMoment, timeFormat).format(timeFormat);
|
||||
|
||||
return currentTime < endTime && currentTime >= startTime;
|
||||
return currentTime < endTime && currentTime >= startTime;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { WithPermissionControl } from 'containers/WithPermissionControl/WithPerm
|
|||
import { ApiToken } from 'models/api_token/api_token.types';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
import { isUserActionAllowed, UserActions } from 'utils/authorization';
|
||||
import { generateMissingPermissionMessage, isUserActionAllowed, UserActions } from 'utils/authorization';
|
||||
|
||||
import ApiTokenForm from './ApiTokenForm';
|
||||
|
||||
|
|
@ -21,6 +21,7 @@ import styles from './ApiTokenSettings.module.css';
|
|||
const cx = cn.bind(styles);
|
||||
|
||||
const MAX_TOKENS_PER_USER = 5;
|
||||
const REQUIRED_PERMISSION_TO_VIEW = UserActions.APIKeysWrite;
|
||||
|
||||
interface ApiTokensProps extends WithStoreProps {}
|
||||
|
||||
|
|
@ -67,6 +68,15 @@ class ApiTokens extends React.Component<ApiTokensProps, any> {
|
|||
},
|
||||
];
|
||||
|
||||
const authorizedToViewAPIKeys = isUserActionAllowed(REQUIRED_PERMISSION_TO_VIEW);
|
||||
|
||||
let emptyText = 'Loading...';
|
||||
if (!authorizedToViewAPIKeys) {
|
||||
emptyText = `${generateMissingPermissionMessage(REQUIRED_PERMISSION_TO_VIEW)} to be able to view API tokens.`;
|
||||
} else if (apiTokens) {
|
||||
emptyText = 'No tokens found';
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GTable
|
||||
|
|
@ -92,13 +102,7 @@ class ApiTokens extends React.Component<ApiTokensProps, any> {
|
|||
className="api-keys"
|
||||
showHeader={!isMobile}
|
||||
data={apiTokens}
|
||||
emptyText={
|
||||
isUserActionAllowed(UserActions.APIKeysWrite)
|
||||
? apiTokens
|
||||
? 'No tokens found'
|
||||
: 'Loading...'
|
||||
: 'API tokens are available only for users with Admin permissions'
|
||||
}
|
||||
emptyText={emptyText}
|
||||
columns={columns}
|
||||
/>
|
||||
{showCreateTokenModal && (
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ class IncidentsFilters extends Component<IncidentsFiltersProps, IncidentsFilters
|
|||
} else {
|
||||
newQuery = {
|
||||
status: [IncidentStatus.New, IncidentStatus.Acknowledged],
|
||||
mine: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { User } from 'models/user/user.types';
|
|||
import { AppFeature } from 'state/features';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { isUserActionAllowed, UserActions } from 'utils/authorization';
|
||||
import { GRAFANA_LICENSE_OSS } from 'utils/consts';
|
||||
|
||||
import styles from './MobileAppConnection.module.scss';
|
||||
import DisconnectButton from './parts/DisconnectButton/DisconnectButton';
|
||||
|
|
@ -153,6 +154,8 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
|
|||
</VerticalGroup>
|
||||
);
|
||||
} else if (QRCodeValue) {
|
||||
const QRCodeDataParsed = getParsedQRCodeValue();
|
||||
|
||||
content = (
|
||||
<VerticalGroup spacing="lg">
|
||||
<Text type="primary" strong>
|
||||
|
|
@ -163,6 +166,15 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
|
|||
<QRCode className={cx({ 'qr-code': true, blurry: isQRBlurry })} value={QRCodeValue} />
|
||||
{isQRBlurry && <QRLoading />}
|
||||
</div>
|
||||
{store.backendLicense === GRAFANA_LICENSE_OSS && QRCodeDataParsed && (
|
||||
<Text type="secondary">
|
||||
Server URL embedded in this QR:
|
||||
<br />
|
||||
<a href={QRCodeDataParsed.oncall_api_url}>
|
||||
<Text type="link">{QRCodeDataParsed.oncall_api_url}</Text>
|
||||
</a>
|
||||
</Text>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
);
|
||||
}
|
||||
|
|
@ -178,6 +190,14 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
|
|||
</div>
|
||||
);
|
||||
|
||||
function getParsedQRCodeValue() {
|
||||
try {
|
||||
return JSON.parse(QRCodeValue);
|
||||
} catch (ex) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function clearTimeouts(): void {
|
||||
clearTimeout(userTimeoutId);
|
||||
clearTimeout(refreshTimeoutId);
|
||||
|
|
|
|||
|
|
@ -58,20 +58,27 @@ exports[`MobileAppConnection if we disconnect the app, it disconnects and fetche
|
|||
<div
|
||||
class="css-bxa289-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="root icon-block root_bordered root--fullWidth root--hover"
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=com.grafana.oncall.prod"
|
||||
rel="noreferrer"
|
||||
style="width: 100%;"
|
||||
target="_blank"
|
||||
>
|
||||
<img
|
||||
alt="Play Store"
|
||||
class="icon"
|
||||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="root text icon-text text--primary text--medium"
|
||||
<div
|
||||
class="root icon-block root_bordered root--fullWidth root--hover"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
</div>
|
||||
<img
|
||||
alt="Play Store"
|
||||
class="icon"
|
||||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="root text icon-text text--primary text--medium"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2389,20 +2396,27 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect
|
|||
<div
|
||||
class="css-bxa289-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="root icon-block root_bordered root--fullWidth root--hover"
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=com.grafana.oncall.prod"
|
||||
rel="noreferrer"
|
||||
style="width: 100%;"
|
||||
target="_blank"
|
||||
>
|
||||
<img
|
||||
alt="Play Store"
|
||||
class="icon"
|
||||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="root text icon-text text--primary text--medium"
|
||||
<div
|
||||
class="root icon-block root_bordered root--fullWidth root--hover"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
</div>
|
||||
<img
|
||||
alt="Play Store"
|
||||
class="icon"
|
||||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="root text icon-text text--primary text--medium"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2488,20 +2502,27 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco
|
|||
<div
|
||||
class="css-bxa289-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="root icon-block root_bordered root--fullWidth root--hover"
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=com.grafana.oncall.prod"
|
||||
rel="noreferrer"
|
||||
style="width: 100%;"
|
||||
target="_blank"
|
||||
>
|
||||
<img
|
||||
alt="Play Store"
|
||||
class="icon"
|
||||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="root text icon-text text--primary text--medium"
|
||||
<div
|
||||
class="root icon-block root_bordered root--fullWidth root--hover"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
</div>
|
||||
<img
|
||||
alt="Play Store"
|
||||
class="icon"
|
||||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="root text icon-text text--primary text--medium"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2587,20 +2608,27 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch
|
|||
<div
|
||||
class="css-bxa289-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="root icon-block root_bordered root--fullWidth root--hover"
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=com.grafana.oncall.prod"
|
||||
rel="noreferrer"
|
||||
style="width: 100%;"
|
||||
target="_blank"
|
||||
>
|
||||
<img
|
||||
alt="Play Store"
|
||||
class="icon"
|
||||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="root text icon-text text--primary text--medium"
|
||||
<div
|
||||
class="root icon-block root_bordered root--fullWidth root--hover"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
</div>
|
||||
<img
|
||||
alt="Play Store"
|
||||
class="icon"
|
||||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="root text icon-text text--primary text--medium"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2686,20 +2714,27 @@ exports[`MobileAppConnection it shows a message when the mobile app is already c
|
|||
<div
|
||||
class="css-bxa289-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="root icon-block root_bordered root--fullWidth root--hover"
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=com.grafana.oncall.prod"
|
||||
rel="noreferrer"
|
||||
style="width: 100%;"
|
||||
target="_blank"
|
||||
>
|
||||
<img
|
||||
alt="Play Store"
|
||||
class="icon"
|
||||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="root text icon-text text--primary text--medium"
|
||||
<div
|
||||
class="root icon-block root_bordered root--fullWidth root--hover"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
</div>
|
||||
<img
|
||||
alt="Play Store"
|
||||
class="icon"
|
||||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="root text icon-text text--primary text--medium"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2885,20 +2920,27 @@ exports[`MobileAppConnection it shows an error message if there was an error dis
|
|||
<div
|
||||
class="css-bxa289-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="root icon-block root_bordered root--fullWidth root--hover"
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=com.grafana.oncall.prod"
|
||||
rel="noreferrer"
|
||||
style="width: 100%;"
|
||||
target="_blank"
|
||||
>
|
||||
<img
|
||||
alt="Play Store"
|
||||
class="icon"
|
||||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="root text icon-text text--primary text--medium"
|
||||
<div
|
||||
class="root icon-block root_bordered root--fullWidth root--hover"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
</div>
|
||||
<img
|
||||
alt="Play Store"
|
||||
class="icon"
|
||||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="root text icon-text text--primary text--medium"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2975,20 +3017,27 @@ exports[`MobileAppConnection it shows an error message if there was an error fet
|
|||
<div
|
||||
class="css-bxa289-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="root icon-block root_bordered root--fullWidth root--hover"
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=com.grafana.oncall.prod"
|
||||
rel="noreferrer"
|
||||
style="width: 100%;"
|
||||
target="_blank"
|
||||
>
|
||||
<img
|
||||
alt="Play Store"
|
||||
class="icon"
|
||||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="root text icon-text text--primary text--medium"
|
||||
<div
|
||||
class="root icon-block root_bordered root--fullWidth root--hover"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
</div>
|
||||
<img
|
||||
alt="Play Store"
|
||||
class="icon"
|
||||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="root text icon-text text--primary text--medium"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -52,20 +52,27 @@ exports[`DownloadIcons it renders properly 1`] = `
|
|||
<div
|
||||
class="css-bxa289-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="root icon-block root_bordered root--fullWidth root--hover"
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=com.grafana.oncall.prod"
|
||||
rel="noreferrer"
|
||||
style="width: 100%;"
|
||||
target="_blank"
|
||||
>
|
||||
<img
|
||||
alt="Play Store"
|
||||
class="icon"
|
||||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="root text icon-text text--primary text--medium"
|
||||
<div
|
||||
class="root icon-block root_bordered root--fullWidth root--hover"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
</div>
|
||||
<img
|
||||
alt="Play Store"
|
||||
class="icon"
|
||||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="root text icon-text text--primary text--medium"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -25,12 +25,19 @@ const DownloadIcons: FC = () => (
|
|||
iOS
|
||||
</Text>
|
||||
</Block>
|
||||
<Block hover fullWidth bordered className={cx('icon-block')}>
|
||||
<img src={PlayStoreLogoSVG} alt="Play Store" className={cx('icon')} />
|
||||
<Text type="primary" className={cx('icon-text')}>
|
||||
Android
|
||||
</Text>
|
||||
</Block>
|
||||
<a
|
||||
style={{ width: '100%' }}
|
||||
href="https://play.google.com/store/apps/details?id=com.grafana.oncall.prod"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Block hover fullWidth bordered className={cx('icon-block')}>
|
||||
<img src={PlayStoreLogoSVG} alt="Play Store" className={cx('icon')} />
|
||||
<Text type="primary" className={cx('icon-text')}>
|
||||
Android
|
||||
</Text>
|
||||
</Block>
|
||||
</a>
|
||||
</VerticalGroup>
|
||||
</VerticalGroup>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import React, { useCallback, useEffect, useMemo, useReducer } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { AsyncMultiSelect, AsyncSelect } from '@grafana/ui';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
|
||||
import { makeRequest } from 'network';
|
||||
import { makeRequest, isNetworkError } from 'network';
|
||||
import { UserAction, generateMissingPermissionMessage } from 'utils/authorization';
|
||||
|
||||
interface RemoteSelectProps {
|
||||
autoFocus?: boolean;
|
||||
|
|
@ -24,6 +25,7 @@ interface RemoteSelectProps {
|
|||
getOptionLabel?: (item: SelectableValue) => React.ReactNode;
|
||||
showError?: boolean;
|
||||
maxMenuHeight?: number;
|
||||
requiredUserAction?: UserAction;
|
||||
}
|
||||
|
||||
const RemoteSelect = inject('store')(
|
||||
|
|
@ -45,9 +47,12 @@ const RemoteSelect = inject('store')(
|
|||
openMenuOnFocus = true,
|
||||
showError,
|
||||
maxMenuHeight,
|
||||
requiredUserAction,
|
||||
} = props;
|
||||
|
||||
const getOptions = (data: any[]) => {
|
||||
const [noOptionsMessage, setNoOptionsMessage] = useState<string>('No options found');
|
||||
|
||||
const getOptions = (data: any[]): SelectableValue[] => {
|
||||
return data.map((option: any) => ({
|
||||
value: option[valueField],
|
||||
label: option[fieldToShow],
|
||||
|
|
@ -62,17 +67,23 @@ const RemoteSelect = inject('store')(
|
|||
|
||||
const [options, setOptions] = useReducer(mergeOptions, []);
|
||||
|
||||
useEffect(() => {
|
||||
makeRequest(href, {}).then((data) => {
|
||||
setOptions(getOptions(data.results || data));
|
||||
});
|
||||
const loadOptionsCallback = useCallback(async (query?: string): Promise<SelectableValue[]> => {
|
||||
try {
|
||||
const data = await makeRequest(href, { params: { search: query } });
|
||||
const options = getOptions(data.results || data);
|
||||
setOptions(options);
|
||||
|
||||
return options;
|
||||
} catch (e) {
|
||||
if (isNetworkError(e) && e.response.status === 403 && requiredUserAction) {
|
||||
setNoOptionsMessage(generateMissingPermissionMessage(requiredUserAction));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadOptionsCallback = useCallback((query: string) => {
|
||||
return makeRequest(href, { params: { search: query } }).then((data) => {
|
||||
setOptions(getOptions(data.results || data));
|
||||
return getOptions(data.results || data);
|
||||
});
|
||||
useEffect(() => {
|
||||
loadOptionsCallback();
|
||||
}, []);
|
||||
|
||||
const onChangeCallback = useCallback(
|
||||
|
|
@ -119,6 +130,7 @@ const RemoteSelect = inject('store')(
|
|||
defaultOptions={options}
|
||||
loadOptions={loadOptionsCallback}
|
||||
getOptionLabel={getOptionLabel}
|
||||
noOptionsMessage={noOptionsMessage}
|
||||
invalid={showError}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -133,7 +133,6 @@ export const TabsContent = observer(({ id, activeTab, onTabChange, isDesktopOrLa
|
|||
) : (
|
||||
<PhoneVerification userPk={id} />
|
||||
))}
|
||||
{/* TODO: we should probably hide this tab when a user (ie. Admin) is viewing the user settings for another user. Would it make sense for an Admin to be able to link their mobile app to another user's profile */}
|
||||
{activeTab === UserSettingsTab.MobileAppConnection && <MobileAppConnection userPk={id} />}
|
||||
{activeTab === UserSettingsTab.SlackInfo && <SlackTab />}
|
||||
{activeTab === UserSettingsTab.TelegramInfo && <TelegramInfo />}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ interface RequestConfig {
|
|||
validateStatus?: (status: number) => boolean;
|
||||
}
|
||||
|
||||
export const isNetworkError = axios.isAxiosError;
|
||||
|
||||
export const makeRequest = async <RT = any>(path: string, config: RequestConfig) => {
|
||||
const { method = 'GET', params, data, validateStatus } = config;
|
||||
|
||||
|
|
|
|||
|
|
@ -127,7 +127,6 @@ export const pages: { [id: string]: PageDefinition } = [
|
|||
icon: 'table',
|
||||
id: 'live-settings',
|
||||
text: 'Env Variables',
|
||||
role: 'Admin',
|
||||
hideFromTabsFn: (store: RootBaseStore) => {
|
||||
const hasLiveSettings = store.hasFeature(AppFeature.LiveSettings);
|
||||
return isTopNavbar() || !hasLiveSettings;
|
||||
|
|
@ -139,7 +138,6 @@ export const pages: { [id: string]: PageDefinition } = [
|
|||
icon: 'cloud',
|
||||
id: 'cloud',
|
||||
text: 'Cloud',
|
||||
role: 'Admin',
|
||||
hideFromTabsFn: (store: RootBaseStore) => {
|
||||
const hasCloudFeature = store.hasFeature(AppFeature.CloudConnection);
|
||||
return isTopNavbar() || !hasCloudFeature;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { WithStoreProps } from 'state/types';
|
|||
import { useStore } from 'state/useStore';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
import { openErrorNotification } from 'utils';
|
||||
import { determineRequiredAuthString, UserActions } from 'utils/authorization';
|
||||
import { PLUGIN_ROOT } from 'utils/consts';
|
||||
|
||||
import styles from './CloudPage.module.css';
|
||||
|
|
@ -261,10 +262,9 @@ const CloudPage = observer((props: CloudPageProps) => {
|
|||
|
||||
<div style={{ width: '100%' }}>
|
||||
<Text type="secondary">
|
||||
{/* TODO: should probably update this message? */}
|
||||
{
|
||||
'Ask your users to sign up in Grafana Cloud, verify phone number and feel free to set up SMS & phone call notifications in personal settings! Only users with Admin or Editor role will be synced.'
|
||||
}
|
||||
{`Ask your users to sign up in Grafana Cloud, verify phone number and feel free to set up SMS & phone call notifications in personal settings! Users must have ${determineRequiredAuthString(
|
||||
UserActions.NotificationsRead
|
||||
)} in order to be synced.`}
|
||||
</Text>
|
||||
|
||||
<GTable
|
||||
|
|
@ -280,15 +280,9 @@ const CloudPage = observer((props: CloudPageProps) => {
|
|||
{matched_users_count === 1 ? '' : 's'}
|
||||
{` matched between OSS and Cloud OnCall`}
|
||||
</Text>
|
||||
{syncingUsers ? (
|
||||
<Button variant="primary" onClick={syncUsers} icon="sync" disabled>
|
||||
Syncing...
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" onClick={syncUsers} icon="sync">
|
||||
Sync users (Editors and Admins)
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="primary" onClick={syncUsers} icon="sync" disabled={syncingUsers}>
|
||||
{syncingUsers ? 'Syncing...' : 'Sync users'}
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { User as UserType } from 'models/user/user.types';
|
|||
import { PageProps, WithStoreProps } from 'state/types';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
import LocationHelper from 'utils/LocationHelper';
|
||||
import { isUserActionAllowed, UserActions } from 'utils/authorization';
|
||||
import { generateMissingPermissionMessage, isUserActionAllowed, UserActions } from 'utils/authorization';
|
||||
import { PLUGIN_ROOT } from 'utils/consts';
|
||||
|
||||
import { getUserRowClassNameFn } from './Users.helpers';
|
||||
|
|
@ -35,6 +35,7 @@ const cx = cn.bind(styles);
|
|||
interface UsersProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {}
|
||||
|
||||
const ITEMS_PER_PAGE = 100;
|
||||
const REQUIRED_PERMISSION_TO_VIEW_USERS = UserActions.UserSettingsWrite;
|
||||
|
||||
interface UsersState extends PageBaseState {
|
||||
page: number;
|
||||
|
|
@ -43,6 +44,7 @@ interface UsersState extends PageBaseState {
|
|||
usersFilters?: {
|
||||
searchTerm: string;
|
||||
};
|
||||
initialUsersLoaded: boolean;
|
||||
}
|
||||
|
||||
@observer
|
||||
|
|
@ -56,10 +58,9 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
},
|
||||
|
||||
errorData: initErrorDataState(),
|
||||
initialUsersLoaded: false,
|
||||
};
|
||||
|
||||
initialUsersLoaded = false;
|
||||
|
||||
async componentDidMount() {
|
||||
const {
|
||||
query: { p },
|
||||
|
|
@ -74,18 +75,19 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
const { usersFilters, page } = this.state;
|
||||
const { userStore } = store;
|
||||
|
||||
if (!isUserActionAllowed(UserActions.UserSettingsWrite)) {
|
||||
if (!isUserActionAllowed(REQUIRED_PERMISSION_TO_VIEW_USERS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
LocationHelper.update({ p: page }, 'partial');
|
||||
return await userStore.updateItems(usersFilters, page);
|
||||
await userStore.updateItems(usersFilters, page);
|
||||
|
||||
this.setState({ initialUsersLoaded: true });
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps: UsersProps) {
|
||||
if (!this.initialUsersLoaded && isUserActionAllowed(UserActions.UserSettingsWrite)) {
|
||||
if (!this.state.initialUsersLoaded) {
|
||||
this.updateUsers();
|
||||
this.initialUsersLoaded = true;
|
||||
}
|
||||
|
||||
if (prevProps.match.params.id !== this.props.match.params.id) {
|
||||
|
|
@ -117,7 +119,7 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
};
|
||||
|
||||
render() {
|
||||
const { usersFilters, userPkToEdit, page, errorData } = this.state;
|
||||
const { usersFilters, userPkToEdit, page, errorData, initialUsersLoaded } = this.state;
|
||||
const {
|
||||
store,
|
||||
match: {
|
||||
|
|
@ -165,6 +167,8 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
|
||||
const { count, results } = userStore.getSearchResult();
|
||||
|
||||
const authorizedToViewUsers = isUserActionAllowed(REQUIRED_PERMISSION_TO_VIEW_USERS);
|
||||
|
||||
return (
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
|
|
@ -182,10 +186,12 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
<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>
|
||||
</Text>
|
||||
{authorizedToViewUsers && (
|
||||
<Text type="secondary">
|
||||
To manage permissions or add users, please visit{' '}
|
||||
<a href="/org/users">Grafana user management</a>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PluginLink query={{ page: 'users', id: 'me' }}>
|
||||
|
|
@ -194,7 +200,7 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
</Button>
|
||||
</PluginLink>
|
||||
</div>
|
||||
{isUserActionAllowed(UserActions.UserSettingsRead) ? (
|
||||
{authorizedToViewUsers ? (
|
||||
<>
|
||||
<div className={cx('user-filters-container')}>
|
||||
<UsersFilters
|
||||
|
|
@ -213,7 +219,7 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
</div>
|
||||
|
||||
<GTable
|
||||
emptyText={results ? 'No users found' : 'Loading...'}
|
||||
emptyText={initialUsersLoaded ? 'No users found' : 'Loading...'}
|
||||
rowKey="pk"
|
||||
data={results}
|
||||
columns={columns}
|
||||
|
|
@ -230,8 +236,9 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
/* @ts-ignore */
|
||||
title={
|
||||
<>
|
||||
You don't have enough permissions to view other users because you are not Admin.{' '}
|
||||
<PluginLink query={{ page: 'users', id: 'me' }}>Click here</PluginLink> to open your profile
|
||||
{generateMissingPermissionMessage(REQUIRED_PERMISSION_TO_VIEW_USERS)} to be able to view OnCall
|
||||
users. <PluginLink query={{ page: 'users', id: 'me' }}>Click here</PluginLink> to open your
|
||||
profile
|
||||
</>
|
||||
}
|
||||
severity="info"
|
||||
|
|
|
|||
|
|
@ -535,7 +535,7 @@
|
|||
{
|
||||
"role": {
|
||||
"name": "User Settings Reader",
|
||||
"description": "Read-only access to OnCall User Settings",
|
||||
"description": "Read-only access to own OnCall User Settings",
|
||||
"permissions": [
|
||||
{ "action": "plugins.app:access", "scope": "plugins:id:grafana-oncall-app" },
|
||||
{ "action": "grafana-oncall-app.user-settings:read" }
|
||||
|
|
@ -546,7 +546,7 @@
|
|||
{
|
||||
"role": {
|
||||
"name": "User Settings Editor",
|
||||
"description": "Read/write access to own OnCall User Settings",
|
||||
"description": "Read/write access to own OnCall User Settings + ability to view basic information about other OnCall users",
|
||||
"permissions": [
|
||||
{ "action": "plugins.app:access", "scope": "plugins:id:grafana-oncall-app" },
|
||||
{ "action": "grafana-oncall-app.user-settings:read" },
|
||||
|
|
|
|||
|
|
@ -34,19 +34,19 @@ exports[`PluginState.generateUnknownErrorMsg it returns the proper error message
|
|||
Refresh your page and try again, or try removing your plugin configuration and reconfiguring."
|
||||
`;
|
||||
|
||||
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a 400 AxiosError properly - has custom error message: false 1`] = `
|
||||
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a 400 network error properly - has custom error message: false 1`] = `
|
||||
"An unknown error occured when trying to install the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct (NOTE: your OnCall API URL is currently being taken from process.env of your UI)?
|
||||
Refresh your page and try again, or try removing your plugin configuration and reconfiguring."
|
||||
`;
|
||||
|
||||
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a 400 AxiosError properly - has custom error message: true 1`] = `"ohhhh nooo an error"`;
|
||||
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a 400 network error properly - has custom error message: true 1`] = `"ohhhh nooo an error"`;
|
||||
|
||||
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a non-400 AxiosError properly - status code: 409 1`] = `
|
||||
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a non-400 network error properly - status code: 409 1`] = `
|
||||
"An unknown error occured when trying to install the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct (NOTE: your OnCall API URL is currently being taken from process.env of your UI)?
|
||||
Refresh your page and try again, or try removing your plugin configuration and reconfiguring."
|
||||
`;
|
||||
|
||||
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a non-400 AxiosError properly - status code: 502 1`] = `
|
||||
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a non-400 network error properly - status code: 502 1`] = `
|
||||
"Could not communicate with your OnCall API at http://hello.com (NOTE: your OnCall API URL is currently being taken from process.env of your UI).
|
||||
Validate that the URL is correct, your OnCall API is running, and that it is accessible from your Grafana instance."
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import axios from 'axios';
|
||||
import { OnCallAppPluginMeta, OnCallPluginMetaJSONData, OnCallPluginMetaSecureJSONData } from 'types';
|
||||
|
||||
import { makeRequest } from 'network';
|
||||
import { makeRequest, isNetworkError } from 'network';
|
||||
import FaroHelper from 'utils/faro';
|
||||
|
||||
export type UpdateGrafanaPluginSettingsProps = {
|
||||
|
|
@ -76,7 +75,7 @@ class PluginState {
|
|||
);
|
||||
const consoleMsg = `occured while trying to ${installationVerb} the plugin w/ the OnCall backend`;
|
||||
|
||||
if (axios.isAxiosError(e)) {
|
||||
if (isNetworkError(e)) {
|
||||
const { status: statusCode } = e.response;
|
||||
|
||||
console.warn(`An HTTP related error ${consoleMsg}`, e.response);
|
||||
|
|
@ -100,7 +99,7 @@ class PluginState {
|
|||
errorMsg = unknownErrorMsg;
|
||||
}
|
||||
} else {
|
||||
// a non-axios related error occured.. this scenario shouldn't occur...
|
||||
// a non-network related error occured.. this scenario shouldn't occur...
|
||||
console.warn(`An unknown error ${consoleMsg}`, e);
|
||||
errorMsg = unknownErrorMsg;
|
||||
}
|
||||
|
|
@ -115,12 +114,12 @@ class PluginState {
|
|||
): string => {
|
||||
let errorMsg: string;
|
||||
|
||||
if (axios.isAxiosError(e)) {
|
||||
if (isNetworkError(e)) {
|
||||
// The user likely put in a bogus URL for the OnCall API URL
|
||||
console.warn('An HTTP related error occured while trying to provision the plugin w/ Grafana', e.response);
|
||||
errorMsg = this.generateInvalidOnCallApiURLErrorMsg(onCallApiUrl, onCallApiUrlIsConfiguredThroughEnvVar);
|
||||
} else {
|
||||
// a non-axios related error occured.. this scenario shouldn't occur...
|
||||
// a non-network related error occured.. this scenario shouldn't occur...
|
||||
console.warn('An unknown error occured while trying to provision the plugin w/ Grafana', e);
|
||||
errorMsg = this.generateUnknownErrorMsg(onCallApiUrl, installationVerb, onCallApiUrlIsConfiguredThroughEnvVar);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { makeRequest as makeRequestOriginal } from 'network';
|
||||
import { makeRequest as makeRequestOriginal, isNetworkError as isNetworkErrorOriginal } from 'network';
|
||||
|
||||
import PluginState, { InstallationVerb, PluginSyncStatusResponse, UpdateGrafanaPluginSettingsProps } from './';
|
||||
|
||||
const makeRequest = makeRequestOriginal as jest.Mock<ReturnType<typeof makeRequestOriginal>>;
|
||||
const isNetworkError = isNetworkErrorOriginal as unknown as jest.Mock<ReturnType<typeof isNetworkErrorOriginal>>;
|
||||
|
||||
jest.mock('network');
|
||||
|
||||
|
|
@ -13,7 +14,7 @@ afterEach(() => {
|
|||
const ONCALL_BASE_URL = '/plugin';
|
||||
const GRAFANA_PLUGIN_SETTINGS_URL = '/api/plugins/grafana-oncall-app/settings';
|
||||
|
||||
const generateMockAxiosError = (status: number, data = {}) => ({ isAxiosError: true, response: { status, ...data } });
|
||||
const generateMockNetworkError = (status: number, data = {}) => ({ response: { status, ...data } });
|
||||
|
||||
describe('PluginState.generateOnCallApiUrlConfiguredThroughEnvVarMsg', () => {
|
||||
test.each([true, false])(
|
||||
|
|
@ -54,10 +55,12 @@ describe('PluginState.getHumanReadableErrorFromOnCallError', () => {
|
|||
console.warn = () => {};
|
||||
});
|
||||
|
||||
test.each([502, 409])('it handles a non-400 AxiosError properly - status code: %s', (status) => {
|
||||
test.each([502, 409])('it handles a non-400 network error properly - status code: %s', (status) => {
|
||||
isNetworkError.mockReturnValueOnce(true);
|
||||
|
||||
expect(
|
||||
PluginState.getHumanReadableErrorFromOnCallError(
|
||||
generateMockAxiosError(status),
|
||||
generateMockNetworkError(status),
|
||||
'http://hello.com',
|
||||
'install',
|
||||
true
|
||||
|
|
@ -66,19 +69,23 @@ describe('PluginState.getHumanReadableErrorFromOnCallError', () => {
|
|||
});
|
||||
|
||||
test.each([true, false])(
|
||||
'it handles a 400 AxiosError properly - has custom error message: %s',
|
||||
'it handles a 400 network error properly - has custom error message: %s',
|
||||
(hasCustomErrorMessage) => {
|
||||
const axiosError = generateMockAxiosError(400) as any;
|
||||
isNetworkError.mockReturnValueOnce(true);
|
||||
|
||||
const networkError = generateMockNetworkError(400) as any;
|
||||
if (hasCustomErrorMessage) {
|
||||
axiosError.response.data = { error: 'ohhhh nooo an error' };
|
||||
networkError.response.data = { error: 'ohhhh nooo an error' };
|
||||
}
|
||||
expect(
|
||||
PluginState.getHumanReadableErrorFromOnCallError(axiosError, 'http://hello.com', 'install', true)
|
||||
PluginState.getHumanReadableErrorFromOnCallError(networkError, 'http://hello.com', 'install', true)
|
||||
).toMatchSnapshot();
|
||||
}
|
||||
);
|
||||
|
||||
test('it handles an unknown error properly', () => {
|
||||
isNetworkError.mockReturnValueOnce(false);
|
||||
|
||||
expect(
|
||||
PluginState.getHumanReadableErrorFromOnCallError(new Error('asdfasdf'), 'http://hello.com', 'install', true)
|
||||
).toMatchSnapshot();
|
||||
|
|
@ -86,23 +93,27 @@ describe('PluginState.getHumanReadableErrorFromOnCallError', () => {
|
|||
});
|
||||
|
||||
describe('PluginState.getHumanReadableErrorFromGrafanaProvisioningError', () => {
|
||||
test.each([true, false])('it handles an error properly', (isAxiosError) => {
|
||||
beforeEach(() => {
|
||||
console.warn = () => {};
|
||||
});
|
||||
|
||||
test.each([true, false])('it handles an error properly - network error: %s', (networkError) => {
|
||||
const onCallApiUrl = 'http://hello.com';
|
||||
const installationVerb = 'install';
|
||||
const onCallApiUrlIsConfiguredThroughEnvVar = true;
|
||||
const axiosError = generateMockAxiosError(400);
|
||||
const nonAxiosError = new Error('oh noooo');
|
||||
const error = isAxiosError ? axiosError : nonAxiosError;
|
||||
const error = networkError ? generateMockNetworkError(400) : new Error('oh noooo');
|
||||
|
||||
const mockGenerateInvalidOnCallApiURLErrorMsgResult = 'asdadslkjfkjlsd';
|
||||
const mockGenerateUnknownErrorMsgResult = 'asdadslkjfkjlsd';
|
||||
|
||||
isNetworkError.mockReturnValueOnce(networkError);
|
||||
|
||||
PluginState.generateInvalidOnCallApiURLErrorMsg = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(mockGenerateInvalidOnCallApiURLErrorMsgResult);
|
||||
PluginState.generateUnknownErrorMsg = jest.fn().mockReturnValueOnce(mockGenerateUnknownErrorMsgResult);
|
||||
|
||||
const expectedErrorMsg = isAxiosError
|
||||
const expectedErrorMsg = networkError
|
||||
? mockGenerateInvalidOnCallApiURLErrorMsgResult
|
||||
: mockGenerateUnknownErrorMsgResult;
|
||||
|
||||
|
|
@ -115,7 +126,7 @@ describe('PluginState.getHumanReadableErrorFromGrafanaProvisioningError', () =>
|
|||
)
|
||||
).toEqual(expectedErrorMsg);
|
||||
|
||||
if (isAxiosError) {
|
||||
if (networkError) {
|
||||
expect(PluginState.generateInvalidOnCallApiURLErrorMsg).toHaveBeenCalledTimes(1);
|
||||
expect(PluginState.generateInvalidOnCallApiURLErrorMsg).toHaveBeenCalledWith(
|
||||
onCallApiUrl,
|
||||
|
|
|
|||
|
|
@ -63,6 +63,32 @@ describe('isUserActionAllowed', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('determineRequiredAuthString', () => {
|
||||
const testPerm = auth.UserActions.UserSettingsRead;
|
||||
|
||||
test.each([
|
||||
[true, `${testPerm.permission} permission`],
|
||||
[false, `${testPerm.fallbackMinimumRoleRequired} role`],
|
||||
])('RBAC enabled: %s', (rbacEnabled, expected) => {
|
||||
config.featureToggles.accessControlOnCall = rbacEnabled;
|
||||
|
||||
expect(auth.determineRequiredAuthString(testPerm)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateMissingPermissionMessage', () => {
|
||||
const testPerm = auth.UserActions.UserSettingsRead;
|
||||
|
||||
test.each([
|
||||
[true, `You are missing the ${testPerm.permission} permission`],
|
||||
[false, `You are missing the ${testPerm.fallbackMinimumRoleRequired} role`],
|
||||
])('RBAC enabled: %s', (rbacEnabled, expected) => {
|
||||
config.featureToggles.accessControlOnCall = rbacEnabled;
|
||||
|
||||
expect(auth.generateMissingPermissionMessage(testPerm)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePermissionString', () => {
|
||||
test('it properly builds permission strings with prefixes', () => {
|
||||
expect(auth.generatePermissionString(auth.Resource.API_KEYS, auth.Action.READ, true)).toEqual(
|
||||
|
|
|
|||
|
|
@ -90,12 +90,24 @@ export const userHasMinimumRequiredRole = (minimumRoleRequired: OrgRole): boolea
|
|||
*
|
||||
* As a fallback (second argument), for cases where RBAC is not enabled for a grafana instance, rely on basic roles
|
||||
*/
|
||||
export const isUserActionAllowed = ({ permission, fallbackMinimumRoleRequired }: UserAction): boolean => {
|
||||
if (config.featureToggles.accessControlOnCall) {
|
||||
return !!contextSrv.user.permissions?.[permission];
|
||||
}
|
||||
return userHasMinimumRequiredRole(fallbackMinimumRoleRequired);
|
||||
};
|
||||
export const isUserActionAllowed = ({ permission, fallbackMinimumRoleRequired }: UserAction): boolean =>
|
||||
config.featureToggles.accessControlOnCall
|
||||
? !!contextSrv.user.permissions?.[permission]
|
||||
: userHasMinimumRequiredRole(fallbackMinimumRoleRequired);
|
||||
|
||||
/**
|
||||
* Given a `UserAction`, returns the permission or fallback-role, prefixed with "permission" or "role" respectively
|
||||
* depending on whether or not RBAC is enabled/disabled
|
||||
*/
|
||||
export const determineRequiredAuthString = ({ permission, fallbackMinimumRoleRequired }: UserAction): string =>
|
||||
config.featureToggles.accessControlOnCall ? `${permission} permission` : `${fallbackMinimumRoleRequired} role`;
|
||||
|
||||
/**
|
||||
* Can be used to generate a user-friendly message about which permission is missing. Method is RBAC-aware
|
||||
* and shows user the missing permission/basic-role depending on whether or not RBAC is enabled/disabled
|
||||
*/
|
||||
export const generateMissingPermissionMessage = (permission: UserAction): string =>
|
||||
`You are missing the ${determineRequiredAuthString(permission)}`;
|
||||
|
||||
export const generatePermissionString = (resource: Resource, action: Action, includePrefix: boolean): string =>
|
||||
`${includePrefix ? `${ONCALL_PERMISSION_PREFIX}.` : ''}${resource}:${action}`;
|
||||
|
|
|
|||
|
|
@ -12411,9 +12411,9 @@ signal-exit@^3.0.2, signal-exit@^3.0.3:
|
|||
integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
|
||||
|
||||
simple-git@^3.6.0:
|
||||
version "3.15.0"
|
||||
resolved "https://registry.yarnpkg.com/simple-git/-/simple-git-3.15.0.tgz#301a95a943c4f9b0a21d051eb6e6d0ffe4c9754f"
|
||||
integrity sha512-FiWoMPlcYHQ+ApRihUsGjC/ZmIlWj62S6MBCwOunczvXcLQt+9ZdrysDrR6QVepkRQfEAaBXrN2QtJKrN6zbtg==
|
||||
version "3.16.0"
|
||||
resolved "https://registry.yarnpkg.com/simple-git/-/simple-git-3.16.0.tgz#421773e24680f5716999cc4a1d60127b4b6a9dec"
|
||||
integrity sha512-zuWYsOLEhbJRWVxpjdiXl6eyAyGo/KzVW+KFhhw9MqEEJttcq+32jTWSGyxTdf9e/YCohxRE+9xpWFj9FdiJNw==
|
||||
dependencies:
|
||||
"@kwsites/file-exists" "^1.1.1"
|
||||
"@kwsites/promise-deferred" "^1.1.1"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue