add on-call to entity card
Signed-off-by: yacut <roman.rogozhnikov@gmail.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
Incident,
|
||||
IncidentAction,
|
||||
IncidentResponder,
|
||||
OnCall,
|
||||
Schedule,
|
||||
UptimeMonitor,
|
||||
User,
|
||||
@@ -388,6 +389,24 @@ export class ILertClient implements ILertApi {
|
||||
return response;
|
||||
}
|
||||
|
||||
async fetchAlertSourceOnCalls(alertSource: AlertSource): Promise<OnCall[]> {
|
||||
const init = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
const response = await this.fetch(
|
||||
`/api/v1/on-calls?policies=${
|
||||
alertSource.escalationPolicy.id
|
||||
}&expand=user&expand=escalationPolicy&timezone=${momentTimezone.tz.guess()}`,
|
||||
init,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async enableAlertSource(alertSource: AlertSource): Promise<AlertSource> {
|
||||
const init = {
|
||||
method: 'PUT',
|
||||
@@ -521,14 +540,28 @@ export class ILertClient implements ILertApi {
|
||||
return `${this.baseUrl}/schedule/view.jsf?id=${schedule.id}`;
|
||||
}
|
||||
|
||||
getUserInitials(assignedTo: User | null) {
|
||||
if (!assignedTo) {
|
||||
getUserPhoneNumber(user: User | null) {
|
||||
if (!user) {
|
||||
return '';
|
||||
}
|
||||
if (!assignedTo.firstName && !assignedTo.lastName) {
|
||||
return assignedTo.username;
|
||||
if (user.mobile) {
|
||||
return user.mobile.number;
|
||||
}
|
||||
return `${assignedTo.firstName} ${assignedTo.lastName} (${assignedTo.username})`;
|
||||
|
||||
if (user.landline) {
|
||||
return user.landline.number;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
getUserInitials(user: User | null) {
|
||||
if (!user) {
|
||||
return '';
|
||||
}
|
||||
if (!user.firstName && !user.lastName) {
|
||||
return user.username;
|
||||
}
|
||||
return `${user.firstName} ${user.lastName} (${user.username})`;
|
||||
}
|
||||
|
||||
private async apiUrl() {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Schedule,
|
||||
IncidentResponder,
|
||||
IncidentAction,
|
||||
OnCall,
|
||||
} from '../types';
|
||||
|
||||
export type TableState = {
|
||||
@@ -74,6 +75,7 @@ export interface ILertApi {
|
||||
|
||||
fetchAlertSources(): Promise<AlertSource[]>;
|
||||
fetchAlertSource(idOrIntegrationKey: number | string): Promise<AlertSource>;
|
||||
fetchAlertSourceOnCalls(alertSource: AlertSource): Promise<OnCall[]>;
|
||||
enableAlertSource(alertSource: AlertSource): Promise<AlertSource>;
|
||||
disableAlertSource(alertSource: AlertSource): Promise<AlertSource>;
|
||||
|
||||
@@ -97,7 +99,8 @@ export interface ILertApi {
|
||||
getEscalationPolicyDetailsURL(escalationPolicy: EscalationPolicy): string;
|
||||
getUptimeMonitorDetailsURL(uptimeMonitor: UptimeMonitor): string;
|
||||
getScheduleDetailsURL(schedule: Schedule): string;
|
||||
getUserInitials(assignedTo: User | null): string;
|
||||
getUserPhoneNumber(user: User | null): string;
|
||||
getUserInitials(user: User | null): string;
|
||||
}
|
||||
|
||||
export type Options = {
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useILertEntity } from '../../hooks';
|
||||
import { ILertCardHeaderStatus } from './ILertCardHeaderStatus';
|
||||
import { ILertCardMaintenanceModal } from './ILertCardMaintenanceModal';
|
||||
import { ILertCardEmptyState } from './ILertCardEmptyState';
|
||||
import { ILertCardOnCall } from './ILertCardOnCall';
|
||||
|
||||
export const isPluginApplicableToEntity = (entity: Entity) =>
|
||||
Boolean(entity.metadata.annotations?.[ILERT_INTEGRATION_KEY]);
|
||||
@@ -111,6 +112,7 @@ export const ILertCard = () => {
|
||||
/>
|
||||
<Divider />
|
||||
<CardContent className={classes.content}>
|
||||
<ILertCardOnCall alertSource={alertSource} />
|
||||
<IncidentsTable
|
||||
incidents={incidents}
|
||||
incidentsCount={incidentsCount}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import List from '@material-ui/core/List';
|
||||
import ListItem from '@material-ui/core/ListItem';
|
||||
import ListItemIcon from '@material-ui/core/ListItemIcon';
|
||||
import ListItemText from '@material-ui/core/ListItemText';
|
||||
import ListSubheader from '@material-ui/core/ListSubheader';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import ReplayIcon from '@material-ui/icons/Replay';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { AlertSource } from '../../types';
|
||||
import { useAlertSourceOnCalls } from '../../hooks/useAlertSourceOnCalls';
|
||||
import { Progress } from '@backstage/core';
|
||||
import { ILertCardOnCallEmptyState } from './ILertCardOnCallEmptyState';
|
||||
import { ILertCardOnCallItem } from './ILertCardOnCallItem';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
repeatText: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
repeatIcon: {
|
||||
marginLeft: theme.spacing(1),
|
||||
},
|
||||
}));
|
||||
|
||||
export const ILertCardOnCall = ({
|
||||
alertSource,
|
||||
}: {
|
||||
alertSource: AlertSource | null;
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
const [{ onCalls, isLoading }, {}] = useAlertSourceOnCalls(alertSource);
|
||||
|
||||
if (isLoading) {
|
||||
return <Progress />;
|
||||
}
|
||||
|
||||
if (!alertSource || !onCalls) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const repeatInfo = () => {
|
||||
if (
|
||||
!onCalls ||
|
||||
!onCalls.length ||
|
||||
!onCalls[onCalls.length - 1].escalationPolicy ||
|
||||
!onCalls[onCalls.length - 1].escalationPolicy.repeating ||
|
||||
!onCalls[onCalls.length - 1].escalationPolicy.frequency
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem key="repeat">
|
||||
<ListItemIcon>
|
||||
<ReplayIcon className={classes.repeatIcon} fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="textSecondary"
|
||||
className={classes.repeatText}
|
||||
>
|
||||
{`Repeat ${
|
||||
onCalls[onCalls.length - 1].escalationPolicy.frequency
|
||||
} times`}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
if (!onCalls.length) {
|
||||
return (
|
||||
<List dense subheader={<ListSubheader>ON CALL</ListSubheader>}>
|
||||
<ILertCardOnCallEmptyState />
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
if (onCalls.length === 1) {
|
||||
<List dense subheader={<ListSubheader>ON CALL</ListSubheader>}>
|
||||
<ILertCardOnCallItem
|
||||
onCall={onCalls[0]}
|
||||
fistItem={false}
|
||||
lastItem={false}
|
||||
/>
|
||||
</List>;
|
||||
}
|
||||
|
||||
return (
|
||||
<List dense subheader={<ListSubheader>ON CALL</ListSubheader>}>
|
||||
{onCalls.map((onCall, index) => (
|
||||
<ILertCardOnCallItem
|
||||
key={index}
|
||||
onCall={onCall}
|
||||
fistItem={index === 0}
|
||||
lastItem={index === onCalls.length - 1}
|
||||
/>
|
||||
))}
|
||||
{repeatInfo()}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import ListItem from '@material-ui/core/ListItem';
|
||||
import ListItemText from '@material-ui/core/ListItemText';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
text: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
});
|
||||
|
||||
export const ILertCardOnCallEmptyState = () => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<ListItem>
|
||||
<ListItemText>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
color="textSecondary"
|
||||
className={classes.text}
|
||||
>
|
||||
Nobody
|
||||
</Typography>
|
||||
</ListItemText>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import Divider from '@material-ui/core/Divider';
|
||||
import ListItem from '@material-ui/core/ListItem';
|
||||
import ListItemIcon from '@material-ui/core/ListItemIcon';
|
||||
import Avatar from '@material-ui/core/Avatar';
|
||||
import ListItemText from '@material-ui/core/ListItemText';
|
||||
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction';
|
||||
import Tooltip from '@material-ui/core/Tooltip';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import EmailIcon from '@material-ui/icons/Email';
|
||||
import PhoneIcon from '@material-ui/icons/Phone';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { OnCall } from '../../types';
|
||||
import { useApi } from '@backstage/core';
|
||||
import { ilertApiRef } from '../../api';
|
||||
import moment from 'moment';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
listItemPrimary: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
fistItemLine: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
height: '50%',
|
||||
left: 36,
|
||||
},
|
||||
lastItemLine: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
height: '50%',
|
||||
left: 36,
|
||||
},
|
||||
itemLine: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
height: '100%',
|
||||
left: 36,
|
||||
},
|
||||
});
|
||||
|
||||
export const ILertCardOnCallItem = ({
|
||||
onCall,
|
||||
fistItem = false,
|
||||
lastItem = false,
|
||||
}: {
|
||||
onCall: OnCall;
|
||||
fistItem?: boolean;
|
||||
lastItem?: boolean;
|
||||
}) => {
|
||||
const ilertApi = useApi(ilertApiRef);
|
||||
const classes = useStyles();
|
||||
|
||||
if (!onCall || !onCall.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const phoneNumber = ilertApi.getUserPhoneNumber(onCall.user);
|
||||
const escalationRepeating = onCall.escalationPolicy.repeating;
|
||||
const escalationSeconds =
|
||||
onCall.escalationPolicy.escalationRules[onCall.escalationLevel - 1]
|
||||
.escalationTimeout;
|
||||
const escalationHoursOnly = Math.floor(escalationSeconds / 60);
|
||||
const escalationMinutesOnly = escalationSeconds % 60;
|
||||
|
||||
let escalationText = '';
|
||||
if (!lastItem || (lastItem && escalationRepeating)) {
|
||||
escalationText = 'escalate';
|
||||
if (escalationSeconds === 0) {
|
||||
escalationText += ' immediately';
|
||||
} else {
|
||||
escalationText += ' after';
|
||||
if (escalationHoursOnly > 0) {
|
||||
escalationText += ` ${escalationHoursOnly} ${
|
||||
escalationHoursOnly === 1 ? 'hour' : 'hours'
|
||||
}`;
|
||||
}
|
||||
if (escalationMinutesOnly > 0 || escalationSeconds === 0) {
|
||||
escalationText += ` ${escalationMinutesOnly} ${
|
||||
escalationMinutesOnly === 1 ? 'minute' : 'minutes'
|
||||
}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem>
|
||||
{fistItem ? (
|
||||
<Divider orientation="vertical" className={classes.fistItemLine} />
|
||||
) : null}
|
||||
{lastItem ? (
|
||||
<Divider orientation="vertical" className={classes.lastItemLine} />
|
||||
) : null}
|
||||
{!fistItem && !lastItem ? (
|
||||
<Divider orientation="vertical" className={classes.itemLine} />
|
||||
) : null}
|
||||
<ListItemIcon>
|
||||
<Tooltip
|
||||
title={`Escalation level #${onCall.escalationLevel}`}
|
||||
placement="top"
|
||||
>
|
||||
<Avatar>{onCall.escalationLevel}</Avatar>
|
||||
</Tooltip>
|
||||
</ListItemIcon>
|
||||
{onCall.schedule ? (
|
||||
<Tooltip
|
||||
title={
|
||||
'On call shift ' +
|
||||
`${moment(onCall.start).format('D MMM, HH:mm')} - ` +
|
||||
`${moment(onCall.end).format('D MMM, HH:mm')}`
|
||||
}
|
||||
placement="top-start"
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography className={classes.listItemPrimary}>
|
||||
{ilertApi.getUserInitials(onCall.user)}
|
||||
</Typography>
|
||||
}
|
||||
secondary={escalationText}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography className={classes.listItemPrimary}>
|
||||
{ilertApi.getUserInitials(onCall.user)}
|
||||
</Typography>
|
||||
}
|
||||
secondary={escalationText}
|
||||
/>
|
||||
)}
|
||||
<ListItemSecondaryAction>
|
||||
{phoneNumber ? (
|
||||
<Tooltip title="Call to user" placement="top">
|
||||
<IconButton href={`tel:${phoneNumber}`}>
|
||||
<PhoneIcon color="secondary" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip
|
||||
title={`Send e-mail to user ${onCall.user.email}`}
|
||||
placement="top"
|
||||
>
|
||||
<IconButton href={`mailto:${onCall.user.email}`}>
|
||||
<EmailIcon color="primary" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
@@ -44,9 +44,9 @@ const useStyles = makeStyles(() => ({
|
||||
indicatorNext: {
|
||||
position: 'absolute',
|
||||
top: 'calc(40% - 10px)',
|
||||
left: -8,
|
||||
width: 16,
|
||||
height: 16,
|
||||
left: -6,
|
||||
width: 12,
|
||||
height: 12,
|
||||
background: '#92949c !important',
|
||||
borderRadius: '50%',
|
||||
},
|
||||
@@ -54,11 +54,33 @@ const useStyles = makeStyles(() => ({
|
||||
indicatorCurrent: {
|
||||
position: 'absolute',
|
||||
top: 'calc(40% - 10px)',
|
||||
left: -8,
|
||||
width: 16,
|
||||
height: 16,
|
||||
left: -6,
|
||||
width: 12,
|
||||
height: 12,
|
||||
background: '#ffb74d !important',
|
||||
color: '#ffb74d !important',
|
||||
borderRadius: '50%',
|
||||
'&::after': {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '50%',
|
||||
animation: '$ripple 1.2s infinite ease-in-out',
|
||||
border: '1px solid currentColor',
|
||||
content: '""',
|
||||
},
|
||||
},
|
||||
'@keyframes ripple': {
|
||||
'0%': {
|
||||
transform: 'scale(.8)',
|
||||
opacity: 1,
|
||||
},
|
||||
'100%': {
|
||||
transform: 'scale(2.4)',
|
||||
opacity: 0,
|
||||
},
|
||||
},
|
||||
|
||||
beforeText: {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { ilertApiRef, UnauthorizedError } from '../api';
|
||||
import { useApi, errorApiRef } from '@backstage/core';
|
||||
import { useAsyncRetry } from 'react-use';
|
||||
import { AlertSource, OnCall } from '../types';
|
||||
|
||||
export const useAlertSourceOnCalls = (alertSource?: AlertSource | null) => {
|
||||
const ilertApi = useApi(ilertApiRef);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
|
||||
const [onCallsList, setOnCallsList] = React.useState<OnCall[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
const fetchAlertSourceOnCallsCall = async () => {
|
||||
try {
|
||||
if (!alertSource) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
const data = await ilertApi.fetchAlertSourceOnCalls(alertSource);
|
||||
setOnCallsList(data || []);
|
||||
setIsLoading(false);
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
if (!(e instanceof UnauthorizedError)) {
|
||||
errorApi.post(e);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const { error, retry } = useAsyncRetry(fetchAlertSourceOnCallsCall, [
|
||||
alertSource,
|
||||
]);
|
||||
|
||||
return [
|
||||
{
|
||||
onCalls: onCallsList,
|
||||
error,
|
||||
isLoading,
|
||||
},
|
||||
{
|
||||
retry,
|
||||
setIsLoading,
|
||||
refetchAlertSourceOnCalls: fetchAlertSourceOnCallsCall,
|
||||
},
|
||||
] as const;
|
||||
};
|
||||
@@ -326,3 +326,12 @@ export interface IncidentActionHistory {
|
||||
actor: User;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface OnCall {
|
||||
user: User;
|
||||
escalationPolicy: EscalationPolicy;
|
||||
schedule?: Schedule;
|
||||
start: string;
|
||||
end: string;
|
||||
escalationLevel: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user