Add PagerDuty ChangeEvents

Signed-off-by: Douglas Read <read.d@northeastern.edu>
This commit is contained in:
Douglas Read
2021-08-23 10:33:33 -04:00
parent 874419a5cf
commit 3990877f9f
11 changed files with 435 additions and 25 deletions
+3 -3
View File
@@ -1,11 +1,11 @@
# PagerDuty + Backstage Integration Benefits
- Display relevant PagerDuty information about an entity within Backstage, such as the escalation policy or if there are any active incidents
- Display relevant PagerDuty information about an entity within Backstage, such as the escalation policy, if there are any active incidents, and recent changes
- Trigger an incident to the currently on-call responder(s) for a service
# How it Works
- The Backstage PagerDuty plugin allows PagerDuty information about a Backstage entity to be displayed within Backstage. This includes active incidents as well as the current on-call responders' names, email addresses, and links to their profiles in PagerDuty.
- The Backstage PagerDuty plugin allows PagerDuty information about a Backstage entity to be displayed within Backstage. This includes active incidents, recent change events, as well as the current on-call responders' names, email addresses, and links to their profiles in PagerDuty.
- Incidents can be manually triggered via the plugin with a user-provided description, which will in turn notify the current on-call responders.
# Requirements
@@ -152,4 +152,4 @@ yarn remove @backstage/plugin-pagerduty
![PagerDuty plugin popup modal for creating an incident](doc/pd3.png)
![PagerDuty plugin showing an active incident](doc/pd4.png)
![PagerDuty plugin showing an active incident](doc/pd4.png)
+18 -7
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Service, Incident, OnCall } from '../components/types';
import { Service, Incident, ChangeEvent, OnCall } from '../components/types';
import {
PagerDutyApi,
TriggerAlarmRequest,
@@ -23,6 +23,7 @@ import {
OnCallsResponse,
ClientApiConfig,
RequestOptions,
ChangeEventsResponse,
} from './types';
import {
createApiRef,
@@ -69,6 +70,17 @@ export class PagerDutyClient implements PagerDutyApi {
return incidents;
}
async getChangeEventsByServiceId(serviceId: string): Promise<ChangeEvent[]> {
const params = `limit=5&time_zone=UTC&sort_by=timestamp`;
const url = `${await this.config.discoveryApi.getBaseUrl(
'proxy',
)}/pagerduty/services/${serviceId}/change_events?${params}`;
const { change_events } = await this.getByUrl<ChangeEventsResponse>(url);
return change_events;
}
async getOnCallByPolicyId(policyId: string): Promise<OnCall[]> {
const params = `time_zone=UTC&include[]=users&escalation_policy_ids[]=${policyId}`;
const url = `${await this.config.discoveryApi.getBaseUrl(
@@ -80,11 +92,11 @@ export class PagerDutyClient implements PagerDutyApi {
}
triggerAlarm({
integrationKey,
source,
description,
userName,
}: TriggerAlarmRequest): Promise<Response> {
integrationKey,
source,
description,
userName,
}: TriggerAlarmRequest): Promise<Response> {
const body = JSON.stringify({
event_action: 'trigger',
routing_key: integrationKey,
@@ -124,7 +136,6 @@ export class PagerDutyClient implements PagerDutyApi {
},
};
const response = await this.request(url, options);
return response.json();
}
+11 -1
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Incident, OnCall, Service } from '../components/types';
import { Incident, ChangeEvent, OnCall, Service } from '../components/types';
import { DiscoveryApi } from '@backstage/core-plugin-api';
export type TriggerAlarmRequest = {
@@ -37,6 +37,12 @@ export interface PagerDutyApi {
*/
getIncidentsByServiceId(serviceId: string): Promise<Incident[]>;
/**
* Fetches a list of change events a provided service has.
*
*/
getChangeEventsByServiceId(serviceId: string): Promise<ChangeEvent[]>;
/**
* Fetches the list of users in an escalation policy.
*
@@ -57,6 +63,10 @@ export type IncidentsResponse = {
incidents: Incident[];
};
export type ChangeEventsResponse = {
change_events: ChangeEvent[];
};
export type OnCallsResponse = {
oncalls: OnCall[];
};
@@ -0,0 +1,36 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { Grid, Typography } from '@material-ui/core';
import EmptyStateImage from '../../assets/emptystate.svg';
export const ChangeEventEmptyState = () => {
return (
<Grid container justify="center" direction="column" alignItems="center">
<Grid item xs={12}>
<Typography variant="h5">No change events to display yet.</Typography>
</Grid>
<Grid item xs={12}>
<img
src={EmptyStateImage}
alt="EmptyState"
data-testid="emptyStateImg"
/>
</Grid>
</Grid>
);
};
@@ -0,0 +1,102 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 {
ListItem,
ListItemSecondaryAction,
Tooltip,
ListItemText,
makeStyles,
IconButton,
Typography,
} from '@material-ui/core';
import { DateTime, Duration } from 'luxon';
import { ChangeEvent } from '../types';
import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser';
import { BackstageTheme } from '@backstage/theme';
const useStyles = makeStyles<BackstageTheme>({
denseListIcon: {
marginRight: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
},
listItemPrimary: {
fontWeight: 'bold',
},
});
type Props = {
changeEvent: ChangeEvent;
};
export const ChangeEventListItem = ({ changeEvent }: Props) => {
const classes = useStyles();
const duration =
new Date().getTime() - new Date(changeEvent.timestamp).getTime();
const changed_at = DateTime.local()
.minus(Duration.fromMillis(duration))
.toRelative({ locale: 'en' });
let externalLinkElem = null;
if (changeEvent.links.length > 0) {
const text: string = changeEvent.links[0].text;
externalLinkElem = (
<Tooltip title={text} placement="top">
<IconButton
href={changeEvent.links[0].href}
target="_blank"
rel="noopener noreferrer"
color="primary"
>
<OpenInBrowserIcon />
</IconButton>
</Tooltip>
);
}
return (
<ListItem dense key={changeEvent.id}>
<ListItemText
primary={<>{changeEvent.summary}</>}
primaryTypographyProps={{
variant: 'body1',
className: classes.listItemPrimary,
}}
secondary={
<Typography variant="body2" color="textSecondary">
Triggered from {changeEvent.source} {changed_at}.
</Typography>
}
/>
<ListItemSecondaryAction>
{externalLinkElem}
<Tooltip title="View in Pagerduty" placement="top">
<IconButton
href={changeEvent.html_url}
target="_blank"
rel="noopener noreferrer"
color="primary"
>
<OpenInBrowserIcon />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);
};
@@ -0,0 +1,114 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { render, waitFor } from '@testing-library/react';
import { ChangeEvent } from '../types';
import { wrapInTestApp } from '@backstage/test-utils';
import { pagerDutyApiRef } from '../../api';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { ChangeEvents } from './ChangeEvents';
const mockPagerDutyApi = {
getChangeEventsByServiceId: () => [],
};
const apis = ApiRegistry.from([[pagerDutyApiRef, mockPagerDutyApi]]);
describe('Incidents', () => {
it('Renders an empty state when there are no change events', async () => {
mockPagerDutyApi.getChangeEventsByServiceId = jest
.fn()
.mockImplementationOnce(async () => []);
const { getByText, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<ChangeEvents serviceId="abc" refreshEvents={false} />
</ApiProvider>,
),
);
await waitFor(() => !queryByTestId('progress'));
expect(getByText('No change events to display yet.')).toBeInTheDocument();
});
it('Renders all change events', async () => {
mockPagerDutyApi.getChangeEventsByServiceId = jest
.fn()
.mockImplementationOnce(
async () =>
[
{
id: 'id1',
source: 'changeSource1',
html_url: 'www.pdlink.com',
links: [
{
href: 'www.externalLink1.com',
text: 'link1',
},
],
summary: 'summary of event',
timestamp: '5 minutes',
},
{
id: 'id2',
source: 'changeSource1',
html_url: 'www.pdlink.com/link',
links: [
{
href: 'www.externalLink1.com',
text: 'link1',
},
],
summary: 'sum of EVENT',
timestamp: '20 minutes',
},
] as ChangeEvent[],
);
const { getByText, getAllByTitle, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<ChangeEvents serviceId="abc" refreshEvents={false} />
</ApiProvider>,
),
);
await waitFor(() => !queryByTestId('progress'));
expect(getByText('summary of event')).toBeInTheDocument();
expect(getByText('sum of EVENT')).toBeInTheDocument();
expect(getByText('5 minutes')).toBeInTheDocument();
expect(getByText('20 minutes')).toBeInTheDocument();
// assert links, mailto and hrefs, date calculation
expect(getAllByTitle('View in PagerDuty').length).toEqual(2);
});
it('Handle errors', async () => {
mockPagerDutyApi.getChangeEventsByServiceId = jest
.fn()
.mockRejectedValueOnce(new Error('Error occurred'));
const { getByText, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<ChangeEvents serviceId="abc" refreshEvents={false} />
</ApiProvider>,
),
);
await waitFor(() => !queryByTestId('progress'));
expect(
getByText('Error encountered while fetching information. Error occurred'),
).toBeInTheDocument();
});
});
@@ -0,0 +1,69 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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, { useEffect } from 'react';
import { List, ListSubheader } from '@material-ui/core';
import { ChangeEventListItem } from './ChangeEventListItem';
import { ChangeEventEmptyState } from './ChangeEventEmptyState';
import { useAsyncFn } from 'react-use';
import { pagerDutyApiRef } from '../../api';
import { useApi } from '@backstage/core-plugin-api';
import { Progress } from '@backstage/core-components';
import { Alert } from '@material-ui/lab';
type Props = {
serviceId: string;
refreshEvents: boolean;
};
export const ChangeEvents = ({ serviceId, refreshEvents }: Props) => {
const api = useApi(pagerDutyApiRef);
const [{ value: changeEvents, loading, error }, getChangeEvents] = useAsyncFn(
async () => await api.getChangeEventsByServiceId(serviceId),
);
useEffect(() => {
getChangeEvents();
}, [refreshEvents, getChangeEvents]);
if (error) {
return (
<Alert severity="error">
Error encountered while fetching information. {error.message}
</Alert>
);
}
if (loading) {
return <Progress />;
}
if (!changeEvents?.length) {
return <ChangeEventEmptyState />;
}
return (
<List dense subheader={<ListSubheader>CHANGE EVENTS</ListSubheader>}>
{changeEvents!.map((changeEvent, index) => (
<ChangeEventListItem
key={changeEvent.id + index}
changeEvent={changeEvent}
/>
))}
</List>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { ChangeEvents } from './ChangeEvents';
@@ -20,7 +20,7 @@ import { Entity } from '@backstage/catalog-model';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { wrapInTestApp } from '@backstage/test-utils';
import { pagerDutyApiRef, UnauthorizedError, PagerDutyClient } from '../../api';
import { Service } from '../types';
import { Service, User } from '../types';
import { alertApiRef, createApiRef } from '@backstage/core-plugin-api';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
@@ -52,19 +52,22 @@ const entity: Entity = {
},
};
const user: User = {
name: 'person1',
id: 'p1',
summary: 'person1',
email: 'person1@example.com',
html_url: 'http://a.com/id1',
};
const service: Service = {
id: 'abc',
name: 'pagerduty-name',
html_url: 'www.example.com',
escalation_policy: {
id: 'def',
user: {
name: 'person1',
id: 'p1',
summary: 'person1',
email: 'person1@example.com',
html_url: 'http://a.com/id1',
},
user: user,
html_url: 'http://a.com/id1',
},
integrationKey: 'abcd',
};
@@ -24,15 +24,19 @@ import { pagerDutyApiRef, UnauthorizedError } from '../../api';
import AlarmAddIcon from '@material-ui/icons/AlarmAdd';
import { MissingTokenError } from '../Errors/MissingTokenError';
import WebIcon from '@material-ui/icons/Web';
import DateRangeIcon from '@material-ui/icons/DateRange';
import { usePagerdutyEntity } from '../../hooks';
import { PAGERDUTY_INTEGRATION_KEY } from '../constants';
import { TriggerDialog } from '../TriggerDialog';
import { ChangeEvents } from '../ChangeEvents';
import { useApi } from '@backstage/core-plugin-api';
import {
Progress,
HeaderIconLinkRow,
IconLinkVerticalProps,
TabbedCard,
CardTab,
} from '@backstage/core-components';
export const isPluginApplicableToEntity = (entity: Entity) =>
@@ -42,6 +46,8 @@ export const PagerDutyCard = () => {
const { integrationKey } = usePagerdutyEntity();
const api = useApi(pagerDutyApiRef);
const [refreshIncidents, setRefreshIncidents] = useState<boolean>(false);
const [refreshChangeEvents, setRefreshChangeEvents] =
useState<boolean>(false);
const [dialogShown, setDialogShown] = useState<boolean>(false);
const showDialog = useCallback(() => {
@@ -53,6 +59,7 @@ export const PagerDutyCard = () => {
const handleRefresh = useCallback(() => {
setRefreshIncidents(x => !x);
setRefreshChangeEvents(x => !x);
}, []);
const {
@@ -69,6 +76,7 @@ export const PagerDutyCard = () => {
name: services[0].name,
url: services[0].html_url,
policyId: services[0].escalation_policy.id,
policyLink: services[0].escalation_policy.html_url,
};
});
@@ -101,19 +109,39 @@ export const PagerDutyCard = () => {
color: 'secondary',
};
const escalationPolicyLink: IconLinkVerticalProps = {
label: 'Escalation Policy',
href: service!.policyLink,
icon: <DateRangeIcon />,
};
return (
<>
<Card data-testid="pagerduty-card">
<CardHeader
title="PagerDuty"
subheader={<HeaderIconLinkRow links={[serviceLink, triggerLink]} />}
subheader={
<HeaderIconLinkRow
links={[serviceLink, triggerLink, escalationPolicyLink]}
/>
}
/>
<Divider />
<CardContent>
<Incidents
serviceId={service!.id}
refreshIncidents={refreshIncidents}
/>
<TabbedCard>
<CardTab label="Incidents">
<Incidents
serviceId={service!.id}
refreshIncidents={refreshIncidents}
/>
</CardTab>
<CardTab label="Change Events">
<ChangeEvents
serviceId={service!.id}
refreshEvents={refreshChangeEvents}
/>
</CardTab>
</TabbedCard>
<EscalationPolicy policyId={service!.policyId} />
</CardContent>
</Card>
+21 -1
View File
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Backstage Authors
* 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.
@@ -14,6 +14,25 @@
* limitations under the License.
*/
export type ChangeEvent = {
id: string;
integration: [
{
service: Service;
},
];
source: string;
html_url: string;
links: [
{
href: string;
text: string;
},
];
summary: string;
timestamp: string;
};
export type Incident = {
id: string;
title: string;
@@ -36,6 +55,7 @@ export type Service = {
escalation_policy: {
id: string;
user: User;
html_url: string;
};
};