Changes to make screens more responsive + incident status toggler change (#1237)

# What this PR does

- Changes to make a few screens be more responsive
- Removed incident actions and replaced incident status with a toggler
- Renamed `IncidentStatus.new` to `IncidentStatus.Firing`
- Removed old schedules code (unused)

## Which issue(s) this PR fixes

#1000 

## Checklist

- [x] `CHANGELOG.md` updated

---------

Co-authored-by: Joey Orlando <joey.orlando@grafana.com>
This commit is contained in:
Rares Mardare 2023-02-07 13:28:58 +02:00 committed by GitHub
parent 89f22207c2
commit 81b5741d34
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 927 additions and 487 deletions

View file

@ -5,13 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## v1.1.24 (2023-02-06)
## Unreleased
### Fixed
- Design polishing ([1290](https://github.com/grafana/oncall/pull/1290))
- Not showing contact details in User tooltip if User does not have edit/admin access
### Changes
- Incidents - Removed buttons column and replaced status with toggler ([#1237](https://github.com/grafana/oncall/issues/1237))
- Responsiveness changes across multiple pages (Incidents, Integrations, Schedules) ([#1237](https://github.com/grafana/oncall/issues/1237))
## v1.1.23 (2023-02-06)
### Fixed

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -80,7 +80,6 @@ exports[`MobileAppConnection if we disconnect the app, it disconnects and fetche
</span>
<span
class="root icon-tag"
style="background-color: rgb(41, 156, 70);"
>
Coming Soon
</span>
@ -2424,7 +2423,6 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect
</span>
<span
class="root icon-tag"
style="background-color: rgb(41, 156, 70);"
>
Coming Soon
</span>
@ -2536,7 +2534,6 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco
</span>
<span
class="root icon-tag"
style="background-color: rgb(41, 156, 70);"
>
Coming Soon
</span>
@ -2648,7 +2645,6 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch
</span>
<span
class="root icon-tag"
style="background-color: rgb(41, 156, 70);"
>
Coming Soon
</span>
@ -2760,7 +2756,6 @@ exports[`MobileAppConnection it shows a message when the mobile app is already c
</span>
<span
class="root icon-tag"
style="background-color: rgb(41, 156, 70);"
>
Coming Soon
</span>
@ -2972,7 +2967,6 @@ exports[`MobileAppConnection it shows an error message if there was an error dis
</span>
<span
class="root icon-tag"
style="background-color: rgb(41, 156, 70);"
>
Coming Soon
</span>
@ -3075,7 +3069,6 @@ exports[`MobileAppConnection it shows an error message if there was an error fet
</span>
<span
class="root icon-tag"
style="background-color: rgb(41, 156, 70);"
>
Coming Soon
</span>

View file

@ -74,7 +74,6 @@ exports[`DownloadIcons it renders properly 1`] = `
</span>
<span
class="root icon-tag"
style="background-color: rgb(41, 156, 70);"
>
Coming Soon
</span>

View file

@ -8,7 +8,6 @@ import PlayStoreLogoSVG from 'assets/img/brand/play-store-logo.svg';
import Block from 'components/GBlock/Block';
import Tag from 'components/Tag/Tag';
import Text from 'components/Text/Text';
import { COLOR_PRIMARY } from 'utils/consts';
import styles from './DownloadIcons.module.scss';
@ -39,7 +38,10 @@ const DownloadIcons: FC = () => (
<Text type="primary" className={cx('icon-text')}>
iOS
</Text>
<Tag color={COLOR_PRIMARY} className={cx('icon-tag')}>
<Tag
color={getComputedStyle(document.documentElement).getPropertyValue('--tag-primary')}
className={cx('icon-tag')}
>
Coming Soon
</Tag>
</Block>

View file

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

View file

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

View file

@ -314,7 +314,7 @@ export class AlertGroupStore extends BaseStore {
const result = await makeRequest(`${this.path}stats/`, {
params: {
...this.incidentFilters,
status: [IncidentStatus.New],
status: [IncidentStatus.Firing],
},
});
this.newIncidents = result;
@ -365,9 +365,6 @@ export class AlertGroupStore extends BaseStore {
async doIncidentAction(alertId: Alert['pk'], action: AlertAction, isUndo = false, data?: any) {
this.updateAlert(alertId, { loading: true });
console.log('action', action);
console.log('isUndo', isUndo);
let undoAction = undefined;
if (!isUndo) {
switch (action) {
@ -411,8 +408,6 @@ export class AlertGroupStore extends BaseStore {
loading: false,
undoAction,
});
console.log('undoAction', undoAction);
} catch (e) {
this.updateAlert(alertId, { loading: false });
openErrorNotification(e.response.data?.detail || e.response.data);

View file

@ -3,7 +3,7 @@ import { Channel } from 'models/channel';
import { User } from 'models/user/user.types';
export enum IncidentStatus {
'New',
'Firing',
'Acknowledged',
'Resolved',
'Silenced',

View file

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

View file

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

View file

@ -36,7 +36,7 @@ export default function Header({ backendLicense }: { backendLicense: string }) {
return (
<div className={cx('heading')}>
<h1 className={cx('page-header__title')}>Grafana OnCall</h1>
<div className="u-flex u-align-items-center">
<div className={cx('navbar-heading-container')}>
<div className={cx('page-header__sub-title')}>{APP_SUBTITLE}</div>
<Card heading={undefined} className={cx('navbar-heading')}>
<a

View file

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

View file

@ -1,6 +1,6 @@
import React, { ReactElement, SyntheticEvent } from 'react';
import { Button, Icon, Tooltip, VerticalGroup, LoadingPlaceholder, HorizontalGroup } from '@grafana/ui';
import { Button, VerticalGroup, LoadingPlaceholder, HorizontalGroup, Tooltip } from '@grafana/ui';
import cn from 'classnames/bind';
import { get } from 'lodash-es';
import { observer } from 'mobx-react';
@ -18,17 +18,15 @@ import { IncidentsFiltersType } from 'containers/IncidentsFilters/IncidentFilter
import IncidentsFilters from 'containers/IncidentsFilters/IncidentsFilters';
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
import { Alert, Alert as AlertType, AlertAction } from 'models/alertgroup/alertgroup.types';
import { User } from 'models/user/user.types';
import { getActionButtons, getIncidentStatusTag, renderRelatedUsers } from 'pages/incident/Incident.helpers';
import { move } from 'state/helpers';
import { renderRelatedUsers } from 'pages/incident/Incident.helpers';
import { PageProps, WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import LocationHelper from 'utils/LocationHelper';
import { UserActions } from 'utils/authorization';
import SilenceDropdown from './parts/SilenceDropdown';
import styles from './Incidents.module.css';
import styles from './Incidents.module.scss';
import { IncidentDropdown } from './parts/IncidentDropdown';
import { SilenceButtonCascader } from './parts/SilenceButtonCascader';
const cx = cn.bind(styles);
@ -236,7 +234,7 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
)}
{'restart' in store.alertGroupStore.bulkActions && (
<WithPermissionControl key="silence" userAction={UserActions.AlertGroupsWrite}>
<SilenceDropdown
<SilenceButtonCascader
disabled={!hasSelected}
onSelect={(ev) => this.getBulkActionClickHandler('silence', ev)}
/>
@ -309,9 +307,8 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
key: 'id',
render: withSkeleton(this.renderId),
},
{
width: '20%',
width: '35%',
title: 'Title',
key: 'title',
render: withSkeleton(this.renderTitle),
@ -340,11 +337,6 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
key: 'users',
render: withSkeleton(renderRelatedUsers),
},
{
width: '15%',
key: 'action',
render: withSkeleton(this.renderActionButtons),
},
];
return (
@ -398,12 +390,16 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
return (
<VerticalGroup spacing="none" justify="center">
<PluginLink
query={{ page: 'incidents', id: record.pk, cursor: incidentsCursor, perpage: incidentsItemsPerPage, start }}
>
{record.render_for_web.title}
</PluginLink>
{Boolean(record.dependent_alert_groups.length) && `+ ${record.dependent_alert_groups.length} attached`}
<div className={'table__wrap-column'}>
<PluginLink
query={{ page: 'incidents', id: record.pk, cursor: incidentsCursor, perpage: incidentsItemsPerPage, start }}
>
<Tooltip placement="top" content={record.render_for_web.title}>
<span>{record.render_for_web.title}</span>
</Tooltip>
</PluginLink>
{Boolean(record.dependent_alert_groups.length) && `+ ${record.dependent_alert_groups.length} attached`}
</div>
</VerticalGroup>
);
};
@ -426,9 +422,19 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
);
};
renderStatus(record: AlertType) {
return getIncidentStatusTag(record);
}
renderStatus = (alert: AlertType) => {
return (
<IncidentDropdown
alert={alert}
onResolve={this.getOnActionButtonClick(alert.pk, AlertAction.Resolve)}
onUnacknowledge={this.getOnActionButtonClick(alert.pk, AlertAction.unAcknowledge)}
onUnresolve={this.getOnActionButtonClick(alert.pk, AlertAction.unResolve)}
onAcknowledge={this.getOnActionButtonClick(alert.pk, AlertAction.Acknowledge)}
onSilence={this.getSilenceClickHandler(alert)}
onUnsilence={this.getUnsilenceClickHandler(alert)}
/>
);
};
renderStartedAt(alert: AlertType) {
const m = moment(alert.started_at);
@ -441,97 +447,33 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
);
}
renderRelatedUsers = (record: AlertType) => {
const { related_users } = record;
let users = [...related_users];
function renderUser(user: User, index: number) {
let badge = undefined;
if (record.resolved_by_user && user.pk === record.resolved_by_user.pk) {
badge = <Icon name="check-circle" style={{ color: '#52c41a' }} />;
} else if (record.acknowledged_by_user && user.pk === record.acknowledged_by_user.pk) {
badge = <Icon name="eye" style={{ color: '#f2c94c' }} />;
}
return (
<PluginLink query={{ page: 'users', id: user.pk }}>
<Text type="secondary">
{index ? ', ' : ''}
{user.username} {badge}
</Text>
</PluginLink>
);
}
if (record.resolved_by_user) {
const index = users.findIndex((user) => user.pk === record.resolved_by_user.pk);
if (index > -1) {
users = move(users, index, 0);
}
}
if (record.acknowledged_by_user) {
const index = users.findIndex((user) => user.pk === record.acknowledged_by_user.pk);
if (index > -1) {
users = move(users, index, 0);
}
}
const visibleUsers = users.slice(0, 2);
const otherUsers = users.slice(2);
return (
<>
{visibleUsers.map(renderUser)}
{Boolean(otherUsers.length) && (
<Tooltip placement="top" content={<>{otherUsers.map(renderUser)}</>}>
<span className={cx('other-users')}>
, <span style={{ textDecoration: 'underline' }}>+{otherUsers.length} users</span>{' '}
</span>
</Tooltip>
)}
</>
);
};
renderActionButtons = (incident: AlertType) => {
return getActionButtons(incident, cx, {
onResolve: this.getOnActionButtonClick(incident.pk, AlertAction.Resolve),
onUnacknowledge: this.getOnActionButtonClick(incident.pk, AlertAction.unAcknowledge),
onUnresolve: this.getOnActionButtonClick(incident.pk, AlertAction.unResolve),
onAcknowledge: this.getOnActionButtonClick(incident.pk, AlertAction.Acknowledge),
onSilence: this.getSilenceClickHandler(incident),
onUnsilence: this.getUnsilenceClickHandler(incident),
});
};
getOnActionButtonClick = (incidentId: string, action: AlertAction) => {
getOnActionButtonClick = (incidentId: string, action: AlertAction): ((e: SyntheticEvent) => Promise<void>) => {
const { store } = this.props;
return (e: SyntheticEvent) => {
e.stopPropagation();
store.alertGroupStore.doIncidentAction(incidentId, action, false);
return store.alertGroupStore.doIncidentAction(incidentId, action, false);
};
};
getSilenceClickHandler = (alert: AlertType) => {
getSilenceClickHandler = (alert: AlertType): ((value: number) => Promise<void>) => {
const { store } = this.props;
return (value: number) => {
store.alertGroupStore.doIncidentAction(alert.pk, AlertAction.Silence, false, {
return store.alertGroupStore.doIncidentAction(alert.pk, AlertAction.Silence, false, {
delay: value,
});
};
};
getUnsilenceClickHandler = (alert: AlertType) => {
getUnsilenceClickHandler = (alert: AlertType): ((event: any) => Promise<void>) => {
const { store } = this.props;
return (event: any) => {
return (event: React.SyntheticEvent) => {
event.stopPropagation();
store.alertGroupStore.doIncidentAction(alert.pk, AlertAction.unSilence, false);
return store.alertGroupStore.doIncidentAction(alert.pk, AlertAction.unSilence, false);
};
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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