From 78589718b823c40aeddab15adb9d18e172abbd81 Mon Sep 17 00:00:00 2001 From: yacut Date: Fri, 16 Apr 2021 22:10:34 +0200 Subject: [PATCH] add on-call to entity card Signed-off-by: yacut --- plugins/ilert/src/api/client.ts | 43 ++++- plugins/ilert/src/api/types.ts | 5 +- .../src/components/ILertCard/ILertCard.tsx | 2 + .../components/ILertCard/ILertCardOnCall.tsx | 120 +++++++++++++ .../ILertCard/ILertCardOnCallEmptyState.tsx | 45 +++++ .../ILertCard/ILertCardOnCallItem.tsx | 169 ++++++++++++++++++ .../OnCallSchedulesGrid.tsx | 34 +++- .../ilert/src/hooks/useAlertSourceOnCalls.ts | 63 +++++++ plugins/ilert/src/types.ts | 9 + 9 files changed, 478 insertions(+), 12 deletions(-) create mode 100644 plugins/ilert/src/components/ILertCard/ILertCardOnCall.tsx create mode 100644 plugins/ilert/src/components/ILertCard/ILertCardOnCallEmptyState.tsx create mode 100644 plugins/ilert/src/components/ILertCard/ILertCardOnCallItem.tsx create mode 100644 plugins/ilert/src/hooks/useAlertSourceOnCalls.ts diff --git a/plugins/ilert/src/api/client.ts b/plugins/ilert/src/api/client.ts index ed79681320..f3c263e9e7 100644 --- a/plugins/ilert/src/api/client.ts +++ b/plugins/ilert/src/api/client.ts @@ -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 { + 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 { 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() { diff --git a/plugins/ilert/src/api/types.ts b/plugins/ilert/src/api/types.ts index b37ae73c93..84b4ecf654 100644 --- a/plugins/ilert/src/api/types.ts +++ b/plugins/ilert/src/api/types.ts @@ -24,6 +24,7 @@ import { Schedule, IncidentResponder, IncidentAction, + OnCall, } from '../types'; export type TableState = { @@ -74,6 +75,7 @@ export interface ILertApi { fetchAlertSources(): Promise; fetchAlertSource(idOrIntegrationKey: number | string): Promise; + fetchAlertSourceOnCalls(alertSource: AlertSource): Promise; enableAlertSource(alertSource: AlertSource): Promise; disableAlertSource(alertSource: AlertSource): Promise; @@ -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 = { diff --git a/plugins/ilert/src/components/ILertCard/ILertCard.tsx b/plugins/ilert/src/components/ILertCard/ILertCard.tsx index 9e2c93d3da..dd4bdde3cf 100644 --- a/plugins/ilert/src/components/ILertCard/ILertCard.tsx +++ b/plugins/ilert/src/components/ILertCard/ILertCard.tsx @@ -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 = () => { /> + ({ + 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 ; + } + + 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 ( + + + + + + {`Repeat ${ + onCalls[onCalls.length - 1].escalationPolicy.frequency + } times`} + + } + /> + + ); + }; + + if (!onCalls.length) { + return ( + ON CALL}> + + + ); + } + + if (onCalls.length === 1) { + ON CALL}> + + ; + } + + return ( + ON CALL}> + {onCalls.map((onCall, index) => ( + + ))} + {repeatInfo()} + + ); +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCardOnCallEmptyState.tsx b/plugins/ilert/src/components/ILertCard/ILertCardOnCallEmptyState.tsx new file mode 100644 index 0000000000..2d43aeccb8 --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCardOnCallEmptyState.tsx @@ -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 ( + + + + Nobody + + + + ); +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCardOnCallItem.tsx b/plugins/ilert/src/components/ILertCard/ILertCardOnCallItem.tsx new file mode 100644 index 0000000000..69ceb0ad55 --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCardOnCallItem.tsx @@ -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 ( + + {fistItem ? ( + + ) : null} + {lastItem ? ( + + ) : null} + {!fistItem && !lastItem ? ( + + ) : null} + + + {onCall.escalationLevel} + + + {onCall.schedule ? ( + + + {ilertApi.getUserInitials(onCall.user)} + + } + secondary={escalationText} + /> + + ) : ( + + {ilertApi.getUserInitials(onCall.user)} + + } + secondary={escalationText} + /> + )} + + {phoneNumber ? ( + + + + + + ) : null} + + + + + + + + ); +}; diff --git a/plugins/ilert/src/components/OnCallSchedulesPage/OnCallSchedulesGrid.tsx b/plugins/ilert/src/components/OnCallSchedulesPage/OnCallSchedulesGrid.tsx index 34601009ff..c84e0a7b8d 100644 --- a/plugins/ilert/src/components/OnCallSchedulesPage/OnCallSchedulesGrid.tsx +++ b/plugins/ilert/src/components/OnCallSchedulesPage/OnCallSchedulesGrid.tsx @@ -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: { diff --git a/plugins/ilert/src/hooks/useAlertSourceOnCalls.ts b/plugins/ilert/src/hooks/useAlertSourceOnCalls.ts new file mode 100644 index 0000000000..ac36c70097 --- /dev/null +++ b/plugins/ilert/src/hooks/useAlertSourceOnCalls.ts @@ -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([]); + 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; +}; diff --git a/plugins/ilert/src/types.ts b/plugins/ilert/src/types.ts index a209b99533..d7746c9d9b 100644 --- a/plugins/ilert/src/types.ts +++ b/plugins/ilert/src/types.ts @@ -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; +}