commit
74a0c41ed3
102 changed files with 2737 additions and 911 deletions
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
20
CHANGELOG.md
20
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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 == ""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
django==3.2.17
|
||||
django==3.2.18
|
||||
djangorestframework==3.12.4
|
||||
slackclient==1.3.0
|
||||
whitenoise==5.3.0
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
../CHANGELOG.md
|
||||
1
grafana-plugin/README.md
Symbolic link
1
grafana-plugin/README.md
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../README.md
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
.assign-responders-button {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.info-block {
|
||||
background: var(--secondary-background);
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
.root {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
import { ScheduleType } from 'models/schedule/schedule.types';
|
||||
|
||||
export interface SchedulesFiltersType {
|
||||
selectedDate: string;
|
||||
searchTerm: string;
|
||||
type: ScheduleType;
|
||||
status: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { ScheduleType } from 'models/schedule/schedule.types';
|
||||
|
||||
export interface SchedulesFiltersType {
|
||||
searchTerm: string;
|
||||
type: ScheduleType;
|
||||
status: string;
|
||||
}
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
.root {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
& .search {
|
||||
width: 320px;
|
||||
}
|
||||
}
|
||||
44
grafana-plugin/src/components/SearchInput/SearchInput.tsx
Normal file
44
grafana-plugin/src/components/SearchInput/SearchInput.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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..." />
|
||||
|
|
|
|||
|
|
@ -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')}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
179
grafana-plugin/src/containers/UserWarningModal/UserWarning.tsx
Normal file
179
grafana-plugin/src/containers/UserWarningModal/UserWarning.tsx
Normal 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;
|
||||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
31
grafana-plugin/src/models/direct_paging/direct_paging.ts
Normal file
31
grafana-plugin/src/models/direct_paging/direct_paging.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }>;
|
||||
}
|
||||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
69
grafana-plugin/src/pages/incident/parts/PagedUsers.tsx
Normal file
69
grafana-plugin/src/pages/incident/parts/PagedUsers.tsx
Normal 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;
|
||||
|
|
@ -35,3 +35,8 @@
|
|||
width: 100%;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 24px;
|
||||
right: 0;
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
265
grafana-plugin/src/pages/incidents/parts/IncidentDropdown.tsx
Normal file
265
grafana-plugin/src/pages/incidents/parts/IncidentDropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
});
|
||||
44
grafana-plugin/src/pages/incidents/parts/SilenceSelect.tsx
Normal file
44
grafana-plugin/src/pages/incidents/parts/SilenceSelect.tsx
Normal 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,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
.root {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
|
||||
--rotations-border: var(--border-weak);
|
||||
--rotations-background: var(--background-secondary);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -43,3 +43,8 @@
|
|||
.page-title {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.rc-table-cell {
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
|
|
|||
23
grafana-plugin/src/style/responsive.css
Normal file
23
grafana-plugin/src/style/responsive.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue