diff --git a/.changeset/great-spiders-repair.md b/.changeset/great-spiders-repair.md new file mode 100644 index 0000000000000..2cf1ddd8e8551 --- /dev/null +++ b/.changeset/great-spiders-repair.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog': patch +'@backstage/plugin-pagerduty': patch +--- + +Added pagerduty plugin to example app diff --git a/app-config.yaml b/app-config.yaml index 7fc041bf13932..236fc8e3b735b 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -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' diff --git a/packages/app/package.json b/packages/app/package.json index fd6667ac85fc7..afe35ff3821d4 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 068019c6222d8..13cbe324c6efb 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -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 }) => ( + {isPagerDutyAvailable(entity) && ( + + + + )} {isGitHubAvailable(entity) && ( <> diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index a02365d17d6de..c6a2a0e7e08bb 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -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'; diff --git a/plugins/catalog/src/components/AboutCard/index.ts b/plugins/catalog/src/components/AboutCard/index.ts index 93765ff942aa5..0491d467f8962 100644 --- a/plugins/catalog/src/components/AboutCard/index.ts +++ b/plugins/catalog/src/components/AboutCard/index.ts @@ -15,3 +15,4 @@ */ export { AboutCard } from './AboutCard'; +export { IconLinkVertical } from './IconLinkVertical'; diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index 606ad9028dc44..5949209128843 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -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'; diff --git a/plugins/pagerduty/.eslintrc.js b/plugins/pagerduty/.eslintrc.js new file mode 100644 index 0000000000000..13573efa9c466 --- /dev/null +++ b/plugins/pagerduty/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/pagerduty/README.md b/plugins/pagerduty/README.md new file mode 100644 index 0000000000000..1c22053b889b5 --- /dev/null +++ b/plugins/pagerduty/README.md @@ -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) && ( + + + + ); +} +``` + +## 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=' 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] +``` diff --git a/plugins/pagerduty/dev/index.tsx b/plugins/pagerduty/dev/index.tsx new file mode 100644 index 0000000000000..264d6f801f5a2 --- /dev/null +++ b/plugins/pagerduty/dev/index.tsx @@ -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(); diff --git a/plugins/pagerduty/package.json b/plugins/pagerduty/package.json new file mode 100644 index 0000000000000..4d58a1da42ffa --- /dev/null +++ b/plugins/pagerduty/package.json @@ -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" + ] +} diff --git a/plugins/pagerduty/src/api/client.ts b/plugins/pagerduty/src/api/client.ts new file mode 100644 index 0000000000000..527a56b2af4ab --- /dev/null +++ b/plugins/pagerduty/src/api/client.ts @@ -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({ + 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 { + 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(url); + + return services; + } + + async getIncidentsByServiceId(serviceId: string): Promise { + 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(url); + + return incidents; + } + + async getOnCallByPolicyId(policyId: string): Promise { + const params = `include[]=users&escalation_policy_ids[]=${policyId}`; + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/pagerduty/oncalls?${params}`; + const { oncalls } = await this.getByUrl(url); + + return oncalls; + } + + triggerAlarm({ + integrationKey, + source, + description, + userName, + }: TriggerAlarmRequest): Promise { + 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(url: string): Promise { + 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 { + 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; + } +} diff --git a/plugins/pagerduty/src/api/index.ts b/plugins/pagerduty/src/api/index.ts new file mode 100644 index 0000000000000..90604c40126be --- /dev/null +++ b/plugins/pagerduty/src/api/index.ts @@ -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'; diff --git a/plugins/pagerduty/src/api/types.ts b/plugins/pagerduty/src/api/types.ts new file mode 100644 index 0000000000000..733f171489bef --- /dev/null +++ b/plugins/pagerduty/src/api/types.ts @@ -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; + + /** + * Fetches a list of incidents a provided service has. + * + */ + getIncidentsByServiceId(serviceId: string): Promise; + + /** + * Fetches the list of users in an escalation policy. + * + */ + getOnCallByPolicyId(policyId: string): Promise; + + /** + * Triggers an incident to whoever is on-call. + */ + triggerAlarm(request: TriggerAlarmRequest): Promise; +} + +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; +}; diff --git a/plugins/pagerduty/src/assets/emptystate.svg b/plugins/pagerduty/src/assets/emptystate.svg new file mode 100644 index 0000000000000..57b35238dd1cd --- /dev/null +++ b/plugins/pagerduty/src/assets/emptystate.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/pagerduty/src/components/About/AboutCard.tsx b/plugins/pagerduty/src/components/About/AboutCard.tsx new file mode 100644 index 0000000000000..7d5419908a02d --- /dev/null +++ b/plugins/pagerduty/src/components/About/AboutCard.tsx @@ -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) => ( + + } /> + + {content} + +); diff --git a/plugins/pagerduty/src/components/About/SubHeader.tsx b/plugins/pagerduty/src/components/About/SubHeader.tsx new file mode 100644 index 0000000000000..5e4ea96fe0b44 --- /dev/null +++ b/plugins/pagerduty/src/components/About/SubHeader.tsx @@ -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 ( + + ); +}; diff --git a/plugins/pagerduty/src/components/About/VerticalIcon.tsx b/plugins/pagerduty/src/components/About/VerticalIcon.tsx new file mode 100644 index 0000000000000..5463372ac0bae --- /dev/null +++ b/plugins/pagerduty/src/components/About/VerticalIcon.tsx @@ -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 = , + href = '#', + action, + ...props +}: VerticalIconProps) { + const classes = useIconStyles(); + + if (action) { + return ( + + {icon} + {action} + + ); + } + + // Absolute links should not be using RouterLink + if (href?.startsWith('//') || href?.includes('://')) { + return ( + + {icon} + {props.label} + + ); + } + + return ( + + {icon} + {props.label} + + ); +} diff --git a/plugins/pagerduty/src/components/About/index.ts b/plugins/pagerduty/src/components/About/index.ts new file mode 100644 index 0000000000000..4e495daa541c2 --- /dev/null +++ b/plugins/pagerduty/src/components/About/index.ts @@ -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'; diff --git a/plugins/pagerduty/src/components/Errors/MissingTokenError.tsx b/plugins/pagerduty/src/components/Errors/MissingTokenError.tsx new file mode 100644 index 0000000000000..c22552b7c6b7c --- /dev/null +++ b/plugins/pagerduty/src/components/Errors/MissingTokenError.tsx @@ -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 = () => ( + + Read More + + } + /> +); diff --git a/plugins/pagerduty/src/components/Errors/index.ts b/plugins/pagerduty/src/components/Errors/index.ts new file mode 100644 index 0000000000000..3c2dfa65f2fcc --- /dev/null +++ b/plugins/pagerduty/src/components/Errors/index.ts @@ -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'; diff --git a/plugins/pagerduty/src/components/Escalation/Escalation.test.tsx b/plugins/pagerduty/src/components/Escalation/Escalation.test.tsx new file mode 100644 index 0000000000000..15ff3278a0cad --- /dev/null +++ b/plugins/pagerduty/src/components/Escalation/Escalation.test.tsx @@ -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( + + + , + ), + ); + 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( + + + , + ), + ); + 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( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + + expect( + getByText('Error encountered while fetching information. Error message'), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/pagerduty/src/components/Escalation/EscalationPolicy.tsx b/plugins/pagerduty/src/components/Escalation/EscalationPolicy.tsx new file mode 100644 index 0000000000000..5e67d5217221b --- /dev/null +++ b/plugins/pagerduty/src/components/Escalation/EscalationPolicy.tsx @@ -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 ( + + Error encountered while fetching information. {error.message} + + ); + } + + if (loading) { + return ; + } + + if (!users?.length) { + return ; + } + + return ( + ON CALL}> + {users!.map((user, index) => ( + + ))} + + ); +}; diff --git a/plugins/pagerduty/src/components/Escalation/EscalationUser.tsx b/plugins/pagerduty/src/components/Escalation/EscalationUser.tsx new file mode 100644 index 0000000000000..41995c86f5835 --- /dev/null +++ b/plugins/pagerduty/src/components/Escalation/EscalationUser.tsx @@ -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 ( + + + + + + {user.name} + + } + secondary={user.email} + /> + + + + + + + + + + + + + + ); +}; diff --git a/plugins/pagerduty/src/components/Escalation/EscalationUsersEmptyState.tsx b/plugins/pagerduty/src/components/Escalation/EscalationUsersEmptyState.tsx new file mode 100644 index 0000000000000..d5870116014a3 --- /dev/null +++ b/plugins/pagerduty/src/components/Escalation/EscalationUsersEmptyState.tsx @@ -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 ( + + +
+ +
+
+ +
+ ); +}; diff --git a/plugins/pagerduty/src/components/Escalation/index.ts b/plugins/pagerduty/src/components/Escalation/index.ts new file mode 100644 index 0000000000000..ac2db62cd9f3a --- /dev/null +++ b/plugins/pagerduty/src/components/Escalation/index.ts @@ -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'; diff --git a/plugins/pagerduty/src/components/Incident/IncidentEmptyState.tsx b/plugins/pagerduty/src/components/Incident/IncidentEmptyState.tsx new file mode 100644 index 0000000000000..b697422b0f91d --- /dev/null +++ b/plugins/pagerduty/src/components/Incident/IncidentEmptyState.tsx @@ -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 ( + + + Nice! No incidents found! + + + EmptyState + + + ); +}; diff --git a/plugins/pagerduty/src/components/Incident/IncidentListItem.tsx b/plugins/pagerduty/src/components/Incident/IncidentListItem.tsx new file mode 100644 index 0000000000000..860623dcd4733 --- /dev/null +++ b/plugins/pagerduty/src/components/Incident/IncidentListItem.tsx @@ -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 ( + + + +
+ {incident.status === 'triggered' ? ( + + ) : ( + + )} +
+
+
+ + Created {createdAt} ago and assigned to{' '} + + {user?.summary ?? 'nobody'} + + + } + /> + + + + + + + +
+ ); +}; diff --git a/plugins/pagerduty/src/components/Incident/Incidents.test.tsx b/plugins/pagerduty/src/components/Incident/Incidents.test.tsx new file mode 100644 index 0000000000000..9da9f9f11b4b8 --- /dev/null +++ b/plugins/pagerduty/src/components/Incident/Incidents.test.tsx @@ -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( + + + , + ), + ); + 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( + + + , + ), + ); + 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( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect( + getByText('Error encountered while fetching information. Error occurred'), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/pagerduty/src/components/Incident/Incidents.tsx b/plugins/pagerduty/src/components/Incident/Incidents.tsx new file mode 100644 index 0000000000000..732a582cdb1f1 --- /dev/null +++ b/plugins/pagerduty/src/components/Incident/Incidents.tsx @@ -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 ( + + Error encountered while fetching information. {error.message} + + ); + } + + if (loading) { + return ; + } + + if (!incidents?.length) { + return ; + } + + return ( + INCIDENTS}> + {incidents!.map((incident, index) => ( + + ))} + + ); +}; diff --git a/plugins/pagerduty/src/components/Incident/index.ts b/plugins/pagerduty/src/components/Incident/index.ts new file mode 100644 index 0000000000000..fb2702602b5e7 --- /dev/null +++ b/plugins/pagerduty/src/components/Incident/index.ts @@ -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'; diff --git a/plugins/pagerduty/src/components/PagerDutyCard.test.tsx b/plugins/pagerduty/src/components/PagerDutyCard.test.tsx new file mode 100644 index 0000000000000..a6615aee82aa1 --- /dev/null +++ b/plugins/pagerduty/src/components/PagerDutyCard.test.tsx @@ -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 = { + 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( + + + , + ), + ); + 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( + + + , + ), + ); + 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( + + + , + ), + ); + 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( + + + , + ), + ); + 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(); + }); +}); diff --git a/plugins/pagerduty/src/components/PagerDutyCard.tsx b/plugins/pagerduty/src/components/PagerDutyCard.tsx new file mode 100644 index 0000000000000..8fd20f37e8511 --- /dev/null +++ b/plugins/pagerduty/src/components/PagerDutyCard.tsx @@ -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(false); + const [refreshIncidents, setRefreshIncidents] = useState(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 ; + } + + if (error) { + return ( + + Error encountered while fetching information. {error.message} + + ); + } + + if (loading) { + return ; + } + + const serviceLink = { + title: 'Service Directory', + href: service!.url, + icon: , + }; + + const triggerLink = { + title: 'Create Incident', + action: ( + + ), + icon: , + }; + + return ( + + + + + + } + /> + ); +}; diff --git a/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.test.tsx b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.test.tsx new file mode 100644 index 0000000000000..8073ab44b5a72 --- /dev/null +++ b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.test.tsx @@ -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 = { + 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( + + {}} + name={entity.metadata.name} + integrationKey="abc123" + onIncidentCreated={() => {}} + /> + , + ), + ); + + 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', + }); + }); +}); diff --git a/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx new file mode 100644 index 0000000000000..4fa88a1b9a35c --- /dev/null +++ b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx @@ -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(''); + + const [{ value, loading, error }, handleTriggerAlarm] = useAsyncFn( + async (description: string) => + await api.triggerAlarm({ + integrationKey, + source: window.location.toString(), + description, + userName, + }), + ); + + const descriptionChanged = ( + event: React.ChangeEvent, + ) => { + 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 ( + + + This action will trigger an incident for "{name}". + + + + + 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. + + + + 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. + + + + + + + + + ); +}; diff --git a/plugins/pagerduty/src/components/TriggerDialog/index.ts b/plugins/pagerduty/src/components/TriggerDialog/index.ts new file mode 100644 index 0000000000000..655cef85044da --- /dev/null +++ b/plugins/pagerduty/src/components/TriggerDialog/index.ts @@ -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'; diff --git a/plugins/pagerduty/src/components/types.ts b/plugins/pagerduty/src/components/types.ts new file mode 100644 index 0000000000000..371f4eea6d0ce --- /dev/null +++ b/plugins/pagerduty/src/components/types.ts @@ -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; +}; diff --git a/plugins/pagerduty/src/index.ts b/plugins/pagerduty/src/index.ts new file mode 100644 index 0000000000000..4ecd4edcc63a2 --- /dev/null +++ b/plugins/pagerduty/src/index.ts @@ -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'; diff --git a/plugins/pagerduty/src/plugin.test.ts b/plugins/pagerduty/src/plugin.test.ts new file mode 100644 index 0000000000000..8d4545ac12fd4 --- /dev/null +++ b/plugins/pagerduty/src/plugin.test.ts @@ -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(); + }); +}); diff --git a/plugins/pagerduty/src/plugin.ts b/plugins/pagerduty/src/plugin.ts new file mode 100644 index 0000000000000..34796f491ec0c --- /dev/null +++ b/plugins/pagerduty/src/plugin.ts @@ -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), + }), + ], +}); diff --git a/plugins/pagerduty/src/setupTests.ts b/plugins/pagerduty/src/setupTests.ts new file mode 100644 index 0000000000000..0bfa67b49a755 --- /dev/null +++ b/plugins/pagerduty/src/setupTests.ts @@ -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';