From 3990877f9fcb4c805f02c0a5154621f776d1bfd5 Mon Sep 17 00:00:00 2001 From: Douglas Read Date: Mon, 23 Aug 2021 10:33:33 -0400 Subject: [PATCH] Add PagerDuty ChangeEvents Signed-off-by: Douglas Read --- plugins/pagerduty/README.md | 6 +- plugins/pagerduty/src/api/client.ts | 25 ++-- plugins/pagerduty/src/api/types.ts | 12 +- .../ChangeEvents/ChangeEventEmptyState.tsx | 36 ++++++ .../ChangeEvents/ChangeEventListItem.tsx | 102 ++++++++++++++++ .../ChangeEvents/ChangeEvents.text.tsx | 114 ++++++++++++++++++ .../components/ChangeEvents/ChangeEvents.tsx | 69 +++++++++++ .../src/components/ChangeEvents/index.ts | 17 +++ .../components/PagerDutyCard/index.test.tsx | 19 +-- .../src/components/PagerDutyCard/index.tsx | 38 +++++- plugins/pagerduty/src/components/types.ts | 22 +++- 11 files changed, 435 insertions(+), 25 deletions(-) create mode 100644 plugins/pagerduty/src/components/ChangeEvents/ChangeEventEmptyState.tsx create mode 100644 plugins/pagerduty/src/components/ChangeEvents/ChangeEventListItem.tsx create mode 100644 plugins/pagerduty/src/components/ChangeEvents/ChangeEvents.text.tsx create mode 100644 plugins/pagerduty/src/components/ChangeEvents/ChangeEvents.tsx create mode 100644 plugins/pagerduty/src/components/ChangeEvents/index.ts diff --git a/plugins/pagerduty/README.md b/plugins/pagerduty/README.md index 478031d36a..02ed547e83 100644 --- a/plugins/pagerduty/README.md +++ b/plugins/pagerduty/README.md @@ -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) \ No newline at end of file diff --git a/plugins/pagerduty/src/api/client.ts b/plugins/pagerduty/src/api/client.ts index a642ae6711..552b0f2f7b 100644 --- a/plugins/pagerduty/src/api/client.ts +++ b/plugins/pagerduty/src/api/client.ts @@ -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 { + 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(url); + + return change_events; + } + async getOnCallByPolicyId(policyId: string): Promise { 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 { + integrationKey, + source, + description, + userName, + }: TriggerAlarmRequest): Promise { 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(); } diff --git a/plugins/pagerduty/src/api/types.ts b/plugins/pagerduty/src/api/types.ts index d5911b4bde..a11cc9d63b 100644 --- a/plugins/pagerduty/src/api/types.ts +++ b/plugins/pagerduty/src/api/types.ts @@ -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; + /** + * Fetches a list of change events a provided service has. + * + */ + getChangeEventsByServiceId(serviceId: string): Promise; + /** * 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[]; }; diff --git a/plugins/pagerduty/src/components/ChangeEvents/ChangeEventEmptyState.tsx b/plugins/pagerduty/src/components/ChangeEvents/ChangeEventEmptyState.tsx new file mode 100644 index 0000000000..38bf8c9e43 --- /dev/null +++ b/plugins/pagerduty/src/components/ChangeEvents/ChangeEventEmptyState.tsx @@ -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 ( + + + No change events to display yet. + + + EmptyState + + + ); +}; diff --git a/plugins/pagerduty/src/components/ChangeEvents/ChangeEventListItem.tsx b/plugins/pagerduty/src/components/ChangeEvents/ChangeEventListItem.tsx new file mode 100644 index 0000000000..a8286ace9b --- /dev/null +++ b/plugins/pagerduty/src/components/ChangeEvents/ChangeEventListItem.tsx @@ -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({ + 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 = ( + + + + + + ); + } + + return ( + + {changeEvent.summary}} + primaryTypographyProps={{ + variant: 'body1', + className: classes.listItemPrimary, + }} + secondary={ + + Triggered from {changeEvent.source} {changed_at}. + + } + /> + + {externalLinkElem} + + + + + + + + ); +}; diff --git a/plugins/pagerduty/src/components/ChangeEvents/ChangeEvents.text.tsx b/plugins/pagerduty/src/components/ChangeEvents/ChangeEvents.text.tsx new file mode 100644 index 0000000000..5e4de5da80 --- /dev/null +++ b/plugins/pagerduty/src/components/ChangeEvents/ChangeEvents.text.tsx @@ -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( + + + , + ), + ); + 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( + + + , + ), + ); + 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( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect( + getByText('Error encountered while fetching information. Error occurred'), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/pagerduty/src/components/ChangeEvents/ChangeEvents.tsx b/plugins/pagerduty/src/components/ChangeEvents/ChangeEvents.tsx new file mode 100644 index 0000000000..8848612303 --- /dev/null +++ b/plugins/pagerduty/src/components/ChangeEvents/ChangeEvents.tsx @@ -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 ( + + Error encountered while fetching information. {error.message} + + ); + } + + if (loading) { + return ; + } + + if (!changeEvents?.length) { + return ; + } + + return ( + CHANGE EVENTS}> + {changeEvents!.map((changeEvent, index) => ( + + ))} + + ); +}; diff --git a/plugins/pagerduty/src/components/ChangeEvents/index.ts b/plugins/pagerduty/src/components/ChangeEvents/index.ts new file mode 100644 index 0000000000..8cdcebb5b8 --- /dev/null +++ b/plugins/pagerduty/src/components/ChangeEvents/index.ts @@ -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'; diff --git a/plugins/pagerduty/src/components/PagerDutyCard/index.test.tsx b/plugins/pagerduty/src/components/PagerDutyCard/index.test.tsx index 1b3ec47336..d644aa019a 100644 --- a/plugins/pagerduty/src/components/PagerDutyCard/index.test.tsx +++ b/plugins/pagerduty/src/components/PagerDutyCard/index.test.tsx @@ -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', }; diff --git a/plugins/pagerduty/src/components/PagerDutyCard/index.tsx b/plugins/pagerduty/src/components/PagerDutyCard/index.tsx index 5c0c18135e..adb47e0b4b 100644 --- a/plugins/pagerduty/src/components/PagerDutyCard/index.tsx +++ b/plugins/pagerduty/src/components/PagerDutyCard/index.tsx @@ -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(false); + const [refreshChangeEvents, setRefreshChangeEvents] = + useState(false); const [dialogShown, setDialogShown] = useState(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: , + }; + return ( <> } + subheader={ + + } /> - + + + + + + + + diff --git a/plugins/pagerduty/src/components/types.ts b/plugins/pagerduty/src/components/types.ts index 584659ffe0..70767890ea 100644 --- a/plugins/pagerduty/src/components/types.ts +++ b/plugins/pagerduty/src/components/types.ts @@ -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; }; };