Merge pull request #1335 from grafana/dev

Dev to main
This commit is contained in:
Vadim Stepanov 2023-02-16 13:09:40 +00:00 committed by GitHub
commit 74a0c41ed3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
102 changed files with 2737 additions and 911 deletions

View file

@ -17,7 +17,7 @@ jobs:
uses: Zomzog/changelog-checker@v1.3.0
with:
fileName: CHANGELOG.md
noChangelogLabel: no changelog
noChangelogLabel: pr:no changelog
checkNotification: Simple
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -12,7 +12,7 @@ jobs:
name: Verify public documentation updated
# Don't run this job if the "no public docs" label is applied to the PR
# https://github.com/orgs/community/discussions/26712#discussioncomment-3253012
if: "!contains(github.event.pull_request.labels.*.name, 'no public docs')"
if: "!contains(github.event.pull_request.labels.*.name, 'pr:no public docs')"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1

View file

@ -5,6 +5,26 @@ 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
### Added
- Add direct user paging ([823](https://github.com/grafana/oncall/issues/823))
- Add App Store link to web UI ([1328](https://github.com/grafana/oncall/pull/1328))
### Fixed
- Cleaning of the name "Incident" ([704](https://github.com/grafana/oncall/pull/704))
- Alert Group/Alert Groups naming polishing. All the names should be with capital letters
- Design polishing ([1290](https://github.com/grafana/oncall/pull/1290))
- Not showing contact details in User tooltip if User does not have edit/admin access
- Updated slack link account to redirect back to user profile instead of chatops
### Changed
- Incidents - Removed buttons column and replaced status with toggler ([#1237](https://github.com/grafana/oncall/issues/1237))
- Responsiveness changes across multiple pages (Incidents, Integrations, Schedules) ([#1237](https://github.com/grafana/oncall/issues/1237))
## v1.1.23 (2023-02-06)
### Fixed

View file

@ -48,7 +48,7 @@ Grafana OnCall is available for Grafana Cloud and Grafana open source users.
Mobile app download:
- [Google Play Store](https://play.google.com/store/apps/details?id=com.grafana.oncall.prod)
- Apple App Store - Coming soon
- [Apple App Store](https://apps.apple.com/us/app/grafana-oncall-preview/id1669759048)
## Connect your Grafana OnCall account

View file

@ -671,7 +671,7 @@ def test_alert_receive_channel_counters_per_integration_permissions(
@pytest.mark.django_db
def test_get_alert_receive_channels_direct_paging_hidden(
def test_get_alert_receive_channels_direct_paging_hidden_from_list(
make_organization_and_user_with_plugin_token, make_alert_receive_channel, make_user_auth_headers
):
organization, user, token = make_organization_and_user_with_plugin_token()
@ -684,3 +684,21 @@ def test_get_alert_receive_channels_direct_paging_hidden(
# Check no direct paging integrations in the response
assert response.status_code == status.HTTP_200_OK
assert response.json() == []
@pytest.mark.django_db
def test_get_alert_receive_channels_direct_paging_present_for_filters(
make_organization_and_user_with_plugin_token, make_alert_receive_channel, make_user_auth_headers
):
organization, user, token = make_organization_and_user_with_plugin_token()
alert_receive_channel = make_alert_receive_channel(
user.organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING
)
client = APIClient()
url = reverse("api-internal:alert_receive_channel-list")
response = client.get(url + "?filters=true", format="json", **make_user_auth_headers(user, token))
# Check direct paging integration is in the response
assert response.status_code == status.HTTP_200_OK
assert response.json()[0]["value"] == alert_receive_channel.public_primary_key

View file

@ -137,8 +137,9 @@ class AlertReceiveChannelView(
if eager:
queryset = self.serializer_class.setup_eager_loading(queryset)
# Hide direct paging integrations
queryset = queryset.exclude(integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING)
# Hide direct paging integrations from the list view, but not from the filters
if not is_filters_request:
queryset = queryset.exclude(integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING)
return queryset

View file

@ -12,6 +12,7 @@ from social_django.utils import psa
from social_django.views import _do_login
from apps.auth_token.auth import PluginAuthentication, SlackTokenAuthentication
from apps.social_auth.backends import LoginSlackOAuth2V2
logger = logging.getLogger(__name__)
@ -36,6 +37,12 @@ def overridden_login_slack_auth(request, backend):
@psa("social:complete")
def overridden_complete_slack_auth(request, backend, *args, **kwargs):
"""Authentication complete view"""
# InstallSlackOAuth2V2 backend
redirect_to = "/a/grafana-oncall-app/chat-ops"
if isinstance(request.backend, LoginSlackOAuth2V2):
# if this was a user login/linking account, redirect to profile
redirect_to = "/a/grafana-oncall-app/users/me"
do_complete(
request.backend,
_do_login,
@ -45,6 +52,7 @@ def overridden_complete_slack_auth(request, backend, *args, **kwargs):
*args,
**kwargs,
)
# We build the frontend url using org url since multiple stacks could be connected to one backend.
return_to = urljoin(request.user.organization.grafana_url, "/a/grafana-oncall-app/?page=chat-ops")
return_to = urljoin(request.user.organization.grafana_url, redirect_to)
return HttpResponseRedirect(return_to)

View file

@ -128,9 +128,11 @@ class OnCallSchedule(PolymorphicModel):
"""Returns list of calendars. Primary calendar should always be the first"""
calendar_primary = None
calendar_overrides = None
if self._ical_file_primary is not None:
# if self._ical_file_(primary|overrides) is None -> no cache, will trigger a refresh
# if self._ical_file_(primary|overrides) == "" -> cached value for an empty schedule
if self._ical_file_primary:
calendar_primary = icalendar.Calendar.from_ical(self._ical_file_primary)
if self._ical_file_overrides is not None:
if self._ical_file_overrides:
calendar_overrides = icalendar.Calendar.from_ical(self._ical_file_overrides)
return calendar_primary, calendar_overrides
@ -563,7 +565,8 @@ class OnCallScheduleCalendar(OnCallSchedule):
"""
Generate iCal events file from custom on-call shifts (created via API)
"""
ical = None
# default to empty string since it is not possible to have a no-events ical file
ical = ""
if self.custom_on_call_shifts.exists():
end_line = "END:VCALENDAR"
calendar = Calendar()
@ -594,7 +597,8 @@ class OnCallScheduleWeb(OnCallSchedule):
def _generate_ical_file_from_shifts(self, qs, extra_shifts=None, allow_empty_users=False):
"""Generate iCal events file from custom on-call shifts."""
ical = None
# default to empty string since it is not possible to have a no-events ical file
ical = ""
if qs.exists() or extra_shifts is not None:
if extra_shifts is None:
extra_shifts = []

View file

@ -965,3 +965,35 @@ def test_filter_events_none_cache_unchanged(
events = schedule.filter_events("UTC", start_date, days=5, filter_by=OnCallSchedule.TYPE_ICAL_PRIMARY)
expected = []
assert events == expected
@pytest.mark.django_db
def test_schedules_ical_shift_cache(make_organization, make_schedule):
organization = make_organization()
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
# initial values are None
assert schedule.cached_ical_file_primary is None
assert schedule.cached_ical_file_overrides is None
# accessing the properties will trigger a refresh of the ical files (both empty)
assert schedule._ical_file_primary == ""
assert schedule._ical_file_overrides == ""
# after the refresh, cached values are updated
# (not None means no need to refresh cached value)
assert schedule.cached_ical_file_primary == ""
assert schedule.cached_ical_file_overrides == ""
# same for Terraform/API schedules
schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar)
# initial values is None
assert schedule.cached_ical_file_primary is None
# accessing the property will trigger a refresh of the ical file (empty)
assert schedule._ical_file_primary == ""
# after the refresh, cached value is updated
# (not None means no need to refresh cached value)
assert schedule.cached_ical_file_primary == ""

View file

@ -6,22 +6,26 @@ from rest_framework import status
from social_core import exceptions
from social_django.middleware import SocialAuthExceptionMiddleware
from apps.social_auth.backends import LoginSlackOAuth2V2
from common.constants.slack_auth import REDIRECT_AFTER_SLACK_INSTALL, SLACK_AUTH_FAILED
class SocialAuthAuthCanceledExceptionMiddleware(SocialAuthExceptionMiddleware):
def process_exception(self, request, exception):
backend = getattr(exception, "backend", None)
redirect_to = "/a/grafana-oncall-app/chat-ops"
if backend is not None and isinstance(backend, LoginSlackOAuth2V2):
redirect_to = "/a/grafana-oncall-app/users/me"
if isinstance(exception, exceptions.AuthCanceled):
# if user canceled authentication, redirect them to the previous page using the same link
# as we used to redirect after auth/install
url_to_redirect = urljoin(request.user.organization.grafana_url, "/a/grafana-oncall-app/?page=chat-ops")
url_to_redirect = urljoin(request.user.organization.grafana_url, redirect_to)
return redirect(url_to_redirect)
elif isinstance(exception, exceptions.AuthFailed):
# if authentication was failed, redirect user to the plugin page using the same link
# as we used to redirect after auth/install with error flag
url_to_redirect = urljoin(
request.user.organization.grafana_url,
f"/a/grafana-oncall-app/?page=chat-ops&slack_error={SLACK_AUTH_FAILED}",
request.user.organization.grafana_url, f"{redirect_to}&slack_error={SLACK_AUTH_FAILED}"
)
return redirect(url_to_redirect)
elif isinstance(exception, KeyError) and REDIRECT_AFTER_SLACK_INSTALL in exception.args:

View file

@ -1,11 +0,0 @@
from django.urls import path
from .views import overridden_complete_slack_auth, overridden_login_slack_auth
app_name = "social_auth"
urlpatterns = [
path(r"login/<backend>", overridden_login_slack_auth, name="slack-auth-with-no-slash"),
path(r"login/<backend>/", overridden_login_slack_auth, name="slack-auth"),
path(r"complete/<backend>/", overridden_complete_slack_auth, name="complete-slack-auth"),
]

View file

@ -1,50 +0,0 @@
import logging
from urllib.parse import urljoin
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.http import HttpResponseRedirect
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_exempt
from rest_framework.decorators import api_view, authentication_classes
from rest_framework.response import Response
from social_core.actions import do_auth, do_complete
from social_django.utils import psa
from social_django.views import _do_login
from apps.auth_token.auth import PluginAuthentication, SlackTokenAuthentication
logger = logging.getLogger(__name__)
@api_view(["GET"])
@authentication_classes([PluginAuthentication])
@never_cache
@psa("social:complete")
def overridden_login_slack_auth(request, backend):
# We can't just redirect frontend here because we need to make a API call and pass tokens to this view from JS.
# So frontend can't follow our redirect.
# So wrapping and returning URL to redirect as a string.
url_to_redirect_to = do_auth(request.backend, redirect_name=REDIRECT_FIELD_NAME).url
return Response(url_to_redirect_to, 200)
@api_view(["GET"])
@authentication_classes([SlackTokenAuthentication])
@never_cache
@csrf_exempt
@psa("social:complete")
def overridden_complete_slack_auth(request, backend, *args, **kwargs):
"""Authentication complete view"""
do_complete(
request.backend,
_do_login,
user=request.user,
redirect_name=REDIRECT_FIELD_NAME,
request=request,
*args,
**kwargs,
)
# We build the frontend url using org url since multiple stacks could be connected to one backend.
return_to = urljoin(request.user.organization.grafana_url, "/a/grafana-oncall-app/?page=chat-ops")
return HttpResponseRedirect(return_to)

View file

@ -32,7 +32,6 @@ urlpatterns = [
path("api/internal/v1/", include("apps.api.urls", namespace="api-internal")),
path("api/internal/v1/", include("social_django.urls", namespace="social")),
path("api/internal/v1/plugin/", include("apps.grafana_plugin.urls", namespace="grafana-plugin")),
path("api/internal/v1/", include("apps.social_auth.urls", namespace="social_auth")),
path("integrations/v1/", include("apps.integrations.urls", namespace="integrations")),
path("twilioapp/", include("apps.twilioapp.urls")),
path("api/v1/", include("apps.public_api.urls", namespace="api-public")),

View file

@ -1,4 +1,4 @@
django==3.2.17
django==3.2.18
djangorestframework==3.12.4
slackclient==1.3.0
whitenoise==5.3.0

View file

@ -1 +0,0 @@
../CHANGELOG.md

1
grafana-plugin/README.md Symbolic link
View file

@ -0,0 +1 @@
../README.md

View file

@ -23,7 +23,7 @@ function RealPlugin(props: AppPluginPageProps): React.ReactNode {
{/* Render alerts at the top */}
<Alerts />
<Header backendLicense={store.backendLicense} />
{pages[page]?.text && <h3 className="page-title">{pages[page].text}</h3>}
{pages[page]?.text && !pages[page]?.hideTitle && <h3 className="page-title">{pages[page].text}</h3>}
{props.children}
</RealPluginPage>
);

View file

@ -231,7 +231,7 @@ const AlertTemplatesForm = (props: AlertTemplatesFormProps) => {
<div className={cx('web-title-message')}>
<Text type="secondary" size="small">
Please note that after changing the web title template new alert groups will be searchable by
new title. Alert groups created before the template was changed will be still searchable by old
new title. Alert Groups created before the template was changed will be still searchable by old
title only.
</Text>
</div>

View file

@ -28,6 +28,7 @@ export interface Props<RecordType = unknown> extends TableProps<RecordType> {
expandIcon?: (props: { expanded: boolean; record: any }) => React.ReactNode;
onExpand?: (expanded: boolean, item: any) => void;
};
showHeader?: boolean;
}
const GTable: FC<Props> = (props) => {
@ -40,6 +41,7 @@ const GTable: FC<Props> = (props) => {
rowSelection,
rowKey,
expandable,
showHeader = true,
...restProps
} = props;
@ -143,6 +145,7 @@ const GTable: FC<Props> = (props) => {
className={cx('filter-table', className)}
columns={columns}
data={data}
showHeader={showHeader}
{...restProps}
/>
{pagination && (

View file

@ -0,0 +1,8 @@
.assign-responders-button {
display: flex;
}
.info-block {
background: var(--secondary-background);
width: 100%;
}

View file

@ -0,0 +1,104 @@
import React, { FC, useCallback, useState } from 'react';
import { Button, Drawer, HorizontalGroup, Icon, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import Block from 'components/GBlock/Block';
import GForm from 'components/GForm/GForm';
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
import Text from 'components/Text/Text';
import EscalationVariants from 'containers/EscalationVariants/EscalationVariants';
import { prepareForUpdate } from 'containers/EscalationVariants/EscalationVariants.helpers';
import { Alert } from 'models/alertgroup/alertgroup.types';
import { useStore } from 'state/useStore';
import styles from './ManualAlertGroup.module.css';
interface ManualAlertGroupProps {
onHide: () => void;
onCreate: (id: Alert['pk']) => void;
}
const cx = cn.bind(styles);
const manualAlertFormConfig: { name: string; fields: FormItem[] } = {
name: 'Manual Alert Group',
fields: [
{
name: 'title',
type: FormItemType.Input,
label: 'Title',
validation: { required: true },
},
{
name: 'message',
type: FormItemType.TextArea,
label: 'Describe what is going on',
},
],
};
const ManualAlertGroup: FC<ManualAlertGroupProps> = (props) => {
const store = useStore();
const [userResponders, setUserResponders] = useState([]);
const [scheduleResponders, setScheduleResponders] = useState([]);
const { onHide, onCreate } = props;
const data = {};
const handleFormSubmit = async (data) => {
store.directPagingStore
.createManualAlertRule(prepareForUpdate(userResponders, scheduleResponders, data))
.then(({ alert_group_id: id }: { alert_group_id: Alert['pk'] }) => {
onCreate(id);
})
.finally(() => {
onHide();
});
};
const onUpdateEscalationVariants = useCallback(
(value) => {
setUserResponders(value.userResponders);
setScheduleResponders(value.scheduleResponders);
},
[userResponders, scheduleResponders]
);
return (
<>
<Drawer scrollableContent title="Create manual alert group" onClose={onHide} closeOnMaskClick>
<VerticalGroup spacing="lg">
<EscalationVariants
value={{ userResponders, scheduleResponders }}
onUpdateEscalationVariants={onUpdateEscalationVariants}
/>
<GForm form={manualAlertFormConfig} data={data} onSubmit={handleFormSubmit} />
{store.teamStore.currentTeam.slack_team_identity && (
<Block className={cx('info-block')}>
<Icon name="info-circle" />{' '}
<Text type="secondary">
The alert group will also be posted to #{store.teamStore.currentTeam?.slack_channel?.display_name} Slack
channel.
</Text>
</Block>
)}
<HorizontalGroup justify="flex-end">
<Button variant="secondary" onClick={onHide}>
Cancel
</Button>
<Button
type="submit"
form={manualAlertFormConfig.name}
disabled={!userResponders.length && !scheduleResponders.length}
>
Create
</Button>
</HorizontalGroup>
</VerticalGroup>
</Drawer>
</>
);
};
export default ManualAlertGroup;

View file

@ -0,0 +1,53 @@
import React, { FC, useEffect, useState } from 'react';
import { Tooltip } from '@grafana/ui';
import { debounce } from 'throttle-debounce';
interface MatchMediaTooltipProps {
placement: 'top' | 'bottom' | 'right' | 'left';
content: string;
children: JSX.Element;
maxWidth?: number;
minWidth?: number;
}
const DEBOUNCE_MS = 200;
export const MatchMediaTooltip: FC<MatchMediaTooltipProps> = ({ minWidth, maxWidth, placement, content, children }) => {
const [match, setMatch] = useState<MediaQueryList>(getMatch());
useEffect(() => {
const debouncedResize = debounce(DEBOUNCE_MS, onWindowResize);
window.addEventListener('resize', debouncedResize);
return () => {
window.removeEventListener('resize', debouncedResize);
};
}, []);
if (match?.matches) {
return (
<Tooltip placement={placement} content={content}>
{children}
</Tooltip>
);
}
return <>{children}</>;
function onWindowResize() {
setMatch(getMatch());
}
function getMatch() {
if (minWidth && maxWidth) {
return window.matchMedia(`(min-width: ${minWidth}px) and (max-width: ${maxWidth}px)`);
} else if (minWidth) {
return window.matchMedia(`(min-width: ${minWidth}px)`);
} else if (maxWidth) {
return window.matchMedia(`(max-width: ${maxWidth}px)`);
}
return undefined;
}
};

View file

@ -1,6 +1,6 @@
.root {
width: 220px;
padding: 10px;
width: 210px;
padding: 8px 4px;
}
.oncall-badge {
@ -25,7 +25,7 @@
.line-break {
width: 100vw;
margin: -4px -18px -4px -18px;
margin: 8px -14px 8px -14px;
}
.times {
@ -37,21 +37,40 @@
color: #ccccdc;
}
.username {
word-break: break-all;
}
.timezone-wrapper {
display: flex;
flex-grow: 1;
}
.timezone-icon {
width: 10%;
margin-right: 8px;
}
.contact-icon {
margin-right: 8px;
}
.timezone-info {
width: 50%;
overflow-wrap: anywhere;
margin-left: 4px;
margin-right: 8px;
}
.contact-details {
display: flex;
}
.contact-details a {
text-decoration-line: none;
word-break: break-all;
}
.user-timezones {
margin-top: 4px;
display: flex;
width: 100%;
}

View file

@ -5,11 +5,14 @@ import cn from 'classnames/bind';
import dayjs from 'dayjs';
import Avatar from 'components/Avatar/Avatar';
import ScheduleBorderedAvatar from 'components/ScheduleBorderedAvatar/ScheduleBorderedAvatar';
import Text from 'components/Text/Text';
import { isInWorkingHours } from 'components/WorkingHours/WorkingHours.helpers';
import { getTzOffsetString } from 'models/timezone/timezone.helpers';
import { User } from 'models/user/user.types';
import { getColorSchemeMappingForUsers } from 'pages/schedule/Schedule.helpers';
import { useStore } from 'state/useStore';
import { isUserActionAllowed, UserActions } from 'utils/authorization';
import styles from './ScheduleUserDetails.module.css';
@ -17,36 +20,49 @@ interface ScheduleUserDetailsProps {
currentMoment: dayjs.Dayjs;
user: User;
isOncall: boolean;
scheduleId: string;
startMoment: dayjs.Dayjs;
}
const cx = cn.bind(styles);
const ScheduleUserDetails: FC<ScheduleUserDetailsProps> = (props) => {
const { user, currentMoment, isOncall } = props;
const { user, currentMoment, isOncall, scheduleId, startMoment } = props;
const userMoment = currentMoment.tz(user.timezone);
const userOffsetHoursStr = getTzOffsetString(userMoment);
const isInWH = isInWorkingHours(currentMoment, user.working_hours, user.timezone);
const store = useStore();
const colorSchemeMapping = getColorSchemeMappingForUsers(store, scheduleId, startMoment);
const colorSchemeList = Array.from(colorSchemeMapping[user.pk] || []);
const { teamStore } = store;
const slackWorkspaceName = teamStore.currentTeam.slack_team_identity?.cached_name?.replace(/[^0-9a-z]/gi, '') || '';
return (
<div className={cx('root')}>
<VerticalGroup spacing="md">
<HorizontalGroup justify="space-between">
<Avatar src={user.avatar} size="large" />
</HorizontalGroup>
<VerticalGroup spacing="sm">
<Text type="primary">{user.username}</Text>
{isOncall && <Badge text="OnCall now" color="green" />}
{isInWH ? (
<Badge text="Inside working hours" color="blue" />
) : (
<Badge text="Outside working hours" color="orange" />
)}
<HorizontalGroup align="flex-start">
<VerticalGroup spacing="xs">
<ScheduleBorderedAvatar
colors={colorSchemeList}
width={35}
height={35}
renderAvatar={() => <Avatar src={user.avatar} size="large" />}
renderIcon={() => null}
></ScheduleBorderedAvatar>
<VerticalGroup spacing="xs" width="100%">
<div className={cx('username')}>
<Text type="primary">{user.username}</Text>
</div>
<HorizontalGroup spacing="xs">
{isOncall && <Badge text="OnCall" color="green" />}
{isInWH ? (
<Badge text="Inside working hours" color="blue" />
) : (
<Badge text="Outside working hours" color="orange" />
)}
</HorizontalGroup>
<div className={cx('user-timezones')}>
<div className={cx('timezone-icon')}>
<Text type="secondary">
<Icon name="clock-nine" />
@ -54,7 +70,7 @@ const ScheduleUserDetails: FC<ScheduleUserDetailsProps> = (props) => {
</div>
<div className={cx('timezone-wrapper')}>
<div className={cx('timezone-info')}>
<VerticalGroup>
<VerticalGroup spacing="none">
<Text type="secondary">Local time</Text>
<Text type="secondary">{currentMoment.tz().format('DD MMM, HH:mm')}</Text>
<Text type="secondary">({getTzOffsetString(currentMoment)})</Text>
@ -62,60 +78,69 @@ const ScheduleUserDetails: FC<ScheduleUserDetailsProps> = (props) => {
</div>
<div className={cx('timezone-info')}>
<VerticalGroup className={cx('timezone-info')}>
<Text>{user.username}'s time</Text>
<VerticalGroup className={cx('timezone-info')} spacing="none">
<Text>User's time</Text>
<Text>{`${userMoment.tz(user.timezone).format('DD MMM, HH:mm')}`}</Text>
<Text>({userOffsetHoursStr})</Text>
</VerticalGroup>
</div>
</div>
</HorizontalGroup>
</VerticalGroup>
<hr className={cx('line-break')} />
<VerticalGroup spacing="sm">
<Text>Contacts</Text>
<div className={cx('contact-details')}>
<Text type="secondary">
<Icon name="envelope" />{' '}
<a href={`mailto:${user.email}`} target="_blank" rel="noreferrer">
<Text type="link">{user.email}</Text>
</a>{' '}
</Text>
</div>
{user.slack_user_identity && (
<div className={cx('contact-details')}>
<Text type="secondary">
<Icon name="slack" />{' '}
<a
href={`https://${slackWorkspaceName}.slack.com/team/${user.slack_user_identity.slack_id}`}
target="_blank"
rel="noreferrer"
>
<Text type="link">{user.slack_user_identity.slack_login}</Text>
</a>{' '}
</Text>
</div>
)}
{user.telegram_configuration && (
<div className={cx('contact-details')}>
<Text type="secondary">
<Icon name="message" />{' '}
<a
href={`https://t.me/${user.telegram_configuration.telegram_nick_name}`}
target="_blank"
rel="noreferrer"
>
<Text type="link">{user.telegram_configuration.telegram_nick_name}</Text>
</a>{' '}
</Text>
</div>
)}
{!user.hide_phone_number && user.verified_phone_number && (
<Text type="secondary">Phone: {user.verified_phone_number}</Text>
)}
</VerticalGroup>
{isUserActionAllowed(UserActions.UserSettingsAdmin) && (
<VerticalGroup spacing="xs">
<hr className={cx('line-break')} />
<VerticalGroup spacing="xs">
<Text>Contacts</Text>
<div className={cx('contact-details')}>
<Text type="secondary">
<Icon name="envelope" className={cx('contact-icon')} />
</Text>
<a href={`mailto:${user.email}`} target="_blank" rel="noreferrer">
<Text type="link">{user.email}</Text>
</a>
</div>
{user.slack_user_identity && (
<div className={cx('contact-details')}>
<Text type="secondary">
<Icon name="slack" className={cx('contact-icon')} />
</Text>
<a
href={`https://${slackWorkspaceName}.slack.com/team/${user.slack_user_identity.slack_id}`}
target="_blank"
rel="noreferrer"
>
<Text type="link">{user.slack_user_identity.slack_login}</Text>
</a>
</div>
)}
{user.telegram_configuration && (
<div className={cx('contact-details')}>
<Text type="secondary">
<Icon name="message" className={cx('contact-icon')} />
</Text>
<a
href={`https://t.me/${user.telegram_configuration.telegram_nick_name}`}
target="_blank"
rel="noreferrer"
>
<Text type="link">{user.telegram_configuration.telegram_nick_name}</Text>
</a>
</div>
)}
{!user.hide_phone_number && user.verified_phone_number && (
<div className={cx('contact-details')}>
<Text type="secondary">
<Icon name="document-info" className={cx('contact-icon')} />
</Text>
<Text type="secondary">{user.verified_phone_number}</Text>
</div>
)}
</VerticalGroup>
</VerticalGroup>
)}
</VerticalGroup>
</div>
);

View file

@ -1,4 +0,0 @@
.root {
display: inline-flex;
align-items: center;
}

View file

@ -0,0 +1,13 @@
.right {
display: flex;
flex-wrap: wrap;
row-gap: 4px;
column-gap: 8px;
}
@media screen and (max-width: 1600px) {
.right {
order: 3;
width: 100%;
}
}

View file

@ -1,60 +1,95 @@
import React, { useCallback, useMemo } from 'react';
import React, { ChangeEvent, useCallback } from 'react';
import { DatePickerWithInput, Field, HorizontalGroup, RadioButtonGroup } from '@grafana/ui';
import { Field, Icon, Input, RadioButtonGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import moment from 'moment-timezone';
import { dateStringToOption, optionToDateString } from './SchedulesFilters.helpers';
import { ScheduleType } from 'models/schedule/schedule.types';
import styles from './SchedulesFilters.module.scss';
import { SchedulesFiltersType } from './SchedulesFilters.types';
import styles from './SchedulesFilters.module.css';
const cx = cn.bind(styles);
interface SchedulesFiltersProps {
value: SchedulesFiltersType;
onChange: (filters: SchedulesFiltersType) => void;
className?: string;
}
const SchedulesFilters = ({ value, onChange, className }: SchedulesFiltersProps) => {
const handleDateChange = useCallback(
(date: Date) => {
onChange({ selectedDate: moment(date).format('YYYY-MM-DD') });
const SchedulesFilters = (props: SchedulesFiltersProps) => {
const { value, onChange } = props;
const onSearchTermChangeCallback = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
onChange({ ...value, searchTerm: e.currentTarget.value });
},
[onChange]
[value]
);
const handleStatusChange = useCallback(
(status) => {
onChange({ ...value, status });
},
[value]
);
const option = useMemo(() => dateStringToOption(value.selectedDate), [value]);
const handleOptionChange = useCallback(
(option: string) => {
onChange({ ...value, selectedDate: optionToDateString(option) });
const handleTypeChange = useCallback(
(type) => {
onChange({ ...value, type });
},
[onChange, value]
[value]
);
const datePickerValue = useMemo(() => moment(value.selectedDate).toDate(), [value]);
return (
<div className={cx('root', className)}>
<HorizontalGroup>
<Field label="Filter events">
<RadioButtonGroup
options={[
{ value: 'today', label: 'Today' },
{ value: 'tomorrow', label: 'Tomorrow' },
{ value: 'custom', label: 'Custom' },
]}
value={option}
onChange={handleOptionChange}
<>
<div className={cx('left')}>
<Field label="Search by name">
<Input
autoFocus
className={cx('search')}
prefix={<Icon name="search" />}
placeholder="Search..."
value={value.searchTerm}
onChange={onSearchTermChangeCallback}
/>
</Field>
<Field label="Date">
<DatePickerWithInput closeOnSelect width={40} value={datePickerValue} onChange={handleDateChange} />
</div>
<div className={cx('right')}>
<Field label="Status">
<RadioButtonGroup
options={[
{ label: 'All', value: 'all' },
{
label: 'Used in escalations',
value: 'used',
},
{ label: 'Unused', value: 'unused' },
]}
value={value.status}
onChange={handleStatusChange}
/>
</Field>
</HorizontalGroup>
</div>
<Field label="Type">
<RadioButtonGroup
options={[
{ label: 'All', value: undefined },
{
label: 'Web',
value: ScheduleType.API,
},
{
label: 'ICal',
value: ScheduleType.Ical,
},
{
label: 'API',
value: ScheduleType.Calendar,
},
]}
value={value?.type}
onChange={handleTypeChange}
/>
</Field>
</div>
</>
);
};

View file

@ -1,3 +1,7 @@
import { ScheduleType } from 'models/schedule/schedule.types';
export interface SchedulesFiltersType {
selectedDate: string;
searchTerm: string;
type: ScheduleType;
status: string;
}

View file

@ -1,25 +0,0 @@
import moment from 'moment-timezone';
export function optionToDateString(option: string) {
switch (option) {
case 'today':
return moment().startOf('day').format('YYYY-MM-DD');
case 'tomorrow':
return moment().add(1, 'day').startOf('day').format('YYYY-MM-DD');
default:
return moment().add(2, 'day').startOf('day').format('YYYY-MM-DD');
}
}
export function dateStringToOption(dateString: string) {
const today = moment().startOf('day').format('YYYY-MM-DD');
if (dateString === today) {
return 'today';
}
const tomorrow = moment().add(1, 'day').startOf('day').format('YYYY-MM-DD');
if (dateString === tomorrow) {
return 'tomorrow';
}
return 'custom';
}

View file

@ -1,95 +0,0 @@
import React, { ChangeEvent, useCallback } from 'react';
import { Field, HorizontalGroup, Icon, Input, RadioButtonGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import { ScheduleType } from 'models/schedule/schedule.types';
import { SchedulesFiltersType } from './SchedulesFilters.types';
import styles from './SchedulesFilters.module.css';
const cx = cn.bind(styles);
interface SchedulesFiltersProps {
value: SchedulesFiltersType;
onChange: (filters: SchedulesFiltersType) => void;
}
const SchedulesFilters = (props: SchedulesFiltersProps) => {
const { value, onChange } = props;
const onSearchTermChangeCallback = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
onChange({ ...value, searchTerm: e.currentTarget.value });
},
[value]
);
const handleStatusChange = useCallback(
(status) => {
onChange({ ...value, status });
},
[value]
);
const handleTypeChange = useCallback(
(type) => {
onChange({ ...value, type });
},
[value]
);
return (
<div className={cx('root')}>
<HorizontalGroup spacing="lg">
<Field label="Search by name">
<Input
autoFocus
className={cx('search')}
prefix={<Icon name="search" />}
placeholder="Search..."
value={value.searchTerm}
onChange={onSearchTermChangeCallback}
/>
</Field>
<Field label="Status">
<RadioButtonGroup
options={[
{ label: 'All', value: 'all' },
{
label: 'Used in escalations',
value: 'used',
},
{ label: 'Unused', value: 'unused' },
]}
value={value.status}
onChange={handleStatusChange}
/>
</Field>
<Field label="Type">
<RadioButtonGroup
options={[
{ label: 'All', value: undefined },
{
label: 'Web',
value: ScheduleType.API,
},
{
label: 'ICal',
value: ScheduleType.Ical,
},
{
label: 'API',
value: ScheduleType.Calendar,
},
]}
value={value?.type}
onChange={handleTypeChange}
/>
</Field>
</HorizontalGroup>
</div>
);
};
export default SchedulesFilters;

View file

@ -1,7 +0,0 @@
import { ScheduleType } from 'models/schedule/schedule.types';
export interface SchedulesFiltersType {
searchTerm: string;
type: ScheduleType;
status: string;
}

View file

@ -1,4 +1,8 @@
.root {
display: inline-flex;
align-items: center;
& .search {
width: 320px;
}
}

View file

@ -0,0 +1,44 @@
import React, { ChangeEvent, useCallback } from 'react';
import { Icon, Input } from '@grafana/ui';
import cn from 'classnames/bind';
import styles from './SearchInput.module.scss';
const cx = cn.bind(styles);
interface SearchInputProps {
value: any;
onChange: (filters: any) => void;
className?: string;
}
const SearchInput = (props: SearchInputProps) => {
const { value = { searchTerm: '' }, onChange, className } = props;
const onSearchTermChangeCallback = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const filters = {
...value,
searchTerm: e.currentTarget.value,
};
onChange(filters);
},
[onChange, value]
);
return (
<div className={cx('root', className)}>
<Input
className={cx('search', 'control')}
placeholder="Search"
value={value.searchTerm}
onChange={onSearchTermChangeCallback}
suffix={<Icon name="search" />}
/>
</div>
);
};
export default SearchInput;

View file

@ -8,15 +8,22 @@ interface TagProps {
color: string;
className?: string;
children?: any;
onClick?: (ev) => void;
forwardedRef?: React.MutableRefObject<HTMLSpanElement>;
}
const cx = cn.bind(styles);
const Tag: FC<TagProps> = (props) => {
const { children, color, className } = props;
const { children, color, className, onClick } = props;
return (
<span style={{ backgroundColor: color }} className={cx('root', className)}>
<span
style={{ backgroundColor: color }}
className={cx('root', className)}
onClick={onClick}
ref={props.forwardedRef}
>
{children}
</span>
);

View file

@ -25,12 +25,6 @@
text-align: center;
}
@media (min-width: 1540px) {
.step {
width: 170px;
}
}
.icon {
width: 60px;
height: 60px;
@ -55,3 +49,9 @@
:global(.theme-dark) .arrow svg {
fill-opacity: 0.15;
}
@media (min-width: 1540px) {
.step {
width: 170px;
}
}

View file

@ -0,0 +1,61 @@
import React, { useEffect, useState } from 'react';
import { ContextMenu } from '@grafana/ui';
export interface WithContextMenuProps {
children: (props: { openMenu: React.MouseEventHandler<HTMLElement> }) => JSX.Element;
renderMenuItems: () => React.ReactNode;
forceIsOpen?: boolean;
focusOnOpen?: boolean;
}
const query = '[class$="-page-container"] .scrollbar-view';
export const WithContextMenu: React.FC<WithContextMenuProps> = ({
children,
renderMenuItems,
forceIsOpen = false,
focusOnOpen = true,
}) => {
const [isMenuOpen, setIsMenuOpen] = useState(false || forceIsOpen);
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
setIsMenuOpen(forceIsOpen);
}, [forceIsOpen]);
useEffect(() => {
const onScrollOrResizeFn = () => setIsMenuOpen(false);
document.querySelector(query)?.addEventListener('scroll', onScrollOrResizeFn);
window.addEventListener('resize', onScrollOrResizeFn);
return () => {
document.querySelector(query)?.removeEventListener('scroll', onScrollOrResizeFn);
window.removeEventListener('resize', onScrollOrResizeFn);
};
}, []);
return (
<>
{children({
openMenu: (e) => {
setIsMenuOpen(true);
setMenuPosition({
x: e.pageX,
y: e.pageY,
});
},
})}
{isMenuOpen && (
<ContextMenu
onClose={() => setIsMenuOpen(false)}
x={menuPosition.x}
y={menuPosition.y}
renderMenuItems={renderMenuItems}
focusOnOpen={focusOnOpen}
/>
)}
</>
);
};

View file

@ -77,7 +77,7 @@ const AlertReceiveChannelCard = observer((props: AlertReceiveChannelCardProps) =
>
<b>{alertReceiveChannelCounter?.alerts_count}</b> alert
{alertReceiveChannelCounter?.alerts_count === 1 ? '' : 's'} in{' '}
<b>{alertReceiveChannelCounter?.alert_groups_count}</b> alert group
<b>{alertReceiveChannelCounter?.alert_groups_count}</b> Alert Group
{alertReceiveChannelCounter?.alert_groups_count === 1 ? '' : 's'}
</PluginLink>
)}

View file

@ -16,12 +16,6 @@
margin-bottom: 10px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
.verbal-name {
font-weight: 500;
}
@ -55,6 +49,8 @@
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.channel-filter-header-title {
@ -116,3 +112,27 @@
.description-style a {
color: var(--primary-text-link);
}
.integration__heading-text {
display: flex;
gap: 8px;
}
.integration__heading-container {
display: flex;
flex-wrap: wrap;
}
.integration__heading-container-left {
margin-bottom: 12px;
}
.integration__heading-container-left,
.integration__heading-container-right {
flex-grow: 1;
}
.integration__heading-container-right {
display: flex;
justify-content: flex-end;
}

View file

@ -155,20 +155,103 @@ class AlertRules extends React.Component<AlertRulesProps, AlertRulesState> {
<>
<div className={cx('root')}>
<Block className={cx('headerBlock')}>
<div className={cx('header')}>
<Text.Title level={4}>
<HorizontalGroup>
Escalate
<div className={cx('verbal-name')}>{parseEmojis(alertReceiveChannel?.verbal_name || '')}</div>
<Tooltip placement="top" content="Edit name">
<div className={cx('integration__heading-container')}>
<div className={cx('integration__heading-container-left')}>
<Text.Title level={4}>
<div className={cx('integration__heading-text')}>
<div className={cx('verbal-name')}>{parseEmojis(alertReceiveChannel?.verbal_name || '')}</div>
<Tooltip placement="top" content="Edit name">
<IconButton
name="pen"
onClick={this.getChangeIntegrationNameHandler(parseEmojis(alertReceiveChannel?.verbal_name))}
/>
</Tooltip>
</div>
</Text.Title>
</div>
<div className={cx('integration__heading-container-right')}>
<div className={cx('buttons')}>
<Button
variant="secondary"
size="sm"
onClick={() => {
onShowSettings(IntegrationSettingsTab.HowToConnect);
}}
>
How to connect
</Button>
<WithPermissionControl userAction={UserActions.IntegrationsTest}>
<Button
variant="secondary"
size="sm"
onClick={this.getSendDemoAlertClickHandler(alertReceiveChannel.id)}
>
Send demo alert
</Button>
</WithPermissionControl>
<div className={cx('icons-container')}>
{maintenanceMode === MaintenanceMode.Debug || maintenanceMode === MaintenanceMode.Maintenance ? (
<Tooltip placement="top" content="Stop maintenance mode">
<Button
className="grey-button"
disabled={!isUserActionAllowed(UserActions.MaintenanceWrite)}
fill="text"
icon="square-shape"
onClick={this.handleStopMaintenance}
/>
</Tooltip>
) : (
<PluginLink
query={{
page: 'maintenance',
maintenance_type: MaintenanceType.alert_receive_channel,
alert_receive_channel: alertReceiveChannel.id,
}}
disabled={!isUserActionAllowed(UserActions.MaintenanceWrite)}
>
<WithPermissionControl userAction={UserActions.MaintenanceWrite}>
<IconButton
name="pause"
size="sm"
tooltip="Setup maintenance mode"
tooltipPlacement="top"
disabled={!isUserActionAllowed(UserActions.MaintenanceWrite)}
/>
</WithPermissionControl>
</PluginLink>
)}
<IconButton
name="pen"
onClick={this.getChangeIntegrationNameHandler(parseEmojis(alertReceiveChannel?.verbal_name))}
name="cog"
size="sm"
tooltip="Settings"
tooltipPlacement="top"
onClick={() => {
onShowSettings();
}}
/>
</Tooltip>
alerts
</HorizontalGroup>
</Text.Title>
<WithPermissionControl userAction={UserActions.EscalationChainsWrite}>
<WithConfirm
title="Delete integration?"
body={
<>
Are you sure you want to delete <Emoji text={alertReceiveChannel.verbal_name} />{' '}
integration?
</>
}
>
<IconButton
size="sm"
tooltip="Delete"
tooltipPlacement="top"
onClick={this.handleDeleteAlertReceiveChannel}
name="trash-alt"
/>
</WithConfirm>
</WithPermissionControl>
</div>
</div>
</div>
{editIntegrationName !== undefined && (
<Modal
@ -199,85 +282,6 @@ class AlertRules extends React.Component<AlertRulesProps, AlertRulesState> {
</div>
</Modal>
)}
<div className={cx('buttons')}>
<Button
variant="secondary"
size="sm"
onClick={() => {
onShowSettings(IntegrationSettingsTab.HowToConnect);
}}
>
How to connect
</Button>
<WithPermissionControl userAction={UserActions.IntegrationsTest}>
<Button
variant="secondary"
size="sm"
onClick={this.getSendDemoAlertClickHandler(alertReceiveChannel.id)}
>
Send demo alert
</Button>
</WithPermissionControl>
<div className={cx('icons-container')}>
{maintenanceMode === MaintenanceMode.Debug || maintenanceMode === MaintenanceMode.Maintenance ? (
<Tooltip placement="top" content="Stop maintenance mode">
<Button
className="grey-button"
disabled={!isUserActionAllowed(UserActions.MaintenanceWrite)}
fill="text"
icon="square-shape"
onClick={this.handleStopMaintenance}
/>
</Tooltip>
) : (
<PluginLink
query={{
page: 'maintenance',
maintenance_type: MaintenanceType.alert_receive_channel,
alert_receive_channel: alertReceiveChannel.id,
}}
disabled={!isUserActionAllowed(UserActions.MaintenanceWrite)}
>
<WithPermissionControl userAction={UserActions.MaintenanceWrite}>
<IconButton
name="pause"
size="sm"
tooltip="Setup maintenance mode"
tooltipPlacement="top"
disabled={!isUserActionAllowed(UserActions.MaintenanceWrite)}
/>
</WithPermissionControl>
</PluginLink>
)}
<IconButton
name="cog"
size="sm"
tooltip="Settings"
tooltipPlacement="top"
onClick={() => {
onShowSettings(IntegrationSettingsTab.Templates);
}}
/>
<WithPermissionControl userAction={UserActions.EscalationChainsWrite}>
<WithConfirm
title="Delete integration?"
body={
<>
Are you sure you want to delete <Emoji text={alertReceiveChannel.verbal_name} /> integration?
</>
}
>
<IconButton
size="sm"
tooltip="Delete"
tooltipPlacement="top"
onClick={this.handleDeleteAlertReceiveChannel}
name="trash-alt"
/>
</WithConfirm>
</WithPermissionControl>
</div>
</div>
</div>
</Block>
{alertReceiveChannel.description && (

View file

@ -26,7 +26,7 @@ export const ChatOpsConnectors = (props: ChatOpsConnectorsProps) => {
}
return (
<Timeline.Item number={0} color="#464C54">
<Timeline.Item number={0} color={getComputedStyle(document.documentElement).getPropertyValue('--tag-secondary')}>
<VerticalGroup>
{isSlackInstalled && <SlackConnector channelFilterId={channelFilterId} />}
{isTelegramInstalled && <TelegramConnector channelFilterId={channelFilterId} />}

View file

@ -9,7 +9,6 @@ import Alerts from 'containers/Alerts/Alerts';
import { pages } from 'pages';
import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
import { DEFAULT_PAGE } from 'utils/consts';
import { useQueryParams } from 'utils/hooks';
import styles from './DefaultPageLayout.module.scss';
@ -21,9 +20,7 @@ interface DefaultPageLayoutProps extends AppRootProps {
}
const DefaultPageLayout: FC<DefaultPageLayoutProps> = observer((props) => {
const { children } = props;
const queryParams = useQueryParams();
const page = queryParams.get('page') || DEFAULT_PAGE;
const { children, page } = props;
if (isTopNavbar()) {
return renderTopNavbar();

View file

@ -92,7 +92,10 @@ const EscalationChainSteps = observer((props: EscalationChainStepsProps) => {
) : (
<LoadingPlaceholder text="Loading..." />
)}
<Timeline.Item number={(escalationPolicyIds?.length || 0) + offset + 1} color="#464C54">
<Timeline.Item
number={(escalationPolicyIds?.length || 0) + offset + 1}
color={getComputedStyle(document.documentElement).getPropertyValue('--tag-secondary')}
>
<WithPermissionControl userAction={UserActions.EscalationChainsWrite}>
<Select
isSearchable

View file

@ -0,0 +1,47 @@
import { User } from 'models/user/user.types';
import { ResponderType } from './EscalationVariants.types';
export const deduplicate = (value) => {
const deduplicatedUserResponders = [];
value.userResponders.forEach((userResponder) => {
if (!deduplicatedUserResponders.some((responder) => responder.data.pk === userResponder.data.pk)) {
deduplicatedUserResponders.push(userResponder);
}
});
const deduplicatedScheduleResponders = [];
value.scheduleResponders.forEach((scheduleResponder) => {
if (!deduplicatedScheduleResponders.some((responder) => responder.data.id === scheduleResponder.data.id)) {
deduplicatedScheduleResponders.push(scheduleResponder);
}
});
return {
...value,
scheduleResponders: deduplicatedScheduleResponders,
userResponders: deduplicatedUserResponders,
};
};
export function prepareForUpdate(userResponders, scheduleResponders, data?) {
return {
...data,
users: userResponders.map((userResponder) => ({ important: userResponder.important, id: userResponder.data.pk })),
schedules: scheduleResponders.map((scheduleResponder) => ({
important: scheduleResponder.important,
id: scheduleResponder.data.id,
})),
};
}
export function prepareForEdit(userResponders) {
return {
userResponders: (userResponders || []).map(({ pk }: { pk: User['pk'] }) => ({
type: ResponderType.User,
data: { pk },
important: false,
})),
scheduleResponders: [],
};
}

View file

@ -0,0 +1,81 @@
.escalation-variants-dropdown {
border: var(--border-medium);
position: absolute;
background: var(--primary-background);
width: 340px;
z-index: 10;
}
.assign-responders-picker {
padding: 8px 8px;
background: var(--primary-background);
height: 196px;
}
.assign-responders-list {
height: 146px;
overflow: auto;
}
.assign-responders-item {
overflow: scroll;
}
.schedule-table {
height: 120px;
overflow: auto;
}
.responders-filters {
margin: 8px;
}
.responder-item {
cursor: pointer;
}
.body {
width: 100%;
}
.responders-list {
list-style-type: none;
margin-bottom: 20px;
width: 100%;
& > li .trash-button {
display: none;
}
& > li:hover .trash-button {
display: block;
}
& > li {
padding: 10px 12px;
width: 100%;
}
& > li:hover {
background: var(--background-secondary);
}
}
.timeline-icon-background {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--timeline-icon-background);
display: flex;
justify-content: center;
align-items: center;
& > img {
width: 100%;
height: 100%;
}
&--green {
background: #299c46;
}
}

View file

@ -0,0 +1,235 @@
import React, { useState, useCallback } from 'react';
import { SelectableValue } from '@grafana/data';
import { ToolbarButton, ButtonGroup, HorizontalGroup, Icon, Select, IconButton, Label } from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import { observer } from 'mobx-react';
import Avatar from 'components/Avatar/Avatar';
import Text from 'components/Text/Text';
import UserWarning from 'containers/UserWarningModal/UserWarning';
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
import { getTzOffsetString } from 'models/timezone/timezone.helpers';
import { User } from 'models/user/user.types';
import { UserActions } from 'utils/authorization';
import { deduplicate } from './EscalationVariants.helpers';
import styles from './EscalationVariants.module.scss';
import { ResponderType, UserAvailability } from './EscalationVariants.types';
import EscalationVariantsPopup from './parts/EscalationVariantsPopup';
const cx = cn.bind(styles);
export interface EscalationVariantsProps {
onUpdateEscalationVariants: (data: any) => void;
value: { scheduleResponders; userResponders };
variant?: 'default' | 'primary';
hideSelected?: boolean;
}
const EscalationVariants = observer(
({
onUpdateEscalationVariants: propsOnUpdateEscalationVariants,
value,
variant = 'primary',
hideSelected = false,
}: EscalationVariantsProps) => {
const [showEscalationVariants, setShowEscalationVariants] = useState(false);
const [showUserWarningModal, setShowUserWarningModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | undefined>(undefined);
const [userAvailability, setUserAvailability] = useState<UserAvailability | undefined>(undefined);
const onUpdateEscalationVariants = useCallback((newValue) => {
const deduplicatedValue = deduplicate(newValue);
propsOnUpdateEscalationVariants(deduplicatedValue);
}, []);
const getUserResponderImportChangeHandler = (index) => {
return ({ value: important }: SelectableValue<number>) => {
const userResponders = [...value.userResponders];
const userResponder = userResponders[index];
userResponder.important = Boolean(important);
onUpdateEscalationVariants({
...value,
userResponders,
});
};
};
const getUserResponderDeleteHandler = (index) => {
return () => {
const userResponders = [...value.userResponders];
userResponders.splice(index, 1);
onUpdateEscalationVariants({
...value,
userResponders,
});
};
};
const getScheduleResponderImportChangeHandler = (index) => {
return ({ value: important }: SelectableValue<number>) => {
const scheduleResponders = [...value.scheduleResponders];
const scheduleResponder = scheduleResponders[index];
scheduleResponder.important = Boolean(important);
onUpdateEscalationVariants({
...value,
scheduleResponders,
});
};
};
const getScheduleResponderDeleteHandler = (index) => {
return () => {
const scheduleResponders = [...value.scheduleResponders];
scheduleResponders.splice(index, 1);
onUpdateEscalationVariants({
...value,
scheduleResponders,
});
};
};
return (
<>
<div className={cx('body')}>
{!hideSelected && Boolean(value.userResponders.length || value.scheduleResponders.length) && (
<>
<Label>Responders:</Label>
<ul className={cx('responders-list')}>
{value.userResponders.map((responder, index) => (
<UserResponder
key={responder.data?.pk}
onImportantChange={getUserResponderImportChangeHandler(index)}
handleDelete={getUserResponderDeleteHandler(index)}
{...responder}
/>
))}
{value.scheduleResponders.map((responder, index) => (
<ScheduleResponder
onImportantChange={getScheduleResponderImportChangeHandler(index)}
handleDelete={getScheduleResponderDeleteHandler(index)}
key={responder.data.id}
{...responder}
/>
))}
</ul>
</>
)}
<div className={cx('assign-responders-button')}>
<ButtonGroup>
<WithPermissionControl userAction={UserActions.AlertGroupsWrite}>
<ToolbarButton
icon="users-alt"
variant={variant}
onClick={() => {
setShowEscalationVariants(true);
}}
>
Add responders
</ToolbarButton>
</WithPermissionControl>
<WithPermissionControl userAction={UserActions.AlertGroupsWrite}>
<ToolbarButton
isOpen={false}
narrow
variant={variant}
onClick={() => {
setShowEscalationVariants(true);
}}
/>
</WithPermissionControl>
</ButtonGroup>
</div>
{showEscalationVariants && (
<EscalationVariantsPopup
value={value}
onUpdateEscalationVariants={onUpdateEscalationVariants}
setShowEscalationVariants={setShowEscalationVariants}
setSelectedUser={setSelectedUser}
setShowUserWarningModal={setShowUserWarningModal}
setUserAvailability={setUserAvailability}
/>
)}
</div>
{showUserWarningModal && (
<UserWarning
user={selectedUser}
userAvailability={userAvailability}
onHide={() => {
setShowUserWarningModal(false);
setSelectedUser(null);
}}
onUserSelect={(user: User) => {
onUpdateEscalationVariants({
...value,
userResponders: [...value.userResponders, { type: ResponderType.User, data: user, important: false }],
});
}}
/>
)}
</>
);
}
);
const UserResponder = ({ important, data, onImportantChange, handleDelete }) => {
return (
<li>
<HorizontalGroup justify="space-between">
<HorizontalGroup>
<div className={cx('timeline-icon-background', { 'timeline-icon-background--green': true })}>
<Avatar size="big" src={data?.avatar} />
</div>
<Text>
{data?.username} ({getTzOffsetString(dayjs().tz(data?.timezone))})
</Text>
<Select
isSearchable={false}
value={Number(important)}
options={[
{ value: 1, label: 'Important' },
{ value: 0, label: 'Default' },
]}
onChange={onImportantChange}
/>
</HorizontalGroup>
<IconButton className={cx('trash-button')} name="trash-alt" onClick={handleDelete} />
</HorizontalGroup>
</li>
);
};
const ScheduleResponder = ({ important, data, onImportantChange, handleDelete }) => {
return (
<li>
<HorizontalGroup justify="space-between">
<HorizontalGroup>
<div className={cx('timeline-icon-background')}>
<Icon size="lg" name="calendar-alt" />
</div>
<Text>{data.name}</Text>
<Select
isSearchable={false}
value={Number(important)}
options={[
{ value: 1, label: 'Important' },
{ value: 0, label: 'Default' },
]}
onChange={onImportantChange}
/>
</HorizontalGroup>
<IconButton className={cx('trash-button')} name="trash-alt" onClick={handleDelete} />
</HorizontalGroup>
</li>
);
};
export default EscalationVariants;

View file

@ -0,0 +1,15 @@
export enum EscalationVariantsTab {
Schedules,
Escalations,
Users,
}
export interface UserAvailability {
warnings: Array<{ error: string; data: any }>;
}
export enum ResponderType {
User,
Schedule,
// EscalationChain, // for future
}

View file

@ -0,0 +1,189 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { Icon, RadioButtonGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import GTable from 'components/GTable/GTable';
import SearchInput from 'components/SearchInput/SearchInput';
import Text from 'components/Text/Text';
import { EscalationVariantsProps } from 'containers/EscalationVariants/EscalationVariants';
import styles from 'containers/EscalationVariants/EscalationVariants.module.scss';
import { ResponderType, UserAvailability } from 'containers/EscalationVariants/EscalationVariants.types';
import { Schedule } from 'models/schedule/schedule.types';
import { User } from 'models/user/user.types';
import { useStore } from 'state/useStore';
import { useDebouncedCallback, useOnClickOutside } from 'utils/hooks';
interface EscalationVariantsPopupProps extends EscalationVariantsProps {
setShowEscalationVariants: (value: boolean) => void;
setShowUserWarningModal: (value: boolean) => void;
setSelectedUser: (user: User) => void;
setUserAvailability: (data: UserAvailability) => void;
}
const cx = cn.bind(styles);
const EscalationVariantsPopup = observer((props: EscalationVariantsPopupProps) => {
const {
onUpdateEscalationVariants,
setShowEscalationVariants,
value,
setSelectedUser,
setShowUserWarningModal,
setUserAvailability,
} = props;
const store = useStore();
const [activeOption, setActiveOption] = useState('schedules');
const [usersSearchTerm, setUsersSearchTerm] = useState('');
const [schedulesSearchTerm, setSchedulesSearchTerm] = useState('');
const handleOptionChange = useCallback((option: string) => {
setActiveOption(option);
}, []);
const addUserResponders = (user: User) => {
store.userStore.checkUserAvailability(user.pk).then((res) => {
setSelectedUser(user);
setUserAvailability(res);
setShowUserWarningModal(true);
});
setShowEscalationVariants(false);
};
const addSchedulesResponders = (schedule: Schedule) => {
setShowEscalationVariants(false);
onUpdateEscalationVariants({
...value,
scheduleResponders: [
...value.scheduleResponders,
{ type: ResponderType.Schedule, data: schedule, important: false },
],
});
};
const handleUsersSearchTermChange = useDebouncedCallback(() => {
store.userStore.updateItems(usersSearchTerm);
}, 500);
useEffect(handleUsersSearchTermChange, [usersSearchTerm]);
const handleSchedulesSearchTermChange = useDebouncedCallback(() => {
store.scheduleStore.updateItems(schedulesSearchTerm);
}, 500);
useEffect(handleSchedulesSearchTermChange, [schedulesSearchTerm]);
const scheduleColumns = [
{
width: 300,
render: (schedule: Schedule) => {
const disabled = value.scheduleResponders.some(
(scheduleResponder) => scheduleResponder.data.id === schedule.id
);
return (
<div
onClick={() => (disabled ? undefined : addSchedulesResponders(schedule))}
className={cx('responder-item')}
>
<Text type={disabled ? 'disabled' : undefined}>{schedule.name}</Text>
</div>
);
},
key: 'Title',
},
{
width: 40,
render: (item: Schedule) =>
value.scheduleResponders.some((scheduleResponder) => scheduleResponder.data.id === item.id) ? (
<Icon name="check" />
) : null,
key: 'Checked',
},
];
const userColumns = [
{
width: 300,
render: (user: User) => {
const disabled = value.userResponders.some((userResponder) => userResponder.data?.pk === user.pk);
return (
<div onClick={() => (disabled ? undefined : addUserResponders(user))} className={cx('responder-item')}>
<Text type={disabled ? 'disabled' : undefined}>
{user.username} ({user.timezone})
</Text>
</div>
);
},
key: 'username',
},
{
width: 40,
render: (item: User) =>
value.userResponders.some((userResponder) => userResponder.data?.pk === item.pk) ? <Icon name="check" /> : null,
key: 'Checked',
},
];
const ref = useRef();
useOnClickOutside(ref, () => {
setShowEscalationVariants(false);
});
return (
<div ref={ref} className={cx('escalation-variants-dropdown')}>
<RadioButtonGroup
options={[
{ value: 'schedules', label: 'Schedules' },
{ value: 'users', label: 'Users' },
]}
value={activeOption}
onChange={handleOptionChange}
fullWidth
/>
{activeOption === 'schedules' && (
<>
<SearchInput
key="schedules search"
className={cx('responders-filters')}
value={schedulesSearchTerm}
onChange={setSchedulesSearchTerm}
/>
<GTable
emptyText={store.scheduleStore.getSearchResult() ? 'No schedules found' : 'Loading...'}
rowKey="id"
columns={scheduleColumns}
data={store.scheduleStore.getSearchResult()}
className={cx('schedule-table')}
showHeader={false}
/>
</>
)}
{activeOption === 'users' && (
<>
<SearchInput
key="users search"
className={cx('responders-filters')}
value={usersSearchTerm}
onChange={setUsersSearchTerm}
/>
<GTable
emptyText={store.userStore.getSearchResult().results ? 'No users found' : 'Loading...'}
rowKey="id"
columns={userColumns}
data={store.userStore.getSearchResult().results}
className={cx('schedule-table')}
showHeader={false}
/>
</>
)}
</div>
);
});
export default EscalationVariantsPopup;

View file

@ -35,6 +35,7 @@ interface GSelectProps {
dropdownRender?: (menu: ReactElement) => ReactElement;
getOptionLabel?: <T>(item: SelectableValue<T>) => React.ReactNode;
getDescription?: (item: any) => React.ReactNode;
openMenuOnFocus?: boolean;
}
const GSelect = observer((props: GSelectProps) => {

View file

@ -63,7 +63,7 @@ const IncidentMatcher = observer((props: IncidentMatcherProps) => {
<div className={cx('columns')}>
<div className={cx('incident-list')}>
<Text.Title className={cx('title')} level={5}>
Matching Incidents
Matching Alert Groups
</Text.Title>
{isLoading ? (
<LoadingPlaceholder text="Loading..." />

View file

@ -70,7 +70,7 @@ class IncidentsFilters extends Component<IncidentsFiltersProps, IncidentsFilters
newQuery = { ...store.incidentFilters };
} else {
newQuery = {
status: [IncidentStatus.New, IncidentStatus.Acknowledged],
status: [IncidentStatus.Firing, IncidentStatus.Acknowledged],
mine: false,
};
}
@ -149,8 +149,8 @@ class IncidentsFilters extends Component<IncidentsFiltersProps, IncidentsFilters
icon={<Icon name="bell" size="xxxl" />}
description="New alert groups"
title={newIncidentsCount}
selected={status.includes(IncidentStatus.New)}
onClick={this.getStatusButtonClickHandler(IncidentStatus.New)}
selected={status.includes(IncidentStatus.Firing)}
onClick={this.getStatusButtonClickHandler(IncidentStatus.Firing)}
/>
</div>
<div key="acknowledged" className={cx('col')}>

View file

@ -37,6 +37,31 @@ exports[`MobileAppConnection if we disconnect the app, it disconnects and fetche
class="css-1j7sh2x-vertical-group"
style="width: 100%; height: 100%;"
>
<div
class="css-bxa289-layoutChildrenWrapper"
>
<a
href="https://apps.apple.com/us/app/grafana-oncall-preview/id1669759048"
rel="noreferrer"
style="width: 100%;"
target="_blank"
>
<div
class="root icon-block root_bordered root--fullWidth root--withBackground root--hover"
>
<img
alt="Apple"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
iOS
</span>
</div>
</a>
</div>
<div
class="css-bxa289-layoutChildrenWrapper"
>
@ -62,30 +87,6 @@ exports[`MobileAppConnection if we disconnect the app, it disconnects and fetche
</div>
</a>
</div>
<div
class="css-bxa289-layoutChildrenWrapper"
>
<div
class="root icon-block root_bordered root--fullWidth root--withBackground root--hover"
>
<img
alt="Apple"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
iOS
</span>
<span
class="root icon-tag"
style="background-color: rgb(41, 156, 70);"
>
Coming Soon
</span>
</div>
</div>
</div>
</div>
</div>
@ -2381,6 +2382,31 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect
class="css-1j7sh2x-vertical-group"
style="width: 100%; height: 100%;"
>
<div
class="css-bxa289-layoutChildrenWrapper"
>
<a
href="https://apps.apple.com/us/app/grafana-oncall-preview/id1669759048"
rel="noreferrer"
style="width: 100%;"
target="_blank"
>
<div
class="root icon-block root_bordered root--fullWidth root--withBackground root--hover"
>
<img
alt="Apple"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
iOS
</span>
</div>
</a>
</div>
<div
class="css-bxa289-layoutChildrenWrapper"
>
@ -2406,30 +2432,6 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect
</div>
</a>
</div>
<div
class="css-bxa289-layoutChildrenWrapper"
>
<div
class="root icon-block root_bordered root--fullWidth root--withBackground root--hover"
>
<img
alt="Apple"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
iOS
</span>
<span
class="root icon-tag"
style="background-color: rgb(41, 156, 70);"
>
Coming Soon
</span>
</div>
</div>
</div>
</div>
</div>
@ -2493,6 +2495,31 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco
class="css-1j7sh2x-vertical-group"
style="width: 100%; height: 100%;"
>
<div
class="css-bxa289-layoutChildrenWrapper"
>
<a
href="https://apps.apple.com/us/app/grafana-oncall-preview/id1669759048"
rel="noreferrer"
style="width: 100%;"
target="_blank"
>
<div
class="root icon-block root_bordered root--fullWidth root--withBackground root--hover"
>
<img
alt="Apple"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
iOS
</span>
</div>
</a>
</div>
<div
class="css-bxa289-layoutChildrenWrapper"
>
@ -2518,30 +2545,6 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco
</div>
</a>
</div>
<div
class="css-bxa289-layoutChildrenWrapper"
>
<div
class="root icon-block root_bordered root--fullWidth root--withBackground root--hover"
>
<img
alt="Apple"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
iOS
</span>
<span
class="root icon-tag"
style="background-color: rgb(41, 156, 70);"
>
Coming Soon
</span>
</div>
</div>
</div>
</div>
</div>
@ -2605,6 +2608,31 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch
class="css-1j7sh2x-vertical-group"
style="width: 100%; height: 100%;"
>
<div
class="css-bxa289-layoutChildrenWrapper"
>
<a
href="https://apps.apple.com/us/app/grafana-oncall-preview/id1669759048"
rel="noreferrer"
style="width: 100%;"
target="_blank"
>
<div
class="root icon-block root_bordered root--fullWidth root--withBackground root--hover"
>
<img
alt="Apple"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
iOS
</span>
</div>
</a>
</div>
<div
class="css-bxa289-layoutChildrenWrapper"
>
@ -2630,30 +2658,6 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch
</div>
</a>
</div>
<div
class="css-bxa289-layoutChildrenWrapper"
>
<div
class="root icon-block root_bordered root--fullWidth root--withBackground root--hover"
>
<img
alt="Apple"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
iOS
</span>
<span
class="root icon-tag"
style="background-color: rgb(41, 156, 70);"
>
Coming Soon
</span>
</div>
</div>
</div>
</div>
</div>
@ -2717,6 +2721,31 @@ exports[`MobileAppConnection it shows a message when the mobile app is already c
class="css-1j7sh2x-vertical-group"
style="width: 100%; height: 100%;"
>
<div
class="css-bxa289-layoutChildrenWrapper"
>
<a
href="https://apps.apple.com/us/app/grafana-oncall-preview/id1669759048"
rel="noreferrer"
style="width: 100%;"
target="_blank"
>
<div
class="root icon-block root_bordered root--fullWidth root--withBackground root--hover"
>
<img
alt="Apple"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
iOS
</span>
</div>
</a>
</div>
<div
class="css-bxa289-layoutChildrenWrapper"
>
@ -2742,30 +2771,6 @@ exports[`MobileAppConnection it shows a message when the mobile app is already c
</div>
</a>
</div>
<div
class="css-bxa289-layoutChildrenWrapper"
>
<div
class="root icon-block root_bordered root--fullWidth root--withBackground root--hover"
>
<img
alt="Apple"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
iOS
</span>
<span
class="root icon-tag"
style="background-color: rgb(41, 156, 70);"
>
Coming Soon
</span>
</div>
</div>
</div>
</div>
</div>
@ -2929,6 +2934,31 @@ exports[`MobileAppConnection it shows an error message if there was an error dis
class="css-1j7sh2x-vertical-group"
style="width: 100%; height: 100%;"
>
<div
class="css-bxa289-layoutChildrenWrapper"
>
<a
href="https://apps.apple.com/us/app/grafana-oncall-preview/id1669759048"
rel="noreferrer"
style="width: 100%;"
target="_blank"
>
<div
class="root icon-block root_bordered root--fullWidth root--withBackground root--hover"
>
<img
alt="Apple"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
iOS
</span>
</div>
</a>
</div>
<div
class="css-bxa289-layoutChildrenWrapper"
>
@ -2954,30 +2984,6 @@ exports[`MobileAppConnection it shows an error message if there was an error dis
</div>
</a>
</div>
<div
class="css-bxa289-layoutChildrenWrapper"
>
<div
class="root icon-block root_bordered root--fullWidth root--withBackground root--hover"
>
<img
alt="Apple"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
iOS
</span>
<span
class="root icon-tag"
style="background-color: rgb(41, 156, 70);"
>
Coming Soon
</span>
</div>
</div>
</div>
</div>
</div>
@ -3032,6 +3038,31 @@ exports[`MobileAppConnection it shows an error message if there was an error fet
class="css-1j7sh2x-vertical-group"
style="width: 100%; height: 100%;"
>
<div
class="css-bxa289-layoutChildrenWrapper"
>
<a
href="https://apps.apple.com/us/app/grafana-oncall-preview/id1669759048"
rel="noreferrer"
style="width: 100%;"
target="_blank"
>
<div
class="root icon-block root_bordered root--fullWidth root--withBackground root--hover"
>
<img
alt="Apple"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
iOS
</span>
</div>
</a>
</div>
<div
class="css-bxa289-layoutChildrenWrapper"
>
@ -3057,30 +3088,6 @@ exports[`MobileAppConnection it shows an error message if there was an error fet
</div>
</a>
</div>
<div
class="css-bxa289-layoutChildrenWrapper"
>
<div
class="root icon-block root_bordered root--fullWidth root--withBackground root--hover"
>
<img
alt="Apple"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
iOS
</span>
<span
class="root icon-tag"
style="background-color: rgb(41, 156, 70);"
>
Coming Soon
</span>
</div>
</div>
</div>
</div>
</div>

View file

@ -31,6 +31,31 @@ exports[`DownloadIcons it renders properly 1`] = `
class="css-1j7sh2x-vertical-group"
style="width: 100%; height: 100%;"
>
<div
class="css-bxa289-layoutChildrenWrapper"
>
<a
href="https://apps.apple.com/us/app/grafana-oncall-preview/id1669759048"
rel="noreferrer"
style="width: 100%;"
target="_blank"
>
<div
class="root icon-block root_bordered root--fullWidth root--withBackground root--hover"
>
<img
alt="Apple"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
iOS
</span>
</div>
</a>
</div>
<div
class="css-bxa289-layoutChildrenWrapper"
>
@ -56,30 +81,6 @@ exports[`DownloadIcons it renders properly 1`] = `
</div>
</a>
</div>
<div
class="css-bxa289-layoutChildrenWrapper"
>
<div
class="root icon-block root_bordered root--fullWidth root--withBackground root--hover"
>
<img
alt="Apple"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
iOS
</span>
<span
class="root icon-tag"
style="background-color: rgb(41, 156, 70);"
>
Coming Soon
</span>
</div>
</div>
</div>
</div>
</div>

View file

@ -6,9 +6,7 @@ import cn from 'classnames/bind';
import AppleLogoSVG from 'assets/img/brand/apple-logo.svg';
import PlayStoreLogoSVG from 'assets/img/brand/play-store-logo.svg';
import Block from 'components/GBlock/Block';
import Tag from 'components/Tag/Tag';
import Text from 'components/Text/Text';
import { COLOR_PRIMARY } from 'utils/consts';
import styles from './DownloadIcons.module.scss';
@ -21,6 +19,19 @@ const DownloadIcons: FC = () => (
</Text>
<Text type="primary">The Grafana IRM app is available on both the App Store and Google Play Store.</Text>
<VerticalGroup>
<a
style={{ width: '100%' }}
href="https://apps.apple.com/us/app/grafana-oncall-preview/id1669759048"
target="_blank"
rel="noreferrer"
>
<Block hover fullWidth withBackground bordered className={cx('icon-block')}>
<img src={AppleLogoSVG} alt="Apple" className={cx('icon')} />
<Text type="primary" className={cx('icon-text')}>
iOS
</Text>
</Block>
</a>
<a
style={{ width: '100%' }}
href="https://play.google.com/store/apps/details?id=com.grafana.oncall.prod"
@ -34,15 +45,6 @@ const DownloadIcons: FC = () => (
</Text>
</Block>
</a>
<Block hover fullWidth withBackground bordered className={cx('icon-block')}>
<img src={AppleLogoSVG} alt="Apple" className={cx('icon')} />
<Text type="primary" className={cx('icon-text')}>
iOS
</Text>
<Tag color={COLOR_PRIMARY} className={cx('icon-tag')}>
Coming Soon
</Tag>
</Block>
</VerticalGroup>
</VerticalGroup>
);

View file

@ -50,7 +50,7 @@ const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
shiftId,
startMoment,
shiftMoment = dayjs().startOf('day').add(1, 'day'),
shiftColor = '#C69B06',
shiftColor = getComputedStyle(document.documentElement).getPropertyValue('--tag-warning'),
} = props;
const store = useStore();

View file

@ -44,11 +44,15 @@ const SlackConnector = (props: SlackConnectorProps) => {
{storeUser.slack_user_identity ? (
<div>
<Text type="secondary"> Slack account is connected</Text>
<WithConfirm title="Are you sure to disconnect Slack account?" confirmText="Disconnect">
<Button size="sm" fill="text" variant="destructive" onClick={handleUnlinkSlackAccount}>
Unlink Slack account
</Button>
</WithConfirm>
{storeUser.pk === userStore.currentUserPk ? (
<WithConfirm title="Are you sure to disconnect your Slack account?" confirmText="Disconnect">
<Button size="sm" fill="text" variant="destructive" onClick={handleUnlinkSlackAccount}>
Unlink Slack account
</Button>
</WithConfirm>
) : (
''
)}
</div>
) : teamStore.currentTeam?.slack_team_identity ? (
<div>

View file

@ -4,6 +4,7 @@ import { Button, Label } from '@grafana/ui';
import cn from 'classnames/bind';
import Text from 'components/Text/Text';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types';
import { User } from 'models/user/user.types';
import { useStore } from 'state/useStore';
@ -39,12 +40,18 @@ const TelegramConnector = (props: TelegramConnectorProps) => {
<div className={cx('user-item')}>
<Label>Telegram username:</Label>
<span className={cx('user-value')}>{storeUser.telegram_configuration?.telegram_nick_name || '—'}</span>
{storeUser.telegram_configuration ? (
{storeUser.telegram_configuration && storeUser.pk === userStore.currentUserPk ? (
<div>
<Text type="secondary"> Telegram account is connected</Text>
<Button size="sm" fill="text" variant="destructive" onClick={handleUnlinkTelegramAccount}>
Unlink Telegram account
</Button>
{storeUser.pk === userStore.currentUserPk ? (
<WithConfirm title="Are you sure to disconnect your Telegram account?" confirmText="Disconnect">
<Button size="sm" fill="text" variant="destructive" onClick={handleUnlinkTelegramAccount}>
Unlink Telegram account
</Button>
</WithConfirm>
) : (
''
)}
</div>
) : (
<div>

View file

@ -0,0 +1,22 @@
.user-warning {
margin: 16px;
}
.users {
list-style-type: none;
margin-left: 23px;
width: 100%;
& > li {
width: 100%;
background: var(--background-secondary);
margin-bottom: 4px;
padding: 14px 12px;
}
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #6ccf8e;
}

View file

@ -0,0 +1,179 @@
import React, { FC, useCallback, useEffect, useMemo } from 'react';
import { Button, HorizontalGroup, Icon, Modal, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import Text from 'components/Text/Text';
import { UserAvailability } from 'containers/EscalationVariants/EscalationVariants.types';
import { getTzOffsetString } from 'models/timezone/timezone.helpers';
import { User } from 'models/user/user.types';
import { useStore } from 'state/useStore';
import styles from './UserWarning.module.scss';
interface UserWarningProps {
onHide: () => void;
user: User;
userAvailability: UserAvailability;
onUserSelect: (user: User) => void;
}
const cx = cn.bind(styles);
const UserWarning: FC<UserWarningProps> = (props) => {
const { onHide, user, userAvailability, onUserSelect } = props;
const store = useStore();
const { userStore } = store;
const getUserSelectHandler = useCallback(
(userId: User['pk']) => {
return async () => {
onHide();
if (!userStore.items[userId]) {
await userStore.updateItem(userId);
}
const user = userStore.items[userId];
onUserSelect(user);
};
},
[userStore.items]
);
const showUserHasNoNotificationPolicyWarning = useMemo(
() => userAvailability.warnings.some((warning) => warning.error === 'USER_HAS_NO_NOTIFICATION_POLICY'),
[userAvailability]
);
const showUserIsNotOncallWarning = useMemo(
() => userAvailability.warnings.some((warning) => warning.error === 'USER_IS_NOT_ON_CALL'),
[userAvailability]
);
const userSchedules = useMemo(
() =>
userAvailability.warnings.reduce((memo, warning) => {
if (warning.error === 'USER_IS_NOT_ON_CALL') {
const schedules = warning.data.schedules;
const userSchedulesKeys = Object.keys(schedules).filter((key: string) => schedules[key].includes(user.pk));
memo.push(...userSchedulesKeys);
}
return memo;
}, []),
[userAvailability]
);
const recommendedUsers = useMemo(
() =>
userAvailability.warnings.reduce((memo, warning) => {
if (warning.error === 'USER_IS_NOT_ON_CALL') {
const users = Object.keys(warning.data.schedules).reduce((memo, key) => {
const users = warning.data.schedules[key];
memo.push(...users);
return memo;
}, []);
memo.push(...users);
}
return memo;
}, []),
[userAvailability]
);
return (
<Modal isOpen title="Add responder" onDismiss={onHide}>
<VerticalGroup className={cx('user-warning')}>
{showUserHasNoNotificationPolicyWarning && (
<HorizontalGroup>
<Icon name="exclamation-triangle" style={{ color: 'orange' }} />
<Text>
<Text strong>{user.username}</Text> has no notification policy
</Text>
</HorizontalGroup>
)}
{showUserIsNotOncallWarning && (
<HorizontalGroup>
<Icon name="exclamation-triangle" style={{ color: 'orange' }} />
<Text>
<Text strong>
{user.username} (Local time {dayjs().tz(user.timezone).format('HH:mm:ss')})
</Text>{' '}
is not currently on-call.
</Text>
</HorizontalGroup>
)}
{userSchedules.length && (
<HorizontalGroup>
<Icon name="calendar-alt" />
<Text>
<Text strong>{user.username}</Text> appears in <Text strong>{userSchedules.join(', ')} </Text>
</Text>
</HorizontalGroup>
)}
{recommendedUsers.length && (
<HorizontalGroup>
<Icon name="info-circle" />
<Text>Recommended on-call users:</Text>
</HorizontalGroup>
)}
{recommendedUsers.length && (
<ul className={cx('users')}>
{recommendedUsers.map((userPk) => (
<RecommendedUser key={userPk} pk={userPk} onSelect={getUserSelectHandler(userPk)} />
))}
</ul>
)}
<Text>
Are you sure you want to select <Text strong>{user.username}</Text>?
</Text>
<HorizontalGroup justify="flex-end">
<Button variant="secondary" onClick={onHide}>
Cancel
</Button>
<Button variant="primary" onClick={getUserSelectHandler(user.pk)}>
Confirm
</Button>
</HorizontalGroup>
</VerticalGroup>
</Modal>
);
};
const RecommendedUser = ({ pk, onSelect }: { pk: User['pk']; onSelect: () => void }) => {
const store = useStore();
const { userStore } = store;
useEffect(() => {
if (!userStore.items[pk]) {
userStore.updateItem(pk);
}
}, [pk]);
const user = userStore.items[pk];
return (
<li>
<HorizontalGroup justify="space-between">
<HorizontalGroup spacing="sm">
<div className={cx('dot')} />
<Text strong>{user?.username}</Text>
<Text>
({getTzOffsetString(dayjs().tz(user?.timezone))}, {user?.timezone})
</Text>
<Icon name="calendar-alt" />
</HorizontalGroup>
<Button size="sm" onClick={onSelect}>
Select
</Button>
</HorizontalGroup>
</li>
);
};
export default UserWarning;

View file

@ -279,7 +279,15 @@ const AvatarGroup = (props: AvatarGroupProps) => {
placement="top"
interactive
key={index}
content={<ScheduleUserDetails currentMoment={currentMoment} user={user} isOncall={isOncall} />}
content={
<ScheduleUserDetails
currentMoment={currentMoment}
user={user}
isOncall={isOncall}
scheduleId={scheduleId}
startMoment={startMoment}
/>
}
>
<div
className={cx('avatar')}

View file

@ -8,6 +8,10 @@
display: none; /* hide header that comes from Grafana (topnavbar) and instead use ours */
}
[class$='-page-container'] {
min-width: 0; /* top navbar container overflows for a few screens */
}
.page-container {
max-width: unset !important;
}

View file

@ -2,6 +2,7 @@ import { action, observable } from 'mobx';
import qs from 'query-string';
import BaseStore from 'models/base_store';
import { User } from 'models/user/user.types';
import { makeRequest } from 'network';
import { Mixpanel } from 'services/mixpanel';
import { RootStore } from 'state';
@ -314,7 +315,7 @@ export class AlertGroupStore extends BaseStore {
const result = await makeRequest(`${this.path}stats/`, {
params: {
...this.incidentFilters,
status: [IncidentStatus.New],
status: [IncidentStatus.Firing],
},
});
this.newIncidents = result;
@ -365,9 +366,6 @@ export class AlertGroupStore extends BaseStore {
async doIncidentAction(alertId: Alert['pk'], action: AlertAction, isUndo = false, data?: any) {
this.updateAlert(alertId, { loading: true });
console.log('action', action);
console.log('isUndo', isUndo);
let undoAction = undefined;
if (!isUndo) {
switch (action) {
@ -411,8 +409,6 @@ export class AlertGroupStore extends BaseStore {
loading: false,
undoAction,
});
console.log('undoAction', undoAction);
} catch (e) {
this.updateAlert(alertId, { loading: false });
openErrorNotification(e.response.data?.detail || e.response.data);
@ -431,4 +427,11 @@ export class AlertGroupStore extends BaseStore {
toggleLiveUpdate(value: boolean) {
this.liveUpdatesEnabled = value;
}
async unpageUser(alertId: Alert['pk'], userId: User['pk']) {
return await makeRequest(`${this.path}${alertId}/unpage_user`, {
method: 'POST',
data: { user_id: userId },
}).catch(this.onApiError);
}
}

View file

@ -3,7 +3,7 @@ import { Channel } from 'models/channel';
import { User } from 'models/user/user.types';
export enum IncidentStatus {
'New',
'Firing',
'Acknowledged',
'Resolved',
'Silenced',
@ -74,6 +74,7 @@ export interface Alert {
short?: boolean;
root_alert_group?: Alert;
alert_receive_channel: Partial<AlertReceiveChannel>;
paged_users: Array<Pick<User, 'pk' | 'username' | 'avatar'>>;
// set by client
loading?: boolean;

View file

@ -61,9 +61,7 @@ export class CloudStore extends BaseStore {
}
async getCloudHeartbeat() {
return await makeRequest(`/cloud_heartbeat/`, { method: 'POST' }).catch((error) => {
console.log(error);
});
return await makeRequest(`/cloud_heartbeat/`, { method: 'POST' });
}
async getCloudUser(id: string) {

View file

@ -0,0 +1,31 @@
import { Alert } from 'models/alertgroup/alertgroup.types';
import BaseStore from 'models/base_store';
import { makeRequest } from 'network';
import { RootStore } from 'state';
import { ManualAlertGroupPayload } from './direct_paging.types';
export class DirectPagingStore extends BaseStore {
constructor(rootStore: RootStore) {
super(rootStore);
this.path = '/direct_paging/';
}
async createManualAlertRule(data: ManualAlertGroupPayload) {
return await makeRequest(`${this.path}`, {
method: 'POST',
data,
}).catch(this.onApiError);
}
async updateAlertGroup(alertId: Alert['pk'], data: ManualAlertGroupPayload) {
return await makeRequest(`${this.path}`, {
method: 'POST',
data: {
alert_group_id: alertId,
...data,
},
}).catch(this.onApiError);
}
}

View file

@ -0,0 +1,7 @@
import { Schedule } from 'models/schedule/schedule.types';
import { User } from 'models/user/user.types';
export interface ManualAlertGroupPayload {
users: Array<{ id: User['pk']; important: boolean }>;
schedules: Array<{ id: Schedule['id']; important: boolean }>;
}

View file

@ -377,4 +377,10 @@ export class UserStore extends BaseStore {
method: 'DELETE',
});
}
async checkUserAvailability(userPk: User['pk']) {
return await makeRequest(`/users/${userPk}/check_availability/`, {
method: 'GET',
});
}
}

View file

@ -9,11 +9,11 @@
.navbar-heading {
padding: 4px;
margin: 0 0 0 8px;
border: 1px solid var(--gray-9);
width: initial;
font-size: 12px;
padding-top: 0;
margin-bottom: 0;
}
.navbar-link {
@ -26,3 +26,12 @@
display: flex;
flex-basis: 100%;
}
.navbar-heading-container {
display: flex;
flex-wrap: wrap;
align-items: center;
flex-direction: row;
column-gap: 8px;
row-gap: 8px;
}

View file

@ -36,7 +36,7 @@ export default function Header({ backendLicense }: { backendLicense: string }) {
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('navbar-heading-container')}>
<div className={cx('page-header__sub-title')}>{APP_SUBTITLE}</div>
<Card heading={undefined} className={cx('navbar-heading')}>
<a

View file

@ -1,8 +1,9 @@
import React from 'react';
import { Button, HorizontalGroup, Icon, Tooltip, VerticalGroup } from '@grafana/ui';
import { Button, HorizontalGroup, IconButton, Tooltip, VerticalGroup } from '@grafana/ui';
import Avatar from 'components/Avatar/Avatar';
import { MatchMediaTooltip } from 'components/MatchMediaTooltip/MatchMediaTooltip';
import PluginLink from 'components/PluginLink/PluginLink';
import Tag from 'components/Tag/Tag';
import Text from 'components/Text/Text';
@ -10,16 +11,16 @@ import { WithPermissionControl } from 'containers/WithPermissionControl/WithPerm
import { MaintenanceIntegration } from 'models/alert_receive_channel';
import { Alert as AlertType, Alert, IncidentStatus } from 'models/alertgroup/alertgroup.types';
import { User } from 'models/user/user.types';
import SilenceDropdown from 'pages/incidents/parts/SilenceDropdown';
import { SilenceButtonCascader } from 'pages/incidents/parts/SilenceButtonCascader';
import { move } from 'state/helpers';
import { UserActions } from 'utils/authorization';
import { COLOR_DANGER, COLOR_PRIMARY, COLOR_SECONDARY, COLOR_WARNING } from 'utils/consts';
import { TABLE_COLUMN_MAX_WIDTH } from 'utils/consts';
export function getIncidentStatusTag(alert: Alert) {
switch (alert.status) {
case IncidentStatus.New:
case IncidentStatus.Firing:
return (
<Tag color={COLOR_DANGER}>
<Tag color={getComputedStyle(document.documentElement).getPropertyValue('--tag-danger')}>
<Text strong size="small">
Firing
</Text>
@ -27,7 +28,7 @@ export function getIncidentStatusTag(alert: Alert) {
);
case IncidentStatus.Acknowledged:
return (
<Tag color={COLOR_WARNING}>
<Tag color={getComputedStyle(document.documentElement).getPropertyValue('--tag-warning')}>
<Text strong size="small">
Acknowledged
</Text>
@ -35,7 +36,7 @@ export function getIncidentStatusTag(alert: Alert) {
);
case IncidentStatus.Resolved:
return (
<Tag color={COLOR_PRIMARY}>
<Tag color={getComputedStyle(document.documentElement).getPropertyValue('--tag-primary')}>
<Text strong size="small">
Resolved
</Text>
@ -43,7 +44,7 @@ export function getIncidentStatusTag(alert: Alert) {
);
case IncidentStatus.Silenced:
return (
<Tag color={COLOR_SECONDARY}>
<Tag color={getComputedStyle(document.documentElement).getPropertyValue('--tag-secondary')}>
<Text strong size="small">
Silenced
</Text>
@ -66,15 +67,19 @@ export function renderRelatedUsers(incident: Alert, isFull = false) {
function renderUser(user: User) {
let badge = undefined;
if (incident.resolved_by_user && user.pk === incident.resolved_by_user.pk) {
badge = <Icon name="check-circle" style={{ color: '#52c41a' }} />;
badge = <IconButton tooltipPlacement="top" tooltip="Resolved" name="check-circle" style={{ color: '#52c41a' }} />;
} else if (incident.acknowledged_by_user && user.pk === incident.acknowledged_by_user.pk) {
badge = <Icon name="eye" style={{ color: '#f2c94c' }} />;
badge = <IconButton tooltipPlacement="top" tooltip="Acknowledged" name="eye" style={{ color: '#f2c94c' }} />;
}
return (
<PluginLink key={user.pk} query={{ page: 'users', id: user.pk }} wrap={false}>
<PluginLink key={user.pk} query={{ page: 'users', id: user.pk }} wrap={false} className="table__email-content">
<Text type="secondary">
<Avatar size="small" src={user.avatar} /> {user.username} {badge}
<Avatar size="small" src={user.avatar} />{' '}
<MatchMediaTooltip placement="top" content={user.username} maxWidth={TABLE_COLUMN_MAX_WIDTH}>
<span>{user.username}</span>
</MatchMediaTooltip>{' '}
{badge}
</Text>
</PluginLink>
);
@ -107,30 +112,32 @@ export function renderRelatedUsers(incident: Alert, isFull = false) {
}
return (
<VerticalGroup spacing="xs">
{visibleUsers.map(renderUser)}
{Boolean(otherUsers.length) && (
<Tooltip
placement="top"
content={
<>
{otherUsers.map((user, index) => (
<>
{index ? ', ' : ''}
{renderUser(user)}
</>
))}
</>
}
>
<span>
<Text type="secondary" underline size="small">
+{otherUsers.length} user{otherUsers.length > 1 ? 's' : ''}
</Text>
</span>
</Tooltip>
)}
</VerticalGroup>
<div className={'table__email-column'}>
<VerticalGroup spacing="xs">
{visibleUsers.map(renderUser)}
{Boolean(otherUsers.length) && (
<Tooltip
placement="top"
content={
<>
{otherUsers.map((user, index) => (
<>
{index ? ', ' : ''}
{renderUser(user)}
</>
))}
</>
}
>
<span>
<Text type="secondary" underline size="small">
+{otherUsers.length} user{otherUsers.length > 1 ? 's' : ''}
</Text>
</span>
</Tooltip>
)}
</VerticalGroup>
</div>
);
}
@ -143,7 +150,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key
const resolveButton = (
<WithPermissionControl key="resolve" userAction={UserActions.AlertGroupsWrite}>
<Button size="sm" disabled={incident.loading} onClick={onResolve} variant="primary">
<Button disabled={incident.loading} onClick={onResolve} variant="primary">
Resolve
</Button>
</WithPermissionControl>
@ -151,7 +158,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key
const unacknowledgeButton = (
<WithPermissionControl key="unacknowledge" userAction={UserActions.AlertGroupsWrite}>
<Button size="sm" disabled={incident.loading} onClick={onUnacknowledge} variant="secondary">
<Button disabled={incident.loading} onClick={onUnacknowledge} variant="secondary">
Unacknowledge
</Button>
</WithPermissionControl>
@ -159,7 +166,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key
const unresolveButton = (
<WithPermissionControl key="unacknowledge" userAction={UserActions.AlertGroupsWrite}>
<Button size="sm" disabled={incident.loading} onClick={onUnresolve} variant="primary">
<Button disabled={incident.loading} onClick={onUnresolve} variant="primary">
Unresolve
</Button>
</WithPermissionControl>
@ -167,7 +174,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key
const acknowledgeButton = (
<WithPermissionControl key="acknowledge" userAction={UserActions.AlertGroupsWrite}>
<Button size="sm" disabled={incident.loading} onClick={onAcknowledge} variant="secondary">
<Button disabled={incident.loading} onClick={onAcknowledge} variant="secondary">
Acknowledge
</Button>
</WithPermissionControl>
@ -176,14 +183,13 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key
const buttons = [];
if (incident.alert_receive_channel.integration !== MaintenanceIntegration) {
if (incident.status === IncidentStatus.New) {
if (incident.status === IncidentStatus.Firing) {
buttons.push(
<SilenceDropdown
<SilenceButtonCascader
className={cx('silence-button-inline')}
key="silence"
disabled={incident.loading}
onSelect={onSilence}
buttonSize="sm"
/>
);
}
@ -191,7 +197,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key
if (incident.status === IncidentStatus.Silenced) {
buttons.push(
<WithPermissionControl key="silence" userAction={UserActions.AlertGroupsWrite}>
<Button size="sm" disabled={incident.loading} variant="secondary" onClick={onUnsilence}>
<Button disabled={incident.loading} variant="secondary" onClick={onUnsilence}>
Unsilence
</Button>
</WithPermissionControl>

View file

@ -83,7 +83,7 @@
.timeline {
list-style-type: none;
margin: 0 0 24px;
margin: 0 0 24px 12px;
}
.timeline-item {
@ -119,4 +119,32 @@
.title-icon {
color: var(--secondary-text-color);
margin-left: 4px;
}
.paged-users {
width: 100%;
}
.paged-users-list {
list-style-type: none;
margin-bottom: 20px;
width: 100%;
& > li .trash-button {
display: none;
}
& > li:hover .trash-button {
display: block;
}
& > li {
padding: 8px 12px;
width: 100%;
}
& > li:hover {
background: var(--background-secondary);
}
}

View file

@ -35,6 +35,8 @@ import PluginLink from 'components/PluginLink/PluginLink';
import SourceCode from 'components/SourceCode/SourceCode';
import Text from 'components/Text/Text';
import AttachIncidentForm from 'containers/AttachIncidentForm/AttachIncidentForm';
import EscalationVariants from 'containers/EscalationVariants/EscalationVariants';
import { prepareForEdit, prepareForUpdate } from 'containers/EscalationVariants/EscalationVariants.helpers';
import IntegrationSettings from 'containers/IntegrationSettings/IntegrationSettings';
import { IntegrationSettingsTab } from 'containers/IntegrationSettings/IntegrationSettings.types';
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
@ -47,6 +49,7 @@ import {
GroupedAlert,
} from 'models/alertgroup/alertgroup.types';
import { ResolutionNoteSourceTypesToDisplayName } from 'models/resolution_note/resolution_note.types';
import { User } from 'models/user/user.types';
import { PageProps, WithStoreProps } from 'state/types';
import { useStore } from 'state/useStore';
import { withMobXProviderContext } from 'state/withStore';
@ -56,8 +59,8 @@ import { PLUGIN_ROOT } from 'utils/consts';
import sanitize from 'utils/sanitize';
import { getActionButtons, getIncidentStatusTag, renderRelatedUsers } from './Incident.helpers';
import styles from './Incident.module.css';
import styles from './Incident.module.scss';
import PagedUsers from './parts/PagedUsers';
const cx = cn.bind(styles);
@ -126,7 +129,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
if (!incident && !isNotFoundError && !isWrongTeamError) {
return (
<div className={cx('root')}>
<LoadingPlaceholder text="Loading alert group..." />
<LoadingPlaceholder text="Loading Alert Group..." />
</div>
);
}
@ -139,10 +142,10 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
<div className={cx('not-found')}>
<VerticalGroup spacing="lg" align="center">
<Text.Title level={1}>404</Text.Title>
<Text.Title level={4}>Incident not found</Text.Title>
<Text.Title level={4}>Alert group not found</Text.Title>
<PluginLink query={{ page: 'incidents', cursor, start, perpage }}>
<Button variant="secondary" icon="arrow-left" size="md">
Go to incidents page
Go to Alert Groups page
</Button>
</PluginLink>
</VerticalGroup>
@ -159,7 +162,12 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
/>
<AttachedIncidentsList id={incident.pk} getUnattachClickHandler={this.getUnattachClickHandler} />
</div>
<div className={cx('column')}>{this.renderTimeline()}</div>
<div className={cx('column')}>
<VerticalGroup>
<PagedUsers pagedUsers={incident.paged_users} onRemove={this.handlePagedUserRemove} />
{this.renderTimeline()}
</VerticalGroup>
</div>
</div>
{showIntegrationSettings && (
<IntegrationSettings
@ -198,6 +206,19 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
);
}
handlePagedUserRemove = async (userId: User['pk']) => {
const {
store,
match: {
params: { id: alertId },
},
} = this.props;
await store.alertGroupStore.unpageUser(alertId, userId);
this.update();
};
renderHeader = () => {
const {
store,
@ -246,25 +267,25 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
</HorizontalGroup>
<HorizontalGroup align="center">
<Text>
{showLinkTo && (
<IconButton
name="share-alt"
onClick={this.showAttachIncidentForm}
tooltip="Attach to another Alert Group"
className={cx('title-icon')}
/>
)}
<a href={incident.slack_permalink} target="_blank" rel="noreferrer">
<IconButton name="slack" tooltip="View in Slack" className={cx('title-icon')} />
</a>
<CopyToClipboard
text={window.location.href}
onCopy={() => {
openNotification('Link copied');
}}
>
<IconButton name="code-branch" tooltip="Copy link" className={cx('title-icon')} />
<IconButton name="copy" tooltip="Copy link" className={cx('title-icon')} />
</CopyToClipboard>
<a href={incident.slack_permalink} target="_blank" rel="noreferrer">
<IconButton name="slack" tooltip="View in Slack" className={cx('title-icon')} />
</a>
{showLinkTo && (
<IconButton
name="share-alt"
onClick={this.showAttachIncidentForm}
tooltip="Attach to another alert group"
className={cx('title-icon')}
/>
)}
</Text>
</HorizontalGroup>
</HorizontalGroup>
@ -297,22 +318,27 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
</HorizontalGroup>
<HorizontalGroup>
<EscalationVariants
variant="default"
hideSelected
value={prepareForEdit(incident.paged_users)}
onUpdateEscalationVariants={this.handleAddResponders}
/>
<PluginLink
disabled={incident.alert_receive_channel.deleted}
query={{ page: 'integrations', id: incident.alert_receive_channel.id }}
>
<Button disabled={incident.alert_receive_channel.deleted} variant="secondary" size="sm" icon="compass">
<Button disabled={incident.alert_receive_channel.deleted} variant="secondary" icon="compass">
Go to Integration
</Button>
</PluginLink>
<Button
disabled={incident.alert_receive_channel.deleted}
variant="secondary"
size="sm"
icon="edit"
onClick={this.showIntegrationSettings}
>
Edit rendering, grouping and other templates
Edit templates
</Button>
</HorizontalGroup>
</HorizontalGroup>
@ -321,6 +347,22 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
);
};
handleAddResponders = async (data) => {
const {
store,
match: {
params: { id: alertId },
},
} = this.props;
await store.directPagingStore.updateAlertGroup(
alertId,
prepareForUpdate(data.userResponders, data.scheduleResponders)
);
this.update();
};
showIntegrationSettings = () => {
this.setState({ showIntegrationSettings: true });
};
@ -356,7 +398,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
const { timelineFilter, resolutionNoteText } = this.state;
const isResolutionNoteTextEmpty = resolutionNoteText === '';
return (
<>
<div>
<Text.Title type="primary" level={4} className={cx('timeline-title')}>
Timeline
</Text.Title>
@ -395,7 +437,10 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
</li>
))}
</ul>
<Field label="Leave a resolution note" description="Will also show up in the thread of incident in Slack">
<Field
label="Leave a resolution note"
description="Will also show up in the thread of the Alert Group in Slack"
>
<TextArea
value={resolutionNoteText}
onChange={(e: any) => this.setState({ resolutionNoteText: e.target.value })}
@ -409,7 +454,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
>
Add resolution note
</ToolbarButton>
</>
</div>
);
};

View file

@ -0,0 +1,69 @@
import React, { useCallback } from 'react';
import { HorizontalGroup, IconButton } from '@grafana/ui';
import cn from 'classnames/bind';
import Avatar from 'components/Avatar/Avatar';
import Text from 'components/Text/Text';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
import { Alert } from 'models/alertgroup/alertgroup.types';
import { User } from 'models/user/user.types';
import { UserActions } from 'utils/authorization';
import styles from './../Incident.module.scss';
const cx = cn.bind(styles);
interface PagedUsersProps {
pagedUsers: Alert['paged_users'];
onRemove: (id: User['pk']) => void;
}
const PagedUsers = (props: PagedUsersProps) => {
const { pagedUsers, onRemove } = props;
const getPagedUserRemoveHandler = useCallback((id: User['pk']) => {
return () => {
onRemove(id);
};
}, []);
if (!pagedUsers || !pagedUsers.length) {
return null;
}
return (
<div className={cx('paged-users')}>
<Text.Title type="primary" level={4} className={cx('timeline-title')}>
Current responders
</Text.Title>
<ul className={cx('paged-users-list')}>
{pagedUsers.map((pagedUser) => (
<li key={pagedUser.pk}>
<HorizontalGroup justify="space-between">
<HorizontalGroup>
<Avatar size="big" src={pagedUser.avatar} />
<Text strong>{pagedUser.username}</Text>
</HorizontalGroup>
<WithPermissionControl userAction={UserActions.AlertGroupsWrite}>
<WithConfirm
title={`Are you sure to remove "${pagedUser.username}" from responders?`}
confirmText="Remove"
>
<IconButton
onClick={getPagedUserRemoveHandler(pagedUser.pk)}
tooltip="Remove from responders"
name="trash-alt"
/>
</WithConfirm>
</WithPermissionControl>
</HorizontalGroup>
</li>
))}
</ul>
</div>
);
};
export default PagedUsers;

View file

@ -35,3 +35,8 @@
width: 100%;
margin-top: 20px;
}
.title {
margin-bottom: 24px;
right: 0;
}

View file

@ -1,15 +1,17 @@
import React, { ReactElement, SyntheticEvent } from 'react';
import { Button, Icon, Tooltip, VerticalGroup, LoadingPlaceholder, HorizontalGroup } from '@grafana/ui';
import { Button, VerticalGroup, LoadingPlaceholder, HorizontalGroup, Tooltip } from '@grafana/ui';
import cn from 'classnames/bind';
import { get } from 'lodash-es';
import { observer } from 'mobx-react';
import moment from 'moment-timezone';
import Emoji from 'react-emoji-render';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import CursorPagination from 'components/CursorPagination/CursorPagination';
import GTable from 'components/GTable/GTable';
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
import ManualAlertGroup from 'components/ManualAlertGroup/ManualAlertGroup';
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import Tutorial from 'components/Tutorial/Tutorial';
@ -18,17 +20,16 @@ import { IncidentsFiltersType } from 'containers/IncidentsFilters/IncidentFilter
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 { getActionButtons, getIncidentStatusTag, renderRelatedUsers } from 'pages/incident/Incident.helpers';
import { move } from 'state/helpers';
import { renderRelatedUsers } from 'pages/incident/Incident.helpers';
import { PageProps, WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import LocationHelper from 'utils/LocationHelper';
import { UserActions } from 'utils/authorization';
import { PLUGIN_ROOT } from 'utils/consts';
import SilenceDropdown from './parts/SilenceDropdown';
import styles from './Incidents.module.css';
import styles from './Incidents.module.scss';
import { IncidentDropdown } from './parts/IncidentDropdown';
import { SilenceButtonCascader } from './parts/SilenceButtonCascader';
const cx = cn.bind(styles);
@ -49,13 +50,14 @@ function withSkeleton(fn: (alert: AlertType) => ReactElement | ReactElement[]) {
return WithSkeleton;
}
interface IncidentsPageProps extends WithStoreProps, PageProps {}
interface IncidentsPageProps extends WithStoreProps, PageProps, RouteComponentProps {}
interface IncidentsPageState {
selectedIncidentIds: Array<Alert['pk']>;
affectedRows: { [key: string]: boolean };
filters?: IncidentsFiltersType;
pagination: Pagination;
showAddAlertGroupForm: boolean;
}
const ITEMS_PER_PAGE = 25;
@ -81,6 +83,7 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
this.state = {
selectedIncidentIds: [],
affectedRows: {},
showAddAlertGroupForm: false,
pagination: {
start,
end: start + itemsPerPage - 1,
@ -98,11 +101,35 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
}
render() {
const { history } = this.props;
const { showAddAlertGroupForm } = this.state;
return (
<div className={cx('root')}>
{this.renderIncidentFilters()}
{this.renderTable()}
</div>
<>
<div className={cx('root')}>
<div className={cx('title')}>
<HorizontalGroup justify="space-between">
<Text.Title level={3}>Alert Groups</Text.Title>
<WithPermissionControl userAction={UserActions.AlertGroupsWrite}>
<Button icon="plus" onClick={this.handleOnClickEscalateTo}>
Manual alert group
</Button>
</WithPermissionControl>
</HorizontalGroup>
</div>
{this.renderIncidentFilters()}
{this.renderTable()}
</div>
{showAddAlertGroupForm && (
<ManualAlertGroup
onHide={() => {
this.setState({ showAddAlertGroupForm: false });
}}
onCreate={(id: Alert['pk']) => {
history.push(`${PLUGIN_ROOT}/incidents/${id}`);
}}
/>
)}
</>
);
}
@ -116,6 +143,10 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
);
}
handleOnClickEscalateTo = () => {
this.setState({ showAddAlertGroupForm: true });
};
handleFiltersChange = (filters: IncidentsFiltersType, isOnMount: boolean) => {
const { store } = this.props;
@ -236,7 +267,7 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
)}
{'restart' in store.alertGroupStore.bulkActions && (
<WithPermissionControl key="silence" userAction={UserActions.AlertGroupsWrite}>
<SilenceDropdown
<SilenceButtonCascader
disabled={!hasSelected}
onSelect={(ev) => this.getBulkActionClickHandler('silence', ev)}
/>
@ -244,8 +275,8 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
)}
<Text type="secondary">
{hasSelected
? `${selectedIncidentIds.length} alert group${selectedIncidentIds.length > 1 ? 's' : ''} selected`
: 'No alert groups selected'}
? `${selectedIncidentIds.length} Alert Group${selectedIncidentIds.length > 1 ? 's' : ''} selected`
: 'No Alert Groups selected'}
</Text>
</HorizontalGroup>
</div>
@ -309,9 +340,8 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
key: 'id',
render: withSkeleton(this.renderId),
},
{
width: '20%',
width: '35%',
title: 'Title',
key: 'title',
render: withSkeleton(this.renderTitle),
@ -340,11 +370,6 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
key: 'users',
render: withSkeleton(renderRelatedUsers),
},
{
width: '15%',
key: 'action',
render: withSkeleton(this.renderActionButtons),
},
];
return (
@ -398,12 +423,16 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
return (
<VerticalGroup spacing="none" justify="center">
<PluginLink
query={{ page: 'incidents', id: record.pk, cursor: incidentsCursor, perpage: incidentsItemsPerPage, start }}
>
{record.render_for_web.title}
</PluginLink>
{Boolean(record.dependent_alert_groups.length) && `+ ${record.dependent_alert_groups.length} attached`}
<div className={'table__wrap-column'}>
<PluginLink
query={{ page: 'incidents', id: record.pk, cursor: incidentsCursor, perpage: incidentsItemsPerPage, start }}
>
<Tooltip placement="top" content={record.render_for_web.title}>
<span>{record.render_for_web.title}</span>
</Tooltip>
</PluginLink>
{Boolean(record.dependent_alert_groups.length) && `+ ${record.dependent_alert_groups.length} attached`}
</div>
</VerticalGroup>
);
};
@ -426,9 +455,19 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
);
};
renderStatus(record: AlertType) {
return getIncidentStatusTag(record);
}
renderStatus = (alert: AlertType) => {
return (
<IncidentDropdown
alert={alert}
onResolve={this.getOnActionButtonClick(alert.pk, AlertAction.Resolve)}
onUnacknowledge={this.getOnActionButtonClick(alert.pk, AlertAction.unAcknowledge)}
onUnresolve={this.getOnActionButtonClick(alert.pk, AlertAction.unResolve)}
onAcknowledge={this.getOnActionButtonClick(alert.pk, AlertAction.Acknowledge)}
onSilence={this.getSilenceClickHandler(alert)}
onUnsilence={this.getUnsilenceClickHandler(alert)}
/>
);
};
renderStartedAt(alert: AlertType) {
const m = moment(alert.started_at);
@ -441,97 +480,33 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
);
}
renderRelatedUsers = (record: AlertType) => {
const { related_users } = record;
let users = [...related_users];
function renderUser(user: User, index: number) {
let badge = undefined;
if (record.resolved_by_user && user.pk === record.resolved_by_user.pk) {
badge = <Icon name="check-circle" style={{ color: '#52c41a' }} />;
} else if (record.acknowledged_by_user && user.pk === record.acknowledged_by_user.pk) {
badge = <Icon name="eye" style={{ color: '#f2c94c' }} />;
}
return (
<PluginLink query={{ page: 'users', id: user.pk }}>
<Text type="secondary">
{index ? ', ' : ''}
{user.username} {badge}
</Text>
</PluginLink>
);
}
if (record.resolved_by_user) {
const index = users.findIndex((user) => user.pk === record.resolved_by_user.pk);
if (index > -1) {
users = move(users, index, 0);
}
}
if (record.acknowledged_by_user) {
const index = users.findIndex((user) => user.pk === record.acknowledged_by_user.pk);
if (index > -1) {
users = move(users, index, 0);
}
}
const visibleUsers = users.slice(0, 2);
const otherUsers = users.slice(2);
return (
<>
{visibleUsers.map(renderUser)}
{Boolean(otherUsers.length) && (
<Tooltip placement="top" content={<>{otherUsers.map(renderUser)}</>}>
<span className={cx('other-users')}>
, <span style={{ textDecoration: 'underline' }}>+{otherUsers.length} users</span>{' '}
</span>
</Tooltip>
)}
</>
);
};
renderActionButtons = (incident: AlertType) => {
return getActionButtons(incident, cx, {
onResolve: this.getOnActionButtonClick(incident.pk, AlertAction.Resolve),
onUnacknowledge: this.getOnActionButtonClick(incident.pk, AlertAction.unAcknowledge),
onUnresolve: this.getOnActionButtonClick(incident.pk, AlertAction.unResolve),
onAcknowledge: this.getOnActionButtonClick(incident.pk, AlertAction.Acknowledge),
onSilence: this.getSilenceClickHandler(incident),
onUnsilence: this.getUnsilenceClickHandler(incident),
});
};
getOnActionButtonClick = (incidentId: string, action: AlertAction) => {
getOnActionButtonClick = (incidentId: string, action: AlertAction): ((e: SyntheticEvent) => Promise<void>) => {
const { store } = this.props;
return (e: SyntheticEvent) => {
e.stopPropagation();
store.alertGroupStore.doIncidentAction(incidentId, action, false);
return store.alertGroupStore.doIncidentAction(incidentId, action, false);
};
};
getSilenceClickHandler = (alert: AlertType) => {
getSilenceClickHandler = (alert: AlertType): ((value: number) => Promise<void>) => {
const { store } = this.props;
return (value: number) => {
store.alertGroupStore.doIncidentAction(alert.pk, AlertAction.Silence, false, {
return store.alertGroupStore.doIncidentAction(alert.pk, AlertAction.Silence, false, {
delay: value,
});
};
};
getUnsilenceClickHandler = (alert: AlertType) => {
getUnsilenceClickHandler = (alert: AlertType): ((event: any) => Promise<void>) => {
const { store } = this.props;
return (event: any) => {
return (event: React.SyntheticEvent) => {
event.stopPropagation();
store.alertGroupStore.doIncidentAction(alert.pk, AlertAction.unSilence, false);
return store.alertGroupStore.doIncidentAction(alert.pk, AlertAction.unSilence, false);
};
};
@ -585,4 +560,4 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
}
}
export default withMobXProviderContext(Incidents);
export default withRouter(withMobXProviderContext(Incidents));

View file

@ -0,0 +1,49 @@
.incident__tag {
padding: 3px 12px;
display: inline-flex;
align-items: center;
cursor: pointer;
}
.incident__icon {
margin-right: -4px;
margin-left: 2px;
}
.incident__options {
display: flex;
flex-direction: column;
}
.incident__option-item {
padding: 8px;
display: flex;
align-items: center;
flex-direction: row;
flex-shrink: 0;
white-space: nowrap;
border-left: 2px solid transparent;
cursor: pointer;
min-width: 84px;
display: flex;
gap: 8px;
flex-direction: row;
&:hover {
background: var(--gray-9);
}
&--acknowledge {
color: var(--tag-warning);
}
&--firing {
color: var(--error-text-color);
}
&--resolve {
color: var(--success-text-color);
}
}
.incident__option-span > div {
margin: 0;
}

View file

@ -0,0 +1,265 @@
import React, { FC, SyntheticEvent, useRef, useState } from 'react';
import { Icon, LoadingPlaceholder } from '@grafana/ui';
import cn from 'classnames/bind';
import Tag from 'components/Tag/Tag';
import Text from 'components/Text/Text';
import { WithContextMenu } from 'components/WithContextMenu/WithContextMenu';
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
import { Alert, AlertAction, IncidentStatus } from 'models/alertgroup/alertgroup.types';
import styles from 'pages/incidents/parts/IncidentDropdown.module.scss';
import { UserActions } from 'utils/authorization';
import { SilenceSelect } from './SilenceSelect';
const cx = cn.bind(styles);
const getIncidentTagColor = (alert: Alert) => {
if (alert.status === IncidentStatus.Resolved) {
return getComputedStyle(document.documentElement).getPropertyValue('--tag-primary');
}
if (alert.status === IncidentStatus.Firing) {
return getComputedStyle(document.documentElement).getPropertyValue('--tag-danger');
}
if (alert.status === IncidentStatus.Acknowledged) {
return getComputedStyle(document.documentElement).getPropertyValue('--tag-warning');
}
return getComputedStyle(document.documentElement).getPropertyValue('--tag-secondary');
};
function ListMenu({ alert, openMenu }: { alert: Alert; openMenu: React.MouseEventHandler<HTMLElement> }) {
const forwardedRef = useRef<HTMLSpanElement>();
return (
<Tag
forwardedRef={forwardedRef}
className={cx('incident__tag')}
color={getIncidentTagColor(alert)}
onClick={() => {
const boundingRect = forwardedRef.current.getBoundingClientRect();
const LEFT_MARGIN = 8;
openMenu({ pageX: boundingRect.left + LEFT_MARGIN, pageY: boundingRect.top + boundingRect.height } as any);
}}
>
<Text strong size="small">
{IncidentStatus[alert.status]}
</Text>
<Icon className={cx('incident__icon')} name="angle-down" size="sm" />
</Tag>
);
}
export const IncidentDropdown: FC<{
alert: Alert;
onResolve: (e: SyntheticEvent) => Promise<void>;
onUnacknowledge: (e: SyntheticEvent) => Promise<void>;
onUnresolve: (e: SyntheticEvent) => Promise<void>;
onAcknowledge: (e: SyntheticEvent) => Promise<void>;
onSilence: (value: number) => Promise<void>;
onUnsilence: (event: React.SyntheticEvent) => Promise<void>;
}> = ({ alert, onResolve, onUnacknowledge, onUnresolve, onAcknowledge, onSilence, onUnsilence }) => {
const [isLoading, setIsLoading] = useState(false);
const [currentLoadingAction, setCurrentActionLoading] = useState<IncidentStatus>(undefined);
const [forcedOpenAction, setForcedOpenAction] = useState<string>(undefined);
const onClickFn = (
ev: React.SyntheticEvent<HTMLDivElement>,
actionName: string,
action: (value: SyntheticEvent | number) => Promise<void>,
status: IncidentStatus
) => {
setIsLoading(true);
setCurrentActionLoading(status);
// set them to forcedOpen so that they do not close
setForcedOpenAction(actionName);
action(ev)
.then(() => {
// network request is done and succesful, close them
setForcedOpenAction(undefined);
})
.finally(() => {
// hide loading/disabled state
setIsLoading(false);
});
};
if (alert.status === IncidentStatus.Resolved) {
return (
<WithContextMenu
forceIsOpen={forcedOpenAction === AlertAction.Resolve}
renderMenuItems={() => (
<div className={cx('incident__options', { 'u-disabled': isLoading })}>
<WithPermissionControl userAction={UserActions.AlertGroupsWrite}>
<div
className={cx('incident__option-item', 'incident__option-item--firing')}
onClick={(e) => onClickFn(e, AlertAction.Resolve, onUnresolve, IncidentStatus.Firing)}
>
Firing{' '}
{currentLoadingAction === IncidentStatus.Firing && isLoading && (
<span className={cx('incident__option-span')}>
<LoadingPlaceholder text="" />
</span>
)}
</div>
</WithPermissionControl>
</div>
)}
>
{({ openMenu }) => <ListMenu alert={alert} openMenu={openMenu} />}
</WithContextMenu>
);
}
if (alert.status === IncidentStatus.Acknowledged) {
return (
<WithContextMenu
forceIsOpen={forcedOpenAction === AlertAction.Acknowledge}
renderMenuItems={() => (
<div className={cx('incident__options', { 'u-disabled': isLoading })}>
<WithPermissionControl userAction={UserActions.AlertGroupsWrite}>
<div
className={cx('incident__option-item', 'incident__option-item--unacknowledge')}
onClick={(e) => onClickFn(e, AlertAction.Acknowledge, onUnacknowledge, IncidentStatus.Firing)}
>
Unacknowledge{' '}
{currentLoadingAction === IncidentStatus.Firing && isLoading && (
<span className={cx('incident__option-span')}>
<LoadingPlaceholder text="" />
</span>
)}
</div>
</WithPermissionControl>
<WithPermissionControl userAction={UserActions.AlertGroupsWrite}>
<div
className={cx('incident__option-item', 'incident__option-item--resolve')}
onClick={(e) => onClickFn(e, AlertAction.Acknowledge, onResolve, IncidentStatus.Resolved)}
>
Resolve{' '}
{currentLoadingAction === IncidentStatus.Resolved && isLoading && (
<span className={cx('incident__option-span')}>
<LoadingPlaceholder text="" />
</span>
)}
</div>
</WithPermissionControl>
</div>
)}
>
{({ openMenu }) => <ListMenu alert={alert} openMenu={openMenu} />}
</WithContextMenu>
);
}
if (alert.status === IncidentStatus.Firing) {
return (
<WithContextMenu
forceIsOpen={forcedOpenAction === AlertAction.unResolve}
renderMenuItems={() => (
<div className={cx('incident__options', { 'u-disabled': isLoading })}>
<WithPermissionControl userAction={UserActions.AlertGroupsWrite}>
<div
className={cx('incident__option-item', 'incident__option-item--acknowledge')}
onClick={(e) => onClickFn(e, AlertAction.unResolve, onAcknowledge, IncidentStatus.Acknowledged)}
>
Acknowledge{' '}
{currentLoadingAction === IncidentStatus.Acknowledged && isLoading && (
<span className={cx('incident__option-span')}>
<LoadingPlaceholder text="" />
</span>
)}
</div>
</WithPermissionControl>
<WithPermissionControl userAction={UserActions.AlertGroupsWrite}>
<div
className={cx('incident__option-item', 'incident__option-item--resolve')}
onClick={(e) => onClickFn(e, AlertAction.unResolve, onResolve, IncidentStatus.Resolved)}
>
Resolve{' '}
{currentLoadingAction === IncidentStatus.Resolved && isLoading && (
<span className={cx('incident__option-span')}>
<LoadingPlaceholder text="" />
</span>
)}
</div>
</WithPermissionControl>
<div className={cx('incident__option-item')}>
<SilenceSelect
placeholder={
currentLoadingAction === IncidentStatus.Silenced && isLoading ? 'Loading...' : 'Silence for'
}
onSelect={(value) => {
setIsLoading(true);
setForcedOpenAction(AlertAction.unResolve);
setCurrentActionLoading(IncidentStatus.Silenced);
onSilence(value).finally(() => {
setIsLoading(false);
setForcedOpenAction(undefined);
setCurrentActionLoading(undefined);
});
}}
/>
</div>
</div>
)}
>
{({ openMenu }) => <ListMenu alert={alert} openMenu={openMenu} />}
</WithContextMenu>
);
}
// Silenced Alerts
return (
<WithContextMenu
forceIsOpen={forcedOpenAction === AlertAction.Silence}
renderMenuItems={() => (
<div className={cx('incident_options', { 'u-disabled': isLoading })}>
<WithPermissionControl userAction={UserActions.AlertGroupsWrite}>
<div
className={cx('incident__option-item')}
onClick={(e) => onClickFn(e, AlertAction.Silence, onUnsilence, IncidentStatus.Firing)}
>
Unsilence{' '}
{currentLoadingAction === IncidentStatus.Firing && isLoading && (
<span className={cx('incident__option-span')}>
<LoadingPlaceholder text="" />
</span>
)}
</div>
</WithPermissionControl>
<WithPermissionControl userAction={UserActions.AlertGroupsWrite}>
<div
className={cx('incident__option-item', 'incident__option-item--acknowledge')}
onClick={(e) => onClickFn(e, AlertAction.Silence, onAcknowledge, IncidentStatus.Acknowledged)}
>
Acknowledge{' '}
{currentLoadingAction === IncidentStatus.Acknowledged && isLoading && (
<span className={cx('incident__option-span')}>
<LoadingPlaceholder text="" />
</span>
)}
</div>
</WithPermissionControl>
<WithPermissionControl userAction={UserActions.AlertGroupsWrite}>
<div
className={cx('incident__option-item', 'incident__option-item--resolve')}
onClick={(e) => onClickFn(e, AlertAction.Silence, onAcknowledge, IncidentStatus.Resolved)}
>
Resolve{' '}
{currentLoadingAction === IncidentStatus.Resolved && isLoading && (
<span className={cx('incident__option-span')}>
<LoadingPlaceholder text="" />
</span>
)}
</div>
</WithPermissionControl>
</div>
)}
>
{({ openMenu }) => <ListMenu alert={alert} openMenu={openMenu} />}
</WithContextMenu>
);
};

View file

@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React from 'react';
import { ButtonCascader, ComponentSize } from '@grafana/ui';
import { observer } from 'mobx-react';
@ -8,41 +8,28 @@ import { SelectOption } from 'state/types';
import { useStore } from 'state/useStore';
import { UserActions } from 'utils/authorization';
interface SilenceDropdownProps {
onSelect: (value: number) => void;
interface SilenceButtonCascaderProps {
className?: string;
disabled?: boolean;
buttonSize?: string;
onSelect: (value: number) => void;
}
const SilenceDropdown = observer((props: SilenceDropdownProps) => {
export const SilenceButtonCascader = observer((props: SilenceButtonCascaderProps) => {
const { onSelect, className, disabled = false, buttonSize } = props;
const onSelectCallback = useCallback(
([value]) => {
onSelect(Number(value));
},
[onSelect]
);
const store = useStore();
const { alertGroupStore } = store;
const { alertGroupStore } = useStore();
const silenceOptions = alertGroupStore.silenceOptions || [];
return (
<WithPermissionControl key="silence" userAction={UserActions.AlertGroupsWrite}>
<ButtonCascader
// @ts-ignore
variant="secondary"
className={className}
disabled={disabled}
onChange={onSelectCallback}
options={silenceOptions.map((silenceOption: SelectOption) => ({
value: silenceOption.value,
label: silenceOption.display_name,
}))}
onChange={(value) => onSelect(Number(value))}
options={getOptions()}
value={undefined}
buttonProps={{ size: buttonSize as ComponentSize }}
>
@ -50,6 +37,11 @@ const SilenceDropdown = observer((props: SilenceDropdownProps) => {
</ButtonCascader>
</WithPermissionControl>
);
});
export default SilenceDropdown;
function getOptions() {
return silenceOptions.map((silenceOption: SelectOption) => ({
value: silenceOption.value,
label: silenceOption.display_name,
}));
}
});

View file

@ -0,0 +1,44 @@
import React from 'react';
import { Select } from '@grafana/ui';
import { observer } from 'mobx-react';
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
import { SelectOption } from 'state/types';
import { useStore } from 'state/useStore';
import { UserActions } from 'utils/authorization';
interface SilenceSelectProps {
placeholder?: string;
onSelect: (value: number) => void;
}
export const SilenceSelect = observer((props: SilenceSelectProps) => {
const { placeholder = 'Silence for', onSelect } = props;
const store = useStore();
const { alertGroupStore } = store;
const silenceOptions = alertGroupStore.silenceOptions || [];
return (
<WithPermissionControl key="silence" userAction={UserActions.AlertGroupsWrite}>
<Select
menuShouldPortal
placeholder={placeholder}
value={undefined}
onChange={({ value }) => onSelect(Number(value))}
options={getOptions()}
/>
</WithPermissionControl>
);
function getOptions() {
return silenceOptions.map((silenceOption: SelectOption) => ({
value: silenceOption.value,
label: silenceOption.display_name,
}));
}
});

View file

@ -15,6 +15,7 @@ export type PageDefinition = {
hideFromTabsFn?: (store: RootBaseStore) => boolean;
hideFromTabs?: boolean;
action?: UserAction;
hideTitle: boolean; // dont't automatically render title above page content
getPageNav(): { text: string; description: string };
};
@ -29,6 +30,7 @@ export const pages: { [id: string]: PageDefinition } = [
id: 'incidents',
hideFromBreadcrumbs: true,
text: 'Alert Groups',
hideTitle: true,
path: getPath('incidents'),
action: UserActions.AlertGroupsRead,
},

View file

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

View file

@ -11,3 +11,26 @@
.root .buttons {
padding-right: 10px;
}
.schedules__filters-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
row-gap: 4px;
column-gap: 8px;
width: 100%;
}
.schedules__actions {
display: flex;
justify-content: flex-end;
flex-grow: 1;
gap: 8px;
padding-top: 19px;
}
.schedules__user-on-call {
display: flex;
flex-wrap: nowrap;
gap: 4px;
}

View file

@ -8,12 +8,13 @@ import { observer } from 'mobx-react';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import Avatar from 'components/Avatar/Avatar';
import { MatchMediaTooltip } from 'components/MatchMediaTooltip/MatchMediaTooltip';
import NewScheduleSelector from 'components/NewScheduleSelector/NewScheduleSelector';
import PluginLink from 'components/PluginLink/PluginLink';
import ScheduleCounter from 'components/ScheduleCounter/ScheduleCounter';
import ScheduleWarning from 'components/ScheduleWarning/ScheduleWarning';
import SchedulesFilters from 'components/SchedulesFilters_NEW/SchedulesFilters';
import { SchedulesFiltersType } from 'components/SchedulesFilters_NEW/SchedulesFilters.types';
import SchedulesFilters from 'components/SchedulesFilters/SchedulesFilters';
import { SchedulesFiltersType } from 'components/SchedulesFilters/SchedulesFilters.types';
import Table from 'components/Table/Table';
import Text from 'components/Text/Text';
import TimelineMarks from 'components/TimelineMarks/TimelineMarks';
@ -29,7 +30,7 @@ import { getStartOfWeek } from 'pages/schedule/Schedule.helpers';
import { WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import { UserActions } from 'utils/authorization';
import { PLUGIN_ROOT } from 'utils/consts';
import { PLUGIN_ROOT, TABLE_COLUMN_MAX_WIDTH } from 'utils/consts';
import styles from './Schedules.module.css';
@ -137,9 +138,9 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
<>
<div className={cx('root')}>
<VerticalGroup>
<HorizontalGroup justify="space-between">
<div className={cx('schedules__filters-container')}>
<SchedulesFilters value={filters} onChange={this.handleSchedulesFiltersChange} />
<HorizontalGroup spacing="lg">
<div className={cx('schedules__actions')}>
{users && (
<UserTimezoneSelect
value={store.currentTimezone}
@ -152,8 +153,8 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
+ New schedule
</Button>
</WithPermissionControl>
</HorizontalGroup>
</HorizontalGroup>
</div>
</div>
<Table
columns={columns}
data={data}
@ -330,18 +331,24 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
renderOncallNow = (item: Schedule, _index: number) => {
if (item.on_call_now?.length > 0) {
return (
<VerticalGroup>
{item.on_call_now.map((user, _index) => {
return (
<PluginLink key={user.pk} query={{ page: 'users', id: user.pk }}>
<div>
<Avatar size="big" src={user.avatar} />
<Text type="secondary"> {user.username}</Text>
</div>
</PluginLink>
);
})}
</VerticalGroup>
<div className="table__email-column">
<VerticalGroup>
{item.on_call_now.map((user) => {
return (
<PluginLink key={user.pk} query={{ page: 'users', id: user.pk }} className="table__email-content">
<div className={cx('schedules__user-on-call')}>
<div>
<Avatar size="big" src={user.avatar} />
</div>
<MatchMediaTooltip placement="top" content={user.username} maxWidth={TABLE_COLUMN_MAX_WIDTH}>
<span className="table__email-content">{user.username}</span>
</MatchMediaTooltip>
</div>
</PluginLink>
);
})}
</VerticalGroup>
</div>
);
}
return null;

View file

@ -50,8 +50,8 @@ class SettingsPage extends React.Component<SettingsPageProps, SettingsPageState>
<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`}
label="Require a resolution note when resolving Alert Groups"
description={`Once user clicks "Resolve" for an Alert Group, they will be required to fill in a resolution note about the Alert Group`}
>
<WithPermissionControl userAction={UserActions.OtherSettingsWrite}>
<Switch

View file

@ -31,13 +31,21 @@
"includes": [
{
"type": "page",
"name": "Incidents",
"path": "/a/grafana-oncall-app/incidents",
"name": "Home",
"path": "/a/grafana-oncall-app",
"role": "Viewer",
"action": "grafana-oncall-app.alert-groups:read",
"defaultNav": true,
"addToNav": true
},
{
"type": "page",
"name": "Alert Groups",
"path": "/a/grafana-oncall-app/incidents",
"role": "Viewer",
"action": "grafana-oncall-app.alert-groups:read",
"addToNav": true
},
{
"type": "page",
"name": "Users",

View file

@ -54,6 +54,7 @@ dayjs.extend(customParseFormat);
import 'style/vars.css';
import 'style/global.css';
import 'style/utils.css';
import 'style/responsive.css';
import { getQueryParams, isTopNavbar } from './GrafanaPluginRootPage.helpers';
import PluginSetup from './PluginSetup';

View file

@ -9,6 +9,7 @@ import { AlertReceiveChannelFiltersStore } from 'models/alert_receive_channel_fi
import { AlertGroupStore } from 'models/alertgroup/alertgroup';
import { ApiTokenStore } from 'models/api_token/api_token';
import { CloudStore } from 'models/cloud/cloud';
import { DirectPagingStore } from 'models/direct_paging/direct_paging';
import { EscalationChainStore } from 'models/escalation_chain/escalation_chain';
import { EscalationPolicyStore } from 'models/escalation_policy/escalation_policy';
import { GlobalSettingStore } from 'models/global_setting/global_setting';
@ -76,6 +77,7 @@ export class RootBaseStore {
userStore: UserStore = new UserStore(this);
cloudStore: CloudStore = new CloudStore(this);
directPagingStore: DirectPagingStore = new DirectPagingStore(this);
grafanaTeamStore: GrafanaTeamStore = new GrafanaTeamStore(this);
alertReceiveChannelStore: AlertReceiveChannelStore = new AlertReceiveChannelStore(this);
outgoingWebhookStore: OutgoingWebhookStore = new OutgoingWebhookStore(this);

View file

@ -43,3 +43,8 @@
.page-title {
margin-bottom: 16px;
}
.rc-table-cell {
padding-left: 4px;
padding-right: 4px;
}

View file

@ -0,0 +1,23 @@
/*
Make sure if you chage max-width here
You also change it in consts.ts
*/
@media screen and (max-width: 1500px) {
.table__email-column {
max-width: 175px;
}
.table__email-content {
text-overflow: ellipsis;
overflow: hidden;
}
.incident__title-column {
overflow-wrap: anywhere;
white-space: pre-wrap;
}
}
.table__wrap-column {
word-break: break-word;
}

View file

@ -26,6 +26,10 @@
height: 100%;
}
.u-display-block {
display: block;
}
.u-flex {
display: flex;
flex-direction: row;
@ -43,3 +47,9 @@
.u-align-items-center {
align-items: center;
}
.u-disabled {
opacity: var(--opacity);
cursor: not-allowed !important;
pointer-events: none;
}

View file

@ -15,6 +15,13 @@
--gradient-brandVertical: linear-gradient(0.01deg, #f53e4c -31.2%, #f83 113.07%);
--always-gray: #ccccdc;
--title-marginBottom: 16px;
--opacity: 0.5;
/* These seem to slightly differ from warning/success/error colors from below */
--tag-danger: #e02f44;
--tag-warning: #c69b06;
--tag-primary: #299c46;
--tag-secondary: #464c54;
}
.theme-light {

View file

@ -31,7 +31,5 @@ export const FARO_ENDPOINT_PROD =
export const DOCS_SLACK_SETUP = 'https://grafana.com/docs/grafana-cloud/oncall/open-source/#slack-setup';
export const DOCS_TELEGRAM_SETUP = 'https://grafana.com/docs/grafana-cloud/oncall/chat-options/configure-telegram/';
export const COLOR_DANGER = '#E02F44';
export const COLOR_WARNING = '#C69B06';
export const COLOR_PRIMARY = '#299C46';
export const COLOR_SECONDARY = '#464C54';
// Make sure if you chage max-width here you also change it in responsive.css
export const TABLE_COLUMN_MAX_WIDTH = 1500;

View file

@ -7,6 +7,24 @@ export function useForceUpdate() {
return () => setValue((value) => value + 1);
}
export function useOnClickOutside(ref, handler) {
useEffect(() => {
const listener = (event) => {
if (!ref.current || ref.current.contains(event.target)) {
return;
}
handler(event);
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]);
}
export function usePrevious(value: any) {
const ref = useRef();
useEffect(() => {

View file

@ -7477,6 +7477,11 @@ hoist-non-react-statics@3.3.2, hoist-non-react-statics@^3.1.0, hoist-non-react-s
dependencies:
react-is "^16.7.0"
hoist-non-react-statics@^2.1.1:
version "2.5.5"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47"
integrity sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==
homedir-polyfill@^1.0.1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8"
@ -11365,6 +11370,13 @@ react-calendar@3.9.0:
merge-class-names "^1.1.1"
prop-types "^15.6.0"
react-click-outside@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/react-click-outside/-/react-click-outside-3.0.1.tgz#6e77e84d2f17afaaac26dbad743cbbf909f5e24c"
integrity sha512-d0KWFvBt+esoZUF15rL2UBB7jkeAqLU8L/Ny35oLK6fW6mIbOv/ChD+ExF4sR9PD26kVx+9hNfD0FTIqRZEyRQ==
dependencies:
hoist-non-react-statics "^2.1.1"
react-colorful@5.5.1:
version "5.5.1"
resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.5.1.tgz#29d9c4e496f2ca784dd2bb5053a3a4340cfaf784"

View file

@ -1,3 +1,4 @@
from contextlib import suppress
from time import sleep
from urllib.parse import urljoin
@ -21,6 +22,23 @@ def api_call(method: str, path: str, **kwargs) -> requests.Response:
cooldown_seconds = int(e.response.headers["Retry-After"])
sleep(cooldown_seconds)
return api_call(method, path, **kwargs)
elif e.response.status_code == 400:
resp_json = None
with suppress(requests.exceptions.JSONDecodeError):
resp_json = response.json()
# if no JSON payload is available, just raise the original exception
if not resp_json:
raise
# this is mostly taken from requests.models.Response.raise_for_status, but with additional JSON payload
http_error_msg = (
"%s Client Error: %s for url: %s, response payload JSON: %s"
% (response.status_code, e.response.reason, response.url, resp_json)
)
raise requests.exceptions.HTTPError(
http_error_msg, response=e.response
) from e
else:
raise

View file

@ -47,7 +47,7 @@ def format_escalation_policy(policy: dict) -> str:
def format_integration(integration: dict) -> str:
result = integration["service"]["name"] + " - " + integration["name"]
result = "{} - {}".format(integration["service"]["name"], integration["name"])
if not integration["oncall_type"]:
result = (
@ -90,7 +90,7 @@ def schedule_report(schedules: list[dict]) -> str:
if not schedule["unmatched_users"] and schedule["oncall_schedule"]:
result += " (existing schedule with name '{}' will be deleted)".format(
schedule["name"]
schedule["oncall_schedule"]["name"]
)
for user in schedule["unmatched_users"]:
@ -120,7 +120,7 @@ def escalation_policy_report(policies: list[dict]) -> str:
):
result += (
" (existing escalation chain with name '{}' will be deleted)".format(
policy["name"]
policy["oncall_escalation_chain"]["name"]
)
)
@ -141,10 +141,8 @@ def integration_report(integrations: list[dict]) -> str:
and not integration["is_escalation_policy_flawed"]
and integration["oncall_integration"]
):
result += (
" (existing integration with name '{} - {}' will be deleted)".format(
integration["service"]["name"], integration["name"]
)
result += " (existing integration with name '{}' will be deleted)".format(
integration["oncall_integration"]["name"]
)
return result

View file

@ -5,7 +5,7 @@ from migrator.utils import find_by_id, transform_wait_delay
def match_escalation_policy(policy: dict, oncall_escalation_chains: list[dict]) -> None:
oncall_escalation_chain = None
for candidate in oncall_escalation_chains:
if candidate["name"] == policy["name"]:
if candidate["name"].lower().strip() == policy["name"].lower().strip():
oncall_escalation_chain = candidate
policy["oncall_escalation_chain"] = oncall_escalation_chain

View file

@ -6,9 +6,12 @@ from migrator.utils import find_by_id
def match_integration(integration: dict, oncall_integrations: list[dict]) -> None:
oncall_integration = None
for candidate in oncall_integrations:
if candidate["name"] == "{} - {}".format(
integration["service"]["name"], integration["name"]
):
name = (
"{} - {}".format(integration["service"]["name"], integration["name"])
.lower()
.strip()
)
if candidate["name"].lower().strip() == name:
oncall_integration = candidate
integration["oncall_integration"] = oncall_integration

View file

@ -4,7 +4,7 @@ from migrator import oncall_api_client
def match_schedule(schedule: dict, oncall_schedules: list[dict]) -> None:
oncall_schedule = None
for candidate in oncall_schedules:
if schedule["name"] == candidate["name"]:
if schedule["name"].lower().strip() == candidate["name"].lower().strip():
oncall_schedule = candidate
schedule["oncall_schedule"] = oncall_schedule

View file

@ -1560,6 +1560,14 @@ def test_match_user():
assert pd_users_payload == expected_users_match_result
def test_match_user_not_found():
pd_user = {"email": "test@test.com"}
oncall_users = [{"email": "test1@test.com"}]
match_user(pd_user, oncall_users)
assert pd_user["oncall_user"] is None
def test_match_schedule():
for schedule in pd_schedules_payload:
match_schedule(schedule, oncall_schedules_payload)

View file

@ -0,0 +1,38 @@
from migrator.resources.escalation_policies import match_escalation_policy
from migrator.resources.integrations import match_integration
from migrator.resources.schedules import match_schedule
from migrator.resources.users import match_user
def test_match_user_email_case_insensitive():
pd_user = {"email": "test@test.com"}
oncall_users = [{"email": "TEST@TEST.COM"}]
match_user(pd_user, oncall_users)
assert pd_user["oncall_user"] == oncall_users[0]
def test_match_schedule_name_case_insensitive():
pd_schedule = {"name": "Test"}
oncall_schedules = [{"name": "test"}]
match_schedule(pd_schedule, oncall_schedules)
assert pd_schedule["oncall_schedule"] == oncall_schedules[0]
def test_match_escalation_policy_name_case_insensitive():
pd_escalation_policy = {"name": "Test"}
oncall_escalation_chains = [{"name": "test"}]
match_escalation_policy(pd_escalation_policy, oncall_escalation_chains)
assert (
pd_escalation_policy["oncall_escalation_chain"] == oncall_escalation_chains[0]
)
def test_match_integration_name_case_insensitive():
pd_integration = {"service": {"name": "Test service"}, "name": "test Integration"}
oncall_integrations = [{"name": "test Service - Test integration"}]
match_integration(pd_integration, oncall_integrations)
assert pd_integration["oncall_integration"] == oncall_integrations[0]

Some files were not shown because too many files have changed in this diff Show more