Merge pull request #3317 from backstage/samiram/pagerduty-plugin

Pagerduty plugin
This commit is contained in:
samiramkr
2020-12-04 17:02:54 +01:00
committed by GitHub
41 changed files with 2063 additions and 2 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-catalog': patch
'@backstage/plugin-pagerduty': patch
---
Added pagerduty plugin to example app
+8
View File
@@ -46,6 +46,12 @@ proxy:
X-Api-Key:
$env: NEW_RELIC_REST_API_KEY
'/pagerduty':
target: https://api.pagerduty.com
headers:
Authorization:
$env: PAGERDUTY_TOKEN
'/buildkite/api':
target: https://api.buildkite.com/v2/
headers:
@@ -310,3 +316,5 @@ homepage:
timezone: 'Europe/Stockholm'
- label: TYO
timezone: 'Asia/Tokyo'
pagerduty:
eventsBaseUrl: 'https://events.pagerduty.com/v2'
+2 -1
View File
@@ -22,6 +22,7 @@
"@backstage/plugin-kubernetes": "^0.3.1",
"@backstage/plugin-lighthouse": "^0.2.4",
"@backstage/plugin-newrelic": "^0.2.1",
"@backstage/plugin-pagerduty": "0.2.1",
"@backstage/plugin-register-component": "^0.2.3",
"@backstage/plugin-rollbar": "^0.2.5",
"@backstage/plugin-scaffolder": "^0.3.2",
@@ -36,10 +37,10 @@
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@octokit/rest": "^18.0.0",
"@roadiehq/backstage-plugin-buildkite": "^0.1.3",
"@roadiehq/backstage-plugin-github-insights": "^0.2.16",
"@roadiehq/backstage-plugin-github-pull-requests": "^0.6.3",
"@roadiehq/backstage-plugin-travis-ci": "^0.2.8",
"@roadiehq/backstage-plugin-buildkite": "^0.1.3",
"history": "^5.0.0",
"prop-types": "^15.7.2",
"react": "^16.12.0",
@@ -67,6 +67,10 @@ import {
PullRequestsStatsCard,
Router as PullRequestsRouter,
} from '@roadiehq/backstage-plugin-github-pull-requests';
import {
isPluginApplicableToEntity as isPagerDutyAvailable,
PagerDutyCard,
} from '@backstage/plugin-pagerduty';
import {
isPluginApplicableToEntity as isTravisCIAvailable,
RecentTravisCIBuildsWidget,
@@ -142,6 +146,11 @@ const ComponentOverviewContent = ({ entity }: { entity: Entity }) => (
<Grid item md={6}>
<AboutCard entity={entity} variant="gridItem" />
</Grid>
{isPagerDutyAvailable(entity) && (
<Grid item md={6}>
<PagerDutyCard entity={entity} />
</Grid>
)}
<RecentCICDRunsSwitcher entity={entity} />
{isGitHubAvailable(entity) && (
<>
+1
View File
@@ -39,5 +39,6 @@ export { plugin as CostInsights } from '@backstage/plugin-cost-insights';
export { plugin as GitHubInsights } from '@roadiehq/backstage-plugin-github-insights';
export { plugin as CatalogImport } from '@backstage/plugin-catalog-import';
export { plugin as UserSettings } from '@backstage/plugin-user-settings';
export { plugin as PagerDuty } from '@backstage/plugin-pagerduty';
export { plugin as Buildkite } from '@roadiehq/backstage-plugin-buildkite';
export { plugin as Search } from '@backstage/plugin-search';
@@ -15,3 +15,4 @@
*/
export { AboutCard } from './AboutCard';
export { IconLinkVertical } from './IconLinkVertical';
+1 -1
View File
@@ -15,7 +15,7 @@
*/
export * from '@backstage/catalog-client';
export { AboutCard } from './components/AboutCard';
export { AboutCard, IconLinkVertical } from './components/AboutCard';
export { EntityPageLayout } from './components/EntityPageLayout';
export { Router } from './components/Router';
export { useEntityCompoundName } from './components/useEntityCompoundName';
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+82
View File
@@ -0,0 +1,82 @@
# PagerDuty
## Overview
This plugin displays PagerDuty information about an entity such as if there are any active incidents and what the escalation policy is.
There is also an easy way to trigger an alarm directly to the person who is currently on-call.
This plugin requires that entities are annotated with an [integration key](https://support.pagerduty.com/docs/services-and-integrations#add-integrations-to-an-existing-service). See more further down in this document.
This plugin provides:
- A list of incidents
- A way to trigger an alarm to the person on-call
- Information details about the person on-call
## Setup instructions
Install the plugin:
```bash
yarn add @backstage/plugin-pagerduty
```
Add it to the app in `plugins.ts`:
```ts
export { plugin as Pagerduty } from '@backstage/plugin-pagerduty';
```
Add it to the `EntityPage.ts`:
```ts
import {
isPluginApplicableToEntity as isPagerDutyAvailable,
PagerDutyCard,
} from '@backstage/plugin-pagerduty';
// add to code
{
isPagerDutyAvailable(entity) && (
<Grid item md={6}>
<PagerDutyCard entity={entity} />
</Grid>
);
}
```
## Client configuration
If you want to override the default URL for events, you can add it to `app-config.yaml`.
In `app-config.yaml`:
```yaml
pagerduty:
eventsBaseUrl: 'https://events.pagerduty.com/v2'
```
## Providing the API Token
In order for the client to make requests to the [PagerDuty API](https://developer.pagerduty.com/docs/rest-api-v2/rest-api/) it needs an [API Token](https://support.pagerduty.com/docs/generating-api-keys#generating-a-general-access-rest-api-key).
Then start the backend passing the token as an environment variable:
```bash
$ PAGERDUTY_TOKEN='Token token=<TOKEN>' yarn start
```
This will proxy the request by adding `Authorization` header with the provided token.
## Integration Key
The information displayed for each entity is based on the [integration key](https://support.pagerduty.com/docs/services-and-integrations#add-integrations-to-an-existing-service).
### Adding the integration key to the entity annotation
If you want to use this plugin for an entity, you need to label it with the below annotation:
```yml
annotations:
pagerduty.com/integration-key: [INTEGRATION_KEY]
```
+19
View File
@@ -0,0 +1,19 @@
/*
* 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 { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
createDevApp().registerPlugin(plugin).render();
+52
View File
@@ -0,0 +1,52 @@
{
"name": "@backstage/plugin-pagerduty",
"version": "0.2.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.4.0",
"@backstage/core": "^0.3.2",
"@backstage/theme": "^0.2.1",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"classnames": "^2.2.6",
"date-fns": "^2.15.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.4.0",
"@backstage/dev-utils": "^0.1.5",
"@backstage/test-utils": "^0.1.4",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"msw": "^0.21.2",
"node-fetch": "^2.6.1",
"cross-fetch": "^3.0.6"
},
"files": [
"dist"
]
}
+143
View File
@@ -0,0 +1,143 @@
/*
* 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 { createApiRef, DiscoveryApi, ConfigApi } from '@backstage/core';
import { Service, Incident, OnCall } from '../components/types';
import {
PagerDutyApi,
TriggerAlarmRequest,
ServicesResponse,
IncidentsResponse,
OnCallsResponse,
ClientApiConfig,
RequestOptions,
} from './types';
export class UnauthorizedError extends Error {}
export const pagerDutyApiRef = createApiRef<PagerDutyApi>({
id: 'plugin.pagerduty.api',
description: 'Used to fetch data from PagerDuty API',
});
export class PagerDutyClient implements PagerDutyApi {
static fromConfig(configApi: ConfigApi, discoveryApi: DiscoveryApi) {
const eventsBaseUrl: string =
configApi.getOptionalString('pagerDuty.eventsBaseUrl') ??
'https://events.pagerduty.com/v2';
return new PagerDutyClient({
eventsBaseUrl,
discoveryApi,
});
}
constructor(private readonly config: ClientApiConfig) {}
async getServiceByIntegrationKey(integrationKey: string): Promise<Service[]> {
const params = `include[]=integrations&include[]=escalation_policies&query=${integrationKey}`;
const url = `${await this.config.discoveryApi.getBaseUrl(
'proxy',
)}/pagerduty/services?${params}`;
const { services } = await this.getByUrl<ServicesResponse>(url);
return services;
}
async getIncidentsByServiceId(serviceId: string): Promise<Incident[]> {
const params = `statuses[]=triggered&statuses[]=acknowledged&service_ids[]=${serviceId}`;
const url = `${await this.config.discoveryApi.getBaseUrl(
'proxy',
)}/pagerduty/incidents?${params}`;
const { incidents } = await this.getByUrl<IncidentsResponse>(url);
return incidents;
}
async getOnCallByPolicyId(policyId: string): Promise<OnCall[]> {
const params = `include[]=users&escalation_policy_ids[]=${policyId}`;
const url = `${await this.config.discoveryApi.getBaseUrl(
'proxy',
)}/pagerduty/oncalls?${params}`;
const { oncalls } = await this.getByUrl<OnCallsResponse>(url);
return oncalls;
}
triggerAlarm({
integrationKey,
source,
description,
userName,
}: TriggerAlarmRequest): Promise<Response> {
const body = JSON.stringify({
event_action: 'trigger',
routing_key: integrationKey,
client: 'Backstage',
client_url: source,
payload: {
summary: description,
source: source,
severity: 'error',
class: 'manual trigger',
custom_details: {
user: userName,
},
},
});
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=UTF-8',
Accept: 'application/json, text/plain, */*',
},
body,
};
const url = this.config.eventsBaseUrl ?? 'https://events.pagerduty.com/v2';
return this.request(`${url}/enqueue`, options);
}
private async getByUrl<T>(url: string): Promise<T> {
const options = {
method: 'GET',
headers: {
Accept: 'application/vnd.pagerduty+json;version=2',
'Content-Type': 'application/json',
},
};
const response = await this.request(url, options);
return response.json();
}
private async request(
url: string,
options: RequestOptions,
): Promise<Response> {
const response = await fetch(url, options);
if (response.status === 401) {
throw new UnauthorizedError();
}
if (!response.ok) {
const payload = await response.json();
const errors = payload.errors.map((error: string) => error).join(' ');
const message = `Request failed with ${response.status}, ${errors}`;
throw new Error(message);
}
return response;
}
}
+18
View File
@@ -0,0 +1,18 @@
/*
* 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.
*/
export { PagerDutyClient, pagerDutyApiRef, UnauthorizedError } from './client';
export type { PagerDutyApi } from './types';
+73
View File
@@ -0,0 +1,73 @@
/*
* 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 { Incident, OnCall, Service } from '../components/types';
import { DiscoveryApi } from '@backstage/core';
export type TriggerAlarmRequest = {
integrationKey: string;
source: string;
description: string;
userName: string;
};
export interface PagerDutyApi {
/**
* Fetches a list of services, filtered by the provided integration key.
*
*/
getServiceByIntegrationKey(integrationKey: string): Promise<Service[]>;
/**
* Fetches a list of incidents a provided service has.
*
*/
getIncidentsByServiceId(serviceId: string): Promise<Incident[]>;
/**
* Fetches the list of users in an escalation policy.
*
*/
getOnCallByPolicyId(policyId: string): Promise<OnCall[]>;
/**
* Triggers an incident to whoever is on-call.
*/
triggerAlarm(request: TriggerAlarmRequest): Promise<Response>;
}
export type ServicesResponse = {
services: Service[];
};
export type IncidentsResponse = {
incidents: Incident[];
};
export type OnCallsResponse = {
oncalls: OnCall[];
};
export type ClientApiConfig = {
eventsBaseUrl?: string;
discoveryApi: DiscoveryApi;
};
export type RequestOptions = {
method: string;
headers: HeadersInit;
body?: BodyInit;
};
@@ -0,0 +1,26 @@
<svg width="282" height="173" viewBox="0 0 282 173" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.4571 45.1637C11.0514 46.1711 7.48574 51.3699 8.49306 56.7756C9.50039 62.1814 14.6992 65.747 20.105 64.7397L27.5528 63.3518C25.4791 65.5835 24.4525 68.7347 25.0535 71.9596C26.0608 77.3653 31.2596 80.931 36.6654 79.9236L89.691 70.0427C89.7016 70.1067 89.7129 70.1708 89.7249 70.2349C90.3258 73.4598 92.4185 76.0298 95.1569 77.3647L91.9031 77.971C86.4974 78.9784 82.9318 84.1772 83.9391 89.583C84.9464 94.9887 90.1452 98.5543 95.551 97.547L250.098 68.7482C255.504 67.7409 259.069 62.5421 258.062 57.1363C257.461 53.9114 255.368 51.3414 252.63 50.0065L257.835 49.0366C263.241 48.0292 266.807 42.8304 265.799 37.4247C264.792 32.0189 259.593 28.4533 254.187 29.4606L161.492 46.7338C161.481 46.6697 161.47 46.6056 161.458 46.5415C160.857 43.3166 158.764 40.7466 156.026 39.4117L165.025 37.7347C170.431 36.7274 173.997 31.5286 172.989 26.1228C171.982 20.7171 166.783 17.1514 161.378 18.1588L16.4571 45.1637ZM24.3031 122.54C23.2958 117.134 26.8614 111.936 32.2672 110.928L190.856 81.3762C196.262 80.3688 201.461 83.9345 202.468 89.3402C203.476 94.746 199.91 99.9448 194.504 100.952L189.963 101.798C190.493 102.057 190.999 102.362 191.474 102.708L246.43 92.4677C251.835 91.4604 257.034 95.026 258.041 100.432C258.642 103.657 257.616 106.808 255.542 109.04L256.649 108.833C262.055 107.826 267.253 111.392 268.261 116.797C269.268 122.203 265.702 127.402 260.297 128.409L95.5591 159.107C90.1534 160.114 84.9545 156.549 83.9472 151.143C82.9399 145.737 86.5055 140.538 91.9113 139.531L103.94 137.29C103.41 137.031 102.904 136.726 102.429 136.38L29.1002 150.044C23.6944 151.051 18.4956 147.486 17.4882 142.08C16.4809 136.674 20.0465 131.475 25.4523 130.468L29.7352 129.67C26.9967 128.335 24.904 125.765 24.3031 122.54Z" fill="black" fill-opacity="0.05"/>
<g filter="url(#filter0_d)">
<path d="M232.896 31.2403H51.2975C49.1452 31.2403 47.4005 32.983 47.4005 35.1327V46.8101C47.4005 48.9598 49.1452 50.7025 51.2975 50.7025H232.896C235.048 50.7025 236.793 48.9598 236.793 46.8101V35.1327C236.793 32.983 235.048 31.2403 232.896 31.2403Z" fill="#EEEEEE"/>
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="47" y="31" width="190" height="114">
<path d="M232.896 31.2403H51.2975C49.1452 31.2403 47.4005 32.983 47.4005 35.1327V141.007C47.4005 143.157 49.1452 144.9 51.2975 144.9H232.896C235.048 144.9 236.793 143.157 236.793 141.007V35.1327C236.793 32.983 235.048 31.2403 232.896 31.2403Z" fill="#404040"/>
</mask>
<g mask="url(#mask0)">
<path d="M239.91 42.1391H47.4005V150.349H239.91V42.1391Z" fill="#EEEEEE"/>
</g>
</g>
<circle cx="188" cy="55" r="6" fill="#69DDC7"/>
<circle cx="91" cy="92" r="6" fill="#69DDC7"/>
<path d="M121 114L95.5 88L86.5 96L121 130L192.5 59L183.5 51L121 114Z" fill="#69DDC7"/>
<defs>
<filter id="filter0_d" x="29.4005" y="15.2403" width="229.392" height="153.66" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dx="2" dy="4"/>
<feGaussianBlur stdDeviation="10"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -0,0 +1,33 @@
/*
* 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 { Card, CardContent, CardHeader, Divider } from '@material-ui/core';
import { SubHeader } from './SubHeader';
import { SubHeaderLink } from '../types';
type Props = {
title: string;
links: SubHeaderLink[];
content: React.ReactNode;
};
export const AboutCard = ({ title, links, content }: Props) => (
<Card>
<CardHeader title={title} subheader={<SubHeader links={links} />} />
<Divider />
<CardContent>{content}</CardContent>
</Card>
);
@@ -0,0 +1,50 @@
/*
* 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 { VerticalIcon } from './VerticalIcon';
import { SubHeaderLink } from '../types';
import { makeStyles } from '@material-ui/core';
const useStyles = makeStyles(theme => ({
links: {
margin: theme.spacing(2, 0),
display: 'grid',
gridAutoFlow: 'column',
gridAutoColumns: 'min-content',
gridGap: theme.spacing(3),
},
}));
type Props = {
links: SubHeaderLink[];
};
export const SubHeader = ({ links }: Props) => {
const classes = useStyles();
return (
<nav className={classes.links}>
{links.map(link => (
<VerticalIcon
key={link.title}
label={link.title}
href={link.href ?? '#'}
action={link.action}
icon={link.icon}
/>
))}
</nav>
);
};
@@ -0,0 +1,85 @@
/*
* 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 classnames from 'classnames';
import { makeStyles, Link } from '@material-ui/core';
import LinkIcon from '@material-ui/icons/Link';
import { Link as RouterLink } from 'react-router-dom';
export type VerticalIconProps = {
icon?: React.ReactNode;
href?: string;
title?: string;
label: string;
action?: React.ReactNode;
};
const useIconStyles = makeStyles(theme => ({
link: {
display: 'grid',
justifyItems: 'center',
gridGap: 4,
textAlign: 'center',
},
label: {
fontSize: '0.7rem',
textTransform: 'uppercase',
fontWeight: 600,
letterSpacing: 1.2,
},
linkStyle: {
color: theme.palette.secondary.main,
},
}));
export function VerticalIcon({
icon = <LinkIcon />,
href = '#',
action,
...props
}: VerticalIconProps) {
const classes = useIconStyles();
if (action) {
return (
<Link
className={classnames(classes.link, classes.linkStyle)}
href={href}
{...props}
>
{icon}
{action}
</Link>
);
}
// Absolute links should not be using RouterLink
if (href?.startsWith('//') || href?.includes('://')) {
return (
<Link className={classes.link} href={href} {...props}>
{icon}
<span className={classes.label}>{props.label}</span>
</Link>
);
}
return (
<Link className={classes.link} to={href} component={RouterLink} {...props}>
{icon}
<span className={classes.label}>{props.label}</span>
</Link>
);
}
@@ -0,0 +1,19 @@
/*
* 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.
*/
export { AboutCard } from './AboutCard';
// export { SubHeader } from './SubHeader';
// export { VerticalIcon } from './VerticalIcon';
@@ -0,0 +1,35 @@
/*
* 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 { EmptyState } from '@backstage/core';
import { Button } from '@material-ui/core';
export const MissingTokenError = () => (
<EmptyState
missing="info"
title="Missing or invalid PagerDuty Token"
description="The request to fetch data needs a valid token. See README for more details."
action={
<Button
color="primary"
variant="contained"
href="https://github.com/backstage/backstage/blob/master/plugins/pagerduty/README.md"
>
Read More
</Button>
}
/>
);
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { MissingTokenError } from './MissingTokenError';
@@ -0,0 +1,95 @@
/*
* 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 { render, waitFor } from '@testing-library/react';
import { EscalationPolicy } from './EscalationPolicy';
import { wrapInTestApp } from '@backstage/test-utils';
import { User } from '../types';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { pagerDutyApiRef } from '../../api';
const mockPagerDutyApi = {
getOnCallByPolicyId: () => [],
};
const apis = ApiRegistry.from([[pagerDutyApiRef, mockPagerDutyApi]]);
describe('Escalation', () => {
it('Handles an empty response', async () => {
mockPagerDutyApi.getOnCallByPolicyId = jest
.fn()
.mockImplementationOnce(async () => []);
const { getByText, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<EscalationPolicy policyId="456" />
</ApiProvider>,
),
);
await waitFor(() => !queryByTestId('progress'));
expect(getByText('Empty escalation policy')).toBeInTheDocument();
expect(mockPagerDutyApi.getOnCallByPolicyId).toHaveBeenCalledWith('456');
});
it('Render a list of users', async () => {
mockPagerDutyApi.getOnCallByPolicyId = jest
.fn()
.mockImplementationOnce(async () => [
{
user: {
name: 'person1',
id: 'p1',
summary: 'person1',
email: 'person1@example.com',
html_url: 'http://a.com/id1',
} as User,
},
]);
const { getByText, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<EscalationPolicy policyId="abc" />
</ApiProvider>,
),
);
await waitFor(() => !queryByTestId('progress'));
expect(getByText('person1')).toBeInTheDocument();
expect(getByText('person1@example.com')).toBeInTheDocument();
expect(mockPagerDutyApi.getOnCallByPolicyId).toHaveBeenCalledWith('abc');
});
it('Handles errors', async () => {
mockPagerDutyApi.getOnCallByPolicyId = jest
.fn()
.mockRejectedValueOnce(new Error('Error message'));
const { getByText, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<EscalationPolicy policyId="abc" />
</ApiProvider>,
),
);
await waitFor(() => !queryByTestId('progress'));
expect(
getByText('Error encountered while fetching information. Error message'),
).toBeInTheDocument();
});
});
@@ -0,0 +1,63 @@
/*
* 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 { List, ListSubheader } from '@material-ui/core';
import { EscalationUsersEmptyState } from './EscalationUsersEmptyState';
import { EscalationUser } from './EscalationUser';
import { useAsync } from 'react-use';
import { pagerDutyApiRef } from '../../api';
import { useApi, Progress } from '@backstage/core';
import { Alert } from '@material-ui/lab';
type Props = {
policyId: string;
};
export const EscalationPolicy = ({ policyId }: Props) => {
const api = useApi(pagerDutyApiRef);
const { value: users, loading, error } = useAsync(async () => {
const oncalls = await api.getOnCallByPolicyId(policyId);
const users = oncalls.map(oncall => oncall.user);
return users;
});
if (error) {
return (
<Alert severity="error">
Error encountered while fetching information. {error.message}
</Alert>
);
}
if (loading) {
return <Progress />;
}
if (!users?.length) {
return <EscalationUsersEmptyState />;
}
return (
<List dense subheader={<ListSubheader>ON CALL</ListSubheader>}>
{users!.map((user, index) => (
<EscalationUser key={index} user={user} />
))}
</List>
);
};
@@ -0,0 +1,78 @@
/*
* 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 {
ListItem,
ListItemIcon,
ListItemSecondaryAction,
Tooltip,
ListItemText,
makeStyles,
IconButton,
Typography,
} from '@material-ui/core';
import Avatar from '@material-ui/core/Avatar';
import EmailIcon from '@material-ui/icons/Email';
import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser';
import { User } from '../types';
const useStyles = makeStyles({
listItemPrimary: {
fontWeight: 'bold',
},
});
type Props = {
user: User;
};
export const EscalationUser = ({ user }: Props) => {
const classes = useStyles();
return (
<ListItem>
<ListItemIcon>
<Avatar alt="User" />
</ListItemIcon>
<ListItemText
primary={
<Typography className={classes.listItemPrimary}>
{user.name}
</Typography>
}
secondary={user.email}
/>
<ListItemSecondaryAction>
<Tooltip title="Send e-mail to user" placement="top">
<IconButton href={`mailto:${user.email}`}>
<EmailIcon color="primary" />
</IconButton>
</Tooltip>
<Tooltip title="View in PagerDuty" placement="top">
<IconButton
href={user.html_url}
target="_blank"
rel="noopener noreferrer"
color="primary"
>
<OpenInBrowserIcon />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);
};
@@ -0,0 +1,48 @@
/*
* 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 {
ListItem,
ListItemIcon,
ListItemText,
makeStyles,
} from '@material-ui/core';
import { StatusWarning } from '@backstage/core';
const useStyles = makeStyles({
denseListIcon: {
marginRight: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
},
});
export const EscalationUsersEmptyState = () => {
const classes = useStyles();
return (
<ListItem>
<ListItemIcon>
<div className={classes.denseListIcon}>
<StatusWarning />
</div>
</ListItemIcon>
<ListItemText primary="Empty escalation policy" />
</ListItem>
);
};
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { EscalationPolicy } from './EscalationPolicy';
@@ -0,0 +1,36 @@
/*
* 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 { Grid, Typography } from '@material-ui/core';
import EmptyStateImage from '../../assets/emptystate.svg';
export const IncidentsEmptyState = () => {
return (
<Grid container justify="center" direction="column" alignItems="center">
<Grid item xs={12}>
<Typography variant="h4">Nice! No incidents found!</Typography>
</Grid>
<Grid item xs={12}>
<img
src={EmptyStateImage}
alt="EmptyState"
data-testid="emptyStateImg"
/>
</Grid>
</Grid>
);
};
@@ -0,0 +1,105 @@
/*
* 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 {
ListItem,
ListItemIcon,
ListItemSecondaryAction,
Tooltip,
ListItemText,
makeStyles,
IconButton,
Link,
Typography,
} from '@material-ui/core';
import { StatusError, StatusWarning } from '@backstage/core';
import { formatDistanceToNowStrict } from 'date-fns';
import { Incident } from '../types';
import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser';
const useStyles = makeStyles({
denseListIcon: {
marginRight: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
},
listItemPrimary: {
fontWeight: 'bold',
},
listItemIcon: {
minWidth: '1em',
},
});
type Props = {
incident: Incident;
};
export const IncidentListItem = ({ incident }: Props) => {
const classes = useStyles();
const user = incident.assignments[0]?.assignee;
const createdAt = formatDistanceToNowStrict(new Date(incident.created_at));
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}
primaryTypographyProps={{
variant: 'body1',
className: classes.listItemPrimary,
}}
secondary={
<Typography noWrap variant="body2" color="textSecondary">
Created {createdAt} ago and assigned to{' '}
<Link
href={user?.html_url ?? '#'}
target="_blank"
rel="noopener noreferrer"
>
{user?.summary ?? 'nobody'}
</Link>
</Typography>
}
/>
<ListItemSecondaryAction>
<Tooltip title="View in PagerDuty" placement="top">
<IconButton
href={incident.html_url}
target="_blank"
rel="noopener noreferrer"
color="primary"
>
<OpenInBrowserIcon />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);
};
@@ -0,0 +1,131 @@
/*
* 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 { render, waitFor } from '@testing-library/react';
import { Incidents } from './Incidents';
import { wrapInTestApp } from '@backstage/test-utils';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { pagerDutyApiRef } from '../../api';
import { Incident } from '../types';
const mockPagerDutyApi = {
getIncidentsByServiceId: () => [],
};
const apis = ApiRegistry.from([[pagerDutyApiRef, mockPagerDutyApi]]);
describe('Incidents', () => {
it('Renders an empty state when there are no incidents', async () => {
mockPagerDutyApi.getIncidentsByServiceId = jest
.fn()
.mockImplementationOnce(async () => []);
const { getByText, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<Incidents serviceId="abc" refreshIncidents={false} />
</ApiProvider>,
),
);
await waitFor(() => !queryByTestId('progress'));
expect(getByText('Nice! No incidents found!')).toBeInTheDocument();
});
it('Renders all incidents', async () => {
mockPagerDutyApi.getIncidentsByServiceId = jest.fn().mockImplementationOnce(
async () =>
[
{
id: 'id1',
status: 'triggered',
title: 'title1',
created_at: '2020-11-06T00:00:00Z',
assignments: [
{
assignee: {
id: 'p1',
summary: 'person1',
html_url: 'http://a.com/id1',
},
},
],
html_url: 'http://a.com/id1',
serviceId: 'sId1',
},
{
id: 'id2',
status: 'acknowledged',
title: 'title2',
created_at: '2020-11-07T00:00:00Z',
assignments: [
{
assignee: {
id: 'p2',
summary: 'person2',
html_url: 'http://a.com/id2',
},
},
],
html_url: 'http://a.com/id2',
serviceId: 'sId2',
},
] as Incident[],
);
const {
getByText,
getByTitle,
getAllByTitle,
getByLabelText,
queryByTestId,
} = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<Incidents serviceId="abc" refreshIncidents={false} />
</ApiProvider>,
),
);
await waitFor(() => !queryByTestId('progress'));
expect(getByText('title1')).toBeInTheDocument();
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();
// assert links, mailto and hrefs, date calculation
expect(getAllByTitle('View in PagerDuty').length).toEqual(2);
});
it('Handle errors', async () => {
mockPagerDutyApi.getIncidentsByServiceId = jest
.fn()
.mockRejectedValueOnce(new Error('Error occurred'));
const { getByText, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<Incidents serviceId="abc" refreshIncidents={false} />
</ApiProvider>,
),
);
await waitFor(() => !queryByTestId('progress'));
expect(
getByText('Error encountered while fetching information. Error occurred'),
).toBeInTheDocument();
});
});
@@ -0,0 +1,65 @@
/*
* 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, { useEffect } from 'react';
import { List, ListSubheader } from '@material-ui/core';
import { IncidentListItem } from './IncidentListItem';
import { IncidentsEmptyState } from './IncidentEmptyState';
import { useAsyncFn } from 'react-use';
import { pagerDutyApiRef } from '../../api';
import { useApi, Progress } from '@backstage/core';
import { Alert } from '@material-ui/lab';
type Props = {
serviceId: string;
refreshIncidents: boolean;
};
export const Incidents = ({ serviceId, refreshIncidents }: Props) => {
const api = useApi(pagerDutyApiRef);
const [{ value: incidents, loading, error }, getIncidents] = useAsyncFn(
async () => await api.getIncidentsByServiceId(serviceId),
);
useEffect(() => {
getIncidents();
}, [refreshIncidents, getIncidents]);
if (error) {
return (
<Alert severity="error">
Error encountered while fetching information. {error.message}
</Alert>
);
}
if (loading) {
return <Progress />;
}
if (!incidents?.length) {
return <IncidentsEmptyState />;
}
return (
<List dense subheader={<ListSubheader>INCIDENTS</ListSubheader>}>
{incidents!.map((incident, index) => (
<IncidentListItem key={incident.id + index} incident={incident} />
))}
</List>
);
};
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { Incidents } from './Incidents';
@@ -0,0 +1,150 @@
/*
* 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 { render, waitFor, fireEvent, act } from '@testing-library/react';
import { PagerDutyCard } from './PagerDutyCard';
import { Entity } from '@backstage/catalog-model';
import { wrapInTestApp } from '@backstage/test-utils';
import {
alertApiRef,
ApiProvider,
ApiRegistry,
createApiRef,
} from '@backstage/core';
import { pagerDutyApiRef, UnauthorizedError, PagerDutyClient } from '../api';
import { Service } from './types';
const mockPagerDutyApi: Partial<PagerDutyClient> = {
getServiceByIntegrationKey: async () => [],
getOnCallByPolicyId: async () => [],
getIncidentsByServiceId: async () => [],
};
const apis = ApiRegistry.from([
[pagerDutyApiRef, mockPagerDutyApi],
[
alertApiRef,
createApiRef({
id: 'core.alert',
description: 'Used to report alerts and forward them to the app',
}),
],
]);
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'pagerduty-test',
annotations: {
'pagerduty.com/integration-key': 'abc123',
},
},
};
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',
},
},
integrationKey: 'abcd',
};
describe('PageDutyCard', () => {
it('Render pagerduty', async () => {
mockPagerDutyApi.getServiceByIntegrationKey = jest
.fn()
.mockImplementationOnce(async () => [service]);
const { getByText, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<PagerDutyCard entity={entity} />
</ApiProvider>,
),
);
await waitFor(() => !queryByTestId('progress'));
expect(getByText('Service Directory')).toBeInTheDocument();
expect(getByText('Create Incident')).toBeInTheDocument();
expect(getByText('Nice! No incidents found!')).toBeInTheDocument();
expect(getByText('Empty escalation policy')).toBeInTheDocument();
});
it('Handles custom error for missing token', async () => {
mockPagerDutyApi.getServiceByIntegrationKey = jest
.fn()
.mockRejectedValueOnce(new UnauthorizedError());
const { getByText, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<PagerDutyCard entity={entity} />
</ApiProvider>,
),
);
await waitFor(() => !queryByTestId('progress'));
expect(getByText('Missing or invalid PagerDuty Token')).toBeInTheDocument();
});
it('handles general error', async () => {
mockPagerDutyApi.getServiceByIntegrationKey = jest
.fn()
.mockRejectedValueOnce(new Error('An error occurred'));
const { getByText, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<PagerDutyCard entity={entity} />
</ApiProvider>,
),
);
await waitFor(() => !queryByTestId('progress'));
expect(
getByText(
'Error encountered while fetching information. An error occurred',
),
).toBeInTheDocument();
});
it('opens the dialog when trigger button is clicked', async () => {
mockPagerDutyApi.getServiceByIntegrationKey = jest
.fn()
.mockImplementationOnce(async () => [service]);
const { getByText, queryByTestId, getByTestId, getByRole } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<PagerDutyCard entity={entity} />
</ApiProvider>,
),
);
await waitFor(() => !queryByTestId('progress'));
expect(getByText('Service Directory')).toBeInTheDocument();
expect(getByText('Create Incident')).toBeInTheDocument();
const triggerButton = getByTestId('trigger-button');
await act(async () => {
fireEvent.click(triggerButton);
});
expect(getByRole('dialog')).toBeInTheDocument();
});
});
@@ -0,0 +1,143 @@
/*
* 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, { useState, useCallback } from 'react';
import { useApi, Progress } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { Button, makeStyles } 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';
import { AboutCard } from './About/AboutCard';
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';
export const isPluginApplicableToEntity = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[PAGERDUTY_INTEGRATION_KEY]);
type Props = {
entity: Entity;
};
export const PagerDutyCard = ({ entity }: Props) => {
const classes = useStyles();
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 handleRefresh = useCallback(() => {
setRefreshIncidents(x => !x);
}, []);
const handleDialog = useCallback(() => {
setShowDialog(x => !x);
}, []);
const { value: service, loading, error } = useAsync(async () => {
const services = await api.getServiceByIntegrationKey(integrationKey);
return {
id: services[0].id,
name: services[0].name,
url: services[0].html_url,
policyId: services[0].escalation_policy.id,
};
});
if (error instanceof UnauthorizedError) {
return <MissingTokenError />;
}
if (error) {
return (
<Alert severity="error">
Error encountered while fetching information. {error.message}
</Alert>
);
}
if (loading) {
return <Progress />;
}
const serviceLink = {
title: 'Service Directory',
href: service!.url,
icon: <WebIcon />,
};
const triggerLink = {
title: 'Create Incident',
action: (
<Button
data-testid="trigger-button"
color="secondary"
onClick={handleDialog}
className={classes.triggerAlarm}
>
Create Incident
</Button>
),
icon: <AlarmAddIcon onClick={handleDialog} />,
};
return (
<AboutCard
title="PagerDuty"
links={[serviceLink, triggerLink]}
content={
<>
<Incidents
serviceId={service!.id}
refreshIncidents={refreshIncidents}
/>
<EscalationPolicy policyId={service!.policyId} />
<TriggerDialog
showDialog={showDialog}
handleDialog={handleDialog}
name={entity.metadata.name}
integrationKey={integrationKey}
onIncidentCreated={handleRefresh}
/>
</>
}
/>
);
};
@@ -0,0 +1,104 @@
/*
* 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 { render, fireEvent, act } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import {
ApiRegistry,
alertApiRef,
createApiRef,
ApiProvider,
IdentityApi,
identityApiRef,
} from '@backstage/core';
import { pagerDutyApiRef } from '../../api';
import { Entity } from '@backstage/catalog-model';
import { TriggerDialog } from './TriggerDialog';
describe('TriggerDialog', () => {
const mockIdentityApi: Partial<IdentityApi> = {
getUserId: () => 'guest@example.com',
};
const mockTriggerAlarmFn = jest.fn();
const mockPagerDutyApi = {
triggerAlarm: mockTriggerAlarmFn,
};
const apis = ApiRegistry.from([
[
alertApiRef,
createApiRef({
id: 'core.alert',
description: 'Used to report alerts and forward them to the app',
}),
],
[identityApiRef, mockIdentityApi],
[pagerDutyApiRef, mockPagerDutyApi],
]);
it('open the dialog and trigger an alarm', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'pagerduty-test',
annotations: {
'pagerduty.com/integration-key': 'abc123',
},
},
};
const { getByText, getByRole, getByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<TriggerDialog
showDialog
handleDialog={() => {}}
name={entity.metadata.name}
integrationKey="abc123"
onIncidentCreated={() => {}}
/>
</ApiProvider>,
),
);
expect(getByRole('dialog')).toBeInTheDocument();
expect(
getByText('This action will trigger an incident for ', {
exact: false,
}),
).toBeInTheDocument();
const input = getByTestId('trigger-input');
const description = 'Test Trigger Alarm';
await act(async () => {
fireEvent.change(input, { target: { value: description } });
});
const triggerButton = getByTestId('trigger-button');
await act(async () => {
fireEvent.click(triggerButton);
});
expect(mockTriggerAlarmFn).toHaveBeenCalled();
expect(mockTriggerAlarmFn).toHaveBeenCalledWith({
integrationKey: entity!.metadata!.annotations![
'pagerduty.com/integration-key'
],
source: window.location.toString(),
description,
userName: 'guest@example.com',
});
});
});
@@ -0,0 +1,143 @@
/*
* 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, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
TextField,
DialogActions,
Button,
DialogContent,
Typography,
CircularProgress,
} from '@material-ui/core';
import { useApi, alertApiRef, identityApiRef } from '@backstage/core';
import { useAsyncFn } from 'react-use';
import { pagerDutyApiRef } from '../../api';
import { Alert } from '@material-ui/lab';
type Props = {
name: string;
integrationKey: string;
showDialog: boolean;
handleDialog: () => void;
onIncidentCreated: () => void;
};
export const TriggerDialog = ({
name,
integrationKey,
showDialog,
handleDialog,
onIncidentCreated: onIncidentCreated,
}: Props) => {
const alertApi = useApi(alertApiRef);
const identityApi = useApi(identityApiRef);
const userName = identityApi.getUserId();
const api = useApi(pagerDutyApiRef);
const [description, setDescription] = useState<string>('');
const [{ value, loading, error }, handleTriggerAlarm] = useAsyncFn(
async (description: string) =>
await api.triggerAlarm({
integrationKey,
source: window.location.toString(),
description,
userName,
}),
);
const descriptionChanged = (
event: React.ChangeEvent<HTMLTextAreaElement>,
) => {
setDescription(event.target.value);
};
useEffect(() => {
if (value) {
alertApi.post({
message: `Alarm successfully triggered by ${userName}`,
});
onIncidentCreated();
handleDialog();
}
}, [value, alertApi, handleDialog, userName, onIncidentCreated]);
if (error) {
alertApi.post({
message: `Failed to trigger alarm. ${error.message}`,
severity: 'error',
});
}
return (
<Dialog maxWidth="md" open={showDialog} onClose={handleDialog} fullWidth>
<DialogTitle>
This action will trigger an incident for <strong>"{name}"</strong>.
</DialogTitle>
<DialogContent>
<Alert severity="info">
<Typography variant="body1" align="justify">
If the issue you are seeing does not need urgent attention, please
get in touch with the responsible team using their preferred
communications channel. You can find information about the owner of
this entity in the "About" card. If the issue is urgent, please
don't hesitate to trigger the alert.
</Typography>
</Alert>
<Typography
variant="body1"
style={{ marginTop: '1em' }}
gutterBottom
align="justify"
>
Please describe the problem you want to report. Be as descriptive as
possible. Your signed in user and a reference to the current page will
automatically be amended to the alarm so that the receiver can reach
out to you if necessary.
</Typography>
<TextField
inputProps={{ 'data-testid': 'trigger-input' }}
id="description"
multiline
fullWidth
rows="4"
margin="normal"
label="Problem description"
variant="outlined"
onChange={descriptionChanged}
/>
</DialogContent>
<DialogActions>
<Button
data-testid="trigger-button"
id="trigger"
color="secondary"
disabled={!description || loading}
variant="contained"
onClick={() => handleTriggerAlarm(description)}
endIcon={loading && <CircularProgress size={16} />}
>
Trigger Incident
</Button>
<Button id="close" color="primary" onClick={handleDialog}>
Close
</Button>
</DialogActions>
</Dialog>
);
};
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { TriggerDialog } from './TriggerDialog';
+65
View File
@@ -0,0 +1,65 @@
/*
* 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.
*/
export type Incident = {
id: string;
title: string;
status: string;
html_url: string;
assignments: [
{
assignee: Assignee;
},
];
serviceId: string;
created_at: string;
};
export type Service = {
id: string;
name: string;
html_url: string;
integrationKey: string;
escalation_policy: {
id: string;
user: User;
};
};
export type OnCall = {
user: User;
};
export type Assignee = {
id: string;
summary: string;
html_url: string;
};
export type User = {
id: string;
summary: string;
email: string;
html_url: string;
name: string;
};
export type SubHeaderLink = {
title: string;
href?: string;
icon: React.ReactNode;
action?: React.ReactNode;
};
+25
View File
@@ -0,0 +1,25 @@
/*
* 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.
*/
export { plugin } from './plugin';
export {
isPluginApplicableToEntity,
PagerDutyCard,
} from './components/PagerDutyCard';
export {
PagerDutyClient,
pagerDutyApiRef,
UnauthorizedError,
} from './api/client';
+22
View File
@@ -0,0 +1,22 @@
/*
* 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 { plugin } from './plugin';
describe('pagerduty', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
});
});
+40
View File
@@ -0,0 +1,40 @@
/*
* 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 {
createApiFactory,
createPlugin,
createRouteRef,
discoveryApiRef,
configApiRef,
} from '@backstage/core';
import { pagerDutyApiRef, PagerDutyClient } from './api';
export const rootRouteRef = createRouteRef({
path: '/pagerduty',
title: 'pagerduty',
});
export const plugin = createPlugin({
id: 'pagerduty',
apis: [
createApiFactory({
api: pagerDutyApiRef,
deps: { discoveryApi: discoveryApiRef, configApi: configApiRef },
factory: ({ configApi, discoveryApi }) =>
PagerDutyClient.fromConfig(configApi, discoveryApi),
}),
],
});
+16
View File
@@ -0,0 +1,16 @@
/*
* 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 '@testing-library/jest-dom';