diff --git a/.changeset/old-pigs-happen.md b/.changeset/old-pigs-happen.md new file mode 100644 index 0000000000..2af5969966 --- /dev/null +++ b/.changeset/old-pigs-happen.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-api': patch +--- + +Add a global type definition for `Symbol.observable`, fix type checking in projects that didn't already have it defined. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b07519824b..206c1a7883 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -16,6 +16,7 @@ /plugins/search-* @backstage/techdocs-core /plugins/techdocs @backstage/techdocs-core /plugins/techdocs-backend @backstage/techdocs-core +/plugins/ilert @yacut /packages/search-common @backstage/techdocs-core /packages/techdocs-common @backstage/techdocs-core /.changeset/cost-insights-* @backstage/silver-lining diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 4e3c6d9a88..7840b331e1 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -1,5 +1,6 @@ Apdex Api +automations Autoscaling Avro Bigtable @@ -32,12 +33,16 @@ GitLab Grafana GraphQL Hackathons +Helidon Heroku +hoc Hostname Iain JaCoCo JavaScript +Jira Kaewkasi +kubernetes Knex Leasot Lerna @@ -47,6 +52,8 @@ Minikube Minio Mkdocs Monorepo +microservices +microservice Namespaces OAuth Okta diff --git a/.github/workflows/changeset.yml b/.github/workflows/changeset.yml index 4bd03b4d96..70afd15eee 100644 --- a/.github/workflows/changeset.yml +++ b/.github/workflows/changeset.yml @@ -20,3 +20,4 @@ jobs: version: yarn release env: GITHUB_TOKEN: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }} + HUSKY: '0' diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 0000000000..31354ec138 --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000000..d2ae35e84b --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +yarn lint-staged diff --git a/ADOPTERS.md b/ADOPTERS.md index 45842b1833..cc7eaa28a5 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -1,27 +1,31 @@ -| Organization | Contact | Description of Use | -| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| [Spotify](https://www.spotify.com) | [@leemills83](https://github.com/leemills83) | Main interface towards all of Spotify's infrastructure and technical documentation. | -| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. | -| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. | -| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up | -| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. | -| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. | -| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. | -| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications | -| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. | -| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | -| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D | -| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling | -| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling | -| [Telenor Sweden](https://www.telenor.se) | [@O5ten](https://github.com/O5ten) | Building a developer portal for scaffolding projects towards our unified build environment and microservice stacks | -| [Fiverr](https://www.fiverr.com) | [@nirga](https://github.com/nirga) | Unifying separate tools that developers are using today (i.e. monitoring, dead letter queues management, etc.) into a single platform. | -| [Zalando SE](https://www.zalando.de) | [@leviferreira](https://github.com/leviferreira) | Building V2 of the Internal Development Portal. | -| [LegalZoom](https://legalzoom.com) | [@backjo](https://github.com/backjo) | Developer portal - hub for all engineering projects and metadata. | -| [Expedia Group](https://www.expediagroup.com) | [Mike Turner](mailto:miturner@expediagroup.com), [Sneha Kumar](mailto:snkumar@expediagroup.com), [@guillermomanzo](https://github.com/guillermomanzo), [Erik Lindgren](https://github.com/lindgren) | EG Common Developer Toolkit | -| [Paddle.com](https://paddle.com) | [Ioannis Georgoulas](https://github.com/geototti21) | Developer portal (Tech Docs, Service Catalog, Internal Tooling), we use vanilla Backstage FE and custom BE implementation in Go | -| [Acast.com](https://acast.com) | [Olle Lundberg](https://github.com/lndbrg) | Developer portal with tech docs, service catalog and a bunch of other internal tooling | -| [Lunar](https://lunar.app) | [Jacob Valdemar](https://github.com/JacobValdemar) | Internal developer portal for service overview and insights, API documentation, technical guides, onboarding guides and RFC's. | -| [Trendyol](https://trendyol.com) | [Erdogan Oksuz](https://github.com/erdoganoksuz) | The Developer Portal has been called `Pandora`. Provides an overview of Trendyol tech ecosystem. TechDocs, Catalog, Custom Plugins and Theme. | -| [Peloton](https://www.onepeloton.com/) | [Jim Haughwout](https://github.com/JimHaughwout) | Creating our first developer portal and tech-docs. Exploring Service Catalog, Tech Insights and Cost Insights as well. | -| [TELUS](https://telus.com) | [Seb Barre](https://github.com/sbarre) | The Go-to place to find answers about development and delivery at TELUS. | -| [Brex](https://www.brex.com/) | [Vamsi Chitters](https://github.com/vamsikc) | A centralized UI to understand how a service fits in the whole Brex architecture and manage a team’s engineering dependencies. | +| Organization | Contact | Description of Use | +| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [Spotify](https://www.spotify.com) | [@leemills83](https://github.com/leemills83) | Main interface towards all of Spotify's infrastructure and technical documentation. | +| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. | +| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. | +| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up | +| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. | +| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. | +| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. | +| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications | +| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. | +| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | +| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D | +| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling | +| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling | +| [Telenor Sweden](https://www.telenor.se) | [@O5ten](https://github.com/O5ten) | Building a developer portal for scaffolding projects towards our unified build environment and microservice stacks | +| [Fiverr](https://www.fiverr.com) | [@nirga](https://github.com/nirga) | Unifying separate tools that developers are using today (i.e. monitoring, dead letter queues management, etc.) into a single platform. | +| [Zalando SE](https://www.zalando.de) | [@leviferreira](https://github.com/leviferreira) | Building V2 of the Internal Development Portal. | +| [LegalZoom](https://legalzoom.com) | [@backjo](https://github.com/backjo) | Developer portal - hub for all engineering projects and metadata. | +| [Expedia Group](https://www.expediagroup.com) | [Mike Turner](mailto:miturner@expediagroup.com), [Sneha Kumar](mailto:snkumar@expediagroup.com), [@guillermomanzo](https://github.com/guillermomanzo), [Erik Lindgren](https://github.com/lindgren) | EG Common Developer Toolkit | +| [Paddle.com](https://paddle.com) | [Ioannis Georgoulas](https://github.com/geototti21) | Developer portal (Tech Docs, Service Catalog, Internal Tooling), we use vanilla Backstage FE and custom BE implementation in Go | +| [Acast.com](https://acast.com) | [Olle Lundberg](https://github.com/lndbrg) | Developer portal with tech docs, service catalog and a bunch of other internal tooling | +| [Lunar](https://lunar.app) | [Jacob Valdemar](https://github.com/JacobValdemar) | Internal developer portal for service overview and insights, API documentation, technical guides, onboarding guides and RFC's. | +| [Trendyol](https://trendyol.com) | [Erdogan Oksuz](https://github.com/erdoganoksuz) | The Developer Portal has been called `Pandora`. Provides an overview of Trendyol tech ecosystem. TechDocs, Catalog, Custom Plugins and Theme. | +| [Peloton](https://www.onepeloton.com/) | [Jim Haughwout](https://github.com/JimHaughwout) | Creating our first developer portal and tech-docs. Exploring Service Catalog, Tech Insights and Cost Insights as well. | +| [TELUS](https://telus.com) | [Seb Barre](https://github.com/sbarre) | The Go-to place to find answers about development and delivery at TELUS. | +| [Brex](https://www.brex.com/) | [Vamsi Chitters](https://github.com/vamsikc) | A centralized UI to understand how a service fits in the whole Brex architecture and manage a team’s engineering dependencies. | +| [Oriflame](https://www.oriflame.com/) | [Oriflame](https://github.com/oriflame) | Internal developer portal for services, single page apps and packages overview, API documentation, technical guides, tech-radar and more. | +| [Booz Allen Hamilton](https://www.boozallen.com/) | [Jason Miller](https://github.com/JasonMiller-BAH) | Developer portal for a full-stack software development ecosystem that accelerates consistent and repeatable Modern Software Development practices for internal innovation and investments. | +| [Netflix](https://www.netflix.com/) | [bleathem](https://github.com/bleathem) | Our Backstage implementation will be the front door to a unified experience connecting our internal platform products across important workflows with integrated knowledge and support. | +| [b.well](https://www.icanbwell.com/) | [Jacob Rosales](https://github.com/jrosales) | Foundation for our engineering portal and cloud insights. | diff --git a/OWNERS.md b/OWNERS.md index a79328dc20..cdbc234183 100644 --- a/OWNERS.md +++ b/OWNERS.md @@ -27,5 +27,5 @@ People that have made significant contributions to the project and earned write - Andrew Thauer - Wealthsimple (GitHub: [andrewthauer](https://github.com/andrewthauer)) - Oliver Sand - SDA SE (GitHub: [Fox32](https://github.com/Fox32)) - David Tuite - Roadie (GitHub: [dtuite](https://github.com/dtuite)) -- Adam Harvey - DXC Technology (GitHub: [adamdmharvey](https://github.com/adamdmharvey)) +- Adam Harvey - Cisco (GitHub: [adamdmharvey](https://github.com/adamdmharvey)) - Dominik Henneke - SDA SE (GitHub: [dhenneke](https://github.com/dhenneke)) diff --git a/app-config.yaml b/app-config.yaml index 3c4f2d7ea5..b1c915f85a 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -81,6 +81,13 @@ proxy: headers: Authorization: ${SENTRY_TOKEN} + '/ilert': + target: https://api.ilert.com + allowedMethods: ['GET', 'POST', 'PUT'] + allowedHeaders: ['Authorization'] + headers: + Authorization: ${ILERT_AUTH_HEADER} + organization: name: My Company diff --git a/cypress/package.json b/cypress/package.json index 8549386f7e..6a231326c4 100644 --- a/cypress/package.json +++ b/cypress/package.json @@ -5,7 +5,7 @@ "license": "MIT", "private": true, "dependencies": { - "cypress": "^6.4.0", + "cypress": "^7.3.0", "typescript": "^4.1.3" } } diff --git a/cypress/yarn.lock b/cypress/yarn.lock index 3c737f6ebc..baf0d92715 100644 --- a/cypress/yarn.lock +++ b/cypress/yarn.lock @@ -53,7 +53,12 @@ dependencies: any-observable "^0.3.0" -"@types/sinonjs__fake-timers@^6.0.1": +"@types/node@^14.14.31": + version "14.17.0" + resolved "https://registry.npmjs.org/@types/node/-/node-14.17.0.tgz#3ba770047723b3eeb8dc9fca02cce8a7fb6378da" + integrity sha512-w8VZUN/f7SSbvVReb9SWp6cJFevxb4/nkG65yLAya//98WgocKm5PLDAtSs5CtJJJM+kHmJjO/6mmYW4MHShZA== + +"@types/sinonjs__fake-timers@^6.0.2": version "6.0.2" resolved "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.2.tgz#3a84cf5ec3249439015e14049bd3161419bf9eae" integrity sha512-dIPoZ3g5gcx9zZEszaxLSVTvMReD3xxyyDnQUjA6IYDG9Ba2AV0otMPs+77sG9ojB4Qr2N2Vk5RnKeuA0X/0bg== @@ -117,7 +122,7 @@ any-observable@^0.3.0: resolved "https://registry.npmjs.org/any-observable/-/any-observable-0.3.0.tgz#af933475e5806a67d0d7df090dd5e8bef65d119b" integrity sha512-/FQM1EDkTsf63Ub2C6O7GuYFDsSXUwsaZDurV0np41ocwq0jthUAYCmhBX9f+KwlaCgIuWyr/4WlUQUBfKfZog== -arch@^2.1.2: +arch@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11" integrity sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ== @@ -171,7 +176,7 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" -blob-util@2.0.2: +blob-util@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz#3b4e3c281111bb7f11128518006cdc60b403a1eb" integrity sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ== @@ -242,10 +247,10 @@ check-more-types@^2.24.0: resolved "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz#1420ffb10fd444dcfc79b43891bbfffd32a84600" integrity sha1-FCD/sQ/URNz8ebQ4kbv//TKoRgA= -ci-info@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" - integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== +ci-info@^3.1.1: + version "3.1.1" + resolved "https://registry.npmjs.org/ci-info/-/ci-info-3.1.1.tgz#9a32fcefdf7bcdb6f0a7e1c0f8098ec57897b80a" + integrity sha512-kdRWLBIJwdsYJWYJFtAFFYxybguqeF91qpZaggjG5Nf8QKdizFG2hjqvaTXbxFIcYbSaD74KpAXv6BSm17DHEQ== cli-cursor@^1.0.2: version "1.0.2" @@ -359,18 +364,19 @@ cross-spawn@^7.0.0: shebang-command "^2.0.0" which "^2.0.1" -cypress@^6.4.0: - version "6.4.0" - resolved "https://registry.npmjs.org/cypress/-/cypress-6.4.0.tgz#432c516bf4f1a0f042a6aa1f2c3a4278fa35a8b2" - integrity sha512-SrsPsZ4IBterudkoFYBvkQmXOVxclh1/+ytbzpV8AH/D2FA+s2Qy5ISsaRzOFsbQa4KZWoi3AKwREmF1HucYkg== +cypress@^7.3.0: + version "7.3.0" + resolved "https://registry.npmjs.org/cypress/-/cypress-7.3.0.tgz#17345b8d18681c120f033e7d8fd0f0271e9d0d51" + integrity sha512-aseRCH1tRVCrM6oEfja6fR/bo5l6e4SkHRRSATh27UeN4f/ANC8U7tGIulmrISJVy9xuOkOdbYKbUb2MNM+nrw== dependencies: "@cypress/listr-verbose-renderer" "^0.4.1" "@cypress/request" "^2.88.5" "@cypress/xvfb" "^1.2.4" - "@types/sinonjs__fake-timers" "^6.0.1" + "@types/node" "^14.14.31" + "@types/sinonjs__fake-timers" "^6.0.2" "@types/sizzle" "^2.3.2" - arch "^2.1.2" - blob-util "2.0.2" + arch "^2.2.0" + blob-util "^2.0.2" bluebird "^3.7.2" cachedir "^2.3.0" chalk "^4.1.0" @@ -378,27 +384,26 @@ cypress@^6.4.0: cli-table3 "~0.6.0" commander "^5.1.0" common-tags "^1.8.0" - dayjs "^1.9.3" - debug "^4.1.1" - eventemitter2 "^6.4.2" - execa "^4.0.2" + dayjs "^1.10.4" + debug "4.3.2" + eventemitter2 "^6.4.3" + execa "4.1.0" executable "^4.1.1" extract-zip "^1.7.0" - fs-extra "^9.0.1" + fs-extra "^9.1.0" getos "^3.2.1" - is-ci "^2.0.0" - is-installed-globally "^0.3.2" + is-ci "^3.0.0" + is-installed-globally "~0.4.0" lazy-ass "^1.6.0" listr "^0.14.3" - lodash "^4.17.19" + lodash "^4.17.21" log-symbols "^4.0.0" minimist "^1.2.5" - moment "^2.29.1" ospath "^1.2.2" - pretty-bytes "^5.4.1" - ramda "~0.26.1" + pretty-bytes "^5.6.0" + ramda "~0.27.1" request-progress "^3.0.0" - supports-color "^7.2.0" + supports-color "^8.1.1" tmp "~0.2.1" untildify "^4.0.0" url "^0.11.0" @@ -416,11 +421,18 @@ date-fns@^1.27.2: resolved "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c" integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw== -dayjs@^1.9.3: +dayjs@^1.10.4: version "1.10.4" resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.10.4.tgz#8e544a9b8683f61783f570980a8a80eaf54ab1e2" integrity sha512-RI/Hh4kqRc1UKLOAf/T5zdMMX5DQIlDxwUe3wSyMMnEbGunnpENCdbUgM+dW7kXidZqCttBrmw7BhN4TMddkCw== +debug@4.3.2: + version "4.3.2" + resolved "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b" + integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw== + dependencies: + ms "2.1.2" + debug@^2.6.9: version "2.6.9" resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -435,13 +447,6 @@ debug@^3.1.0: dependencies: ms "^2.1.1" -debug@^4.1.1: - version "4.3.1" - resolved "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" - integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== - dependencies: - ms "2.1.2" - delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" @@ -477,12 +482,12 @@ escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= -eventemitter2@^6.4.2: - version "6.4.3" - resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.3.tgz#35c563619b13f3681e7eb05cbdaf50f56ba58820" - integrity sha512-t0A2msp6BzOf+QAcI6z9XMktLj52OjGQg+8SJH6v5+3uxNpWYRR3wQmfA+6xtMU9kOC59qk9licus5dYcrYkMQ== +eventemitter2@^6.4.3: + version "6.4.4" + resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.4.tgz#aa96e8275c4dbeb017a5d0e03780c65612a1202b" + integrity sha512-HLU3NDY6wARrLCEwyGKRBvuWYyvW6mHYv72SJJAH3iJN3a6eVUvkjFkcxah1bcTgGVBBrFdIopBJPhCQFMLyXw== -execa@^4.0.2: +execa@4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a" integrity sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA== @@ -580,7 +585,7 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" -fs-extra@^9.0.1: +fs-extra@^9.1.0: version "9.1.0" resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== @@ -628,12 +633,12 @@ glob@^7.1.3: once "^1.3.0" path-is-absolute "^1.0.0" -global-dirs@^2.0.1: - version "2.1.0" - resolved "https://registry.npmjs.org/global-dirs/-/global-dirs-2.1.0.tgz#e9046a49c806ff04d6c1825e196c8f0091e8df4d" - integrity sha512-MG6kdOUh/xBnyo9cJFeIKkLEc1AyFq42QTU4XiX51i2NEdxLxLWXIjEjmqKeSuKR7pAZjTqUVoT2b2huxVLgYQ== +global-dirs@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.0.tgz#70a76fe84ea315ab37b1f5576cbde7d48ef72686" + integrity sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA== dependencies: - ini "1.3.7" + ini "2.0.0" graceful-fs@^4.1.6, graceful-fs@^4.2.0: version "4.2.5" @@ -702,17 +707,17 @@ inherits@2, inherits@^2.0.3, inherits@~2.0.3: resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== -ini@1.3.7: - version "1.3.7" - resolved "https://registry.npmjs.org/ini/-/ini-1.3.7.tgz#a09363e1911972ea16d7a8851005d84cf09a9a84" - integrity sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ== - -is-ci@^2.0.0: +ini@2.0.0: version "2.0.0" - resolved "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" - integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== + resolved "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" + integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== + +is-ci@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/is-ci/-/is-ci-3.0.0.tgz#c7e7be3c9d8eef7d0fa144390bd1e4b88dc4c994" + integrity sha512-kDXyttuLeslKAHYL/K28F2YkM3x5jvFPEw3yXbRptXydjD9rpLEz+C5K5iutY9ZiUu6AP41JdvRQwF4Iqs4ZCQ== dependencies: - ci-info "^2.0.0" + ci-info "^3.1.1" is-fullwidth-code-point@^1.0.0: version "1.0.0" @@ -731,13 +736,13 @@ is-fullwidth-code-point@^3.0.0: resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== -is-installed-globally@^0.3.2: - version "0.3.2" - resolved "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.3.2.tgz#fd3efa79ee670d1187233182d5b0a1dd00313141" - integrity sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g== +is-installed-globally@~0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz#9a0fd407949c30f86eb6959ef1b7994ed0b7b520" + integrity sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ== dependencies: - global-dirs "^2.0.1" - is-path-inside "^3.0.1" + global-dirs "^3.0.0" + is-path-inside "^3.0.2" is-observable@^1.1.0: version "1.1.0" @@ -746,10 +751,10 @@ is-observable@^1.1.0: dependencies: symbol-observable "^1.1.0" -is-path-inside@^3.0.1: - version "3.0.2" - resolved "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.2.tgz#f5220fc82a3e233757291dddc9c5877f2a1f3017" - integrity sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg== +is-path-inside@^3.0.2: + version "3.0.3" + resolved "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" + integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== is-promise@^2.1.0: version "2.2.2" @@ -879,10 +884,10 @@ lodash.once@^4.1.1: resolved "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w= -lodash@^4.17.19: - version "4.17.20" - resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" - integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== log-symbols@^1.0.2: version "1.0.2" @@ -953,11 +958,6 @@ mkdirp@^0.5.4: dependencies: minimist "^1.2.5" -moment@^2.29.1: - version "2.29.1" - resolved "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" - integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== - ms@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -1056,10 +1056,10 @@ pify@^2.2.0: resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw= -pretty-bytes@^5.4.1: - version "5.5.0" - resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.5.0.tgz#0cecda50a74a941589498011cf23275aa82b339e" - integrity sha512-p+T744ZyjjiaFlMUZZv6YPC5JrkNj8maRmPaQCWFJFplUAzpIUTRaTcS+7wmZtUoFXHtESJb23ISliaWyz3SHA== +pretty-bytes@^5.6.0: + version "5.6.0" + resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" + integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg== process-nextick-args@~2.0.0: version "2.0.1" @@ -1099,10 +1099,10 @@ querystring@0.2.0: resolved "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= -ramda@~0.26.1: - version "0.26.1" - resolved "https://registry.npmjs.org/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06" - integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ== +ramda@~0.27.1: + version "0.27.1" + resolved "https://registry.npmjs.org/ramda/-/ramda-0.27.1.tgz#66fc2df3ef873874ffc2da6aa8984658abacf5c9" + integrity sha512-PgIdVpn5y5Yns8vqb8FzBUEYn98V3xcPgawAkkgj0YJ0qDsnHCiNmZYfOGMgOvoB0eWFLpYbhxUR3mxfDIMvpw== readable-stream@^2.2.2: version "2.3.7" @@ -1277,13 +1277,20 @@ supports-color@^5.3.0: dependencies: has-flag "^3.0.0" -supports-color@^7.1.0, supports-color@^7.2.0: +supports-color@^7.1.0: version "7.2.0" resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== dependencies: has-flag "^4.0.0" +supports-color@^8.1.1: + version "8.1.1" + resolved "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + symbol-observable@^1.1.0: version "1.2.0" resolved "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" diff --git a/docs/architecture-decisions/adr002-default-catalog-file-format.md b/docs/architecture-decisions/adr002-default-catalog-file-format.md index 8523b4a111..013e2cbf39 100644 --- a/docs/architecture-decisions/adr002-default-catalog-file-format.md +++ b/docs/architecture-decisions/adr002-default-catalog-file-format.md @@ -125,8 +125,8 @@ entity, and for machines and other components to reference the entity (e.g. in URLs or from other entity specification files). Names must be unique per kind, within a given namespace (if specified), at any -point in time. Names may be reused at a later time, after an entity is deleted -from the registry. +point in time. This uniqueness constraint is also case insensitive. Names may be +reused at a later time, after an entity is deleted from the registry. Names are required to follow a certain format. Entities that do not follow those rules will not be accepted for registration in the catalog. The ruleset is @@ -139,18 +139,6 @@ follows. Example: `visits-tracking-service`, `CircleciBuildsDs_avro_gcs` -In addition to this, names are passed through a normalization function and then -compared to the same normalized form of other entity names and made sure to not -collide. This rule of uniqueness exists to avoid situations where e.g. both -`my-component` and `MyComponent` are registered side by side, which leads to -confusion and risk. The normalization function is also configurable, but the -default behavior is as follows. - -- Strip out all characters outside of the set `[a-zA-Z0-9]` -- Convert to lowercase - -Example: `CircleciBuildsDs_avro_gcs` -> `circlecibuildsdsavrogcs` - ### `namespace` The `name` of a namespace that the entity belongs to. This field is optional, diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index eb087a9c5f..aab2af3145 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -23,6 +23,7 @@ we recommend that you name them `catalog-info.yaml`. - [Common to All Kinds: The Envelope](#common-to-all-kinds-the-envelope) - [Common to All Kinds: The Metadata](#common-to-all-kinds-the-metadata) - [Common to All Kinds: Relations](#common-to-all-kinds-relations) +- [Common to All Kinds: Status](#common-to-all-kinds-status) - [Kind: Component](#kind-component) - [Kind: Template](#kind-template) - [Kind: API](#kind-api) @@ -396,6 +397,54 @@ with it (such as the default kind being `Group` if not specified). See the [well-known relations section](well-known-relations.md) for a list of well-known / common relations and their semantics. +## Common to All Kinds: Status + +The `status` root field is a read-only set of statuses, pertaining to the +current state or health of the entity, described in the +[well-known statuses section](well-known-statuses.md). Each status field +contains a specific blob of data that describes some aspect of the state of the +entity, as seen from the point of view of some specific system. Different +systems may contribute to this status object, under their own respective keys. + +The current main use case for this field is for the ingestion processes of the +catalog itself to convey information about failures and warnings back to the +user. + +A status field as part of a single entity that's read out of the API may look as +follows. + +```js +{ + // ... + "status": { + "backstage.io/catalog-processing": { + "errors": [] + } + }, + "spec": { + // ... + } +} +``` + +The keys of the `status` object are arbitrary strings. We recommend that any +statuses that are not strictly private within the organization be namespaced to +avoid collisions. Statuses emitted by Backstage core processes will for example +be prefixed with `backstage.io/` as in the example above. + +The values of the `status` object are currently left unrestricted, except that +they must be objects. We reserve the right to extend this model in the future, +such that some fields of those value objects gain standardized meaning. We may +for example want to add a standard concept of "severity" or "level" to these. + +Entity descriptor YAML files are not supposed to contain this field. Instead, +catalog processors analyze the entity descriptor data and its surroundings, and +deduce status entries that are then attached onto the entity as read from the +catalog. + +See the [well-known statuses section](well-known-statuses.md) for a list of +well-known / common relations and their semantics. + ## Kind: Component Describes the following entity kind: @@ -657,6 +706,25 @@ You can find out more about the `parameters` key You can find out more about the `steps` key [here](../software-templates/writing-templates.md) +### `spec.owner` [optional] + +An [entity reference](#string-references) to the owner of the component, e.g. +`artist-relations-team`. This field is required. + +In Backstage, the owner of a Template is the singular entity (commonly a team) +that bears ultimate responsibility for the Template, and has the authority and +capability to develop and maintain it. They will be the point of contact if +something goes wrong, or if features are to be requested. The main purpose of +this field is for display purposes in Backstage, so that people looking at +catalog items can get an understanding of to whom this Template belongs. It is +not to be used by automated processes to for example assign authorization in +runtime systems. There may be others that also develop or otherwise touch the +Template, but there will always be one ultimate owner. + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| ------------------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------- | +| [`Group`](#kind-group) (default), [`User`](#kind-user) | Same as this entity, typically `default` | [`ownerOf`, and reverse `ownedBy`](well-known-relations.md#ownedby-and-ownerof) | + ## Kind: API Describes the following entity kind: diff --git a/docs/features/software-catalog/extending-the-model.md b/docs/features/software-catalog/extending-the-model.md index d4dca0c059..4cc1bc1110 100644 --- a/docs/features/software-catalog/extending-the-model.md +++ b/docs/features/software-catalog/extending-the-model.md @@ -1,6 +1,7 @@ --- id: extending-the-model title: Extending the model +# prettier-ignore description: Documentation on extending the catalog model --- @@ -301,9 +302,9 @@ Example intents: > "We have this concept of service maintainership, separate from ownership, that > we would like to make relations to individual users for." -> We feel that we want to explicitly model the team-to-global-department mapping -> as a relation, because it is core to our org setup and we frequently query for -> it. +> "We feel that we want to explicitly model the team-to-global-department +> mapping as a relation, because it is core to our org setup and we frequently +> query for it." Any processor can emit relations for entities as they are being processed, and new processors can be added when building the backend catalog using the @@ -349,3 +350,21 @@ If you want to extend the use of an established relation type in a way that has an effect outside of your organization, reach out to the Backstage maintainers or a support partner to discuss risk/impact. It may even be that one end of the relation could be considered for addition to the core. + +## Adding a New Status field + +Example intents: + +> "We would like to convey entity statuses through the catalog in a generic way, +> as an integration layer. Our monitoring and alerting system has a plugin with +> Backstage, and it would be useful if the entity's status field contained the +> current alert state close to the actual entity data for anyone to consume. + +While we are considering a mechanism for contributing generic statuses to +entities, no such mechanism has yet been built. If you are interested in that +topic, [this issue](https://github.com/backstage/backstage/issues/2292) contains +more context. + +But in general, errors emitted (and exceptions thrown) by any processor +including custom ones, end up in the [well known key](well-known-statuses.md) +for ingestion status. diff --git a/docs/features/software-catalog/well-known-statuses.md b/docs/features/software-catalog/well-known-statuses.md new file mode 100644 index 0000000000..7ddf320d26 --- /dev/null +++ b/docs/features/software-catalog/well-known-statuses.md @@ -0,0 +1,51 @@ +--- +id: well-known-statuses +title: Well-known Status fields of Catalog Entities +sidebar_label: Well-known Statuses +# prettier-ignore +description: Lists a number of well known entity statuses, that have defined semantics. They can be attached to catalog entities and consumed by plugins as needed. +--- + +This section lists a number of well known +[entity status fields](descriptor-format.md#common-to-all-kinds-status), that +have defined semantics. They can be attached to catalog entities and consumed by +plugins as needed. + +If you are looking to extend the set of statuses, see +[Extending the model](extending-the-model.md). + +## Common Fields + +The values of statuses are currently left unrestricted, except that they must be +objects. They therefore currently formally have no common fields. + +We reserve the right to extend this model in the future, such that some fields +of those value objects gain standardized meaning. We may for example want to add +a standard concept of "severity" or "level" to these. + +## Statuses + +This is a (non-exhaustive) list of statuses that are known to be in active use. + +### `backstage.io/catalog-processing` + +Contains the current status of the catalog's ingestion of this entity. Errors +that may appear here include inability to read from the remote SCM provider, +syntax errors in the YAML file, and similar. + +Note that the entity data itself may be of an older version, when errors are +present. The ingestion system keeps the old, valid entity data untouched when +possible, so the errors described in this state may not seem to align with the +rest of the entity, because they pertain to a remote that could not be +successfully ingested. This is normal. + +```yaml +# Example: +status: + backstage.io/catalog-processing: + errors: [] +``` + +This status is in active development and its format will change unexpectedly. Do +not consume it in your own code until such a time that this documentation has +been updated. diff --git a/docs/features/software-templates/migrating-from-v1alpha1-to-v1beta2.md b/docs/features/software-templates/migrating-from-v1alpha1-to-v1beta2.md new file mode 100644 index 0000000000..25ff2a2f3a --- /dev/null +++ b/docs/features/software-templates/migrating-from-v1alpha1-to-v1beta2.md @@ -0,0 +1,334 @@ +--- +id: migrating-from-v1alpha1-to-v1beta2 +title: Migrating to v1beta2 templates +# prettier-ignore +description: + How to move your old templates from v1alpha1 to the more declarative v1beta2 +--- + +# What's new? + +Previously, the scaffolder was very restricted in what you could do when +creating new software components from templates. There were three scaffolding +steps which was pretty hard to extend and add new functionality to, difficult to +re-use logic between templates. There used to be a fixed pipeline of +`preparers`, `templaters`, and `publishers`, which were defined by the backend +and needed to be run for each template. This is now changed, to give the +template total control over what should be executed as part of the templating +run. This makes templates a little more declarative as you can now register +different `actions` or `functions` with the `scaffolder-backend` which you then +can decide how, and in what order, to run using the template definition YAML +file. + +We've also made some improvements, and added some helpers to work with +cookiecutter. The skeleton for a template can now be stored in a different place +to where your entity definition is: previously you needed to have your +`template.yaml` next to the skeleton source (`{{cookiecutter.component_id}}` +directory), but now that's not the case. Part of the changes with the `v1beta2` +syntax is that you can grab your template source from any repository, and re-use +them between templates. + +We've also renamed the `schema` property to `parameters` as this makes more +sense when using them as parameters to the actions or steps that you've setup +for your templates. There's the added benefit that you can now assign an array +to the `parameters` property, which will then give you multiple steps in the UI, +so you can split apart your input parameters and group them as needed rather +than having one long list of input fields. + +## The `parameters` property + +The `schema` key has now been renamed to `parameters` with a few more features. +You can pass an array now to break apart the input form into different steps in +the UI. You can also specify `ui:schema` fields that are passed along to +[`react-jsonschema-form`](https://rjsf-team.github.io/react-jsonschema-form/) +inline with the JSON schema. + +```yaml +spec: + parameters: + - title: Fill in some steps + required: + - name + properties: + name: + title: Name + type: string + description: Unique name of the component + ui:autofocus: true + ui:options: + rows: 5 +``` + +## The `steps` property + +`v1beta2` template syntax introduces the new `steps` property, which is an array +of `actions` that the scaffolder will run in combination with the user input +that is declared in the `schema`. Actions look like the following: + +```yaml +spec: + steps: + - id: publish # a unique id for the step, can be anything you like + name: Publish # a user friendly name for the step, this is what is shown in the frontend + action: publish:github # the action ID that has been registered with the scaffolder-backend + input: # parameters that are passed as input to the action handler function + allowedHosts: ['github.com'] + description: 'This is {{ parameters.name }}' # handlebars templating is supported with the values from the parameters section in the same file. + repoUrl: '{{ parameters.repoUrl }}' +``` + +# Migrating a `v1alpha1` template + +## The template definition (.yaml) + +### `parameters` + +Because of the changes to invert the control to the `template.yaml` definition +for running the workflow, we need to adjust the `schema` property and we also +now need to define what the template is actually going to do as part of the +template run. + +A simple migration would move the following yaml: + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Template +metadata: + name: react-ssr-template + title: React SSR Template + description: Create a website powered with Next.js + tags: + - recommended + - react +spec: + owner: web@example.com + templater: cookiecutter + type: website + path: '.' + schema: + required: + - component_id + - description + properties: + component_id: + title: Name + type: string + description: Unique name of the component + description: + title: Description + type: string + description: Help others understand what this website is for. +``` + +To something that looks like the following: + +```yaml +apiVersion: backstage.io/v1beta2 +kind: Template +metadata: + name: react-ssr-template + title: React SSR Template + description: Create a website powered with Next.js + tags: + - recommended + - react +spec: + owner: web@example.com + type: website + parameters: + - title: Add some input + required: + - component_id + - description + properties: + component_id: + title: Name + type: string + description: Unique name of the component + description: + title: Description + type: string + description: Help others understand what this website is for. + - title: Some more additional info that was previously provided automatically + required: + - owner + - repoUrl + properties: + owner: + title: Owner + type: string + description: Owner of the component + ui:field: OwnerPicker + ui:options: + allowedKinds: + - Group + - title: Choose a location + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - github.com +``` + +There are a few things to note here. On the `alpha` version, the second step of +the template flow in the frontend was provided by Backstage for free, so we used +to collect the user input for the `owner` field and the `repositoryUrl` that you +were going to publish to. Now because `actions` can have any workflow they like, +it doesn't make sense to still provide these fields for every scaffolding +workflow, as you might not need these anymore. That's why we now manually add +those fields back into the template parameters that are shown to the user: + +```yaml + - title: Some more additional info that was previously provided automatically + required: + - owner + - repoUrl + properties: + owner: + title: Owner + type: string + description: Owner of the component + ui:field: OwnerPicker + ui:options: + allowedKinds: + - Group + - title: Choose a location + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - github.com +``` + +Maybe you also don't need to publish to `github.com`, you should replace this +with your VCS provider URL that is listed in your `integrations` config instead. + +### `steps` + +So now we should have all the required information that we need from the user in +a much more extensible way. We now need to tell the scaffolder what to do with +these parameters and what to do with the user input. + +We've made templating using `cookiecutter` a little simpler. You don't need to +store the `cookiecutter` skeleton in the same directory as the `template.yaml` +definition, it can live wherever you like - maybe a shared repository somewhere +so you can re-use the skeletons but apply different actions for different +templates depending on your use case. + +We also no longer need to have a directory called +`{{cookiecutter.component_id}}`. This is because now we can't ensure that +`component_id` will be a parameter that is provided from the frontend, this +could break `cookiecutter`. If your directory structure used to look like this: + +``` +my-awesome-template + -> {{cookiecutter.component_id}} + -> file.txt + -> some_more_files.ts + -> hooks + -> post_gen_project.sh + -> template.yaml +``` + +We now recommend that you move to the following structure: + +``` +my-awesome-template + -> skeleton + -> file.txt + -> some_more_files.ts + -> template.yaml +``` + +This migration renames the skeleton folder to something more semantic, and also +drops support for `cookiecutter` hooks. We've dropped support for `cookiecutter` +hooks for now, as hopefully everything that is stored in these hooks can be +moved to `actions` instead, and for security reasons, it's more secure to run +trusted code that you ship with Backstage as an action rather than some script +that can be pulled in from anywhere which doesn't get vetted first. It's a +pretty big security risk that those scripts will be run on Backstage instances +inside your infrastructure, especially `.sh` files. + +If you really need hooks and can't find a suitable solution by using actions +please reach out to us through a ticket and we'll see what we can do to assist +:) + +You'll notice that we removed the `templater` property from the `spec` +definition in the template `yaml`, so there's no way to define that this is a +`cookiecutter` `templater`. + +We've created a built-in action that you can use which will when run, go grab a +directory from anywhere and run `cookiecutter` on top of it, and then extract +the contents into the working directory for the scaffolder. + +Adding the `steps` for a simple template should look something like the +following: + +```yaml +spec: + steps: + # this action will go use cookiecutter to template some files into the working directory + - id: template # an ID for the templating step + name: Create skeleton # A user friendly name for the action + action: fetch:cookiecutter + input: + url: ./skeleton # this is the directory for your skeleton files. + # If it's located next to the `template.yaml` then you can use a relative path, + # otherwise you can use absolute URLs that point at the VCS: https://github.com/backstage/backstage/tree/master/some_folder_somewhere + values: + # for each value that you need to pass to cookiecutter, they should be listed here and set in this values object. + # You can use the handlebars templating syntax to pull them from the input parameters listed in the same file + name: '{{ parameters.name }}' + owner: '{{ parameters.owner }}' + destination: '{{ parseRepoUrl parameters.repoUrl }}' + + # this action is for publishing the working directory to the VCS + - id: publish + name: Publish + action: publish:github + input: + allowedHosts: ['github.com'] + description: 'This is {{ parameters.name }}' + repoUrl: '{{ parameters.repoUrl }}' + + # this action will then register the created component in Backstage + - id: register + name: Register + action: catalog:register + input: + repoContentsUrl: '{{ steps.publish.output.repoContentsUrl }}' + catalogInfoPath: '/catalog-info.yaml' +``` + +### `output` + +Steps can output values, and so can the template itself. This is good for +returning values to the frontend, so we can make the buttons like +`Go to catalog` and `Go to repo` work correctly. You can add the following to +your `template.yaml` to make sure you return the right values from the steps: + +```yaml +spec: + output: + remoteUrl: '{{ steps.publish.output.remoteUrl }}' + entityRef: '{{ steps.register.output.entityRef }}' +``` + +Or you can return a `links` array with text and a URL explicitly: + +```yaml +spec: + output: + links: + - url: '{{steps.publish.output.remoteUrl}}' + text: 'Go to Repo' +``` + +## Questions? + +If you have any questions or feedback, please reach out to us on GitHub or +Discord and we will do our best to help! diff --git a/docs/plugins/backend-plugin.md b/docs/plugins/backend-plugin.md index 516ba4b111..4480cd69e3 100644 --- a/docs/plugins/backend-plugin.md +++ b/docs/plugins/backend-plugin.md @@ -1,7 +1,113 @@ --- id: backend-plugin -title: Backend plugin -description: Documentation on Backend plugin +title: Backend plugins +description: Creating and Developing Backend plugins --- -## TODO +This page describes the process of creating and managing backend plugins in your +Backstage repository. + +## Creating a Backend Plugin + +A new, bare-bones backend plugin package can be created by issuing the following +command in your Backstage repository root: + +```sh +yarn create-plugin --backend +``` + +Please also see the `--help` flag for the `create-plugin` command for some +further options that are available, notably the `--scope` and `--no-private` +flags that control naming and publishing of the newly created package. Your repo +root `package.json` will probably also have some default values already set up +for these. + +You will be asked to supply a name for the plugin. This is an identifier that +will be part of the NPM package name, so make it short and containing only +lowercase characters separated by dashes, for example `carmen`, if it's a +package that adds an integration with a system named Carmen, for example. The +full NPM package name would then be something like +`@internal/plugin-carmen-backend`, depending on the other flags passed to the +`create-plugin` command, and your settings for the `create-plugin` command in +your root `package.json`. + +Creating the plugin will take a little while, so be patient. It will helpfully +run the initial installation and build commands, so that your package is ready +to be hacked on! It will be located in a new folder in your `plugins` directory, +in this example `plugins/carmen-backend`. + +For simple development purposes, a backend plugin can actually be started in a +standalone mode. You can do a first-light test of your service: + +```sh +cd plugins/carmen-backend +yarn start +``` + +This will think for a bit, and then say `Listening on :7000`. In a different +terminal window, now run + +```sh +curl localhost:7000/carmen/health +``` + +This should return `{"status":"ok"}`. Success! Press `Ctrl + c` to kill it +again. + +## Developing your Backend Plugin + +A freshly created backend plugin does basically nothing, in terms of the overall +app. It has a small set of basic dependencies and exposes an Express router in +`src/service/router.ts`. This is where you will start adding routes and +connecting those to actual underlying functionality. But nothing in your +Backstage application / backend exposes it. + +To actually attach and run the plugin router, you will make some modifications +to your backend. + +```sh +# From the Backstage root directory +cd packages/backend +yarn add @internal/plugin-carmen-backend@^0.1.1 # Change this to match the plugin's package.json +``` + +Create a new file named `packages/backend/src/plugins/carmen.ts`, and add the +following to it + +```ts +import { createRouter } from '@internal/plugin-carmen-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin(env: PluginEnvironment) { + // Here is where you will add all of the required initialization code that + // your backend plugin needs to be able to start! + + // The env contains a lot of goodies, but our router currently only + // needs a logger + return await createRouter({ + logger: env.logger, + }); +} +``` + +And finally, wire this into the overall backend router. Edit +`packages/backend/src/index.ts`: + +```ts +import carmen from './plugins/carmen'; +// ... +async function main() { + // ... + const carmenEnv = useHotMemoize(module, () => createEnv('carmen')); + apiRouter.use('/carmen', await carmen(badgesEnv)); +``` + +After you start the backend (e.g. using `yarn start-backend` from the repo +root), you should be able to fetch data from it. + +```sh +# Note the extra /api here +curl localhost:7000/api/carmen/health +``` + +This should return `{"status":"ok"}` like before. Success! diff --git a/microsite/blog/2021-05-20-adopting-backstage.md b/microsite/blog/2021-05-20-adopting-backstage.md new file mode 100644 index 0000000000..1aad5ce0bd --- /dev/null +++ b/microsite/blog/2021-05-20-adopting-backstage.md @@ -0,0 +1,135 @@ +--- +title: Where do you start when adopting Backstage? +author: Austin Lamon, Spotify +authorURL: https://www.linkedin.com/in/austinlamon +--- + +![Create, Manage, Explore](assets/21-05-20/create-manage-explore.gif) + +One of the greatest strengths of Backstage also presents a never-ending challenge: Backstage is highly customizable and allows you to easily build a unique developer portal suited to your organization’s needs. The downside of this flexibility is that it can be hard to know where to start. Backstage can do so many things — integrating every part of your tech infrastructure and developer experience — but if you set off building a developer portal without a plan, it’s easy to get overwhelmed by all the possibilities. To help you form your plan, this post will detail how Spotify came to design our internal portal and recommend potential models for you to use when designing and building your own. + + + +## Infrastructure as tech culture + +Before providing recommendations on getting started with Backstage, it’s helpful to have a bit of context as to why Spotify made the design decisions we did. No two companies are identical — and thus, no two Backstage implementations are the same. + +Rolling back the clock just a few years, [Spotify was challenged](https://engineering.atspotify.com/2021/05/18/a-product-story-the-lessons-of-backstage-and-spotifys-autonomous-culture/) to continue to scale our engineering team (and the number of features and components built) but retain the speed of product development. Some user research with Spotify developers highlighted a clear problem: there was simply too much non-documented institutional knowledge needed to get things done. No one could find anything and everyone was interrupting everyone else trying to figure things out. + +Spotify’s developers were facing three big challenges on a daily basis: + +- They not only needed to build software quickly, they also needed to pass along knowledge to new joiners about how best to create new components. +- They needed to somehow maintain a mental model of the systems their squad owned. (Or, if they were lucky, they found a hopefully-up-to-date spreadsheet tracking this information.) +- They needed to keep an eye on what squads around them might be building to ensure they could reuse systems when they needed to solve similar problems in the future. + +In short, Spotify developers needed to continue building industry leading features at breakneck speed, while simultaneously maintaining a mental model for all the software at Spotify (oh, and help every new joiner develop that mental model as well!). +
+ +## Three jobs: Create, manage, explore + +Around the same time, the [jobs to be done](https://hbr.org/2016/09/know-your-customers-jobs-to-be-done) framework was becoming popular and luckily, a few Spotifiers helped guide the vision for a _sense-making tool for developers_ toward using it. After user research and many failed attempts, we landed on three jobs Spotify developers needed to do consistently: +
+ +- **Create**: Spotify developers want to delight their customers with incredible features. They create new software to do that. +- **Manage**: Spotify developers are proud of their work and treat their software and data like products. That requires maintaining all the software they own on an ongoing basis. +- **Explore**: Spotify developers want to solve new, yet unsolved problems. They try to build on existing systems to help them do that. + +So: make software, maintain the software you own throughout its lifecycle, and integrate with other people’s software. + +Within today’s complex development environments, there are barriers both big and small that get in the way of those three jobs. Backstage provides the building blocks for removing those barriers, streamlining your development cycle, and letting developers do what they really want to do: build great features. Let’s take a closer look at each of these jobs. + +![Backstage Software Templates rocket](assets/21-05-20/create.gif) + +### Create + +**Job:** You’re an engineer, ready to start building a new microservice. Do you just pick whatever framework you want? How do you reserve capacity to run your service in production? What about managing CI/CD? + +**Tool:** At Spotify, we use **Backstage Software Templates** to simplify all of this, reducing time–to–Hello World to just a few clicks. Instead of researching Spring Boot versus Helidon, opening a Jira ticket, rummaging through docs, and configuring CI automations, you just choose a template and your project is automatically set up in the repo for you, with the CI already running your first build. + +**Result:** By making it easier to start new projects, your engineers get to the good part of coding features faster. And your organization’s best practices are built into the templates, encouraging standards and reducing complexity in your tech ecosystem. + +![Backstage Service Catalog system](assets/21-05-20/manage.gif) + +### Manage + +**Job:** You’re on a small team that owns a dozen services. Whenever you update and deploy those services, you’re switching between your CI, the AWS console, a security dashboard, and a CLI so you can try to figure out which Kubernetes cluster your service ended up on. In other words, you have a lot of open windows and tabs, and each step means switching to a new interface. + +**Tool:** All of your team’s software components are organized together on one page in the **Backstage Service Catalog**. Go to any service’s page from there and its CI/CD status, [Kubernetes deployment status](https://backstage.io/blog/2021/01/12/new-backstage-feature-kubernetes-for-service-owners), documentation, security checks — and everything else related to that service — is grouped together in one seamless interface that shows you only the info you want. + +**Result**: One page in Backstage has everything you need to manage the software you own. No more context-switching. No more digging through your cloud provider’s obscure admin features. Outside the repo and your IDE, everything you need to manage your service is right inside Backstage. + +![Backstage explorer](assets/21-05-20/explore.gif) + +### Explore + +**Job:** You're building a new mobile feature that needs to ensure a user is paying for the premium version of your product — but someone must’ve already built a library that handles that, right? A company-wide email and a few calls for help on Slack yield no response, so you resign yourself to building the capability yourself. Turns out someone did build the library you needed. They were just on vacation so they didn’t see your messages. How do you enable better discovery and collaboration across your org? + +**Tools:** At Spotify, anyone can find everyone else’s software — because everything is centralized inside Backstage, organized by the **Backstage Service Catalog** and accessible by **search**. Go to any library or services page, and you’ll find the owners and documentation, even its API and how to extend it if need be. + +**Result:** One place for everything, one place to search. Developers can more easily share components, build on top of each other’s work, and discover tools, libraries, frameworks, documentation, system designs, org charts, and more. + +## Sounds great. Where do I start? + +After talking with companies who have already adopted Backstage, we’ve seen a few common strategies for getting started. The different strategies are based on the size of your engineering org (which often also corresponds with how fast you’re growing). + +![200 engineers](assets/21-05-20/200-engineers.png) +
+ +### My org has ~200 engineers (and is growing fast) + +You’re big enough to start feeling the pain, and only getting bigger. Onboarding and discoverability are your biggest challenges. + +**Pain points:** + +- This size seems to be the tipping point — where complexity is taking hold, collaboration starts breaking down, and ad hoc solutions stop working. +- Often this is also when you’re growing so fast (doubling in size every 6–12 months) that there are now more new engineers than old engineers. +- New engineers can’t find anything, so they ask around, which pulls all your other engineers off-task with interruptions. +- Logjams form. One company told us how it was taking 3–4 days for pull requests to get merged. + +**Recommendation — Explore, then create:** + +- New engineers need an easy way to find out how to do things, so you’re not just adding bodies, you’re adding happy, productive contributors. +- To streamline onboarding, start with centralizing your documentation and making your tools and software components discoverable in Backstage. +- At this size, you might not have a dedicated platform/infra team in place yet, but Backstage can provide the framework for centralizing and sharing knowledge — from managing compliance requirements to finding the right API documentation. +- This allows both new and old engineers to collaborate more efficiently, easily discover best practices, and cuts down on duplicated work (e.g., a new team doesn’t end up rebuilding a database that already exists but nobody can find). + +![1,000 engineers](assets/21-05-20/1000-engineers.png) + +### My org has ~1,000 engineers + +You’re officially big. Lots and lots of teams are managing lots and lots of software — and the frustration of switching between all the various tools to do that is growing exponentially. + +**Pain points:** + +- Fragmentation and entropy are real threats to productivity. From security requirements to cluster management to a thousand data endpoints, there’s too much to keep track of, leading to cognitive overload. +- A death by a thousand cuts: constantly logging into new interfaces — from your cloud provider’s console to some brand new security tool then back to your CI/CD — is bogging your engineers down with too much context-switching and a lot of noise. +- Every day, it’s getting more and more difficult for one team to manage their own microservices, data, and documentation, let alone share their knowledge with other teams. + +**Recommendation — Manage, then explore, then create:** + +- Backstage will allow your teams to get a handle on the software they own, since everything they need to manage it is in one place. +- From CI/CD status to documentation to deciphering a monorepo, Backstage makes it easy to manage a service (or other software component) on a day-to-day basis. +- The service catalog also helps your developers visualize your existing software ecosystem, beyond the software they own. And with Backstage Software Templates, every new software component is already added to the catalog. +- Even at this scale, a small platform team should be all it takes to build and maintain your own version of Backstage. At Spotify, we have a 4-person team responsible for our internal version of Backstage, which is used by all of Spotify’s 1,600+ developers. + +![More than 1,000 engineers](assets/21-05-20/1000-plus-engineers.png) + +### My org has 1,000+ engineers + +Integrating infrastructure of this size and complexity can seem overwhelming. It’s an even bigger challenge to bring this level of change to a well-established culture with ingrained processes. + +**Pain points:** + +- When you’re this large, you’ve incorporated a lot of technology and processes, as well as a lot of partners, each with their own technologies and processes. +- You’re focussed on “replacing cruft” and bringing discoverability and order to your vast ecosystem of components and tools. +- Getting your tools adopted by your engineers while modernizing your tech stack and coordinating with your infra teams to improve your engineering practices across the whole company… it’s a lot. + +**Recommendation — Create, then manage, then explore:** + +- The fastest way to bring change to your ecosystem is to start at the beginning of the chain with Backstage Software Templates. +- With every new component created with your templates, you’re establishing best practices and rewarding your developers with a streamlined experience focused on their needs — all the while building up your new service catalog. +- The more the templates ease the process of starting up a project, the more your engineers will adopt them, and the faster the other benefits of Backstage will build up, transforming productivity across your organization. ([That’s how we did it at Spotify](https://open.spotify.com/episode/7iuQ3ew1Wwpuiq6LbBKzCl).) + +## More questions about adopting Backstage? + +[Contact the Backstage team at Spotify.](https://calendly.com/spotify-backstage) We’ll share more about what we’ve learned from our experience here at Spotify — and from other companies who are already using Backstage to transform their developer experience. diff --git a/microsite/blog/assets/21-05-20/1000-engineers.png b/microsite/blog/assets/21-05-20/1000-engineers.png new file mode 100644 index 0000000000..b7c9413e99 Binary files /dev/null and b/microsite/blog/assets/21-05-20/1000-engineers.png differ diff --git a/microsite/blog/assets/21-05-20/1000-plus-engineers.png b/microsite/blog/assets/21-05-20/1000-plus-engineers.png new file mode 100644 index 0000000000..63d49dd63e Binary files /dev/null and b/microsite/blog/assets/21-05-20/1000-plus-engineers.png differ diff --git a/microsite/blog/assets/21-05-20/200-engineers.png b/microsite/blog/assets/21-05-20/200-engineers.png new file mode 100644 index 0000000000..d0f9bd73be Binary files /dev/null and b/microsite/blog/assets/21-05-20/200-engineers.png differ diff --git a/microsite/blog/assets/21-05-20/create-manage-explore.gif b/microsite/blog/assets/21-05-20/create-manage-explore.gif new file mode 100644 index 0000000000..104523a886 Binary files /dev/null and b/microsite/blog/assets/21-05-20/create-manage-explore.gif differ diff --git a/microsite/blog/assets/21-05-20/create.gif b/microsite/blog/assets/21-05-20/create.gif new file mode 100644 index 0000000000..ce2c04f384 Binary files /dev/null and b/microsite/blog/assets/21-05-20/create.gif differ diff --git a/microsite/blog/assets/21-05-20/explore.gif b/microsite/blog/assets/21-05-20/explore.gif new file mode 100644 index 0000000000..76b527af44 Binary files /dev/null and b/microsite/blog/assets/21-05-20/explore.gif differ diff --git a/microsite/blog/assets/21-05-20/manage.gif b/microsite/blog/assets/21-05-20/manage.gif new file mode 100644 index 0000000000..f4cf00d9f5 Binary files /dev/null and b/microsite/blog/assets/21-05-20/manage.gif differ diff --git a/microsite/data/plugins/ilert.yaml b/microsite/data/plugins/ilert.yaml new file mode 100644 index 0000000000..29cc4f6aa5 --- /dev/null +++ b/microsite/data/plugins/ilert.yaml @@ -0,0 +1,15 @@ +--- +title: iLert +author: iLert +authorUrl: https://www.ilert.com +category: Monitoring +description: iLert is a platform for alerting, on-call management and uptime monitoring targeted at DevOps and IT teams. +documentation: https://github.com/backstage/backstage/tree/master/plugins/ilert +iconUrl: https://avatars.githubusercontent.com/u/13230510?s=200&v=4 +npmPackageName: '@backstage/plugin-ilert' +tags: + - monitoring + - errors + - alerting + - uptime + - on-call diff --git a/microsite/sidebars.json b/microsite/sidebars.json index c7b049a53e..6c42614a61 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -40,6 +40,7 @@ "features/software-catalog/references", "features/software-catalog/well-known-annotations", "features/software-catalog/well-known-relations", + "features/software-catalog/well-known-statuses", "features/software-catalog/extending-the-model", "features/software-catalog/external-integrations", "features/software-catalog/software-catalog-api" @@ -65,7 +66,8 @@ "features/software-templates/writing-templates", "features/software-templates/builtin-actions", "features/software-templates/writing-custom-actions", - "features/software-templates/template-legacy" + "features/software-templates/template-legacy", + "features/software-templates/migrating-from-v1alpha1-to-v1beta2" ] }, { diff --git a/microsite/static/img/git-release-manager-logo.svg b/microsite/static/img/git-release-manager-logo.svg index b505d21560..b8e9cfe3ac 100644 --- a/microsite/static/img/git-release-manager-logo.svg +++ b/microsite/static/img/git-release-manager-logo.svg @@ -1,13 +1 @@ - - - Export - - - - - - - - - - \ No newline at end of file +Export \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 7826b3221d..574b04f8f7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -37,6 +37,7 @@ nav: - Entity References: 'features/software-catalog/references.md' - Well-known Annotations: 'features/software-catalog/well-known-annotations.md' - Well-known Relations: 'features/software-catalog/well-known-relations.md' + - Well-known Statuses: 'features/software-catalog/well-known-statuses.md' - Extending the model: 'features/software-catalog/extending-the-model.md' - External integrations: 'features/software-catalog/external-integrations.md' - API: 'features/software-catalog/api.md' @@ -53,6 +54,8 @@ nav: - Builtin Actions: 'features/software-templates/builtin-actions.md' - Writing Custom Actions: 'features/software-templates/writing-custom-actions.md' - Writing Templates (Legacy): 'features/software-templates/legacy.md' + - Migrating from v1alpha1 to v1beta2 templates: 'features/software-templates/migrating-from-v1alpha1-to-v1beta2.md' + - Backstage Search: - Overview: 'features/search/README.md' - Search Architecture: 'features/search/architecture.md' diff --git a/package.json b/package.json index b0f79031de..ed404565e4 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "prettier:check": "prettier --check .", "lerna": "lerna", "storybook": "yarn workspace storybook start", - "build-storybook": "yarn workspace storybook build-storybook" + "build-storybook": "yarn workspace storybook build-storybook", + "prepare": "husky install" }, "workspaces": { "packages": [ @@ -60,18 +61,13 @@ "command-exists": "^1.2.9", "concurrently": "^6.0.0", "fs-extra": "^9.0.0", - "husky": "^4.2.3", + "husky": "^6.0.0", "lerna": "^4.0.0", "lint-staged": "^10.1.0", "prettier": "^2.2.1", "recursive-readdir": "^2.2.2", "shx": "^0.3.2" }, - "husky": { - "hooks": { - "pre-commit": "lint-staged" - } - }, "prettier": "@spotify/prettier-config", "lint-staged": { "*.{js,jsx,ts,tsx}": [ diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index 1cc00e5ff9..09dbb534f9 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,34 @@ # example-app +## 0.2.29 + +### Patch Changes + +- Updated dependencies [7cbfcae48] +- Updated dependencies [2bfec55a6] +- Updated dependencies [f7f7783a3] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5da6a561d] +- Updated dependencies [ca6e0ab69] +- Updated dependencies [5914a76d5] +- Updated dependencies [81d7b9c6f] +- Updated dependencies [a62cfe068] +- Updated dependencies [35e091604] +- Updated dependencies [a53f3d603] +- Updated dependencies [b203699e9] + - @backstage/plugin-cost-insights@0.10.0 + - @backstage/cli@0.6.12 + - @backstage/catalog-model@0.7.10 + - @backstage/plugin-scaffolder@0.9.5 + - @backstage/core@0.7.10 + - @backstage/plugin-api-docs@0.4.14 + - @backstage/plugin-cloudbuild@0.2.15 + - @backstage/plugin-github-actions@0.4.7 + - @backstage/plugin-techdocs@0.9.3 + - @backstage/plugin-catalog-import@0.5.7 + - @backstage/plugin-catalog@0.5.8 + ## 0.2.28 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index 6acba6f4cc..6cb10d13a6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,25 +1,25 @@ { "name": "example-app", - "version": "0.2.28", + "version": "0.2.29", "private": true, "bundled": true, "dependencies": { - "@backstage/catalog-model": "^0.7.9", - "@backstage/cli": "^0.6.11", - "@backstage/core": "^0.7.9", + "@backstage/catalog-model": "^0.7.10", + "@backstage/cli": "^0.6.12", + "@backstage/core": "^0.7.10", "@backstage/integration-react": "^0.1.2", - "@backstage/plugin-api-docs": "^0.4.13", + "@backstage/plugin-api-docs": "^0.4.14", "@backstage/plugin-badges": "^0.2.1", - "@backstage/plugin-catalog": "^0.5.7", - "@backstage/plugin-catalog-import": "^0.5.6", + "@backstage/plugin-catalog": "^0.5.8", + "@backstage/plugin-catalog-import": "^0.5.7", "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/plugin-circleci": "^0.2.14", - "@backstage/plugin-cloudbuild": "^0.2.14", + "@backstage/plugin-cloudbuild": "^0.2.15", "@backstage/plugin-code-coverage": "^0.1.3", - "@backstage/plugin-cost-insights": "^0.9.1", + "@backstage/plugin-cost-insights": "^0.10.0", "@backstage/plugin-explore": "^0.3.5", "@backstage/plugin-gcp-projects": "^0.2.6", - "@backstage/plugin-github-actions": "^0.4.6", + "@backstage/plugin-github-actions": "^0.4.7", "@backstage/plugin-graphiql": "^0.2.11", "@backstage/plugin-jenkins": "^0.4.3", "@backstage/plugin-kafka": "^0.2.7", @@ -29,12 +29,12 @@ "@backstage/plugin-org": "^0.3.13", "@backstage/plugin-pagerduty": "0.3.4", "@backstage/plugin-rollbar": "^0.3.5", - "@backstage/plugin-scaffolder": "^0.9.4", + "@backstage/plugin-scaffolder": "^0.9.5", "@backstage/plugin-search": "^0.3.6", "@backstage/plugin-sentry": "^0.3.10", "@backstage/plugin-shortcuts": "^0.1.2", "@backstage/plugin-tech-radar": "^0.3.11", - "@backstage/plugin-techdocs": "^0.9.2", + "@backstage/plugin-techdocs": "^0.9.3", "@backstage/plugin-todo": "^0.1.1", "@backstage/plugin-user-settings": "^0.2.10", "@backstage/theme": "^0.2.7", @@ -42,7 +42,7 @@ "@material-ui/icons": "^4.9.1", "@octokit/rest": "^18.5.3", "@roadiehq/backstage-plugin-buildkite": "^1.0.0", - "@roadiehq/backstage-plugin-github-insights": "^1.0.0", + "@roadiehq/backstage-plugin-github-insights": "^1.1.10", "@roadiehq/backstage-plugin-github-pull-requests": "^1.0.0", "@roadiehq/backstage-plugin-travis-ci": "^1.0.0", "history": "^5.0.0", @@ -56,7 +56,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/test-utils": "^0.1.11", + "@backstage/test-utils": "^0.1.12", "@testing-library/cypress": "^7.0.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", @@ -67,7 +67,7 @@ "@types/react-dom": "^16.9.8", "@types/zen-observable": "^0.8.0", "cross-env": "^7.0.0", - "cypress": "^4.2.0", + "cypress": "^7.3.0", "eslint-plugin-cypress": "^2.10.3", "start-server-and-test": "^1.10.11" }, diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 601134362d..f646992c97 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -28,6 +28,8 @@ import { import { EntityBadgesDialog } from '@backstage/plugin-badges'; import { EntityAboutCard, + EntityDependsOnComponentsCard, + EntityDependsOnResourcesCard, EntityHasComponentsCard, EntityHasSubcomponentsCard, EntityHasSystemsCard, @@ -37,6 +39,9 @@ import { EntitySwitch, isComponentType, isKind, + EntityHasResourcesCard, + EntityOrphanWarning, + isOrphan, } from '@backstage/plugin-catalog'; import { EntityCircleCIContent, @@ -211,7 +216,15 @@ const errorsContent = ( const overviewContent = ( - + + + + + + + + + @@ -223,7 +236,7 @@ const overviewContent = ( - + @@ -257,7 +270,7 @@ const overviewContent = ( - + @@ -288,6 +301,17 @@ const serviceEntityPage = ( + + + + + + + + + + + @@ -336,6 +360,17 @@ const websiteEntityPage = ( {errorsContent} + + + + + + + + + + + @@ -466,6 +501,9 @@ const systemPage = ( + + + diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md index 9c66f387d9..728d8fabcd 100644 --- a/packages/backend-common/CHANGELOG.md +++ b/packages/backend-common/CHANGELOG.md @@ -1,5 +1,30 @@ # @backstage/backend-common +## 0.8.1 + +### Patch Changes + +- c7dad9218: All cache-related connection errors are now handled and logged by the cache manager. App Integrators may provide an optional error handler when instantiating the cache manager if custom error handling is needed. + + ```typescript + // Providing an error handler + const cacheManager = CacheManager.fromConfig(config, { + onError: e => { + if (isSomehowUnrecoverable(e)) { + gracefullyShutThingsDown(); + process.exit(1); + } + }, + }); + ``` + +- 65e6c4541: Remove circular dependencies +- 5001de908: Change GitlabUrlReader to SHA timestamp compare using only commits that modify given file path, if file path given +- Updated dependencies [65e6c4541] +- Updated dependencies [290405276] + - @backstage/integration@0.5.3 + - @backstage/config-loader@0.6.2 + ## 0.8.0 ### Minor Changes diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 23e22fe34b..068220cad3 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -73,7 +73,7 @@ export interface CacheClient { // @public export class CacheManager { forPlugin(pluginId: string): PluginCacheManager; - static fromConfig(config: Config): CacheManager; + static fromConfig(config: Config, options?: CacheManagerOptions): CacheManager; } // @public (undocumented) diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 7e647e57d0..4de9c36c8c 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-common", "description": "Common functionality library for Backstage backends", - "version": "0.8.0", + "version": "0.8.1", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -31,9 +31,9 @@ "dependencies": { "@backstage/cli-common": "^0.1.1", "@backstage/config": "^0.1.5", - "@backstage/config-loader": "^0.6.1", + "@backstage/config-loader": "^0.6.2", "@backstage/errors": "^0.1.1", - "@backstage/integration": "^0.5.2", + "@backstage/integration": "^0.5.3", "@google-cloud/storage": "^5.8.0", "@octokit/rest": "^18.5.3", "@types/cors": "^2.8.6", @@ -76,8 +76,8 @@ } }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/test-utils": "^0.1.12", "@types/archiver": "^5.1.0", "@types/compression": "^1.7.0", "@types/concat-stream": "^1.6.0", diff --git a/packages/backend-common/src/cache/CacheClient.ts b/packages/backend-common/src/cache/CacheClient.ts index 49b5c367dd..3b654e248b 100644 --- a/packages/backend-common/src/cache/CacheClient.ts +++ b/packages/backend-common/src/cache/CacheClient.ts @@ -92,7 +92,8 @@ export class DefaultCacheClient implements CacheClient { const wellFormedKey = Buffer.from(candidateKey).toString('base64'); // Memcache in particular doesn't do well with keys > 250 bytes. - if (wellFormedKey.length < 250) { + // Padded because a plugin ID is also prepended to the key. + if (wellFormedKey.length < 200) { return wellFormedKey; } diff --git a/packages/backend-common/src/cache/CacheManager.test.ts b/packages/backend-common/src/cache/CacheManager.test.ts index ece4687ee6..97c1714c53 100644 --- a/packages/backend-common/src/cache/CacheManager.test.ts +++ b/packages/backend-common/src/cache/CacheManager.test.ts @@ -86,6 +86,16 @@ describe('CacheManager', () => { expect(client).toHaveBeenCalledTimes(1); }); + it('attaches error handler to client', () => { + const pluginId = 'error-test'; + manager.forPlugin(pluginId).getClient(); + + const client = DefaultCacheClient as jest.Mock; + const mockCalls = client.mock.calls.splice(-1); + const realClient = mockCalls[0][0].client as Keyv; + expect(realClient.on).toHaveBeenCalledWith('error', expect.any(Function)); + }); + it('provides different plugins different cache clients', async () => { const plugin1Id = 'test1'; const plugin2Id = 'test2'; @@ -163,4 +173,53 @@ describe('CacheManager', () => { expect(mockMemcacheCalls[0][0]).toEqual(expectedHost); }); }); + + describe('connection errors', () => { + it('uses provided logger', () => { + // Set up and inject mock logger. + const mockLogger = { child: jest.fn(), error: jest.fn() }; + mockLogger.child.mockImplementation(() => mockLogger as any); + const manager = CacheManager.fromConfig(defaultConfig(), { + logger: mockLogger as any, + }); + + // Set up a cache client using the configured manager. + manager.forPlugin('error-logger-test').getClient(); + + // Retrieve the error handler attached to the cache client. + const client = DefaultCacheClient as jest.Mock; + const mockCalls = client.mock.calls.splice(-1); + const realClient = mockCalls[0][0].client as Keyv; + const realOnError = realClient.on as jest.Mock; + const realHandler = realOnError.mock.calls.splice(-1)[0][1]; + + // Invoke the actual error handler. + const expectedError = new Error('some error'); + realHandler(expectedError); + expect(mockLogger.error).toHaveBeenCalledWith(expectedError); + }); + + it('calls provided handler', () => { + // Set up and inject mock logger. + const mockHandler = jest.fn(); + const manager = CacheManager.fromConfig(defaultConfig(), { + onError: mockHandler, + }); + + // Set up a cache client using the configured manager. + manager.forPlugin('error-handler-test').getClient(); + + // Retrieve the error handler attached to the cache client. + const client = DefaultCacheClient as jest.Mock; + const mockCalls = client.mock.calls.splice(-1); + const realClient = mockCalls[0][0].client as Keyv; + const realOnError = realClient.on as jest.Mock; + const realHandler = realOnError.mock.calls.splice(-1)[0][1]; + + // Invoke the actual error handler. + const expectedError = new Error('some error'); + realHandler(expectedError); + expect(mockHandler).toHaveBeenCalledWith(expectedError); + }); + }); }); diff --git a/packages/backend-common/src/cache/CacheManager.ts b/packages/backend-common/src/cache/CacheManager.ts index fbd7e3ba0a..e6699e628e 100644 --- a/packages/backend-common/src/cache/CacheManager.ts +++ b/packages/backend-common/src/cache/CacheManager.ts @@ -18,9 +18,15 @@ import { Config } from '@backstage/config'; import Keyv from 'keyv'; // @ts-expect-error import KeyvMemcache from 'keyv-memcache'; +import { Logger } from 'winston'; +import { getRootLogger } from '../logging'; import { DefaultCacheClient, CacheClient } from './CacheClient'; import { NoStore } from './NoStore'; -import { PluginCacheManager } from './types'; +import { + CacheManagerOptions, + OptionalOnError, + PluginCacheManager, +} from './types'; /** * Implements a Cache Manager which will automatically create new cache clients @@ -38,8 +44,10 @@ export class CacheManager { none: this.getNoneClient, }; + private readonly logger: Logger; private readonly store: keyof CacheManager['storeFactories']; private readonly connection: string; + private readonly errorHandler: OptionalOnError; /** * Creates a new CacheManager instance by reading from the `backend` config @@ -47,25 +55,38 @@ export class CacheManager { * * @param config The loaded application configuration. */ - static fromConfig(config: Config): CacheManager { + static fromConfig( + config: Config, + options: CacheManagerOptions = {}, + ): CacheManager { // If no `backend.cache` config is provided, instantiate the CacheManager // with a "NoStore" cache client. const store = config.getOptionalString('backend.cache.store') || 'none'; const connectionString = config.getOptionalString('backend.cache.connection') || ''; - return new CacheManager(store, connectionString); + const logger = (options.logger || getRootLogger()).child({ + type: 'cacheManager', + }); + return new CacheManager(store, connectionString, logger, options.onError); } - private constructor(store: string, connectionString: string) { + private constructor( + store: string, + connectionString: string, + logger: Logger, + errorHandler: OptionalOnError, + ) { if (!this.storeFactories.hasOwnProperty(store)) { throw new Error(`Unknown cache store: ${store}`); } + this.logger = logger; this.store = store as keyof CacheManager['storeFactories']; this.connection = connectionString; + this.errorHandler = errorHandler; } /** - * Generates a CacheManagerInstance for consumption by plugins. + * Generates a PluginCacheManager for consumption by plugins. * * @param pluginId The plugin that the cache manager should be created for. Plugin names should be unique. */ @@ -73,6 +94,18 @@ export class CacheManager { return { getClient: (opts = {}): CacheClient => { const concreteClient = this.getClientWithTtl(pluginId, opts.defaultTtl); + + // Always provide an error handler to avoid killing the process. + concreteClient.on('error', (err: Error) => { + // In all cases, just log the error. + this.logger.error(err); + + // Invoke any custom error handler if provided. + if (typeof this.errorHandler === 'function') { + this.errorHandler(err); + } + }); + return new DefaultCacheClient({ client: concreteClient, }); diff --git a/packages/backend-common/src/cache/types.ts b/packages/backend-common/src/cache/types.ts index 4dd3cf7d1b..30db53d420 100644 --- a/packages/backend-common/src/cache/types.ts +++ b/packages/backend-common/src/cache/types.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { Logger } from 'winston'; import { CacheClient } from './CacheClient'; type ClientOptions = { @@ -25,6 +26,21 @@ type ClientOptions = { defaultTtl?: number; }; +export type OptionalOnError = ((err: Error) => void) | undefined; + +export type CacheManagerOptions = { + /** + * An optional logger for use by the PluginCacheManager. + */ + logger?: Logger; + + /** + * An optional handler for connection errors emitted from the underlying data + * store. + */ + onError?: OptionalOnError; +}; + /** * The PluginCacheManager manages access to cache stores that Plugins get. */ diff --git a/packages/backend-common/src/reading/AzureUrlReader.test.ts b/packages/backend-common/src/reading/AzureUrlReader.test.ts index bb76181287..c4a0fe0466 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.test.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts @@ -29,11 +29,11 @@ import path from 'path'; import { NotModifiedError } from '@backstage/errors'; import { getVoidLogger } from '../logging'; import { AzureUrlReader } from './AzureUrlReader'; -import { ReadTreeResponseFactory } from './tree'; +import { DefaultReadTreeResponseFactory } from './tree'; const logger = getVoidLogger(); -const treeResponseFactory = ReadTreeResponseFactory.create({ +const treeResponseFactory = DefaultReadTreeResponseFactory.create({ config: new ConfigReader({}), }); diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts index 16e7628ec8..e2b230a05f 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -27,9 +27,9 @@ import parseGitUrl from 'git-url-parse'; import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; import { NotFoundError, NotModifiedError } from '@backstage/errors'; -import { ReadTreeResponseFactory } from './tree'; import { stripFirstDirectoryFromPath } from './tree/util'; import { + ReadTreeResponseFactory, ReaderFactory, ReadTreeOptions, ReadTreeResponse, diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts index aa78bf27d5..e06122e04b 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts @@ -28,9 +28,9 @@ import os from 'os'; import path from 'path'; import { NotModifiedError } from '@backstage/errors'; import { BitbucketUrlReader } from './BitbucketUrlReader'; -import { ReadTreeResponseFactory } from './tree'; +import { DefaultReadTreeResponseFactory } from './tree'; -const treeResponseFactory = ReadTreeResponseFactory.create({ +const treeResponseFactory = DefaultReadTreeResponseFactory.create({ config: new ConfigReader({}), }); diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index f102be7283..251743cb25 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -27,9 +27,9 @@ import parseGitUrl from 'git-url-parse'; import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; import { NotFoundError, NotModifiedError } from '@backstage/errors'; -import { ReadTreeResponseFactory } from './tree'; import { stripFirstDirectoryFromPath } from './tree/util'; import { + ReadTreeResponseFactory, ReaderFactory, ReadTreeOptions, ReadTreeResponse, diff --git a/packages/backend-common/src/reading/FetchUrlReader.test.ts b/packages/backend-common/src/reading/FetchUrlReader.test.ts index 8dc4aba29b..2363a16d1a 100644 --- a/packages/backend-common/src/reading/FetchUrlReader.test.ts +++ b/packages/backend-common/src/reading/FetchUrlReader.test.ts @@ -19,7 +19,7 @@ import { msw } from '@backstage/test-utils'; import { setupServer } from 'msw/node'; import { getVoidLogger } from '../logging'; import { FetchUrlReader } from './FetchUrlReader'; -import { ReadTreeResponseFactory } from './tree'; +import { DefaultReadTreeResponseFactory } from './tree'; describe('FetchUrlReader', () => { const worker = setupServer(); @@ -45,7 +45,7 @@ describe('FetchUrlReader', () => { }, }), logger: getVoidLogger(), - treeResponseFactory: ReadTreeResponseFactory.create({ + treeResponseFactory: DefaultReadTreeResponseFactory.create({ config: new ConfigReader({}), }), }); diff --git a/packages/backend-common/src/reading/GithubUrlReader.test.ts b/packages/backend-common/src/reading/GithubUrlReader.test.ts index 67e7f27069..cbe61f2e66 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.test.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts @@ -35,9 +35,9 @@ import { GhTreeResponse, GithubUrlReader, } from './GithubUrlReader'; -import { ReadTreeResponseFactory } from './tree'; +import { DefaultReadTreeResponseFactory } from './tree'; -const treeResponseFactory = ReadTreeResponseFactory.create({ +const treeResponseFactory = DefaultReadTreeResponseFactory.create({ config: new ConfigReader({}), }); diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts index 77e33cbd54..2b1247fe77 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -26,8 +26,8 @@ import parseGitUrl from 'git-url-parse'; import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; import { NotFoundError, NotModifiedError } from '@backstage/errors'; -import { ReadTreeResponseFactory } from './tree'; import { + ReadTreeResponseFactory, ReaderFactory, ReadTreeOptions, ReadTreeResponse, diff --git a/packages/backend-common/src/reading/GitlabUrlReader.test.ts b/packages/backend-common/src/reading/GitlabUrlReader.test.ts index 9dcc92a4a6..b5592c09f5 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.test.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.test.ts @@ -24,7 +24,7 @@ import os from 'os'; import path from 'path'; import { getVoidLogger } from '../logging'; import { GitlabUrlReader } from './GitlabUrlReader'; -import { ReadTreeResponseFactory } from './tree'; +import { DefaultReadTreeResponseFactory } from './tree'; import { NotModifiedError, NotFoundError } from '@backstage/errors'; import { GitLabIntegration, @@ -33,7 +33,7 @@ import { const logger = getVoidLogger(); -const treeResponseFactory = ReadTreeResponseFactory.create({ +const treeResponseFactory = DefaultReadTreeResponseFactory.create({ config: new ConfigReader({}), }); @@ -190,11 +190,17 @@ describe('GitlabUrlReader', () => { default_branch: 'main', }; - const branchGitlabApiResponse = { - commit: { + const commitsGitlabApiResponse = [ + { id: 'sha123abc', }, - }; + ]; + + const specificPathCommitsGitlabApiResponse = [ + { + id: 'sha456def', + }, + ]; beforeEach(() => { worker.use( @@ -221,17 +227,29 @@ describe('GitlabUrlReader', () => { ), ), rest.get( - 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/branches/main', - (_, res, ctx) => - res( - ctx.status(200), - ctx.set('Content-Type', 'application/json'), - ctx.json(branchGitlabApiResponse), - ), - ), - rest.get( - 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/branches/branchDoesNotExist', - (_, res, ctx) => res(ctx.status(404)), + 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/commits', + (req, res, ctx) => { + const refName = req.url.searchParams.get('ref_name'); + if (refName === 'main') { + const filepath = req.url.searchParams.get('path'); + if (filepath === 'testFilepath') { + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(specificPathCommitsGitlabApiResponse), + ); + } + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(commitsGitlabApiResponse), + ); + } + if (refName === 'branchDoesNotExist') { + return res(ctx.status(404)); + } + return res(); + }, ), rest.get( 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock', @@ -243,13 +261,26 @@ describe('GitlabUrlReader', () => { ), ), rest.get( - 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/branches/main', - (_, res, ctx) => - res( - ctx.status(200), - ctx.set('Content-Type', 'application/json'), - ctx.json(branchGitlabApiResponse), - ), + 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/commits', + (req, res, ctx) => { + const refName = req.url.searchParams.get('ref_name'); + if (refName === 'main') { + const filepath = req.url.searchParams.get('path'); + if (filepath === 'testFilepath') { + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(specificPathCommitsGitlabApiResponse), + ); + } + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(commitsGitlabApiResponse), + ); + } + return res(); + }, ), rest.get( 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/archive.zip?sha=main', @@ -351,7 +382,7 @@ describe('GitlabUrlReader', () => { ).resolves.toBe('# Test\n'); }); - it('throws a NotModifiedError when given a etag in options', async () => { + it('throws a NotModifiedError when given a etag in options matching last commit', async () => { const fnGitlab = async () => { await gitlabProcessor.readTree('https://gitlab.com/backstage/mock', { etag: 'sha123abc', @@ -371,6 +402,29 @@ describe('GitlabUrlReader', () => { await expect(fnHostedGitlab).rejects.toThrow(NotModifiedError); }); + it('throws a NotModifiedError when given a etag in options matching last commit affecting specified filepath', async () => { + const fnGitlab = async () => { + await gitlabProcessor.readTree( + 'https://gitlab.com/backstage/mock/blob/main/testFilepath', + { + etag: 'sha456def', + }, + ); + }; + + const fnHostedGitlab = async () => { + await hostedGitlabProcessor.readTree( + 'https://gitlab.mycompany.com/backstage/mock/blob/main/testFilepath', + { + etag: 'sha456def', + }, + ); + }; + + await expect(fnGitlab).rejects.toThrow(NotModifiedError); + await expect(fnHostedGitlab).rejects.toThrow(NotModifiedError); + }); + it('should not throw error when given an outdated etag in options', async () => { const response = await gitlabProcessor.readTree( 'https://gitlab.com/backstage/mock/tree/main', @@ -389,12 +443,12 @@ describe('GitlabUrlReader', () => { }); it('should throw error on missing branch', async () => { - const fnGithub = async () => { + const fnGitlab = async () => { await gitlabProcessor.readTree( 'https://gitlab.com/backstage/mock/tree/branchDoesNotExist', ); }; - await expect(fnGithub).rejects.toThrow(NotFoundError); + await expect(fnGitlab).rejects.toThrow(NotFoundError); }); }); @@ -408,11 +462,11 @@ describe('GitlabUrlReader', () => { default_branch: 'main', }; - const branchGitlabApiResponse = { - commit: { + const commitsGitlabApiResponse = [ + { id: 'sha123abc', }, - }; + ]; beforeEach(() => { worker.use( @@ -439,13 +493,18 @@ describe('GitlabUrlReader', () => { ), ), rest.get( - 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/branches/main', - (_, res, ctx) => - res( - ctx.status(200), - ctx.set('Content-Type', 'application/json'), - ctx.json(branchGitlabApiResponse), - ), + 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/commits', + (req, res, ctx) => { + const refName = req.url.searchParams.get('ref_name'); + if (refName === 'main') { + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(commitsGitlabApiResponse), + ); + } + return res(); + }, ), ); }); diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts index c93103b2da..635565c8a0 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -25,9 +25,9 @@ import parseGitUrl from 'git-url-parse'; import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; import { NotFoundError, NotModifiedError } from '@backstage/errors'; -import { ReadTreeResponseFactory } from './tree'; import { stripFirstDirectoryFromPath } from './tree/util'; import { + ReadTreeResponseFactory, ReaderFactory, ReadTreeOptions, ReadTreeResponse, @@ -106,25 +106,30 @@ export class GitlabUrlReader implements UrlReader { // ref is an empty string if no branch is set in provided url to readTree. const branch = ref || projectGitlabResponseJson.default_branch; - // Fetch the latest commit in the provided or default branch to compare against - // the provided sha. - const branchGitlabResponse = await fetch( + // Fetch the latest commit that modifies the the filepath in the provided or default branch + // to compare against the provided sha. + const commitsReqParams = new URLSearchParams(); + commitsReqParams.set('ref_name', branch); + if (!!filepath) { + commitsReqParams.set('path', filepath); + } + const commitsGitlabResponse = await fetch( new URL( `${this.integration.config.apiBaseUrl}/projects/${encodeURIComponent( full_name, - )}/repository/branches/${branch}`, + )}/repository/commits?${commitsReqParams.toString()}`, ).toString(), getGitLabRequestOptions(this.integration.config), ); - if (!branchGitlabResponse.ok) { - const message = `Failed to read tree (branch) from ${url}, ${branchGitlabResponse.status} ${branchGitlabResponse.statusText}`; - if (branchGitlabResponse.status === 404) { + if (!commitsGitlabResponse.ok) { + const message = `Failed to read tree (branch) from ${url}, ${commitsGitlabResponse.status} ${commitsGitlabResponse.statusText}`; + if (commitsGitlabResponse.status === 404) { throw new NotFoundError(message); } throw new Error(message); } - const commitSha = (await branchGitlabResponse.json()).commit.id; + const commitSha = (await commitsGitlabResponse.json())[0].id; if (options?.etag && options.etag === commitSha) { throw new NotModifiedError(); diff --git a/packages/backend-common/src/reading/GoogleGcsUrlReader.test.ts b/packages/backend-common/src/reading/GoogleGcsUrlReader.test.ts index e201361a91..5d4b01da44 100644 --- a/packages/backend-common/src/reading/GoogleGcsUrlReader.test.ts +++ b/packages/backend-common/src/reading/GoogleGcsUrlReader.test.ts @@ -16,7 +16,7 @@ import { ConfigReader, JsonObject } from '@backstage/config'; import { getVoidLogger } from '../logging'; -import { ReadTreeResponseFactory } from './tree'; +import { DefaultReadTreeResponseFactory } from './tree'; import { GoogleGcsUrlReader } from './GoogleGcsUrlReader'; import { UrlReaderPredicateTuple } from './types'; @@ -25,7 +25,7 @@ describe('GcsUrlReader', () => { return GoogleGcsUrlReader.factory({ config: new ConfigReader(config), logger: getVoidLogger(), - treeResponseFactory: ReadTreeResponseFactory.create({ + treeResponseFactory: DefaultReadTreeResponseFactory.create({ config: new ConfigReader({}), }), }); diff --git a/packages/backend-common/src/reading/UrlReaders.ts b/packages/backend-common/src/reading/UrlReaders.ts index 06c6fdcdcf..8f27d058df 100644 --- a/packages/backend-common/src/reading/UrlReaders.ts +++ b/packages/backend-common/src/reading/UrlReaders.ts @@ -22,7 +22,7 @@ import { AzureUrlReader } from './AzureUrlReader'; import { BitbucketUrlReader } from './BitbucketUrlReader'; import { GithubUrlReader } from './GithubUrlReader'; import { GitlabUrlReader } from './GitlabUrlReader'; -import { ReadTreeResponseFactory } from './tree'; +import { DefaultReadTreeResponseFactory } from './tree'; import { FetchUrlReader } from './FetchUrlReader'; import { GoogleGcsUrlReader } from './GoogleGcsUrlReader'; @@ -44,7 +44,9 @@ export class UrlReaders { */ static create({ logger, config, factories }: CreateOptions): UrlReader { const mux = new UrlReaderPredicateMux(); - const treeResponseFactory = ReadTreeResponseFactory.create({ config }); + const treeResponseFactory = DefaultReadTreeResponseFactory.create({ + config, + }); for (const factory of factories ?? []) { const tuples = factory({ config, logger: logger, treeResponseFactory }); diff --git a/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts index 05ecdb0fc3..1ce7555a30 100644 --- a/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts +++ b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts @@ -15,27 +15,18 @@ */ import os from 'os'; -import { Readable } from 'stream'; import { Config } from '@backstage/config'; -import { ReadTreeResponse } from '../types'; +import { + ReadTreeResponse, + FromArchiveOptions, + ReadTreeResponseFactory, +} from '../types'; import { TarArchiveResponse } from './TarArchiveResponse'; import { ZipArchiveResponse } from './ZipArchiveResponse'; -type FromArchiveOptions = { - // A binary stream of a tar archive. - stream: Readable; - // If unset, the files at the root of the tree will be read. - // subpath must not contain the name of the top level directory. - subpath?: string; - // etag of the blob - etag: string; - // Filter passed on from the ReadTreeOptions - filter?: (path: string) => boolean; -}; - -export class ReadTreeResponseFactory { - static create(options: { config: Config }): ReadTreeResponseFactory { - return new ReadTreeResponseFactory( +export class DefaultReadTreeResponseFactory implements ReadTreeResponseFactory { + static create(options: { config: Config }): DefaultReadTreeResponseFactory { + return new DefaultReadTreeResponseFactory( options.config.getOptionalString('backend.workingDirectory') ?? os.tmpdir(), ); diff --git a/packages/backend-common/src/reading/tree/index.ts b/packages/backend-common/src/reading/tree/index.ts index 858cf15877..3126907c1e 100644 --- a/packages/backend-common/src/reading/tree/index.ts +++ b/packages/backend-common/src/reading/tree/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { ReadTreeResponseFactory } from './ReadTreeResponseFactory'; +export { DefaultReadTreeResponseFactory } from './ReadTreeResponseFactory'; diff --git a/packages/backend-common/src/reading/types.ts b/packages/backend-common/src/reading/types.ts index 4caa76841a..7ba806197d 100644 --- a/packages/backend-common/src/reading/types.ts +++ b/packages/backend-common/src/reading/types.ts @@ -14,9 +14,9 @@ * limitations under the License. */ +import { Readable } from 'stream'; import { Logger } from 'winston'; import { Config } from '@backstage/config'; -import { ReadTreeResponseFactory } from './tree'; /** * A generic interface for fetching plain data from URLs. @@ -105,6 +105,23 @@ export type ReadTreeResponseFile = { content(): Promise; }; +export type FromArchiveOptions = { + // A binary stream of a tar archive. + stream: Readable; + // If unset, the files at the root of the tree will be read. + // subpath must not contain the name of the top level directory. + subpath?: string; + // etag of the blob + etag: string; + // Filter passed on from the ReadTreeOptions + filter?: (path: string) => boolean; +}; + +export interface ReadTreeResponseFactory { + fromTarArchive(options: FromArchiveOptions): Promise; + fromZipArchive(options: FromArchiveOptions): Promise; +} + /** * An options object for search operations. */ diff --git a/packages/catalog-model/CHANGELOG.md b/packages/catalog-model/CHANGELOG.md index 312a329649..d697461487 100644 --- a/packages/catalog-model/CHANGELOG.md +++ b/packages/catalog-model/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/catalog-model +## 0.7.10 + +### Patch Changes + +- f7f7783a3: Add Owner field in template card and new data distribution + Add spec.owner as optional field into TemplateV1Alpha and TemplateV1Beta Schema + Add relations ownedBy and ownerOf into Template entity + Template documentation updated +- 68fdbf014: Add the `status` field to the Entity envelope + ## 0.7.9 ### Patch Changes diff --git a/packages/catalog-model/api-report.md b/packages/catalog-model/api-report.md index 2b56879f48..890a45b6e7 100644 --- a/packages/catalog-model/api-report.md +++ b/packages/catalog-model/api-report.md @@ -112,6 +112,7 @@ export type Entity = { metadata: EntityMeta; spec?: JsonObject; relations?: EntityRelation[]; + status?: Record; }; // @public @@ -486,6 +487,7 @@ interface TemplateEntityV1alpha1 extends Entity { templater: string; path?: string; schema: JSONSchema; + owner?: string; }; } @@ -519,6 +521,7 @@ export interface TemplateEntityV1beta2 extends Entity { output?: { [name: string]: string; }; + owner?: string; }; } diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index 6b5b096cb6..7a19be8708 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-model", - "version": "0.7.9", + "version": "0.7.10", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -39,7 +39,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.6.11", + "@backstage/cli": "^0.6.12", "@types/express": "^4.17.6", "@types/jest": "^26.0.7", "@types/lodash": "^4.14.151", diff --git a/packages/catalog-model/src/entity/Entity.ts b/packages/catalog-model/src/entity/Entity.ts index 7abe1f9c0c..1b5d38dee9 100644 --- a/packages/catalog-model/src/entity/Entity.ts +++ b/packages/catalog-model/src/entity/Entity.ts @@ -48,6 +48,14 @@ export type Entity = { * The relations that this entity has with other entities. */ relations?: EntityRelation[]; + + /** + * The current status of the entity, as claimed by various sources. + * + * The keys are implementation defined and the values can be any JSON object + * with semantics that match that implementation. + */ + status?: Record; }; /** diff --git a/packages/catalog-model/src/entity/util.ts b/packages/catalog-model/src/entity/util.ts index 2c63cc4c49..116913c85c 100644 --- a/packages/catalog-model/src/entity/util.ts +++ b/packages/catalog-model/src/entity/util.ts @@ -42,11 +42,9 @@ export function generateEntityEtag(): string { * the next version of this entity. * * Significance, in this case, means that we do not compare generated fields - * such as uid, etag and generation, and we only check that no new annotations - * are added or existing annotations were changed (since they are effectively - * merged when doing updates). + * such as uid, etag and generation. * - * Note that this comparison does NOT take state, relations or similar into + * Note that this comparison does NOT take status, relations or similar into * account. It only compares the actual input entity data, i.e. metadata and * spec. * @@ -86,7 +84,9 @@ export function entityHasChanges(previous: Entity, next: Entity): boolean { // Remove things that we explicitly do not compare delete e1.relations; + delete e1.status; delete e2.relations; + delete e2.status; return !lodash.isEqual(e1, e2); } diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts index e3d29aad4f..bfb27b4ed6 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts @@ -48,6 +48,7 @@ describe('templateEntityV1alpha1Validator', () => { }, }, }, + owner: 'team-a@example.com', }, }; }); @@ -90,4 +91,16 @@ describe('templateEntityV1alpha1Validator', () => { (entity as any).spec.templater = ''; await expect(validator.check(entity)).rejects.toThrow(/templater/); }); + it('accepts missing owner', async () => { + delete (entity as any).spec.owner; + await expect(validator.check(entity)).resolves.toBe(true); + }); + it('rejects empty owner', async () => { + (entity as any).spec.owner = ''; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); + it('rejects wrong type owner', async () => { + (entity as any).spec.owner = 5; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); }); diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts index 72479c7efa..64721e3739 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts @@ -33,6 +33,7 @@ export interface TemplateEntityV1alpha1 extends Entity { templater: string; path?: string; schema: JSONSchema; + owner?: string; }; } diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts index 958a486687..458bdcd0aa 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts @@ -59,6 +59,7 @@ describe('templateEntityV1beta2Validator', () => { output: { fetchUrl: '{{ steps.fetch.output.targetUrl }}', }, + owner: 'team-b@example.com', }, }; }); @@ -121,4 +122,16 @@ describe('templateEntityV1beta2Validator', () => { delete (entity as any).spec.steps[0].action; await expect(validator.check(entity)).rejects.toThrow(/action/); }); + it('accepts missing owner', async () => { + delete (entity as any).spec.owner; + await expect(validator.check(entity)).resolves.toBe(true); + }); + it('rejects empty owner', async () => { + (entity as any).spec.owner = ''; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); + it('rejects wrong type owner', async () => { + (entity as any).spec.owner = 5; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); }); diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts index 1f4e3bcd90..7a100de4bb 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts @@ -41,6 +41,7 @@ export interface TemplateEntityV1beta2 extends Entity { input?: JsonObject; }>; output?: { [name: string]: string }; + owner?: string; }; } diff --git a/packages/catalog-model/src/schema/Entity.schema.json b/packages/catalog-model/src/schema/Entity.schema.json index 803e025796..e6b004dfd0 100644 --- a/packages/catalog-model/src/schema/Entity.schema.json +++ b/packages/catalog-model/src/schema/Entity.schema.json @@ -62,6 +62,15 @@ "items": { "$ref": "common#relation" } + }, + "status": { + "type": "object", + "description": "The current status of the entity, as claimed by various sources.", + "patternProperties": { + "^.+$": { + "$ref": "common#status" + } + } } } } diff --git a/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json index 53109ac7ee..5a0c0efa73 100644 --- a/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json +++ b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json @@ -85,6 +85,11 @@ "schema": { "type": "object", "description": "The JSONSchema describing the inputs for the template." + }, + "owner": { + "type": "string", + "description": "The user (or group) owner of the template", + "minLength": 1 } } } diff --git a/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json index b88b344626..a3d8e6ba86 100644 --- a/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json +++ b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json @@ -168,6 +168,11 @@ "additionalProperties": { "type": "string" } + }, + "owner": { + "type": "string", + "description": "The user (or group) owner of the template", + "minLength": 1 } } } diff --git a/packages/catalog-model/src/schema/shared/common.schema.json b/packages/catalog-model/src/schema/shared/common.schema.json index 84ae8a4147..cb4d82cdb9 100644 --- a/packages/catalog-model/src/schema/shared/common.schema.json +++ b/packages/catalog-model/src/schema/shared/common.schema.json @@ -29,7 +29,7 @@ "$id": "#relation", "type": "object", "description": "A directed relation from one entity to another.", - "required": ["type", "source", "target"], + "required": ["type", "target"], "additionalProperties": false, "properties": { "type": { @@ -38,13 +38,16 @@ "pattern": "^\\w+$", "description": "The type of relation." }, - "source": { - "$ref": "#reference" - }, "target": { "$ref": "#reference" } } + }, + "status": { + "$id": "#status", + "type": "object", + "description": "A specific status of an entity.", + "additionalProperties": true } } } diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index cf28960f13..c9826ccd6d 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/cli +## 0.6.12 + +### Patch Changes + +- 2bfec55a6: Update `fork-ts-checker-webpack-plugin` +- Updated dependencies [290405276] + - @backstage/config-loader@0.6.2 + ## 0.6.11 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index b17bc8cbda..a72ef48bf3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli", "description": "CLI for developing Backstage plugins and apps", - "version": "0.6.11", + "version": "0.6.12", "private": false, "publishConfig": { "access": "public" @@ -32,7 +32,7 @@ "@babel/plugin-transform-modules-commonjs": "^7.4.4", "@backstage/cli-common": "^0.1.1", "@backstage/config": "^0.1.5", - "@backstage/config-loader": "^0.6.1", + "@backstage/config-loader": "^0.6.2", "@hot-loader/react-dom": "^16.13.0", "@lerna/package-graph": "^4.0.0", "@lerna/project": "^4.0.0", @@ -75,7 +75,7 @@ "eslint-plugin-react-hooks": "^4.0.0", "express": "^4.17.1", "file-loader": "^6.2.0", - "fork-ts-checker-webpack-plugin": "^4.0.5", + "fork-ts-checker-webpack-plugin": "^6.2.9", "fs-extra": "^9.0.0", "handlebars": "^4.7.3", "html-webpack-plugin": "^4.3.0", @@ -117,11 +117,11 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-common": "^0.8.0", + "@backstage/backend-common": "^0.8.1", "@backstage/config": "^0.1.5", - "@backstage/core": "^0.7.9", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/core": "^0.7.10", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@backstage/theme": "^0.2.7", "@types/diff": "^5.0.0", "@types/express": "^4.17.6", diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 8837554f22..4cc18f2083 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -97,15 +97,18 @@ export async function createConfig( if (checksEnabled) { plugins.push( new ForkTsCheckerWebpackPlugin({ - tsconfig: paths.targetTsConfig, - eslint: true, - eslintOptions: { - parserOptions: { - project: paths.targetTsConfig, - tsconfigRootDir: paths.targetPath, + typescript: { + configFile: paths.targetTsConfig, + }, + eslint: { + files: ['**', '!**/__tests__/**', '!**/?(*.)(spec|test).*'], + options: { + parserOptions: { + project: paths.targetTsConfig, + tsconfigRootDir: paths.targetPath, + }, }, }, - reportFiles: ['**', '!**/__tests__/**', '!**/?(*.)(spec|test).*'], }), ); } @@ -291,15 +294,18 @@ export async function createBackendConfig( ...(checksEnabled ? [ new ForkTsCheckerWebpackPlugin({ - tsconfig: paths.targetTsConfig, - eslint: true, - eslintOptions: { - parserOptions: { - project: paths.targetTsConfig, - tsconfigRootDir: paths.targetPath, + typescript: { + configFile: paths.targetTsConfig, + }, + eslint: { + files: ['**', '!**/__tests__/**', '!**/?(*.)(spec|test).*'], + options: { + parserOptions: { + project: paths.targetTsConfig, + tsconfigRootDir: paths.targetPath, + }, }, }, - reportFiles: ['**', '!**/__tests__/**', '!**/?(*.)(spec|test).*'], }), ] : []), diff --git a/packages/config-loader/CHANGELOG.md b/packages/config-loader/CHANGELOG.md index d6b1c9a751..75c94a46a5 100644 --- a/packages/config-loader/CHANGELOG.md +++ b/packages/config-loader/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/config-loader +## 0.6.2 + +### Patch Changes + +- 290405276: Updated dependencies + ## 0.6.1 ### Patch Changes diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 16d7923d0b..f7df2ddea0 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/config-loader", "description": "Config loading functionality used by Backstage backend, and CLI", - "version": "0.6.1", + "version": "0.6.2", "private": false, "publishConfig": { "access": "public", @@ -36,7 +36,7 @@ "ajv": "^7.0.3", "fs-extra": "^9.0.0", "json-schema": "^0.3.0", - "json-schema-merge-allof": "^0.7.0", + "json-schema-merge-allof": "^0.8.1", "typescript-json-schema": "^0.49.0", "yaml": "^1.9.2", "yup": "^0.29.3" diff --git a/packages/core-api/CHANGELOG.md b/packages/core-api/CHANGELOG.md index 3652cf6bc0..76af4a3060 100644 --- a/packages/core-api/CHANGELOG.md +++ b/packages/core-api/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/core-api +## 0.2.19 + +### Patch Changes + +- 61c3f927c: Updated the `Observable` type to provide interoperability with `Symbol.observable`, making it compatible with at least `zen-observable` and `RxJS 7`. + + In cases where this change breaks tests that mocked the `Observable` type, the following addition to the mock should fix the breakage: + + ```ts + [Symbol.observable]() { + return this; + }, + ``` + +- 65e6c4541: Remove circular dependencies + ## 0.2.18 ### Patch Changes diff --git a/packages/core-api/package.json b/packages/core-api/package.json index b5cd8cba69..cf80fb0d72 100644 --- a/packages/core-api/package.json +++ b/packages/core-api/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-api", "description": "Internal Core API used by Backstage plugins and apps", - "version": "0.2.18", + "version": "0.2.19", "private": false, "publishConfig": { "access": "public", @@ -42,8 +42,8 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/test-utils": "^0.1.12", "@backstage/test-utils-core": "^0.1.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", diff --git a/packages/core-api/src/apis/definitions/OAuthRequestApi.ts b/packages/core-api/src/apis/definitions/OAuthRequestApi.ts index b9776ed037..fc4f1ef166 100644 --- a/packages/core-api/src/apis/definitions/OAuthRequestApi.ts +++ b/packages/core-api/src/apis/definitions/OAuthRequestApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { IconComponent } from '../../icons'; +import { IconComponent } from '../../icons/types'; import { Observable } from '../../types'; import { ApiRef, createApiRef } from '../system'; diff --git a/packages/core-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts b/packages/core-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts index 19da7a3765..f18829d99c 100644 --- a/packages/core-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts +++ b/packages/core-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { PublishSubject } from '../../../lib'; +import { PublishSubject } from '../../../lib/subjects'; import { Observable } from '../../../types'; import { AlertApi, AlertMessage } from '../../definitions'; diff --git a/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts b/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts index 43b52a254b..837c5f54db 100644 --- a/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts +++ b/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts @@ -15,7 +15,7 @@ */ import { AppThemeApi, AppTheme } from '../../definitions'; -import { BehaviorSubject } from '../../../lib'; +import { BehaviorSubject } from '../../../lib/subjects'; import { Observable } from '../../../types'; const STORAGE_KEY = 'theme'; diff --git a/packages/core-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts b/packages/core-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts index 51548708d0..5993ede367 100644 --- a/packages/core-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts +++ b/packages/core-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { PublishSubject } from '../../../lib'; +import { PublishSubject } from '../../../lib/subjects'; import { Observable } from '../../../types'; import { ErrorApi, ErrorContext } from '../../definitions'; diff --git a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts index e616ece5ae..f0710cb466 100644 --- a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts +++ b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { BehaviorSubject } from '../../../lib'; +import { BehaviorSubject } from '../../../lib/subjects'; import { Observable } from '../../../types'; type RequestQueueEntry = { diff --git a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts index 23e9e1ddc4..a0a01d1bc9 100644 --- a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts +++ b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts @@ -21,7 +21,7 @@ import { AuthRequesterOptions, } from '../../definitions'; import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests'; -import { BehaviorSubject } from '../../../lib'; +import { BehaviorSubject } from '../../../lib/subjects'; import { Observable } from '../../../types'; /** diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index cf3aaac931..df0f65f33f 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -15,13 +15,12 @@ */ import { ComponentType } from 'react'; -import { IconComponent, IconComponentMap, IconKey } from '../icons'; +import { IconComponent, IconComponentMap, IconKey } from '../icons/types'; import { AnyExternalRoutes, BackstagePlugin } from '../plugin/types'; -import { ExternalRouteRef, RouteRef } from '../routing'; -import { AnyApiFactory } from '../apis'; +import { ExternalRouteRef, RouteRef, SubRouteRef } from '../routing/types'; +import { AnyApiFactory } from '../apis/system'; import { AppTheme, ProfileInfo } from '../apis/definitions'; import { AppConfig } from '@backstage/config'; -import { SubRouteRef } from '../routing/types'; export type BootErrorPageProps = { step: 'load-config' | 'load-chunk'; diff --git a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts index 1c7e92daa8..1a1bab0eb9 100644 --- a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts +++ b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { AuthRequester } from '../../apis'; import { + AuthRequester, OAuthRequestApi, AuthProvider, DiscoveryApi, diff --git a/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts b/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts index 6cc9cf5237..af3d8bc9b6 100644 --- a/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts +++ b/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { SessionState } from '../../apis'; +import { SessionState } from '../../apis/definitions'; import { Observable } from '../../types'; import { BehaviorSubject } from '../subjects'; diff --git a/packages/core-api/src/lib/AuthSessionManager/types.ts b/packages/core-api/src/lib/AuthSessionManager/types.ts index f57afd6760..332824e438 100644 --- a/packages/core-api/src/lib/AuthSessionManager/types.ts +++ b/packages/core-api/src/lib/AuthSessionManager/types.ts @@ -15,7 +15,7 @@ */ import { Observable } from '../../types'; -import { SessionState } from '../../apis'; +import { SessionState } from '../../apis/definitions'; export type GetSessionOptions = { optional?: boolean; diff --git a/packages/core-api/src/lib/subjects.ts b/packages/core-api/src/lib/subjects.ts index 9ebde6ebbc..5239e460af 100644 --- a/packages/core-api/src/lib/subjects.ts +++ b/packages/core-api/src/lib/subjects.ts @@ -53,6 +53,10 @@ export class PublishSubject ZenObservable.SubscriptionObserver >(); + [Symbol.observable]() { + return this; + } + get closed() { return this.isClosed; } @@ -148,6 +152,10 @@ export class BehaviorSubject ZenObservable.SubscriptionObserver >(); + [Symbol.observable]() { + return this; + } + get closed() { return this.isClosed; } diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index 7ce2b2afbd..4603b03a30 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -23,7 +23,7 @@ import { ParamKeys, OptionalParams, } from './types'; -import { IconComponent } from '../icons'; +import { IconComponent } from '../icons/types'; // TODO(Rugvip): Remove this in the next breaking release, it's exported but unused export type RouteRefConfig = { diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts index 1fabd39cc4..0089e29e4b 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { IconComponent } from '../icons'; +import { IconComponent } from '../icons/types'; import { getOrCreateGlobalSingleton } from '../lib/globalObject'; export type AnyParams = { [param in string]: string } | undefined; diff --git a/packages/core-api/src/types.ts b/packages/core-api/src/types.ts index 13a8a72570..ab0aa56a1b 100644 --- a/packages/core-api/src/types.ts +++ b/packages/core-api/src/types.ts @@ -39,9 +39,17 @@ export type Subscription = { /** * Value indicating whether the subscription is closed. */ - readonly closed: Boolean; + readonly closed: boolean; }; +// Declares the global well-known Symbol.observable +// We get the actual runtime polyfill from zen-observable +declare global { + interface SymbolConstructor { + readonly observable: symbol; + } +} + /** * Observable sequence of values and errors, see TC39. * @@ -51,12 +59,14 @@ export type Subscription = { * using many different observable implementations, such as zen-observable or RxJS 5. */ export type Observable = { + [Symbol.observable](): Observable; + /** * Subscribes to this observable to start receiving new values. */ subscribe(observer: Observer): Subscription; subscribe( - onNext: (value: T) => void, + onNext?: (value: T) => void, onError?: (error: Error) => void, onComplete?: () => void, ): Subscription; diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 51c6b3f935..97427f27e9 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/core +## 0.7.10 + +### Patch Changes + +- 65e6c4541: Remove circular dependencies +- 5da6a561d: Fix a bug where users are asked to log-in on every page refresh. This is specific to people with only one sign-in provider in their SignInPage component. +- Updated dependencies [61c3f927c] +- Updated dependencies [65e6c4541] + - @backstage/core-api@0.2.19 + ## 0.7.9 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index e418a969ce..11f8037c06 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core", "description": "Core API used by Backstage plugins and apps", - "version": "0.7.9", + "version": "0.7.10", "private": false, "publishConfig": { "access": "public", @@ -30,7 +30,7 @@ }, "dependencies": { "@backstage/config": "^0.1.5", - "@backstage/core-api": "^0.2.18", + "@backstage/core-api": "^0.2.19", "@backstage/errors": "^0.1.1", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", @@ -71,8 +71,8 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/packages/core/src/components/SimpleStepper/SimpleStepperFooter.tsx b/packages/core/src/components/SimpleStepper/SimpleStepperFooter.tsx index a49e20913c..750ee55c10 100644 --- a/packages/core/src/components/SimpleStepper/SimpleStepperFooter.tsx +++ b/packages/core/src/components/SimpleStepper/SimpleStepperFooter.tsx @@ -15,7 +15,7 @@ */ import React, { useContext, ReactNode, PropsWithChildren } from 'react'; import { Button, makeStyles } from '@material-ui/core'; -import { StepActions } from './SimpleStepperStep'; +import { StepActions } from './types'; import { VerticalStepperContext } from './SimpleStepper'; const useStyles = makeStyles(theme => ({ diff --git a/packages/core/src/components/SimpleStepper/SimpleStepperStep.tsx b/packages/core/src/components/SimpleStepper/SimpleStepperStep.tsx index da467a5981..bdf3e8c59d 100644 --- a/packages/core/src/components/SimpleStepper/SimpleStepperStep.tsx +++ b/packages/core/src/components/SimpleStepper/SimpleStepperStep.tsx @@ -22,6 +22,7 @@ import { makeStyles, } from '@material-ui/core'; import { SimpleStepperFooter } from './SimpleStepperFooter'; +import { StepProps } from './types'; const useStyles = makeStyles(theme => ({ end: { @@ -29,30 +30,6 @@ const useStyles = makeStyles(theme => ({ }, })); -export type StepActions = { - showNext?: boolean; - canNext?: () => boolean; - onNext?: () => void; - nextStep?: (current: number, last: number) => number; - nextText?: string; - - showBack?: boolean; - backText?: string; - onBack?: () => void; - - showRestart?: boolean; - canRestart?: () => boolean; - onRestart?: () => void; - restartText?: string; -}; - -export type StepProps = { - title: string; - children: React.ReactElement; - end?: boolean; - actions?: StepActions; -}; - export const SimpleStepperStep = ({ title, children, diff --git a/packages/core/src/components/SimpleStepper/types.ts b/packages/core/src/components/SimpleStepper/types.ts new file mode 100644 index 0000000000..0a488ff356 --- /dev/null +++ b/packages/core/src/components/SimpleStepper/types.ts @@ -0,0 +1,41 @@ +/* + * 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'; + +export type StepActions = { + showNext?: boolean; + canNext?: () => boolean; + onNext?: () => void; + nextStep?: (current: number, last: number) => number; + nextText?: string; + + showBack?: boolean; + backText?: string; + onBack?: () => void; + + showRestart?: boolean; + canRestart?: () => boolean; + onRestart?: () => void; + restartText?: string; +}; + +export type StepProps = { + title: string; + children: React.ReactElement; + end?: boolean; + actions?: StepActions; +}; diff --git a/packages/core/src/components/TabbedLayout/RoutedTabs.tsx b/packages/core/src/components/TabbedLayout/RoutedTabs.tsx index 6b7b39329b..8d32e63688 100644 --- a/packages/core/src/components/TabbedLayout/RoutedTabs.tsx +++ b/packages/core/src/components/TabbedLayout/RoutedTabs.tsx @@ -16,7 +16,8 @@ import React, { useMemo } from 'react'; import { Helmet } from 'react-helmet'; import { matchRoutes, useNavigate, useParams, useRoutes } from 'react-router'; -import { Content, HeaderTabs } from '../../layout'; +import { Content } from '../../layout/Content'; +import { HeaderTabs } from '../../layout/HeaderTabs'; import { SubRoute } from './types'; export function useSelectedSubRoute( diff --git a/packages/core/src/layout/SignInPage/SignInPage.tsx b/packages/core/src/layout/SignInPage/SignInPage.tsx index daefe40c73..6a8dcf454e 100644 --- a/packages/core/src/layout/SignInPage/SignInPage.tsx +++ b/packages/core/src/layout/SignInPage/SignInPage.tsx @@ -14,18 +14,18 @@ * limitations under the License. */ +import { configApiRef, SignInPageProps, useApi } from '@backstage/core-api'; +import { Button, Grid, Typography } from '@material-ui/core'; import React, { useEffect, useState } from 'react'; -import { Page } from '../Page'; -import { Header } from '../Header'; +import { Progress } from '../../components/Progress'; import { Content } from '../Content/Content'; import { ContentHeader } from '../ContentHeader/ContentHeader'; -import { Grid, Button, Typography } from '@material-ui/core'; -import { SignInPageProps, useApi, configApiRef } from '@backstage/core-api'; -import { useSignInProviders, getSignInProviders } from './providers'; -import { IdentityProviders, SignInProviderConfig } from './types'; -import { Progress } from '../../components/Progress'; -import { GridItem, useStyles } from './styles'; +import { Header } from '../Header'; import { InfoCard } from '../InfoCard'; +import { Page } from '../Page'; +import { getSignInProviders, useSignInProviders } from './providers'; +import { GridItem, useStyles } from './styles'; +import { IdentityProviders, SignInProviderConfig } from './types'; type MultiSignInPageProps = SignInPageProps & { providers: IdentityProviders; @@ -87,34 +87,63 @@ export const SingleSignInPage = ({ const authApi = useApi(provider.apiRef); const configApi = useApi(configApiRef); - const [retry, setRetry] = useState<{} | boolean | undefined>(auto); + const [autoShowPopup, setAutoShowPopup] = useState(auto ?? false); + // Defaults to true so that an initial check for existing user session is made + const [retry, setRetry] = useState<{} | boolean | undefined>(true); const [error, setError] = useState(); + // The SignIn component takes some time to decide whether the user is logged-in or not. + // showLoginPage is used to prevent a glitch-like experience where the sign-in page is + // displayed for a split second when the user is already logged-in. + const [showLoginPage, setShowLoginPage] = useState(false); + useEffect(() => { const login = async () => { - const identity = await authApi.getBackstageIdentity({ - instantPopup: true, - }); + try { + let identity; + // Do an initial check if any logged-in session exists + identity = await authApi.getBackstageIdentity({ + optional: true, + }); - const profile = await authApi.getProfile(); - onResult({ - userId: identity!.id, - profile: profile!, - getIdToken: () => { - return authApi.getBackstageIdentity().then(i => i!.idToken); - }, - signOut: async () => { - await authApi.signOut(); - }, - }); + // If no session exists, show the sign-in page + if (!identity && autoShowPopup) { + // Unless auto is set to true, this step should not happen. + // When user intentionally clicks the Sign In button, autoShowPopup is set to true + identity = await authApi.getBackstageIdentity({ + instantPopup: true, + }); + } + + if (!identity) { + setShowLoginPage(true); + return; + } + + const profile = await authApi.getProfile(); + onResult({ + userId: identity!.id, + profile: profile!, + getIdToken: () => { + return authApi.getBackstageIdentity().then(i => i!.idToken); + }, + signOut: async () => { + await authApi.signOut(); + }, + }); + } catch (err) { + // User closed the sign-in modal + setError(err); + setShowLoginPage(true); + } }; if (retry) { - login().catch(setError); + login(); } - }, [onResult, authApi, retry]); + }, [onResult, authApi, retry, autoShowPopup]); - return ( + return showLoginPage ? (
@@ -133,7 +162,10 @@ export const SingleSignInPage = ({ @@ -150,6 +182,8 @@ export const SingleSignInPage = ({ + ) : ( + ); }; diff --git a/packages/create-app/templates/default-app/packages/app/package.json.hbs b/packages/create-app/templates/default-app/packages/app/package.json.hbs index 97ff521a89..6038f7c8b9 100644 --- a/packages/create-app/templates/default-app/packages/app/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/app/package.json.hbs @@ -38,7 +38,7 @@ "@types/node": "^14.14.32", "@types/react-dom": "^16.9.8", "cross-env": "^7.0.0", - "cypress": "^4.2.0", + "cypress": "^7.3.0", "eslint-plugin-cypress": "^2.10.3", "start-server-and-test": "^1.10.11" }, diff --git a/packages/dev-utils/CHANGELOG.md b/packages/dev-utils/CHANGELOG.md index ac88847489..91585424dd 100644 --- a/packages/dev-utils/CHANGELOG.md +++ b/packages/dev-utils/CHANGELOG.md @@ -1,5 +1,19 @@ # @backstage/dev-utils +## 0.1.15 + +### Patch Changes + +- 5e7b36e74: Fix a bug where an instance of the SCM integrations API wasn't being provided properly to the dev app. +- Updated dependencies [f7f7783a3] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5da6a561d] +- Updated dependencies [61c3f927c] + - @backstage/catalog-model@0.7.10 + - @backstage/core@0.7.10 + - @backstage/test-utils@0.1.12 + ## 0.1.14 ### Patch Changes diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index fb1f8a26dc..563a5102d7 100644 --- a/packages/dev-utils/package.json +++ b/packages/dev-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/dev-utils", "description": "Utilities for developing Backstage plugins.", - "version": "0.1.14", + "version": "0.1.15", "private": false, "publishConfig": { "access": "public", @@ -29,11 +29,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.9", - "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.10", + "@backstage/catalog-model": "^0.7.10", "@backstage/integration-react": "^0.1.2", "@backstage/plugin-catalog-react": "^0.1.6", - "@backstage/test-utils": "^0.1.11", + "@backstage/test-utils": "^0.1.12", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -48,7 +48,7 @@ "react-router-dom": "6.0.0-beta.0" }, "devDependencies": { - "@backstage/cli": "^0.6.11", + "@backstage/cli": "^0.6.12", "@types/jest": "^26.0.7", "@types/node": "^14.14.32" }, diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index 1785fcbe38..06975df20f 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -154,7 +154,7 @@ class DevAppBuilder { } const app = createApp({ - apis: this.apis, + apis, plugins: this.plugins, bindRoutes: ({ bind }) => { for (const plugin of this.plugins ?? []) { diff --git a/packages/integration/CHANGELOG.md b/packages/integration/CHANGELOG.md index d892523d25..6fefbe2670 100644 --- a/packages/integration/CHANGELOG.md +++ b/packages/integration/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/integration +## 0.5.3 + +### Patch Changes + +- 65e6c4541: Remove circular dependencies + ## 0.5.2 ### Patch Changes diff --git a/packages/integration/package.json b/packages/integration/package.json index 441b42e584..ba5876aae5 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/integration", - "version": "0.5.2", + "version": "0.5.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -37,9 +37,9 @@ "luxon": "^1.25.0" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/config-loader": "^0.6.1", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/config-loader": "^0.6.2", + "@backstage/test-utils": "^0.1.12", "@types/jest": "^26.0.7", "@types/luxon": "^1.25.0", "msw": "^0.21.2" diff --git a/packages/integration/src/ScmIntegrations.ts b/packages/integration/src/ScmIntegrations.ts index 0cba8e17c9..0f44958ab6 100644 --- a/packages/integration/src/ScmIntegrations.ts +++ b/packages/integration/src/ScmIntegrations.ts @@ -20,11 +20,8 @@ import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; import { GitHubIntegration } from './github/GitHubIntegration'; import { GitLabIntegration } from './gitlab/GitLabIntegration'; import { defaultScmResolveUrl } from './helpers'; -import { - ScmIntegration, - ScmIntegrationRegistry, - ScmIntegrationsGroup, -} from './types'; +import { ScmIntegration, ScmIntegrationsGroup } from './types'; +import { ScmIntegrationRegistry } from './registry'; type IntegrationsByType = { azure: ScmIntegrationsGroup; diff --git a/packages/integration/src/index.ts b/packages/integration/src/index.ts index d15eea0832..8742114008 100644 --- a/packages/integration/src/index.ts +++ b/packages/integration/src/index.ts @@ -21,8 +21,5 @@ export * from './gitlab'; export * from './googleGcs'; export { defaultScmResolveUrl } from './helpers'; export { ScmIntegrations } from './ScmIntegrations'; -export type { - ScmIntegration, - ScmIntegrationRegistry, - ScmIntegrationsGroup, -} from './types'; +export type { ScmIntegration, ScmIntegrationsGroup } from './types'; +export type { ScmIntegrationRegistry } from './registry'; diff --git a/packages/integration/src/registry.ts b/packages/integration/src/registry.ts new file mode 100644 index 0000000000..8f38bb8d5b --- /dev/null +++ b/packages/integration/src/registry.ts @@ -0,0 +1,67 @@ +/* + * 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 { ScmIntegration, ScmIntegrationsGroup } from './types'; +import { AzureIntegration } from './azure/AzureIntegration'; +import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; +import { GitHubIntegration } from './github/GitHubIntegration'; +import { GitLabIntegration } from './gitlab/GitLabIntegration'; + +/** + * Holds all registered SCM integrations, of all types. + */ +export interface ScmIntegrationRegistry + extends ScmIntegrationsGroup { + azure: ScmIntegrationsGroup; + bitbucket: ScmIntegrationsGroup; + github: ScmIntegrationsGroup; + gitlab: ScmIntegrationsGroup; + + /** + * Resolves an absolute or relative URL in relation to a base URL. + * + * This method is adapted for use within SCM systems, so relative URLs are + * within the context of the root of the hierarchy pointed to by the base + * URL. + * + * For example, if the base URL is `/folder/a.yaml`, i.e. + * within the file tree of a certain repo, an absolute path of `/b.yaml` does + * not resolve to `https://hostname/b.yaml` but rather to + * `/b.yaml` inside the file tree of that same repo. + * + * @param options.url The (absolute or relative) URL or path to resolve + * @param options.base The base URL onto which this resolution happens + * @param options.lineNumber The line number in the target file to link to, starting with 1. Only applicable when linking to files. + */ + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string; + + /** + * Resolves the edit URL for a file within the SCM system. + * + * Most SCM systems have a web interface that allows viewing and editing files + * in the repository. The returned URL directly jumps into the edit mode for + * the file. + * If this is not possible, the integration can fall back to a URL to view + * the file in the web interface. + * + * @param url The absolute URL to the file that should be edited. + */ + resolveEditUrl(url: string): string; +} diff --git a/packages/integration/src/types.ts b/packages/integration/src/types.ts index f06bda285a..30c298c57f 100644 --- a/packages/integration/src/types.ts +++ b/packages/integration/src/types.ts @@ -15,10 +15,6 @@ */ import { Config } from '@backstage/config'; -import { AzureIntegration } from './azure/AzureIntegration'; -import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; -import { GitHubIntegration } from './github/GitHubIntegration'; -import { GitLabIntegration } from './gitlab/GitLabIntegration'; /** * Encapsulates a single SCM integration. @@ -95,52 +91,6 @@ export interface ScmIntegrationsGroup { byHost(host: string): T | undefined; } -/** - * Holds all registered SCM integrations, of all types. - */ -export interface ScmIntegrationRegistry - extends ScmIntegrationsGroup { - azure: ScmIntegrationsGroup; - bitbucket: ScmIntegrationsGroup; - github: ScmIntegrationsGroup; - gitlab: ScmIntegrationsGroup; - - /** - * Resolves an absolute or relative URL in relation to a base URL. - * - * This method is adapted for use within SCM systems, so relative URLs are - * within the context of the root of the hierarchy pointed to by the base - * URL. - * - * For example, if the base URL is `/folder/a.yaml`, i.e. - * within the file tree of a certain repo, an absolute path of `/b.yaml` does - * not resolve to `https://hostname/b.yaml` but rather to - * `/b.yaml` inside the file tree of that same repo. - * - * @param options.url The (absolute or relative) URL or path to resolve - * @param options.base The base URL onto which this resolution happens - * @param options.lineNumber The line number in the target file to link to, starting with 1. Only applicable when linking to files. - */ - resolveUrl(options: { - url: string; - base: string; - lineNumber?: number; - }): string; - - /** - * Resolves the edit URL for a file within the SCM system. - * - * Most SCM systems have a web interface that allows viewing and editing files - * in the repository. The returned URL directly jumps into the edit mode for - * the file. - * If this is not possible, the integration can fall back to a URL to view - * the file in the web interface. - * - * @param url The absolute URL to the file that should be edited. - */ - resolveEditUrl(url: string): string; -} - export type ScmIntegrationsFactory = (options: { config: Config; }) => ScmIntegrationsGroup; diff --git a/packages/techdocs-common/CHANGELOG.md b/packages/techdocs-common/CHANGELOG.md index ec47f22475..6f57a9ed3f 100644 --- a/packages/techdocs-common/CHANGELOG.md +++ b/packages/techdocs-common/CHANGELOG.md @@ -1,5 +1,19 @@ # @backstage/techdocs-common +## 0.6.2 + +### Patch Changes + +- 65e6c4541: Remove circular dependencies +- Updated dependencies [f7f7783a3] +- Updated dependencies [c7dad9218] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5001de908] + - @backstage/catalog-model@0.7.10 + - @backstage/backend-common@0.8.1 + - @backstage/integration@0.5.3 + ## 0.6.1 ### Patch Changes diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index e6debbb817..bdeba7a789 100644 --- a/packages/techdocs-common/package.json +++ b/packages/techdocs-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/techdocs-common", "description": "Common functionalities for TechDocs, to be shared between techdocs-backend plugin and techdocs-cli", - "version": "0.6.1", + "version": "0.6.2", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -38,11 +38,11 @@ "dependencies": { "@azure/identity": "^1.2.2", "@azure/storage-blob": "^12.4.0", - "@backstage/backend-common": "^0.8.0", - "@backstage/catalog-model": "^0.7.9", + "@backstage/backend-common": "^0.8.1", + "@backstage/catalog-model": "^0.7.10", "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", - "@backstage/integration": "^0.5.2", + "@backstage/integration": "^0.5.3", "@google-cloud/storage": "^5.6.0", "@types/express": "^4.17.6", "aws-sdk": "^2.840.0", @@ -60,7 +60,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.6.11", + "@backstage/cli": "^0.6.12", "@types/fs-extra": "^9.0.5", "@types/git-url-parse": "^9.0.0", "@types/js-yaml": "^4.0.0", diff --git a/packages/techdocs-common/src/stages/prepare/preparers.ts b/packages/techdocs-common/src/stages/prepare/preparers.ts index 4c6325f305..91cfcc72ff 100644 --- a/packages/techdocs-common/src/stages/prepare/preparers.ts +++ b/packages/techdocs-common/src/stages/prepare/preparers.ts @@ -18,7 +18,9 @@ import { Entity } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { Logger } from 'winston'; import { parseReferenceAnnotation } from '../../helpers'; -import { CommonGitPreparer, DirectoryPreparer, UrlPreparer } from '../prepare'; +import { DirectoryPreparer } from './dir'; +import { CommonGitPreparer } from './commonGit'; +import { UrlPreparer } from './url'; import { PreparerBase, PreparerBuilder, RemoteProtocol } from './types'; type factoryOptions = { diff --git a/packages/test-utils/CHANGELOG.md b/packages/test-utils/CHANGELOG.md index 9a77665e28..7fe7efe490 100644 --- a/packages/test-utils/CHANGELOG.md +++ b/packages/test-utils/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/test-utils +## 0.1.12 + +### Patch Changes + +- 61c3f927c: Updated `MockErrorApi` to work with new `Observable` type in `@backstage/core`. +- Updated dependencies [61c3f927c] +- Updated dependencies [65e6c4541] + - @backstage/core-api@0.2.19 + ## 0.1.11 ### Patch Changes diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 7b7b73a64a..429d616572 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/test-utils", "description": "Utilities to test Backstage plugins and apps.", - "version": "0.1.11", + "version": "0.1.12", "private": false, "publishConfig": { "access": "public", @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-api": "^0.2.18", + "@backstage/core-api": "^0.2.19", "@backstage/test-utils-core": "^0.1.1", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", @@ -45,7 +45,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.11", + "@backstage/cli": "^0.6.12", "@types/jest": "^26.0.7", "@types/node": "^14.14.32" }, diff --git a/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts b/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts index 7bf50314d0..b90646201a 100644 --- a/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts +++ b/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts @@ -32,6 +32,10 @@ type Waiter = { const nullObservable = { subscribe: () => ({ unsubscribe: () => {}, closed: true }), + + [Symbol.observable]() { + return this; + }, }; export class MockErrorApi implements ErrorApi { diff --git a/plugins/api-docs/CHANGELOG.md b/plugins/api-docs/CHANGELOG.md index 94fd3fe74a..9c8a29eeec 100644 --- a/plugins/api-docs/CHANGELOG.md +++ b/plugins/api-docs/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-api-docs +## 0.4.14 + +### Patch Changes + +- 65e6c4541: Remove circular dependencies +- Updated dependencies [f7f7783a3] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5da6a561d] + - @backstage/catalog-model@0.7.10 + - @backstage/core@0.7.10 + ## 0.4.13 ### Patch Changes diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index 9610ae4e10..5bb574e592 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-api-docs", - "version": "0.4.13", + "version": "0.4.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,8 +30,8 @@ }, "dependencies": { "@asyncapi/react-component": "^0.23.0", - "@backstage/catalog-model": "^0.7.9", - "@backstage/core": "^0.7.9", + "@backstage/catalog-model": "^0.7.10", + "@backstage/core": "^0.7.10", "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-icons/font": "^1.0.2", @@ -49,9 +49,9 @@ "swagger-ui-react": "^3.37.2" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/api-docs/src/config.ts b/plugins/api-docs/src/config.ts index a90189a037..c2a806d8b2 100644 --- a/plugins/api-docs/src/config.ts +++ b/plugins/api-docs/src/config.ts @@ -16,7 +16,7 @@ import { ApiEntity } from '@backstage/catalog-model'; import { createApiRef } from '@backstage/core'; -import { ApiDefinitionWidget } from './components'; +import { ApiDefinitionWidget } from './components/ApiDefinitionCard/ApiDefinitionWidget'; export const apiDocsConfigRef = createApiRef({ id: 'plugin.api-docs.config', diff --git a/plugins/auth-backend/CHANGELOG.md b/plugins/auth-backend/CHANGELOG.md index 1003140dec..6573f7ee2e 100644 --- a/plugins/auth-backend/CHANGELOG.md +++ b/plugins/auth-backend/CHANGELOG.md @@ -1,5 +1,20 @@ # @backstage/plugin-auth-backend +## 0.3.11 + +### Patch Changes + +- 65e6c4541: Remove circular dependencies +- Updated dependencies [f7f7783a3] +- Updated dependencies [c7dad9218] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5001de908] +- Updated dependencies [61c3f927c] + - @backstage/catalog-model@0.7.10 + - @backstage/backend-common@0.8.1 + - @backstage/test-utils@0.1.12 + ## 0.3.10 ### Patch Changes diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 1b0ad25a84..ba8fcdc794 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-auth-backend", - "version": "0.3.10", + "version": "0.3.11", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,12 +29,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.8.0", + "@backstage/backend-common": "^0.8.1", "@backstage/catalog-client": "^0.3.11", - "@backstage/catalog-model": "^0.7.9", + "@backstage/catalog-model": "^0.7.10", "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", - "@backstage/test-utils": "^0.1.11", + "@backstage/test-utils": "^0.1.12", "@types/express": "^4.17.6", "@types/passport": "^1.0.3", "compression": "^1.7.4", @@ -68,7 +68,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.11", + "@backstage/cli": "^0.6.12", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/express-session": "^1.17.2", diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts index 6cf6a94964..5648ca5965 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts @@ -23,7 +23,7 @@ import { AuthProviderConfig, } from '../../providers/types'; import { InputError } from '@backstage/errors'; -import { TokenIssuer } from '../../identity'; +import { TokenIssuer } from '../../identity/types'; import { verifyNonce } from './helpers'; import { postMessageResponse, ensuresXRequestedWith } from '../flow'; import { OAuthHandlers, OAuthStartRequest, OAuthRefreshRequest } from './types'; diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index a00644a90d..a714674447 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -39,7 +39,7 @@ import { PassportDoneCallback, } from '../../lib/passport'; import { AuthProviderFactory, RedirectInfo } from '../types'; -import { TokenIssuer } from '../../identity'; +import { TokenIssuer } from '../../identity/types'; type PrivateInfo = { refreshToken: string; diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts index c3a91b3b77..979380da9e 100644 --- a/plugins/auth-backend/src/providers/saml/provider.ts +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -28,7 +28,7 @@ import { } from '../../lib/passport'; import { AuthProviderRouteHandlers, AuthProviderFactory } from '../types'; import { postMessageResponse } from '../../lib/flow'; -import { TokenIssuer } from '../../identity'; +import { TokenIssuer } from '../../identity/types'; type SamlInfo = { fullProfile: any; diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 1a4e7b118a..700af3ebef 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -19,7 +19,7 @@ import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; import express from 'express'; import { Logger } from 'winston'; -import { TokenIssuer } from '../identity'; +import { TokenIssuer } from '../identity/types'; export type AuthProviderConfig = { /** diff --git a/plugins/badges-backend/CHANGELOG.md b/plugins/badges-backend/CHANGELOG.md index d171d0d590..465170c239 100644 --- a/plugins/badges-backend/CHANGELOG.md +++ b/plugins/badges-backend/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-badges-backend +## 0.1.5 + +### Patch Changes + +- a286a6397: Fix generated badge links +- Updated dependencies [f7f7783a3] +- Updated dependencies [c7dad9218] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5001de908] + - @backstage/catalog-model@0.7.10 + - @backstage/backend-common@0.8.1 + ## 0.1.4 ### Patch Changes diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json index d7e0df88b5..3bbabde739 100644 --- a/plugins/badges-backend/package.json +++ b/plugins/badges-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-badges-backend", - "version": "0.1.4", + "version": "0.1.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,9 +30,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.8.0", + "@backstage/backend-common": "^0.8.1", "@backstage/catalog-client": "^0.3.11", - "@backstage/catalog-model": "^0.7.9", + "@backstage/catalog-model": "^0.7.10", "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", "@types/express": "^4.17.6", @@ -45,7 +45,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.11", + "@backstage/cli": "^0.6.12", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" }, diff --git a/plugins/badges-backend/src/badges.test.ts b/plugins/badges-backend/src/badges.test.ts index 15be96154e..1d5e57141d 100644 --- a/plugins/badges-backend/src/badges.test.ts +++ b/plugins/badges-backend/src/badges.test.ts @@ -65,4 +65,28 @@ describe('BadgeFactories', () => { expect(badge.kind).toEqual('entity'); } }); + + it('returns valid link for entity', () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'service', + metadata: { + name: 'test', + }, + }; + + const context: BadgeContext = { + badgeUrl: '/dummy/url', + config, + entity, + }; + + expect.assertions(Object.keys(badgeFactories).length); + for (const badgeFactory of Object.values(badgeFactories)) { + const badge = badgeFactory.createBadge(context); + expect(badge.link).toContain( + 'http://localhost/catalog/default/service/test', + ); + } + }); }); diff --git a/plugins/badges-backend/src/badges.ts b/plugins/badges-backend/src/badges.ts index f8637dfe88..ccd5f55855 100644 --- a/plugins/badges-backend/src/badges.ts +++ b/plugins/badges-backend/src/badges.ts @@ -24,8 +24,8 @@ function appTitle(context: BadgeContext): string { function entityUrl(context: BadgeContext): string { const e = context.entity!; - const entityUri = `${e.kind}/${ - e.metadata.namespace || ENTITY_DEFAULT_NAMESPACE + const entityUri = `${e.metadata.namespace || ENTITY_DEFAULT_NAMESPACE}/${ + e.kind }/${e.metadata.name}`; const catalogUrl = `${context.config.getString('app.baseUrl')}/catalog`; return `${catalogUrl}/${entityUri}`.toLowerCase(); diff --git a/plugins/badges/package.json b/plugins/badges/package.json index 72aa2df787..aba90d257b 100644 --- a/plugins/badges/package.json +++ b/plugins/badges/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.9", - "@backstage/core": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/errors": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", @@ -34,9 +34,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/bitrise/package.json b/plugins/bitrise/package.json index 1414f729fd..f2b9288dd7 100644 --- a/plugins/bitrise/package.json +++ b/plugins/bitrise/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.9", - "@backstage/core": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", @@ -37,9 +37,9 @@ "recharts": "^1.8.5" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/catalog-backend/CHANGELOG.md b/plugins/catalog-backend/CHANGELOG.md index 0018d83399..5815ac5f78 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,26 @@ # @backstage/plugin-catalog-backend +## 0.9.1 + +### Patch Changes + +- 50a5348b7: Fix error handling in `LdapOrgReaderProcessor`, and support complex paging options +- 1b8e28aed: Resolve the `target` for glob `file` locations correctly +- dcd5a93a9: Correctly add `/project-slug` annotation for new catalog-info.yaml PRs based on SCM integration. +- f7f7783a3: Add Owner field in template card and new data distribution + Add spec.owner as optional field into TemplateV1Alpha and TemplateV1Beta Schema + Add relations ownedBy and ownerOf into Template entity + Template documentation updated +- 62579ced6: Skip adding entries to the `entities_search` table if their `key` exceeds a length limit. +- Updated dependencies [f7f7783a3] +- Updated dependencies [c7dad9218] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5001de908] + - @backstage/catalog-model@0.7.10 + - @backstage/backend-common@0.8.1 + - @backstage/integration@0.5.3 + ## 0.9.0 ### Minor Changes diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index dc9397de82..f55ca2c13f 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend", - "version": "0.9.0", + "version": "0.9.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,11 +30,11 @@ }, "dependencies": { "@azure/msal-node": "^1.0.0-beta.3", - "@backstage/backend-common": "^0.8.0", - "@backstage/catalog-model": "^0.7.9", + "@backstage/backend-common": "^0.8.1", + "@backstage/catalog-model": "^0.7.10", "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", - "@backstage/integration": "^0.5.2", + "@backstage/integration": "^0.5.3", "@backstage/plugin-search-backend-node": "^0.1.4", "@backstage/search-common": "^0.1.1", "@octokit/graphql": "^4.5.8", @@ -63,8 +63,8 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/test-utils": "^0.1.12", "@types/core-js": "^2.5.4", "@types/git-url-parse": "^9.0.0", "@types/lodash": "^4.14.151", diff --git a/plugins/catalog-backend/src/database/search.test.ts b/plugins/catalog-backend/src/database/search.test.ts index c6793cbdf4..656da8b6f5 100644 --- a/plugins/catalog-backend/src/database/search.test.ts +++ b/plugins/catalog-backend/src/database/search.test.ts @@ -101,6 +101,12 @@ describe('search', () => { expect(output).toEqual([{ entity_id: 'eid', key: 'foo', value: 'bar' }]); }); + it('skips very large keys', () => { + const input = [{ key: 'a'.repeat(10000), value: 'foo' }]; + const output = mapToRows(input, 'eid'); + expect(output).toEqual([]); + }); + it('skips very large values', () => { const input = [{ key: 'foo', value: 'a'.repeat(10000) }]; const output = mapToRows(input, 'eid'); diff --git a/plugins/catalog-backend/src/database/search.ts b/plugins/catalog-backend/src/database/search.ts index 9e58d2468b..67b6d14b4f 100644 --- a/plugins/catalog-backend/src/database/search.ts +++ b/plugins/catalog-backend/src/database/search.ts @@ -31,6 +31,7 @@ const SPECIAL_KEYS = [ // The maximum length allowed for search values. These columns are indexed, and // database engines do not like to index on massive values. For example, // postgres will balk after 8191 byte line sizes. +const MAX_KEY_LENGTH = 200; const MAX_VALUE_LENGTH = 200; type Kv = { @@ -136,7 +137,7 @@ export function mapToRows( result.push({ entity_id: entityId, key, value: null }); } else { const value = String(rawValue).toLowerCase(); - if (value.length <= MAX_VALUE_LENGTH) { + if (key.length <= MAX_KEY_LENGTH && value.length <= MAX_VALUE_LENGTH) { result.push({ entity_id: entityId, key, value }); } } diff --git a/plugins/catalog-backend/src/ingestion/LocationAnalyzer.ts b/plugins/catalog-backend/src/ingestion/LocationAnalyzer.ts index bc81cf14f8..cacdf38ffd 100644 --- a/plugins/catalog-backend/src/ingestion/LocationAnalyzer.ts +++ b/plugins/catalog-backend/src/ingestion/LocationAnalyzer.ts @@ -16,6 +16,8 @@ import { Logger } from 'winston'; import parseGitUrl from 'git-url-parse'; +import { Entity } from '@backstage/catalog-model'; +import { ScmIntegrationRegistry } from '@backstage/integration'; import { AnalyzeLocationRequest, AnalyzeLocationResponse, @@ -24,25 +26,50 @@ import { export class RepoLocationAnalyzer implements LocationAnalyzer { private readonly logger: Logger; + private readonly scmIntegrations: ScmIntegrationRegistry; - constructor(logger: Logger) { + constructor(logger: Logger, scmIntegrations: ScmIntegrationRegistry) { this.logger = logger; + this.scmIntegrations = scmIntegrations; } async analyzeLocation( request: AnalyzeLocationRequest, ): Promise { - const { owner, name, source } = parseGitUrl(request.location.target); - const entity = { + const { owner, name } = parseGitUrl(request.location.target); + const entity: Entity = { apiVersion: 'backstage.io/v1alpha1', kind: 'Component', metadata: { name: name, - // Probably won't handle properly self-hosted git providers with custom url - annotations: { [`${source}/project-slug`]: `${owner}/${name}` }, }, spec: { type: 'other', lifecycle: 'unknown' }, }; + const integration = this.scmIntegrations.byUrl(request.location.target); + let annotationPrefix; + switch (integration?.type) { + case 'azure': + annotationPrefix = 'dev.azure.com'; + break; + case 'bitbucket': + annotationPrefix = 'bitbucket.org'; + break; + case 'github': + annotationPrefix = 'github.com'; + break; + case 'gitlab': + annotationPrefix = 'gitlab.com'; + break; + default: + break; + } + + if (annotationPrefix) { + entity.metadata.annotations = { + [`${annotationPrefix}/project-slug`]: `${owner}/${name}`, + }; + } + this.logger.debug(`entity created for ${request.location.target}`); return { existingEntityFiles: [], diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts index cb60c119b6..3a8c061892 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts @@ -21,6 +21,7 @@ import { GroupEntity, ResourceEntity, SystemEntity, + TemplateEntity, UserEntity, } from '@backstage/catalog-model'; import { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor'; @@ -520,5 +521,47 @@ describe('BuiltinKindsEntityProcessor', () => { }, }); }); + it('generates relations for template entities', async () => { + const entity: TemplateEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Template', + metadata: { name: 'n' }, + spec: { + schema: { + properties: { + description: { + title: 'd', + type: 'string', + description: 'des', + }, + }, + }, + templater: 'cookiecutter', + path: '.', + type: 'service', + owner: 'o', + }, + }; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toBeCalledTimes(2); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'o' }, + type: 'ownerOf', + target: { kind: 'Template', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Template', namespace: 'default', name: 'n' }, + type: 'ownedBy', + target: { kind: 'Group', namespace: 'default', name: 'o' }, + }, + }); + }); }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts index 404d7fd30d..f9acae6b4f 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts @@ -46,6 +46,7 @@ import { resourceEntityV1alpha1Validator, SystemEntity, systemEntityV1alpha1Validator, + TemplateEntity, templateEntityV1alpha1Validator, templateEntityV1beta2Validator, UserEntity, @@ -131,6 +132,19 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { } } + /* + * Emit relations for the Template kind + */ + if (entity.kind === 'Template') { + const template = entity as TemplateEntity; + doEmit( + template.spec.owner, + { defaultKind: 'Group', defaultNamespace: selfRef.namespace }, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + ); + } + /* * Emit relations for the Component kind */ diff --git a/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.test.ts index 0155f93916..d51f22f538 100644 --- a/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.test.ts @@ -79,6 +79,14 @@ describe('FileReaderProcessor', () => { expect(emit).toBeCalledTimes(2); expect(emit.mock.calls[0][0].entity).toEqual({ kind: 'Component' }); + expect(emit.mock.calls[0][0].location).toEqual({ + type: 'file', + target: expect.stringMatching(/^[^*]*$/), + }); expect(emit.mock.calls[1][0].entity).toEqual({ kind: 'API' }); + expect(emit.mock.calls[1][0].location).toEqual({ + type: 'file', + target: expect.stringMatching(/^[^*]*$/), + }); }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.ts index db069913a2..8c4c522abb 100644 --- a/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.ts @@ -16,11 +16,12 @@ import { LocationSpec } from '@backstage/catalog-model'; import fs from 'fs-extra'; +import g from 'glob'; +import path from 'path'; +import { promisify } from 'util'; import * as result from './results'; import { CatalogProcessor, CatalogProcessorEmit } from './types'; import { parseEntityYaml } from './util/parse'; -import { promisify } from 'util'; -import g from 'glob'; const glob = promisify(g); @@ -41,7 +42,12 @@ export class FileReaderProcessor implements CatalogProcessor { for (const fileMatch of fileMatches) { const data = await fs.readFile(fileMatch); - for (const parseResult of parseEntityYaml(data, location)) { + // The normalize converts to native slashes; the glob library returns + // forward slashes even on windows + for (const parseResult of parseEntityYaml(data, { + type: 'file', + target: path.normalize(fileMatch), + })) { emit(parseResult); } } diff --git a/plugins/catalog-backend/src/ingestion/processors/LdapOrgReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/LdapOrgReaderProcessor.ts index d863142169..474e813590 100644 --- a/plugins/catalog-backend/src/ingestion/processors/LdapOrgReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/LdapOrgReaderProcessor.ts @@ -69,7 +69,11 @@ export class LdapOrgReaderProcessor implements CatalogProcessor { // Be lazy and create the client each time; even though it's pretty // inefficient, we usually only do this once per entire refresh loop and // don't have to worry about timeouts and reconnects etc. - const client = await LdapClient.create(provider.target, provider.bind); + const client = await LdapClient.create( + this.logger, + provider.target, + provider.bind, + ); const { users, groups } = await readLdapOrg( client, provider.users, diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/client.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/client.ts index bf0d0f740e..0557f21003 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/client.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/client.ts @@ -15,6 +15,7 @@ */ import ldap, { Client, SearchEntry, SearchOptions } from 'ldapjs'; +import { Logger } from 'winston'; import { BindConfig } from './config'; import { errorString } from './util'; import { @@ -31,8 +32,20 @@ import { export class LdapClient { private vendor: Promise | undefined; - static async create(target: string, bind?: BindConfig): Promise { + static async create( + logger: Logger, + target: string, + bind?: BindConfig, + ): Promise { const client = ldap.createClient({ url: target }); + + // We want to have a catch-all error handler at the top, since the default + // behavior of the client is to blow up the entire process when it fails, + // unless an error handler is set. + client.on('error', (err: ldap.Error) => { + logger.warn(`LDAP client threw an error, ${errorString(err)}`); + }); + if (!bind) { return new LdapClient(client); } @@ -92,7 +105,7 @@ export class LdapClient { }); }); } catch (e) { - throw new Error(`LDAP search at ${dn} failed, ${e.message}`); + throw new Error(`LDAP search at DN "${dn}" failed, ${e.message}`); } } diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts index 607581e153..755c173d19 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts @@ -105,7 +105,10 @@ describe('readLdapConfig', () => { scope: 'base', attributes: ['*'], filter: 'f', - paged: true, + paged: { + pageSize: 7, + pagePause: true, + }, }, set: { p: 'v' }, map: { @@ -153,7 +156,10 @@ describe('readLdapConfig', () => { scope: 'base', attributes: ['*'], filter: 'f', - paged: true, + paged: { + pageSize: 7, + pagePause: true, + }, }, set: { p: 'v' }, map: { diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts index aca02e6e10..417657b244 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts @@ -180,11 +180,32 @@ export function readLdapConfig(config: Config): LdapProviderConfig[] { if (!c) { return {}; } + + const paged = readOptionsPagedConfig(c); + return { scope: c.getOptionalString('scope') as SearchOptions['scope'], filter: formatFilter(c.getOptionalString('filter')), attributes: c.getOptionalStringArray('attributes'), - paged: c.getOptionalBoolean('paged'), + ...(paged !== undefined ? { paged } : undefined), + }; + } + + function readOptionsPagedConfig(c: Config): SearchOptions['paged'] { + const pagedConfig = c.getOptional('paged'); + if (pagedConfig === undefined) { + return undefined; + } + + if (pagedConfig === true || pagedConfig === false) { + return pagedConfig; + } + + const pageSize = c.getOptionalNumber('paged.pageSize'); + const pagePause = c.getOptionalBoolean('paged.pagePause'); + return { + ...(pageSize !== undefined ? { pageSize } : undefined), + ...(pagePause !== undefined ? { pagePause } : undefined), }; } @@ -258,7 +279,7 @@ export function readLdapConfig(config: Config): LdapProviderConfig[] { } function formatFilter(filter?: string): string | undefined { - // Remove extra whitespaces between blocks to support multiline filters from the configuration + // Remove extra whitespace between blocks to support multiline filters from the configuration return filter?.replace(/\s*(\(|\))/g, '$1')?.trim(); } diff --git a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts index 43f321248e..e10d13c129 100644 --- a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts +++ b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts @@ -275,7 +275,7 @@ export class NextCatalogBuilder { ); const locationsCatalog = new DatabaseLocationsCatalog(db); - const locationAnalyzer = new RepoLocationAnalyzer(logger); + const locationAnalyzer = new RepoLocationAnalyzer(logger, integrations); const locationService = new DefaultLocationService( locationStore, orchestrator, diff --git a/plugins/catalog-backend/src/next/Stitcher.test.ts b/plugins/catalog-backend/src/next/Stitcher.test.ts index f38af384fa..644284f3e4 100644 --- a/plugins/catalog-backend/src/next/Stitcher.test.ts +++ b/plugins/catalog-backend/src/next/Stitcher.test.ts @@ -91,6 +91,9 @@ describe('Stitcher', () => { }, }, ], + status: { + 'backstage.io/catalog-processing': {}, + }, apiVersion: 'a', kind: 'k', metadata: { @@ -171,6 +174,9 @@ describe('Stitcher', () => { }, }, ]), + status: { + 'backstage.io/catalog-processing': {}, + }, apiVersion: 'a', kind: 'k', metadata: { diff --git a/plugins/catalog-backend/src/next/Stitcher.ts b/plugins/catalog-backend/src/next/Stitcher.ts index 2bddf41ac9..d7d4325ad0 100644 --- a/plugins/catalog-backend/src/next/Stitcher.ts +++ b/plugins/catalog-backend/src/next/Stitcher.ts @@ -15,6 +15,7 @@ */ import { Entity, parseEntityRef } from '@backstage/catalog-model'; +import { JsonObject } from '@backstage/config'; import { ConflictError } from '@backstage/errors'; import { createHash } from 'crypto'; import stableStringify from 'fast-json-stable-stringify'; @@ -35,12 +36,21 @@ export type DbFinalEntitiesRow = { final_entity: string; }; +type ProcessingStatus = { + errors?: JsonObject[]; +}; + function generateStableHash(entity: Entity) { return createHash('sha1') .update(stableStringify({ ...entity })) .digest('hex'); } +/** + * Performs the act of stitching - to take all of the various outputs from the + * ingestion process, and stitching them together into the final entity JSON + * shape. + */ export class Stitcher { constructor( private readonly database: Knex, @@ -109,7 +119,7 @@ export class Stitcher { const { entityId, processedEntity, - // errors, + errors, incomingReferenceCount, previousHash, } = result[0]; @@ -129,6 +139,7 @@ export class Stitcher { // it const entity = JSON.parse(processedEntity) as Entity; const isOrphan = Number(incomingReferenceCount) === 0; + const processingStatus: ProcessingStatus = {}; if (isOrphan) { this.logger.debug(`${entityRef} is an orphan`); @@ -137,6 +148,12 @@ export class Stitcher { ['backstage.io/orphan']: 'true', }; } + if (errors) { + const parsedErrors = JSON.parse(errors); + if (Array.isArray(parsedErrors) && parsedErrors.length) { + processingStatus.errors = parsedErrors; + } + } // TODO: entityRef is lower case and should be uppercase in the final // result @@ -146,6 +163,10 @@ export class Stitcher { type: row.relationType!, target: parseEntityRef(row.relationTarget!), })); + entity.status = { + ...entity.status, + 'backstage.io/catalog-processing': processingStatus, + }; // If the output entity was actually not changed, just abort const hash = generateStableHash(entity); diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts index 140c93929d..8588ba5e52 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts @@ -376,8 +376,8 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { .update({ next_update_at: tx.client.config.client === 'sqlite3' - ? tx.raw(`datetime('now', ?)`, [`10 seconds`]) // TODO: test this in sqlite3 - : tx.raw(`now() + interval '30 seconds'`), + ? tx.raw(`datetime('now', ?)`, [`100 seconds`]) + : tx.raw(`now() + interval '100 seconds'`), }); return { diff --git a/plugins/catalog-backend/src/next/search.test.ts b/plugins/catalog-backend/src/next/search.test.ts index 2e0b07b4a9..be896319b7 100644 --- a/plugins/catalog-backend/src/next/search.test.ts +++ b/plugins/catalog-backend/src/next/search.test.ts @@ -104,6 +104,12 @@ describe('search', () => { expect(output).toEqual([{ entity_id: 'eid', key: 'foo', value: 'bar' }]); }); + it('skips very large keys', () => { + const input = [{ key: 'a'.repeat(10000), value: 'foo' }]; + const output = mapToRows(input, 'eid'); + expect(output).toEqual([]); + }); + it('skips very large values', () => { const input = [{ key: 'foo', value: 'a'.repeat(10000) }]; const output = mapToRows(input, 'eid'); diff --git a/plugins/catalog-backend/src/next/search.ts b/plugins/catalog-backend/src/next/search.ts index 383b29fd70..0681bffa17 100644 --- a/plugins/catalog-backend/src/next/search.ts +++ b/plugins/catalog-backend/src/next/search.ts @@ -43,6 +43,7 @@ const SPECIAL_KEYS = [ // The maximum length allowed for search values. These columns are indexed, and // database engines do not like to index on massive values. For example, // postgres will balk after 8191 byte line sizes. +const MAX_KEY_LENGTH = 200; const MAX_VALUE_LENGTH = 200; type Kv = { @@ -145,7 +146,7 @@ export function mapToRows(input: Kv[], entityId: string): DbSearchRow[] { result.push({ entity_id: entityId, key, value: null }); } else { const value = String(rawValue).toLocaleLowerCase('en-US'); - if (value.length <= MAX_VALUE_LENGTH) { + if (key.length <= MAX_KEY_LENGTH && value.length <= MAX_VALUE_LENGTH) { result.push({ entity_id: entityId, key, value }); } } diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index b8d8b3ee61..f742e3c0db 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -228,6 +228,7 @@ export class CatalogBuilder { locationAnalyzer: LocationAnalyzer; }> { const { config, database, logger } = this.env; + const integrations = ScmIntegrations.fromConfig(config); const policy = this.buildEntityPolicy(); const processors = this.buildProcessors(); @@ -255,7 +256,7 @@ export class CatalogBuilder { locationReader, logger, ); - const locationAnalyzer = new RepoLocationAnalyzer(logger); + const locationAnalyzer = new RepoLocationAnalyzer(logger, integrations); return { entitiesCatalog, diff --git a/plugins/catalog-import/CHANGELOG.md b/plugins/catalog-import/CHANGELOG.md index e41b135014..d5ecc45b51 100644 --- a/plugins/catalog-import/CHANGELOG.md +++ b/plugins/catalog-import/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-catalog-import +## 0.5.7 + +### Patch Changes + +- ca6e0ab69: Make UI errors friendlier when importing existing components +- Updated dependencies [f7f7783a3] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5da6a561d] + - @backstage/catalog-model@0.7.10 + - @backstage/core@0.7.10 + - @backstage/integration@0.5.3 + ## 0.5.6 ### Patch Changes diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index 196b3d6e83..b90954ea99 100644 --- a/plugins/catalog-import/package.json +++ b/plugins/catalog-import/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-import", - "version": "0.5.6", + "version": "0.5.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,10 +30,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.9", + "@backstage/catalog-model": "^0.7.10", "@backstage/catalog-client": "^0.3.11", - "@backstage/core": "^0.7.9", - "@backstage/integration": "^0.5.2", + "@backstage/core": "^0.7.10", + "@backstage/integration": "^0.5.3", "@backstage/integration-react": "^0.1.2", "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", @@ -53,9 +53,9 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^3.3.0", diff --git a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx index 49bd776db5..09f6230fdc 100644 --- a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx +++ b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx @@ -108,7 +108,7 @@ export const StepInitAnalyzeUrl = ({ } } } catch (e) { - setError(e.message); + setError(e.data?.error?.message ?? e.message); setSubmitted(false); } }, diff --git a/plugins/catalog/CHANGELOG.md b/plugins/catalog/CHANGELOG.md index 160440c69f..02aacab77f 100644 --- a/plugins/catalog/CHANGELOG.md +++ b/plugins/catalog/CHANGELOG.md @@ -1,5 +1,26 @@ # @backstage/plugin-catalog +## 0.5.8 + +### Patch Changes + +- a53f3d603: - Added `RelatedEntitesCard` as a base implementation of displaying entities that are related to another entity. + - Added `HasResourcesCard` to display resources that are part of a system. + - Added `DependsOnComponentsCard` to display components that are dependencies of a component. + - Added `DependsOnResourcesCard` to display resources that are dependencies of a component. + - Refactored `HasComponentsCard` to use base `RelatedEntitiesCard`. Card remains backwards compatible. + - Refactored `HasSubcomponentsCard` to use base `RelatedEntitiesCard`. Card remains backwards compatible. + - Refactored `HasSystemsCard` to use base `RelatedEntitiesCard`. Card remains backwards compatible. + - Updated the example app to take advantage of these new components. +- b203699e9: Display warning when Entity has orphan annotation. +- Updated dependencies [f7f7783a3] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5da6a561d] + - @backstage/catalog-model@0.7.10 + - @backstage/core@0.7.10 + - @backstage/integration@0.5.3 + ## 0.5.7 ### Patch Changes diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index ee541ce8ce..2896d82fe3 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog", - "version": "0.5.7", + "version": "0.5.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,10 +31,10 @@ }, "dependencies": { "@backstage/catalog-client": "^0.3.10", - "@backstage/catalog-model": "^0.7.9", - "@backstage/core": "^0.7.9", + "@backstage/catalog-model": "^0.7.10", + "@backstage/core": "^0.7.10", "@backstage/errors": "^0.1.1", - "@backstage/integration": "^0.5.1", + "@backstage/integration": "^0.5.3", "@backstage/integration-react": "^0.1.2", "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", @@ -53,9 +53,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@microsoft/microsoft-graph-types": "^1.25.0", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", diff --git a/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.test.tsx b/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.test.tsx new file mode 100644 index 0000000000..8ea7638c8e --- /dev/null +++ b/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.test.tsx @@ -0,0 +1,121 @@ +/* + * 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 { Entity, RELATION_DEPENDS_ON } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { DependsOnComponentsCard } from './DependsOnComponentsCard'; + +describe('', () => { + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no dependencies', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-component', + namespace: 'my-namespace', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText('Components')).toBeInTheDocument(); + expect( + getByText(/No component is a dependency of this component/i), + ).toBeInTheDocument(); + }); + + it('shows dependency components', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-component', + namespace: 'my-namespace', + }, + relations: [ + { + target: { + kind: 'Component', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_DEPENDS_ON, + }, + ], + }; + catalogApi.getEntities.mockResolvedValue({ + items: [ + { + apiVersion: 'v1', + kind: 'Component', + metadata: { + namespace: 'my-namespace', + name: 'target-name', + }, + spec: {}, + }, + ], + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(getByText('Components')).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.tsx b/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.tsx new file mode 100644 index 0000000000..f837bb02ff --- /dev/null +++ b/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.tsx @@ -0,0 +1,43 @@ +/* + * 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 { RELATION_DEPENDS_ON } from '@backstage/catalog-model'; +import React from 'react'; +import { + asComponentEntities, + componentEntityColumns, + componentEntityHelpLink, + RelatedEntitiesCard, +} from '../RelatedEntitiesCard'; + +type Props = { + variant?: 'gridItem'; +}; + +export const DependsOnComponentsCard = ({ variant = 'gridItem' }: Props) => { + return ( + + ); +}; diff --git a/plugins/catalog/src/components/DependsOnComponentsCard/index.ts b/plugins/catalog/src/components/DependsOnComponentsCard/index.ts new file mode 100644 index 0000000000..f46faf8198 --- /dev/null +++ b/plugins/catalog/src/components/DependsOnComponentsCard/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 { DependsOnComponentsCard } from './DependsOnComponentsCard'; diff --git a/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.test.tsx b/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.test.tsx new file mode 100644 index 0000000000..8f425ba6aa --- /dev/null +++ b/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.test.tsx @@ -0,0 +1,121 @@ +/* + * 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 { Entity, RELATION_DEPENDS_ON } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { DependsOnResourcesCard } from './DependsOnResourcesCard'; + +describe('', () => { + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no dependencies', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-component', + namespace: 'my-namespace', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText('Resources')).toBeInTheDocument(); + expect( + getByText(/No resource is a dependency of this component/i), + ).toBeInTheDocument(); + }); + + it('shows dependency resources', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-component', + namespace: 'my-namespace', + }, + relations: [ + { + target: { + kind: 'Resource', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_DEPENDS_ON, + }, + ], + }; + catalogApi.getEntities.mockResolvedValue({ + items: [ + { + apiVersion: 'v1', + kind: 'Resource', + metadata: { + namespace: 'my-namespace', + name: 'target-name', + }, + spec: {}, + }, + ], + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(getByText('Resources')).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.tsx b/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.tsx new file mode 100644 index 0000000000..7b64654f20 --- /dev/null +++ b/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.tsx @@ -0,0 +1,43 @@ +/* + * 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 { RELATION_DEPENDS_ON } from '@backstage/catalog-model'; +import React from 'react'; +import { + asResourceEntities, + componentEntityHelpLink, + RelatedEntitiesCard, + resourceEntityColumns, +} from '../RelatedEntitiesCard'; + +type Props = { + variant?: 'gridItem'; +}; + +export const DependsOnResourcesCard = ({ variant = 'gridItem' }: Props) => { + return ( + + ); +}; diff --git a/plugins/catalog/src/components/DependsOnResourcesCard/index.ts b/plugins/catalog/src/components/DependsOnResourcesCard/index.ts new file mode 100644 index 0000000000..20062f51dd --- /dev/null +++ b/plugins/catalog/src/components/DependsOnResourcesCard/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 { DependsOnResourcesCard } from './DependsOnResourcesCard'; diff --git a/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx b/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx new file mode 100644 index 0000000000..d9f266cce7 --- /dev/null +++ b/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx @@ -0,0 +1,132 @@ +/* + * Copyright 2021 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 userEvent from '@testing-library/user-event'; +import React from 'react'; +import { DeleteEntityDialog } from './DeleteEntityDialog'; +import { ORIGIN_LOCATION_ANNOTATION } from '@backstage/catalog-model'; +import { + AlertApi, + alertApiRef, + ApiProvider, + ApiRegistry, +} from '@backstage/core'; +import { CatalogApi } from '@backstage/catalog-client'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { screen, waitFor } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; + +describe('DeleteEntityDialog', () => { + const alertApi: jest.Mocked = { + post: jest.fn(), + alert$: jest.fn(), + }; + + const catalogClient: jest.Mocked = { + removeEntityByUid: jest.fn(), + } as any; + const apis = ApiRegistry.with(catalogApiRef, catalogClient).with( + alertApiRef, + alertApi, + ); + + const entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + uid: '123', + name: 'n', + namespace: 'ns', + annotations: { + [ORIGIN_LOCATION_ANNOTATION]: 'url:http://example.com', + }, + }, + spec: {}, + }; + + const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('can cancel', async () => { + const onClose = jest.fn(); + + await renderInTestApp( + + {}} + entity={entity} + /> + , + ); + + userEvent.click(screen.getByText('Cancel')); + + await waitFor(() => { + expect(onClose).toBeCalled(); + }); + }); + + it('can delete', async () => { + const onConfirm = jest.fn(); + + await renderInTestApp( + + {}} + onConfirm={onConfirm} + entity={entity} + /> + , + ); + + userEvent.click(screen.getByText('Delete')); + + await waitFor(() => { + expect(catalogClient.removeEntityByUid).toBeCalledWith('123'); + expect(onConfirm).toBeCalled(); + }); + }); + + it('handles error', async () => { + const onConfirm = jest.fn(); + + await renderInTestApp( + + {}} + onConfirm={onConfirm} + entity={entity} + /> + , + ); + + catalogClient.removeEntityByUid.mockRejectedValue(new Error('no no no')); + userEvent.click(screen.getByText('Delete')); + + await waitFor(() => { + expect(catalogClient.removeEntityByUid).toBeCalledWith('123'); + expect(alertApi.post).toBeCalledWith({ message: 'no no no' }); + }); + }); +}); diff --git a/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.tsx b/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.tsx new file mode 100644 index 0000000000..26b6b29650 --- /dev/null +++ b/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.tsx @@ -0,0 +1,73 @@ +/* + * Copyright 2021 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 { Entity } from '@backstage/catalog-model'; +import { alertApiRef, useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { Button, Dialog, DialogActions, DialogTitle } from '@material-ui/core'; +import React, { useState } from 'react'; + +type Props = { + open: boolean; + onClose: () => any; + onConfirm: () => any; + entity: Entity; +}; + +export const DeleteEntityDialog = ({ + open, + onClose, + onConfirm, + entity, +}: Props) => { + const [busy, setBusy] = useState(false); + const catalogApi = useApi(catalogApiRef); + const alertApi = useApi(alertApiRef); + + const onDelete = async () => { + setBusy(true); + try { + const uid = entity.metadata.uid; + await catalogApi.removeEntityByUid(uid!); + onConfirm(); + } catch (err) { + alertApi.post({ message: err.message }); + } finally { + setBusy(false); + } + }; + + return ( + + + Are you sure you want to delete this entity? + + + + + + + ); +}; diff --git a/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.test.tsx b/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.test.tsx new file mode 100644 index 0000000000..0b3b8de504 --- /dev/null +++ b/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.test.tsx @@ -0,0 +1,70 @@ +/* + * 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 { ApiProvider, ApiRegistry } from '@backstage/core'; + +import { + CatalogApi, + catalogApiRef, + catalogRouteRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { EntityOrphanWarning } from './EntityOrphanWarning'; + +describe('', () => { + const catalogClient: jest.Mocked = { + removeEntityByUid: jest.fn(), + } as any; + const apis = ApiRegistry.with(catalogApiRef, catalogClient); + + it('renders EntityOrphanWarning if the entity is orphan', async () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + description: 'This is the description', + annotations: { 'backstage.io/orphan': 'true' }, + }, + + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + + const { getByText } = await renderInTestApp( + + + + + , + { + mountedRoutes: { + '/create': catalogRouteRef, + }, + }, + ); + expect( + getByText( + 'This entity is not referenced by any location and is therefore not receiving updates. Click here to delete.', + ), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.tsx b/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.tsx new file mode 100644 index 0000000000..070c36c56c --- /dev/null +++ b/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.tsx @@ -0,0 +1,56 @@ +/* + * Copyright 2021 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 { Entity } from '@backstage/catalog-model'; +import { useRouteRef } from '@backstage/core'; +import { catalogRouteRef, useEntity } from '@backstage/plugin-catalog-react'; +import { Alert } from '@material-ui/lab'; +import React, { useState } from 'react'; +import { useNavigate } from 'react-router'; +import { DeleteEntityDialog } from './DeleteEntityDialog'; + +export const isOrphan = (entity: Entity) => + entity?.metadata?.annotations?.['backstage.io/orphan'] === 'true'; + +/** + * Displays a warning alert if the entity is marked as orphan with the ability to delete said entity. + */ +export const EntityOrphanWarning = () => { + const navigate = useNavigate(); + const catalogLink = useRouteRef(catalogRouteRef); + const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); + const { entity } = useEntity(); + + const cleanUpAfterRemoval = async () => { + setConfirmationDialogOpen(false); + navigate(catalogLink()); + }; + + return ( + <> + setConfirmationDialogOpen(true)}> + This entity is not referenced by any location and is therefore not + receiving updates. Click here to delete. + + setConfirmationDialogOpen(false)} + /> + + ); +}; diff --git a/plugins/catalog/src/components/EntityOrphanWarning/index.ts b/plugins/catalog/src/components/EntityOrphanWarning/index.ts new file mode 100644 index 0000000000..b64f8c1232 --- /dev/null +++ b/plugins/catalog/src/components/EntityOrphanWarning/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 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 { EntityOrphanWarning, isOrphan } from './EntityOrphanWarning'; diff --git a/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.tsx b/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.tsx index b6eae81f30..e2fbb6ff70 100644 --- a/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.tsx +++ b/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.tsx @@ -14,79 +14,30 @@ * limitations under the License. */ -import { ComponentEntity, RELATION_HAS_PART } from '@backstage/catalog-model'; -import { - CodeSnippet, - InfoCard, - Link, - Progress, - WarningPanel, -} from '@backstage/core'; -import { Typography } from '@material-ui/core'; -import { - EntityTable, - useEntity, - useRelatedEntities, -} from '@backstage/plugin-catalog-react'; +import { RELATION_HAS_PART } from '@backstage/catalog-model'; import React from 'react'; +import { + asComponentEntities, + componentEntityColumns, + componentEntityHelpLink, + RelatedEntitiesCard, +} from '../RelatedEntitiesCard'; type Props = { variant?: 'gridItem'; }; -const columns = [ - EntityTable.columns.createEntityRefColumn({ defaultKind: 'component' }), - EntityTable.columns.createOwnerColumn(), - EntityTable.columns.createSpecTypeColumn(), - EntityTable.columns.createSpecLifecycleColumn(), - EntityTable.columns.createMetadataDescriptionColumn(), -]; - export const HasComponentsCard = ({ variant = 'gridItem' }: Props) => { - const { entity } = useEntity(); - const { entities, loading, error } = useRelatedEntities(entity, { - type: RELATION_HAS_PART, - kind: 'Component', - }); - - if (loading) { - return ( - - - - ); - } - - if (error || !entities) { - return ( - - } - /> - - ); - } - return ( - - - No component is part of this system. - - - - Learn how to change this. - - - - } - columns={columns} - entities={entities as ComponentEntity[]} + title="Components" + entityKind="Component" + relationType={RELATION_HAS_PART} + columns={componentEntityColumns} + emptyMessage="No component is part of this system" + emptyHelpLink={componentEntityHelpLink} + asRenderableEntities={asComponentEntities} /> ); }; diff --git a/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.test.tsx b/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.test.tsx new file mode 100644 index 0000000000..38c8a3b5f5 --- /dev/null +++ b/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.test.tsx @@ -0,0 +1,121 @@ +/* + * 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 { Entity, RELATION_HAS_PART } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { HasResourcesCard } from './HasResourcesCard'; + +describe('', () => { + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'System', + metadata: { + name: 'my-system', + namespace: 'my-namespace', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText('Resources')).toBeInTheDocument(); + expect( + getByText(/No resource is part of this system/i), + ).toBeInTheDocument(); + }); + + it('shows related resources', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'System', + metadata: { + name: 'my-system', + namespace: 'my-namespace', + }, + relations: [ + { + target: { + kind: 'Resource', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_HAS_PART, + }, + ], + }; + catalogApi.getEntities.mockResolvedValue({ + items: [ + { + apiVersion: 'v1', + kind: 'Resource', + metadata: { + name: 'target-name', + namespace: 'my-namespace', + }, + spec: {}, + }, + ], + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(getByText('Resources')).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.tsx b/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.tsx new file mode 100644 index 0000000000..be923d30a1 --- /dev/null +++ b/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.tsx @@ -0,0 +1,43 @@ +/* + * 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 { RELATION_HAS_PART } from '@backstage/catalog-model'; +import React from 'react'; +import { + asResourceEntities, + RelatedEntitiesCard, + resourceEntityColumns, + resourceEntityHelpLink, +} from '../RelatedEntitiesCard'; + +type Props = { + variant?: 'gridItem'; +}; + +export const HasResourcesCard = ({ variant = 'gridItem' }: Props) => { + return ( + + ); +}; diff --git a/plugins/catalog/src/components/HasResourcesCard/index.ts b/plugins/catalog/src/components/HasResourcesCard/index.ts new file mode 100644 index 0000000000..48b1d419aa --- /dev/null +++ b/plugins/catalog/src/components/HasResourcesCard/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 { HasResourcesCard } from './HasResourcesCard'; diff --git a/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.tsx b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.tsx index 41244ba3d4..bf4aeb18cc 100644 --- a/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.tsx +++ b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.tsx @@ -14,79 +14,29 @@ * limitations under the License. */ -import { ComponentEntity, RELATION_HAS_PART } from '@backstage/catalog-model'; -import { - CodeSnippet, - InfoCard, - Link, - Progress, - WarningPanel, -} from '@backstage/core'; -import { Typography } from '@material-ui/core'; -import { - EntityTable, - useEntity, - useRelatedEntities, -} from '@backstage/plugin-catalog-react'; +import { RELATION_HAS_PART } from '@backstage/catalog-model'; import React from 'react'; +import { + asComponentEntities, + componentEntityColumns, + RelatedEntitiesCard, +} from '../RelatedEntitiesCard'; type Props = { variant?: 'gridItem'; }; -const columns = [ - EntityTable.columns.createEntityRefColumn({ defaultKind: 'component' }), - EntityTable.columns.createOwnerColumn(), - EntityTable.columns.createSpecTypeColumn(), - EntityTable.columns.createSpecLifecycleColumn(), - EntityTable.columns.createMetadataDescriptionColumn(), -]; - export const HasSubcomponentsCard = ({ variant = 'gridItem' }: Props) => { - const { entity } = useEntity(); - const { entities, loading, error } = useRelatedEntities(entity, { - type: RELATION_HAS_PART, - kind: 'Component', - }); - - if (loading) { - return ( - - - - ); - } - - if (error || !entities) { - return ( - - } - /> - - ); - } - return ( - - - No subcomponent is part of this component. - - - - Learn how to change this. - - - - } - columns={columns} - entities={entities as ComponentEntity[]} + title="Subcomponents" + entityKind="Component" + relationType={RELATION_HAS_PART} + columns={componentEntityColumns} + asRenderableEntities={asComponentEntities} + emptyMessage="No subcomponent is part of this component" + emptyHelpLink="https://backstage.io/docs/features/software-catalog/descriptor-format#specsubcomponentof-optional" /> ); }; diff --git a/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.tsx b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.tsx index ea4de474de..b6c5bb7ca2 100644 --- a/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.tsx +++ b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.tsx @@ -14,76 +14,30 @@ * limitations under the License. */ -import { RELATION_HAS_PART, SystemEntity } from '@backstage/catalog-model'; -import { - CodeSnippet, - InfoCard, - Link, - Progress, - WarningPanel, -} from '@backstage/core'; -import { Typography } from '@material-ui/core'; -import { - EntityTable, - useEntity, - useRelatedEntities, -} from '@backstage/plugin-catalog-react'; +import { RELATION_HAS_PART } from '@backstage/catalog-model'; import React from 'react'; +import { + asSystemEntities, + RelatedEntitiesCard, + systemEntityColumns, + systemEntityHelpLink, +} from '../RelatedEntitiesCard'; type Props = { variant?: 'gridItem'; }; -const columns = [ - EntityTable.columns.createEntityRefColumn({ defaultKind: 'system' }), - EntityTable.columns.createOwnerColumn(), - EntityTable.columns.createMetadataDescriptionColumn(), -]; - export const HasSystemsCard = ({ variant = 'gridItem' }: Props) => { - const { entity } = useEntity(); - const { entities, loading, error } = useRelatedEntities(entity, { - type: RELATION_HAS_PART, - }); - - if (loading) { - return ( - - - - ); - } - - if (error || !entities) { - return ( - - } - /> - - ); - } - return ( - - - No system is part of this domain. - - - - Learn how to change this. - - - - } - columns={columns} - entities={entities as SystemEntity[]} + title="Systems" + entityKind="System" + relationType={RELATION_HAS_PART} + columns={systemEntityColumns} + asRenderableEntities={asSystemEntities} + emptyMessage="No system is part of this domain" + emptyHelpLink={systemEntityHelpLink} /> ); }; diff --git a/plugins/catalog/src/components/RelatedEntitiesCard/RelatedEntitiesCard.tsx b/plugins/catalog/src/components/RelatedEntitiesCard/RelatedEntitiesCard.tsx new file mode 100644 index 0000000000..f80f138515 --- /dev/null +++ b/plugins/catalog/src/components/RelatedEntitiesCard/RelatedEntitiesCard.tsx @@ -0,0 +1,92 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { + InfoCard, + Link, + Progress, + ResponseErrorPanel, + TableColumn, +} from '@backstage/core'; +import { Typography } from '@material-ui/core'; +import { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; + +type Props = { + variant?: 'gridItem'; + title: string; + columns: TableColumn[]; + entityKind: string; + relationType: string; + emptyMessage: string; + emptyHelpLink: string; + asRenderableEntities: (entities: Entity[]) => T[]; +}; + +export function RelatedEntitiesCard({ + variant = 'gridItem', + title, + columns, + entityKind, + relationType, + emptyMessage, + emptyHelpLink, + asRenderableEntities, +}: Props) { + const { entity } = useEntity(); + const { entities, loading, error } = useRelatedEntities(entity, { + type: relationType, + kind: entityKind, + }); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + + + ); + } + + return ( + + {emptyMessage} + + Learn how to change this. + + + } + columns={columns} + entities={asRenderableEntities(entities || [])} + /> + ); +} diff --git a/plugins/catalog/src/components/RelatedEntitiesCard/index.ts b/plugins/catalog/src/components/RelatedEntitiesCard/index.ts new file mode 100644 index 0000000000..3104004dd6 --- /dev/null +++ b/plugins/catalog/src/components/RelatedEntitiesCard/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 { RelatedEntitiesCard } from './RelatedEntitiesCard'; +export * from './presets'; diff --git a/plugins/catalog/src/components/RelatedEntitiesCard/presets.ts b/plugins/catalog/src/components/RelatedEntitiesCard/presets.ts new file mode 100644 index 0000000000..9d3586a7e9 --- /dev/null +++ b/plugins/catalog/src/components/RelatedEntitiesCard/presets.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2021 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 { + ComponentEntity, + Entity, + ResourceEntity, + SystemEntity, +} from '@backstage/catalog-model'; +import { TableColumn } from '@backstage/core'; +import { EntityTable } from '@backstage/plugin-catalog-react'; + +export const componentEntityColumns: TableColumn[] = [ + EntityTable.columns.createEntityRefColumn({ defaultKind: 'component' }), + EntityTable.columns.createOwnerColumn(), + EntityTable.columns.createSpecTypeColumn(), + EntityTable.columns.createSpecLifecycleColumn(), + EntityTable.columns.createMetadataDescriptionColumn(), +]; +export const componentEntityHelpLink: string = + 'https://backstage.io/docs/features/software-catalog/descriptor-format#kind-component'; +export const asComponentEntities = (entities: Entity[]): ComponentEntity[] => + entities as ComponentEntity[]; + +export const resourceEntityColumns: TableColumn[] = [ + EntityTable.columns.createEntityRefColumn({ defaultKind: 'resource' }), + EntityTable.columns.createOwnerColumn(), + EntityTable.columns.createSpecTypeColumn(), + EntityTable.columns.createSpecLifecycleColumn(), + EntityTable.columns.createMetadataDescriptionColumn(), +]; +export const resourceEntityHelpLink: string = + 'https://backstage.io/docs/features/software-catalog/descriptor-format#kind-resource'; +export const asResourceEntities = (entities: Entity[]): ResourceEntity[] => + entities as ResourceEntity[]; + +export const systemEntityColumns: TableColumn[] = [ + EntityTable.columns.createEntityRefColumn({ defaultKind: 'system' }), + EntityTable.columns.createOwnerColumn(), + EntityTable.columns.createMetadataDescriptionColumn(), +]; +export const systemEntityHelpLink: string = + 'https://backstage.io/docs/features/software-catalog/descriptor-format#kind-system'; +export const asSystemEntities = (entities: Entity[]): SystemEntity[] => + entities as SystemEntity[]; diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index fb269fc484..f7506b534a 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -26,9 +26,13 @@ export { catalogPlugin, catalogPlugin as plugin, EntityAboutCard, + EntityDependsOnComponentsCard, + EntityDependsOnResourcesCard, EntityHasComponentsCard, + EntityHasResourcesCard, EntityHasSubcomponentsCard, EntityHasSystemsCard, EntityLinksCard, EntitySystemDiagramCard, } from './plugin'; +export * from './components/EntityOrphanWarning'; diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index 20c7a865db..3f4de6c67d 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -115,6 +115,37 @@ export const EntityHasSubcomponentsCard = catalogPlugin.provide( }), ); +export const EntityHasResourcesCard = catalogPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/HasResourcesCard').then(m => m.HasResourcesCard), + }, + }), +); + +export const EntityDependsOnComponentsCard = catalogPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/DependsOnComponentsCard').then( + m => m.DependsOnComponentsCard, + ), + }, + }), +); + +export const EntityDependsOnResourcesCard = catalogPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/DependsOnResourcesCard').then( + m => m.DependsOnResourcesCard, + ), + }, + }), +); + export const EntitySystemDiagramCard = catalogPlugin.provide( createComponentExtension({ component: { diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index e9b18c43d0..e3f40f29c8 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.9", - "@backstage/core": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", @@ -50,9 +50,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/cloudbuild/CHANGELOG.md b/plugins/cloudbuild/CHANGELOG.md index a7540f2e72..8a21e9b872 100644 --- a/plugins/cloudbuild/CHANGELOG.md +++ b/plugins/cloudbuild/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-cloudbuild +## 0.2.15 + +### Patch Changes + +- 65e6c4541: Remove circular dependencies +- Updated dependencies [f7f7783a3] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5da6a561d] + - @backstage/catalog-model@0.7.10 + - @backstage/core@0.7.10 + ## 0.2.14 ### Patch Changes diff --git a/plugins/cloudbuild/package.json b/plugins/cloudbuild/package.json index 510f417bc2..795807f45f 100644 --- a/plugins/cloudbuild/package.json +++ b/plugins/cloudbuild/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-cloudbuild", - "version": "0.2.14", + "version": "0.2.15", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,9 +30,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.9", + "@backstage/catalog-model": "^0.7.10", "@backstage/plugin-catalog-react": "^0.1.6", - "@backstage/core": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -47,9 +47,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/cloudbuild/src/components/Cards/Cards.tsx b/plugins/cloudbuild/src/components/Cards/Cards.tsx index 75ae33027e..e7b3d54de1 100644 --- a/plugins/cloudbuild/src/components/Cards/Cards.tsx +++ b/plugins/cloudbuild/src/components/Cards/Cards.tsx @@ -14,8 +14,8 @@ * limitations under the License. */ import React, { useEffect } from 'react'; -import { useWorkflowRuns } from '../useWorkflowRuns'; -import { WorkflowRun, WorkflowRunsTable } from '../WorkflowRunsTable'; +import { useWorkflowRuns, WorkflowRun } from '../useWorkflowRuns'; +import { WorkflowRunsTable } from '../WorkflowRunsTable'; import { Entity } from '@backstage/catalog-model'; import { useEntity } from '@backstage/plugin-catalog-react'; import { WorkflowRunStatus } from '../WorkflowRunStatus'; diff --git a/plugins/cloudbuild/src/components/Router.tsx b/plugins/cloudbuild/src/components/Router.tsx index 0f71efcf86..bd2299f8e9 100644 --- a/plugins/cloudbuild/src/components/Router.tsx +++ b/plugins/cloudbuild/src/components/Router.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { Entity } from '@backstage/catalog-model'; import { useEntity } from '@backstage/plugin-catalog-react'; import { Routes, Route } from 'react-router'; -import { rootRouteRef, buildRouteRef } from '../plugin'; +import { rootRouteRef, buildRouteRef } from '../routes'; import { WorkflowRunDetails } from './WorkflowRunDetails'; import { WorkflowRunsTable } from './WorkflowRunsTable'; import { CLOUDBUILD_ANNOTATION } from './useProjectName'; diff --git a/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx b/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx index 43cbbefe24..886d772dd5 100644 --- a/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx +++ b/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx @@ -19,26 +19,14 @@ import RetryIcon from '@material-ui/icons/Replay'; import GoogleIcon from '@material-ui/icons/CloudCircle'; import { Link as RouterLink, generatePath } from 'react-router-dom'; import { Table, TableColumn } from '@backstage/core'; -import { useWorkflowRuns } from '../useWorkflowRuns'; +import { useWorkflowRuns, WorkflowRun } from '../useWorkflowRuns'; import { WorkflowRunStatus } from '../WorkflowRunStatus'; import SyncIcon from '@material-ui/icons/Sync'; import { useProjectName } from '../useProjectName'; import { Entity } from '@backstage/catalog-model'; -import { Substitutions } from '../../api/types'; -import { buildRouteRef } from '../../plugin'; +import { buildRouteRef } from '../../routes'; import moment from 'moment'; -export type WorkflowRun = { - id: string; - message: string; - url?: string; - googleUrl?: string; - status: string; - substitutions: Substitutions; - createTime: string; - rerun: () => void; -}; - const generatedColumns: TableColumn[] = [ { title: 'Status', diff --git a/plugins/cloudbuild/src/components/WorkflowRunsTable/index.ts b/plugins/cloudbuild/src/components/WorkflowRunsTable/index.ts index 4e21cc67c2..e190aa55bc 100644 --- a/plugins/cloudbuild/src/components/WorkflowRunsTable/index.ts +++ b/plugins/cloudbuild/src/components/WorkflowRunsTable/index.ts @@ -14,4 +14,3 @@ * limitations under the License. */ export { WorkflowRunsTable, WorkflowRunsTableView } from './WorkflowRunsTable'; -export type { WorkflowRun } from './WorkflowRunsTable'; diff --git a/plugins/cloudbuild/src/components/useWorkflowRuns.ts b/plugins/cloudbuild/src/components/useWorkflowRuns.ts index b6d6c2c620..ac7400cbf3 100644 --- a/plugins/cloudbuild/src/components/useWorkflowRuns.ts +++ b/plugins/cloudbuild/src/components/useWorkflowRuns.ts @@ -15,10 +15,23 @@ */ import { useState } from 'react'; import { useAsyncRetry } from 'react-use'; -import { WorkflowRun } from './WorkflowRunsTable/WorkflowRunsTable'; import { cloudbuildApiRef } from '../api/CloudbuildApi'; import { useApi, errorApiRef } from '@backstage/core'; -import { ActionsListWorkflowRunsForRepoResponseData } from '../api/types'; +import { + ActionsListWorkflowRunsForRepoResponseData, + Substitutions, +} from '../api/types'; + +export type WorkflowRun = { + id: string; + message: string; + url?: string; + googleUrl?: string; + status: string; + substitutions: Substitutions; + createTime: string; + rerun: () => void; +}; export function useWorkflowRuns({ projectId }: { projectId: string }) { const api = useApi(cloudbuildApiRef); diff --git a/plugins/cloudbuild/src/plugin.ts b/plugins/cloudbuild/src/plugin.ts index f5aaf4696a..f0db5702e6 100644 --- a/plugins/cloudbuild/src/plugin.ts +++ b/plugins/cloudbuild/src/plugin.ts @@ -15,23 +15,13 @@ */ import { createPlugin, - createRouteRef, createApiFactory, googleAuthApiRef, createRoutableExtension, createComponentExtension, } from '@backstage/core'; import { cloudbuildApiRef, CloudbuildClient } from './api'; - -export const rootRouteRef = createRouteRef({ - path: '', - title: 'Google Cloudbuild', -}); - -export const buildRouteRef = createRouteRef({ - path: ':id', - title: 'Cloudbuild Run', -}); +import { rootRouteRef } from './routes'; export const cloudbuildPlugin = createPlugin({ id: 'cloudbuild', diff --git a/plugins/cloudbuild/src/routes.ts b/plugins/cloudbuild/src/routes.ts new file mode 100644 index 0000000000..610d708b44 --- /dev/null +++ b/plugins/cloudbuild/src/routes.ts @@ -0,0 +1,26 @@ +/* + * 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 { createRouteRef } from '@backstage/core'; + +export const rootRouteRef = createRouteRef({ + path: '', + title: 'Google Cloudbuild', +}); + +export const buildRouteRef = createRouteRef({ + path: ':id', + title: 'Cloudbuild Run', +}); diff --git a/plugins/code-coverage/package.json b/plugins/code-coverage/package.json index f90985145f..b1a2696861 100644 --- a/plugins/code-coverage/package.json +++ b/plugins/code-coverage/package.json @@ -22,7 +22,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.9", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/errors": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", @@ -39,9 +39,9 @@ "recharts": "^1.8.5" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/config-schema/package.json b/plugins/config-schema/package.json index c878f418d0..2332d9ef67 100644 --- a/plugins/config-schema/package.json +++ b/plugins/config-schema/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/errors": "^0.1.1", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", @@ -34,9 +34,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/cost-insights/CHANGELOG.md b/plugins/cost-insights/CHANGELOG.md index a94910cfb2..d60d8c709c 100644 --- a/plugins/cost-insights/CHANGELOG.md +++ b/plugins/cost-insights/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-cost-insights +## 0.10.0 + +### Minor Changes + +- 7cbfcae48: support jsx in alert titles + +### Patch Changes + +- 5914a76d5: Added example client +- Updated dependencies [65e6c4541] +- Updated dependencies [5da6a561d] + - @backstage/core@0.7.10 + ## 0.9.1 ### Patch Changes diff --git a/plugins/cost-insights/README.md b/plugins/cost-insights/README.md index 3fee89e77a..2311937890 100644 --- a/plugins/cost-insights/README.md +++ b/plugins/cost-insights/README.md @@ -25,9 +25,7 @@ yarn add @backstage/plugin-cost-insights 1. Configure `app-config.yaml`. See [Configuration](#configuration). -2. Create a CostInsights client. Clients must implement the CostInsightsApi interface. See the [API file](https://github.com/backstage/backstage/blob/master/plugins/cost-insights/src/api/CostInsightsApi.ts) for required methods and documentation. - -**Note:** We've briefly explored using the AWS Cost Explorer API to implement a CostInsights client. Learn more about our findings [here](https://github.com/backstage/backstage/blob/master/plugins/cost-insights/contrib/aws-cost-explorer-api.md). +2. Create a CostInsights client. Clients must implement the [CostInsightsApi](https://github.com/backstage/backstage/blob/master/plugins/cost-insights/src/api/CostInsightsApi.ts) interface. Create your own or [use a template](https://github.com/backstage/backstage/blob/master/plugins/cost-insights/src/example/templates/CostInsightsClient.ts) to get started. ```ts // path/to/CostInsightsClient.ts @@ -36,7 +34,9 @@ import { CostInsightsApi } from '@backstage/plugin-cost-insights'; export class CostInsightsClient implements CostInsightsApi { ... } ``` -3. Import the client and the CostInsights plugin API to your Backstage instance. +**Note:** We've briefly explored using the AWS Cost Explorer API to implement a Cost Insights client. Learn more about our findings [here](https://github.com/backstage/backstage/blob/master/plugins/cost-insights/contrib/aws-cost-explorer-api.md). + +3. Import the client and the Cost Insights plugin API to your Backstage instance. ```ts // packages/app/src/api.ts diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json index a467fc0542..b8cb87e3fe 100644 --- a/plugins/cost-insights/package.json +++ b/plugins/cost-insights/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-cost-insights", - "version": "0.9.1", + "version": "0.10.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/config": "^0.1.5", - "@backstage/core": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -55,9 +55,9 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx b/plugins/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx index 70ae44bcdc..b686076e17 100644 --- a/plugins/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx +++ b/plugins/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx @@ -43,4 +43,22 @@ describe('', () => { expect(rendered.getByText(alert.title)).toBeInTheDocument(); expect(rendered.getByText(alert.subtitle)).toBeInTheDocument(); }); + + it('renders custom title elements', async () => { + const rendered = await renderInTestApp( + + Foo, + subtitle: Bar, + }} + avatar={
1
} + /> +
, + ); + + expect(rendered.getByText('Foo')).toBeInTheDocument(); + expect(rendered.getByText('Bar')).toBeInTheDocument(); + }); }); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx index 093ca1ff0c..f9fe985e43 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx @@ -41,14 +41,33 @@ describe('', () => { onAccept={jest.fn()} />, ); - expect(getByText(mockAlert.title)).toBeInTheDocument(); - expect(getByText(mockAlert.subtitle)).toBeInTheDocument(); + expect(getByText(mockAlert.title as string)).toBeInTheDocument(); + expect(getByText(mockAlert.subtitle as string)).toBeInTheDocument(); expect(getByText('View Instructions')).toBeInTheDocument(); expect(queryByText('Snooze')).not.toBeInTheDocument(); expect(queryByText('Accept')).not.toBeInTheDocument(); expect(queryByText('Dismiss')).not.toBeInTheDocument(); }); + it('renders custom title elements', () => { + const { getByText } = renderInContext( + Foo, + subtitle: Bar, + }} + number={1} + onSnooze={jest.fn()} + onDismiss={jest.fn()} + onAccept={jest.fn()} + />, + ); + + expect(getByText('Foo')).toBeInTheDocument(); + expect(getByText('Bar')).toBeInTheDocument(); + }); + it('Hides instructions button if url is not provided', () => { const alert: Alert = { ...mockAlert, diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx index ab39e0364e..754bc6db5c 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx @@ -50,8 +50,8 @@ describe('', () => { , ); [mockSnoozed, mockAccepted, mockDismissed].forEach(a => { - expect(getByText(a.title)).toBeInTheDocument(); - expect(getByText(a.subtitle)).toBeInTheDocument(); + expect(getByText(a.title as string)).toBeInTheDocument(); + expect(getByText(a.subtitle as string)).toBeInTheDocument(); expect(getByRole('img', { name: a.status })).toBeInTheDocument(); }); }); diff --git a/plugins/cost-insights/src/example/alerts/KubernetesMigrationAlert.tsx b/plugins/cost-insights/src/example/alerts/KubernetesMigrationAlert.tsx index 4878462f62..8b386f338c 100644 --- a/plugins/cost-insights/src/example/alerts/KubernetesMigrationAlert.tsx +++ b/plugins/cost-insights/src/example/alerts/KubernetesMigrationAlert.tsx @@ -17,6 +17,7 @@ import React from 'react'; import pluralize from 'pluralize'; import { KubernetesMigrationAlertCard } from '../components'; +import { Lifecycle } from '@backstage/core'; import { CostInsightsApi } from '../../api'; import { Alert, @@ -87,11 +88,13 @@ export class KubernetesMigrationAlert implements KubernetesMigrationApi { } get title() { - return `Consider migrating ${pluralize( - 'service', - this.data.services.length, - true, - )} to Kubernetes.`; + return ( + + Consider migrating{' '} + {pluralize('service', this.data.services.length, true)} to Kubernetes{' '} + + + ); } get element() { diff --git a/plugins/cost-insights/src/example/templates/CostInsightsClient.ts b/plugins/cost-insights/src/example/templates/CostInsightsClient.ts new file mode 100644 index 0000000000..f7e4686961 --- /dev/null +++ b/plugins/cost-insights/src/example/templates/CostInsightsClient.ts @@ -0,0 +1,101 @@ +/* + * 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. + */ + +/* + This is a copy-pastable client template to get up and running quickly. + API Reference: + https://github.com/backstage/backstage/blob/master/plugins/cost-insights/src/api/CostInsightsApi.ts +*/ + +// IMPORTANT: Remove the lines below to enable type checking and linting +// @ts-nocheck +/* eslint-disable import/no-extraneous-dependencies */ + +import { + CostInsightsApi, + ProductInsightsOptions, + Alert, + Cost, + Entity, + Group, + MetricData, + Project, +} from '@backstage/plugin-cost-insights'; + +export class CostInsightsClient implements CostInsightsApi { + + async getLastCompleteBillingDate(): Promise { + return '2021-01-01'; // YYYY-MM-DD + } + + async getUserGroups(userId: string): Promise { + return []; + } + + async getGroupProjects(group: string): Promise { + return []; + } + + async getAlerts(group: string): Promise { + return []; + } + + async getDailyMetricData(metric: string, intervals: string): Promise { + return { + id: 'remove-me', + format: 'number', + aggregation: [], + change: { + ratio: 0, + amount: 0 + } + } + } + + async getGroupDailyCost(group: string, intervals: string): Promise { + return { + id: 'remove-me', + aggregation: [], + change: { + ratio: 0, + amount: 0 + } + } + } + + async getProjectDailyCost(project: string, intervals: string): Promise { + return { + id: 'remove-me', + aggregation: [], + change: { + ratio: 0, + amount: 0 + } + } + } + + async getProductInsights(options: ProductInsightsOptions): Promise { + return { + id: 'remove-me', + aggregation: [0, 0], + change: { + ratio: 0, + amount: 0 + }, + entities: {} + } + } +} diff --git a/plugins/cost-insights/src/types/Alert.ts b/plugins/cost-insights/src/types/Alert.ts index 84c37a7c15..a8b041a67e 100644 --- a/plugins/cost-insights/src/types/Alert.ts +++ b/plugins/cost-insights/src/types/Alert.ts @@ -34,8 +34,8 @@ import { Maybe } from './Maybe'; */ export type Alert = { - title: string; - subtitle: string; + title: string | JSX.Element; + subtitle: string | JSX.Element; element?: JSX.Element; status?: AlertStatus; url?: string; diff --git a/plugins/explore/package.json b/plugins/explore/package.json index d96c2f4d18..63fe3448a6 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.9", - "@backstage/core": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/plugin-explore-react": "^0.0.5", "@backstage/theme": "^0.2.7", @@ -45,9 +45,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/fossa/package.json b/plugins/fossa/package.json index 51a6c5cf55..159739cc58 100644 --- a/plugins/fossa/package.json +++ b/plugins/fossa/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.9", - "@backstage/core": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/errors": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", @@ -47,9 +47,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/gcp-projects/package.json b/plugins/gcp-projects/package.json index 6b0c625501..919b5bba15 100644 --- a/plugins/gcp-projects/package.json +++ b/plugins/gcp-projects/package.json @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -41,9 +41,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/git-release-manager/CHANGELOG.md b/plugins/git-release-manager/CHANGELOG.md new file mode 100644 index 0000000000..65018d7925 --- /dev/null +++ b/plugins/git-release-manager/CHANGELOG.md @@ -0,0 +1,11 @@ +# @backstage/plugin-git-release-manager + +## 0.1.2 + +### Patch Changes + +- f915a342d: [ImgBot] Optimize images +- Updated dependencies [65e6c4541] +- Updated dependencies [5da6a561d] + - @backstage/core@0.7.10 + - @backstage/integration@0.5.3 diff --git a/plugins/git-release-manager/package.json b/plugins/git-release-manager/package.json index cccb452b34..994593a808 100644 --- a/plugins/git-release-manager/package.json +++ b/plugins/git-release-manager/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-git-release-manager", - "version": "0.1.1", + "version": "0.1.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,8 +20,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.9", - "@backstage/integration": "^0.5.1", + "@backstage/core": "^0.7.10", + "@backstage/integration": "^0.5.3", "@backstage/theme": "^0.2.7", "recharts": "^1.8.5", "@material-ui/core": "^4.11.0", @@ -36,9 +36,9 @@ "react": "^16.13.1" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@types/recharts": "^1.8.15", "@testing-library/react-hooks": "^3.4.2", diff --git a/plugins/git-release-manager/src/features/Info/flow.png b/plugins/git-release-manager/src/features/Info/flow.png index e70df0d7fa..04fe5bbe34 100644 Binary files a/plugins/git-release-manager/src/features/Info/flow.png and b/plugins/git-release-manager/src/features/Info/flow.png differ diff --git a/plugins/git-release-manager/src/features/Stats/Info/InDepth/InDepth.tsx b/plugins/git-release-manager/src/features/Stats/Info/InDepth/InDepth.tsx index 9fbd2bc925..ba3ee536f6 100644 --- a/plugins/git-release-manager/src/features/Stats/Info/InDepth/InDepth.tsx +++ b/plugins/git-release-manager/src/features/Stats/Info/InDepth/InDepth.tsx @@ -76,7 +76,7 @@ export function InDepth() { - Longest release:{' '} + Lengthiest release:{' '} diff --git a/plugins/github-actions/CHANGELOG.md b/plugins/github-actions/CHANGELOG.md index f435ff31bc..b1cb9d3ad2 100644 --- a/plugins/github-actions/CHANGELOG.md +++ b/plugins/github-actions/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-github-actions +## 0.4.7 + +### Patch Changes + +- 65e6c4541: Remove circular dependencies +- Updated dependencies [f7f7783a3] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5da6a561d] + - @backstage/catalog-model@0.7.10 + - @backstage/core@0.7.10 + - @backstage/integration@0.5.3 + ## 0.4.6 ### Patch Changes diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index 841bb3ebc5..f23010b862 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-github-actions", - "version": "0.4.6", + "version": "0.4.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,10 +32,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.9", + "@backstage/catalog-model": "^0.7.10", "@backstage/plugin-catalog-react": "^0.1.6", - "@backstage/core": "^0.7.9", - "@backstage/integration": "^0.5.2", + "@backstage/core": "^0.7.10", + "@backstage/integration": "^0.5.3", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -50,9 +50,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/github-actions/src/components/Cards/Cards.tsx b/plugins/github-actions/src/components/Cards/Cards.tsx index 4ce667eb93..c2eaaf885e 100644 --- a/plugins/github-actions/src/components/Cards/Cards.tsx +++ b/plugins/github-actions/src/components/Cards/Cards.tsx @@ -34,8 +34,8 @@ import { import ExternalLinkIcon from '@material-ui/icons/Launch'; import React, { useEffect } from 'react'; import { GITHUB_ACTIONS_ANNOTATION } from '../useProjectName'; -import { useWorkflowRuns } from '../useWorkflowRuns'; -import { WorkflowRun, WorkflowRunsTable } from '../WorkflowRunsTable'; +import { useWorkflowRuns, WorkflowRun } from '../useWorkflowRuns'; +import { WorkflowRunsTable } from '../WorkflowRunsTable'; import { WorkflowRunStatus } from '../WorkflowRunStatus'; const useStyles = makeStyles({ diff --git a/plugins/github-actions/src/components/Router.tsx b/plugins/github-actions/src/components/Router.tsx index 834fcae43c..52cd66375f 100644 --- a/plugins/github-actions/src/components/Router.tsx +++ b/plugins/github-actions/src/components/Router.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { Entity } from '@backstage/catalog-model'; import { useEntity } from '@backstage/plugin-catalog-react'; import { Routes, Route } from 'react-router'; -import { rootRouteRef, buildRouteRef } from '../plugin'; +import { rootRouteRef, buildRouteRef } from '../routes'; import { WorkflowRunDetails } from './WorkflowRunDetails'; import { WorkflowRunsTable } from './WorkflowRunsTable'; import { GITHUB_ACTIONS_ANNOTATION } from './useProjectName'; diff --git a/plugins/github-actions/src/components/WorkflowRunDetails/useWorkflowRunsDetails.ts b/plugins/github-actions/src/components/WorkflowRunDetails/useWorkflowRunsDetails.ts index 3e2a5c4382..124b8e8a87 100644 --- a/plugins/github-actions/src/components/WorkflowRunDetails/useWorkflowRunsDetails.ts +++ b/plugins/github-actions/src/components/WorkflowRunDetails/useWorkflowRunsDetails.ts @@ -16,7 +16,7 @@ import { useApi, useRouteRefParams } from '@backstage/core'; import { useAsync } from 'react-use'; import { githubActionsApiRef } from '../../api'; -import { buildRouteRef } from '../../plugin'; +import { buildRouteRef } from '../../routes'; export const useWorkflowRunsDetails = ({ hostname, diff --git a/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx index f04ec90323..bec2503562 100644 --- a/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx +++ b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx @@ -32,32 +32,14 @@ import { configApiRef, useApi, } from '@backstage/core'; -import { useWorkflowRuns } from '../useWorkflowRuns'; +import { useWorkflowRuns, WorkflowRun } from '../useWorkflowRuns'; import { WorkflowRunStatus } from '../WorkflowRunStatus'; import SyncIcon from '@material-ui/icons/Sync'; -import { buildRouteRef } from '../../plugin'; +import { buildRouteRef } from '../../routes'; import { useProjectName } from '../useProjectName'; import { Entity } from '@backstage/catalog-model'; import { readGitHubIntegrationConfigs } from '@backstage/integration'; -export type WorkflowRun = { - workflowName: string; - id: string; - message: string; - url?: string; - githubUrl?: string; - source: { - branchName: string; - commit: { - hash: string; - url?: string; - }; - }; - status: string; - conclusion: string; - onReRunClick: () => void; -}; - const generatedColumns: TableColumn[] = [ { title: 'ID', diff --git a/plugins/github-actions/src/components/WorkflowRunsTable/index.ts b/plugins/github-actions/src/components/WorkflowRunsTable/index.ts index 4e21cc67c2..e190aa55bc 100644 --- a/plugins/github-actions/src/components/WorkflowRunsTable/index.ts +++ b/plugins/github-actions/src/components/WorkflowRunsTable/index.ts @@ -14,4 +14,3 @@ * limitations under the License. */ export { WorkflowRunsTable, WorkflowRunsTableView } from './WorkflowRunsTable'; -export type { WorkflowRun } from './WorkflowRunsTable'; diff --git a/plugins/github-actions/src/components/useWorkflowRuns.ts b/plugins/github-actions/src/components/useWorkflowRuns.ts index 0690632a7b..f62b03f506 100644 --- a/plugins/github-actions/src/components/useWorkflowRuns.ts +++ b/plugins/github-actions/src/components/useWorkflowRuns.ts @@ -15,10 +15,27 @@ */ import { useState } from 'react'; import { useAsyncRetry } from 'react-use'; -import { WorkflowRun } from './WorkflowRunsTable/WorkflowRunsTable'; import { githubActionsApiRef } from '../api/GithubActionsApi'; import { useApi, errorApiRef } from '@backstage/core'; +export type WorkflowRun = { + workflowName: string; + id: string; + message: string; + url?: string; + githubUrl?: string; + source: { + branchName: string; + commit: { + hash: string; + url?: string; + }; + }; + status: string; + conclusion: string; + onReRunClick: () => void; +}; + export function useWorkflowRuns({ hostname, owner, diff --git a/plugins/github-actions/src/plugin.ts b/plugins/github-actions/src/plugin.ts index f66e96a981..a34428e18c 100644 --- a/plugins/github-actions/src/plugin.ts +++ b/plugins/github-actions/src/plugin.ts @@ -17,25 +17,13 @@ import { configApiRef, createPlugin, - createRouteRef, createApiFactory, githubAuthApiRef, createRoutableExtension, createComponentExtension, } from '@backstage/core'; import { githubActionsApiRef, GithubActionsClient } from './api'; - -// TODO(freben): This is just a demo route for now -export const rootRouteRef = createRouteRef({ - path: '', - title: 'GitHub Actions', -}); - -export const buildRouteRef = createRouteRef({ - path: ':id', - params: ['id'], - title: 'GitHub Actions Workflow Run', -}); +import { rootRouteRef } from './routes'; export const githubActionsPlugin = createPlugin({ id: 'github-actions', diff --git a/plugins/github-actions/src/routes.ts b/plugins/github-actions/src/routes.ts new file mode 100644 index 0000000000..e576379d6c --- /dev/null +++ b/plugins/github-actions/src/routes.ts @@ -0,0 +1,29 @@ +/* + * 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 { createRouteRef } from '@backstage/core'; + +// TODO(freben): This is just a demo route for now +export const rootRouteRef = createRouteRef({ + path: '', + title: 'GitHub Actions', +}); + +export const buildRouteRef = createRouteRef({ + path: ':id', + params: ['id'], + title: 'GitHub Actions Workflow Run', +}); diff --git a/plugins/github-deployments/CHANGELOG.md b/plugins/github-deployments/CHANGELOG.md index 3212382684..ab766a789c 100644 --- a/plugins/github-deployments/CHANGELOG.md +++ b/plugins/github-deployments/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-github-deployments +## 0.1.6 + +### Patch Changes + +- fa69982fb: Support GHE by properly parsing enterprise instance hosts +- Updated dependencies [f7f7783a3] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5da6a561d] + - @backstage/catalog-model@0.7.10 + - @backstage/core@0.7.10 + - @backstage/integration@0.5.3 + ## 0.1.5 ### Patch Changes diff --git a/plugins/github-deployments/package.json b/plugins/github-deployments/package.json index 1058a30fed..c245a4e3ba 100644 --- a/plugins/github-deployments/package.json +++ b/plugins/github-deployments/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-github-deployments", - "version": "0.1.5", + "version": "0.1.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,10 +20,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.9", - "@backstage/core": "^0.7.9", + "@backstage/catalog-model": "^0.7.10", + "@backstage/core": "^0.7.10", "@backstage/errors": "^0.1.1", - "@backstage/integration": "^0.5.1", + "@backstage/integration": "^0.5.3", "@backstage/integration-react": "^0.1.2", "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", @@ -37,9 +37,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/github-deployments/src/api/index.ts b/plugins/github-deployments/src/api/index.ts index 23a381f771..d7e7e2fca6 100644 --- a/plugins/github-deployments/src/api/index.ts +++ b/plugins/github-deployments/src/api/index.ts @@ -28,21 +28,25 @@ const getBaseUrl = ( } const location = parseLocationReference(host); - if (location.type !== 'github') { + if (location.type !== 'github' && location.type !== 'url') { return 'https://api.github.com'; } - const config = scmIntegrationsApi.github.byHost(location.target); + const config = scmIntegrationsApi.github.byUrl(location.target); if (!config) { throw new InputError( - `No matching GitHub integration configuration for host ${host}, please check your integrations config`, + `No matching GitHub integration configuration for host ${ + new URL(location.target).hostname + }, please check your integrations config`, ); } if (!config.config.apiBaseUrl) { throw new InputError( - `No apiBaseUrl available for host ${host}, please check your integrations config`, + `No apiBaseUrl available for host ${ + new URL(location.target).hostname + }, please check your integrations config`, ); } diff --git a/plugins/github-deployments/src/components/GithubDeploymentsCard.test.tsx b/plugins/github-deployments/src/components/GithubDeploymentsCard.test.tsx index bec60c47bc..68f5b5df43 100644 --- a/plugins/github-deployments/src/components/GithubDeploymentsCard.test.tsx +++ b/plugins/github-deployments/src/components/GithubDeploymentsCard.test.tsx @@ -250,7 +250,8 @@ describe('github-deployments', () => { entity = entityStub; entity.entity.metadata.annotations = { 'github.com/project-slug': 'org/repo', - 'backstage.io/source-location': 'github:my-github-1.com', + 'backstage.io/source-location': + 'github:https://my-github-1.com/org/repo/tree/master/', }; }); @@ -271,7 +272,8 @@ describe('github-deployments', () => { entity = entityStub; entity.entity.metadata.annotations = { 'github.com/project-slug': 'org/repo', - 'backstage.io/managed-by-location': 'github:my-github-2.com', + 'backstage.io/managed-by-location': + 'github:https://my-github-2.com/org/repo/blob/master/catalog-info.yaml', }; }); @@ -292,7 +294,8 @@ describe('github-deployments', () => { entity = entityStub; entity.entity.metadata.annotations = { 'github.com/project-slug': 'org/repo', - 'backstage.io/managed-by-location': 'github:my-github-3.com', + 'backstage.io/managed-by-location': + 'github:https://my-github-3.com/org/repo/blob/master/catalog-info.yaml', }; }); @@ -305,7 +308,7 @@ describe('github-deployments', () => { expect( await rendered.findByText( - 'Warning: No apiBaseUrl available for host github:my-github-3.com, please check your integrations config', + 'Warning: No apiBaseUrl available for host my-github-3.com, please check your integrations config', ), ).toBeInTheDocument(); }); @@ -317,7 +320,7 @@ describe('github-deployments', () => { entity.entity.metadata.annotations = { 'github.com/project-slug': 'org/repo', 'backstage.io/managed-by-location': - 'github:my-github-unknown.com/org/repo', + 'github:https://my-github-unknown.com/org/repo/blob/master/catalog-info.yaml', }; }); @@ -330,7 +333,7 @@ describe('github-deployments', () => { expect( await rendered.findByText( - 'Warning: No matching GitHub integration configuration for host github:my-github-unknown.com/org/repo, please check your integrations config', + 'Warning: No matching GitHub integration configuration for host my-github-unknown.com, please check your integrations config', ), ).toBeInTheDocument(); }); @@ -342,15 +345,15 @@ describe('github-deployments', () => { entity.entity.metadata.annotations = { 'github.com/project-slug': 'org/repo', 'backstage.io/source-location': - 'url:my-favourite-url-location/org/repo', + 'url:https://my-github-1.com/org/repo/tree/master/', 'backstage.io/managed-by-location': - 'url:my-favourite-url-location/org/repo', + 'url:https://my-github-1.com/org/repo/blob/master/catalog-info.yaml', }; }); - it('displays fetched data using default github api', async () => { + it('should fetch data using custom api', async () => { worker.use( - GRAPHQL_GITHUB_API.query('deployments', (_, res, ctx) => + GRAPHQL_CUSTOM_API_1.query('deployments', (_, res, ctx) => res(ctx.data(responseStub)), ), ); diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index dbb84e4994..f07caf0fbe 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -42,9 +42,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index 66c8790b8f..36a850b901 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -43,9 +43,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/ilert/.eslintrc.js b/plugins/ilert/.eslintrc.js new file mode 100644 index 0000000000..4d51616e98 --- /dev/null +++ b/plugins/ilert/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], + rules: { + quotes: ['error', 'single'], + }, +}; diff --git a/plugins/ilert/CHANGELOG.md b/plugins/ilert/CHANGELOG.md new file mode 100644 index 0000000000..678ede204a --- /dev/null +++ b/plugins/ilert/CHANGELOG.md @@ -0,0 +1,13 @@ +# @backstage/plugin-ilert + +## 0.1.2 + +### Patch Changes + +- f915a342d: [ImgBot] Optimize images +- Updated dependencies [f7f7783a3] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5da6a561d] + - @backstage/catalog-model@0.7.10 + - @backstage/core@0.7.10 diff --git a/plugins/ilert/README.md b/plugins/ilert/README.md new file mode 100644 index 0000000000..da019d6bb6 --- /dev/null +++ b/plugins/ilert/README.md @@ -0,0 +1,128 @@ +# @backstage/plugin-ilert + +## Introduction + +[iLert](https://www.ilert.com) is a platform for alerting, on-call management and uptime monitoring. It helps teams to reduce response times to critical incidents by extending monitoring tools with reliable alerting, automatic escalations, on-call schedules and other features to support the incident response process, such as informing stakeholders or creating tickets in external incident management tools. + +## Overview + +This plugin gives an overview about ongoing iLert incidents, on-call and uptime monitor status. +See who is on-call, which incidents are active and trigger incidents directly from backstage for the configured alert sources. + +In detail this plugin provides: + +- Information details about the person on-call (all escalation levels of the current time) +- A way to override the current on-call person +- A list of active incidents +- A way to trigger a new incident +- A way to reassign/acknowledge/resolve an incident +- A way to trigger an incident action +- A way to trigger an immediate maintenance +- A way to disable/enable an alert source +- A list of uptime monitors + +## Setup instructions + +Install the plugin: + +```bash +# From the Backstage repository root +cd packages/app +yarn add @backstage/plugin-ilert +``` + +Add it to the `EntityPage.tsx`: + +```ts +import { + isPluginApplicableToEntity as isILertAvailable, + EntityILertCard, +} from '@backstage/plugin-ilert'; + +// ... + + + + + + +; +// ... +``` + +> To force an iLert card for each entity just add the `` component. An instruction card will appear if no integration key is set. + +## Add iLert explorer to the app sidebar + +Modify your app routes in [`App.tsx`](https://github.com/backstage/backstage/blob/master/packages/app/src/App.tsx) to include the Router component exported by the plugin - for example: + +```tsx +import { ILertPage } from '@backstage/plugin-ilert'; + + // ... + } /> + // ... +; +``` + +Modify your sidebar in [`Root.tsx`](https://github.com/backstage/backstage/blob/master/packages/app/src/components/Root/Root.tsx) to include the icon component exported by the plugin - for example: + +```tsx +import { ILertIcon } from '@backstage/plugin-ilert'; + + // ... + + // ... +; +``` + +## Client configuration + +If you want to override the default URL for api calls and detail pages, you can add it to `app-config.yaml`. + +In `app-config.yaml`: + +```yaml +ilert: + baseUrl: https://my-org.ilert.com/ +``` + +## Providing the Authorization Header + +In order to make the API calls, you need to provide a new proxy config which will redirect to the [iLert API](https://api.ilert.com/api-docs/) endpoint. It needs an [Authorization Header](https://api.ilert.com/api-docs/#section/Authentication). + +Add the proxy configuration in `app-config.yaml` + +```yaml +proxy: + ... + '/ilert': + target: https://api.ilert.com + allowedMethods: ['GET', 'POST', 'PUT'] + allowedHeaders: ['Authorization'] + headers: + Authorization: ${ILERT_AUTH_HEADER} +``` + +Then start the backend, passing the authorization header (bearer token or basic auth) as environment variable: + +```bash +$ ILERT_AUTH_HEADER='' yarn start +``` + +## Integration Key + +The information displayed for each entity is based on the alert source integration key. + +### 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 +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: example + annotations: + ilert.com/integration-key: [INTEGRATION_KEY] +``` diff --git a/plugins/ilert/config.d.ts b/plugins/ilert/config.d.ts new file mode 100644 index 0000000000..6bf3f569ab --- /dev/null +++ b/plugins/ilert/config.d.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2021 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 interface Config { + ilert?: { + /** + * Domain used by users to access iLert web UI. + * Example: https://my-app.ilert.com/ + * @visibility frontend + */ + baseUrl: string; + + /** + * Path to use for requests via the proxy, defaults to /ilert/api + * @visibility frontend + */ + proxyPath?: string; + }; +} diff --git a/plugins/ilert/dev/index.tsx b/plugins/ilert/dev/index.tsx new file mode 100644 index 0000000000..737666e650 --- /dev/null +++ b/plugins/ilert/dev/index.tsx @@ -0,0 +1,27 @@ +/* + * Copyright 2021 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 { createDevApp } from '@backstage/dev-utils'; +import { ilertPlugin } from '../src/plugin'; +import { ILertPage } from '../src'; + +createDevApp() + .registerPlugin(ilertPlugin) + .addPage({ + element: , + title: 'Root Page', + }) + .render(); diff --git a/plugins/ilert/package.json b/plugins/ilert/package.json new file mode 100644 index 0000000000..b3bc3c6f87 --- /dev/null +++ b/plugins/ilert/package.json @@ -0,0 +1,56 @@ +{ + "name": "@backstage/plugin-ilert", + "version": "0.1.2", + "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.7.10", + "@backstage/core": "^0.7.10", + "@backstage/errors": "^0.1.1", + "@backstage/plugin-catalog-react": "^0.1.4", + "@backstage/theme": "^0.2.7", + "@date-io/luxon": "1.x", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "@material-ui/pickers": "^3.3.10", + "humanize-duration": "^3.26.0", + "luxon": "^1.26.0", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-use": "^17.2.4" + }, + "devDependencies": { + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^13.1.8", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" + }, + "files": [ + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" +} diff --git a/plugins/ilert/src/api/client.ts b/plugins/ilert/src/api/client.ts new file mode 100644 index 0000000000..ed1e671bc0 --- /dev/null +++ b/plugins/ilert/src/api/client.ts @@ -0,0 +1,535 @@ +/* + * Copyright 2021 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 { ConfigApi, createApiRef, DiscoveryApi } from '@backstage/core'; +import { AuthenticationError, ResponseError } from '@backstage/errors'; +import { + AlertSource, + EscalationPolicy, + Incident, + IncidentAction, + IncidentResponder, + OnCall, + Schedule, + UptimeMonitor, + User, +} from '../types'; +import { + ILertApi, + GetIncidentsOpts, + GetIncidentsCountOpts, + Options, + EventRequest, +} from './types'; +import { DateTime as dt } from 'luxon'; + +export const ilertApiRef = createApiRef({ + id: 'plugin.ilert.service', + description: 'Used to make requests towards iLert API', +}); + +const DEFAULT_PROXY_PATH = '/ilert'; +const JSON_HEADERS = { + 'Content-Type': 'application/json', + Accept: 'application/json', +}; + +export class ILertClient implements ILertApi { + private readonly discoveryApi: DiscoveryApi; + private readonly proxyPath: string; + private readonly baseUrl: string; + + static fromConfig(configApi: ConfigApi, discoveryApi: DiscoveryApi) { + const baseUrl: string = + configApi.getOptionalString('ilert.baseUrl') ?? 'https://app.ilert.com'; + + return new ILertClient({ + discoveryApi: discoveryApi, + baseUrl, + proxyPath: + configApi.getOptionalString('ilert.proxyPath') ?? DEFAULT_PROXY_PATH, + }); + } + + constructor(opts: Options) { + this.discoveryApi = opts.discoveryApi; + this.baseUrl = opts.baseUrl; + this.proxyPath = opts.proxyPath; + } + + private async fetch(input: string, init?: RequestInit): Promise { + const apiUrl = await this.apiUrl(); + + const response = await fetch(`${apiUrl}${input}`, init); + if (response.status === 401) { + throw new AuthenticationError( + 'This request requires HTTP authentication.', + ); + } + if (!response.ok || response.status >= 400) { + throw await ResponseError.fromResponse(response); + } + + return await response.json(); + } + + async fetchIncidents(opts?: GetIncidentsOpts): Promise { + const init = { + headers: JSON_HEADERS, + }; + const query = new URLSearchParams(); + if (opts?.maxResults !== undefined) { + query.append('max-results', String(opts.maxResults)); + } + if (opts?.startIndex !== undefined) { + query.append('start-index', String(opts.startIndex)); + } + if (opts?.alertSources !== undefined && Array.isArray(opts.alertSources)) { + opts.alertSources.forEach((a: number) => { + if (a) { + query.append('alert-source', String(a)); + } + }); + } + + if (opts?.states !== undefined && Array.isArray(opts.states)) { + opts.states.forEach(state => { + query.append('state', state); + }); + } + const response = await this.fetch( + `/api/v1/incidents?${query.toString()}`, + init, + ); + + return response; + } + + async fetchIncidentsCount(opts?: GetIncidentsCountOpts): Promise { + const init = { + headers: JSON_HEADERS, + }; + const query = new URLSearchParams(); + if (opts && opts.states && Array.isArray(opts.states)) { + opts.states.forEach(state => { + query.append('state', state); + }); + } + const response = await this.fetch( + `/api/v1/incidents/count?${query.toString()}`, + init, + ); + + return response && response.count ? response.count : 0; + } + + async fetchIncident(id: number): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch( + `/api/v1/incidents/${encodeURIComponent(id)}`, + init, + ); + + return response; + } + + async fetchIncidentResponders( + incident: Incident, + ): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch( + `/api/v1/incidents/${encodeURIComponent(incident.id)}/responders`, + init, + ); + + return response; + } + + async fetchIncidentActions(incident: Incident): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch( + `/api/v1/incidents/${encodeURIComponent(incident.id)}/actions`, + init, + ); + + return response; + } + + async acceptIncident( + incident: Incident, + userName: string, + ): Promise { + const init = { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ + apiKey: incident.alertSource?.integrationKey || '', + incidentKey: incident.incidentKey, + summary: `from ${userName} via Backstage plugin`, + eventType: 'ACCEPT', + }), + }; + + await this.fetch('/api/v1/events', init); + return this.fetchIncident(incident.id); + } + + async resolveIncident( + incident: Incident, + userName: string, + ): Promise { + const init = { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ + apiKey: incident.alertSource?.integrationKey || '', + incidentKey: incident.incidentKey, + summary: `from ${userName} via Backstage plugin`, + eventType: 'RESOLVE', + }), + }; + + await this.fetch('/api/v1/events', init); + return this.fetchIncident(incident.id); + } + + async assignIncident( + incident: Incident, + responder: IncidentResponder, + ): Promise { + const init = { + method: 'PUT', + headers: JSON_HEADERS, + }; + + const query = new URLSearchParams(); + switch (responder.group) { + case 'ESCALATION_POLICY': + query.append('policy-id', String(responder.id)); + break; + case 'ON_CALL_SCHEDULE': + query.append('schedule-id', String(responder.id)); + break; + default: + query.append('user-id', String(responder.id)); + break; + } + + const response = await this.fetch( + `/api/v1/incidents/${encodeURIComponent( + incident.id, + )}/assign?${query.toString()}`, + init, + ); + + return response; + } + + async triggerIncidentAction( + incident: Incident, + action: IncidentAction, + ): Promise { + const init = { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ + webhookId: action.webhookId, + extensionId: action.extensionId, + type: action.type, + name: action.name, + }), + }; + + await this.fetch( + `/api/v1/incidents/${encodeURIComponent(incident.id)}/actions`, + init, + ); + } + + async createIncident(eventRequest: EventRequest): Promise { + const init = { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ + apiKey: eventRequest.integrationKey, + summary: eventRequest.summary, + details: eventRequest.details, + eventType: 'ALERT', + links: [ + { + href: eventRequest.source, + text: 'Backstage Url', + }, + ], + customDetails: { + userName: eventRequest.userName, + }, + }), + }; + + const response = await this.fetch('/api/v1/events', init); + return response; + } + + async fetchUptimeMonitors(): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch('/api/v1/uptime-monitors', init); + + return response; + } + + async fetchUptimeMonitor(id: number): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response: UptimeMonitor = await this.fetch( + `/api/v1/uptime-monitors/${encodeURIComponent(id)}`, + init, + ); + + return response; + } + + async pauseUptimeMonitor( + uptimeMonitor: UptimeMonitor, + ): Promise { + const init = { + method: 'PUT', + headers: JSON_HEADERS, + body: JSON.stringify({ ...uptimeMonitor, paused: true }), + }; + + const response = await this.fetch( + `/api/v1/uptime-monitors/${encodeURIComponent(uptimeMonitor.id)}`, + init, + ); + + return response; + } + + async resumeUptimeMonitor( + uptimeMonitor: UptimeMonitor, + ): Promise { + const init = { + method: 'PUT', + headers: JSON_HEADERS, + body: JSON.stringify({ ...uptimeMonitor, paused: false }), + }; + + const response = await this.fetch( + `/api/v1/uptime-monitors/${encodeURIComponent(uptimeMonitor.id)}`, + init, + ); + + return response; + } + + async fetchAlertSources(): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch('/api/v1/alert-sources', init); + + return response; + } + + async fetchAlertSource( + idOrIntegrationKey: number | string, + ): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch( + `/api/v1/alert-sources/${encodeURIComponent(idOrIntegrationKey)}`, + init, + ); + + return response; + } + + async fetchAlertSourceOnCalls(alertSource: AlertSource): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch( + `/api/v1/on-calls?policies=${encodeURIComponent( + alertSource.escalationPolicy.id, + )}&expand=user&expand=escalationPolicy&timezone=${dt.local().zoneName}`, + init, + ); + + return response; + } + + async enableAlertSource(alertSource: AlertSource): Promise { + const init = { + method: 'PUT', + headers: JSON_HEADERS, + body: JSON.stringify({ ...alertSource, active: true }), + }; + + const response = await this.fetch( + `/api/v1/alert-sources/${encodeURIComponent(alertSource.id)}`, + init, + ); + + return response; + } + + async disableAlertSource(alertSource: AlertSource): Promise { + const init = { + method: 'PUT', + headers: JSON_HEADERS, + body: JSON.stringify({ ...alertSource, active: false }), + }; + + const response = await this.fetch( + `/api/v1/alert-sources/${encodeURIComponent(alertSource.id)}`, + init, + ); + + return response; + } + + async addImmediateMaintenance( + alertSourceId: number, + minutes: number, + ): Promise { + const init = { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ + start: dt.utc().toISO(), + end: dt.utc().plus({ minutes }).toISO(), + description: `Immediate maintenance window for ${minutes} minutes. Backstage — iLert plugin.`, + createdBy: 'Backstage', + timezone: dt.local().zoneName, + alertSources: [{ id: alertSourceId }], + }), + }; + + const response = await this.fetch('/api/v1/maintenance-windows', init); + + return response; + } + + async fetchOnCallSchedules(): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch('/api/v1/schedules', init); + + return response; + } + + async fetchUsers(): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch('/api/v1/users', init); + + return response; + } + + async overrideShift( + scheduleId: number, + userId: number, + start: string, + end: string, + ): Promise { + const init = { + method: 'PUT', + headers: JSON_HEADERS, + body: JSON.stringify({ user: { id: userId }, start, end }), + }; + + const response = await this.fetch( + `/api/v1/schedules/${encodeURIComponent(scheduleId)}/overrides`, + init, + ); + + return response; + } + + getIncidentDetailsURL(incident: Incident): string { + return `${this.baseUrl}/incident/view.jsf?id=${encodeURIComponent( + incident.id, + )}`; + } + + getAlertSourceDetailsURL(alertSource: AlertSource | null): string { + if (!alertSource) { + return ''; + } + return `${this.baseUrl}/source/view.jsf?id=${encodeURIComponent( + alertSource.id, + )}`; + } + + getEscalationPolicyDetailsURL(escalationPolicy: EscalationPolicy): string { + return `${this.baseUrl}/policy/view.jsf?id=${encodeURIComponent( + escalationPolicy.id, + )}`; + } + + getUptimeMonitorDetailsURL(uptimeMonitor: UptimeMonitor): string { + return `${this.baseUrl}/uptime/view.jsf?id=${encodeURIComponent( + uptimeMonitor.id, + )}`; + } + + getScheduleDetailsURL(schedule: Schedule): string { + return `${this.baseUrl}/schedule/view.jsf?id=${encodeURIComponent( + schedule.id, + )}`; + } + + getUserPhoneNumber(user: User | null) { + return user?.mobile?.number || user?.landline?.number || ''; + } + + getUserInitials(user: User | null) { + if (!user) { + return ''; + } + if (!user.firstName && !user.lastName) { + return user.username; + } + return `${user.firstName} ${user.lastName} (${user.username})`; + } + + private async apiUrl() { + const proxyUrl = await this.discoveryApi.getBaseUrl('proxy'); + return proxyUrl + this.proxyPath; + } +} diff --git a/plugins/ilert/src/api/index.ts b/plugins/ilert/src/api/index.ts new file mode 100644 index 0000000000..65c3571bff --- /dev/null +++ b/plugins/ilert/src/api/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2021 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 { ILertClient, ilertApiRef } from './client'; +export type { + ILertApi, + GetIncidentsCountOpts, + GetIncidentsOpts, + TableState, +} from './types'; diff --git a/plugins/ilert/src/api/types.ts b/plugins/ilert/src/api/types.ts new file mode 100644 index 0000000000..ab9cca3dc6 --- /dev/null +++ b/plugins/ilert/src/api/types.ts @@ -0,0 +1,120 @@ +/* + * Copyright 2021 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 { DiscoveryApi } from '@backstage/core'; +import { + AlertSource, + Incident, + User, + IncidentStatus, + UptimeMonitor, + EscalationPolicy, + Schedule, + IncidentResponder, + IncidentAction, + OnCall, +} from '../types'; + +export type TableState = { + page: number; + pageSize: number; +}; + +export type GetIncidentsOpts = { + maxResults?: number; + startIndex?: number; + states?: IncidentStatus[]; + alertSources?: number[]; +}; + +export type GetIncidentsCountOpts = { + states?: IncidentStatus[]; +}; + +export type EventRequest = { + integrationKey: string; + summary: string; + details: string; + userName: string; + source: string; +}; + +export interface ILertApi { + fetchIncidents(opts?: GetIncidentsOpts): Promise; + fetchIncidentsCount(opts?: GetIncidentsCountOpts): Promise; + fetchIncident(id: number): Promise; + fetchIncidentResponders(incident: Incident): Promise; + fetchIncidentActions(incident: Incident): Promise; + acceptIncident(incident: Incident, userName: string): Promise; + resolveIncident(incident: Incident, userName: string): Promise; + assignIncident( + incident: Incident, + responder: IncidentResponder, + ): Promise; + createIncident(eventRequest: EventRequest): Promise; + triggerIncidentAction( + incident: Incident, + action: IncidentAction, + ): Promise; + + fetchUptimeMonitors(): Promise; + pauseUptimeMonitor(uptimeMonitor: UptimeMonitor): Promise; + resumeUptimeMonitor(uptimeMonitor: UptimeMonitor): Promise; + fetchUptimeMonitor(id: number): Promise; + + fetchAlertSources(): Promise; + fetchAlertSource(idOrIntegrationKey: number | string): Promise; + fetchAlertSourceOnCalls(alertSource: AlertSource): Promise; + enableAlertSource(alertSource: AlertSource): Promise; + disableAlertSource(alertSource: AlertSource): Promise; + + addImmediateMaintenance( + alertSourceId: number, + minutes: number, + ): Promise; + + fetchOnCallSchedules(): Promise; + fetchUsers(): Promise; + + overrideShift( + scheduleId: number, + userId: number, + start: string, + end: string, + ): Promise; + + getIncidentDetailsURL(incident: Incident): string; + getAlertSourceDetailsURL(alertSource: AlertSource | null): string; + getEscalationPolicyDetailsURL(escalationPolicy: EscalationPolicy): string; + getUptimeMonitorDetailsURL(uptimeMonitor: UptimeMonitor): string; + getScheduleDetailsURL(schedule: Schedule): string; + getUserPhoneNumber(user: User | null): string; + getUserInitials(user: User | null): string; +} + +export type Options = { + discoveryApi: DiscoveryApi; + + /** + * URL used by users to access iLert web UI. + * Example: https://my-org.ilert.com/ + */ + baseUrl: string; + + /** + * Path to use for requests via the proxy, defaults to /ilert/api + */ + proxyPath: string; +}; diff --git a/plugins/ilert/src/assets/ilert.icon.svg b/plugins/ilert/src/assets/ilert.icon.svg new file mode 100644 index 0000000000..5109286355 --- /dev/null +++ b/plugins/ilert/src/assets/ilert.icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/ilert/src/components/AlertSource/AlertSourceLink.tsx b/plugins/ilert/src/components/AlertSource/AlertSourceLink.tsx new file mode 100644 index 0000000000..f7f6dbb78c --- /dev/null +++ b/plugins/ilert/src/components/AlertSource/AlertSourceLink.tsx @@ -0,0 +1,70 @@ +/* + * Copyright 2021 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 { useApi, Link } from '@backstage/core'; +import Grid from '@material-ui/core/Grid'; +import { AlertSource } from '../../types'; +import { ilertApiRef } from '../../api'; +import { makeStyles } from '@material-ui/core/styles'; +import useMediaQuery from '@material-ui/core/useMediaQuery'; + +const useStyles = makeStyles({ + root: { + display: 'flex', + maxWidth: '100%', + }, + image: { + height: 22, + paddingRight: 4, + }, + link: { + lineHeight: '22px', + }, +}); + +export const AlertSourceLink = ({ + alertSource, +}: { + alertSource: AlertSource | null; +}) => { + const ilertApi = useApi(ilertApiRef); + const classes = useStyles(); + const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); + + if (!alertSource) { + return null; + } + + return ( + + + {alertSource.name} + + + + {alertSource.name} + + + + ); +}; diff --git a/plugins/ilert/src/components/Errors/MissingAuthorizationHeaderError.tsx b/plugins/ilert/src/components/Errors/MissingAuthorizationHeaderError.tsx new file mode 100644 index 0000000000..6b3d463d2a --- /dev/null +++ b/plugins/ilert/src/components/Errors/MissingAuthorizationHeaderError.tsx @@ -0,0 +1,35 @@ +/* + * Copyright 2021 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 MissingAuthorizationHeaderError = () => ( + + Read More + + } + /> +); diff --git a/plugins/ilert/src/components/Errors/index.ts b/plugins/ilert/src/components/Errors/index.ts new file mode 100644 index 0000000000..f20de48b90 --- /dev/null +++ b/plugins/ilert/src/components/Errors/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 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 { MissingAuthorizationHeaderError } from './MissingAuthorizationHeaderError'; diff --git a/plugins/ilert/src/components/EscalationPolicy/EscalationPolicyLink.tsx b/plugins/ilert/src/components/EscalationPolicy/EscalationPolicyLink.tsx new file mode 100644 index 0000000000..9e7ff102f8 --- /dev/null +++ b/plugins/ilert/src/components/EscalationPolicy/EscalationPolicyLink.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2021 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 { useApi, Link } from '@backstage/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { EscalationPolicy } from '../../types'; +import { ilertApiRef } from '../../api'; + +const useStyles = makeStyles({ + link: { + lineHeight: '22px', + }, +}); + +export const EscalationPolicyLink = ({ + escalationPolicy, +}: { + escalationPolicy: EscalationPolicy | null; +}) => { + const ilertApi = useApi(ilertApiRef); + const classes = useStyles(); + + if (!escalationPolicy) { + return null; + } + + return ( + + {escalationPolicy.name} + + ); +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCard.tsx b/plugins/ilert/src/components/ILertCard/ILertCard.tsx new file mode 100644 index 0000000000..bbc82c2b2d --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCard.tsx @@ -0,0 +1,142 @@ +/* + * Copyright 2021 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 { Entity } from '@backstage/catalog-model'; +import { AuthenticationError } from '@backstage/errors'; +import { ResponseErrorPanel } from '@backstage/core'; +import Card from '@material-ui/core/Card'; +import CardContent from '@material-ui/core/CardContent'; +import CardHeader from '@material-ui/core/CardHeader'; +import Divider from '@material-ui/core/Divider'; +import { makeStyles } from '@material-ui/core/styles'; +import { ILERT_INTEGRATION_KEY_ANNOTATION } from '../../constants'; +import { MissingAuthorizationHeaderError } from '../Errors'; +import { useIncidents } from '../../hooks/useIncidents'; +import { IncidentsTable } from '../IncidentsPage'; +import { IncidentNewModal } from '../Incident/IncidentNewModal'; +import { ILertCardActionsHeader } from './ILertCardActionsHeader'; +import { useAlertSource } from '../../hooks/useAlertSource'; +import { useILertEntity } from '../../hooks'; +import { ILertCardHeaderStatus } from './ILertCardHeaderStatus'; +import { ILertCardMaintenanceModal } from './ILertCardMaintenanceModal'; +import { ILertCardEmptyState } from './ILertCardEmptyState'; +import { ILertCardOnCall } from './ILertCardOnCall'; + +export const isPluginApplicableToEntity = (entity: Entity) => + Boolean(entity.metadata.annotations?.[ILERT_INTEGRATION_KEY_ANNOTATION]); + +const useStyles = makeStyles({ + content: { + paddingLeft: '0 !important', + paddingRight: '0 !important', + paddingBottom: '0 !important', + paddingTop: '0 !important', + '& div div': { + boxShadow: 'none !important', + }, + }, +}); + +export const ILertCard = () => { + const classes = useStyles(); + const { integrationKey, name } = useILertEntity(); + const [ + { alertSource, uptimeMonitor }, + { setAlertSource, refetchAlertSource }, + ] = useAlertSource(integrationKey); + const [ + { tableState, states, incidents, incidentsCount, isLoading, error }, + { + onIncidentStatesChange, + onChangePage, + onChangeRowsPerPage, + onIncidentChanged, + refetchIncidents, + setIsLoading, + }, + ] = useIncidents(false, true, alertSource); + + const [ + isNewIncidentModalOpened, + setIsNewIncidentModalOpened, + ] = React.useState(false); + const [ + isMaintenanceModalOpened, + setIsMaintenanceModalOpened, + ] = React.useState(false); + + if (error) { + if (error instanceof AuthenticationError) { + return ; + } + + return ; + } + + if (!integrationKey) { + return ; + } + + return ( + <> + + + } + action={} + /> + + + + + + + + + + ); +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCardActionsHeader.tsx b/plugins/ilert/src/components/ILertCard/ILertCardActionsHeader.tsx new file mode 100644 index 0000000000..c19c31839a --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCardActionsHeader.tsx @@ -0,0 +1,203 @@ +/* + * Copyright 2021 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 { + HeaderIconLinkRow, + IconLinkVerticalProps, + useApi, + alertApiRef, +} from '@backstage/core'; +import Alert from '@material-ui/lab/Alert'; +import Button from '@material-ui/core/Button'; +import Dialog from '@material-ui/core/Dialog'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import AlarmAddIcon from '@material-ui/icons/AlarmAdd'; +import BuildIcon from '@material-ui/icons/Build'; +import PauseIcon from '@material-ui/icons/Pause'; +import PlayArrowIcon from '@material-ui/icons/PlayArrow'; +import TimelineIcon from '@material-ui/icons/Timeline'; +import WebIcon from '@material-ui/icons/Web'; +import Typography from '@material-ui/core/Typography'; +import { ilertApiRef } from '../../api'; +import { AlertSource, UptimeMonitor } from '../../types'; + +export const ILertCardActionsHeader = ({ + alertSource, + setAlertSource, + setIsNewIncidentModalOpened, + setIsMaintenanceModalOpened, + uptimeMonitor, +}: { + alertSource: AlertSource | null; + setAlertSource: (alertSource: AlertSource) => void; + setIsNewIncidentModalOpened: (isOpen: boolean) => void; + setIsMaintenanceModalOpened: (isOpen: boolean) => void; + uptimeMonitor: UptimeMonitor | null; +}) => { + const ilertApi = useApi(ilertApiRef); + const alertApi = useApi(alertApiRef); + const [isLoading, setIsLoading] = React.useState(false); + const [isDisableModalOpened, setIsDisableModalOpened] = React.useState(false); + + const handleCreateNewIncident = () => { + setIsNewIncidentModalOpened(true); + }; + + const handleEnableAlertSource = async () => { + try { + if (!alertSource) { + return; + } + setIsLoading(true); + const newAlertSource = await ilertApi.enableAlertSource(alertSource); + alertApi.post({ message: 'Alert source enabled.' }); + setIsLoading(false); + setAlertSource(newAlertSource); + } catch (err) { + setIsLoading(false); + alertApi.post({ message: err, severity: 'error' }); + } + }; + const handleDisableAlertSource = async () => { + try { + if (!alertSource) { + return; + } + setIsDisableModalOpened(false); + setIsLoading(true); + const newAlertSource = await ilertApi.disableAlertSource(alertSource); + alertApi.post({ message: 'Alert source disabled.' }); + setIsLoading(false); + setAlertSource(newAlertSource); + } catch (err) { + setIsLoading(false); + alertApi.post({ message: err, severity: 'error' }); + } + }; + + const handleDisableAlertSourceWarningOpen = () => { + setIsDisableModalOpened(true); + }; + + const handleDisableAlertSourceWarningClose = () => { + setIsDisableModalOpened(false); + }; + + const handleMaintenanceAlertSource = () => { + setIsMaintenanceModalOpened(true); + }; + + const alertSourceLink: IconLinkVerticalProps = { + label: 'Alert Source', + href: ilertApi.getAlertSourceDetailsURL(alertSource), + icon: , + }; + + const createIncidentLink: IconLinkVerticalProps = { + label: 'Create Incident', + onClick: handleCreateNewIncident, + icon: , + color: 'secondary', + disabled: + !alertSource || + alertSource.status === 'DISABLED' || + alertSource.status === 'IN_MAINTENANCE', + }; + + const enableAlertSourceLink: IconLinkVerticalProps = { + label: 'Enable', + onClick: handleEnableAlertSource, + icon: , + disabled: !alertSource || isLoading, + }; + + const disableAlertSourceLink: IconLinkVerticalProps = { + label: 'Disable', + onClick: handleDisableAlertSourceWarningOpen, + icon: , + disabled: !alertSource || isLoading, + }; + + const maintenanceAlertSourceLink: IconLinkVerticalProps = { + label: 'Immediate maintenance', + onClick: handleMaintenanceAlertSource, + icon: , + disabled: !alertSource || isLoading, + }; + + const uptimeMonitorReportLink: IconLinkVerticalProps = { + label: 'Uptime Report', + href: uptimeMonitor ? uptimeMonitor.shareUrl : '', + icon: , + disabled: !alertSource || !uptimeMonitor || isLoading, + }; + + const links: IconLinkVerticalProps[] = [ + alertSourceLink, + createIncidentLink, + alertSource && alertSource.active + ? disableAlertSourceLink + : enableAlertSourceLink, + ]; + + if (alertSource && alertSource.integrationType === 'MONITOR') { + links.push(uptimeMonitorReportLink); + } + + if (alertSource && alertSource.status !== 'IN_MAINTENANCE') { + links.push(maintenanceAlertSourceLink); + } + + return ( + <> + + + + Disable alert source + + + + + Do you really want to disable this alert source? A disabled alert + source cannot create new incidents. + + + + + + + + + + ); +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCardEmptyState.tsx b/plugins/ilert/src/components/ILertCard/ILertCardEmptyState.tsx new file mode 100644 index 0000000000..955d514564 --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCardEmptyState.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2021 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 { CodeSnippet } from '@backstage/core'; +import { BackstageTheme } from '@backstage/theme'; +import Card from '@material-ui/core/Card'; +import CardContent from '@material-ui/core/CardContent'; +import CardHeader from '@material-ui/core/CardHeader'; +import Button from '@material-ui/core/Button'; +import Divider from '@material-ui/core/Divider'; +import Typography from '@material-ui/core/Typography'; +import { makeStyles } from '@material-ui/core/styles'; +import React from 'react'; + +const ENTITY_YAML = `apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: example + description: example.com + annotations: + ilert.com/integration-key: [INTEGRATION_KEY] +spec: + type: website + lifecycle: production + owner: guest`; + +const useStyles = makeStyles(theme => ({ + code: { + borderRadius: 6, + margin: theme.spacing(2, 0), + background: theme.palette.type === 'dark' ? '#444' : '#fff', + }, + header: { + display: 'inline-block', + padding: theme.spacing(2, 2, 2, 2.5), + }, +})); + +export const ILertCardEmptyState = () => { + const classes = useStyles(); + + return ( + + + + + + No integration key defined for this entity. You can add integration + key to your entity YAML as shown in the highlighted example below: + +
+ +
+ +
+
+ ); +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCardHeaderStatus.tsx b/plugins/ilert/src/components/ILertCard/ILertCardHeaderStatus.tsx new file mode 100644 index 0000000000..2c1b44551e --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCardHeaderStatus.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2021 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 Chip from '@material-ui/core/Chip'; +import { withStyles } from '@material-ui/core/styles'; +import { AlertSource } from '../../types'; + +const MaintenanceChip = withStyles({ + root: { + backgroundColor: '#92949c', + color: 'white', + marginTop: 8, + }, +})(Chip); + +export const ILertCardHeaderStatus = ({ + alertSource, +}: { + alertSource: AlertSource | null; +}) => { + if (!alertSource) { + return null; + } + + switch (alertSource.status) { + case 'IN_MAINTENANCE': + return ; + case 'DISABLED': + return ; + default: + return null; + } +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCardMaintenanceModal.tsx b/plugins/ilert/src/components/ILertCard/ILertCardMaintenanceModal.tsx new file mode 100644 index 0000000000..54d465a83c --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCardMaintenanceModal.tsx @@ -0,0 +1,138 @@ +/* + * Copyright 2021 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 { alertApiRef, useApi } from '@backstage/core'; +import Button from '@material-ui/core/Button'; +import Dialog from '@material-ui/core/Dialog'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogContentText from '@material-ui/core/DialogContentText'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import MenuItem from '@material-ui/core/MenuItem'; +import TextField from '@material-ui/core/TextField'; +import { ilertApiRef } from '../../api'; +import { AlertSource } from '../../types'; + +export const ILertCardMaintenanceModal = ({ + alertSource, + refetchAlertSource, + isModalOpened, + setIsModalOpened, +}: { + alertSource: AlertSource | null; + refetchAlertSource: () => void; + isModalOpened: boolean; + setIsModalOpened: (isModalOpened: boolean) => void; +}) => { + const ilertApi = useApi(ilertApiRef); + const alertApi = useApi(alertApiRef); + const [minutes, setMinutes] = React.useState(5); + + const handleClose = () => { + setIsModalOpened(false); + }; + + const handleImmediateMaintenance = () => { + if (!alertSource) { + return; + } + setIsModalOpened(false); + setTimeout(async () => { + try { + await ilertApi.addImmediateMaintenance(alertSource.id, minutes); + alertApi.post({ message: 'Maintenance started.' }); + refetchAlertSource(); + } catch (err) { + alertApi.post({ message: err, severity: 'error' }); + } + }, 250); + }; + + const handleMinutesChange = (event: any) => { + setMinutes(event.target.value); + }; + + const minuteOptions = [ + { + value: 5, + label: '5 minutes', + }, + { + value: 10, + label: '10 minutes', + }, + { + value: 15, + label: '15 minutes', + }, + { + value: 30, + label: '30 minutes', + }, + { + value: 60, + label: '60 minutes', + }, + ]; + + if (!alertSource) { + return null; + } + + return ( + + + New maintenance window + + + + Keep your alert sources quiet, when your systems are under + maintenance. + + + {minuteOptions.map(option => ( + + {option.label} + + ))} + + + + + + + + ); +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCardOnCall.tsx b/plugins/ilert/src/components/ILertCard/ILertCardOnCall.tsx new file mode 100644 index 0000000000..8f28790375 --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCardOnCall.tsx @@ -0,0 +1,117 @@ +/* + * Copyright 2021 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 from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemText from '@material-ui/core/ListItemText'; +import ListSubheader from '@material-ui/core/ListSubheader'; +import Typography from '@material-ui/core/Typography'; +import ReplayIcon from '@material-ui/icons/Replay'; +import { makeStyles } from '@material-ui/core/styles'; +import { AlertSource } from '../../types'; +import { useAlertSourceOnCalls } from '../../hooks/useAlertSourceOnCalls'; +import { Progress } from '@backstage/core'; +import { ILertCardOnCallEmptyState } from './ILertCardOnCallEmptyState'; +import { ILertCardOnCallItem } from './ILertCardOnCallItem'; + +const useStyles = makeStyles(theme => ({ + repeatText: { + fontStyle: 'italic', + }, + repeatIcon: { + marginLeft: theme.spacing(1), + }, +})); + +export const ILertCardOnCall = ({ + alertSource, +}: { + alertSource: AlertSource | null; +}) => { + const classes = useStyles(); + const [{ onCalls, isLoading }, {}] = useAlertSourceOnCalls(alertSource); + + if (isLoading) { + return ; + } + + if (!alertSource || !onCalls) { + return null; + } + + const repeatInfo = () => { + if ( + !alertSource || + !alertSource.escalationPolicy || + !alertSource.escalationPolicy.repeating || + !alertSource.escalationPolicy.frequency + ) { + return null; + } + + return ( + + + + + + {`Repeat ${alertSource.escalationPolicy.frequency} times`} + + } + /> + + ); + }; + + if (!onCalls.length) { + return ( + ON CALL}> + + + ); + } + + if (onCalls.length === 1) { + ON CALL}> + + ; + } + + return ( + ON CALL}> + {onCalls.map((onCall, index) => ( + + ))} + {repeatInfo()} + + ); +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCardOnCallEmptyState.tsx b/plugins/ilert/src/components/ILertCard/ILertCardOnCallEmptyState.tsx new file mode 100644 index 0000000000..2d43aeccb8 --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCardOnCallEmptyState.tsx @@ -0,0 +1,45 @@ +/* + * 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 { makeStyles } from '@material-ui/core/styles'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemText from '@material-ui/core/ListItemText'; +import Typography from '@material-ui/core/Typography'; + +const useStyles = makeStyles({ + text: { + fontStyle: 'italic', + }, +}); + +export const ILertCardOnCallEmptyState = () => { + const classes = useStyles(); + + return ( + + + + Nobody + + + + ); +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCardOnCallItem.tsx b/plugins/ilert/src/components/ILertCard/ILertCardOnCallItem.tsx new file mode 100644 index 0000000000..994666084b --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCardOnCallItem.tsx @@ -0,0 +1,169 @@ +/* + * Copyright 2021 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 Divider from '@material-ui/core/Divider'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import Avatar from '@material-ui/core/Avatar'; +import ListItemText from '@material-ui/core/ListItemText'; +import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; +import Tooltip from '@material-ui/core/Tooltip'; +import IconButton from '@material-ui/core/IconButton'; +import Typography from '@material-ui/core/Typography'; +import EmailIcon from '@material-ui/icons/Email'; +import PhoneIcon from '@material-ui/icons/Phone'; +import { makeStyles } from '@material-ui/core/styles'; +import { OnCall } from '../../types'; +import { useApi } from '@backstage/core'; +import { ilertApiRef } from '../../api'; +import { DateTime as dt } from 'luxon'; + +const useStyles = makeStyles({ + listItemPrimary: { + fontWeight: 'bold', + }, + fistItemLine: { + position: 'absolute', + bottom: 0, + height: '50%', + left: 36, + }, + lastItemLine: { + position: 'absolute', + top: 0, + height: '50%', + left: 36, + }, + itemLine: { + position: 'absolute', + top: 0, + bottom: 0, + height: '100%', + left: 36, + }, +}); + +export const ILertCardOnCallItem = ({ + onCall, + fistItem = false, + lastItem = false, +}: { + onCall: OnCall; + fistItem?: boolean; + lastItem?: boolean; +}) => { + const ilertApi = useApi(ilertApiRef); + const classes = useStyles(); + + if (!onCall || !onCall.user) { + return null; + } + + const phoneNumber = ilertApi.getUserPhoneNumber(onCall.user); + const escalationRepeating = onCall.escalationPolicy.repeating; + const escalationSeconds = + onCall.escalationPolicy.escalationRules[onCall.escalationLevel - 1] + .escalationTimeout; + const escalationHoursOnly = Math.floor(escalationSeconds / 60); + const escalationMinutesOnly = escalationSeconds % 60; + + let escalationText = ''; + if (!lastItem || (lastItem && escalationRepeating)) { + escalationText = 'escalate'; + if (escalationSeconds === 0) { + escalationText += ' immediately'; + } else { + escalationText += ' after'; + if (escalationHoursOnly > 0) { + escalationText += ` ${escalationHoursOnly} ${ + escalationHoursOnly === 1 ? 'hour' : 'hours' + }`; + } + if (escalationMinutesOnly > 0 || escalationSeconds === 0) { + escalationText += ` ${escalationMinutesOnly} ${ + escalationMinutesOnly === 1 ? 'minute' : 'minutes' + }`; + } + } + } + + return ( + + {fistItem ? ( + + ) : null} + {lastItem ? ( + + ) : null} + {!fistItem && !lastItem ? ( + + ) : null} + + + {onCall.escalationLevel} + + + {onCall.schedule ? ( + + + {ilertApi.getUserInitials(onCall.user)} + + } + secondary={escalationText} + /> + + ) : ( + + {ilertApi.getUserInitials(onCall.user)} + + } + secondary={escalationText} + /> + )} + + {phoneNumber ? ( + + + + + + ) : null} + + + + + + + + ); +}; diff --git a/plugins/ilert/src/components/ILertCard/index.ts b/plugins/ilert/src/components/ILertCard/index.ts new file mode 100644 index 0000000000..4ae259bfc5 --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 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 * from './ILertCard'; diff --git a/plugins/ilert/src/components/ILertPage/ILertPage.tsx b/plugins/ilert/src/components/ILertPage/ILertPage.tsx new file mode 100644 index 0000000000..40934bfdfb --- /dev/null +++ b/plugins/ilert/src/components/ILertPage/ILertPage.tsx @@ -0,0 +1,68 @@ +/* + * Copyright 2021 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 { + Page, + Header, + HeaderTabs, + HeaderLabel, + Content, +} from '@backstage/core'; +import { IncidentsPage } from '../IncidentsPage'; +import { UptimeMonitorsPage } from '../UptimeMonitorsPage'; +import { OnCallSchedulesPage } from '../OnCallSchedulesPage'; + +export const ILertPage = () => { + const [selectedTab, setSelectedTab] = React.useState(0); + const tabs = [ + { label: 'Who is on call?' }, + { label: 'Incidents' }, + { label: 'Uptime Monitors' }, + ]; + const renderTab = () => { + switch (selectedTab) { + case 0: + return ; + case 1: + return ; + case 2: + return ; + default: + return null; + } + }; + + return ( + +
+ + +
+ setSelectedTab(index)} + tabs={tabs.map(({ label }, index) => ({ + id: index.toString(), + label, + }))} + /> + + {renderTab()} +
+ ); +}; + +export default ILertPage; diff --git a/plugins/ilert/src/components/ILertPage/index.ts b/plugins/ilert/src/components/ILertPage/index.ts new file mode 100644 index 0000000000..5de46b63d4 --- /dev/null +++ b/plugins/ilert/src/components/ILertPage/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 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 * from './ILertPage'; diff --git a/plugins/ilert/src/components/Incident/IncidentActionsMenu.tsx b/plugins/ilert/src/components/Incident/IncidentActionsMenu.tsx new file mode 100644 index 0000000000..5b06cc4eb6 --- /dev/null +++ b/plugins/ilert/src/components/Incident/IncidentActionsMenu.tsx @@ -0,0 +1,203 @@ +/* + * Copyright 2021 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 { + alertApiRef, + Progress, + useApi, + identityApiRef, + Link, +} from '@backstage/core'; +import { IconButton, Menu, MenuItem, Typography } from '@material-ui/core'; +import MoreVertIcon from '@material-ui/icons/MoreVert'; +import { ilertApiRef } from '../../api'; +import { Incident, IncidentAction } from '../../types'; +import { IncidentAssignModal } from './IncidentAssignModal'; +import { useIncidentActions } from '../../hooks/useIncidentActions'; + +export const IncidentActionsMenu = ({ + incident, + onIncidentChanged, + setIsLoading, +}: { + incident: Incident; + onIncidentChanged?: (incident: Incident) => void; + setIsLoading?: (isLoading: boolean) => void; +}) => { + const ilertApi = useApi(ilertApiRef); + const alertApi = useApi(alertApiRef); + const identityApi = useApi(identityApiRef); + const userName = identityApi.getUserId(); + const [anchorEl, setAnchorEl] = React.useState(null); + const callback = onIncidentChanged || ((_: Incident): void => {}); + const setProcessing = setIsLoading || ((_: boolean): void => {}); + const [ + isAssignIncidentModalOpened, + setIsAssignIncidentModalOpened, + ] = React.useState(false); + + const [{ incidentActions, isLoading }] = useIncidentActions( + incident, + Boolean(anchorEl), + ); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleCloseMenu = () => { + setAnchorEl(null); + }; + + const handleAccept = async (): Promise => { + try { + handleCloseMenu(); + setProcessing(true); + const newIncident = await ilertApi.acceptIncident(incident, userName); + alertApi.post({ message: 'Incident accepted.' }); + + callback(newIncident); + setProcessing(false); + } catch (err) { + setProcessing(false); + alertApi.post({ message: err, severity: 'error' }); + } + }; + + const handleResolve = async (): Promise => { + try { + handleCloseMenu(); + setProcessing(true); + const newIncident = await ilertApi.resolveIncident(incident, userName); + alertApi.post({ message: 'Incident resolved.' }); + + callback(newIncident); + setProcessing(false); + } catch (err) { + setProcessing(false); + alertApi.post({ message: err, severity: 'error' }); + } + }; + + const handleAssign = () => { + handleCloseMenu(); + setIsAssignIncidentModalOpened(true); + }; + + const handleTriggerAction = (action: IncidentAction) => async () => { + try { + handleCloseMenu(); + setProcessing(true); + await ilertApi.triggerIncidentAction(incident, action); + alertApi.post({ message: 'Incident action triggered.' }); + setProcessing(false); + } catch (err) { + setProcessing(false); + alertApi.post({ message: err, severity: 'error' }); + } + }; + + const actions: React.ReactNode[] = incidentActions.map(a => { + const successTrigger = a.history + ? a.history.find(h => h.success) + : undefined; + const triggeredBy = + successTrigger && successTrigger.actor + ? `${successTrigger.actor.firstName} ${successTrigger.actor.lastName}` + : ''; + return ( + + + {triggeredBy ? `${a.name} (by ${triggeredBy})` : a.name} + + + ); + }); + + return ( + <> + + + + + {incident.status === 'PENDING' ? ( + + + Accept + + + ) : null} + + {incident.status !== 'RESOLVED' ? ( + + + Resolve + + + ) : null} + + {incident.status !== 'RESOLVED' ? ( + + + Assign + + + ) : null} + + {isLoading ? ( + + + + ) : ( + actions + )} + + + + + View in iLert + + + + + + + ); +}; diff --git a/plugins/ilert/src/components/Incident/IncidentAssignModal.tsx b/plugins/ilert/src/components/Incident/IncidentAssignModal.tsx new file mode 100644 index 0000000000..edac5e4e0e --- /dev/null +++ b/plugins/ilert/src/components/Incident/IncidentAssignModal.tsx @@ -0,0 +1,183 @@ +/* + * Copyright 2021 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 { alertApiRef, useApi } from '@backstage/core'; +import { makeStyles } from '@material-ui/core/styles'; +import Alert from '@material-ui/lab/Alert'; +import Button from '@material-ui/core/Button'; +import TextField from '@material-ui/core/TextField'; +import Dialog from '@material-ui/core/Dialog'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import Autocomplete from '@material-ui/lab/Autocomplete'; +import { useAssignIncident } from '../../hooks/useAssignIncident'; +import { Typography } from '@material-ui/core'; +import { ilertApiRef } from '../../api'; +import { Incident } from '../../types'; + +const useStyles = makeStyles(() => ({ + container: { + display: 'flex', + flexWrap: 'wrap', + }, + formControl: { + minWidth: 120, + width: '100%', + }, + option: { + fontSize: 15, + '& > span': { + marginRight: 10, + fontSize: 18, + }, + }, + optionWrapper: { + display: 'flex', + width: '100%', + }, + sourceImage: { + height: 22, + paddingRight: 4, + }, +})); + +export const IncidentAssignModal = ({ + incident, + isModalOpened, + setIsModalOpened, + onIncidentChanged, +}: { + incident: Incident | null; + isModalOpened: boolean; + setIsModalOpened: (open: boolean) => void; + onIncidentChanged?: (incident: Incident) => void; +}) => { + const [ + { incidentRespondersList, incidentResponder, isLoading }, + { setIsLoading, setIncidentResponder, setIncidentRespondersList }, + ] = useAssignIncident(incident, isModalOpened); + const callback = onIncidentChanged || ((_: Incident): void => {}); + const ilertApi = useApi(ilertApiRef); + const alertApi = useApi(alertApiRef); + const classes = useStyles(); + + const handleClose = () => { + setIncidentRespondersList([]); + setIsModalOpened(false); + }; + + const handleAssign = () => { + if (!incident || !incidentResponder) { + return; + } + setIsLoading(true); + setIncidentRespondersList([]); + setTimeout(async () => { + try { + const newIncident = await ilertApi.assignIncident( + incident, + incidentResponder, + ); + callback(newIncident); + alertApi.post({ message: 'Incident assigned.' }); + } catch (err) { + alertApi.post({ message: err, severity: 'error' }); + } + setIsLoading(false); + setIsModalOpened(false); + }, 250); + }; + + const canAssign = !!incidentResponder; + + return ( + + + Select responder to assign + + + + + This action will assign the incident to the selected responder. + + + { + setIncidentResponder(newValue); + }} + autoHighlight + groupBy={option => { + switch (option.group) { + case 'SUGGESTED': + return 'Suggested responders'; + case 'USER': + return 'Users'; + case 'ESCALATION_POLICY': + return 'Escalation policies'; + case 'ON_CALL_SCHEDULE': + return 'Schedules'; + default: + return ''; + } + }} + getOptionLabel={a => a.name} + renderOption={a => ( +
+ {a.name} +
+ )} + renderInput={params => ( + + )} + /> +
+ + + + +
+ ); +}; diff --git a/plugins/ilert/src/components/Incident/IncidentLink.tsx b/plugins/ilert/src/components/Incident/IncidentLink.tsx new file mode 100644 index 0000000000..7bfe08b7a9 --- /dev/null +++ b/plugins/ilert/src/components/Incident/IncidentLink.tsx @@ -0,0 +1,44 @@ +/* + * Copyright 2021 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 { useApi, Link } from '@backstage/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { Incident } from '../../types'; +import { ilertApiRef } from '../../api'; + +const useStyles = makeStyles({ + link: { + lineHeight: '22px', + }, +}); + +export const IncidentLink = ({ incident }: { incident: Incident | null }) => { + const ilertApi = useApi(ilertApiRef); + const classes = useStyles(); + + if (!incident) { + return null; + } + + return ( + + #{incident.id} + + ); +}; diff --git a/plugins/ilert/src/components/Incident/IncidentNewModal.tsx b/plugins/ilert/src/components/Incident/IncidentNewModal.tsx new file mode 100644 index 0000000000..8ff33e6319 --- /dev/null +++ b/plugins/ilert/src/components/Incident/IncidentNewModal.tsx @@ -0,0 +1,228 @@ +/* + * Copyright 2021 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 { alertApiRef, identityApiRef, useApi } from '@backstage/core'; +import { makeStyles } from '@material-ui/core/styles'; +import Alert from '@material-ui/lab/Alert'; +import Button from '@material-ui/core/Button'; +import TextField from '@material-ui/core/TextField'; +import Dialog from '@material-ui/core/Dialog'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import Autocomplete from '@material-ui/lab/Autocomplete'; +import { useNewIncident } from '../../hooks/useNewIncident'; +import { Typography } from '@material-ui/core'; +import useMediaQuery from '@material-ui/core/useMediaQuery'; +import { ilertApiRef } from '../../api'; +import { AlertSource } from '../../types'; + +const useStyles = makeStyles(() => ({ + container: { + display: 'flex', + flexWrap: 'wrap', + }, + formControl: { + minWidth: 120, + width: '100%', + }, + option: { + fontSize: 15, + '& > span': { + marginRight: 10, + fontSize: 18, + }, + }, + optionWrapper: { + display: 'flex', + width: '100%', + }, + sourceImage: { + height: 22, + paddingRight: 4, + }, +})); + +export const IncidentNewModal = ({ + isModalOpened, + setIsModalOpened, + refetchIncidents, + initialAlertSource, + entityName, +}: { + isModalOpened: boolean; + setIsModalOpened: (open: boolean) => void; + refetchIncidents: () => void; + initialAlertSource?: AlertSource | null; + entityName?: string; +}) => { + const [ + { alertSources, alertSource, summary, details, isLoading }, + { setAlertSource, setSummary, setDetails, setIsLoading }, + ] = useNewIncident(isModalOpened, initialAlertSource); + const ilertApi = useApi(ilertApiRef); + const alertApi = useApi(alertApiRef); + const identityApi = useApi(identityApiRef); + const userName = identityApi.getUserId(); + const source = window.location.toString(); + const classes = useStyles(); + const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); + + const handleClose = () => { + setIsModalOpened(false); + }; + + let integrationKey = ''; + if (initialAlertSource && initialAlertSource.integrationKey) { + integrationKey = initialAlertSource.integrationKey; + } else if (alertSource && alertSource.integrationKey) { + integrationKey = alertSource.integrationKey; + } + const handleCreate = () => { + if (!integrationKey) { + return; + } + setIsLoading(true); + setTimeout(async () => { + try { + await ilertApi.createIncident({ + integrationKey, + summary, + details, + userName, + source, + }); + alertApi.post({ message: 'Incident created.' }); + refetchIncidents(); + } catch (err) { + alertApi.post({ message: err, severity: 'error' }); + } + setIsModalOpened(false); + }, 250); + }; + + const canCreate = !!integrationKey && !!summary; + + return ( + + + {entityName ? ( +
+ This action will trigger an incident for{' '} + "{entityName}". +
+ ) : ( + 'New incident' + )} +
+ + + + 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. + + + {!initialAlertSource ? ( + { + setAlertSource(newValue); + }} + autoHighlight + getOptionLabel={a => a.name} + renderOption={a => ( +
+ {a.name} + {a.name} +
+ )} + renderInput={params => ( + + )} + /> + ) : null} + { + setSummary(event.target.value); + }} + /> + { + setDetails(event.target.value); + }} + /> +
+ + + + +
+ ); +}; diff --git a/plugins/ilert/src/components/Incident/IncidentStatus.tsx b/plugins/ilert/src/components/Incident/IncidentStatus.tsx new file mode 100644 index 0000000000..931a472ec6 --- /dev/null +++ b/plugins/ilert/src/components/Incident/IncidentStatus.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2021 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 { StatusError, StatusOK } from '@backstage/core'; +import { makeStyles } from '@material-ui/core/styles'; +import Tooltip from '@material-ui/core/Tooltip'; +import { ACCEPTED, Incident, PENDING, RESOLVED } from '../../types'; + +const useStyles = makeStyles({ + denseListIcon: { + marginRight: 0, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + }, +}); + +export const incidentStatusLabels = { + [RESOLVED]: 'Resolved', + [ACCEPTED]: 'Accepted', + [PENDING]: 'Pending', +} as Record; + +export const IncidentStatus = ({ incident }: { incident: Incident }) => { + const classes = useStyles(); + + return ( + +
+ {incident.status === 'PENDING' ? : } +
+
+ ); +}; diff --git a/plugins/ilert/src/components/Incident/index.ts b/plugins/ilert/src/components/Incident/index.ts new file mode 100644 index 0000000000..df8bab5d95 --- /dev/null +++ b/plugins/ilert/src/components/Incident/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 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 * from './IncidentActionsMenu'; +export * from './IncidentStatus'; diff --git a/plugins/ilert/src/components/IncidentsPage/IncidentsPage.tsx b/plugins/ilert/src/components/IncidentsPage/IncidentsPage.tsx new file mode 100644 index 0000000000..782848645e --- /dev/null +++ b/plugins/ilert/src/components/IncidentsPage/IncidentsPage.tsx @@ -0,0 +1,101 @@ +/* + * Copyright 2021 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 { + Content, + ContentHeader, + SupportButton, + ResponseErrorPanel, +} from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import Button from '@material-ui/core/Button'; +import AddIcon from '@material-ui/icons/Add'; +import { IncidentsTable } from './IncidentsTable'; +import { MissingAuthorizationHeaderError } from '../Errors'; +import { useIncidents } from '../../hooks/useIncidents'; +import { IncidentNewModal } from '../Incident/IncidentNewModal'; + +export const IncidentsPage = () => { + const [ + { tableState, states, incidents, incidentsCount, isLoading, error }, + { + onIncidentStatesChange, + onChangePage, + onChangeRowsPerPage, + onIncidentChanged, + refetchIncidents, + setIsLoading, + }, + ] = useIncidents(true); + + const [isModalOpened, setIsModalOpened] = React.useState(false); + + const handleCreateNewIncidentClick = () => { + setIsModalOpened(true); + }; + + if (error) { + if (error instanceof AuthenticationError) { + return ( + + + + ); + } + + return ( + + + + ); + } + + return ( + + + + + + This helps you to bring iLert into your developer portal. + + + + + ); +}; diff --git a/plugins/ilert/src/components/IncidentsPage/IncidentsTable.tsx b/plugins/ilert/src/components/IncidentsPage/IncidentsTable.tsx new file mode 100644 index 0000000000..087b2bfd40 --- /dev/null +++ b/plugins/ilert/src/components/IncidentsPage/IncidentsTable.tsx @@ -0,0 +1,262 @@ +/* + * Copyright 2021 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 { Table, TableColumn, useApi } from '@backstage/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { ilertApiRef, TableState } from '../../api'; +import { Incident, IncidentStatus } from '../../types'; +import { StatusChip } from './StatusChip'; +import { AlertSourceLink } from '../AlertSource/AlertSourceLink'; +import { TableTitle } from './TableTitle'; +import Typography from '@material-ui/core/Typography'; +import { DateTime as dt, Interval } from 'luxon'; +import humanizeDuration from 'humanize-duration'; +import { IncidentActionsMenu } from '../Incident/IncidentActionsMenu'; +import { IncidentLink } from '../Incident/IncidentLink'; + +const useStyles = makeStyles(theme => ({ + empty: { + padding: theme.spacing(2), + display: 'flex', + justifyContent: 'center', + }, +})); + +export const IncidentsTable = ({ + incidents, + incidentsCount, + tableState, + states, + isLoading, + onIncidentChanged, + setIsLoading, + onIncidentStatesChange, + onChangePage, + onChangeRowsPerPage, + compact, +}: { + incidents: Incident[]; + incidentsCount: number; + tableState: TableState; + states: IncidentStatus[]; + isLoading: boolean; + onIncidentChanged: (incident: Incident) => void; + setIsLoading: (isLoading: boolean) => void; + onIncidentStatesChange: (states: IncidentStatus[]) => void; + onChangePage: (page: number) => void; + onChangeRowsPerPage: (pageSize: number) => void; + compact?: boolean; +}) => { + const ilertApi = useApi(ilertApiRef); + const classes = useStyles(); + + const xsColumnStyle = { + width: '5%', + maxWidth: '5%', + }; + const smColumnStyle = { + width: '10%', + maxWidth: '10%', + }; + const mdColumnStyle = { + width: '15%', + maxWidth: '15%', + }; + const lgColumnStyle = { + width: '20%', + maxWidth: '20%', + }; + const xlColumnStyle = { + width: '30%', + maxWidth: '30%', + }; + + const idColumn: TableColumn = { + title: 'ID', + field: 'id', + highlight: true, + cellStyle: smColumnStyle, + headerStyle: smColumnStyle, + render: rowData => , + }; + const summaryColumn: TableColumn = { + title: 'Summary', + field: 'summary', + cellStyle: !compact ? xlColumnStyle : undefined, + headerStyle: !compact ? xlColumnStyle : undefined, + render: rowData => {(rowData as Incident).summary}, + }; + const sourceColumn: TableColumn = { + title: 'Source', + field: 'source', + cellStyle: mdColumnStyle, + headerStyle: mdColumnStyle, + render: rowData => ( + + ), + }; + const durationColumn: TableColumn = { + title: 'Duration', + field: 'reportTime', + type: 'datetime', + cellStyle: smColumnStyle, + headerStyle: smColumnStyle, + render: rowData => ( + + {(rowData as Incident).status !== 'RESOLVED' + ? humanizeDuration( + Interval.fromDateTimes( + dt.fromISO((rowData as Incident).reportTime), + dt.now(), + ) + .toDuration() + .valueOf(), + { units: ['h', 'm', 's'], largest: 2, round: true }, + ) + : humanizeDuration( + Interval.fromDateTimes( + dt.fromISO((rowData as Incident).reportTime), + dt.fromISO((rowData as Incident).resolvedOn), + ) + .toDuration() + .valueOf(), + { units: ['h', 'm', 's'], largest: 2, round: true }, + )} + + ), + }; + const assignedToColumn: TableColumn = { + title: 'Assigned to', + field: 'assignedTo', + cellStyle: !compact ? mdColumnStyle : lgColumnStyle, + headerStyle: !compact ? mdColumnStyle : lgColumnStyle, + render: rowData => ( + + {ilertApi.getUserInitials((rowData as Incident).assignedTo)} + + ), + }; + const priorityColumn: TableColumn = { + title: 'Priority', + field: 'priority', + cellStyle: smColumnStyle, + headerStyle: smColumnStyle, + render: rowData => ( + + {(rowData as Incident).priority === 'HIGH' ? 'High' : 'Low'} + + ), + }; + const statusColumn: TableColumn = { + title: 'Status', + field: 'status', + cellStyle: xsColumnStyle, + headerStyle: xsColumnStyle, + render: rowData => , + }; + const actionsColumn: TableColumn = { + title: '', + field: '', + cellStyle: xsColumnStyle, + headerStyle: xsColumnStyle, + render: rowData => ( + + ), + }; + + const columns: TableColumn[] = compact + ? [ + summaryColumn, + durationColumn, + assignedToColumn, + statusColumn, + actionsColumn, + ] + : [ + idColumn, + summaryColumn, + sourceColumn, + durationColumn, + assignedToColumn, + priorityColumn, + statusColumn, + actionsColumn, + ]; + let tableStyle: React.CSSProperties = {}; + if (compact) { + tableStyle = { + width: '100%', + maxWidth: '100%', + minWidth: '0', + height: 'calc(100% - 10px)', + boxShadow: 'none !important', + borderRadius: 'none !important', + }; + } else { + tableStyle = { + width: '100%', + maxWidth: '100%', + }; + } + + return ( + + No incidents right now + + } + title={ + !compact ? ( + + ) : ( + + INCIDENTS + + ) + } + page={tableState.page} + totalCount={incidentsCount} + onChangePage={onChangePage} + onChangeRowsPerPage={onChangeRowsPerPage} + // localization={{ header: { actions: undefined } }} + columns={columns} + data={incidents} + isLoading={isLoading} + /> + ); +}; diff --git a/plugins/ilert/src/components/IncidentsPage/StatusChip.tsx b/plugins/ilert/src/components/IncidentsPage/StatusChip.tsx new file mode 100644 index 0000000000..9713b16a55 --- /dev/null +++ b/plugins/ilert/src/components/IncidentsPage/StatusChip.tsx @@ -0,0 +1,57 @@ +/* + * Copyright 2021 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 { Chip, withStyles } from '@material-ui/core'; +import { Incident, PENDING, ACCEPTED, RESOLVED } from '../../types'; +import { incidentStatusLabels } from '../Incident/IncidentStatus'; + +const ResolvedChip = withStyles({ + root: { + backgroundColor: '#4caf50', + color: 'white', + margin: 0, + }, +})(Chip); + +const AcceptedChip = withStyles({ + root: { + backgroundColor: '#ffb74d', + color: 'white', + margin: 0, + }, +})(Chip); +const PendingChip = withStyles({ + root: { + backgroundColor: '#d32f2f', + color: 'white', + margin: 0, + }, +})(Chip); + +export const StatusChip = ({ incident }: { incident: Incident }) => { + const label = `${incidentStatusLabels[incident.status]}`; + + switch (incident.status) { + case RESOLVED: + return ; + case ACCEPTED: + return ; + case PENDING: + return ; + default: + return ; + } +}; diff --git a/plugins/ilert/src/components/IncidentsPage/TableTitle.tsx b/plugins/ilert/src/components/IncidentsPage/TableTitle.tsx new file mode 100644 index 0000000000..f35981dec2 --- /dev/null +++ b/plugins/ilert/src/components/IncidentsPage/TableTitle.tsx @@ -0,0 +1,97 @@ +/* + * Copyright 2021 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 { PENDING, ACCEPTED, RESOLVED, IncidentStatus } from '../../types'; +import { incidentStatusLabels } from '../Incident/IncidentStatus'; +import FormControl from '@material-ui/core/FormControl'; +import ListItemText from '@material-ui/core/ListItemText'; +import Select from '@material-ui/core/Select'; +import Typography from '@material-ui/core/Typography'; +import MenuItem from '@material-ui/core/MenuItem'; +import Checkbox from '@material-ui/core/Checkbox'; +import { makeStyles } from '@material-ui/core/styles'; + +const ITEM_HEIGHT = 48; +const ITEM_PADDING_TOP = 8; +const MenuProps = { + PaperProps: { + style: { + maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP, + width: 250, + }, + }, +}; + +const useStyles = makeStyles({ + root: { + display: 'flex', + }, + label: { + marginTop: 8, + marginRight: 4, + }, + formControl: { + minWidth: 120, + maxWidth: 300, + }, + grow: { + flexGrow: 1, + }, +}); + +export const TableTitle = ({ + incidentStates, + onIncidentStatesChange, +}: { + incidentStates: IncidentStatus[]; + onIncidentStatesChange: (states: IncidentStatus[]) => void; +}) => { + const classes = useStyles(); + const handleIncidentStatusSelectChange = (event: any) => { + onIncidentStatesChange(event.target.value); + }; + + return ( +
+ + Status: + + + + +
+ ); +}; diff --git a/plugins/ilert/src/components/IncidentsPage/index.ts b/plugins/ilert/src/components/IncidentsPage/index.ts new file mode 100644 index 0000000000..7159247c68 --- /dev/null +++ b/plugins/ilert/src/components/IncidentsPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 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 * from './IncidentsPage'; +export * from './IncidentsTable'; diff --git a/plugins/ilert/src/components/OnCallSchedulesPage/OnCallSchedulesGrid.tsx b/plugins/ilert/src/components/OnCallSchedulesPage/OnCallSchedulesGrid.tsx new file mode 100644 index 0000000000..31da9c1097 --- /dev/null +++ b/plugins/ilert/src/components/OnCallSchedulesPage/OnCallSchedulesGrid.tsx @@ -0,0 +1,171 @@ +/* + * Copyright 2021 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 { useApi, ItemCardGrid, Progress, Link } from '@backstage/core'; +import { makeStyles } from '@material-ui/core/styles'; +import Card from '@material-ui/core/Card'; +import CardContent from '@material-ui/core/CardContent'; +import CardHeader from '@material-ui/core/CardHeader'; +import Typography from '@material-ui/core/Typography'; +import { Schedule } from '../../types'; +import { ilertApiRef } from '../../api'; +import { OnCallShiftItem } from './OnCallShiftItem'; + +const useStyles = makeStyles(() => ({ + card: { + margin: 16, + width: 'calc(100% - 32px)', + }, + + cardHeader: { + maxWidth: '100%', + }, + + cardContent: { + marginLeft: 80, + borderLeft: '1px #808289 solid', + position: 'relative', + }, + + indicatorNext: { + position: 'absolute', + top: 'calc(40% - 10px)', + left: -6, + width: 12, + height: 12, + background: '#92949c !important', + borderRadius: '50%', + }, + + indicatorCurrent: { + position: 'absolute', + top: 'calc(40% - 10px)', + left: -6, + width: 12, + height: 12, + background: '#ffb74d !important', + color: '#ffb74d !important', + borderRadius: '50%', + '&::after': { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + borderRadius: '50%', + animation: '$ripple 1.2s infinite ease-in-out', + border: '1px solid currentColor', + content: '""', + }, + }, + '@keyframes ripple': { + '0%': { + transform: 'scale(.8)', + opacity: 1, + }, + '100%': { + transform: 'scale(2.4)', + opacity: 0, + }, + }, + + beforeText: { + position: 'absolute', + top: 'calc(31% - 10px)', + left: -78, + width: 65, + height: 20, + textAlign: 'center', + color: '#808289', + }, + + marginBottom: { + marginBottom: 16, + }, + + link: { + fontSize: '1.5rem', + fontWeight: 700, + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + display: 'block', + }, +})); + +export const OnCallSchedulesGrid = ({ + onCallSchedules, + isLoading, + refetchOnCallSchedules, +}: { + onCallSchedules: Schedule[]; + isLoading: boolean; + refetchOnCallSchedules: () => void; +}) => { + const ilertApi = useApi(ilertApiRef); + const classes = useStyles(); + + if (isLoading) { + return ; + } + return ( + + {!onCallSchedules?.length + ? null + : onCallSchedules.map((schedule, index) => ( + + + {schedule.name} + + } + /> + + +
+ + + On call now + + + + +
+ + + Next on call + + + + ))} + + ); +}; diff --git a/plugins/ilert/src/components/OnCallSchedulesPage/OnCallSchedulesPage.tsx b/plugins/ilert/src/components/OnCallSchedulesPage/OnCallSchedulesPage.tsx new file mode 100644 index 0000000000..bb0cd0218e --- /dev/null +++ b/plugins/ilert/src/components/OnCallSchedulesPage/OnCallSchedulesPage.tsx @@ -0,0 +1,64 @@ +/* + * Copyright 2021 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 { + Content, + ContentHeader, + SupportButton, + ResponseErrorPanel, +} from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { OnCallSchedulesGrid } from './OnCallSchedulesGrid'; +import { MissingAuthorizationHeaderError } from '../Errors'; +import { useOnCallSchedules } from '../../hooks/useOnCallSchedules'; + +export const OnCallSchedulesPage = () => { + const [ + { onCallSchedules, isLoading, error }, + { refetchOnCallSchedules }, + ] = useOnCallSchedules(); + + if (error) { + if (error instanceof AuthenticationError) { + return ( + + + + ); + } + + return ( + + + + ); + } + + return ( + + + + This helps you to bring iLert into your developer portal. + + + + + ); +}; diff --git a/plugins/ilert/src/components/OnCallSchedulesPage/OnCallShiftItem.tsx b/plugins/ilert/src/components/OnCallSchedulesPage/OnCallShiftItem.tsx new file mode 100644 index 0000000000..46f6963445 --- /dev/null +++ b/plugins/ilert/src/components/OnCallSchedulesPage/OnCallShiftItem.tsx @@ -0,0 +1,105 @@ +/* + * Copyright 2021 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 Button from '@material-ui/core/Button'; +import Grid from '@material-ui/core/Grid'; +import Typography from '@material-ui/core/Typography'; +import RepeatIcon from '@material-ui/icons/Repeat'; +import { Shift } from '../../types'; +import { DateTime as dt } from 'luxon'; +import { makeStyles } from '@material-ui/core/styles'; +import { ShiftOverrideModal } from '../Shift/ShiftOverrideModal'; + +const useStyles = makeStyles({ + button: { + marginTop: 4, + padding: 0, + lineHeight: 1.8, + '& span': { + lineHeight: 1.8, + fontSize: '0.65rem', + }, + '& svg': { + fontSize: '0.85rem !important', + }, + }, +}); + +export const OnCallShiftItem = ({ + scheduleId, + shift, + refetchOnCallSchedules, +}: { + scheduleId: number; + shift: Shift; + refetchOnCallSchedules: () => void; +}) => { + const classes = useStyles(); + const [isModalOpened, setIsModalOpened] = React.useState(false); + + const handleOverride = () => { + setIsModalOpened(true); + }; + + if (!shift || !shift.start) { + return ( + + + + Nobody + + + + ); + } + + return ( + + {shift && shift.user ? ( + + + {`${shift.user.firstName} ${shift.user.lastName} (${shift.user.username})`} + + + ) : null} + + + {`${dt.fromISO(shift.start).toFormat('D MMM, HH:mm')} - ${dt + .fromISO(shift.end) + .toFormat('D MMM, HH:mm')}`} + + + + + + + + ); +}; diff --git a/plugins/ilert/src/components/OnCallSchedulesPage/index.ts b/plugins/ilert/src/components/OnCallSchedulesPage/index.ts new file mode 100644 index 0000000000..a52accc2bb --- /dev/null +++ b/plugins/ilert/src/components/OnCallSchedulesPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 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 * from './OnCallSchedulesPage'; +export * from './OnCallSchedulesGrid'; diff --git a/plugins/ilert/src/components/Shift/ShiftOverrideModal.tsx b/plugins/ilert/src/components/Shift/ShiftOverrideModal.tsx new file mode 100644 index 0000000000..47e13f9b7e --- /dev/null +++ b/plugins/ilert/src/components/Shift/ShiftOverrideModal.tsx @@ -0,0 +1,196 @@ +/* + * Copyright 2021 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 { alertApiRef, useApi } from '@backstage/core'; +import { makeStyles } from '@material-ui/core/styles'; +import Button from '@material-ui/core/Button'; +import TextField from '@material-ui/core/TextField'; +import Dialog from '@material-ui/core/Dialog'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import Autocomplete from '@material-ui/lab/Autocomplete'; +import { Typography } from '@material-ui/core'; +import { ilertApiRef } from '../../api'; +import { useShiftOverride } from '../../hooks/useShiftOverride'; +import { Shift } from '../../types'; +import { DateTimePicker, MuiPickersUtilsProvider } from '@material-ui/pickers'; +import LuxonUtils from '@date-io/luxon'; + +const useStyles = makeStyles(() => ({ + container: { + display: 'flex', + flexWrap: 'wrap', + }, + formControl: { + minWidth: 120, + width: '100%', + }, + option: { + fontSize: 15, + '& > span': { + marginRight: 10, + fontSize: 18, + }, + }, + optionWrapper: { + display: 'flex', + width: '100%', + }, + sourceImage: { + height: 22, + paddingRight: 4, + }, + grow: { + flexGrow: 1, + }, +})); + +export const ShiftOverrideModal = ({ + scheduleId, + shift, + refetchOnCallSchedules, + isModalOpened, + setIsModalOpened, +}: { + scheduleId: number; + shift: Shift; + refetchOnCallSchedules: () => void; + isModalOpened: boolean; + setIsModalOpened: (isModalOpened: boolean) => void; +}) => { + const [ + { isLoading, users, user, start, end }, + { setUser, setStart, setEnd, setIsLoading }, + ] = useShiftOverride(shift, isModalOpened); + const ilertApi = useApi(ilertApiRef); + const alertApi = useApi(alertApiRef); + const classes = useStyles(); + + const handleClose = () => { + setIsModalOpened(false); + }; + + const handleOverride = () => { + if (!shift || !shift.user) { + return; + } + setIsLoading(true); + setTimeout(async () => { + try { + const success = await ilertApi.overrideShift( + scheduleId, + user.id, + start, + end, + ); + if (success) { + alertApi.post({ message: 'Shift overridden.' }); + refetchOnCallSchedules(); + } + } catch (err) { + alertApi.post({ message: err, severity: 'error' }); + } + setIsModalOpened(false); + }, 250); + }; + + if (!shift) { + return null; + } + + return ( + + Shift override + + + { + setUser(newValue); + }} + autoHighlight + getOptionLabel={a => ilertApi.getUserInitials(a)} + renderOption={a => ( +
+ {ilertApi.getUserInitials(a)} +
+ )} + renderInput={params => ( + + )} + /> + { + setStart(date ? date.toISO() : ''); + }} + /> + { + setEnd(date ? date.toISO() : ''); + }} + /> +
+
+ + + + +
+ ); +}; diff --git a/plugins/ilert/src/components/UptimeMonitor/UptimeMonitorActionsMenu.tsx b/plugins/ilert/src/components/UptimeMonitor/UptimeMonitorActionsMenu.tsx new file mode 100644 index 0000000000..36671bff90 --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitor/UptimeMonitorActionsMenu.tsx @@ -0,0 +1,135 @@ +/* + * Copyright 2021 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 { alertApiRef, useApi, Link } from '@backstage/core'; +import { IconButton, Menu, MenuItem, Typography } from '@material-ui/core'; +import MoreVertIcon from '@material-ui/icons/MoreVert'; + +import { ilertApiRef } from '../../api'; +import { UptimeMonitor } from '../../types'; + +export const UptimeMonitorActionsMenu = ({ + uptimeMonitor, + onUptimeMonitorChanged, +}: { + uptimeMonitor: UptimeMonitor; + onUptimeMonitorChanged?: (uptimeMonitor: UptimeMonitor) => void; +}) => { + const ilertApi = useApi(ilertApiRef); + const alertApi = useApi(alertApiRef); + const [anchorEl, setAnchorEl] = React.useState(null); + const callback = onUptimeMonitorChanged || ((_: UptimeMonitor): void => {}); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleCloseMenu = () => { + setAnchorEl(null); + }; + + const handlePause = async (): Promise => { + try { + const newUptimeMonitor = await ilertApi.pauseUptimeMonitor(uptimeMonitor); + handleCloseMenu(); + alertApi.post({ message: 'Uptime monitor paused.' }); + + callback(newUptimeMonitor); + } catch (err) { + alertApi.post({ message: err, severity: 'error' }); + } + }; + + const handleResume = async (): Promise => { + try { + const newUptimeMonitor = await ilertApi.resumeUptimeMonitor( + uptimeMonitor, + ); + handleCloseMenu(); + alertApi.post({ message: 'Uptime monitor resumed.' }); + + callback(newUptimeMonitor); + } catch (err) { + alertApi.post({ message: err, severity: 'error' }); + } + }; + + const handleOpenReport = async (): Promise => { + try { + const um = await ilertApi.fetchUptimeMonitor(uptimeMonitor.id); + handleCloseMenu(); + window.open(um.shareUrl, '_blank'); + } catch (err) { + alertApi.post({ message: err, severity: 'error' }); + } + }; + + return ( + <> + + + + + {uptimeMonitor.paused ? ( + + + Resume + + + ) : null} + + {!uptimeMonitor.paused ? ( + + + Pause + + + ) : null} + + + + + View Report + + + + + + + + View in iLert + + + + + + ); +}; diff --git a/plugins/ilert/src/components/UptimeMonitor/UptimeMonitorLink.tsx b/plugins/ilert/src/components/UptimeMonitor/UptimeMonitorLink.tsx new file mode 100644 index 0000000000..507e0208e2 --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitor/UptimeMonitorLink.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2021 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 { useApi, Link } from '@backstage/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { UptimeMonitor } from '../../types'; +import { ilertApiRef } from '../../api'; + +const useStyles = makeStyles({ + link: { + lineHeight: '22px', + }, +}); + +export const UptimeMonitorLink = ({ + uptimeMonitor, +}: { + uptimeMonitor: UptimeMonitor | null; +}) => { + const ilertApi = useApi(ilertApiRef); + const classes = useStyles(); + + if (!uptimeMonitor) { + return null; + } + + return ( + + #{uptimeMonitor.id} + + ); +}; diff --git a/plugins/ilert/src/components/UptimeMonitor/index.ts b/plugins/ilert/src/components/UptimeMonitor/index.ts new file mode 100644 index 0000000000..f30d1ef15a --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitor/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 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 * from './UptimeMonitorActionsMenu'; diff --git a/plugins/ilert/src/components/UptimeMonitorsPage/StatusChip.tsx b/plugins/ilert/src/components/UptimeMonitorsPage/StatusChip.tsx new file mode 100644 index 0000000000..dd17b1758b --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitorsPage/StatusChip.tsx @@ -0,0 +1,73 @@ +/* + * Copyright 2021 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 Chip from '@material-ui/core/Chip'; +import { withStyles } from '@material-ui/core/styles'; +import { UptimeMonitor } from '../../types'; + +const UpChip = withStyles({ + root: { + backgroundColor: '#4caf50', + color: 'white', + margin: 0, + }, +})(Chip); + +const DownChip = withStyles({ + root: { + backgroundColor: '#d32f2f', + color: 'white', + margin: 0, + }, +})(Chip); + +const UnknownChip = withStyles({ + root: { + backgroundColor: '#92949c', + color: 'white', + margin: 0, + }, +})(Chip); + +export const uptimeMonitorStatusLabels = { + ['up']: 'Up', + ['down']: 'Down', + ['unknown']: 'Unknown', +} as Record; + +export const StatusChip = ({ + uptimeMonitor, +}: { + uptimeMonitor: UptimeMonitor; +}) => { + let label = `${uptimeMonitorStatusLabels[uptimeMonitor.status]}`; + + if (uptimeMonitor.paused) { + label = 'Paused'; + return ; + } + + switch (uptimeMonitor.status) { + case 'up': + return ; + case 'down': + return ; + case 'unknown': + return ; + default: + return ; + } +}; diff --git a/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorCheckType.tsx b/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorCheckType.tsx new file mode 100644 index 0000000000..3fdecd65e9 --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorCheckType.tsx @@ -0,0 +1,39 @@ +/* + * Copyright 2021 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 { UptimeMonitor } from '../../types'; +import Typography from '@material-ui/core/Typography'; + +export const UptimeMonitorCheckType = ({ + uptimeMonitor, +}: { + uptimeMonitor: UptimeMonitor; +}) => { + switch (uptimeMonitor.region) { + case 'EU': + return ( + {`${uptimeMonitor.checkType.toUpperCase()} 🇩🇪`} + ); + default: + return ( + {`${uptimeMonitor.checkType.toUpperCase()} 🇺🇸`} + ); + } +}; diff --git a/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorsPage.tsx b/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorsPage.tsx new file mode 100644 index 0000000000..9769fa408d --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorsPage.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2021 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 { + Content, + ContentHeader, + SupportButton, + ResponseErrorPanel, +} from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { UptimeMonitorsTable } from './UptimeMonitorsTable'; +import { MissingAuthorizationHeaderError } from '../Errors'; +import { useUptimeMonitors } from '../../hooks/useUptimeMonitors'; + +export const UptimeMonitorsPage = () => { + const [ + { tableState, uptimeMonitors, isLoading, error }, + { onChangePage, onChangeRowsPerPage, onUptimeMonitorChanged }, + ] = useUptimeMonitors(); + + if (error) { + if (error instanceof AuthenticationError) { + return ( + + + + ); + } + + return ( + + + + ); + } + + return ( + + + + This helps you to bring iLert into your developer portal. + + + + + ); +}; diff --git a/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorsTable.tsx b/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorsTable.tsx new file mode 100644 index 0000000000..2173f8e1b7 --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorsTable.tsx @@ -0,0 +1,176 @@ +/* + * Copyright 2021 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 { makeStyles } from '@material-ui/core/styles'; +import { Table, TableColumn } from '@backstage/core'; +import { TableState } from '../../api'; +import { UptimeMonitor } from '../../types'; +import { StatusChip } from './StatusChip'; +import Typography from '@material-ui/core/Typography'; +import { DateTime as dt, Interval } from 'luxon'; +import humanizeDuration from 'humanize-duration'; +import { EscalationPolicyLink } from '../EscalationPolicy/EscalationPolicyLink'; +import { UptimeMonitorCheckType } from './UptimeMonitorCheckType'; +import { UptimeMonitorActionsMenu } from '../UptimeMonitor/UptimeMonitorActionsMenu'; +import { UptimeMonitorLink } from '../UptimeMonitor/UptimeMonitorLink'; + +const useStyles = makeStyles(theme => ({ + empty: { + padding: theme.spacing(2), + display: 'flex', + justifyContent: 'center', + }, +})); + +export const UptimeMonitorsTable = ({ + uptimeMonitors, + tableState, + isLoading, + onChangePage, + onChangeRowsPerPage, + onUptimeMonitorChanged, +}: { + uptimeMonitors: UptimeMonitor[]; + tableState: TableState; + isLoading: boolean; + onChangePage: (page: number) => void; + onChangeRowsPerPage: (pageSize: number) => void; + onUptimeMonitorChanged: (uptimeMonitor: UptimeMonitor) => void; +}) => { + const classes = useStyles(); + + const smColumnStyle = { + width: '5%', + maxWidth: '5%', + }; + const mdColumnStyle = { + width: '10%', + maxWidth: '10%', + }; + const lgColumnStyle = { + width: '15%', + maxWidth: '15%', + }; + + const columns: TableColumn[] = [ + { + title: 'ID', + field: 'id', + highlight: true, + cellStyle: mdColumnStyle, + headerStyle: mdColumnStyle, + render: rowData => ( + + ), + }, + { + title: 'Name', + field: 'name', + render: rowData => ( + {(rowData as UptimeMonitor).name} + ), + }, + { + title: 'Check Type', + field: 'checkType', + cellStyle: lgColumnStyle, + headerStyle: lgColumnStyle, + render: rowData => ( + + ), + }, + { + title: 'Last state change', + field: 'lastStatusChange', + type: 'datetime', + cellStyle: mdColumnStyle, + headerStyle: mdColumnStyle, + render: rowData => ( + + {humanizeDuration( + Interval.fromDateTimes( + dt.fromISO((rowData as UptimeMonitor).lastStatusChange), + dt.now(), + ) + .toDuration() + .valueOf(), + { units: ['h', 'm', 's'], largest: 2, round: true }, + )} + + ), + }, + { + title: 'Escalation policy', + field: 'assignedTo', + cellStyle: lgColumnStyle, + headerStyle: lgColumnStyle, + render: rowData => ( + + ), + }, + { + title: 'Status', + field: 'status', + cellStyle: smColumnStyle, + headerStyle: smColumnStyle, + render: rowData => ( + + ), + }, + { + title: '', + field: '', + cellStyle: smColumnStyle, + headerStyle: smColumnStyle, + render: rowData => ( + + ), + }, + ]; + + return ( +
+ No uptime monitor + + } + page={tableState.page} + onChangePage={onChangePage} + onChangeRowsPerPage={onChangeRowsPerPage} + localization={{ header: { actions: undefined } }} + isLoading={isLoading} + columns={columns} + data={uptimeMonitors} + /> + ); +}; diff --git a/plugins/ilert/src/components/UptimeMonitorsPage/index.ts b/plugins/ilert/src/components/UptimeMonitorsPage/index.ts new file mode 100644 index 0000000000..d1a8809a4e --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitorsPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 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 * from './UptimeMonitorsPage'; +export * from './UptimeMonitorsTable'; diff --git a/plugins/ilert/src/components/index.ts b/plugins/ilert/src/components/index.ts new file mode 100644 index 0000000000..81f7e02e23 --- /dev/null +++ b/plugins/ilert/src/components/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021 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 * from './ILertPage'; +export * from './ILertCard'; diff --git a/plugins/ilert/src/constants.ts b/plugins/ilert/src/constants.ts new file mode 100644 index 0000000000..bba644785f --- /dev/null +++ b/plugins/ilert/src/constants.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 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 const ILERT_INTEGRATION_KEY_ANNOTATION = 'ilert.com/integration-key'; diff --git a/plugins/ilert/src/hooks/index.ts b/plugins/ilert/src/hooks/index.ts new file mode 100644 index 0000000000..6e7e207112 --- /dev/null +++ b/plugins/ilert/src/hooks/index.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2021 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 { useEntity } from '@backstage/plugin-catalog-react'; + +import { ILERT_INTEGRATION_KEY_ANNOTATION } from '../constants'; + +export function useILertEntity() { + const { entity } = useEntity(); + const integrationKey = + entity.metadata.annotations?.[ILERT_INTEGRATION_KEY_ANNOTATION] || ''; + const name = entity.metadata.name; + const identifier = `${entity.kind}:${ + entity.metadata.namespace || 'default' + }/${entity.metadata.name}`; + + return { integrationKey, name, identifier }; +} diff --git a/plugins/ilert/src/hooks/useAlertSource.ts b/plugins/ilert/src/hooks/useAlertSource.ts new file mode 100644 index 0000000000..15cc213b48 --- /dev/null +++ b/plugins/ilert/src/hooks/useAlertSource.ts @@ -0,0 +1,104 @@ +/* + * Copyright 2021 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 { ilertApiRef } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { useAsyncRetry } from 'react-use'; +import { AlertSource, UptimeMonitor } from '../types'; + +export const useAlertSource = (integrationKey: string) => { + const ilertApi = useApi(ilertApiRef); + const errorApi = useApi(errorApiRef); + + const [alertSource, setAlertSource] = React.useState( + null, + ); + const [isAlertSourceLoading, setIsAlertSourceLoading] = React.useState(false); + const [ + uptimeMonitor, + setUptimeMonitor, + ] = React.useState(null); + const [isUptimeMonitorLoading, setIsUptimeMonitorLoading] = React.useState( + false, + ); + + const fetchAlertSourceCall = async () => { + try { + if (!integrationKey) { + return; + } + setIsAlertSourceLoading(true); + const data = await ilertApi.fetchAlertSource(integrationKey); + setAlertSource(data || null); + setIsAlertSourceLoading(false); + } catch (e) { + setIsAlertSourceLoading(false); + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }; + + const { + error: alertSourceError, + retry: alertSourceRetry, + } = useAsyncRetry(fetchAlertSourceCall, [integrationKey]); + + const fetchUptimeMonitorCall = async () => { + try { + if (!alertSource || alertSource.integrationType !== 'MONITOR') { + return; + } + setIsUptimeMonitorLoading(true); + const data = await ilertApi.fetchUptimeMonitor(alertSource.id); + setUptimeMonitor(data || null); + setIsUptimeMonitorLoading(false); + } catch (e) { + setIsUptimeMonitorLoading(false); + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }; + + const { + error: uptimeMonitorError, + retry: uptimeMonitorRetry, + } = useAsyncRetry(fetchUptimeMonitorCall, [alertSource]); + + const retry = () => { + alertSourceRetry(); + uptimeMonitorRetry(); + }; + + return [ + { + alertSource, + uptimeMonitor, + error: alertSourceError || uptimeMonitorError, + isLoading: isAlertSourceLoading || isUptimeMonitorLoading, + }, + { + retry, + setIsLoading: setIsAlertSourceLoading, + refetchAlertSource: fetchAlertSourceCall, + setAlertSource, + }, + ] as const; +}; diff --git a/plugins/ilert/src/hooks/useAlertSourceOnCalls.ts b/plugins/ilert/src/hooks/useAlertSourceOnCalls.ts new file mode 100644 index 0000000000..0fbb190a02 --- /dev/null +++ b/plugins/ilert/src/hooks/useAlertSourceOnCalls.ts @@ -0,0 +1,64 @@ +/* + * Copyright 2021 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 { ilertApiRef } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { useAsyncRetry } from 'react-use'; +import { AlertSource, OnCall } from '../types'; + +export const useAlertSourceOnCalls = (alertSource?: AlertSource | null) => { + const ilertApi = useApi(ilertApiRef); + const errorApi = useApi(errorApiRef); + + const [onCallsList, setOnCallsList] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(false); + + const fetchAlertSourceOnCallsCall = async () => { + try { + if (!alertSource) { + return; + } + setIsLoading(true); + const data = await ilertApi.fetchAlertSourceOnCalls(alertSource); + setOnCallsList(data || []); + setIsLoading(false); + } catch (e) { + setIsLoading(false); + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }; + + const { error, retry } = useAsyncRetry(fetchAlertSourceOnCallsCall, [ + alertSource, + ]); + + return [ + { + onCalls: onCallsList, + error, + isLoading, + }, + { + retry, + setIsLoading, + refetchAlertSourceOnCalls: fetchAlertSourceOnCallsCall, + }, + ] as const; +}; diff --git a/plugins/ilert/src/hooks/useAssignIncident.ts b/plugins/ilert/src/hooks/useAssignIncident.ts new file mode 100644 index 0000000000..8f0f663d2b --- /dev/null +++ b/plugins/ilert/src/hooks/useAssignIncident.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2021 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 { ilertApiRef } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { useAsyncRetry } from 'react-use'; +import { Incident, IncidentResponder } from '../types'; + +export const useAssignIncident = (incident: Incident | null, open: boolean) => { + const ilertApi = useApi(ilertApiRef); + const errorApi = useApi(errorApiRef); + + const [incidentRespondersList, setIncidentRespondersList] = React.useState< + IncidentResponder[] + >([]); + const [ + incidentResponder, + setIncidentResponder, + ] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + + const { error, retry } = useAsyncRetry(async () => { + try { + if (!incident || !open) { + return; + } + const data = await ilertApi.fetchIncidentResponders(incident); + if (data && Array.isArray(data)) { + const groups = [ + 'SUGGESTED', + 'USER', + 'ESCALATION_POLICY', + 'ON_CALL_SCHEDULE', + ]; + data.sort((a, b) => groups.indexOf(a.group) - groups.indexOf(b.group)); + setIncidentRespondersList(data); + } + } catch (e) { + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }, [incident, open]); + + return [ + { + incidentRespondersList, + incidentResponder, + error, + isLoading, + }, + { + setIncidentRespondersList, + setIncidentResponder, + setIsLoading, + retry, + }, + ] as const; +}; diff --git a/plugins/ilert/src/hooks/useIncidentActions.ts b/plugins/ilert/src/hooks/useIncidentActions.ts new file mode 100644 index 0000000000..e23341ed9e --- /dev/null +++ b/plugins/ilert/src/hooks/useIncidentActions.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2021 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 { ilertApiRef } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { useAsyncRetry } from 'react-use'; +import { Incident, IncidentAction } from '../types'; + +export const useIncidentActions = ( + incident: Incident | null, + open: boolean, +) => { + const ilertApi = useApi(ilertApiRef); + const errorApi = useApi(errorApiRef); + + const [incidentActionsList, setIncidentActionsList] = React.useState< + IncidentAction[] + >([]); + const [isLoading, setIsLoading] = React.useState(false); + + const { error, retry } = useAsyncRetry(async () => { + try { + if (!incident || !open) { + return; + } + const data = await ilertApi.fetchIncidentActions(incident); + setIncidentActionsList(data); + } catch (e) { + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }, [incident, open]); + + return [ + { + incidentActions: incidentActionsList, + error, + isLoading, + }, + { + setIncidentActionsList, + setIsLoading, + retry, + }, + ] as const; +}; diff --git a/plugins/ilert/src/hooks/useIncidents.ts b/plugins/ilert/src/hooks/useIncidents.ts new file mode 100644 index 0000000000..5f130fb20c --- /dev/null +++ b/plugins/ilert/src/hooks/useIncidents.ts @@ -0,0 +1,159 @@ +/* + * Copyright 2021 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 { GetIncidentsOpts, ilertApiRef, TableState } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { useAsyncRetry } from 'react-use'; +import { + ACCEPTED, + PENDING, + Incident, + IncidentStatus, + AlertSource, +} from '../types'; + +export const useIncidents = ( + paging: boolean, + singleSource?: boolean, + alertSource?: AlertSource | null, +) => { + const ilertApi = useApi(ilertApiRef); + const errorApi = useApi(errorApiRef); + + const [tableState, setTableState] = React.useState({ + page: 0, + pageSize: 10, + }); + const [states, setStates] = React.useState([ + ACCEPTED, + PENDING, + ]); + const [incidentsList, setIncidentsList] = React.useState([]); + const [incidentsCount, setIncidentsCount] = React.useState(0); + const [isLoading, setIsLoading] = React.useState(false); + + const fetchIncidentsCall = async () => { + try { + if (singleSource && !alertSource) { + return; + } + setIsLoading(true); + const opts: GetIncidentsOpts = { + states, + alertSources: alertSource ? [alertSource.id] : [], + }; + if (paging) { + opts.maxResults = tableState.pageSize; + opts.startIndex = tableState.page * tableState.pageSize; + } + const data = await ilertApi.fetchIncidents(opts); + setIncidentsList(data || []); + setIsLoading(false); + } catch (e) { + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + setIsLoading(false); + throw e; + } + }; + + const fetchIncidentsCountCall = async () => { + try { + const count = await ilertApi.fetchIncidentsCount({ states }); + setIncidentsCount(count || 0); + } catch (e) { + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }; + const fetchIncidents = useAsyncRetry(fetchIncidentsCall, [ + tableState, + states, + singleSource, + alertSource, + ]); + + const refetchIncidents = () => { + setTableState({ ...tableState, page: 0 }); + Promise.all([fetchIncidentsCall(), fetchIncidentsCountCall()]); + }; + + const fetchIncidentsCount = useAsyncRetry(fetchIncidentsCountCall, [states]); + + const error = fetchIncidents.error || fetchIncidentsCount.error; + const retry = () => { + fetchIncidents.retry(); + fetchIncidentsCount.retry(); + }; + + const onIncidentChanged = (newIncident: Incident) => { + let shouldRefetchIncidents = false; + setIncidentsList( + incidentsList.reduce((acc: Incident[], incident: Incident) => { + if (newIncident.id === incident.id) { + if (states.includes(newIncident.status)) { + acc.push(newIncident); + } else { + shouldRefetchIncidents = true; + } + return acc; + } + acc.push(incident); + return acc; + }, []), + ); + if (shouldRefetchIncidents) { + refetchIncidents(); + } + }; + + const onChangePage = (page: number) => { + setTableState({ ...tableState, page }); + }; + const onChangeRowsPerPage = (p: number) => { + setTableState({ ...tableState, pageSize: p }); + }; + const onIncidentStatesChange = (s: IncidentStatus[]) => { + setStates(s); + }; + + return [ + { + tableState, + states, + incidents: incidentsList, + incidentsCount, + error, + isLoading, + }, + { + setTableState, + setStates, + setIncidentsList, + setIsLoading, + retry, + onIncidentChanged, + refetchIncidents, + onChangePage, + onChangeRowsPerPage, + onIncidentStatesChange, + }, + ] as const; +}; diff --git a/plugins/ilert/src/hooks/useNewIncident.ts b/plugins/ilert/src/hooks/useNewIncident.ts new file mode 100644 index 0000000000..6fbcfbb5a5 --- /dev/null +++ b/plugins/ilert/src/hooks/useNewIncident.ts @@ -0,0 +1,78 @@ +/* + * Copyright 2021 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 { ilertApiRef } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { useAsyncRetry } from 'react-use'; +import { AlertSource } from '../types'; + +export const useNewIncident = ( + open: boolean, + initialAlertSource?: AlertSource | null, +) => { + const ilertApi = useApi(ilertApiRef); + const errorApi = useApi(errorApiRef); + + const [alertSourcesList, setAlertSourcesList] = React.useState( + [], + ); + const [alertSource, setAlertSource] = React.useState( + null, + ); + const [summary, setSummary] = React.useState(''); + const [details, setDetails] = React.useState(''); + const [isLoading, setIsLoading] = React.useState(false); + + const fetchAlertSources = useAsyncRetry(async () => { + try { + if (!open || initialAlertSource) { + return; + } + const count = await ilertApi.fetchAlertSources(); + setAlertSourcesList(count || 0); + } catch (e) { + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }, [open]); + + const error = fetchAlertSources.error; + const retry = () => { + fetchAlertSources.retry(); + }; + + return [ + { + alertSources: alertSourcesList, + alertSource: initialAlertSource ? initialAlertSource : alertSource, + summary, + details, + error, + isLoading, + }, + { + setAlertSourcesList, + setAlertSource, + setSummary, + setDetails, + setIsLoading, + retry, + }, + ] as const; +}; diff --git a/plugins/ilert/src/hooks/useOnCallSchedules.ts b/plugins/ilert/src/hooks/useOnCallSchedules.ts new file mode 100644 index 0000000000..ce76a0770c --- /dev/null +++ b/plugins/ilert/src/hooks/useOnCallSchedules.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2021 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 { ilertApiRef } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { useAsyncRetry } from 'react-use'; +import { Schedule } from '../types'; + +export const useOnCallSchedules = () => { + const ilertApi = useApi(ilertApiRef); + const errorApi = useApi(errorApiRef); + + const [onCallSchedulesList, setOnCallSchedulesList] = React.useState< + Schedule[] + >([]); + const [isLoading, setIsLoading] = React.useState(false); + + const fetchOnCallSchedulesCall = async () => { + try { + setIsLoading(true); + const data = await ilertApi.fetchOnCallSchedules(); + setOnCallSchedulesList(data || []); + setIsLoading(false); + } catch (e) { + setIsLoading(false); + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }; + + const { error, retry } = useAsyncRetry(fetchOnCallSchedulesCall, []); + + return [ + { + onCallSchedules: onCallSchedulesList, + error, + isLoading, + }, + { + retry, + setIsLoading, + refetchOnCallSchedules: fetchOnCallSchedulesCall, + }, + ] as const; +}; diff --git a/plugins/ilert/src/hooks/useShiftOverride.ts b/plugins/ilert/src/hooks/useShiftOverride.ts new file mode 100644 index 0000000000..136d20d286 --- /dev/null +++ b/plugins/ilert/src/hooks/useShiftOverride.ts @@ -0,0 +1,79 @@ +/* + * Copyright 2021 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 { ilertApiRef } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { useAsyncRetry } from 'react-use'; +import { User, Shift } from '../types'; + +export const useShiftOverride = (s: Shift, isModalOpened: boolean) => { + const ilertApi = useApi(ilertApiRef); + const errorApi = useApi(errorApiRef); + + const [shift, setShift] = React.useState(s); + const [usersList, setUsersList] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(false); + + const { error, retry } = useAsyncRetry(async () => { + try { + if (!isModalOpened) { + return; + } + setIsLoading(true); + const data = await ilertApi.fetchUsers(); + setUsersList(data || []); + setIsLoading(false); + } catch (e) { + setIsLoading(false); + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }, [isModalOpened]); + + const setUser = (user: User) => { + setShift({ ...shift, user }); + }; + + const setStart = (start: string) => { + setShift({ ...shift, start }); + }; + + const setEnd = (end: string) => { + setShift({ ...shift, end }); + }; + + return [ + { + shift, + users: usersList, + user: shift.user, + start: shift.start, + end: shift.end, + error, + isLoading, + }, + { + retry, + setIsLoading, + setUser, + setStart, + setEnd, + }, + ] as const; +}; diff --git a/plugins/ilert/src/hooks/useUptimeMonitors.ts b/plugins/ilert/src/hooks/useUptimeMonitors.ts new file mode 100644 index 0000000000..fa1576642a --- /dev/null +++ b/plugins/ilert/src/hooks/useUptimeMonitors.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2021 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 { ilertApiRef, TableState } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { useAsyncRetry } from 'react-use'; +import { UptimeMonitor } from '../types'; + +export const useUptimeMonitors = () => { + const ilertApi = useApi(ilertApiRef); + const errorApi = useApi(errorApiRef); + + const [tableState, setTableState] = React.useState({ + page: 0, + pageSize: 10, + }); + const [uptimeMonitorsList, setUptimeMonitorsList] = React.useState< + UptimeMonitor[] + >([]); + const [isLoading, setIsLoading] = React.useState(false); + + const { error, retry } = useAsyncRetry(async () => { + try { + setIsLoading(true); + const data = await ilertApi.fetchUptimeMonitors(); + setUptimeMonitorsList(data || []); + setIsLoading(false); + } catch (e) { + setIsLoading(false); + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }, [tableState]); + + const onUptimeMonitorChanged = (newUptimeMonitor: UptimeMonitor) => { + setUptimeMonitorsList( + uptimeMonitorsList.map( + (uptimeMonitor: UptimeMonitor): UptimeMonitor => { + if (newUptimeMonitor.id === uptimeMonitor.id) { + return newUptimeMonitor; + } + + return uptimeMonitor; + }, + ), + ); + }; + + const onChangePage = (page: number) => { + setTableState({ ...tableState, page }); + }; + const onChangeRowsPerPage = (pageSize: number) => { + setTableState({ ...tableState, pageSize }); + }; + + return [ + { + tableState, + uptimeMonitors: uptimeMonitorsList, + error, + isLoading, + }, + { + setTableState, + setUptimeMonitorsList, + retry, + onUptimeMonitorChanged, + onChangePage, + onChangeRowsPerPage, + setIsLoading, + }, + ] as const; +}; diff --git a/plugins/ilert/src/index.ts b/plugins/ilert/src/index.ts new file mode 100644 index 0000000000..2e5301f151 --- /dev/null +++ b/plugins/ilert/src/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2021 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 { IconComponent } from '@backstage/core'; +import ILertIconComponent from './assets/ilert.icon.svg'; + +export { + ilertPlugin, + ilertPlugin as plugin, + ILertPage, + EntityILertCard, +} from './plugin'; +export { + ILertPage as Router, + isPluginApplicableToEntity, + isPluginApplicableToEntity as isILertAvailable, + ILertCard, +} from './components'; +export * from './api'; +export * from './route-refs'; +export const ILertIcon: IconComponent = ILertIconComponent; diff --git a/plugins/ilert/src/plugin.test.ts b/plugins/ilert/src/plugin.test.ts new file mode 100644 index 0000000000..89a0cb231f --- /dev/null +++ b/plugins/ilert/src/plugin.test.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2021 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 { ilertPlugin } from './plugin'; + +describe('plugin-ilert', () => { + it('should export plugin', () => { + expect(ilertPlugin).toBeDefined(); + }); +}); diff --git a/plugins/ilert/src/plugin.ts b/plugins/ilert/src/plugin.ts new file mode 100644 index 0000000000..546b77911d --- /dev/null +++ b/plugins/ilert/src/plugin.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2021 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 { + configApiRef, + createApiFactory, + createPlugin, + discoveryApiRef, + identityApiRef, + createRoutableExtension, + createComponentExtension, +} from '@backstage/core'; +import { ILertClient, ilertApiRef } from './api'; +import { iLertRouteRef } from './route-refs'; + +export const ilertPlugin = createPlugin({ + id: 'ilert', + apis: [ + createApiFactory({ + api: ilertApiRef, + deps: { + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, configApi }) => + ILertClient.fromConfig(configApi, discoveryApi), + }), + ], + routes: { + root: iLertRouteRef, + }, +}); + +export const ILertPage = ilertPlugin.provide( + createRoutableExtension({ + component: () => import('./components').then(m => m.ILertPage), + mountPoint: iLertRouteRef, + }), +); + +export const EntityILertCard = ilertPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components/ILertCard').then(m => m.ILertCard), + }, + }), +); diff --git a/plugins/ilert/src/route-refs.tsx b/plugins/ilert/src/route-refs.tsx new file mode 100644 index 0000000000..b63ba15315 --- /dev/null +++ b/plugins/ilert/src/route-refs.tsx @@ -0,0 +1,24 @@ +/* + * Copyright 2021 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 { createRouteRef } from '@backstage/core'; +import ILertIcon from './assets/ilert.icon.svg'; + +export const iLertRouteRef = createRouteRef({ + icon: ILertIcon, + path: '/ilert', + title: 'iLert', +}); diff --git a/plugins/ilert/src/setupTests.ts b/plugins/ilert/src/setupTests.ts new file mode 100644 index 0000000000..0cec5b395d --- /dev/null +++ b/plugins/ilert/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 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'; +import 'cross-fetch/polyfill'; diff --git a/plugins/ilert/src/types.ts b/plugins/ilert/src/types.ts new file mode 100644 index 0000000000..d7746c9d9b --- /dev/null +++ b/plugins/ilert/src/types.ts @@ -0,0 +1,337 @@ +/* + * Copyright 2021 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 interface Incident { + id: number; + summary: string; + details: string; + reportTime: string; + resolvedOn: string; + status: IncidentStatus; + priority: IncidentPriority; + incidentKey: string; + alertSource: AlertSource | null; + assignedTo: User | null; + logEntries: LogEntry[]; + links: Link[]; + images: Image[]; + subscribers: Subscriber[]; + commentText: string; + commentPublishToSubscribers: boolean; +} + +export const PENDING = 'PENDING'; +export const ACCEPTED = 'ACCEPTED'; +export const RESOLVED = 'RESOLVED'; +export type IncidentStatus = typeof PENDING | typeof ACCEPTED | typeof RESOLVED; +export type IncidentPriority = 'HIGH' | 'LOW'; + +export interface Link { + href: string; + text: string; +} + +export interface Image { + src: string; + href: string; + alt: string; +} + +export type SubscriberType = 'TEAM' | 'USER'; + +export interface Subscriber { + id: number; + name: string; + type: SubscriberType; +} + +export interface LogEntry { + id: number; + timestamp: string; + logEntryType: string; + text: string; + incidentId?: number; + iconName?: string; + iconClass?: string; + filterTypes?: string[]; +} + +export interface User { + id: number; + username: string; + firstName: string; + lastName: string; + email: string; + mobile: Phone; + landline: Phone; + timezone?: string; + language?: Language; + role?: UserRole; + notificationPreferences?: any[]; + position: string; + department: string; +} + +export type UserRole = + | 'USER' + | 'ADMIN' + | 'STAKEHOLDER' + | 'ACCOUNT_OWNER' + | 'RESPONDER'; +export type Language = 'de' | 'en'; +export interface Phone { + regionCode: string; + number: string; +} + +export interface AlertSource { + id: number; + name: string; + status: AlertSourceStatus; + escalationPolicy: EscalationPolicy; + integrationType: AlertSourceIntegrationType; + integrationKey?: string; + iconUrl?: string; + lightIconUrl?: string; + darkIconUrl?: string; + incidentCreation?: AlertSourceIncidentCreation; + incidentPriorityRule?: AlertSourceIncidentPriorityRule; + emailFiltered?: boolean; + emailResolveFiltered?: boolean; + active?: boolean; + emailPredicates?: AlertSourceEmailPredicate[]; + emailResolvePredicates?: AlertSourceEmailPredicate[]; + filterOperator?: AlertSourceFilterOperator; + resolveFilterOperator?: AlertSourceFilterOperator; + supportHours?: AlertSourceSupportHours; + heartbeat?: AlertSourceHeartbeat; + autotaskMetadata?: AlertSourceAutotaskMetadata; + autoResolutionTimeout?: string; + teams: TeamShort[]; +} + +export interface TeamShort { + id: number; + name: string; +} + +export interface TeamMember { + user: User; + role: 'STAKEHOLDER' | 'RESPONDER' | 'USER' | 'ADMIN'; +} + +export type AlertSourceStatus = + | 'PENDING' + | 'ALL_ACCEPTED' + | 'ALL_RESOLVED' + | 'IN_MAINTENANCE' + | 'DISABLED'; +export type AlertSourceIntegrationType = + | 'NAGIOS' + | 'ICINGA' + | 'EMAIL' + | 'SMS' + | 'API' + | 'CRN' + | 'HEARTBEAT' + | 'PRTG' + | 'PINGDOM' + | 'CLOUDWATCH' + | 'AWSPHD' + | 'STACKDRIVER' + | 'INSTANA' + | 'ZABBIX' + | 'SOLARWINDS' + | 'PROMETHEUS' + | 'NEWRELIC' + | 'GRAFANA' + | 'GITHUB' + | 'DATADOG' + | 'UPTIMEROBOT' + | 'APPDYNAMICS' + | 'DYNATRACE' + | 'TOPDESK' + | 'STATUSCAKE' + | 'MONITOR' + | 'TOOL' + | 'CHECKMK' + | 'AUTOTASK' + | 'AWSBUDGET' + | 'KENTIXAM' + | 'CONSUL' + | 'ZAMMAD' + | 'SIGNALFX' + | 'SPLUNK' + | 'KUBERNETES' + | 'SEMATEXT' + | 'SENTRY' + | 'SUMOLOGIC' + | 'RAYGUN' + | 'MXTOOLBOX' + | 'ESWATCHER' + | 'AMAZONSNS' + | 'KAPACITOR' + | 'CORTEXXSOAR' + | string; +export type AlertSourceIncidentCreation = + | 'ONE_INCIDENT_PER_EMAIL' + | 'ONE_INCIDENT_PER_EMAIL_SUBJECT' + | 'ONE_PENDING_INCIDENT_ALLOWED' + | 'ONE_OPEN_INCIDENT_ALLOWED' + | 'OPEN_RESOLVE_ON_EXTRACTION'; +export type AlertSourceFilterOperator = 'AND' | 'OR'; +export type AlertSourceIncidentPriorityRule = + | 'HIGH' + | 'LOW' + | 'HIGH_DURING_SUPPORT_HOURS' + | 'LOW_DURING_SUPPORT_HOURS'; +export interface AlertSourceEmailPredicate { + field: 'EMAIL_FROM' | 'EMAIL_SUBJECT' | 'EMAIL_BODY'; + criteria: + | 'CONTAINS_ANY_WORDS' + | 'CONTAINS_NOT_WORDS' + | 'CONTAINS_STRING' + | 'CONTAINS_NOT_STRING' + | 'IS_STRING' + | 'IS_NOT_STRING' + | 'MATCHES_REGEX' + | 'MATCHES_NOT_REGEX'; + value: string; +} +export type AlertSourceTimeZone = + | 'Europe/Berlin' + | 'America/New_York' + | 'America/Los_Angeles' + | 'Asia/Istanbul'; +export interface AlertSourceSupportDay { + start: string; + end: string; +} +export interface AlertSourceSupportHours { + timezone: AlertSourceTimeZone; + autoRaiseIncidents: boolean; + supportDays: { + MONDAY: AlertSourceSupportDay; + TUESDAY: AlertSourceSupportDay; + WEDNESDAY: AlertSourceSupportDay; + THURSDAY: AlertSourceSupportDay; + FRIDAY: AlertSourceSupportDay; + SATURDAY: AlertSourceSupportDay; + SUNDAY: AlertSourceSupportDay; + }; +} +export interface AlertSourceHeartbeat { + summary: string; + intervalSec: number; + status: 'OVERDUE' | 'ON_TIME' | 'NEVER_RECEIVED'; +} + +export interface AlertSourceAutotaskMetadata { + userName: string; + secret: string; + apiIntegrationCode: string; + webServer: string; +} + +export interface EscalationPolicy { + id: number; + name: string; + escalationRules: EscalationRule[]; + newEscalationRule: EscalationRule; + repeating?: boolean; + frequency?: number; + teams: TeamShort[]; +} + +export interface EscalationRule { + user: User | null; + schedule: Schedule | null; + escalationTimeout: number; +} + +export interface Schedule { + id: number; + name: string; + timezone: string; + startsOn: string; + currentShift: Shift; + nextShift: Shift; + shifts: Shift[]; + overrides: Shift[]; + teams: TeamShort[]; +} + +export interface Shift { + user: User; + start: string; + end: string; +} + +export interface UptimeMonitor { + id: number; + name: string; + region: 'EU' | 'US'; + checkType: 'http' | 'tcp' | 'udp' | 'ping'; + checkParams: UptimeMonitorCheckParams; + intervalSec: number; + timeoutMs: number; + createIncidentAfterFailedChecks: number; + paused: boolean; + embedUrl: string; + shareUrl: string; + status: string; + lastStatusChange: string; + escalationPolicy: EscalationPolicy; + teams: TeamShort[]; +} + +export interface UptimeMonitorCheckParams { + host?: string; + port?: number; + url?: string; +} + +export interface IncidentResponder { + group: 'SUGGESTED' | 'USER' | 'ESCALATION_POLICY' | 'ON_CALL_SCHEDULE'; + id: number; + name: string; + disabled: boolean; +} + +export interface IncidentAction { + name: string; + type: string; + webhookId: string; + extensionId?: string; + history?: IncidentActionHistory[]; +} + +export interface IncidentActionHistory { + id: string; + webhookId: string; + incidentId: number; + actor: User; + success: boolean; +} + +export interface OnCall { + user: User; + escalationPolicy: EscalationPolicy; + schedule?: Schedule; + start: string; + end: string; + escalationLevel: number; +} diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index d14a18ffe7..829d493034 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.9", - "@backstage/core": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", @@ -47,9 +47,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/kafka/package.json b/plugins/kafka/package.json index b6c600a90c..b1664c823e 100644 --- a/plugins/kafka/package.json +++ b/plugins/kafka/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.9", - "@backstage/core": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", @@ -33,9 +33,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^3.4.2", diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 5036e08852..9ab0af3f29 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -32,7 +32,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.9", "@backstage/config": "^0.1.5", - "@backstage/core": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/plugin-kubernetes-common": "^0.1.0", "@backstage/theme": "^0.2.7", @@ -49,9 +49,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^3.4.2", diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 68f4722157..8bb12bf0ce 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.9", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", @@ -46,9 +46,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/newrelic/package.json b/plugins/newrelic/package.json index 7938f19100..5d99b98b40 100644 --- a/plugins/newrelic/package.json +++ b/plugins/newrelic/package.json @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -41,9 +41,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/org/package.json b/plugins/org/package.json index 4f8e37fa99..b07279e287 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.9", - "@backstage/core": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/core-api": "^0.2.18", "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", @@ -35,9 +35,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/pagerduty/package.json b/plugins/pagerduty/package.json index b3fbc37569..e57c53ebf3 100644 --- a/plugins/pagerduty/package.json +++ b/plugins/pagerduty/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.9", - "@backstage/core": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", @@ -46,9 +46,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index d10bdcfc38..08d443fa26 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.9", - "@backstage/core": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", @@ -45,9 +45,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index 37ada55f5f..a8d6380003 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.9", - "@backstage/core": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", @@ -47,9 +47,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^3.3.0", diff --git a/plugins/scaffolder-backend/CHANGELOG.md b/plugins/scaffolder-backend/CHANGELOG.md index cdb8fd734b..db6044310c 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -1,5 +1,31 @@ # @backstage/plugin-scaffolder-backend +## 0.11.3 + +### Patch Changes + +- 021eb366a: Instead of failing, warn when you need to pay for GitHub Pro. + +## 0.11.2 + +### Patch Changes + +- f7f7783a3: Add Owner field in template card and new data distribution + Add spec.owner as optional field into TemplateV1Alpha and TemplateV1Beta Schema + Add relations ownedBy and ownerOf into Template entity + Template documentation updated +- 65e6c4541: Remove circular dependencies +- 81d7b9c6f: Added deprecation warnings for `v1alpha1` templates +- 9962faa2b: Add branch protection for default branches of scaffolded GitHub repositories +- Updated dependencies [f7f7783a3] +- Updated dependencies [c7dad9218] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5001de908] + - @backstage/catalog-model@0.7.10 + - @backstage/backend-common@0.8.1 + - @backstage/integration@0.5.3 + ## 0.11.1 ### Patch Changes diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 2565a9d05a..c135f97ec9 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.11.1", + "version": "0.11.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,12 +29,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.8.0", + "@backstage/backend-common": "^0.8.1", "@backstage/catalog-client": "^0.3.11", - "@backstage/catalog-model": "^0.7.9", + "@backstage/catalog-model": "^0.7.10", "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", - "@backstage/integration": "^0.5.2", + "@backstage/integration": "^0.5.3", "@gitbeaker/core": "^28.0.2", "@gitbeaker/node": "^28.0.2", "@octokit/rest": "^18.5.3", @@ -64,8 +64,8 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/test-utils": "^0.1.12", "@types/fs-extra": "^9.0.1", "@types/mock-fs": "^4.13.0", "@types/supertest": "^2.0.8", diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts index 66dfc1d655..a3befa0dfe 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts @@ -19,7 +19,10 @@ import { ScmIntegrationRegistry, } from '@backstage/integration'; import { Octokit } from '@octokit/rest'; -import { initRepoAndPush } from '../../../stages/publish/helpers'; +import { + enableBranchProtectionOnDefaultRepoBranch, + initRepoAndPush, +} from '../../../stages/publish/helpers'; import { getRepoSourceDirectory, parseRepoUrl } from './util'; import { createTemplateAction } from '../../createTemplateAction'; @@ -171,7 +174,7 @@ export function createPublishGithubAction(options: { description: description, }); - const { data } = await repoCreationPromise; + const { data: newRepo } = await repoCreationPromise; if (access?.startsWith(`${owner}/`)) { const [, team] = access.split('/'); await client.teams.addOrUpdateRepoPermissionsInOrg({ @@ -212,8 +215,8 @@ export function createPublishGithubAction(options: { } } - const remoteUrl = data.clone_url; - const repoContentsUrl = `${data.html_url}/blob/master`; + const remoteUrl = newRepo.clone_url; + const repoContentsUrl = `${newRepo.html_url}/blob/master`; await initRepoAndPush({ dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath), @@ -225,6 +228,19 @@ export function createPublishGithubAction(options: { logger: ctx.logger, }); + try { + await enableBranchProtectionOnDefaultRepoBranch({ + owner, + client, + repoName: newRepo.name, + logger: ctx.logger, + }); + } catch (e) { + throw new Error( + `Failed to add branch protection to '${newRepo.name}', ${e}`, + ); + } + ctx.output('remoteUrl', remoteUrl); ctx.output('repoContentsUrl', repoContentsUrl); }, diff --git a/plugins/scaffolder-backend/src/scaffolder/index.ts b/plugins/scaffolder-backend/src/scaffolder/index.ts index 9de10ca1eb..e4bdc73f49 100644 --- a/plugins/scaffolder-backend/src/scaffolder/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/index.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +export { createLegacyActions } from './stages/legacy'; export * from './stages'; export * from './jobs'; export * from './actions'; diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts index c782037ec8..c1da51c40e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts @@ -60,6 +60,7 @@ describe('JobProcessor', () => { }, }, }, + owner: 'example@email.com', }, }; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts index fc36eb64e5..c314f7c727 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts @@ -55,6 +55,7 @@ describe('Helpers', () => { }, }, }, + owner: 'team-d@example.com', }, }; @@ -103,6 +104,7 @@ describe('Helpers', () => { }, }, }, + owner: 'team-b@example.com', }, }; @@ -151,6 +153,7 @@ describe('Helpers', () => { }, }, }, + owner: 'team-a@example.com', }, }; @@ -198,6 +201,7 @@ describe('Helpers', () => { }, }, }, + owner: 'team-b@example.com', }, }; @@ -243,6 +247,7 @@ describe('Helpers', () => { }, }, }, + owner: 'team-c@example.com', }, }; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/index.ts index ef23af1b3e..dfe49a8aa5 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/index.ts @@ -17,5 +17,3 @@ export * from './prepare'; export * from './publish'; export * from './templater'; export * from './helpers'; - -export { createLegacyActions } from './legacy'; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts index 829856ec0c..d859d97dc3 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts @@ -15,7 +15,10 @@ */ import { PublisherBase, PublisherOptions, PublisherResult } from './types'; -import { initRepoAndPush } from './helpers'; +import { + enableBranchProtectionOnDefaultRepoBranch, + initRepoAndPush, +} from './helpers'; import { GitHubIntegrationConfig, GithubCredentialsProvider, @@ -26,6 +29,7 @@ import path from 'path'; export type RepoVisibilityOptions = 'private' | 'internal' | 'public'; +/** @deprecated use createPublishGithubAction instead */ export class GithubPublisher implements PublisherBase { static async fromConfig( config: GitHubIntegrationConfig, @@ -99,6 +103,18 @@ export class GithubPublisher implements PublisherBase { /\.git$/, '/blob/master/catalog-info.yaml', ); + + try { + await enableBranchProtectionOnDefaultRepoBranch({ + owner, + client, + repoName: name, + logger, + }); + } catch (e) { + throw new Error(`Failed to add branch protection to '${name}', ${e}`); + } + return { remoteUrl, catalogInfoUrl }; } @@ -130,7 +146,7 @@ export class GithubPublisher implements PublisherBase { description, }); - const { data } = await repoCreationPromise; + const { data: newRepo } = await repoCreationPromise; try { if (access?.startsWith(`${owner}/`)) { @@ -156,6 +172,7 @@ export class GithubPublisher implements PublisherBase { `Failed to add access to '${access}'. Status ${e.status} ${e.message}`, ); } - return data?.clone_url; + + return newRepo.clone_url; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts index ffefb88cf0..8531dbcdc8 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts @@ -17,6 +17,7 @@ import globby from 'globby'; import { Logger } from 'winston'; import { Git } from '@backstage/backend-common'; +import { Octokit } from '@octokit/rest'; export async function initRepoAndPush({ dir, @@ -67,3 +68,65 @@ export async function initRepoAndPush({ remote: 'origin', }); } + +type BranchProtectionOptions = { + client: Octokit; + owner: string; + repoName: string; + logger: Logger; +}; + +export const enableBranchProtectionOnDefaultRepoBranch = async ({ + repoName, + client, + owner, + logger, +}: BranchProtectionOptions): Promise => { + const tryOnce = async () => { + try { + await client.repos.updateBranchProtection({ + mediaType: { + /** + * 👇 we need this preview because allowing a custom + * reviewer count on branch protection is a preview + * feature + * + * More here: https://docs.github.com/en/rest/overview/api-previews#require-multiple-approving-reviews + */ + previews: ['luke-cage-preview'], + }, + owner, + repo: repoName, + branch: 'master', + required_status_checks: { strict: true, contexts: [] }, + restrictions: null, + enforce_admins: true, + required_pull_request_reviews: { required_approving_review_count: 1 }, + }); + } catch (e) { + if ( + e.message.includes( + 'Upgrade to GitHub Pro or make this repository public to enable this feature', + ) + ) { + logger.warn( + 'Branch protection was not enabled as it requires GitHub Pro for private repositories', + ); + } else { + throw e; + } + } + }; + + try { + await tryOnce(); + } catch (e) { + if (!e.message.includes('Branch not found')) { + throw e; + } + + // GitHub has eventual consistency. Fail silently, wait, and try again. + await new Promise(resolve => setTimeout(resolve, 600)); + await tryOnce(); + } +}; diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index e8aa76a63a..9ca2eef9e6 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -64,120 +64,6 @@ const mockUrlReader = UrlReaders.default({ config: new ConfigReader({}), }); -describe('createRouter - working directory', () => { - const mockPrepare = jest.fn(); - const mockPreparers = new Preparers(); - - beforeAll(() => { - const mockPreparer = { - prepare: mockPrepare, - }; - mockPreparers.register('dev.azure.com', mockPreparer); - }); - - beforeEach(() => { - jest.resetAllMocks(); - }); - - const workDirConfig = (path: string) => ({ - backend: { - workingDirectory: path, - }, - }); - - const template = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - 'backstage.io/managed-by-location': 'url:https://dev.azure.com', - }, - }, - spec: { - owner: 'template@backstage.io', - path: '.', - schema: {}, - }, - }; - - it('should throw an error when working directory does not exist or is not writable', async () => { - mockAccess.mockImplementation(() => { - throw new Error('access error'); - }); - - await expect( - createRouter({ - logger: getVoidLogger(), - preparers: new Preparers(), - templaters: new Templaters(), - publishers: new Publishers(), - config: new ConfigReader(workDirConfig('/path')), - database: createDatabase(), - catalogClient: createCatalogClient([template]), - reader: mockUrlReader, - }), - ).rejects.toThrow('access error'); - }); - - it('should use the working directory when configured', async () => { - const router = await createRouter({ - logger: getVoidLogger(), - preparers: mockPreparers, - templaters: new Templaters(), - publishers: new Publishers(), - config: new ConfigReader(workDirConfig('/path')), - database: createDatabase(), - catalogClient: createCatalogClient([template]), - reader: mockUrlReader, - }); - - const app = express().use(router); - await request(app) - .post('/v1/jobs') - .send({ - templateName: '', - values: { - storePath: 'https://github.com/backstage/good', - }, - }); - - expect(mockPrepare).toBeCalledWith({ - logger: expect.anything(), - workspacePath: expect.stringContaining('path'), - url: expect.anything(), - }); - }); - - it('should not pass along anything when no working directory is configured', async () => { - const router = await createRouter({ - logger: getVoidLogger(), - preparers: mockPreparers, - templaters: new Templaters(), - publishers: new Publishers(), - config: new ConfigReader({}), - database: createDatabase(), - catalogClient: createCatalogClient([template]), - reader: mockUrlReader, - }); - - const app = express().use(router); - await request(app) - .post('/v1/jobs') - .send({ - templateName: '', - values: { - storePath: 'https://github.com/backstage/goodrepo', - }, - }); - - expect(mockPrepare).toBeCalledWith({ - logger: expect.anything(), - workspacePath: expect.anything(), - url: expect.anything(), - }); - }); -}); - describe('createRouter', () => { let app: express.Express; const template = { @@ -239,21 +125,6 @@ describe('createRouter', () => { jest.resetAllMocks(); }); - describe('POST /v1/jobs', () => { - it('rejects template values which do not match the template schema definition', async () => { - const response = await request(app) - .post('/v1/jobs') - .send({ - templateName: '', - values: { - storePath: 'https://github.com/backstage/backstage', - }, - }); - - expect(response.status).toEqual(400); - }); - }); - describe('GET /v2/actions', () => { it('lists available actions', async () => { const response = await request(app).get('/v2/actions').send(); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 5e0790526e..dc8e69eb46 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -16,22 +16,16 @@ import { Config } from '@backstage/config'; import express from 'express'; -import { resolve as resolvePath, dirname } from 'path'; import Router from 'express-promise-router'; import { Logger } from 'winston'; import { - JobProcessor, PreparerBuilder, TemplaterBuilder, TemplaterValues, PublisherBuilder, - parseLocationAnnotation, - joinGitUrlPath, - FilePreparer, } from '../scaffolder'; import { CatalogEntityClient } from '../lib/catalog'; -import { validate, ValidatorResult } from 'jsonschema'; -import parseGitUrl from 'git-url-parse'; +import { validate } from 'jsonschema'; import { DatabaseTaskStore, StorageTaskBroker, @@ -101,7 +95,6 @@ export async function createRouter( const logger = parentLogger.child({ plugin: 'scaffolder' }); const workingDirectory = await getWorkingDirectory(config, logger); - const jobProcessor = await JobProcessor.fromConfig({ config, logger }); const entityClient = new CatalogEntityClient(catalogClient); const integrations = ScmIntegrations.fromConfig(config); @@ -137,134 +130,6 @@ export async function createRouter( worker.start(); - router - .get('/v1/job/:jobId', ({ params }, res) => { - const job = jobProcessor.get(params.jobId); - - if (!job) { - res.status(404).json({ error: 'job not found' }); - return; - } - - res.json({ - id: job.id, - metadata: { - ...job.context, - logger: undefined, - logStream: undefined, - }, - status: job.status, - stages: job.stages.map(stage => ({ - ...stage, - handler: undefined, - })), - error: job.error, - }); - }) - .post('/v1/jobs', async (req, res) => { - const templateName: string = req.body.templateName; - const values: TemplaterValues = { - ...req.body.values, - destination: { - git: parseGitUrl(req.body.values.storePath), - }, - }; - - // Forward authorization from client - const template = await entityClient.findTemplate(templateName, { - token: getBearerToken(req.headers.authorization), - }); - if (!isAlpha1Template(template)) { - throw new InputError( - `This endpoint does not support templates with version ${template.apiVersion}`, - ); - } - - const validationResult: ValidatorResult = validate( - values, - template.spec.schema, - ); - - if (!validationResult.valid) { - res.status(400).json({ errors: validationResult.errors }); - return; - } - - const job = jobProcessor.create({ - entity: template, - values, - stages: [ - { - name: 'Prepare the skeleton', - async handler(ctx) { - const { - protocol, - location: templateEntityLocation, - } = parseLocationAnnotation(ctx.entity); - - if (protocol === 'file') { - const preparer = new FilePreparer(); - - const path = resolvePath( - dirname(templateEntityLocation), - template.spec.path || '.', - ); - - await preparer.prepare({ - url: `file://${path}`, - logger: ctx.logger, - workspacePath: ctx.workspacePath, - }); - return; - } - - const preparer = preparers.get(templateEntityLocation); - - const url = joinGitUrlPath( - templateEntityLocation, - template.spec.path, - ); - - await preparer.prepare({ - url, - logger: ctx.logger, - workspacePath: ctx.workspacePath, - }); - }, - }, - { - name: 'Run the templater', - async handler(ctx) { - const templater = templaters.get(ctx.entity.spec.templater); - await templater.run({ - workspacePath: ctx.workspacePath, - logStream: ctx.logStream, - values: ctx.values, - }); - }, - }, - { - name: 'Publish template', - handler: async ctx => { - const publisher = publishers.get(ctx.values.storePath); - ctx.logger.info('Will now store the template'); - const result = await publisher.publish({ - values: ctx.values, - workspacePath: ctx.workspacePath, - logger: ctx.logger, - }); - return result; - }, - }, - ], - }); - - jobProcessor.run(job); - - res.status(201).json({ id: job.id }); - }); - - // NOTE: The v2 API is unstable router .get( '/v2/templates/:namespace/:kind/:name/parameter-schema', @@ -359,8 +224,11 @@ export async function createRouter( let taskSpec; if (isAlpha1Template(template)) { - const result = validate(values, template.spec.schema); + logger.warn( + `[DEPRECATION] - Template: ${template.metadata.name} has version ${template.apiVersion} which is going to be deprecated. Please refer to https://backstage.io/docs/features/software-templates/migrating-from-v1alpha1-to-v1beta2 for help on migrating`, + ); + const result = validate(values, template.spec.schema); if (!result.valid) { res.status(400).json({ errors: result.errors }); return; diff --git a/plugins/scaffolder/CHANGELOG.md b/plugins/scaffolder/CHANGELOG.md index 7e55dabc55..7c1118489a 100644 --- a/plugins/scaffolder/CHANGELOG.md +++ b/plugins/scaffolder/CHANGELOG.md @@ -1,5 +1,22 @@ # @backstage/plugin-scaffolder +## 0.9.5 + +### Patch Changes + +- f7f7783a3: Add Owner field in template card and new data distribution + Add spec.owner as optional field into TemplateV1Alpha and TemplateV1Beta Schema + Add relations ownedBy and ownerOf into Template entity + Template documentation updated +- 81d7b9c6f: Added deprecation warnings for `v1alpha1` templates +- Updated dependencies [f7f7783a3] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5da6a561d] + - @backstage/catalog-model@0.7.10 + - @backstage/core@0.7.10 + - @backstage/integration@0.5.3 + ## 0.9.4 ### Patch Changes diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index a29251f705..7203371e35 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder", - "version": "0.9.4", + "version": "0.9.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,11 +31,11 @@ }, "dependencies": { "@backstage/catalog-client": "^0.3.11", - "@backstage/catalog-model": "^0.7.9", + "@backstage/catalog-model": "^0.7.10", "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", - "@backstage/core": "^0.7.9", - "@backstage/integration": "^0.5.2", + "@backstage/core": "^0.7.10", + "@backstage/integration": "^0.5.3", "@backstage/integration-react": "^0.1.2", "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", @@ -60,9 +60,9 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.11", - "@backstage/dev-utils": "^0.1.14", - "@backstage/test-utils": "^0.1.11", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index 4a482402f8..bc1bcefe1e 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -175,7 +175,10 @@ export const ScaffolderPageContents = () => { {matchingEntities && matchingEntities?.length > 0 && matchingEntities.map(template => ( - + ))} diff --git a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx index 8c64d981ad..bb56f7fb6e 100644 --- a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx +++ b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx @@ -22,33 +22,68 @@ import { CardContent, CardMedia, Chip, + Link, makeStyles, + Tooltip, + Typography, useTheme, } from '@material-ui/core'; import React from 'react'; +import WarningIcon from '@material-ui/icons/Warning'; import { generatePath } from 'react-router'; import { rootRouteRef } from '../../routes'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { + TemplateEntityV1alpha1, + Entity, + RELATION_OWNED_BY, +} from '@backstage/catalog-model'; import { FavouriteTemplate } from '../FavouriteTemplate/FavouriteTemplate'; +import { + getEntityRelations, + EntityRefLinks, +} from '@backstage/plugin-catalog-react'; -const useStyles = makeStyles({ +const useStyles = makeStyles(theme => ({ cardHeader: { position: 'relative', }, title: { backgroundImage: ({ backgroundImage }: any) => backgroundImage, }, - description: { + box: { overflow: 'hidden', textOverflow: 'ellipsis', display: '-webkit-box', '-webkit-line-clamp': 10, '-webkit-box-orient': 'vertical', + paddingBottom: '0.8em', }, -}); + label: { + color: theme.palette.text.secondary, + textTransform: 'uppercase', + fontSize: '0.65rem', + fontWeight: 'bold', + letterSpacing: 0.5, + lineHeight: 1, + paddingBottom: '0.2rem', + }, +})); + +const useDeprecationStyles = makeStyles(theme => ({ + deprecationIcon: { + position: 'absolute', + top: theme.spacing(0.5), + right: theme.spacing(3.5), + padding: '0.25rem', + }, + link: { + color: theme.palette.warning.light, + }, +})); export type TemplateCardProps = { template: TemplateEntityV1alpha1; + deprecated?: boolean; }; type TemplateProps = { @@ -72,11 +107,37 @@ const getTemplateCardProps = ( }; }; -export const TemplateCard = ({ template }: TemplateCardProps) => { +const DeprecationWarning = () => { + const styles = useDeprecationStyles(); + + const Title = ( + + This template syntax is deprecated. Click for more info. + + ); + + return ( +
+ + + + + +
+ ); +}; + +export const TemplateCard = ({ template, deprecated }: TemplateCardProps) => { const backstageTheme = useTheme(); const rootLink = useRouteRef(rootRouteRef); const templateProps = getTemplateCardProps(template); - + const ownedByRelations = getEntityRelations( + template as Entity, + RELATION_OWNED_BY, + ); const themeId = pageTheme[templateProps.type] ? templateProps.type : 'other'; const theme = backstageTheme.getPageTheme({ themeId }); const classes = useStyles({ backgroundImage: theme.backgroundImage }); @@ -88,19 +149,34 @@ export const TemplateCard = ({ template }: TemplateCardProps) => { + {deprecated && } - + + + + Description + + {templateProps.description} + + + + Owner + + + + + Tags + {templateProps.tags?.map(tag => ( ))} - {templateProps.description}