add on-call to entity card

Signed-off-by: yacut <roman.rogozhnikov@gmail.com>
This commit is contained in:
yacut
2021-04-16 22:10:34 +02:00
parent 687d973c6b
commit 78589718b8
9 changed files with 478 additions and 12 deletions
+38 -5
View File
@@ -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() {
+4 -1
View File
@@ -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;
};
+9
View File
@@ -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;
}