Merge pull request #4557 from grantila/improved-pagerduty

Improve pagerduty plugin
This commit is contained in:
Ben Lambert
2021-02-17 15:36:07 +01:00
committed by GitHub
10 changed files with 185 additions and 94 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-pagerduty': minor
---
Improved the UI of the pagerduty plugin, and added a standalone TriggerButton
@@ -39,7 +39,7 @@ import {
EntityLinksCard,
EntityPageLayout,
} from '@backstage/plugin-catalog';
import { useEntity } from '@backstage/plugin-catalog-react';
import { EntityProvider, useEntity } from '@backstage/plugin-catalog-react';
import {
isPluginApplicableToEntity as isCircleCIAvailable,
Router as CircleCIRouter,
@@ -184,7 +184,9 @@ const ComponentOverviewContent = ({ entity }: { entity: Entity }) => (
</Grid>
{isPagerDutyAvailable(entity) && (
<Grid item md={6}>
<PagerDutyCard entity={entity} />
<EntityProvider entity={entity}>
<PagerDutyCard />
</EntityProvider>
</Grid>
)}
<Grid item md={4} sm={6}>
+1
View File
@@ -37,6 +37,7 @@
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/react": "^16.9",
"classnames": "^2.2.6",
"luxon": "1.25.0",
"react": "^16.13.1",
@@ -17,7 +17,6 @@
import React from 'react';
import {
ListItem,
ListItemIcon,
ListItemSecondaryAction,
Tooltip,
ListItemText,
@@ -25,13 +24,16 @@ import {
IconButton,
Link,
Typography,
Chip,
} from '@material-ui/core';
import { StatusError, StatusWarning } from '@backstage/core';
import Done from '@material-ui/icons/Done';
import Warning from '@material-ui/icons/Warning';
import { DateTime, Duration } from 'luxon';
import { Incident } from '../types';
import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser';
import { BackstageTheme } from '@backstage/theme';
const useStyles = makeStyles({
const useStyles = makeStyles<BackstageTheme>(theme => ({
denseListIcon: {
marginRight: 0,
display: 'flex',
@@ -42,10 +44,21 @@ const useStyles = makeStyles({
listItemPrimary: {
fontWeight: 'bold',
},
listItemIcon: {
minWidth: '1em',
warning: {
borderColor: theme.palette.status.warning,
color: theme.palette.status.warning,
'& *': {
color: theme.palette.status.warning,
},
},
});
error: {
borderColor: theme.palette.status.error,
color: theme.palette.status.error,
'& *': {
color: theme.palette.status.error,
},
},
}));
type Props = {
incident: Incident;
@@ -62,19 +75,24 @@ export const IncidentListItem = ({ incident }: Props) => {
return (
<ListItem dense key={incident.id}>
<ListItemIcon className={classes.listItemIcon}>
<Tooltip title={incident.status} placement="top">
<div className={classes.denseListIcon}>
{incident.status === 'triggered' ? (
<StatusError />
) : (
<StatusWarning />
)}
</div>
</Tooltip>
</ListItemIcon>
<ListItemText
primary={incident.title}
primary={
<>
<Chip
data-testid={`chip-${incident.status}`}
label={incident.status}
size="small"
variant="outlined"
icon={incident.status === 'acknowledged' ? <Done /> : <Warning />}
className={
incident.status === 'triggered'
? classes.error
: classes.warning
}
/>
{incident.title}
</>
}
primaryTypographyProps={{
variant: 'body1',
className: classes.listItemPrimary,
@@ -84,13 +84,7 @@ describe('Incidents', () => {
},
] as Incident[],
);
const {
getByText,
getByTitle,
getAllByTitle,
getByLabelText,
queryByTestId,
} = render(
const { getByText, getAllByTitle, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<Incidents serviceId="abc" refreshIncidents={false} />
@@ -102,10 +96,10 @@ describe('Incidents', () => {
expect(getByText('title2')).toBeInTheDocument();
expect(getByText('person1')).toBeInTheDocument();
expect(getByText('person2')).toBeInTheDocument();
expect(getByTitle('triggered')).toBeInTheDocument();
expect(getByTitle('acknowledged')).toBeInTheDocument();
expect(getByLabelText('Status error')).toBeInTheDocument();
expect(getByLabelText('Status warning')).toBeInTheDocument();
expect(getByText('triggered')).toBeInTheDocument();
expect(getByText('acknowledged')).toBeInTheDocument();
expect(queryByTestId('chip-triggered')).toBeInTheDocument();
expect(queryByTestId('chip-acknowledged')).toBeInTheDocument();
// assert links, mailto and hrefs, date calculation
expect(getAllByTitle('View in PagerDuty').length).toEqual(2);
@@ -17,68 +17,38 @@ import React, { useState, useCallback } from 'react';
import { useApi, Progress, HeaderIconLinkRow } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { useEntity } from '@backstage/plugin-catalog-react';
import {
Button,
makeStyles,
Card,
CardHeader,
Divider,
CardContent,
} from '@material-ui/core';
import { Card, CardHeader, Divider, CardContent } from '@material-ui/core';
import { Incidents } from './Incident';
import { EscalationPolicy } from './Escalation';
import { useAsync } from 'react-use';
import { Alert } from '@material-ui/lab';
import { pagerDutyApiRef, UnauthorizedError } from '../api';
import AlarmAddIcon from '@material-ui/icons/AlarmAdd';
import { TriggerDialog } from './TriggerDialog';
import { MissingTokenError } from './Errors/MissingTokenError';
import WebIcon from '@material-ui/icons/Web';
const useStyles = makeStyles({
triggerAlarm: {
paddingTop: 0,
paddingBottom: 0,
fontSize: '0.7rem',
textTransform: 'uppercase',
fontWeight: 600,
letterSpacing: 1.2,
lineHeight: 1.5,
'&:hover, &:focus, &.focus': {
backgroundColor: 'transparent',
textDecoration: 'none',
},
},
});
export const PAGERDUTY_INTEGRATION_KEY = 'pagerduty.com/integration-key';
import { PAGERDUTY_INTEGRATION_KEY } from './constants';
import { TriggerButton, useShowDialog } from './TriggerButton';
export const isPluginApplicableToEntity = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[PAGERDUTY_INTEGRATION_KEY]);
type Props = {
/** @deprecated The entity is now grabbed from context instead */
entity?: Entity;
};
export const PagerDutyCard = (_props: Props) => {
const classes = useStyles();
export const PagerDutyCard = () => {
const { entity } = useEntity();
const api = useApi(pagerDutyApiRef);
const [showDialog, setShowDialog] = useState<boolean>(false);
const [refreshIncidents, setRefreshIncidents] = useState<boolean>(false);
const integrationKey = entity.metadata.annotations![
PAGERDUTY_INTEGRATION_KEY
];
const setShowDialog = useShowDialog()[1];
const showDialog = useCallback(() => {
setShowDialog(true);
}, [setShowDialog]);
const handleRefresh = useCallback(() => {
setRefreshIncidents(x => !x);
}, []);
const handleDialog = useCallback(() => {
setShowDialog(x => !x);
}, []);
const { value: service, loading, error } = useAsync(async () => {
const services = await api.getServiceByIntegrationKey(integrationKey);
@@ -114,17 +84,8 @@ export const PagerDutyCard = (_props: Props) => {
const triggerLink = {
label: 'Create Incident',
action: (
<Button
data-testid="trigger-button"
color="secondary"
onClick={handleDialog}
className={classes.triggerAlarm}
>
Create Incident
</Button>
),
icon: <AlarmAddIcon onClick={handleDialog} />,
action: <TriggerButton design="link" onIncidentCreated={handleRefresh} />,
icon: <AlarmAddIcon onClick={showDialog} />,
};
return (
@@ -140,13 +101,6 @@ export const PagerDutyCard = (_props: Props) => {
refreshIncidents={refreshIncidents}
/>
<EscalationPolicy policyId={service!.policyId} />
<TriggerDialog
showDialog={showDialog}
handleDialog={handleDialog}
name={entity.metadata.name}
integrationKey={integrationKey}
onIncidentCreated={handleRefresh}
/>
</CardContent>
</Card>
);
@@ -0,0 +1,94 @@
/*
* 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, { useCallback, PropsWithChildren } from 'react';
import { createGlobalState } from 'react-use';
import { makeStyles, Button } from '@material-ui/core';
import { useEntity } from '@backstage/plugin-catalog-react';
import { BackstageTheme } from '@backstage/theme';
import { TriggerDialog } from '../TriggerDialog';
import { PAGERDUTY_INTEGRATION_KEY } from '../constants';
export interface TriggerButtonProps {
design: 'link' | 'button';
onIncidentCreated?: () => void;
}
const useStyles = makeStyles<BackstageTheme>(theme => ({
buttonStyle: {
backgroundColor: theme.palette.error.main,
color: theme.palette.error.contrastText,
'&:hover': {
backgroundColor: theme.palette.error.dark,
},
},
triggerAlarm: {
paddingTop: 0,
paddingBottom: 0,
fontSize: '0.7rem',
textTransform: 'uppercase',
fontWeight: 600,
letterSpacing: 1.2,
lineHeight: 1.5,
'&:hover, &:focus, &.focus': {
backgroundColor: 'transparent',
textDecoration: 'none',
},
},
}));
export const useShowDialog = createGlobalState(false);
export function TriggerButton({
design,
onIncidentCreated,
children,
}: PropsWithChildren<TriggerButtonProps>) {
const { buttonStyle, triggerAlarm } = useStyles();
const { entity } = useEntity();
const [dialogShown = false, setDialogShown] = useShowDialog();
const showDialog = useCallback(() => {
setDialogShown(true);
}, [setDialogShown]);
const hideDialog = useCallback(() => {
setDialogShown(false);
}, [setDialogShown]);
const integrationKey = entity.metadata.annotations![
PAGERDUTY_INTEGRATION_KEY
];
return (
<>
<Button
data-testid="trigger-button"
{...(design === 'link' && { color: 'secondary' })}
onClick={showDialog}
className={design === 'link' ? triggerAlarm : buttonStyle}
>
{children ?? 'Create Incident'}
</Button>
<TriggerDialog
showDialog={dialogShown}
handleDialog={hideDialog}
name={entity.metadata.name}
integrationKey={integrationKey}
onIncidentCreated={onIncidentCreated}
/>
</>
);
}
@@ -35,7 +35,7 @@ type Props = {
integrationKey: string;
showDialog: boolean;
handleDialog: () => void;
onIncidentCreated: () => void;
onIncidentCreated?: () => void;
};
export const TriggerDialog = ({
@@ -69,11 +69,17 @@ export const TriggerDialog = ({
useEffect(() => {
if (value) {
alertApi.post({
message: `Alarm successfully triggered by ${userName}`,
});
onIncidentCreated();
handleDialog();
(async () => {
alertApi.post({
message: `Alarm successfully triggered by ${userName}`,
});
handleDialog();
// The pager duty API isn't always returning the newly created alarm immediately
await new Promise(resolve => setTimeout(resolve, 1000));
onIncidentCreated?.();
})();
}
}, [value, alertApi, handleDialog, userName, onIncidentCreated]);
@@ -0,0 +1,16 @@
/*
* 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.
*/
export const PAGERDUTY_INTEGRATION_KEY = 'pagerduty.com/integration-key';
+1
View File
@@ -23,6 +23,7 @@ export {
isPluginApplicableToEntity as isPagerDutyAvailable,
PagerDutyCard,
} from './components/PagerDutyCard';
export { TriggerButton } from './components/TriggerButton';
export {
PagerDutyClient,
pagerDutyApiRef,