823 direct paging users (#1215)

# What this PR does

Adds direct user paging

## Which issue(s) this PR fixes

https://github.com/grafana/oncall/issues/823

## Checklist

- [ ] Tests updated
- [ ] Documentation added (documentation will be added later)
- [x] `CHANGELOG.md` updated

---------

Co-authored-by: Maxim <maxim.mordasov@grafana.com>
This commit is contained in:
Yulia Shanyrova 2023-02-10 12:10:09 +01:00 committed by GitHub
parent b8f15904a8
commit 4ee2f09e7a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 1234 additions and 29 deletions

View file

@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
Add direct user paging ([823](https://github.com/grafana/oncall/issues/823))
### Fixed
- Cleaning of the name "Incident" ([704](https://github.com/grafana/oncall/pull/704))

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -37,8 +37,8 @@ const ScheduleUserDetails: FC<ScheduleUserDetailsProps> = (props) => {
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="xs">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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';
@ -426,4 +427,11 @@ export class AlertGroupStore extends BaseStore {
toggleLiveUpdate(value: boolean) {
this.liveUpdatesEnabled = value;
}
async unpageUser(alertId: Alert['pk'], userId: User['pk']) {
return await makeRequest(`${this.path}${alertId}/unpage_user`, {
method: 'POST',
data: { user_id: userId },
}).catch(this.onApiError);
}
}

View file

@ -74,6 +74,7 @@ export interface Alert {
short?: boolean;
root_alert_group?: Alert;
alert_receive_channel: Partial<AlertReceiveChannel>;
paged_users: Array<Pick<User, 'pk' | 'username' | 'avatar'>>;
// set by client
loading?: boolean;

View file

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

View file

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

View file

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

View file

@ -150,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>
@ -158,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>
@ -166,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>
@ -174,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>
@ -190,7 +190,6 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key
key="silence"
disabled={incident.loading}
onSelect={onSilence}
buttonSize="sm"
/>
);
}
@ -198,7 +197,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key
if (incident.status === IncidentStatus.Silenced) {
buttons.push(
<WithPermissionControl key="silence" userAction={UserActions.AlertGroupsWrite}>
<Button size="sm" disabled={incident.loading} variant="secondary" onClick={onUnsilence}>
<Button disabled={incident.loading} variant="secondary" onClick={onUnsilence}>
Unsilence
</Button>
</WithPermissionControl>

View file

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

View file

@ -35,6 +35,8 @@ import PluginLink from 'components/PluginLink/PluginLink';
import SourceCode from 'components/SourceCode/SourceCode';
import Text from 'components/Text/Text';
import AttachIncidentForm from 'containers/AttachIncidentForm/AttachIncidentForm';
import EscalationVariants from 'containers/EscalationVariants/EscalationVariants';
import { prepareForEdit, prepareForUpdate } from 'containers/EscalationVariants/EscalationVariants.helpers';
import IntegrationSettings from 'containers/IntegrationSettings/IntegrationSettings';
import { IntegrationSettingsTab } from 'containers/IntegrationSettings/IntegrationSettings.types';
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
@ -47,6 +49,7 @@ import {
GroupedAlert,
} from 'models/alertgroup/alertgroup.types';
import { ResolutionNoteSourceTypesToDisplayName } from 'models/resolution_note/resolution_note.types';
import { User } from 'models/user/user.types';
import { PageProps, WithStoreProps } from 'state/types';
import { useStore } from 'state/useStore';
import { withMobXProviderContext } from 'state/withStore';
@ -56,8 +59,8 @@ import { PLUGIN_ROOT } from 'utils/consts';
import sanitize from 'utils/sanitize';
import { getActionButtons, getIncidentStatusTag, renderRelatedUsers } from './Incident.helpers';
import styles from './Incident.module.css';
import styles from './Incident.module.scss';
import PagedUsers from './parts/PagedUsers';
const cx = cn.bind(styles);
@ -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,
@ -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>
@ -412,7 +454,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
>
Add resolution note
</ToolbarButton>
</>
</div>
);
};

View file

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

View file

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

View file

@ -6,10 +6,12 @@ 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';
@ -23,6 +25,7 @@ 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 styles from './Incidents.module.scss';
import { IncidentDropdown } from './parts/IncidentDropdown';
@ -47,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;
@ -79,6 +83,7 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
this.state = {
selectedIncidentIds: [],
affectedRows: {},
showAddAlertGroupForm: false,
pagination: {
start,
end: start + itemsPerPage - 1,
@ -96,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}`);
}}
/>
)}
</>
);
}
@ -114,6 +143,10 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
);
}
handleOnClickEscalateTo = () => {
this.setState({ showAddAlertGroupForm: true });
};
handleFiltersChange = (filters: IncidentsFiltersType, isOnMount: boolean) => {
const { store } = this.props;
@ -527,4 +560,4 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
}
}
export default withMobXProviderContext(Incidents);
export default withRouter(withMobXProviderContext(Incidents));

View file

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

View file

@ -29,13 +29,21 @@
"updated": "%TODAY%"
},
"includes": [
{
"type": "page",
"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",
"defaultNav": true,
"addToNav": true
},
{

View file

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

View file

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

View file

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