diff --git a/.github/workflows/verify-changelog-updated.yml b/.github/workflows/verify-changelog-updated.yml index e27be8be..3b92afc6 100644 --- a/.github/workflows/verify-changelog-updated.yml +++ b/.github/workflows/verify-changelog-updated.yml @@ -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 }} diff --git a/.github/workflows/verify-public-docs-updated.yml b/.github/workflows/verify-public-docs-updated.yml index 657082a3..63a5f865 100644 --- a/.github/workflows/verify-public-docs-updated.yml +++ b/.github/workflows/verify-public-docs-updated.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c5940d9..958ec604 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/docs/sources/mobile-app/_index.md b/docs/sources/mobile-app/_index.md index ddfc3371..c9549f6e 100644 --- a/docs/sources/mobile-app/_index.md +++ b/docs/sources/mobile-app/_index.md @@ -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 diff --git a/engine/apps/api/tests/test_alert_receive_channel.py b/engine/apps/api/tests/test_alert_receive_channel.py index a886c425..f6153c06 100644 --- a/engine/apps/api/tests/test_alert_receive_channel.py +++ b/engine/apps/api/tests/test_alert_receive_channel.py @@ -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 diff --git a/engine/apps/api/views/alert_receive_channel.py b/engine/apps/api/views/alert_receive_channel.py index 687b68df..6132abc2 100644 --- a/engine/apps/api/views/alert_receive_channel.py +++ b/engine/apps/api/views/alert_receive_channel.py @@ -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 diff --git a/engine/apps/api/views/auth.py b/engine/apps/api/views/auth.py index 20889896..2282c50e 100644 --- a/engine/apps/api/views/auth.py +++ b/engine/apps/api/views/auth.py @@ -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) diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index df012d59..1a06968f 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -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 = [] diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index e95cbbfa..208bf760 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -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 == "" diff --git a/engine/apps/social_auth/middlewares.py b/engine/apps/social_auth/middlewares.py index b4a85b1f..2a16f9ff 100644 --- a/engine/apps/social_auth/middlewares.py +++ b/engine/apps/social_auth/middlewares.py @@ -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: diff --git a/engine/apps/social_auth/urls.py b/engine/apps/social_auth/urls.py deleted file mode 100644 index ed65946b..00000000 --- a/engine/apps/social_auth/urls.py +++ /dev/null @@ -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/", overridden_login_slack_auth, name="slack-auth-with-no-slash"), - path(r"login//", overridden_login_slack_auth, name="slack-auth"), - path(r"complete//", overridden_complete_slack_auth, name="complete-slack-auth"), -] diff --git a/engine/apps/social_auth/views.py b/engine/apps/social_auth/views.py deleted file mode 100644 index 20889896..00000000 --- a/engine/apps/social_auth/views.py +++ /dev/null @@ -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) diff --git a/engine/engine/urls.py b/engine/engine/urls.py index 2ae1f2ef..761107ee 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -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")), diff --git a/engine/requirements.txt b/engine/requirements.txt index 08e382f5..3c86476e 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -1,4 +1,4 @@ -django==3.2.17 +django==3.2.18 djangorestframework==3.12.4 slackclient==1.3.0 whitenoise==5.3.0 diff --git a/grafana-plugin/CHANGELOG.md b/grafana-plugin/CHANGELOG.md deleted file mode 120000 index 04c99a55..00000000 --- a/grafana-plugin/CHANGELOG.md +++ /dev/null @@ -1 +0,0 @@ -../CHANGELOG.md \ No newline at end of file diff --git a/grafana-plugin/README.md b/grafana-plugin/README.md new file mode 120000 index 00000000..32d46ee8 --- /dev/null +++ b/grafana-plugin/README.md @@ -0,0 +1 @@ +../README.md \ No newline at end of file diff --git a/grafana-plugin/src/PluginPage.tsx b/grafana-plugin/src/PluginPage.tsx index 2fbb8dae..21a86341 100644 --- a/grafana-plugin/src/PluginPage.tsx +++ b/grafana-plugin/src/PluginPage.tsx @@ -23,7 +23,7 @@ function RealPlugin(props: AppPluginPageProps): React.ReactNode { {/* Render alerts at the top */}
- {pages[page]?.text &&

{pages[page].text}

} + {pages[page]?.text && !pages[page]?.hideTitle &&

{pages[page].text}

} {props.children} ); diff --git a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx index 873bdd57..ec175eec 100644 --- a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx +++ b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx @@ -231,7 +231,7 @@ const AlertTemplatesForm = (props: AlertTemplatesFormProps) => {
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.
diff --git a/grafana-plugin/src/components/GTable/GTable.tsx b/grafana-plugin/src/components/GTable/GTable.tsx index 26ec3c6a..a778c0db 100644 --- a/grafana-plugin/src/components/GTable/GTable.tsx +++ b/grafana-plugin/src/components/GTable/GTable.tsx @@ -28,6 +28,7 @@ export interface Props extends TableProps { expandIcon?: (props: { expanded: boolean; record: any }) => React.ReactNode; onExpand?: (expanded: boolean, item: any) => void; }; + showHeader?: boolean; } const GTable: FC = (props) => { @@ -40,6 +41,7 @@ const GTable: FC = (props) => { rowSelection, rowKey, expandable, + showHeader = true, ...restProps } = props; @@ -143,6 +145,7 @@ const GTable: FC = (props) => { className={cx('filter-table', className)} columns={columns} data={data} + showHeader={showHeader} {...restProps} /> {pagination && ( diff --git a/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.module.css b/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.module.css new file mode 100644 index 00000000..2b167d9c --- /dev/null +++ b/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.module.css @@ -0,0 +1,8 @@ +.assign-responders-button { + display: flex; +} + +.info-block { + background: var(--secondary-background); + width: 100%; +} diff --git a/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.tsx b/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.tsx new file mode 100644 index 00000000..50735092 --- /dev/null +++ b/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.tsx @@ -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 = (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 ( + <> + + + + + {store.teamStore.currentTeam.slack_team_identity && ( + + {' '} + + The alert group will also be posted to #{store.teamStore.currentTeam?.slack_channel?.display_name} Slack + channel. + + + )} + + + + + + + + ); +}; + +export default ManualAlertGroup; diff --git a/grafana-plugin/src/components/MatchMediaTooltip/MatchMediaTooltip.tsx b/grafana-plugin/src/components/MatchMediaTooltip/MatchMediaTooltip.tsx new file mode 100644 index 00000000..6199712c --- /dev/null +++ b/grafana-plugin/src/components/MatchMediaTooltip/MatchMediaTooltip.tsx @@ -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 = ({ minWidth, maxWidth, placement, content, children }) => { + const [match, setMatch] = useState(getMatch()); + + useEffect(() => { + const debouncedResize = debounce(DEBOUNCE_MS, onWindowResize); + window.addEventListener('resize', debouncedResize); + return () => { + window.removeEventListener('resize', debouncedResize); + }; + }, []); + + if (match?.matches) { + return ( + + {children} + + ); + } + + 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; + } +}; diff --git a/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.module.css b/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.module.css index d362e504..4980b7ee 100644 --- a/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.module.css +++ b/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.module.css @@ -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%; +} diff --git a/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.tsx b/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.tsx index 1f09dcc2..e5a11b01 100644 --- a/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.tsx +++ b/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.tsx @@ -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 = (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 (
- - - - - - {user.username} - {isOncall && } - {isInWH ? ( - - ) : ( - - )} - + + } + renderIcon={() => null} + > + + +
+ {user.username} +
+ + {isOncall && } + {isInWH ? ( + + ) : ( + + )} + +
@@ -54,7 +70,7 @@ const ScheduleUserDetails: FC = (props) => {
- + Local time {currentMoment.tz().format('DD MMM, HH:mm')} ({getTzOffsetString(currentMoment)}) @@ -62,60 +78,69 @@ const ScheduleUserDetails: FC = (props) => {
- - {user.username}'s time + + User's time {`${userMoment.tz(user.timezone).format('DD MMM, HH:mm')}`} ({userOffsetHoursStr})
- - - -
- - Contacts - -
- - {' '} - - {user.email} - {' '} -
- {user.slack_user_identity && ( - - )} - {user.telegram_configuration && ( - - )} - {!user.hide_phone_number && user.verified_phone_number && ( - Phone: {user.verified_phone_number} - )}
+ + {isUserActionAllowed(UserActions.UserSettingsAdmin) && ( + +
+ + Contacts + + + {user.slack_user_identity && ( + + )} + {user.telegram_configuration && ( + + )} + {!user.hide_phone_number && user.verified_phone_number && ( +
+ + + + {user.verified_phone_number} +
+ )} +
+
+ )}
); diff --git a/grafana-plugin/src/components/SchedulesFilters/SchedulesFilters.module.css b/grafana-plugin/src/components/SchedulesFilters/SchedulesFilters.module.css deleted file mode 100644 index e7bbaff6..00000000 --- a/grafana-plugin/src/components/SchedulesFilters/SchedulesFilters.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.root { - display: inline-flex; - align-items: center; -} diff --git a/grafana-plugin/src/components/SchedulesFilters/SchedulesFilters.module.scss b/grafana-plugin/src/components/SchedulesFilters/SchedulesFilters.module.scss new file mode 100644 index 00000000..0d35e21d --- /dev/null +++ b/grafana-plugin/src/components/SchedulesFilters/SchedulesFilters.module.scss @@ -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%; + } +} diff --git a/grafana-plugin/src/components/SchedulesFilters/SchedulesFilters.tsx b/grafana-plugin/src/components/SchedulesFilters/SchedulesFilters.tsx index ce99674f..25fb810b 100644 --- a/grafana-plugin/src/components/SchedulesFilters/SchedulesFilters.tsx +++ b/grafana-plugin/src/components/SchedulesFilters/SchedulesFilters.tsx @@ -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) => { + 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 ( -
- - - +
+ + } + placeholder="Search..." + value={value.searchTerm} + onChange={onSearchTermChangeCallback} /> - - +
+
+ + - -
+ + + +
+ ); }; diff --git a/grafana-plugin/src/components/SchedulesFilters/SchedulesFilters.types.ts b/grafana-plugin/src/components/SchedulesFilters/SchedulesFilters.types.ts index 4ec857f9..ec0ab632 100644 --- a/grafana-plugin/src/components/SchedulesFilters/SchedulesFilters.types.ts +++ b/grafana-plugin/src/components/SchedulesFilters/SchedulesFilters.types.ts @@ -1,3 +1,7 @@ +import { ScheduleType } from 'models/schedule/schedule.types'; + export interface SchedulesFiltersType { - selectedDate: string; + searchTerm: string; + type: ScheduleType; + status: string; } diff --git a/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.helpers.ts b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.helpers.ts deleted file mode 100644 index 1b35ebb3..00000000 --- a/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.helpers.ts +++ /dev/null @@ -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'; -} diff --git a/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.tsx b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.tsx deleted file mode 100644 index cc6ee209..00000000 --- a/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.tsx +++ /dev/null @@ -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) => { - onChange({ ...value, searchTerm: e.currentTarget.value }); - }, - [value] - ); - const handleStatusChange = useCallback( - (status) => { - onChange({ ...value, status }); - }, - [value] - ); - - const handleTypeChange = useCallback( - (type) => { - onChange({ ...value, type }); - }, - [value] - ); - - return ( -
- - - } - placeholder="Search..." - value={value.searchTerm} - onChange={onSearchTermChangeCallback} - /> - - - - - - - - -
- ); -}; - -export default SchedulesFilters; diff --git a/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.types.ts b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.types.ts deleted file mode 100644 index ec0ab632..00000000 --- a/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ScheduleType } from 'models/schedule/schedule.types'; - -export interface SchedulesFiltersType { - searchTerm: string; - type: ScheduleType; - status: string; -} diff --git a/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.module.css b/grafana-plugin/src/components/SearchInput/SearchInput.module.scss similarity index 60% rename from grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.module.css rename to grafana-plugin/src/components/SearchInput/SearchInput.module.scss index e7bbaff6..a294b221 100644 --- a/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.module.css +++ b/grafana-plugin/src/components/SearchInput/SearchInput.module.scss @@ -1,4 +1,8 @@ .root { display: inline-flex; align-items: center; + + & .search { + width: 320px; + } } diff --git a/grafana-plugin/src/components/SearchInput/SearchInput.tsx b/grafana-plugin/src/components/SearchInput/SearchInput.tsx new file mode 100644 index 00000000..e80bfbee --- /dev/null +++ b/grafana-plugin/src/components/SearchInput/SearchInput.tsx @@ -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) => { + const filters = { + ...value, + searchTerm: e.currentTarget.value, + }; + + onChange(filters); + }, + [onChange, value] + ); + + return ( +
+ } + /> +
+ ); +}; + +export default SearchInput; diff --git a/grafana-plugin/src/components/Tag/Tag.tsx b/grafana-plugin/src/components/Tag/Tag.tsx index 6ade4339..61037524 100644 --- a/grafana-plugin/src/components/Tag/Tag.tsx +++ b/grafana-plugin/src/components/Tag/Tag.tsx @@ -8,15 +8,22 @@ interface TagProps { color: string; className?: string; children?: any; + onClick?: (ev) => void; + forwardedRef?: React.MutableRefObject; } const cx = cn.bind(styles); const Tag: FC = (props) => { - const { children, color, className } = props; + const { children, color, className, onClick } = props; return ( - + {children} ); diff --git a/grafana-plugin/src/components/Tutorial/Tutorial.module.css b/grafana-plugin/src/components/Tutorial/Tutorial.module.css index bb3b2c79..67d607aa 100644 --- a/grafana-plugin/src/components/Tutorial/Tutorial.module.css +++ b/grafana-plugin/src/components/Tutorial/Tutorial.module.css @@ -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; + } +} diff --git a/grafana-plugin/src/components/WithContextMenu/WithContextMenu.tsx b/grafana-plugin/src/components/WithContextMenu/WithContextMenu.tsx new file mode 100644 index 00000000..c28a213c --- /dev/null +++ b/grafana-plugin/src/components/WithContextMenu/WithContextMenu.tsx @@ -0,0 +1,61 @@ +import React, { useEffect, useState } from 'react'; + +import { ContextMenu } from '@grafana/ui'; + +export interface WithContextMenuProps { + children: (props: { openMenu: React.MouseEventHandler }) => JSX.Element; + renderMenuItems: () => React.ReactNode; + forceIsOpen?: boolean; + focusOnOpen?: boolean; +} + +const query = '[class$="-page-container"] .scrollbar-view'; + +export const WithContextMenu: React.FC = ({ + 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 && ( + setIsMenuOpen(false)} + x={menuPosition.x} + y={menuPosition.y} + renderMenuItems={renderMenuItems} + focusOnOpen={focusOnOpen} + /> + )} + + ); +}; diff --git a/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.tsx b/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.tsx index 0a961b6d..0337ab7f 100644 --- a/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.tsx +++ b/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.tsx @@ -77,7 +77,7 @@ const AlertReceiveChannelCard = observer((props: AlertReceiveChannelCardProps) = > {alertReceiveChannelCounter?.alerts_count} alert {alertReceiveChannelCounter?.alerts_count === 1 ? '' : 's'} in{' '} - {alertReceiveChannelCounter?.alert_groups_count} alert group + {alertReceiveChannelCounter?.alert_groups_count} Alert Group {alertReceiveChannelCounter?.alert_groups_count === 1 ? '' : 's'} )} diff --git a/grafana-plugin/src/containers/AlertRules/AlertRules.module.css b/grafana-plugin/src/containers/AlertRules/AlertRules.module.css index e85a4acb..cb6bfe72 100644 --- a/grafana-plugin/src/containers/AlertRules/AlertRules.module.css +++ b/grafana-plugin/src/containers/AlertRules/AlertRules.module.css @@ -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; +} diff --git a/grafana-plugin/src/containers/AlertRules/AlertRules.tsx b/grafana-plugin/src/containers/AlertRules/AlertRules.tsx index fe9f35ee..b88ce8a6 100644 --- a/grafana-plugin/src/containers/AlertRules/AlertRules.tsx +++ b/grafana-plugin/src/containers/AlertRules/AlertRules.tsx @@ -155,20 +155,103 @@ class AlertRules extends React.Component { <>
-
- - - Escalate -
{parseEmojis(alertReceiveChannel?.verbal_name || '')}
- +
+
+ +
+
{parseEmojis(alertReceiveChannel?.verbal_name || '')}
+ + + +
+
+
+ +
+
+ + + + +
+ {maintenanceMode === MaintenanceMode.Debug || maintenanceMode === MaintenanceMode.Maintenance ? ( + +
+
+
{editIntegrationName !== undefined && ( {
)} -
- - - - -
- {maintenanceMode === MaintenanceMode.Debug || maintenanceMode === MaintenanceMode.Maintenance ? ( - -
-
{alertReceiveChannel.description && ( diff --git a/grafana-plugin/src/containers/AlertRules/parts/index.tsx b/grafana-plugin/src/containers/AlertRules/parts/index.tsx index fdff1405..bcf2f0b4 100644 --- a/grafana-plugin/src/containers/AlertRules/parts/index.tsx +++ b/grafana-plugin/src/containers/AlertRules/parts/index.tsx @@ -26,7 +26,7 @@ export const ChatOpsConnectors = (props: ChatOpsConnectorsProps) => { } return ( - + {isSlackInstalled && } {isTelegramInstalled && } diff --git a/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx b/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx index c1fbd2d5..3b2531a1 100644 --- a/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx +++ b/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx @@ -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 = observer((props) => { - const { children } = props; - const queryParams = useQueryParams(); - const page = queryParams.get('page') || DEFAULT_PAGE; + const { children, page } = props; if (isTopNavbar()) { return renderTopNavbar(); diff --git a/grafana-plugin/src/containers/EscalationChainSteps/EscalationChainSteps.tsx b/grafana-plugin/src/containers/EscalationChainSteps/EscalationChainSteps.tsx index f7bb9688..5b7cf07c 100644 --- a/grafana-plugin/src/containers/EscalationChainSteps/EscalationChainSteps.tsx +++ b/grafana-plugin/src/containers/EscalationChainSteps/EscalationChainSteps.tsx @@ -92,7 +92,10 @@ const EscalationChainSteps = observer((props: EscalationChainStepsProps) => { ) : ( )} - + + + + + + ); +}; + +const ScheduleResponder = ({ important, data, onImportantChange, handleDelete }) => { + return ( +
  • + + +
    + +
    + {data.name} +