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:
parent
b8f15904a8
commit
4ee2f09e7a
35 changed files with 1234 additions and 29 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -0,0 +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;
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue