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:
parent
89f22207c2
commit
81b5741d34
44 changed files with 927 additions and 487 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
import React, { FC, useEffect, useState } from 'react';
|
||||
|
||||
import { Tooltip } from '@grafana/ui';
|
||||
import { debounce } from 'throttle-debounce';
|
||||
|
||||
interface MatchMediaTooltipProps {
|
||||
placement: 'top' | 'bottom' | 'right' | 'left';
|
||||
content: string;
|
||||
children: JSX.Element;
|
||||
|
||||
maxWidth?: number;
|
||||
minWidth?: number;
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 200;
|
||||
|
||||
export const MatchMediaTooltip: FC<MatchMediaTooltipProps> = ({ minWidth, maxWidth, placement, content, children }) => {
|
||||
const [match, setMatch] = useState<MediaQueryList>(getMatch());
|
||||
|
||||
useEffect(() => {
|
||||
const debouncedResize = debounce(DEBOUNCE_MS, onWindowResize);
|
||||
window.addEventListener('resize', debouncedResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', debouncedResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (match?.matches) {
|
||||
return (
|
||||
<Tooltip placement={placement} content={content}>
|
||||
{children}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
function onWindowResize() {
|
||||
setMatch(getMatch());
|
||||
}
|
||||
|
||||
function getMatch() {
|
||||
if (minWidth && maxWidth) {
|
||||
return window.matchMedia(`(min-width: ${minWidth}px) and (max-width: ${maxWidth}px)`);
|
||||
} else if (minWidth) {
|
||||
return window.matchMedia(`(min-width: ${minWidth}px)`);
|
||||
} else if (maxWidth) {
|
||||
return window.matchMedia(`(max-width: ${maxWidth}px)`);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
.root {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
.right {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 4px;
|
||||
column-gap: 8px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1600px) {
|
||||
.right {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +1,95 @@
|
|||
import React, { useCallback, useMemo } from 'react';
|
||||
import React, { ChangeEvent, useCallback } from 'react';
|
||||
|
||||
import { DatePickerWithInput, Field, HorizontalGroup, RadioButtonGroup } from '@grafana/ui';
|
||||
import { Field, Icon, Input, RadioButtonGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
import { dateStringToOption, optionToDateString } from './SchedulesFilters.helpers';
|
||||
import { ScheduleType } from 'models/schedule/schedule.types';
|
||||
|
||||
import styles from './SchedulesFilters.module.scss';
|
||||
import { SchedulesFiltersType } from './SchedulesFilters.types';
|
||||
|
||||
import styles from './SchedulesFilters.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface SchedulesFiltersProps {
|
||||
value: SchedulesFiltersType;
|
||||
onChange: (filters: SchedulesFiltersType) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SchedulesFilters = ({ value, onChange, className }: SchedulesFiltersProps) => {
|
||||
const handleDateChange = useCallback(
|
||||
(date: Date) => {
|
||||
onChange({ selectedDate: moment(date).format('YYYY-MM-DD') });
|
||||
const SchedulesFilters = (props: SchedulesFiltersProps) => {
|
||||
const { value, onChange } = props;
|
||||
|
||||
const onSearchTermChangeCallback = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange({ ...value, searchTerm: e.currentTarget.value });
|
||||
},
|
||||
[onChange]
|
||||
[value]
|
||||
);
|
||||
const handleStatusChange = useCallback(
|
||||
(status) => {
|
||||
onChange({ ...value, status });
|
||||
},
|
||||
[value]
|
||||
);
|
||||
|
||||
const option = useMemo(() => dateStringToOption(value.selectedDate), [value]);
|
||||
|
||||
const handleOptionChange = useCallback(
|
||||
(option: string) => {
|
||||
onChange({ ...value, selectedDate: optionToDateString(option) });
|
||||
const handleTypeChange = useCallback(
|
||||
(type) => {
|
||||
onChange({ ...value, type });
|
||||
},
|
||||
[onChange, value]
|
||||
[value]
|
||||
);
|
||||
|
||||
const datePickerValue = useMemo(() => moment(value.selectedDate).toDate(), [value]);
|
||||
|
||||
return (
|
||||
<div className={cx('root', className)}>
|
||||
<HorizontalGroup>
|
||||
<Field label="Filter events">
|
||||
<RadioButtonGroup
|
||||
options={[
|
||||
{ value: 'today', label: 'Today' },
|
||||
{ value: 'tomorrow', label: 'Tomorrow' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
]}
|
||||
value={option}
|
||||
onChange={handleOptionChange}
|
||||
<>
|
||||
<div className={cx('left')}>
|
||||
<Field label="Search by name">
|
||||
<Input
|
||||
autoFocus
|
||||
className={cx('search')}
|
||||
prefix={<Icon name="search" />}
|
||||
placeholder="Search..."
|
||||
value={value.searchTerm}
|
||||
onChange={onSearchTermChangeCallback}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Date">
|
||||
<DatePickerWithInput closeOnSelect width={40} value={datePickerValue} onChange={handleDateChange} />
|
||||
</div>
|
||||
<div className={cx('right')}>
|
||||
<Field label="Status">
|
||||
<RadioButtonGroup
|
||||
options={[
|
||||
{ label: 'All', value: 'all' },
|
||||
{
|
||||
label: 'Used in escalations',
|
||||
value: 'used',
|
||||
},
|
||||
{ label: 'Unused', value: 'unused' },
|
||||
]}
|
||||
value={value.status}
|
||||
onChange={handleStatusChange}
|
||||
/>
|
||||
</Field>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<Field label="Type">
|
||||
<RadioButtonGroup
|
||||
options={[
|
||||
{ label: 'All', value: undefined },
|
||||
{
|
||||
label: 'Web',
|
||||
value: ScheduleType.API,
|
||||
},
|
||||
{
|
||||
label: 'ICal',
|
||||
value: ScheduleType.Ical,
|
||||
},
|
||||
{
|
||||
label: 'API',
|
||||
value: ScheduleType.Calendar,
|
||||
},
|
||||
]}
|
||||
value={value?.type}
|
||||
onChange={handleTypeChange}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
import { ScheduleType } from 'models/schedule/schedule.types';
|
||||
|
||||
export interface SchedulesFiltersType {
|
||||
selectedDate: string;
|
||||
searchTerm: string;
|
||||
type: ScheduleType;
|
||||
status: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
import moment from 'moment-timezone';
|
||||
|
||||
export function optionToDateString(option: string) {
|
||||
switch (option) {
|
||||
case 'today':
|
||||
return moment().startOf('day').format('YYYY-MM-DD');
|
||||
case 'tomorrow':
|
||||
return moment().add(1, 'day').startOf('day').format('YYYY-MM-DD');
|
||||
default:
|
||||
return moment().add(2, 'day').startOf('day').format('YYYY-MM-DD');
|
||||
}
|
||||
}
|
||||
|
||||
export function dateStringToOption(dateString: string) {
|
||||
const today = moment().startOf('day').format('YYYY-MM-DD');
|
||||
if (dateString === today) {
|
||||
return 'today';
|
||||
}
|
||||
const tomorrow = moment().add(1, 'day').startOf('day').format('YYYY-MM-DD');
|
||||
if (dateString === tomorrow) {
|
||||
return 'tomorrow';
|
||||
}
|
||||
|
||||
return 'custom';
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
.root {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import React, { ChangeEvent, useCallback } from 'react';
|
||||
|
||||
import { Field, HorizontalGroup, Icon, Input, RadioButtonGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
|
||||
import { ScheduleType } from 'models/schedule/schedule.types';
|
||||
|
||||
import { SchedulesFiltersType } from './SchedulesFilters.types';
|
||||
|
||||
import styles from './SchedulesFilters.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface SchedulesFiltersProps {
|
||||
value: SchedulesFiltersType;
|
||||
onChange: (filters: SchedulesFiltersType) => void;
|
||||
}
|
||||
|
||||
const SchedulesFilters = (props: SchedulesFiltersProps) => {
|
||||
const { value, onChange } = props;
|
||||
|
||||
const onSearchTermChangeCallback = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange({ ...value, searchTerm: e.currentTarget.value });
|
||||
},
|
||||
[value]
|
||||
);
|
||||
const handleStatusChange = useCallback(
|
||||
(status) => {
|
||||
onChange({ ...value, status });
|
||||
},
|
||||
[value]
|
||||
);
|
||||
|
||||
const handleTypeChange = useCallback(
|
||||
(type) => {
|
||||
onChange({ ...value, type });
|
||||
},
|
||||
[value]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<HorizontalGroup spacing="lg">
|
||||
<Field label="Search by name">
|
||||
<Input
|
||||
autoFocus
|
||||
className={cx('search')}
|
||||
prefix={<Icon name="search" />}
|
||||
placeholder="Search..."
|
||||
value={value.searchTerm}
|
||||
onChange={onSearchTermChangeCallback}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Status">
|
||||
<RadioButtonGroup
|
||||
options={[
|
||||
{ label: 'All', value: 'all' },
|
||||
{
|
||||
label: 'Used in escalations',
|
||||
value: 'used',
|
||||
},
|
||||
{ label: 'Unused', value: 'unused' },
|
||||
]}
|
||||
value={value.status}
|
||||
onChange={handleStatusChange}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Type">
|
||||
<RadioButtonGroup
|
||||
options={[
|
||||
{ label: 'All', value: undefined },
|
||||
{
|
||||
label: 'Web',
|
||||
value: ScheduleType.API,
|
||||
},
|
||||
{
|
||||
label: 'ICal',
|
||||
value: ScheduleType.Ical,
|
||||
},
|
||||
{
|
||||
label: 'API',
|
||||
value: ScheduleType.Calendar,
|
||||
},
|
||||
]}
|
||||
value={value?.type}
|
||||
onChange={handleTypeChange}
|
||||
/>
|
||||
</Field>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchedulesFilters;
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { ScheduleType } from 'models/schedule/schedule.types';
|
||||
|
||||
export interface SchedulesFiltersType {
|
||||
searchTerm: string;
|
||||
type: ScheduleType;
|
||||
status: string;
|
||||
}
|
||||
|
|
@ -8,15 +8,22 @@ interface TagProps {
|
|||
color: string;
|
||||
className?: string;
|
||||
children?: any;
|
||||
onClick?: (ev) => void;
|
||||
forwardedRef?: React.MutableRefObject<HTMLSpanElement>;
|
||||
}
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
const Tag: FC<TagProps> = (props) => {
|
||||
const { children, color, className } = props;
|
||||
const { children, color, className, onClick } = props;
|
||||
|
||||
return (
|
||||
<span style={{ backgroundColor: color }} className={cx('root', className)}>
|
||||
<span
|
||||
style={{ backgroundColor: color }}
|
||||
className={cx('root', className)}
|
||||
onClick={onClick}
|
||||
ref={props.forwardedRef}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -25,12 +25,6 @@
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 1540px) {
|
||||
.step {
|
||||
width: 170px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
|
|
@ -55,3 +49,9 @@
|
|||
:global(.theme-dark) .arrow svg {
|
||||
fill-opacity: 0.15;
|
||||
}
|
||||
|
||||
@media (min-width: 1540px) {
|
||||
.step {
|
||||
width: 170px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { ContextMenu } from '@grafana/ui';
|
||||
|
||||
export interface WithContextMenuProps {
|
||||
children: (props: { openMenu: React.MouseEventHandler<HTMLElement> }) => JSX.Element;
|
||||
renderMenuItems: () => React.ReactNode;
|
||||
forceIsOpen?: boolean;
|
||||
focusOnOpen?: boolean;
|
||||
}
|
||||
|
||||
const query = '[class$="-page-container"] .scrollbar-view';
|
||||
|
||||
export const WithContextMenu: React.FC<WithContextMenuProps> = ({
|
||||
children,
|
||||
renderMenuItems,
|
||||
forceIsOpen = false,
|
||||
focusOnOpen = true,
|
||||
}) => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false || forceIsOpen);
|
||||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
setIsMenuOpen(forceIsOpen);
|
||||
}, [forceIsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const onScrollOrResizeFn = () => setIsMenuOpen(false);
|
||||
document.querySelector(query)?.addEventListener('scroll', onScrollOrResizeFn);
|
||||
window.addEventListener('resize', onScrollOrResizeFn);
|
||||
|
||||
return () => {
|
||||
document.querySelector(query)?.removeEventListener('scroll', onScrollOrResizeFn);
|
||||
window.removeEventListener('resize', onScrollOrResizeFn);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children({
|
||||
openMenu: (e) => {
|
||||
setIsMenuOpen(true);
|
||||
setMenuPosition({
|
||||
x: e.pageX,
|
||||
y: e.pageY,
|
||||
});
|
||||
},
|
||||
})}
|
||||
|
||||
{isMenuOpen && (
|
||||
<ContextMenu
|
||||
onClose={() => setIsMenuOpen(false)}
|
||||
x={menuPosition.x}
|
||||
y={menuPosition.y}
|
||||
renderMenuItems={renderMenuItems}
|
||||
focusOnOpen={focusOnOpen}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -16,12 +16,6 @@
|
|||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.verbal-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
|
@ -55,6 +49,8 @@
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.channel-filter-header-title {
|
||||
|
|
@ -116,3 +112,27 @@
|
|||
.description-style a {
|
||||
color: var(--primary-text-link);
|
||||
}
|
||||
|
||||
.integration__heading-text {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.integration__heading-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.integration__heading-container-left {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.integration__heading-container-left,
|
||||
.integration__heading-container-right {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.integration__heading-container-right {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -155,20 +155,103 @@ class AlertRules extends React.Component<AlertRulesProps, AlertRulesState> {
|
|||
<>
|
||||
<div className={cx('root')}>
|
||||
<Block className={cx('headerBlock')}>
|
||||
<div className={cx('header')}>
|
||||
<Text.Title level={4}>
|
||||
<HorizontalGroup>
|
||||
Escalate
|
||||
<div className={cx('verbal-name')}>{parseEmojis(alertReceiveChannel?.verbal_name || '')}</div>
|
||||
<Tooltip placement="top" content="Edit name">
|
||||
<div className={cx('integration__heading-container')}>
|
||||
<div className={cx('integration__heading-container-left')}>
|
||||
<Text.Title level={4}>
|
||||
<div className={cx('integration__heading-text')}>
|
||||
<div className={cx('verbal-name')}>{parseEmojis(alertReceiveChannel?.verbal_name || '')}</div>
|
||||
<Tooltip placement="top" content="Edit name">
|
||||
<IconButton
|
||||
name="pen"
|
||||
onClick={this.getChangeIntegrationNameHandler(parseEmojis(alertReceiveChannel?.verbal_name))}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Text.Title>
|
||||
</div>
|
||||
|
||||
<div className={cx('integration__heading-container-right')}>
|
||||
<div className={cx('buttons')}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onShowSettings(IntegrationSettingsTab.HowToConnect);
|
||||
}}
|
||||
>
|
||||
How to connect
|
||||
</Button>
|
||||
<WithPermissionControl userAction={UserActions.IntegrationsTest}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={this.getSendDemoAlertClickHandler(alertReceiveChannel.id)}
|
||||
>
|
||||
Send demo alert
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
<div className={cx('icons-container')}>
|
||||
{maintenanceMode === MaintenanceMode.Debug || maintenanceMode === MaintenanceMode.Maintenance ? (
|
||||
<Tooltip placement="top" content="Stop maintenance mode">
|
||||
<Button
|
||||
className="grey-button"
|
||||
disabled={!isUserActionAllowed(UserActions.MaintenanceWrite)}
|
||||
fill="text"
|
||||
icon="square-shape"
|
||||
onClick={this.handleStopMaintenance}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<PluginLink
|
||||
query={{
|
||||
page: 'maintenance',
|
||||
maintenance_type: MaintenanceType.alert_receive_channel,
|
||||
alert_receive_channel: alertReceiveChannel.id,
|
||||
}}
|
||||
disabled={!isUserActionAllowed(UserActions.MaintenanceWrite)}
|
||||
>
|
||||
<WithPermissionControl userAction={UserActions.MaintenanceWrite}>
|
||||
<IconButton
|
||||
name="pause"
|
||||
size="sm"
|
||||
tooltip="Setup maintenance mode"
|
||||
tooltipPlacement="top"
|
||||
disabled={!isUserActionAllowed(UserActions.MaintenanceWrite)}
|
||||
/>
|
||||
</WithPermissionControl>
|
||||
</PluginLink>
|
||||
)}
|
||||
<IconButton
|
||||
name="pen"
|
||||
onClick={this.getChangeIntegrationNameHandler(parseEmojis(alertReceiveChannel?.verbal_name))}
|
||||
name="cog"
|
||||
size="sm"
|
||||
tooltip="Settings"
|
||||
tooltipPlacement="top"
|
||||
onClick={() => {
|
||||
onShowSettings();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
alerts
|
||||
</HorizontalGroup>
|
||||
</Text.Title>
|
||||
<WithPermissionControl userAction={UserActions.EscalationChainsWrite}>
|
||||
<WithConfirm
|
||||
title="Delete integration?"
|
||||
body={
|
||||
<>
|
||||
Are you sure you want to delete <Emoji text={alertReceiveChannel.verbal_name} />{' '}
|
||||
integration?
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
size="sm"
|
||||
tooltip="Delete"
|
||||
tooltipPlacement="top"
|
||||
onClick={this.handleDeleteAlertReceiveChannel}
|
||||
name="trash-alt"
|
||||
/>
|
||||
</WithConfirm>
|
||||
</WithPermissionControl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editIntegrationName !== undefined && (
|
||||
<Modal
|
||||
|
|
@ -199,85 +282,6 @@ class AlertRules extends React.Component<AlertRulesProps, AlertRulesState> {
|
|||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
<div className={cx('buttons')}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onShowSettings(IntegrationSettingsTab.HowToConnect);
|
||||
}}
|
||||
>
|
||||
How to connect
|
||||
</Button>
|
||||
<WithPermissionControl userAction={UserActions.IntegrationsTest}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={this.getSendDemoAlertClickHandler(alertReceiveChannel.id)}
|
||||
>
|
||||
Send demo alert
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
<div className={cx('icons-container')}>
|
||||
{maintenanceMode === MaintenanceMode.Debug || maintenanceMode === MaintenanceMode.Maintenance ? (
|
||||
<Tooltip placement="top" content="Stop maintenance mode">
|
||||
<Button
|
||||
className="grey-button"
|
||||
disabled={!isUserActionAllowed(UserActions.MaintenanceWrite)}
|
||||
fill="text"
|
||||
icon="square-shape"
|
||||
onClick={this.handleStopMaintenance}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<PluginLink
|
||||
query={{
|
||||
page: 'maintenance',
|
||||
maintenance_type: MaintenanceType.alert_receive_channel,
|
||||
alert_receive_channel: alertReceiveChannel.id,
|
||||
}}
|
||||
disabled={!isUserActionAllowed(UserActions.MaintenanceWrite)}
|
||||
>
|
||||
<WithPermissionControl userAction={UserActions.MaintenanceWrite}>
|
||||
<IconButton
|
||||
name="pause"
|
||||
size="sm"
|
||||
tooltip="Setup maintenance mode"
|
||||
tooltipPlacement="top"
|
||||
disabled={!isUserActionAllowed(UserActions.MaintenanceWrite)}
|
||||
/>
|
||||
</WithPermissionControl>
|
||||
</PluginLink>
|
||||
)}
|
||||
<IconButton
|
||||
name="cog"
|
||||
size="sm"
|
||||
tooltip="Settings"
|
||||
tooltipPlacement="top"
|
||||
onClick={() => {
|
||||
onShowSettings(IntegrationSettingsTab.Templates);
|
||||
}}
|
||||
/>
|
||||
<WithPermissionControl userAction={UserActions.EscalationChainsWrite}>
|
||||
<WithConfirm
|
||||
title="Delete integration?"
|
||||
body={
|
||||
<>
|
||||
Are you sure you want to delete <Emoji text={alertReceiveChannel.verbal_name} /> integration?
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
size="sm"
|
||||
tooltip="Delete"
|
||||
tooltipPlacement="top"
|
||||
onClick={this.handleDeleteAlertReceiveChannel}
|
||||
name="trash-alt"
|
||||
/>
|
||||
</WithConfirm>
|
||||
</WithPermissionControl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Block>
|
||||
{alertReceiveChannel.description && (
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export const ChatOpsConnectors = (props: ChatOpsConnectorsProps) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Timeline.Item number={0} color="#464C54">
|
||||
<Timeline.Item number={0} color={getComputedStyle(document.documentElement).getPropertyValue('--tag-secondary')}>
|
||||
<VerticalGroup>
|
||||
{isSlackInstalled && <SlackConnector channelFilterId={channelFilterId} />}
|
||||
{isTelegramInstalled && <TelegramConnector channelFilterId={channelFilterId} />}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Channel } from 'models/channel';
|
|||
import { User } from 'models/user/user.types';
|
||||
|
||||
export enum IncidentStatus {
|
||||
'New',
|
||||
'Firing',
|
||||
'Acknowledged',
|
||||
'Resolved',
|
||||
'Silenced',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@
|
|||
|
||||
.navbar-heading {
|
||||
padding: 4px;
|
||||
margin: 0 0 0 8px;
|
||||
border: 1px solid var(--gray-9);
|
||||
width: initial;
|
||||
font-size: 12px;
|
||||
padding-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.navbar-link {
|
||||
|
|
@ -26,3 +26,12 @@
|
|||
display: flex;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.navbar-heading-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
column-gap: 8px;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export default function Header({ backendLicense }: { backendLicense: string }) {
|
|||
return (
|
||||
<div className={cx('heading')}>
|
||||
<h1 className={cx('page-header__title')}>Grafana OnCall</h1>
|
||||
<div className="u-flex u-align-items-center">
|
||||
<div className={cx('navbar-heading-container')}>
|
||||
<div className={cx('page-header__sub-title')}>{APP_SUBTITLE}</div>
|
||||
<Card heading={undefined} className={cx('navbar-heading')}>
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Button, HorizontalGroup, Icon, Tooltip, VerticalGroup } from '@grafana/ui';
|
||||
import { Button, HorizontalGroup, IconButton, Tooltip, VerticalGroup } from '@grafana/ui';
|
||||
|
||||
import Avatar from 'components/Avatar/Avatar';
|
||||
import { MatchMediaTooltip } from 'components/MatchMediaTooltip/MatchMediaTooltip';
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import Tag from 'components/Tag/Tag';
|
||||
import Text from 'components/Text/Text';
|
||||
|
|
@ -10,16 +11,16 @@ import { WithPermissionControl } from 'containers/WithPermissionControl/WithPerm
|
|||
import { MaintenanceIntegration } from 'models/alert_receive_channel';
|
||||
import { Alert as AlertType, Alert, IncidentStatus } from 'models/alertgroup/alertgroup.types';
|
||||
import { User } from 'models/user/user.types';
|
||||
import SilenceDropdown from 'pages/incidents/parts/SilenceDropdown';
|
||||
import { SilenceButtonCascader } from 'pages/incidents/parts/SilenceButtonCascader';
|
||||
import { move } from 'state/helpers';
|
||||
import { UserActions } from 'utils/authorization';
|
||||
import { COLOR_DANGER, COLOR_PRIMARY, COLOR_SECONDARY, COLOR_WARNING } from 'utils/consts';
|
||||
import { TABLE_COLUMN_MAX_WIDTH } from 'utils/consts';
|
||||
|
||||
export function getIncidentStatusTag(alert: Alert) {
|
||||
switch (alert.status) {
|
||||
case IncidentStatus.New:
|
||||
case IncidentStatus.Firing:
|
||||
return (
|
||||
<Tag color={COLOR_DANGER}>
|
||||
<Tag color={getComputedStyle(document.documentElement).getPropertyValue('--tag-danger')}>
|
||||
<Text strong size="small">
|
||||
Firing
|
||||
</Text>
|
||||
|
|
@ -27,7 +28,7 @@ export function getIncidentStatusTag(alert: Alert) {
|
|||
);
|
||||
case IncidentStatus.Acknowledged:
|
||||
return (
|
||||
<Tag color={COLOR_WARNING}>
|
||||
<Tag color={getComputedStyle(document.documentElement).getPropertyValue('--tag-warning')}>
|
||||
<Text strong size="small">
|
||||
Acknowledged
|
||||
</Text>
|
||||
|
|
@ -35,7 +36,7 @@ export function getIncidentStatusTag(alert: Alert) {
|
|||
);
|
||||
case IncidentStatus.Resolved:
|
||||
return (
|
||||
<Tag color={COLOR_PRIMARY}>
|
||||
<Tag color={getComputedStyle(document.documentElement).getPropertyValue('--tag-primary')}>
|
||||
<Text strong size="small">
|
||||
Resolved
|
||||
</Text>
|
||||
|
|
@ -43,7 +44,7 @@ export function getIncidentStatusTag(alert: Alert) {
|
|||
);
|
||||
case IncidentStatus.Silenced:
|
||||
return (
|
||||
<Tag color={COLOR_SECONDARY}>
|
||||
<Tag color={getComputedStyle(document.documentElement).getPropertyValue('--tag-secondary')}>
|
||||
<Text strong size="small">
|
||||
Silenced
|
||||
</Text>
|
||||
|
|
@ -66,15 +67,19 @@ export function renderRelatedUsers(incident: Alert, isFull = false) {
|
|||
function renderUser(user: User) {
|
||||
let badge = undefined;
|
||||
if (incident.resolved_by_user && user.pk === incident.resolved_by_user.pk) {
|
||||
badge = <Icon name="check-circle" style={{ color: '#52c41a' }} />;
|
||||
badge = <IconButton tooltipPlacement="top" tooltip="Resolved" name="check-circle" style={{ color: '#52c41a' }} />;
|
||||
} else if (incident.acknowledged_by_user && user.pk === incident.acknowledged_by_user.pk) {
|
||||
badge = <Icon name="eye" style={{ color: '#f2c94c' }} />;
|
||||
badge = <IconButton tooltipPlacement="top" tooltip="Acknowledged" name="eye" style={{ color: '#f2c94c' }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PluginLink key={user.pk} query={{ page: 'users', id: user.pk }} wrap={false}>
|
||||
<PluginLink key={user.pk} query={{ page: 'users', id: user.pk }} wrap={false} className="table__email-content">
|
||||
<Text type="secondary">
|
||||
<Avatar size="small" src={user.avatar} /> {user.username} {badge}
|
||||
<Avatar size="small" src={user.avatar} />{' '}
|
||||
<MatchMediaTooltip placement="top" content={user.username} maxWidth={TABLE_COLUMN_MAX_WIDTH}>
|
||||
<span>{user.username}</span>
|
||||
</MatchMediaTooltip>{' '}
|
||||
{badge}
|
||||
</Text>
|
||||
</PluginLink>
|
||||
);
|
||||
|
|
@ -107,30 +112,32 @@ export function renderRelatedUsers(incident: Alert, isFull = false) {
|
|||
}
|
||||
|
||||
return (
|
||||
<VerticalGroup spacing="xs">
|
||||
{visibleUsers.map(renderUser)}
|
||||
{Boolean(otherUsers.length) && (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
content={
|
||||
<>
|
||||
{otherUsers.map((user, index) => (
|
||||
<>
|
||||
{index ? ', ' : ''}
|
||||
{renderUser(user)}
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Text type="secondary" underline size="small">
|
||||
+{otherUsers.length} user{otherUsers.length > 1 ? 's' : ''}
|
||||
</Text>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
<div className={'table__email-column'}>
|
||||
<VerticalGroup spacing="xs">
|
||||
{visibleUsers.map(renderUser)}
|
||||
{Boolean(otherUsers.length) && (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
content={
|
||||
<>
|
||||
{otherUsers.map((user, index) => (
|
||||
<>
|
||||
{index ? ', ' : ''}
|
||||
{renderUser(user)}
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Text type="secondary" underline size="small">
|
||||
+{otherUsers.length} user{otherUsers.length > 1 ? 's' : ''}
|
||||
</Text>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
.incident__tag {
|
||||
padding: 3px 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.incident__icon {
|
||||
margin-right: -4px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.incident__options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.incident__option-item {
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
border-left: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
min-width: 84px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-direction: row;
|
||||
|
||||
&:hover {
|
||||
background: var(--gray-9);
|
||||
}
|
||||
|
||||
&--acknowledge {
|
||||
color: var(--tag-warning);
|
||||
}
|
||||
&--firing {
|
||||
color: var(--error-text-color);
|
||||
}
|
||||
&--resolve {
|
||||
color: var(--success-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.incident__option-span > div {
|
||||
margin: 0;
|
||||
}
|
||||
265
grafana-plugin/src/pages/incidents/parts/IncidentDropdown.tsx
Normal file
265
grafana-plugin/src/pages/incidents/parts/IncidentDropdown.tsx
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import React, { FC, SyntheticEvent, useRef, useState } from 'react';
|
||||
|
||||
import { Icon, LoadingPlaceholder } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
|
||||
import Tag from 'components/Tag/Tag';
|
||||
import Text from 'components/Text/Text';
|
||||
import { WithContextMenu } from 'components/WithContextMenu/WithContextMenu';
|
||||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { Alert, AlertAction, IncidentStatus } from 'models/alertgroup/alertgroup.types';
|
||||
import styles from 'pages/incidents/parts/IncidentDropdown.module.scss';
|
||||
import { UserActions } from 'utils/authorization';
|
||||
|
||||
import { SilenceSelect } from './SilenceSelect';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
const getIncidentTagColor = (alert: Alert) => {
|
||||
if (alert.status === IncidentStatus.Resolved) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--tag-primary');
|
||||
}
|
||||
if (alert.status === IncidentStatus.Firing) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--tag-danger');
|
||||
}
|
||||
if (alert.status === IncidentStatus.Acknowledged) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--tag-warning');
|
||||
}
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--tag-secondary');
|
||||
};
|
||||
|
||||
function ListMenu({ alert, openMenu }: { alert: Alert; openMenu: React.MouseEventHandler<HTMLElement> }) {
|
||||
const forwardedRef = useRef<HTMLSpanElement>();
|
||||
|
||||
return (
|
||||
<Tag
|
||||
forwardedRef={forwardedRef}
|
||||
className={cx('incident__tag')}
|
||||
color={getIncidentTagColor(alert)}
|
||||
onClick={() => {
|
||||
const boundingRect = forwardedRef.current.getBoundingClientRect();
|
||||
const LEFT_MARGIN = 8;
|
||||
openMenu({ pageX: boundingRect.left + LEFT_MARGIN, pageY: boundingRect.top + boundingRect.height } as any);
|
||||
}}
|
||||
>
|
||||
<Text strong size="small">
|
||||
{IncidentStatus[alert.status]}
|
||||
</Text>
|
||||
<Icon className={cx('incident__icon')} name="angle-down" size="sm" />
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
export const IncidentDropdown: FC<{
|
||||
alert: Alert;
|
||||
onResolve: (e: SyntheticEvent) => Promise<void>;
|
||||
onUnacknowledge: (e: SyntheticEvent) => Promise<void>;
|
||||
onUnresolve: (e: SyntheticEvent) => Promise<void>;
|
||||
onAcknowledge: (e: SyntheticEvent) => Promise<void>;
|
||||
onSilence: (value: number) => Promise<void>;
|
||||
onUnsilence: (event: React.SyntheticEvent) => Promise<void>;
|
||||
}> = ({ alert, onResolve, onUnacknowledge, onUnresolve, onAcknowledge, onSilence, onUnsilence }) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentLoadingAction, setCurrentActionLoading] = useState<IncidentStatus>(undefined);
|
||||
const [forcedOpenAction, setForcedOpenAction] = useState<string>(undefined);
|
||||
|
||||
const onClickFn = (
|
||||
ev: React.SyntheticEvent<HTMLDivElement>,
|
||||
actionName: string,
|
||||
action: (value: SyntheticEvent | number) => Promise<void>,
|
||||
status: IncidentStatus
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
setCurrentActionLoading(status);
|
||||
|
||||
// set them to forcedOpen so that they do not close
|
||||
setForcedOpenAction(actionName);
|
||||
|
||||
action(ev)
|
||||
.then(() => {
|
||||
// network request is done and succesful, close them
|
||||
setForcedOpenAction(undefined);
|
||||
})
|
||||
.finally(() => {
|
||||
// hide loading/disabled state
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
if (alert.status === IncidentStatus.Resolved) {
|
||||
return (
|
||||
<WithContextMenu
|
||||
forceIsOpen={forcedOpenAction === AlertAction.Resolve}
|
||||
renderMenuItems={() => (
|
||||
<div className={cx('incident__options', { 'u-disabled': isLoading })}>
|
||||
<WithPermissionControl userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx('incident__option-item', 'incident__option-item--firing')}
|
||||
onClick={(e) => onClickFn(e, AlertAction.Resolve, onUnresolve, IncidentStatus.Firing)}
|
||||
>
|
||||
Firing{' '}
|
||||
{currentLoadingAction === IncidentStatus.Firing && isLoading && (
|
||||
<span className={cx('incident__option-span')}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</WithPermissionControl>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{({ openMenu }) => <ListMenu alert={alert} openMenu={openMenu} />}
|
||||
</WithContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
if (alert.status === IncidentStatus.Acknowledged) {
|
||||
return (
|
||||
<WithContextMenu
|
||||
forceIsOpen={forcedOpenAction === AlertAction.Acknowledge}
|
||||
renderMenuItems={() => (
|
||||
<div className={cx('incident__options', { 'u-disabled': isLoading })}>
|
||||
<WithPermissionControl userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx('incident__option-item', 'incident__option-item--unacknowledge')}
|
||||
onClick={(e) => onClickFn(e, AlertAction.Acknowledge, onUnacknowledge, IncidentStatus.Firing)}
|
||||
>
|
||||
Unacknowledge{' '}
|
||||
{currentLoadingAction === IncidentStatus.Firing && isLoading && (
|
||||
<span className={cx('incident__option-span')}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</WithPermissionControl>
|
||||
<WithPermissionControl userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx('incident__option-item', 'incident__option-item--resolve')}
|
||||
onClick={(e) => onClickFn(e, AlertAction.Acknowledge, onResolve, IncidentStatus.Resolved)}
|
||||
>
|
||||
Resolve{' '}
|
||||
{currentLoadingAction === IncidentStatus.Resolved && isLoading && (
|
||||
<span className={cx('incident__option-span')}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</WithPermissionControl>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{({ openMenu }) => <ListMenu alert={alert} openMenu={openMenu} />}
|
||||
</WithContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
if (alert.status === IncidentStatus.Firing) {
|
||||
return (
|
||||
<WithContextMenu
|
||||
forceIsOpen={forcedOpenAction === AlertAction.unResolve}
|
||||
renderMenuItems={() => (
|
||||
<div className={cx('incident__options', { 'u-disabled': isLoading })}>
|
||||
<WithPermissionControl userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx('incident__option-item', 'incident__option-item--acknowledge')}
|
||||
onClick={(e) => onClickFn(e, AlertAction.unResolve, onAcknowledge, IncidentStatus.Acknowledged)}
|
||||
>
|
||||
Acknowledge{' '}
|
||||
{currentLoadingAction === IncidentStatus.Acknowledged && isLoading && (
|
||||
<span className={cx('incident__option-span')}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</WithPermissionControl>
|
||||
<WithPermissionControl userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx('incident__option-item', 'incident__option-item--resolve')}
|
||||
onClick={(e) => onClickFn(e, AlertAction.unResolve, onResolve, IncidentStatus.Resolved)}
|
||||
>
|
||||
Resolve{' '}
|
||||
{currentLoadingAction === IncidentStatus.Resolved && isLoading && (
|
||||
<span className={cx('incident__option-span')}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</WithPermissionControl>
|
||||
|
||||
<div className={cx('incident__option-item')}>
|
||||
<SilenceSelect
|
||||
placeholder={
|
||||
currentLoadingAction === IncidentStatus.Silenced && isLoading ? 'Loading...' : 'Silence for'
|
||||
}
|
||||
onSelect={(value) => {
|
||||
setIsLoading(true);
|
||||
setForcedOpenAction(AlertAction.unResolve);
|
||||
setCurrentActionLoading(IncidentStatus.Silenced);
|
||||
onSilence(value).finally(() => {
|
||||
setIsLoading(false);
|
||||
setForcedOpenAction(undefined);
|
||||
setCurrentActionLoading(undefined);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{({ openMenu }) => <ListMenu alert={alert} openMenu={openMenu} />}
|
||||
</WithContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// Silenced Alerts
|
||||
return (
|
||||
<WithContextMenu
|
||||
forceIsOpen={forcedOpenAction === AlertAction.Silence}
|
||||
renderMenuItems={() => (
|
||||
<div className={cx('incident_options', { 'u-disabled': isLoading })}>
|
||||
<WithPermissionControl userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx('incident__option-item')}
|
||||
onClick={(e) => onClickFn(e, AlertAction.Silence, onUnsilence, IncidentStatus.Firing)}
|
||||
>
|
||||
Unsilence{' '}
|
||||
{currentLoadingAction === IncidentStatus.Firing && isLoading && (
|
||||
<span className={cx('incident__option-span')}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</WithPermissionControl>
|
||||
<WithPermissionControl userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx('incident__option-item', 'incident__option-item--acknowledge')}
|
||||
onClick={(e) => onClickFn(e, AlertAction.Silence, onAcknowledge, IncidentStatus.Acknowledged)}
|
||||
>
|
||||
Acknowledge{' '}
|
||||
{currentLoadingAction === IncidentStatus.Acknowledged && isLoading && (
|
||||
<span className={cx('incident__option-span')}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</WithPermissionControl>
|
||||
<WithPermissionControl userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx('incident__option-item', 'incident__option-item--resolve')}
|
||||
onClick={(e) => onClickFn(e, AlertAction.Silence, onAcknowledge, IncidentStatus.Resolved)}
|
||||
>
|
||||
Resolve{' '}
|
||||
{currentLoadingAction === IncidentStatus.Resolved && isLoading && (
|
||||
<span className={cx('incident__option-span')}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</WithPermissionControl>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{({ openMenu }) => <ListMenu alert={alert} openMenu={openMenu} />}
|
||||
</WithContextMenu>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { ButtonCascader, ComponentSize } from '@grafana/ui';
|
||||
import { observer } from 'mobx-react';
|
||||
|
|
@ -8,41 +8,28 @@ import { SelectOption } from 'state/types';
|
|||
import { useStore } from 'state/useStore';
|
||||
import { UserActions } from 'utils/authorization';
|
||||
|
||||
interface SilenceDropdownProps {
|
||||
onSelect: (value: number) => void;
|
||||
interface SilenceButtonCascaderProps {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
buttonSize?: string;
|
||||
|
||||
onSelect: (value: number) => void;
|
||||
}
|
||||
|
||||
const SilenceDropdown = observer((props: SilenceDropdownProps) => {
|
||||
export const SilenceButtonCascader = observer((props: SilenceButtonCascaderProps) => {
|
||||
const { onSelect, className, disabled = false, buttonSize } = props;
|
||||
|
||||
const onSelectCallback = useCallback(
|
||||
([value]) => {
|
||||
onSelect(Number(value));
|
||||
},
|
||||
[onSelect]
|
||||
);
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const { alertGroupStore } = store;
|
||||
const { alertGroupStore } = useStore();
|
||||
|
||||
const silenceOptions = alertGroupStore.silenceOptions || [];
|
||||
|
||||
return (
|
||||
<WithPermissionControl key="silence" userAction={UserActions.AlertGroupsWrite}>
|
||||
<ButtonCascader
|
||||
// @ts-ignore
|
||||
variant="secondary"
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
onChange={onSelectCallback}
|
||||
options={silenceOptions.map((silenceOption: SelectOption) => ({
|
||||
value: silenceOption.value,
|
||||
label: silenceOption.display_name,
|
||||
}))}
|
||||
onChange={(value) => onSelect(Number(value))}
|
||||
options={getOptions()}
|
||||
value={undefined}
|
||||
buttonProps={{ size: buttonSize as ComponentSize }}
|
||||
>
|
||||
|
|
@ -50,6 +37,11 @@ const SilenceDropdown = observer((props: SilenceDropdownProps) => {
|
|||
</ButtonCascader>
|
||||
</WithPermissionControl>
|
||||
);
|
||||
});
|
||||
|
||||
export default SilenceDropdown;
|
||||
function getOptions() {
|
||||
return silenceOptions.map((silenceOption: SelectOption) => ({
|
||||
value: silenceOption.value,
|
||||
label: silenceOption.display_name,
|
||||
}));
|
||||
}
|
||||
});
|
||||
44
grafana-plugin/src/pages/incidents/parts/SilenceSelect.tsx
Normal file
44
grafana-plugin/src/pages/incidents/parts/SilenceSelect.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Select } from '@grafana/ui';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { SelectOption } from 'state/types';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { UserActions } from 'utils/authorization';
|
||||
|
||||
interface SilenceSelectProps {
|
||||
placeholder?: string;
|
||||
|
||||
onSelect: (value: number) => void;
|
||||
}
|
||||
|
||||
export const SilenceSelect = observer((props: SilenceSelectProps) => {
|
||||
const { placeholder = 'Silence for', onSelect } = props;
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const { alertGroupStore } = store;
|
||||
|
||||
const silenceOptions = alertGroupStore.silenceOptions || [];
|
||||
|
||||
return (
|
||||
<WithPermissionControl key="silence" userAction={UserActions.AlertGroupsWrite}>
|
||||
<Select
|
||||
menuShouldPortal
|
||||
placeholder={placeholder}
|
||||
value={undefined}
|
||||
onChange={({ value }) => onSelect(Number(value))}
|
||||
options={getOptions()}
|
||||
/>
|
||||
</WithPermissionControl>
|
||||
);
|
||||
|
||||
function getOptions() {
|
||||
return silenceOptions.map((silenceOption: SelectOption) => ({
|
||||
value: silenceOption.value,
|
||||
label: silenceOption.display_name,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
|
@ -1,7 +1,4 @@
|
|||
.root {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
|
||||
--rotations-border: var(--border-weak);
|
||||
--rotations-background: var(--background-secondary);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,3 +11,26 @@
|
|||
.root .buttons {
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.schedules__filters-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 4px;
|
||||
column-gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.schedules__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-grow: 1;
|
||||
gap: 8px;
|
||||
padding-top: 19px;
|
||||
}
|
||||
|
||||
.schedules__user-on-call {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,13 @@ import { observer } from 'mobx-react';
|
|||
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
||||
|
||||
import Avatar from 'components/Avatar/Avatar';
|
||||
import { MatchMediaTooltip } from 'components/MatchMediaTooltip/MatchMediaTooltip';
|
||||
import NewScheduleSelector from 'components/NewScheduleSelector/NewScheduleSelector';
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import ScheduleCounter from 'components/ScheduleCounter/ScheduleCounter';
|
||||
import ScheduleWarning from 'components/ScheduleWarning/ScheduleWarning';
|
||||
import SchedulesFilters from 'components/SchedulesFilters_NEW/SchedulesFilters';
|
||||
import { SchedulesFiltersType } from 'components/SchedulesFilters_NEW/SchedulesFilters.types';
|
||||
import SchedulesFilters from 'components/SchedulesFilters/SchedulesFilters';
|
||||
import { SchedulesFiltersType } from 'components/SchedulesFilters/SchedulesFilters.types';
|
||||
import Table from 'components/Table/Table';
|
||||
import Text from 'components/Text/Text';
|
||||
import TimelineMarks from 'components/TimelineMarks/TimelineMarks';
|
||||
|
|
@ -29,7 +30,7 @@ import { getStartOfWeek } from 'pages/schedule/Schedule.helpers';
|
|||
import { WithStoreProps } from 'state/types';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
import { UserActions } from 'utils/authorization';
|
||||
import { PLUGIN_ROOT } from 'utils/consts';
|
||||
import { PLUGIN_ROOT, TABLE_COLUMN_MAX_WIDTH } from 'utils/consts';
|
||||
|
||||
import styles from './Schedules.module.css';
|
||||
|
||||
|
|
@ -137,9 +138,9 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
|
|||
<>
|
||||
<div className={cx('root')}>
|
||||
<VerticalGroup>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<div className={cx('schedules__filters-container')}>
|
||||
<SchedulesFilters value={filters} onChange={this.handleSchedulesFiltersChange} />
|
||||
<HorizontalGroup spacing="lg">
|
||||
<div className={cx('schedules__actions')}>
|
||||
{users && (
|
||||
<UserTimezoneSelect
|
||||
value={store.currentTimezone}
|
||||
|
|
@ -152,8 +153,8 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
|
|||
+ New schedule
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
data={data}
|
||||
|
|
@ -330,18 +331,24 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
|
|||
renderOncallNow = (item: Schedule, _index: number) => {
|
||||
if (item.on_call_now?.length > 0) {
|
||||
return (
|
||||
<VerticalGroup>
|
||||
{item.on_call_now.map((user, _index) => {
|
||||
return (
|
||||
<PluginLink key={user.pk} query={{ page: 'users', id: user.pk }}>
|
||||
<div>
|
||||
<Avatar size="big" src={user.avatar} />
|
||||
<Text type="secondary"> {user.username}</Text>
|
||||
</div>
|
||||
</PluginLink>
|
||||
);
|
||||
})}
|
||||
</VerticalGroup>
|
||||
<div className="table__email-column">
|
||||
<VerticalGroup>
|
||||
{item.on_call_now.map((user) => {
|
||||
return (
|
||||
<PluginLink key={user.pk} query={{ page: 'users', id: user.pk }} className="table__email-content">
|
||||
<div className={cx('schedules__user-on-call')}>
|
||||
<div>
|
||||
<Avatar size="big" src={user.avatar} />
|
||||
</div>
|
||||
<MatchMediaTooltip placement="top" content={user.username} maxWidth={TABLE_COLUMN_MAX_WIDTH}>
|
||||
<span className="table__email-content">{user.username}</span>
|
||||
</MatchMediaTooltip>
|
||||
</div>
|
||||
</PluginLink>
|
||||
);
|
||||
})}
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -43,3 +43,8 @@
|
|||
.page-title {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.rc-table-cell {
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
|
|
|||
23
grafana-plugin/src/style/responsive.css
Normal file
23
grafana-plugin/src/style/responsive.css
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
Make sure if you chage max-width here
|
||||
You also change it in consts.ts
|
||||
*/
|
||||
@media screen and (max-width: 1500px) {
|
||||
.table__email-column {
|
||||
max-width: 175px;
|
||||
}
|
||||
|
||||
.table__email-content {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.incident__title-column {
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.table__wrap-column {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
|
@ -26,6 +26,10 @@
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
.u-display-block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.u-flex {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
|
@ -43,3 +47,9 @@
|
|||
.u-align-items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.u-disabled {
|
||||
opacity: var(--opacity);
|
||||
cursor: not-allowed !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@
|
|||
--gradient-brandVertical: linear-gradient(0.01deg, #f53e4c -31.2%, #f83 113.07%);
|
||||
--always-gray: #ccccdc;
|
||||
--title-marginBottom: 16px;
|
||||
--opacity: 0.5;
|
||||
|
||||
/* These seem to slightly differ from warning/success/error colors from below */
|
||||
--tag-danger: #e02f44;
|
||||
--tag-warning: #c69b06;
|
||||
--tag-primary: #299c46;
|
||||
--tag-secondary: #464c54;
|
||||
}
|
||||
|
||||
.theme-light {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,5 @@ export const FARO_ENDPOINT_PROD =
|
|||
export const DOCS_SLACK_SETUP = 'https://grafana.com/docs/grafana-cloud/oncall/open-source/#slack-setup';
|
||||
export const DOCS_TELEGRAM_SETUP = 'https://grafana.com/docs/grafana-cloud/oncall/chat-options/configure-telegram/';
|
||||
|
||||
export const COLOR_DANGER = '#E02F44';
|
||||
export const COLOR_WARNING = '#C69B06';
|
||||
export const COLOR_PRIMARY = '#299C46';
|
||||
export const COLOR_SECONDARY = '#464C54';
|
||||
// Make sure if you chage max-width here you also change it in responsive.css
|
||||
export const TABLE_COLUMN_MAX_WIDTH = 1500;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue