diff --git a/.changeset/calm-icons-jump.md b/.changeset/calm-icons-jump.md new file mode 100644 index 0000000000..16054a80e6 --- /dev/null +++ b/.changeset/calm-icons-jump.md @@ -0,0 +1,23 @@ +--- +'@backstage/core-api': patch +'@backstage/dev-utils': patch +--- + +Update ApiFactory type to correctly infer API type and disallow mismatched implementations. + +This fixes for example the following code: + +```ts +interface MyApi { + myMethod(): void +} + +const myApiRef = createApiRef({...}); + +createApiFactory({ + api: myApiRef, + deps: {}, + // This should've caused an error, since the empty object does not fully implement MyApi + factory: () => ({}), +}) +``` diff --git a/.changeset/great-spiders-repair.md b/.changeset/great-spiders-repair.md new file mode 100644 index 0000000000..2cf1ddd8e8 --- /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/.changeset/grumpy-meals-wink.md b/.changeset/grumpy-meals-wink.md new file mode 100644 index 0000000000..8fb346b831 --- /dev/null +++ b/.changeset/grumpy-meals-wink.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-import': patch +--- + +Align plugin ID and fix variable typo diff --git a/.changeset/grumpy-toys-live.md b/.changeset/grumpy-toys-live.md new file mode 100644 index 0000000000..bb11001601 --- /dev/null +++ b/.changeset/grumpy-toys-live.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-import': patch +--- + +Add register existing component instructions diff --git a/.changeset/perfect-donkeys-hope.md b/.changeset/perfect-donkeys-hope.md new file mode 100644 index 0000000000..80feae11a0 --- /dev/null +++ b/.changeset/perfect-donkeys-hope.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Fix config schema for `.app.listen` diff --git a/.changeset/purple-ghosts-attack.md b/.changeset/purple-ghosts-attack.md new file mode 100644 index 0000000000..bf13d541e2 --- /dev/null +++ b/.changeset/purple-ghosts-attack.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search': patch +--- + +change default size for pageSize in search result view diff --git a/.changeset/shaggy-camels-remain.md b/.changeset/shaggy-camels-remain.md new file mode 100644 index 0000000000..f1c4e17643 --- /dev/null +++ b/.changeset/shaggy-camels-remain.md @@ -0,0 +1,8 @@ +--- +'example-backend': patch +'@backstage/plugin-scaffolder-backend': patch +'@backstage/plugin-techdocs-backend': patch +'@backstage/create-app': patch +--- + +Unify `dockerode` library and type dependency versions diff --git a/.changeset/unlucky-kiwis-rescue.md b/.changeset/unlucky-kiwis-rescue.md new file mode 100644 index 0000000000..71afaeb44a --- /dev/null +++ b/.changeset/unlucky-kiwis-rescue.md @@ -0,0 +1,14 @@ +--- +'@backstage/core': minor +--- + +Introducing a new optional property within `app-config.yaml` called `auth.environment` to have configurable environment value for `auth.providers` + +**Default Value:** 'development' + +**Optional Values:** 'production' | 'development' + +**Migration-steps:** + +- To override the default value, one could simply introduce the new property `environment` within the `auth` section of the `config.yaml` +- re-run the build to reflect the changed configs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ac33ca7f8..9df533bb88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,9 @@ # Backstage Changelog -This is a best-effort changelog where we manually collect breaking changes. It is not an exhaustive list of all changes or even features added. +This changelog is no longer being updated and will be removed in the future, as each package now has its own changelog instead. It was a best-effort changelog where we manually collected breaking changes during the `v0.1.1-alpha.` releases. If you encounter issues while upgrading to a newer version, don't hesitate to reach out on [Discord](https://discord.gg/EBHEGzX) or [open an issue](https://github.com/backstage/backstage/issues/new/choose)! -## Next Release - -> Collect changes for the next release below - ## v0.1.1-alpha.26 ### @backstage/cli diff --git a/app-config.yaml b/app-config.yaml index 4ad6f5345b..65581b7d23 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: @@ -194,6 +200,7 @@ scaffolder: token: $env: AZURE_TOKEN auth: + environment: development ### Providing an auth.session.secret will enable session support in the auth-backend # session: # secret: custom session secret @@ -313,3 +320,5 @@ homepage: timezone: 'Europe/Stockholm' - label: TYO timezone: 'Asia/Tokyo' +pagerduty: + eventsBaseUrl: 'https://events.pagerduty.com/v2' diff --git a/docs/assets/software-catalog/bsc-register-2.png b/docs/assets/software-catalog/bsc-register-2.png index de71141ba0..18a6c5a4f0 100644 Binary files a/docs/assets/software-catalog/bsc-register-2.png and b/docs/assets/software-catalog/bsc-register-2.png differ diff --git a/packages/app/package.json b/packages/app/package.json index fd6667ac85..afe35ff382 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 068019c622..13cbe324c6 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 a02365d17d..c6a2a0e7e0 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/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts index 692a3c6dd8..de798a7067 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -196,6 +196,7 @@ export class GithubUrlReader implements UrlReader { new URL( `${protocol}://${resource}/${full_name}/archive/${ref}.tar.gz`, ).toString(), + getRawRequestOptions(this.config), ); if (!response.ok) { const message = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`; diff --git a/packages/backend/package.json b/packages/backend/package.json index 9e635902e9..5937d6a6f3 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -34,7 +34,7 @@ "@gitbeaker/node": "^25.2.0", "@octokit/rest": "^18.0.0", "azure-devops-node-api": "^10.1.1", - "dockerode": "^3.2.0", + "dockerode": "^3.2.1", "example-app": "^0.2.5", "express": "^4.17.1", "express-promise-router": "^3.0.3", @@ -46,7 +46,7 @@ }, "devDependencies": { "@backstage/cli": "^0.4.0", - "@types/dockerode": "^2.5.32", + "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5", "@types/helmet": "^0.0.48" diff --git a/packages/cli/package.json b/packages/cli/package.json index fd02473453..0d585436bb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -178,7 +178,7 @@ "description": "Listening configuration for local development", "properties": { "host": { - "type": "number", + "type": "string", "visibility": "frontend", "description": "The host that the frontend should be bound to. Only used for local development." }, diff --git a/packages/core-api/src/apis/system/ApiFactoryRegistry.ts b/packages/core-api/src/apis/system/ApiFactoryRegistry.ts index 556f63589c..9d9b058eac 100644 --- a/packages/core-api/src/apis/system/ApiFactoryRegistry.ts +++ b/packages/core-api/src/apis/system/ApiFactoryRegistry.ts @@ -55,9 +55,9 @@ export class ApiFactoryRegistry implements ApiFactoryHolder { * A factory will not be added to the registry if there is already * an existing factory with the same or higher priority. */ - register( + register( scope: ApiFactoryScope, - factory: ApiFactory, + factory: ApiFactory, ) { const priority = ScopePriority[scope]; const existing = this.factories.get(factory.api); @@ -69,12 +69,14 @@ export class ApiFactoryRegistry implements ApiFactoryHolder { return true; } - get(api: ApiRef): ApiFactory | undefined { + get( + api: ApiRef, + ): ApiFactory | undefined { const tuple = this.factories.get(api); if (!tuple) { return undefined; } - return tuple.factory as ApiFactory; + return tuple.factory as ApiFactory; } getAllApis(): Set { diff --git a/packages/core-api/src/apis/system/helpers.ts b/packages/core-api/src/apis/system/helpers.ts index 0ccd0cdb80..cabff73060 100644 --- a/packages/core-api/src/apis/system/helpers.ts +++ b/packages/core-api/src/apis/system/helpers.ts @@ -25,18 +25,19 @@ export function createApiFactory< Api, Impl extends Api, Deps extends { [name in string]: unknown } ->(factory: ApiFactory): ApiFactory; -export function createApiFactory( +>(factory: ApiFactory): ApiFactory; +export function createApiFactory( api: ApiRef, - instance: Api, -): ApiFactory; + instance: Impl, +): ApiFactory; export function createApiFactory< Api, + Impl extends Api, Deps extends { [name in string]: unknown } >( - factory: ApiFactory | ApiRef, - instance?: Api, -): ApiFactory { + factory: ApiFactory | ApiRef, + instance?: Impl, +): ApiFactory { if ('id' in factory) { return { api: factory, diff --git a/packages/core-api/src/apis/system/types.ts b/packages/core-api/src/apis/system/types.ts index b0c19e551c..6234a55c71 100644 --- a/packages/core-api/src/apis/system/types.ts +++ b/packages/core-api/src/apis/system/types.ts @@ -34,16 +34,24 @@ export type ApiHolder = { get(api: ApiRef): T | undefined; }; -export type ApiFactory = { +export type ApiFactory< + Api, + Impl extends Api, + Deps extends { [name in string]: unknown } +> = { api: ApiRef; deps: TypesToApiRefs; - factory(deps: Deps): Api; + factory(deps: Deps): Impl; }; -export type AnyApiFactory = ApiFactory; +export type AnyApiFactory = ApiFactory< + unknown, + unknown, + { [key in string]: unknown } +>; export type ApiFactoryHolder = { get( api: ApiRef, - ): ApiFactory | undefined; + ): ApiFactory | undefined; }; diff --git a/packages/core/config.d.ts b/packages/core/config.d.ts index 014e4ac934..a6f95ae71c 100644 --- a/packages/core/config.d.ts +++ b/packages/core/config.d.ts @@ -62,4 +62,17 @@ export interface Config { timezone: string; }[]; }; + + /** + * Configuration that provides information on available authentication providers configured for app + */ + auth?: { + /** + * The 'environment' attribute added as an optional parameter to have configurable environment value for `auth.providers`. + * default value: 'development' + * optional values: 'development' | 'production' + * @visibility frontend + */ + environment?: string; + }; } diff --git a/packages/core/src/api-wrappers/defaultApis.ts b/packages/core/src/api-wrappers/defaultApis.ts index 1f18f54d2a..d044b50b7a 100644 --- a/packages/core/src/api-wrappers/defaultApis.ts +++ b/packages/core/src/api-wrappers/defaultApis.ts @@ -78,30 +78,42 @@ export const defaultApis = [ deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => - GoogleAuth.create({ discoveryApi, oauthRequestApi }), + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + GoogleAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: microsoftAuthApiRef, deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => - MicrosoftAuth.create({ discoveryApi, oauthRequestApi }), + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + MicrosoftAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: githubAuthApiRef, deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => + factory: ({ discoveryApi, oauthRequestApi, configApi }) => GithubAuth.create({ discoveryApi, oauthRequestApi, defaultScopes: ['read:user'], + environment: configApi.getOptionalString('auth.environment'), }), }), createApiFactory({ @@ -109,60 +121,91 @@ export const defaultApis = [ deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => - OktaAuth.create({ discoveryApi, oauthRequestApi }), + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + OktaAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: gitlabAuthApiRef, deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => - GitlabAuth.create({ discoveryApi, oauthRequestApi }), + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + GitlabAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: auth0AuthApiRef, deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => - Auth0Auth.create({ discoveryApi, oauthRequestApi }), + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + Auth0Auth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: oauth2ApiRef, deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => - OAuth2.create({ discoveryApi, oauthRequestApi }), + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + OAuth2.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: samlAuthApiRef, deps: { discoveryApi: discoveryApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi }) => SamlAuth.create({ discoveryApi }), + factory: ({ discoveryApi, configApi }) => + SamlAuth.create({ + discoveryApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: oneloginAuthApiRef, deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => - OneLoginAuth.create({ discoveryApi, oauthRequestApi }), + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + OneLoginAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: oidcAuthApiRef, deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => + factory: ({ discoveryApi, oauthRequestApi, configApi }) => OAuth2.create({ discoveryApi, oauthRequestApi, @@ -171,6 +214,7 @@ export const defaultApis = [ title: 'Your Identity Provider', icon: OAuth2Icon, }, + environment: configApi.getOptionalString('auth.environment'), }), }), ]; diff --git a/packages/create-app/templates/default-app/packages/backend/package.json.hbs b/packages/create-app/templates/default-app/packages/backend/package.json.hbs index 07d1b0be58..8dccb0b683 100644 --- a/packages/create-app/templates/default-app/packages/backend/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/backend/package.json.hbs @@ -29,7 +29,7 @@ "@backstage/plugin-techdocs-backend": "^{{version '@backstage/plugin-techdocs-backend'}}", "@octokit/rest": "^18.0.0", "@gitbeaker/node": "^25.2.0", - "dockerode": "^3.2.0", + "dockerode": "^3.2.1", "express": "^4.17.1", "express-promise-router": "^3.0.3", "knex": "^0.21.6", @@ -43,7 +43,7 @@ }, "devDependencies": { "@backstage/cli": "^{{version '@backstage/cli'}}", - "@types/dockerode": "^2.5.32", + "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5", "@types/helmet": "^0.0.47" diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index cca3470f3f..869de01024 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -56,9 +56,11 @@ class DevAppBuilder { /** * Register an API factory to add to the app */ - registerApi( - factory: ApiFactory, - ): DevAppBuilder { + registerApi< + Api, + Impl extends Api, + Deps extends { [name in string]: unknown } + >(factory: ApiFactory): DevAppBuilder { this.apis.push(factory); return this; } diff --git a/plugins/catalog-import/src/api/CatalogImportApi.ts b/plugins/catalog-import/src/api/CatalogImportApi.ts index 49012764e2..0ce569171f 100644 --- a/plugins/catalog-import/src/api/CatalogImportApi.ts +++ b/plugins/catalog-import/src/api/CatalogImportApi.ts @@ -18,7 +18,7 @@ import { createApiRef } from '@backstage/core'; import { PartialEntity } from '../util/types'; export const catalogImportApiRef = createApiRef({ - id: 'plugin.catalogimport.service', + id: 'plugin.catalog-import.service', description: 'Used by the catalog import plugin to make requests', }); diff --git a/plugins/catalog-import/src/api/CatalogImportClient.ts b/plugins/catalog-import/src/api/CatalogImportClient.ts index 94ba260d55..aaa110329e 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.ts @@ -160,7 +160,7 @@ export class CatalogImportClient implements CatalogImportApi { ); }); - const pullRequestRespone = await octo.pulls + const pullRequestResponse = await octo.pulls .create({ owner, repo, @@ -178,7 +178,7 @@ export class CatalogImportClient implements CatalogImportApi { }); return { - link: pullRequestRespone.data.html_url, + link: pullRequestResponse.data.html_url, location: `https://github.com/${owner}/${repo}/blob/${repoData.data.default_branch}/${fileName}`, }; } diff --git a/plugins/catalog-import/src/components/ImportComponentForm.tsx b/plugins/catalog-import/src/components/ImportComponentForm.tsx index 9a4fd38a06..dab5ccbea2 100644 --- a/plugins/catalog-import/src/components/ImportComponentForm.tsx +++ b/plugins/catalog-import/src/components/ImportComponentForm.tsx @@ -99,7 +99,7 @@ export const RegisterComponentForm = ({ nextStep, saveConfig }: Props) => { variant="outlined" label="Repository URL" error={hasErrors} - placeholder="https://github.com/spotify/backstage" + placeholder="https://github.com/backstage/backstage" name="componentLocation" required margin="normal" diff --git a/plugins/catalog-import/src/components/ImportComponentPage.tsx b/plugins/catalog-import/src/components/ImportComponentPage.tsx index c2eb935e54..9fdb465dd1 100644 --- a/plugins/catalog-import/src/components/ImportComponentPage.tsx +++ b/plugins/catalog-import/src/components/ImportComponentPage.tsx @@ -15,7 +15,7 @@ */ import React, { useState } from 'react'; -import { Grid } from '@material-ui/core'; +import { Grid, Typography } from '@material-ui/core'; import { InfoCard, Page, @@ -57,14 +57,41 @@ export const ImportComponentPage = ({
- + - Start tracking your component in Backstage. TODO: Add more - information about what this is. + Start tracking your component in Backstage by adding it to the + software catalog. - - + + + + + There are two ways to register an existing component. + + GitHub Repo + + If you already have code in a GitHub repository, enter the full + URL to your repo and a new pull request with a sample Backstage + metadata Entity File (catalog-info.yaml) will be + opened for you. + + + GitHub Repo & Entity File + + + If you've already created a Backstage metadata file and put it + in your repo, you can enter the full URL to that Entity File. + + + + + + + ); +} +``` + +## 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 0000000000..264d6f801f --- /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 0000000000..4d58a1da42 --- /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 0000000000..527a56b2af --- /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 0000000000..90604c4012 --- /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 0000000000..733f171489 --- /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 0000000000..57b35238dd --- /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 0000000000..7d5419908a --- /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 0000000000..5e4ea96fe0 --- /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 0000000000..5463372ac0 --- /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 0000000000..4e495daa54 --- /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 0000000000..c22552b7c6 --- /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 0000000000..3c2dfa65f2 --- /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 0000000000..15ff3278a0 --- /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 0000000000..5e67d52172 --- /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 0000000000..41995c86f5 --- /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 0000000000..d587011601 --- /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 0000000000..ac2db62cd9 --- /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 0000000000..b697422b0f --- /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 0000000000..860623dcd4 --- /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 0000000000..9da9f9f11b --- /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 0000000000..732a582cdb --- /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 0000000000..fb2702602b --- /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 0000000000..a6615aee82 --- /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 0000000000..8fd20f37e8 --- /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 0000000000..8073ab44b5 --- /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 0000000000..4fa88a1b9a --- /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 0000000000..655cef8504 --- /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 0000000000..371f4eea6d --- /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 0000000000..4ecd4edcc6 --- /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 0000000000..8d4545ac12 --- /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 0000000000..34796f491e --- /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 0000000000..0bfa67b49a --- /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'; diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index f71d8bc36c..7aa8fc873a 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -26,13 +26,13 @@ "@gitbeaker/core": "^25.2.0", "@gitbeaker/node": "^25.2.0", "@octokit/rest": "^18.0.0", - "@types/dockerode": "^2.5.32", + "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", "azure-devops-node-api": "^10.1.1", "command-exists-promise": "^2.0.2", "compression": "^1.7.4", "cors": "^2.8.5", - "dockerode": "^3.2.0", + "dockerode": "^3.2.1", "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.0", diff --git a/plugins/search/src/components/SearchResult/SearchResult.tsx b/plugins/search/src/components/SearchResult/SearchResult.tsx index 9fd54ac06c..4bbb15e229 100644 --- a/plugins/search/src/components/SearchResult/SearchResult.tsx +++ b/plugins/search/src/components/SearchResult/SearchResult.tsx @@ -251,7 +251,7 @@ export const SearchResult = ({ searchQuery }: SearchResultProps) => { )}