diff --git a/.changeset/moody-shrimps-whisper.md b/.changeset/moody-shrimps-whisper.md new file mode 100644 index 0000000000..ea37ed5ef3 --- /dev/null +++ b/.changeset/moody-shrimps-whisper.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Fixes a bug in the catalog where entities were not being marked as orphaned. diff --git a/.changeset/neat-cooks-sell.md b/.changeset/neat-cooks-sell.md new file mode 100644 index 0000000000..11859c3589 --- /dev/null +++ b/.changeset/neat-cooks-sell.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Fix search in Firefox. When the search was performed by pressing enter, the query parameter was first set but then reverted back. diff --git a/.changeset/nine-laws-run.md b/.changeset/nine-laws-run.md new file mode 100644 index 0000000000..742ec4d8b6 --- /dev/null +++ b/.changeset/nine-laws-run.md @@ -0,0 +1,11 @@ +--- +'@backstage/core-components': patch +--- + +Change the Table search field placeholder to "Filter" and change icon accordingly + +We had feedback that users expected the catalog table search field to have more sophisticated behaviour +than simple filtering. This change sets the search field placeholder to read "Filter" +to avoid confusion with the search feature. The icon is updated to match. This change is applied +generally in core-components so this change is made consistently across the app given the search +field is present on all pages via the sidebar. diff --git a/.changeset/old-fishes-drum.md b/.changeset/old-fishes-drum.md new file mode 100644 index 0000000000..cb6bf6c05e --- /dev/null +++ b/.changeset/old-fishes-drum.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-api-docs': patch +--- + +Add actions props to the ApiExplorerPage diff --git a/.changeset/slow-frogs-deliver.md b/.changeset/slow-frogs-deliver.md new file mode 100644 index 0000000000..adb0831311 --- /dev/null +++ b/.changeset/slow-frogs-deliver.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Bump mini-css-extract-plugin to v2 diff --git a/.changeset/stale-seahorses-sell.md b/.changeset/stale-seahorses-sell.md new file mode 100644 index 0000000000..2fa52b109f --- /dev/null +++ b/.changeset/stale-seahorses-sell.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-azure-devops-backend': patch +--- + +Expands the Azure DevOps backend plugin to provide pull request data to be used by the front end plugin diff --git a/.changeset/two-foxes-marry.md b/.changeset/two-foxes-marry.md new file mode 100644 index 0000000000..a6ac7df666 --- /dev/null +++ b/.changeset/two-foxes-marry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-home': patch +--- + +Fix `` to display only the selected tab, not the other way around. diff --git a/.changeset/wicked-clocks-kneel.md b/.changeset/wicked-clocks-kneel.md new file mode 100644 index 0000000000..fc2ee54440 --- /dev/null +++ b/.changeset/wicked-clocks-kneel.md @@ -0,0 +1,37 @@ +--- +'@backstage/plugin-home': patch +--- + +Added HeaderWorldClock to the Home plugin which is a copy of the HomepageTimer from core-components that has been updated to use props over static config from app-config.yaml. To use HeaderWorldClock you'll need to create an array of ClockConfig like this: + +```ts +const clockConfigs: ClockConfig[] = [ + { + label: 'NYC', + timeZone: 'America/New_York', + }, + { + label: 'UTC', + timeZone: 'UTC', + }, + { + label: 'STO', + timeZone: 'Europe/Stockholm', + }, + { + label: 'TYO', + timeZone: 'Asia/Tokyo', + }, +]; +``` + +Then you can pass `clockConfigs` into the HeaderWorldClock like this: + +```ts + +
+ +
+ ... +
+``` diff --git a/.changeset/witty-dolls-walk.md b/.changeset/witty-dolls-walk.md new file mode 100644 index 0000000000..0c6532e725 --- /dev/null +++ b/.changeset/witty-dolls-walk.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Deprecated HomepageTimer in favor of HeaderWorldClock which is found in the [home plugin](https://github.com/backstage/backstage/tree/master/plugins/home) diff --git a/.changeset/young-peas-unite.md b/.changeset/young-peas-unite.md new file mode 100644 index 0000000000..4f22742b24 --- /dev/null +++ b/.changeset/young-peas-unite.md @@ -0,0 +1,25 @@ +--- +'@backstage/plugin-kubernetes-backend': patch +--- + +Enable customization of services used by the kubernetes backend plugin + +The createRouter function has been deprecated in favor of a KubernetesBuilder object. +Here's how you should upgrade your projects when configuring the Kubernetes backend plugin. +in your `packages/backend/src/plugins/kubernetes.ts` file for instance: + +```typescript +import { KubernetesBuilder } from '@backstage/plugin-kubernetes-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + logger, + config, +}: PluginEnvironment) { + const { router } = await KubernetesBuilder.createBuilder({ + logger, + config, + }).build(); + return router; +} +``` diff --git a/ADOPTERS.md b/ADOPTERS.md index c94d83e97e..9c1f1e046b 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -1,58 +1,59 @@ -| 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) | [@sagacity](https://github.com/sagacity) | 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) | [Gamze Senturk](https://github.com/gmzsenturk), [Mert Can Bilgic](https://github.com/mertcb) | 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. | -| [PagerDuty](https://www.pagerduty.com/) | [Mark Shaw](https://github.com/markshawtoronto) | Developer portal, initially focused on software templates and tech-docs. | -| [MoonShiner](https://moonshiner.at) | [Fabian Hippmann](https://github.com/FabianHippmann) | Developer portal - helps us keep track of our customer projects, onboard new developers & improve our development process πŸŒ•πŸš€πŸ§‘β€πŸš€ | -| [FundApps](https://www.fundapps.co/) | [Elliot Greenwood](https://github.com/egnwd) | Developer Portal - A place for us to keep track of our projects and documentation for all services and processes | -| [DAZN](https://dazn.com/) | [Lou Bichard](https://twitter.com/loujaybee), [Marco Crivellaro](https://github.com/crivetechie), [Alex Hollerith](mailto:alex.hollerith@dazn.com) | Ingesting all of DAZN's repos for the catalog, migrating our internal platform apps (pull request boards, release information, inner source marketplace etc) to Backstage plugins (where applicable). | -| [HelloFresh](https://www.hellofresh.de/) | [@iammuho](https://github.com/iammuho), [@ElenaForester](https://github.com/ElenaForester), [@diegomarangoni](https://github.com/diegomarangoni) | Our developer portal at HelloFresh - Spread across an organisation of 500+ engineers globally. | -| [FactSet](https://www.factset.com/) | [@kuangp](https://github.com/kuangp) | Developer portal to provide discoverability to all internal components, APIs, documentation, and scaffold templates with integrations to our internal infrastructure tools. | -| [Workrise](https://www.workrise.com/) | [Michael Rode](https://github.com/michaelrode) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | -| [RedVentures](https://www.redventures.com/) | [Chris Diaz](https://github.com/codingdiaz) | Developer portal that brings everything an engineer needs to provide value into a single pane of glass. | -| [MavTek](https://www.mavtek.com/) | [@fgascon](https://github.com/fgascon) | Developer portal focused on standardizing practices, centralizing documentation and streamlining developer practices. | -| [QuintoAndar](https://www.quintoandar.com.br/) | [@quintoandar](https://github.com/quintoandar) | Developer portal, services catalog and centralization of service metrics. | -| [empathy.co](https://empathy.co/) | [@guillermotti](https://github.com/guillermotti) | Developer portal for tech docs, service catalog, plugin discovery and much more. | -| [creditas.com](https://creditas.com/) | [@aureliosaraiva](https://github.com/aureliosaraiva) [@Creditas](https://github.com/creditas) | Centralization of all services, standards, documentation, etc. We started the deployment process. | -| [Prisjakt](https://www.prisjakt.nu) / [PriceSpy](https://pricespy.co.uk) | [@kennylindahl](https://github.com/kennylindahl) | Internal developer portal - Documentation, scaffolding, software catalog, TechRadar, Gitlab org data integration | -| [Powerspike](https://powerspike.tv/) | [@trelore](https://github.com/trelore) | Developer portal for documentation of core libraries and repositories. | -| [2U](https://2u.com) | [Andrew Thal](https://github.com/athal7) | Development team home-base, promoting service discoverability, resource dependencies, and tech radar | -| [Taxfix](https://taxfix.de/) | [Sami Ur Rehman](https://github.com/samiurrehman92) | Developer's portal with software catalog at it's core. Hosts API Specs, Tech Docs, Tech Radar and some custom plugins. | -| [Busuu](https://busuu.com/) | [Adam Tester](https://github.com/adamtester) | Developer portal with service catalog, API docs, Event docs, service templating, and cost insights. | -| [Loadsmart](https://loadsmart.com/) | [Loadsmart](https://github.com/loadsmart) | Improve services visibility and operations for service owners and developers. | -| [Monzo](https://monzo.com/) | [@WillSewell](https://github.com/WillSewell), [@joechrisellis](https://github.com/joechrisellis) | Developer portal showing metadata and docs for over 2000 microservices. We have built a number of plugins such as a UI for our system to measure [software excellence](https://monzo.com/blog/2021/09/15/how-we-measure-software-excellence), and a UI to show deployment and config change events. | -| [Vaimo](https://www.vaimo.com) | [@vaimo-magnus](https://github.com/vaimo-magnus) | Developer Portal for our developers at Vaimo, currently docs and self-service towards our internal PaaS based on k8s. Plans to extend the catalog into Projects, Environments etc | -| [Wayfair](https://www.wayfair.com) | [@fransan](https://github.com/fransan), [@errskipower](https://github.com/errskipower), [@hrrs](https://github.com/hrrs) | Developer portal for service catalog, technical documentation, and APIs. | -| [CircleHD](https://www.circlehd.com) | [@circlehddev](https://github.com/circlehddev) | Developer Portal for internal dev team across the globe | -| [CastDesk](https://castdesk.com) | [@circlehddev](https://github.com/circlehddev) | Developer Portal for internal dev team across the globe | -| [Santagostino](https://santagostino.it) | [@santagostino](https://github.com/santagostino) | Developer portal, gateway to our infrastructure, documentation, service catalog and internal tooling. | -| [Peak](https://peak.ai) | [Luke Beamish](https://github.com/lukebeamish-peak) | Developer portal for all internal engineers to access documentation and tooling. | -| [Gelato](https://gelato.com/) | [Dmitry Makarenko](https://github.com/dmitry-makarenko-gelato) | Developer portal: documentation, service templates, org structure, service catalog, plugins for integration with internal and third-party systemsπŸš€. | -| [GoCardless](https://gocardless.com/) | [James Turley](https://github.com/tragiclifestories) | Developer portal: documentation, service templates, org structure, service catalog, plugins for integration with internal systems. | +| 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) | [@sagacity](https://github.com/sagacity) | 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) | [Gamze Senturk](https://github.com/gmzsenturk), [Mert Can Bilgic](https://github.com/mertcb) | 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. | +| [PagerDuty](https://www.pagerduty.com/) | [Mark Shaw](https://github.com/markshawtoronto) | Developer portal, initially focused on software templates and tech-docs. | +| [MoonShiner](https://moonshiner.at) | [Fabian Hippmann](https://github.com/FabianHippmann) | Developer portal - helps us keep track of our customer projects, onboard new developers & improve our development process πŸŒ•πŸš€πŸ§‘β€πŸš€ | +| [FundApps](https://www.fundapps.co/) | [Elliot Greenwood](https://github.com/egnwd) | Developer Portal - A place for us to keep track of our projects and documentation for all services and processes | +| [DAZN](https://dazn.com/) | [Lou Bichard](https://twitter.com/loujaybee), [Marco Crivellaro](https://github.com/crivetechie), [Alex Hollerith](mailto:alex.hollerith@dazn.com) | Ingesting all of DAZN's repos for the catalog, migrating our internal platform apps (pull request boards, release information, inner source marketplace etc) to Backstage plugins (where applicable). | +| [HelloFresh](https://www.hellofresh.de/) | [@iammuho](https://github.com/iammuho), [@ElenaForester](https://github.com/ElenaForester), [@diegomarangoni](https://github.com/diegomarangoni) | Our developer portal at HelloFresh - Spread across an organisation of 500+ engineers globally. | +| [FactSet](https://www.factset.com/) | [@kuangp](https://github.com/kuangp) | Developer portal to provide discoverability to all internal components, APIs, documentation, and scaffold templates with integrations to our internal infrastructure tools. | +| [Workrise](https://www.workrise.com/) | [Michael Rode](https://github.com/michaelrode) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | +| [RedVentures](https://www.redventures.com/) | [Chris Diaz](https://github.com/codingdiaz) | Developer portal that brings everything an engineer needs to provide value into a single pane of glass. | +| [MavTek](https://www.mavtek.com/) | [@fgascon](https://github.com/fgascon) | Developer portal focused on standardizing practices, centralizing documentation and streamlining developer practices. | +| [QuintoAndar](https://www.quintoandar.com.br/) | [@quintoandar](https://github.com/quintoandar) | Developer portal, services catalog and centralization of service metrics. | +| [empathy.co](https://empathy.co/) | [@guillermotti](https://github.com/guillermotti) | Developer portal for tech docs, service catalog, plugin discovery and much more. | +| [creditas.com](https://creditas.com/) | [@aureliosaraiva](https://github.com/aureliosaraiva) [@Creditas](https://github.com/creditas) | Centralization of all services, standards, documentation, etc. We started the deployment process. | +| [Prisjakt](https://www.prisjakt.nu) / [PriceSpy](https://pricespy.co.uk) | [@kennylindahl](https://github.com/kennylindahl) | Internal developer portal - Documentation, scaffolding, software catalog, TechRadar, Gitlab org data integration | +| [Powerspike](https://powerspike.tv/) | [@trelore](https://github.com/trelore) | Developer portal for documentation of core libraries and repositories. | +| [2U](https://2u.com) | [Andrew Thal](https://github.com/athal7) | Development team home-base, promoting service discoverability, resource dependencies, and tech radar | +| [Taxfix](https://taxfix.de/) | [Sami Ur Rehman](https://github.com/samiurrehman92) | Developer's portal with software catalog at it's core. Hosts API Specs, Tech Docs, Tech Radar and some custom plugins. | +| [Busuu](https://busuu.com/) | [Adam Tester](https://github.com/adamtester) | Developer portal with service catalog, API docs, Event docs, service templating, and cost insights. | +| [Loadsmart](https://loadsmart.com/) | [Loadsmart](https://github.com/loadsmart) | Improve services visibility and operations for service owners and developers. | +| [Monzo](https://monzo.com/) | [@WillSewell](https://github.com/WillSewell), [@joechrisellis](https://github.com/joechrisellis) | Developer portal showing metadata and docs for over 2000 microservices. We have built a number of plugins such as a UI for our system to measure [software excellence](https://monzo.com/blog/2021/09/15/how-we-measure-software-excellence), and a UI to show deployment and config change events. | +| [Vaimo](https://www.vaimo.com) | [@vaimo-magnus](https://github.com/vaimo-magnus) | Developer Portal for our developers at Vaimo, currently docs and self-service towards our internal PaaS based on k8s. Plans to extend the catalog into Projects, Environments etc | +| [Wayfair](https://www.wayfair.com) | [@fransan](https://github.com/fransan), [@errskipower](https://github.com/errskipower), [@hrrs](https://github.com/hrrs) | Developer portal for service catalog, technical documentation, and APIs. | +| [CircleHD](https://www.circlehd.com) | [@circlehddev](https://github.com/circlehddev) | Developer Portal for internal dev team across the globe | +| [CastDesk](https://castdesk.com) | [@circlehddev](https://github.com/circlehddev) | Developer Portal for internal dev team across the globe | +| [Santagostino](https://santagostino.it) | [@santagostino](https://github.com/santagostino) | Developer portal, gateway to our infrastructure, documentation, service catalog and internal tooling. | +| [Peak](https://peak.ai) | [Luke Beamish](https://github.com/lukebeamish-peak) | Developer portal for all internal engineers to access documentation and tooling. | +| [Gelato](https://gelato.com/) | [Dmitry Makarenko](https://github.com/dmitry-makarenko-gelato) | Developer portal: documentation, service templates, org structure, service catalog, plugins for integration with internal and third-party systemsπŸš€. | +| [GoCardless](https://gocardless.com/) | [James Turley](https://github.com/tragiclifestories) | Developer portal: documentation, service templates, org structure, service catalog, plugins for integration with internal systems. | +| [Box](https://www.box.com) | [@kielosz](https://github.com/kielosz), [@jluk-box](https://github.com/jluk-box), [@ptychu](https://github.com/ptychu), [@alexrybch](https://github.com/alexrybch), [@szubster](https://github.com/szubster) | Developer portal for service catalog, integration with internal systems, new service onboarding. | diff --git a/docs/features/kubernetes/installation.md b/docs/features/kubernetes/installation.md index 36bb724be6..2e0d9dccb9 100644 --- a/docs/features/kubernetes/installation.md +++ b/docs/features/kubernetes/installation.md @@ -59,14 +59,18 @@ add the following: ```typescript // In packages/backend/src/plugins/kubernetes.ts -import { createRouter } from '@backstage/plugin-kubernetes-backend'; +import { KubernetesBuilder } from '@backstage/plugin-kubernetes-backend'; import { PluginEnvironment } from '../types'; export default async function createPlugin({ logger, config, }: PluginEnvironment) { - return await createRouter({ logger, config }); + const { router } = await KubernetesBuilder.createBuilder({ + logger, + config, + }).build(); + return router; } ``` diff --git a/docs/integrations/google-analytics/installation.md b/docs/integrations/google-analytics/installation.md deleted file mode 100644 index d6c23cc4b0..0000000000 --- a/docs/integrations/google-analytics/installation.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -id: installation -title: Google Analytics Installation -sidebar_label: Installation -# prettier-ignore -description: Adding Google Analytics to Your App ---- - -There is a basic -[Google Analytics](https://marketingplatform.google.com/about/analytics/) -integration built into Backstage. You can enable it by adding the following to -your app configuration: - -```yaml -app: - googleAnalyticsTrackingId: UA-000000-0 -``` - -Replace the tracking ID with the one generated for you after signing up for the -Google Analytics service. - -The default behavior is only to send a pageview hit. To record more, review the -[Google Analytics developer documentation](https://developers.google.com/analytics/devguides/collection/gtagjs). diff --git a/docs/plugins/analytics.md b/docs/plugins/analytics.md new file mode 100644 index 0000000000..0b3d5dfd9c --- /dev/null +++ b/docs/plugins/analytics.md @@ -0,0 +1,298 @@ +--- +id: analytics +title: Plugin Analytics +description: Measuring usage of your Backstage instance. +--- + +Setting up, maintaining, and iterating on an instance of Backstage can be a +large investment. To help measure return on this investment, Backstage comes +with an event-based Analytics API that grants app integrators the flexibility to +collect and analyze Backstage usage in the analytics tool of their choice, while +providing plugin developers a standard interface for instrumenting key user +interactions. + +## Concepts + +- **Events** consist of, at a minimum, an `action` (like `click`) and a + `subject` (like `thing that was clicked on`). +- **Attributes** represent additional dimensional data (in the form of key/value + pairs) that may be provided on an event-by-event basis. To continue the above + example, the URL a user clicked to might look like `{ "to": "/a/page" }`. +- **Context** represents the broader context in which an event took place. By + default, information like `pluginId`, `extension`, and `routeRef` are + provided. + +This composition of events aims to allow analysis at different levels of detail, +enabling very granular questions (like "what is the most clicked on thing on a +particular route") as well as very high-level questions (like "what is the most +used plugin in my Backstage instance") to be answered. + +## Supported Analytics Tools + +While all that's needed to consume and forward these events to an analytics tool +is a concrete implementation of [AnalyticsApi][analytics-api-type], common +integrations are packaged and provided as plugins. Find your analytics tool of +choice below. + +| Analytics Tool | Support Status | +| ---------------------- | -------------- | +| [Google Analytics][ga] | Yes βœ… | + +To suggest an integration, please [open an issue][add-tool] for the analytics +tool your organization uses. Or jump to [Writing Integrations][int-howto] to +learn how to contribute the integration yourself! + +[ga]: + https://github.com/backstage/backstage/blob/master/plugins/analytics-module-ga/README.md +[add-tool]: + https://github.com/backstage/backstage/issues/new?assignees=&labels=plugin&template=plugin_template.md&title=%5BAnalytics+Module%5D+THE+ANALYTICS+TOOL+TO+INTEGRATE +[int-howto]: #writing-integrations +[analytics-api-type]: + https://backstage.io/docs/reference/core-plugin-api.analyticsapi + +## Key Events + +The following table summarizes events that, depending on the plugins you have +installed, may be captured. + +| Action | Provided By | Subject | +| ---------- | -------------- | ----------------------------------------- | +| `navigate` | Backstage Core | The URL of the page that was navigated to | +| `click` | Backstage Core | The text of the link that was clicked on | + +If there is an event you'd like to see captured, please [open an +issue][add-event] describing the event you want to see and the questions it +would help you answer. Or jump to [Capturing Events][event-howto] to learn how +to contribute the instrumentation yourself! + +_OSS plugin maintainers: feel free to document your events in the table above._ + +[add-event]: +https://github.com/backstage/backstage/issues/new?assignees=&labels=enhancement&template=feature_template.md&title=[Analytics%20Event]:%20THE+EVENT+TO+CAPTURE +[event-howto]: #capturing-events + +## Writing Integrations + +Analytics event forwarding is implemented as a Backstage utility API. Just as +you might provide a custom API implementation for errors or SCM Authentication, +you can provide one for analytics. + +The provided API need only provide a single method `captureEvent`, which takes +an `AnalyticsEvent` object. + +```ts +import { + analyticsApiRef, + AnalyticsEvent, + AnyApiFactory, + createApiFactory, +} from '@backstage/core-plugin-api'; + +export const apis: AnyApiFactory[] = [ + createApiFactory(analyticsApiRef, { + captureEvent: (event: AnalyticsEvent) => { + window._AcmeAnalyticsQ.push(event); + }, + }), +]; +``` + +In reality, you would likely want to encapsulate instantiation logic and pull +some details from configuration. A more complete example might look like: + +```ts +import { + AnalyticsApi, + analyticsApiRef, + AnalyticsEvent, + AnyApiFactory, + configApiRef, + createApiFactory, +} from '@backstage/core-plugin-api'; +import { AcmeAnalytics } from 'acme-analytics'; + +class AcmeAnalytics implements AnalyticsApi { + private constructor(accountId: number) { + AcmeAnalytics.init(accountId); + } + + static fromConfig(config) { + const accountId = config.getString('app.analytics.acme.id'); + return new AcmeAnalytics(accountId); + } + + captureEvent(event: AnalyticsEvent) { + const { action, ...rest } = event; + AcmeAnalytics.send(action, rest); + } +} + +export const apis: AnyApiFactory[] = [ + createApiFactory({ + api: analyticsApiRef, + deps: { configApi: configApiRef }, + factory: ({ configApi }) => AcmeAnalytics.fromConfig(configApi), + }), +]; +``` + +If you are integrating with an analytics service (as opposed to an internal +tool), consider contributing your API implementation as a plugin! + +By convention, such packages should be named +`@backstage/analytics-module-[name]`, and any configuration should be keyed +under `app.analytics.[name]`. + +## Capturing Events + +To instrument an event in a component, start by retrieving an analytics tracker +using the `useAnalytics()` hook provided by `@backstage/core-plugin-api`. The +tracker includes a `captureEvent` method which takes an `action` and a `subject` +as arguments. + +```ts +import { useAnalytics } from '@backstage/core-plugin-api'; + +const analytics = useAnalytics(); +analytics.captureEvent('deploy', serviceName); +``` + +### Providing Extra Attributes + +Additional dimensional `attributes` as well as a numeric `value` can be provided +on a third `options` argument if/when relevant for the event: + +```ts +analytics.captureEvent('merge', pullRequestName, { + value: pullRequestAgeInMinutes, + attributes: { + org, + repo, + }, +}); +``` + +In the above example, an event resembling the following object would be +captured: + +```json +{ + "action": "merge", + "subject": "Name of Pull Request", + "value": 60, + "attributes": { + "org": "some-org", + "repo": "some-repo" + } +} +``` + +### Providing Context for Events + +The `attributes` option is good for capturing details available to you within +the component that you're instrumenting. For capturing metadata only available +further up the react tree, or to help app integrators aggregate distinct events +by some common value, use an ``. + +```tsx +import { AnalyticsContext, useAnalytics } from '@backstage/core-plugin-api'; + +const MyComponent = ({ value }) => { + const analytics = useAnalytics(); + const handleClick = () => analytics.captureEvent('check', value); + return ; +}; + +const MyWrapper = () => { + return ( + + + + ); +}; +``` + +In the above example, clicking on `` would result in an analytics +event resembling: + +```json +{ + "action": "check", + "subject": "Some Value", + "context": { + "segment": "xyz" + } +} +``` + +Note that, for brevity in the example above, the context keys provided by +Backstage core (`pluginId`, `extension`, and `routeRef`) have been omitted. In +reality, those details would be included alongside any additional context +provided by you. + +Analytics contexts can be nested; their values are merged down the react tree, +allowing keys to be overwritten. + +### Event Naming Considerations + +An event is split into its constituent parts to enable analysis at various +levels of granularity. In order to maintain this flexibility at analysis-time, +it's important to keep each of these levels of detail disaggregated. + +- Avoid providing an overly specific `action`. For example, instead of + `filterEntityTable`, consider just using `filter` as the action, and allowing + `EntityTable` to be specified as part of the event's `context` (most likely + automatically as part of the `extension` in which the `filter` event was + captured). + +- On the flip side, when adding `attributes` to an event, look at existing + events and see if the data you are capturing matches the intention, type, or + even the content of _their_ `attributes`. For instance, it may be common for + events that involve the Catalog to add details like entity `name`, `kind`, + and/or `namespace` as `attributes`. Using the same keys in your event will + ensure that events instrumented across plugins can easily be aggregated. + +### Unit Testing Event Capture + +The `@backstage/test-utils` package includes a `MockAnalyticsApi` implementation +that you can use in your unit tests to spy on and make assertions about any +analytics events captured. + +Use it like this: + +```tsx +import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { analyticsApiRef } from '@backstage/core-plugin-api'; +import { MockAnalyticsApi, wrapInTestApp } from '@backstage/test-utils'; +import { render, fireEvent, waitFor } from '@testing-library/react'; + +describe('SomeComponent', () => { + it('should capture event on click', () => { + // Use the Mock Analytics API to spy on event captures. + const apiSpy = new MockAnalyticsApi(); + + // Render the component being tested + const { getByText } = render( + wrapInTestApp( + + + , + ), + ); + + // Fire the event that triggers event capture. + fireEvent.click(getByText('some component text')); + + // Assert that the event was captured with the expected data. + await waitFor(() => { + expect(apiSpy.getEvents()[0]).toMatchObject({ + action: 'expected action', + subject: 'expected subject'', + attributes: { + foo: 'bar', + }, + }); + }); + }); +}); +``` diff --git a/docs/plugins/observability.md b/docs/plugins/observability.md index b5a6a0ba60..d8778f07f8 100644 --- a/docs/plugins/observability.md +++ b/docs/plugins/observability.md @@ -8,11 +8,6 @@ description: Adding Observability to Your Plugin This article briefly describes the observability options that are available to a Backstage integrator. -## Google Analytics - -See how to install Google Analytics in your app -[here](../integrations/google-analytics/installation.md) - ## Datadog RUM Events See how to install Datadog Events in your app diff --git a/microsite/data/plugins/backstage-analytics-module-ga.yaml b/microsite/data/plugins/backstage-analytics-module-ga.yaml new file mode 100644 index 0000000000..0a03ec20e8 --- /dev/null +++ b/microsite/data/plugins/backstage-analytics-module-ga.yaml @@ -0,0 +1,9 @@ +--- +title: 'Analytics Module: Google Analytics' +author: Spotify +authorUrl: https://github.com/spotify +category: Analytics +description: Track usage of your Backstage instance using Google Analytics. +documentation: https://github.com/backstage/backstage/blob/master/plugins/analytics-module-ga/README.md +iconUrl: img/ga-icon.png +npmPackageName: '@backstage/plugin-analytics-module-ga' diff --git a/microsite/sidebars.json b/microsite/sidebars.json index bb9fd5783a..c269f8ac8f 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -154,11 +154,6 @@ "label": "GitLab", "ids": ["integrations/gitlab/locations"] }, - { - "type": "subcategory", - "label": "Google Analytics", - "ids": ["integrations/google-analytics/installation"] - }, { "type": "subcategory", "label": "Google GCS", @@ -178,6 +173,7 @@ "plugins/structure-of-a-plugin", "plugins/integrating-plugin-into-software-catalog", "plugins/composability", + "plugins/analytics", { "type": "subcategory", "label": "Backends and APIs", diff --git a/microsite/static/img/ga-icon.png b/microsite/static/img/ga-icon.png new file mode 100644 index 0000000000..8eaebd06aa Binary files /dev/null and b/microsite/static/img/ga-icon.png differ diff --git a/mkdocs.yml b/mkdocs.yml index 7376893586..2f4aa4203f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -100,8 +100,6 @@ nav: - Org Data: 'integrations/github/org.md' - GitLab: - Locations: 'integrations/gitlab/locations.md' - - Google Analytics: - - Installation: 'integrations/google-analytics/installation.md' - Google GCS: - Locations: 'integrations/google-cloud-storage/locations.md' - LDAP: @@ -115,6 +113,7 @@ nav: - Plugin Development: 'plugins/plugin-development.md' - Integrate into the Software Catalog: 'plugins/integrating-plugin-into-software-catalog.md' - Composability System: 'plugins/composability.md' + - Plugin Analytics: 'plugins/analytics.md' - Backends and APIs: - Proxying: 'plugins/proxying.md' - Backend plugin: 'plugins/backend-plugin.md' diff --git a/packages/app/package.json b/packages/app/package.json index d1892438d8..fe86077273 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -11,6 +11,7 @@ "@backstage/core-plugin-api": "^0.1.10", "@backstage/integration-react": "^0.1.11", "@backstage/plugin-api-docs": "^0.6.11", + "@backstage/plugin-azure-devops": "^0.1.0", "@backstage/plugin-badges": "^0.2.12", "@backstage/plugin-catalog": "^0.7.0", "@backstage/plugin-catalog-graph": "^0.1.3", diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 62fdcf4d3f..3fe027ddad 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -33,6 +33,10 @@ import { EntityProvidedApisCard, EntityProvidingComponentsCard, } from '@backstage/plugin-api-docs'; +import { + EntityAzurePipelinesContent, + isAzureDevOpsAvailable, +} from '@backstage/plugin-azure-devops'; import { EntityBadgesDialog } from '@backstage/plugin-badges'; import { EntityAboutCard, @@ -183,6 +187,10 @@ export const cicdContent = ( + + + + (
} pageTitleOverride="Home"> - +
diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index f25ec5a14e..b6c148dfba 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -38,7 +38,7 @@ import { Config } from '@backstage/config'; import healthcheck from './plugins/healthcheck'; import { metricsInit, metricsHandler } from './metrics'; import auth from './plugins/auth'; -import azureDevOps from './plugins/azuredevops'; +import azureDevOps from './plugins/azure-devops'; import catalog from './plugins/catalog'; import codeCoverage from './plugins/codecoverage'; import kubernetes from './plugins/kubernetes'; diff --git a/packages/backend/src/plugins/azuredevops.ts b/packages/backend/src/plugins/azure-devops.ts similarity index 89% rename from packages/backend/src/plugins/azuredevops.ts rename to packages/backend/src/plugins/azure-devops.ts index e11a21ba2e..4120e655ed 100644 --- a/packages/backend/src/plugins/azuredevops.ts +++ b/packages/backend/src/plugins/azure-devops.ts @@ -18,9 +18,9 @@ import { createRouter } from '@backstage/plugin-azure-devops-backend'; import { Router } from 'express'; import type { PluginEnvironment } from '../types'; -export default async function createPlugin({ +export default function createPlugin({ logger, config, }: PluginEnvironment): Promise { - return await createRouter({ logger, config }); + return createRouter({ logger, config }); } diff --git a/packages/backend/src/plugins/kubernetes.ts b/packages/backend/src/plugins/kubernetes.ts index a19520312f..6d40d89b66 100644 --- a/packages/backend/src/plugins/kubernetes.ts +++ b/packages/backend/src/plugins/kubernetes.ts @@ -14,12 +14,16 @@ * limitations under the License. */ -import { createRouter } from '@backstage/plugin-kubernetes-backend'; +import { KubernetesBuilder } from '@backstage/plugin-kubernetes-backend'; import { PluginEnvironment } from '../types'; export default async function createPlugin({ logger, config, }: PluginEnvironment) { - return await createRouter({ logger, config }); + const { router } = await KubernetesBuilder.createBuilder({ + logger, + config, + }).build(); + return router; } diff --git a/packages/cli/package.json b/packages/cli/package.json index 53c1b06e76..606525cbb6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -85,7 +85,7 @@ "json-schema": "^0.3.0", "jest-transform-yaml": "^0.1.1", "lodash": "^4.17.21", - "mini-css-extract-plugin": "^1.4.1", + "mini-css-extract-plugin": "^2.4.2", "node-libs-browser": "^2.2.1", "ora": "^5.3.0", "postcss": "^8.1.0", diff --git a/packages/cli/src/lib/bundler/optimization.ts b/packages/cli/src/lib/bundler/optimization.ts index b8164d263d..254799f78c 100644 --- a/packages/cli/src/lib/bundler/optimization.ts +++ b/packages/cli/src/lib/bundler/optimization.ts @@ -26,7 +26,7 @@ export const optimization = ( return { minimize: !isDev, - // Only configure when parallel is explicitly overriden from the default + // Only configure when parallel is explicitly overridden from the default ...(!isParallelDefault(options.parallel) ? { minimizer: [ @@ -45,11 +45,15 @@ export const optimization = ( // enough, if they're smaller they end up in the main packages: { chunks: 'initial', - test: /[\\/]node_modules[\\/]/, + test(module: any) { + return Boolean( + module?.resource?.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/), + ); + }, name(module: any) { // get the name. E.g. node_modules/packageName/not/this/part.js // or node_modules/packageName - const packageName = module.context.match( + const packageName = module.resource.match( /[\\/]node_modules[\\/](.*?)([\\/]|$)/, )[1]; diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index 38fe19fc8b..dc8115287a 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -569,7 +569,7 @@ export function HelpIcon(props: IconComponentProps): JSX.Element; // Warning: (ae-missing-release-tag) "HomepageTimer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public @deprecated export function HomepageTimer(_props: {}): JSX.Element | null; // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts diff --git a/packages/core-components/src/components/Table/Table.tsx b/packages/core-components/src/components/Table/Table.tsx index f61811938e..234b64ec54 100644 --- a/packages/core-components/src/components/Table/Table.tsx +++ b/packages/core-components/src/components/Table/Table.tsx @@ -32,7 +32,6 @@ import FirstPage from '@material-ui/icons/FirstPage'; import LastPage from '@material-ui/icons/LastPage'; import Remove from '@material-ui/icons/Remove'; import SaveAlt from '@material-ui/icons/SaveAlt'; -import Search from '@material-ui/icons/Search'; import ViewColumn from '@material-ui/icons/ViewColumn'; import { isEqual, transform } from 'lodash'; import MTable, { @@ -73,7 +72,7 @@ const tableIcons: Icons = { )), ResetSearch: forwardRef((props, ref) => ), - Search: forwardRef((props, ref) => ), + Search: forwardRef((props, ref) => ), SortArrow: forwardRef((props, ref) => ), ThirdStateCheck: forwardRef((props, ref) => ), ViewColumn: forwardRef((props, ref) => ), @@ -497,6 +496,7 @@ export function Table(props: TableProps) { } data={typeof data === 'function' ? data : tableData} style={{ width: '100%' }} + localization={{ toolbar: { searchPlaceholder: 'Filter' } }} {...restProps} /> diff --git a/packages/core-components/src/layout/HomepageTimer/HomepageTimer.tsx b/packages/core-components/src/layout/HomepageTimer/HomepageTimer.tsx index a78f4af8ca..a825d52c94 100644 --- a/packages/core-components/src/layout/HomepageTimer/HomepageTimer.tsx +++ b/packages/core-components/src/layout/HomepageTimer/HomepageTimer.tsx @@ -67,6 +67,10 @@ function getTimes(configApi: ConfigApi) { return clocks; } +/** + * Please use the HeaderWorldClock in the home plugin + * @deprecated in favor of the HeaderWorldClock which is found in the to home plugin + */ export function HomepageTimer(_props: {}) { const configApi = useApi(configApiRef); diff --git a/packages/core-components/src/layout/Sidebar/Items.test.tsx b/packages/core-components/src/layout/Sidebar/Items.test.tsx index 48fcd98666..123c30ceae 100644 --- a/packages/core-components/src/layout/Sidebar/Items.test.tsx +++ b/packages/core-components/src/layout/Sidebar/Items.test.tsx @@ -16,12 +16,12 @@ import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; -import { screen } from '@testing-library/react'; +import { createEvent, fireEvent, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import HomeIcon from '@material-ui/icons/Home'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; import { Sidebar } from './Bar'; -import { SidebarItem } from './Items'; +import { SidebarItem, SidebarSearchField } from './Items'; import { renderHook } from '@testing-library/react-hooks'; import { hexToRgb, makeStyles } from '@material-ui/core/styles'; @@ -36,6 +36,7 @@ async function renderSidebar() { await renderInTestApp( + {}} to="/search" /> { ).toHaveStyle(`background-color: ${hexToRgb('2b2a2a')}`); }); }); + describe('SidebarSearchField', () => { + it('should be defaultPrevented when enter is pressed', async () => { + const searchEvent = createEvent.keyDown( + await screen.findByPlaceholderText('Search'), + { key: 'Enter', code: 'Enter', charCode: 13 }, + ); + fireEvent(await screen.findByPlaceholderText('Search'), searchEvent); + expect(searchEvent.defaultPrevented).toBeTruthy(); + }); + }); }); diff --git a/packages/core-components/src/layout/Sidebar/Items.tsx b/packages/core-components/src/layout/Sidebar/Items.tsx index 8a66e182f6..883e0395cd 100644 --- a/packages/core-components/src/layout/Sidebar/Items.tsx +++ b/packages/core-components/src/layout/Sidebar/Items.tsx @@ -306,6 +306,7 @@ export function SidebarSearchField(props: SidebarSearchFieldProps) { const handleEnter: KeyboardEventHandler = ev => { if (ev.key === 'Enter') { + ev.preventDefault(); search(); } }; diff --git a/plugins/api-docs/api-report.md b/plugins/api-docs/api-report.md index 74a0dc07dd..de36857945 100644 --- a/plugins/api-docs/api-report.md +++ b/plugins/api-docs/api-report.md @@ -5,6 +5,7 @@ ```ts /// +import { Action } from '@material-table/core'; import { ApiEntity } from '@backstage/catalog-model'; import { ApiRef } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; @@ -58,9 +59,20 @@ export { apiDocsPlugin as plugin }; export const ApiExplorerPage: ({ initiallySelectedFilter, columns, + actions, }: { initiallySelectedFilter?: UserListFilterKind | undefined; columns?: TableColumn[] | undefined; + actions?: + | ( + | Action + | { + action: (rowData: CatalogTableRow) => Action; + position: string; + } + | ((rowData: CatalogTableRow) => Action) + )[] + | undefined; }) => JSX.Element; // Warning: (ae-missing-release-tag) "ApiTypeTitle" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx index 518c3c7001..d0b7523ead 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx @@ -15,7 +15,11 @@ */ import { Entity, RELATION_MEMBER_OF } from '@backstage/catalog-model'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; +import { + CatalogApi, + catalogApiRef, + entityRouteRef, +} from '@backstage/plugin-catalog-react'; import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; import React from 'react'; @@ -32,6 +36,9 @@ import { ConfigApi, configApiRef, } from '@backstage/core-plugin-api'; +import { TableColumn, TableProps } from '@backstage/core-components'; +import DashboardIcon from '@material-ui/icons/Dashboard'; +import { CatalogTableRow } from '@backstage/plugin-catalog'; describe('ApiCatalogPage', () => { const catalogApi: Partial = { @@ -46,14 +53,6 @@ describe('ApiCatalogPage', () => { }, spec: { type: 'openapi' }, }, - { - apiVersion: 'backstage.io/v1alpha1', - kind: 'API', - metadata: { - name: 'Entity2', - }, - spec: { type: 'openapi' }, - }, ] as Entity[], }), getLocationByEntity: () => @@ -96,6 +95,11 @@ describe('ApiCatalogPage', () => { > {children} , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, ), ); @@ -106,4 +110,82 @@ describe('ApiCatalogPage', () => { const { findByText } = renderWrapped(); expect(await findByText(/My Company API Explorer/)).toBeInTheDocument(); }); + + it('should render the default column of the grid', async () => { + const { getAllByRole } = renderWrapped(); + + const columnHeader = getAllByRole('button').filter( + c => c.tagName === 'SPAN', + ); + const columnHeaderLabels = columnHeader.map(c => c.textContent); + + expect(columnHeaderLabels).toEqual([ + 'Name', + 'System', + 'Owner', + 'Type', + 'Lifecycle', + 'Description', + 'Tags', + 'Actions', + ]); + }); + + it('should render the custom column passed as prop', async () => { + const columns: TableColumn[] = [ + { title: 'Foo', field: 'entity.foo' }, + { title: 'Bar', field: 'entity.bar' }, + { title: 'Baz', field: 'entity.spec.lifecycle' }, + ]; + const { getAllByRole } = renderWrapped( + , + ); + + const columnHeader = getAllByRole('button').filter( + c => c.tagName === 'SPAN', + ); + const columnHeaderLabels = columnHeader.map(c => c.textContent); + + expect(columnHeaderLabels).toEqual(['Foo', 'Bar', 'Baz', 'Actions']); + }); + + it('should render the default actions of an item in the grid', async () => { + const { findByTitle, findByText } = await renderWrapped( + , + ); + expect(await findByText(/All \(1\)/)).toBeInTheDocument(); + expect(await findByTitle(/View/)).toBeInTheDocument(); + expect(await findByTitle(/View/)).toBeInTheDocument(); + expect(await findByTitle(/Edit/)).toBeInTheDocument(); + expect(await findByTitle(/Add to favorites/)).toBeInTheDocument(); + }); + + it('should render the custom actions of an item passed as prop', async () => { + const actions: TableProps['actions'] = [ + () => { + return { + icon: () => , + tooltip: 'Foo Action', + disabled: false, + onClick: () => jest.fn(), + }; + }, + () => { + return { + icon: () => , + tooltip: 'Bar Action', + disabled: true, + onClick: () => jest.fn(), + }; + }, + ]; + + const { findByTitle, findByText } = await renderWrapped( + , + ); + expect(await findByText(/All \(1\)/)).toBeInTheDocument(); + expect(await findByTitle(/Foo Action/)).toBeInTheDocument(); + expect(await findByTitle(/Bar Action/)).toBeInTheDocument(); + expect((await findByTitle(/Bar Action/)).firstChild).toBeDisabled(); + }); }); diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx index 2f74aaa166..f28574145e 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx @@ -21,6 +21,7 @@ import { PageWithHeader, SupportButton, TableColumn, + TableProps, } from '@backstage/core-components'; import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api'; import { @@ -56,11 +57,13 @@ const defaultColumns: TableColumn[] = [ type ApiExplorerPageProps = { initiallySelectedFilter?: UserListFilterKind; columns?: TableColumn[]; + actions?: TableProps['actions']; }; export const ApiExplorerPage = ({ initiallySelectedFilter = 'all', columns, + actions, }: ApiExplorerPageProps) => { const configApi = useApi(configApiRef); const generatedSubtitle = `${ @@ -94,7 +97,10 @@ export const ApiExplorerPage = ({ - + diff --git a/plugins/azure-devops-backend/api-report.md b/plugins/azure-devops-backend/api-report.md index 4ad2f833d7..74b4ac5b23 100644 --- a/plugins/azure-devops-backend/api-report.md +++ b/plugins/azure-devops-backend/api-report.md @@ -10,6 +10,7 @@ import { Config } from '@backstage/config'; import express from 'express'; import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces'; import { Logger as Logger_2 } from 'winston'; +import { PullRequestStatus } from 'azure-devops-node-api/interfaces/GitInterfaces'; import { WebApi } from 'azure-devops-node-api'; // Warning: (ae-missing-release-tag) "AzureDevOpsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -28,6 +29,14 @@ export class AzureDevOpsApi { projectName: string, repoName: string, ): Promise; + // Warning: (ae-forgotten-export) The symbol "PullRequestOptions" needs to be exported by the entry point index.d.ts + // + // (undocumented) + getPullRequests( + projectName: string, + repoName: string, + options: PullRequestOptions, + ): Promise; // (undocumented) getRepoBuilds( projectName: string, @@ -41,6 +50,23 @@ export class AzureDevOpsApi { // @public (undocumented) export function createRouter(options: RouterOptions): Promise; +// Warning: (ae-missing-release-tag) "PullRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type PullRequest = { + pullRequestId?: number; + repoName?: string; + title?: string; + uniqueName?: string; + createdBy?: string; + creationDate?: Date; + sourceRefName?: string; + targetRefName?: string; + status?: PullRequestStatus; + isDraft?: boolean; + link: string; +}; + // Warning: (ae-missing-release-tag) "RepoBuild" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts index 25022b4c02..fd9aa169d5 100644 --- a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts @@ -13,17 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { repoBuildFromBuild } from './AzureDevOpsApi'; -import { RepoBuild } from './types'; +import { mappedPullRequest, mappedRepoBuild } from './AzureDevOpsApi'; +import { PullRequest, RepoBuild } from './types'; import { Build, BuildResult, BuildStatus, DefinitionReference, } from 'azure-devops-node-api/interfaces/BuildInterfaces'; +import { + GitPullRequest, + PullRequestStatus, +} from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { GitRepository } from 'azure-devops-node-api/interfaces/TfvcInterfaces'; +import { IdentityRef } from 'azure-devops-node-api/interfaces/common/VSSInterfaces'; describe('AzureDevOpsApi', () => { - describe('repoBuildFromBuild', () => { + describe('mappedRepoBuild', () => { it('should return RepoBuild from Build', () => { const inputBuildDefinition: DefinitionReference = { name: 'My Build Definition', @@ -57,11 +63,11 @@ describe('AzureDevOpsApi', () => { source: 'refs/heads/develop (f4f78b31)', }; - expect(repoBuildFromBuild(inputBuild)).toEqual(outputRepoBuild); + expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); }); }); - describe('repoBuildFromBuild with no Build definition name', () => { + describe('mappedRepoBuild with no Build definition name', () => { it('should return RepoBuild with only Build Number for title', () => { const inputLinks: any = { web: { @@ -91,11 +97,11 @@ describe('AzureDevOpsApi', () => { source: 'refs/heads/develop (f4f78b31)', }; - expect(repoBuildFromBuild(inputBuild)).toEqual(outputRepoBuild); + expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); }); }); - describe('repoBuildFromBuild with undefined status', () => { + describe('mappedRepoBuild with undefined status', () => { it('should return BuildStatus of None for status', () => { const inputLinks: any = { web: { @@ -125,11 +131,11 @@ describe('AzureDevOpsApi', () => { source: 'refs/heads/develop (f4f78b31)', }; - expect(repoBuildFromBuild(inputBuild)).toEqual(outputRepoBuild); + expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); }); }); - describe('repoBuildFromBuild with undefined result', () => { + describe('mappedRepoBuild with undefined result', () => { it('should return BuildResult of None for result', () => { const inputLinks: any = { web: { @@ -159,11 +165,11 @@ describe('AzureDevOpsApi', () => { source: 'refs/heads/develop (f4f78b31)', }; - expect(repoBuildFromBuild(inputBuild)).toEqual(outputRepoBuild); + expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); }); }); - describe('repoBuildFromBuild with undefined link', () => { + describe('mappedRepoBuild with undefined link', () => { it('should return empty string for link', () => { const inputBuild: Build = { id: 1, @@ -187,7 +193,53 @@ describe('AzureDevOpsApi', () => { source: 'refs/heads/develop (f4f78b31)', }; - expect(repoBuildFromBuild(inputBuild)).toEqual(outputRepoBuild); + expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); + }); + }); + + describe('mappedPullRequest', () => { + it('should return PullRequest from GitPullRequest', () => { + const inputGitRepository: GitRepository = { + name: 'super-feature-repo', + }; + + const inputIdentityRef: IdentityRef = { + displayName: 'Jane Doe', + uniqueName: 'DOMAINjdoe', + }; + + const inputPullRequest: GitPullRequest = { + pullRequestId: 7181, + repository: inputGitRepository, + title: 'My Awesome New Feature', + createdBy: inputIdentityRef, + creationDate: new Date('2020-09-12T06:10:23.9325232Z'), + sourceRefName: 'refs/heads/topic/super-awesome-feature', + targetRefName: 'refs/heads/main', + status: PullRequestStatus.Active, + isDraft: false, + }; + + const inputBaseUrl = + 'https://host.com/myOrg/_git/super-feature-repo/pullrequest'; + + const outputPullRequest: PullRequest = { + pullRequestId: 7181, + repoName: 'super-feature-repo', + title: 'My Awesome New Feature', + uniqueName: 'DOMAINjdoe', + createdBy: 'Jane Doe', + creationDate: new Date('2020-09-12T06:10:23.9325232Z'), + sourceRefName: 'refs/heads/topic/super-awesome-feature', + targetRefName: 'refs/heads/main', + status: PullRequestStatus.Active, + isDraft: false, + link: 'https://host.com/myOrg/_git/super-feature-repo/pullrequest/7181', + }; + + expect(mappedPullRequest(inputPullRequest, inputBaseUrl)).toEqual( + outputPullRequest, + ); }); }); }); diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts index 3da4dff11d..89e32aa2f3 100644 --- a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts @@ -16,12 +16,16 @@ import { Logger } from 'winston'; import { WebApi } from 'azure-devops-node-api'; -import { RepoBuild } from './types'; import { Build, BuildResult, BuildStatus, } from 'azure-devops-node-api/interfaces/BuildInterfaces'; +import { + GitPullRequest, + GitPullRequestSearchCriteria, +} from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { PullRequest, PullRequestOptions, RepoBuild } from './types'; export class AzureDevOpsApi { constructor( @@ -88,14 +92,48 @@ export class AzureDevOpsApi { ); const repoBuilds: RepoBuild[] = buildList.map(build => { - return repoBuildFromBuild(build); + return mappedRepoBuild(build); }); return repoBuilds; } + + async getPullRequests( + projectName: string, + repoName: string, + options: PullRequestOptions, + ) { + if (this.logger) { + this.logger.debug( + `Calling Azure DevOps REST API, getting up to ${top} Pull Requests for Repository ${repoName} for Project ${projectName}`, + ); + } + + const gitRepository = await this.getGitRepository(projectName, repoName); + const client = await this.webApi.getGitApi(); + const searchCriteria: GitPullRequestSearchCriteria = { + status: options.status, + }; + const gitPullRequests = await client.getPullRequests( + gitRepository.id as string, + searchCriteria, + projectName, + undefined, + undefined, + options.top, + ); + const linkBaseUrl = `${this.webApi.serverUrl}/${encodeURIComponent( + projectName, + )}/_git/${encodeURIComponent(repoName)}/pullrequest`; + const pullRequests: PullRequest[] = gitPullRequests.map(gitPullRequest => { + return mappedPullRequest(gitPullRequest, linkBaseUrl); + }); + + return pullRequests; + } } -export function repoBuildFromBuild(build: Build) { +export function mappedRepoBuild(build: Build) { return { id: build.id, title: [build.definition?.name, build.buildNumber] @@ -108,3 +146,22 @@ export function repoBuildFromBuild(build: Build) { source: `${build.sourceBranch} (${build.sourceVersion?.substr(0, 8)})`, }; } + +export function mappedPullRequest( + pullRequest: GitPullRequest, + linkBaseUrl: string, +) { + return { + pullRequestId: pullRequest.pullRequestId, + repoName: pullRequest.repository?.name, + title: pullRequest.title, + uniqueName: pullRequest.createdBy?.uniqueName, + createdBy: pullRequest.createdBy?.displayName, + creationDate: pullRequest.creationDate, + sourceRefName: pullRequest.sourceRefName, + targetRefName: pullRequest.targetRefName, + status: pullRequest.status, + isDraft: pullRequest.isDraft, + link: `${linkBaseUrl}/${pullRequest.pullRequestId}`, + }; +} diff --git a/plugins/azure-devops-backend/src/api/index.ts b/plugins/azure-devops-backend/src/api/index.ts index 8faf37fe4e..893f0f4f2c 100644 --- a/plugins/azure-devops-backend/src/api/index.ts +++ b/plugins/azure-devops-backend/src/api/index.ts @@ -15,4 +15,4 @@ */ export { AzureDevOpsApi } from './AzureDevOpsApi'; -export type { RepoBuild } from './types'; +export type { RepoBuild, PullRequest } from './types'; diff --git a/plugins/azure-devops-backend/src/api/types.ts b/plugins/azure-devops-backend/src/api/types.ts index b3562f76c6..a53f501f26 100644 --- a/plugins/azure-devops-backend/src/api/types.ts +++ b/plugins/azure-devops-backend/src/api/types.ts @@ -18,6 +18,7 @@ import { BuildResult, BuildStatus, } from 'azure-devops-node-api/interfaces/BuildInterfaces'; +import { PullRequestStatus } from 'azure-devops-node-api/interfaces/GitInterfaces'; export type RepoBuild = { id?: number; @@ -28,3 +29,22 @@ export type RepoBuild = { queueTime?: Date; source: string; }; + +export type PullRequest = { + pullRequestId?: number; + repoName?: string; + title?: string; + uniqueName?: string; + createdBy?: string; + creationDate?: Date; + sourceRefName?: string; + targetRefName?: string; + status?: PullRequestStatus; + isDraft?: boolean; + link: string; +}; + +export type PullRequestOptions = { + top: number; + status: PullRequestStatus; +}; diff --git a/plugins/azure-devops-backend/src/index.ts b/plugins/azure-devops-backend/src/index.ts index a7004ec73e..ff4d3973bf 100644 --- a/plugins/azure-devops-backend/src/index.ts +++ b/plugins/azure-devops-backend/src/index.ts @@ -14,5 +14,5 @@ * limitations under the License. */ export { AzureDevOpsApi } from './api'; -export type { RepoBuild } from './api'; +export type { RepoBuild, PullRequest } from './api'; export * from './service/router'; diff --git a/plugins/azure-devops-backend/src/service/router.test.ts b/plugins/azure-devops-backend/src/service/router.test.ts index a66b86f418..4143291409 100644 --- a/plugins/azure-devops-backend/src/service/router.test.ts +++ b/plugins/azure-devops-backend/src/service/router.test.ts @@ -20,8 +20,11 @@ import express from 'express'; import request from 'supertest'; import { AzureDevOpsApi } from '../api'; import { createRouter } from './router'; -import { RepoBuild } from '../api/types'; -import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { PullRequest, RepoBuild } from '../api/types'; +import { + GitRepository, + PullRequestStatus, +} from 'azure-devops-node-api/interfaces/GitInterfaces'; import { Build, BuildResult, @@ -37,6 +40,7 @@ describe('createRouter', () => { getGitRepository: jest.fn(), getBuildList: jest.fn(), getRepoBuilds: jest.fn(), + getPullRequests: jest.fn(), } as any; const router = await createRouter({ azureDevOpsApi, @@ -193,4 +197,67 @@ describe('createRouter', () => { expect(response.body).toEqual(repoBuilds); }); }); + + describe('GET /pull-requests/:projectName/:repoName', () => { + it('fetches a list of pull requests', async () => { + const firstPullRequest: PullRequest = { + pullRequestId: 7181, + repoName: 'super-feature-repo', + title: 'My Awesome New Feature', + createdBy: 'Jane Doe', + creationDate: undefined, + sourceRefName: 'refs/heads/topic/super-awesome-feature', + targetRefName: 'refs/heads/main', + status: PullRequestStatus.Active, + isDraft: false, + link: 'https://host.com/myOrg/_git/super-feature-repo/pullrequest/7181', + }; + + const secondPullRequest: PullRequest = { + pullRequestId: 7182, + repoName: 'super-feature-repo', + title: 'Refactoring My Awesome New Feature', + createdBy: 'Jane Doe', + creationDate: undefined, + sourceRefName: 'refs/heads/topic/refactor-super-awesome-feature', + targetRefName: 'refs/heads/main', + status: PullRequestStatus.Active, + isDraft: false, + link: 'https://host.com/myOrg/_git/super-feature-repo/pullrequest/7182', + }; + + const thirdPullRequest: PullRequest = { + pullRequestId: 7183, + repoName: 'super-feature-repo', + title: 'Bug Fix for My Awesome New Feature', + createdBy: 'Jane Doe', + creationDate: undefined, + sourceRefName: 'refs/heads/topic/fix-super-awesome-feature', + targetRefName: 'refs/heads/main', + status: PullRequestStatus.Active, + isDraft: false, + link: 'https://host.com/myOrg/_git/super-feature-repo/pullrequest/7183', + }; + + const pullRequests: PullRequest[] = [ + firstPullRequest, + secondPullRequest, + thirdPullRequest, + ]; + + azureDevOpsApi.getPullRequests.mockResolvedValueOnce(pullRequests); + + const response = await request(app) + .get('/pull-requests/myProject/myRepo') + .query({ top: '50', status: 1 }); + + expect(azureDevOpsApi.getPullRequests).toHaveBeenCalledWith( + 'myProject', + 'myRepo', + { status: 1, top: 50 }, + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual(pullRequests); + }); + }); }); diff --git a/plugins/azure-devops-backend/src/service/router.ts b/plugins/azure-devops-backend/src/service/router.ts index 1fc86128e6..0929d5cde9 100644 --- a/plugins/azure-devops-backend/src/service/router.ts +++ b/plugins/azure-devops-backend/src/service/router.ts @@ -21,6 +21,8 @@ import { Logger } from 'winston'; import { Config } from '@backstage/config'; import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api'; import { AzureDevOpsApi } from '../api'; +import { PullRequestStatus } from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { PullRequestOptions } from '../api/types'; const DEFAULT_TOP: number = 10; @@ -84,6 +86,24 @@ export async function createRouter( res.status(200).json(gitRepository); }); + router.get('/pull-requests/:projectName/:repoName', async (req, res) => { + const { projectName, repoName } = req.params; + const top = req.query.top ? Number(req.query.top) : DEFAULT_TOP; + const status = req.query.status + ? Number(req.query.status) + : PullRequestStatus.Active; + const pullRequestOptions: PullRequestOptions = { + top: top, + status: status, + }; + const gitPullRequest = await azureDevOpsApi.getPullRequests( + projectName, + repoName, + pullRequestOptions, + ); + res.status(200).json(gitPullRequest); + }); + router.use(errorHandler()); return router; } diff --git a/plugins/azure-devops/.eslintrc.js b/plugins/azure-devops/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/azure-devops/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/azure-devops/README.md b/plugins/azure-devops/README.md new file mode 100644 index 0000000000..edf26ecfb3 --- /dev/null +++ b/plugins/azure-devops/README.md @@ -0,0 +1,135 @@ +# Azure DevOps Plugin + +Website: [https://dev.azure.com/](https://dev.azure.com/) + +![Azure DevOps Builds Example](./docs/azure-devops-builds.png) + +## Setup + +The following sections will help you get the Azure DevOps plugin setup and running + +### Configuration + +The Azure DevOps plugin requires the following YAML to be added to your app-config.yaml: + +```yaml +azureDevOps: + host: dev.azure.com + token: ${AZURE_TOKEN} + organization: my-company +``` + +Configuration Details: + +- `host` and `token` can be the same as the ones used for the `integration` section +- `AZURE_TOKEN` environment variable must be set to a [Personal Access Token](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page) with read access to both Code and Build +- `organization` is your Azure DevOps Organization name or for Azure DevOps Server (on-premise) this will be your Collection name + +### Backend + +Here's how to get the backend up and running: + +1. First we need to add the `@backstage/plugin-azure-devops-backend` package to your backend: + + ```sh + # From the Backstage root directory + cd packages/backend + yarn add @backstage/plugin-azure-devops-backend + ``` + +2. Then we will create a new file named `packages/backend/src/plugins/azure-devops.ts`, and add the + following to it: + + ```ts + import { createRouter } from '@backstage/plugin-azure-devops-backend'; + import { Router } from 'express'; + import type { PluginEnvironment } from '../types'; + + export default function createPlugin({ + logger, + config, + }: PluginEnvironment): Promise { + return createRouter({ logger, config }); + } + ``` + +3. Next we wire this into the overall backend router, edit `packages/backend/src/index.ts`: + + ```ts + import azureDevOps from './plugins/azuredevops'; + // ... + async function main() { + // ... + const azureDevOpsEnv = useHotMemoize(module, () => createEnv('azure-devops')); + apiRouter.use('/azure-devops', await azureDevOps(azureDevOpsEnv)); + ``` + +4. Now run `yarn start-backend` from the repo root +5. Finally open `http://localhost:7000/api/azure-devops/health` in a browser and it should return `{"status":"ok"}` + +### Frontend + +To get the frontend working you'll need to do the following two steps: + +1. First we need to add the @backstage/plugin-azure-devops package to your frontend app: + + ```bash + # From your Backstage root directory + cd packages/app + yarn add @backstage/plugin-azure-devops + ``` + +2. Second we need to add the `EntityAzurePipelinesContent` extension to the entity page in your app: + + ```tsx + // In packages/app/src/components/catalog/EntityPage.tsx + import { + EntityAzurePipelinesContent, + isAzureDevOpsAvailable, + } from '@backstage/plugin-azure-devops'; + + // For example in the CI/CD section + const cicdContent = ( + + // ... + + // Set defaultLimit to the max number of builds you would like to be able to see + // the default if not set is 10 + + + // ... + + ``` + +### Entity Annotation + +You need to add the following annotation to any entities you want to be able to use the Azure Devops plugin with: + +```yaml +dev.azure.com/project-repo: / +``` + +Let's break this down a little: `` will be the name of your Team Project and `` will be the name of your repository which needs to be part of the Team Project you entered for ``. + +Here's what that will look like in action: + +```yaml +# Example catalog-info.yaml entity definition file +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + # ... + annotations: + dev.azure.com/project-repo: my-project/my-repo +spec: + type: service + # ... +``` + +## Features + +- Lists the top _n_ builds for a given repository where _n_ is the value configured for `top` + +## Limitations + +- Currently multiple organizations is not supported diff --git a/plugins/azure-devops/api-report.md b/plugins/azure-devops/api-report.md new file mode 100644 index 0000000000..fbe28c179c --- /dev/null +++ b/plugins/azure-devops/api-report.md @@ -0,0 +1,37 @@ +## API Report File for "@backstage/plugin-azure-devops" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +/// + +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { Entity } from '@backstage/catalog-model'; +import { RouteRef } from '@backstage/core-plugin-api'; + +// Warning: (ae-missing-release-tag) "azureDevOpsPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const azureDevOpsPlugin: BackstagePlugin< + { + entityContent: RouteRef; + }, + {} +>; + +// Warning: (ae-missing-release-tag) "EntityAzurePipelinesContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const EntityAzurePipelinesContent: ({ + defaultLimit, +}: { + defaultLimit?: number | undefined; +}) => JSX.Element; + +// Warning: (ae-missing-release-tag) "isAzureDevOpsAvailable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const isAzureDevOpsAvailable: (entity: Entity) => boolean; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/azure-devops/dev/index.tsx b/plugins/azure-devops/dev/index.tsx new file mode 100644 index 0000000000..8d1c3ef6ea --- /dev/null +++ b/plugins/azure-devops/dev/index.tsx @@ -0,0 +1,20 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createDevApp } from '@backstage/dev-utils'; +import { azureDevOpsPlugin } from '../src/plugin'; + +createDevApp().registerPlugin(azureDevOpsPlugin).render(); diff --git a/plugins/azure-devops/docs/azure-devops-builds.png b/plugins/azure-devops/docs/azure-devops-builds.png new file mode 100644 index 0000000000..4788a185ed Binary files /dev/null and b/plugins/azure-devops/docs/azure-devops-builds.png differ diff --git a/plugins/azure-devops/package.json b/plugins/azure-devops/package.json new file mode 100644 index 0000000000..3aa545cc27 --- /dev/null +++ b/plugins/azure-devops/package.json @@ -0,0 +1,62 @@ +{ + "name": "@backstage/plugin-azure-devops", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/azure-devops" + }, + "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.9.3", + "@backstage/core-components": "^0.6.1", + "@backstage/core-plugin-api": "^0.1.10", + "@backstage/errors": "^0.1.2", + "@backstage/plugin-catalog-react": "^0.5.1", + "@backstage/theme": "^0.2.10", + "@material-ui/core": "^4.12.2", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.57", + "azure-devops-node-api": "^11.0.1", + "luxon": "^2.0.2", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-router": "6.0.0-beta.0", + "react-use": "^17.2.4" + }, + "devDependencies": { + "@backstage/cli": "^0.7.15", + "@backstage/core-app-api": "^0.1.16", + "@backstage/dev-utils": "^0.2.11", + "@backstage/test-utils": "^0.1.18", + "@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.29.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/azure-devops/src/api/AzureDevOpsApi.ts b/plugins/azure-devops/src/api/AzureDevOpsApi.ts new file mode 100644 index 0000000000..f0bad53b43 --- /dev/null +++ b/plugins/azure-devops/src/api/AzureDevOpsApi.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RepoBuild, RepoBuildOptions } from './types'; +import { createApiRef } from '@backstage/core-plugin-api'; + +export const azureDevOpsApiRef = createApiRef({ + id: 'plugin.azure-devops.service', + description: + 'Used by the Azure DevOps plugin to make requests to accompanying backend', +}); + +export interface AzureDevOpsApi { + getRepoBuilds( + projectName: string, + repoName: string, + options?: RepoBuildOptions, + ): Promise<{ items: RepoBuild[] }>; +} diff --git a/plugins/azure-devops/src/api/AzureDevOpsClient.ts b/plugins/azure-devops/src/api/AzureDevOpsClient.ts new file mode 100644 index 0000000000..e03428e511 --- /dev/null +++ b/plugins/azure-devops/src/api/AzureDevOpsClient.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AzureDevOpsApi } from './AzureDevOpsApi'; +import { RepoBuild, RepoBuildOptions } from './types'; +import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; +import { ResponseError } from '@backstage/errors'; + +export class AzureDevOpsClient implements AzureDevOpsApi { + private readonly discoveryApi: DiscoveryApi; + private readonly identityApi: IdentityApi; + + constructor(options: { + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { + this.discoveryApi = options.discoveryApi; + this.identityApi = options.identityApi; + } + + async getRepoBuilds( + projectName: string, + repoName: string, + options?: RepoBuildOptions, + ): Promise<{ items: RepoBuild[] }> { + const items = await this.get( + `repo-builds/${projectName}/${repoName}?top=${options?.top}`, + ); + return { items }; + } + + private async get(path: string): Promise { + const baseUrl = `${await this.discoveryApi.getBaseUrl('azure-devops')}/`; + const url = new URL(path, baseUrl); + + const idToken = await this.identityApi.getIdToken(); + const response = await fetch(url.toString(), { + headers: idToken ? { Authorization: `Bearer ${idToken}` } : {}, + }); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return response.json() as Promise; + } +} diff --git a/plugins/azure-devops/src/api/index.ts b/plugins/azure-devops/src/api/index.ts new file mode 100644 index 0000000000..c69636222f --- /dev/null +++ b/plugins/azure-devops/src/api/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './AzureDevOpsApi'; +export * from './AzureDevOpsClient'; diff --git a/plugins/azure-devops/src/api/types.ts b/plugins/azure-devops/src/api/types.ts new file mode 100644 index 0000000000..1fbc34231e --- /dev/null +++ b/plugins/azure-devops/src/api/types.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + BuildResult, + BuildStatus, +} from 'azure-devops-node-api/interfaces/BuildInterfaces'; + +export type RepoBuild = { + id?: number; + title: string; + link?: string; + status?: BuildStatus; + result?: BuildResult; + queueTime?: Date; + source: string; +}; + +export type RepoBuildOptions = { + top?: number; +}; diff --git a/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx b/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx new file mode 100644 index 0000000000..bead424ffb --- /dev/null +++ b/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx @@ -0,0 +1,186 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { DateTime } from 'luxon'; +import { + Link, + Table, + TableColumn, + StatusError, + StatusOK, + StatusWarning, + StatusAborted, + StatusRunning, + StatusPending, + ResponseErrorPanel, +} from '@backstage/core-components'; +import { Box, Typography } from '@material-ui/core'; +import { RepoBuild } from '../../api/types'; +import { + BuildResult, + BuildStatus, +} from 'azure-devops-node-api/interfaces/BuildInterfaces'; + +const getBuildResultComponent = (result: number | undefined) => { + switch (result) { + case BuildResult.Succeeded: + return ( + + Succeeded + + ); + case BuildResult.PartiallySucceeded: + return ( + + Partially Succeeded + + ); + case BuildResult.Failed: + return ( + + Failed + + ); + case BuildResult.Canceled: + return ( + + Canceled + + ); + case BuildResult.None: + default: + return ( + + Unknown + + ); + } +}; + +const getBuildStateComponent = ( + status: number | undefined, + result: number | undefined, +) => { + switch (status) { + case BuildStatus.InProgress: + return ( + + In Progress + + ); + case BuildStatus.Completed: + return getBuildResultComponent(result); + case BuildStatus.Cancelling: + return ( + + Cancelling + + ); + case BuildStatus.Postponed: + return ( + + Postponed + + ); + case BuildStatus.NotStarted: + return ( + + Not Started + + ); + case BuildStatus.None: + default: + return ( + + Unknown + + ); + } +}; + +const columns: TableColumn[] = [ + { + title: 'ID', + field: 'id', + highlight: false, + width: 'auto', + }, + { + title: 'Build', + field: 'title', + width: 'auto', + render: (row: Partial) => ( + {row.title} + ), + }, + { + title: 'Source', + field: 'source', + width: 'auto', + }, + { + title: 'State', + width: 'auto', + render: (row: Partial) => ( + + + {getBuildStateComponent(row.status, row.result)} + + + ), + }, + { + title: 'Age', + field: 'queueTime', + width: 'auto', + render: (row: Partial) => + DateTime.fromISO( + row.queueTime ? row.queueTime.toString() : new Date().toString(), + ).toRelative(), + }, +]; + +type Props = { + items?: RepoBuild[]; + loading: boolean; + error?: any; +}; + +export const BuildTable = ({ items, loading, error }: Props) => { + if (error) { + return ( +
+ +
+ ); + } + + return ( + + ); +}; diff --git a/plugins/azure-devops/src/components/BuildTable/index.ts b/plugins/azure-devops/src/components/BuildTable/index.ts new file mode 100644 index 0000000000..acf20eb523 --- /dev/null +++ b/plugins/azure-devops/src/components/BuildTable/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { BuildTable } from './BuildTable'; diff --git a/plugins/azure-devops/src/components/EntityPageAzurePipelines/EntityPageAzurePipelines.tsx b/plugins/azure-devops/src/components/EntityPageAzurePipelines/EntityPageAzurePipelines.tsx new file mode 100644 index 0000000000..c993922105 --- /dev/null +++ b/plugins/azure-devops/src/components/EntityPageAzurePipelines/EntityPageAzurePipelines.tsx @@ -0,0 +1,31 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEntity } from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { useRepoBuilds } from '../../hooks/useRepoBuilds'; +import { BuildTable } from '../BuildTable/BuildTable'; + +export const EntityPageAzurePipelines = ({ + defaultLimit, +}: { + defaultLimit?: number; +}) => { + const { entity } = useEntity(); + const { items, loading, error } = useRepoBuilds(entity, defaultLimit); + + return ; +}; diff --git a/plugins/azure-devops/src/components/EntityPageAzurePipelines/index.ts b/plugins/azure-devops/src/components/EntityPageAzurePipelines/index.ts new file mode 100644 index 0000000000..c907c61b2c --- /dev/null +++ b/plugins/azure-devops/src/components/EntityPageAzurePipelines/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { EntityPageAzurePipelines } from './EntityPageAzurePipelines'; diff --git a/plugins/azure-devops/src/components/Router.tsx b/plugins/azure-devops/src/components/Router.tsx new file mode 100644 index 0000000000..3000deb5cc --- /dev/null +++ b/plugins/azure-devops/src/components/Router.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Routes, Route } from 'react-router'; +import { azureDevOpsRouteRef } from '../routes'; +import { EntityPageAzurePipelines } from './EntityPageAzurePipelines'; +import { AZURE_DEVOPS_ANNOTATION } from '../constants'; +import { Entity } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { MissingAnnotationEmptyState } from '@backstage/core-components'; + +export const isAzureDevOpsAvailable = (entity: Entity) => + Boolean(entity.metadata.annotations?.[AZURE_DEVOPS_ANNOTATION]); + +export const Router = ({ defaultLimit }: { defaultLimit?: number }) => { + const { entity } = useEntity(); + + if (!isAzureDevOpsAvailable(entity)) { + return ; + } + + return ( + + } + /> + + ); +}; diff --git a/plugins/azure-devops/src/constants.ts b/plugins/azure-devops/src/constants.ts new file mode 100644 index 0000000000..d6e67ff7ed --- /dev/null +++ b/plugins/azure-devops/src/constants.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const AZURE_DEVOPS_ANNOTATION = 'dev.azure.com/project-repo'; +export const AZURE_DEVOPS_DEFAULT_TOP: number = 10; diff --git a/plugins/azure-devops/src/hooks/useProjectRepoFromEntity.ts b/plugins/azure-devops/src/hooks/useProjectRepoFromEntity.ts new file mode 100644 index 0000000000..418badc630 --- /dev/null +++ b/plugins/azure-devops/src/hooks/useProjectRepoFromEntity.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; +import { AZURE_DEVOPS_ANNOTATION } from '../constants'; + +export function useProjectRepoFromEntity(entity: Entity): { + project: string; + repo: string; +} { + const [project, repo] = ( + entity.metadata.annotations?.[AZURE_DEVOPS_ANNOTATION] ?? '' + ).split('/'); + + if (!project && !repo) { + throw new Error( + 'Value for annotation dev.azure.com/project-repo was not in the correct format: /', + ); + } + + if (!project) { + throw new Error( + 'Project Name for annotation dev.azure.com/project-repo was not found; expected format is: /', + ); + } + + if (!repo) { + throw new Error( + 'Repo Name for annotation dev.azure.com/project-repo was not found; expected format is: /', + ); + } + + return { project, repo }; +} diff --git a/plugins/azure-devops/src/hooks/useRepoBuilds.ts b/plugins/azure-devops/src/hooks/useRepoBuilds.ts new file mode 100644 index 0000000000..14a8a79428 --- /dev/null +++ b/plugins/azure-devops/src/hooks/useRepoBuilds.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useAsync } from 'react-use'; +import { Entity } from '@backstage/catalog-model'; +import { useApi } from '@backstage/core-plugin-api'; +import { azureDevOpsApiRef } from '../api'; +import { RepoBuild, RepoBuildOptions } from '../api/types'; +import { useProjectRepoFromEntity } from './useProjectRepoFromEntity'; +import { AZURE_DEVOPS_DEFAULT_TOP } from '../constants'; + +export function useRepoBuilds( + entity: Entity, + defaultLimit?: number, +): { + items: RepoBuild[] | undefined; + loading: boolean; + error: any; +} { + const top = defaultLimit ?? AZURE_DEVOPS_DEFAULT_TOP; + const options: RepoBuildOptions = { + top: top, + }; + const api = useApi(azureDevOpsApiRef); + const { project, repo } = useProjectRepoFromEntity(entity); + const { value, loading, error } = useAsync(() => { + return api.getRepoBuilds(project, repo, options); + }, [api, project, repo, entity]); + + return { + items: value?.items, + loading, + error, + }; +} diff --git a/plugins/azure-devops/src/index.ts b/plugins/azure-devops/src/index.ts new file mode 100644 index 0000000000..9a90d6ed6a --- /dev/null +++ b/plugins/azure-devops/src/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { azureDevOpsPlugin, EntityAzurePipelinesContent } from './plugin'; +export { isAzureDevOpsAvailable } from './components/Router'; diff --git a/plugins/azure-devops/src/plugin.test.ts b/plugins/azure-devops/src/plugin.test.ts new file mode 100644 index 0000000000..f23639798f --- /dev/null +++ b/plugins/azure-devops/src/plugin.test.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { azureDevOpsPlugin } from './plugin'; + +describe('azure-devops', () => { + it('should export plugin', () => { + expect(azureDevOpsPlugin).toBeDefined(); + }); +}); diff --git a/plugins/azure-devops/src/plugin.ts b/plugins/azure-devops/src/plugin.ts new file mode 100644 index 0000000000..1cf6e680d0 --- /dev/null +++ b/plugins/azure-devops/src/plugin.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { azureDevOpsApiRef } from './api/AzureDevOpsApi'; +import { AzureDevOpsClient } from './api/AzureDevOpsClient'; +import { + createApiFactory, + createPlugin, + createRoutableExtension, + discoveryApiRef, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { azureDevOpsRouteRef } from './routes'; + +export const azureDevOpsPlugin = createPlugin({ + id: 'azureDevOps', + apis: [ + createApiFactory({ + api: azureDevOpsApiRef, + deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, + factory: ({ discoveryApi, identityApi }) => + new AzureDevOpsClient({ discoveryApi, identityApi }), + }), + ], + routes: { + entityContent: azureDevOpsRouteRef, + }, +}); + +export const EntityAzurePipelinesContent = azureDevOpsPlugin.provide( + createRoutableExtension({ + name: 'EntityAzurePipelinesContent', + component: () => import('./components/Router').then(m => m.Router), + mountPoint: azureDevOpsRouteRef, + }), +); diff --git a/plugins/azure-devops/src/routes.ts b/plugins/azure-devops/src/routes.ts new file mode 100644 index 0000000000..81f4b48fda --- /dev/null +++ b/plugins/azure-devops/src/routes.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createRouteRef } from '@backstage/core-plugin-api'; + +export const azureDevOpsRouteRef = createRouteRef({ + title: 'azure-devops', +}); diff --git a/plugins/azure-devops/src/setupTests.ts b/plugins/azure-devops/src/setupTests.ts new file mode 100644 index 0000000000..fc6dbd98f8 --- /dev/null +++ b/plugins/azure-devops/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/plugins/catalog-backend/src/database/DefaultProcessingDatabase.test.ts b/plugins/catalog-backend/src/database/DefaultProcessingDatabase.test.ts index 00b3de2fc7..905339af17 100644 --- a/plugins/catalog-backend/src/database/DefaultProcessingDatabase.test.ts +++ b/plugins/catalog-backend/src/database/DefaultProcessingDatabase.test.ts @@ -1182,4 +1182,134 @@ describe('Default Processing Database', () => { 60_000, ); }); + + describe('listAncestors', () => { + let nextId = 1; + function makeEntity(ref: string) { + return { + entity_id: String(nextId++), + entity_ref: ref, + unprocessed_entity: JSON.stringify({ + kind: 'Location', + apiVersion: '1.0.0', + metadata: { + name: 'xyz', + }, + }), + errors: '[]', + next_update_at: '2019-01-01 23:00:00', + last_discovery_at: '2021-04-01 13:37:00', + }; + } + + it.each(databases.eachSupportedId())( + 'should return ancestors, %p', + async databaseId => { + const { knex, db } = await createDatabase(databaseId); + + await knex('refresh_state').insert( + makeEntity('location:default/root-1'), + ); + await knex('refresh_state').insert( + makeEntity('location:default/root-2'), + ); + await knex('refresh_state').insert( + makeEntity('component:default/foobar'), + ); + + await insertRefRow(knex, { + source_key: 'source', + target_entity_ref: 'location:default/root-2', + }); + await insertRefRow(knex, { + source_entity_ref: 'location:default/root-2', + target_entity_ref: 'location:default/root-1', + }); + await insertRefRow(knex, { + source_entity_ref: 'location:default/root-1', + target_entity_ref: 'component:default/foobar', + }); + + const result = await db.transaction(async tx => + db.listAncestors(tx, { entityRef: 'component:default/foobar' }), + ); + expect(result.entityRefs).toEqual([ + 'location:default/root-1', + 'location:default/root-2', + ]); + }, + ); + }); + + describe('listParents', () => { + let nextId = 1; + function makeEntity(ref: string) { + return { + entity_id: String(nextId++), + entity_ref: ref, + unprocessed_entity: JSON.stringify({ + kind: 'Location', + apiVersion: '1.0.0', + metadata: { + name: 'xyz', + }, + }), + errors: '[]', + next_update_at: '2019-01-01 23:00:00', + last_discovery_at: '2021-04-01 13:37:00', + }; + } + + it.each(databases.eachSupportedId())( + 'should return parents, %p', + async databaseId => { + const { knex, db } = await createDatabase(databaseId); + + await knex('refresh_state').insert( + makeEntity('location:default/root-1'), + ); + await knex('refresh_state').insert( + makeEntity('location:default/root-2'), + ); + await knex('refresh_state').insert( + makeEntity('component:default/foobar'), + ); + + await insertRefRow(knex, { + source_key: 'source', + target_entity_ref: 'location:default/root-2', + }); + await insertRefRow(knex, { + source_entity_ref: 'location:default/root-2', + target_entity_ref: 'location:default/root-1', + }); + await insertRefRow(knex, { + source_entity_ref: 'location:default/root-1', + target_entity_ref: 'component:default/foobar', + }); + await insertRefRow(knex, { + source_entity_ref: 'location:default/root-2', + target_entity_ref: 'component:default/foobar', + }); + + const result1 = await db.transaction(async tx => + db.listParents(tx, { entityRef: 'component:default/foobar' }), + ); + expect(result1.entityRefs).toEqual([ + 'location:default/root-1', + 'location:default/root-2', + ]); + + const result2 = await db.transaction(async tx => + db.listParents(tx, { entityRef: 'location:default/root-1' }), + ); + expect(result2.entityRefs).toEqual(['location:default/root-2']); + + const result3 = await db.transaction(async tx => + db.listParents(tx, { entityRef: 'location:default/root-2' }), + ); + expect(result3.entityRefs).toEqual([]); + }, + ); + }); }); diff --git a/plugins/catalog-backend/src/database/DefaultProcessingDatabase.ts b/plugins/catalog-backend/src/database/DefaultProcessingDatabase.ts index 9785ac3935..0b9d3653a0 100644 --- a/plugins/catalog-backend/src/database/DefaultProcessingDatabase.ts +++ b/plugins/catalog-backend/src/database/DefaultProcessingDatabase.ts @@ -31,6 +31,8 @@ import { ListAncestorsOptions, ListAncestorsResult, UpdateEntityCacheOptions, + ListParentsOptions, + ListParentsResult, } from './types'; import { DeferredEntity } from '../processing/types'; import { RefreshIntervalFunction } from '../processing/refresh'; @@ -419,6 +421,23 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { ); } + async listParents( + txOpaque: Transaction, + options: ListParentsOptions, + ): Promise { + const tx = txOpaque as Knex.Transaction; + + const rows = await tx( + 'refresh_state_references', + ) + .where({ target_entity_ref: options.entityRef }) + .select(); + + const entityRefs = rows.map(r => r.source_entity_ref!).filter(Boolean); + + return { entityRefs }; + } + async refresh(txOpaque: Transaction, options: RefreshOptions): Promise { const tx = txOpaque as Knex.Transaction; const { entityRef } = options; diff --git a/plugins/catalog-backend/src/database/types.ts b/plugins/catalog-backend/src/database/types.ts index 041a3fdc5b..9d28387e2c 100644 --- a/plugins/catalog-backend/src/database/types.ts +++ b/plugins/catalog-backend/src/database/types.ts @@ -91,6 +91,14 @@ export type ListAncestorsResult = { entityRefs: string[]; }; +export type ListParentsOptions = { + entityRef: string; +}; + +export type ListParentsResult = { + entityRefs: string[]; +}; + export interface ProcessingDatabase { transaction(fn: (tx: Transaction) => Promise): Promise; @@ -148,4 +156,9 @@ export interface ProcessingDatabase { txOpaque: Transaction, options: ListAncestorsOptions, ): Promise; + + listParents( + txOpaque: Transaction, + options: ListParentsOptions, + ): Promise; } diff --git a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.test.ts b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.test.ts index 044c3e7c4b..6b55b8b9eb 100644 --- a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.test.ts +++ b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.test.ts @@ -29,6 +29,7 @@ describe('DefaultCatalogProcessingEngine', () => { getProcessableEntities: jest.fn(), updateProcessedEntity: jest.fn(), updateEntityCache: jest.fn(), + listParents: jest.fn(), } as unknown as jest.Mocked; const orchestrator: jest.Mocked = { process: jest.fn(), @@ -209,18 +210,19 @@ describe('DefaultCatalogProcessingEngine', () => { db.transaction.mockImplementation(cb => cb((() => {}) as any)); + db.listParents.mockResolvedValue({ entityRefs: [] }); db.getProcessableEntities .mockResolvedValueOnce({ items: [{ ...refreshState, resultHash: 'NOT RIGHT' }], }) .mockResolvedValue({ items: [] }); - await engine.start(); await waitForExpect(() => { expect(orchestrator.process).toBeCalledTimes(1); expect(hash.digest).toBeCalledTimes(1); expect(db.updateProcessedEntity).toBeCalledTimes(1); + expect(db.listParents).toBeCalledTimes(1); }); expect(db.updateEntityCache).not.toHaveBeenCalled(); @@ -236,6 +238,7 @@ describe('DefaultCatalogProcessingEngine', () => { expect(hash.digest).toBeCalledTimes(2); expect(db.updateProcessedEntity).toBeCalledTimes(1); expect(db.updateEntityCache).toBeCalledTimes(1); + expect(db.listParents).toBeCalledTimes(2); }); expect(db.updateEntityCache).toHaveBeenCalledWith(expect.anything(), { id: '', diff --git a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts index d0faff25d5..59cd4d94ae 100644 --- a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts +++ b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts @@ -189,10 +189,18 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { let hashBuilder = this.createHash().update(errorsString); if (result.ok) { + const { entityRefs: parents } = + await this.processingDatabase.transaction(tx => + this.processingDatabase.listParents(tx, { + entityRef, + }), + ); + hashBuilder = hashBuilder .update(stableStringify({ ...result.completedEntity })) .update(stableStringify([...result.deferredEntities])) - .update(stableStringify([...result.relations])); + .update(stableStringify([...result.relations])) + .update(stableStringify([...parents])); } const resultHash = hashBuilder.digest('hex'); diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index a1cd98124a..57b0b899e2 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -10,6 +10,14 @@ import { Extension } from '@backstage/core-plugin-api'; import { ReactNode } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; +// Warning: (ae-missing-release-tag) "ClockConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type ClockConfig = { + label: string; + timeZone: string; +}; + // Warning: (ae-missing-release-tag) "ComponentAccordion" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -76,6 +84,15 @@ export function createCardExtension({ } & T) => JSX.Element >; +// Warning: (ae-missing-release-tag) "HeaderWorldClock" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const HeaderWorldClock: ({ + clockConfigs, +}: { + clockConfigs: ClockConfig[]; +}) => JSX.Element | null; + // Warning: (ae-missing-release-tag) "HomepageCompositionRoot" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/plugins/home/src/componentRenderers/ComponentTabs/ComponentTabs.test.tsx b/plugins/home/src/componentRenderers/ComponentTabs/ComponentTabs.test.tsx new file mode 100644 index 0000000000..96ab37a8aa --- /dev/null +++ b/plugins/home/src/componentRenderers/ComponentTabs/ComponentTabs.test.tsx @@ -0,0 +1,76 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { ComponentTabs } from './ComponentTabs'; + +describe('', () => { + test('should render tabs without exploding', () => { + const { getByText } = render( + <>ContentA, + }, + { + label: 'TabB', + Component: () => <>ContentB, + }, + ]} + />, + ); + + expect(getByText('TabA')).toBeInTheDocument(); + expect(getByText('TabB')).toBeInTheDocument(); + + expect(getByText('ContentA')).toBeInTheDocument(); + expect(getByText('ContentB')).toHaveStyle({ + display: 'none', + }); + }); + + test('should switch tab on click', () => { + const { getByText } = render( + <>ContentA, + }, + { + label: 'TabB', + Component: () => <>ContentB, + }, + ]} + />, + ); + + expect(getByText('ContentB')).toHaveStyle({ + display: 'none', + }); + + userEvent.click(getByText('TabB')); + + expect(getByText('ContentA')).toHaveStyle({ + display: 'none', + }); + }); +}); diff --git a/plugins/home/src/componentRenderers/ComponentTabs/ComponentTabs.tsx b/plugins/home/src/componentRenderers/ComponentTabs/ComponentTabs.tsx index f404a80980..d6d3561fe1 100644 --- a/plugins/home/src/componentRenderers/ComponentTabs/ComponentTabs.tsx +++ b/plugins/home/src/componentRenderers/ComponentTabs/ComponentTabs.tsx @@ -44,7 +44,10 @@ export const ComponentTabs = ({ ))} {tabs.map(({ Component }, idx) => ( -
+
))} diff --git a/plugins/home/src/components/HeaderWorldClock/HeaderWorldClock.test.tsx b/plugins/home/src/components/HeaderWorldClock/HeaderWorldClock.test.tsx new file mode 100644 index 0000000000..9dbfceac81 --- /dev/null +++ b/plugins/home/src/components/HeaderWorldClock/HeaderWorldClock.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderInTestApp } from '@backstage/test-utils'; +import { HeaderWorldClock, ClockConfig } from './HeaderWorldClock'; +import React from 'react'; +import { lightTheme } from '@backstage/theme'; +import { ThemeProvider } from '@material-ui/core'; + +describe('HeaderWorldClock with valid Time Zones', () => { + it('displays Time Zones as expected', async () => { + const clockConfigs: ClockConfig[] = [ + { + label: 'NYC', + timeZone: 'America/New_York', + }, + { + label: 'UTC', + timeZone: 'UTC', + }, + { + label: 'STO', + timeZone: 'Europe/Stockholm', + }, + { + label: 'TYO', + timeZone: 'Asia/Tokyo', + }, + ]; + + const rendered = await renderInTestApp( + + + , + ); + + expect(rendered.getByText('NYC')).toBeInTheDocument(); + expect(rendered.getByText('UTC')).toBeInTheDocument(); + expect(rendered.getByText('STO')).toBeInTheDocument(); + expect(rendered.getByText('TYO')).toBeInTheDocument(); + }); +}); + +describe('HeaderWorldClock with no Time Zones provided', () => { + it('should not appear in output', async () => { + const clockConfigs: ClockConfig[] = []; + + const rendered = await renderInTestApp( + + + , + ); + + expect(rendered.container).toBeEmptyDOMElement(); + }); +}); + +describe('HeaderWorldClock with invalid Time Zone', () => { + it('uses GMT as fallback Time Zone', async () => { + const clockConfigs: ClockConfig[] = [ + { + label: 'New York', + timeZone: 'America/New_Pork', + }, + ]; + + const rendered = await renderInTestApp( + + + , + ); + + expect(rendered.getByText('GMT')).toBeInTheDocument(); + }); +}); diff --git a/plugins/home/src/components/HeaderWorldClock/HeaderWorldClock.tsx b/plugins/home/src/components/HeaderWorldClock/HeaderWorldClock.tsx new file mode 100644 index 0000000000..32bf305020 --- /dev/null +++ b/plugins/home/src/components/HeaderWorldClock/HeaderWorldClock.tsx @@ -0,0 +1,105 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { HeaderLabel } from '@backstage/core-components'; + +const timeFormat: Intl.DateTimeFormatOptions = { + hour: '2-digit', + minute: '2-digit', +}; + +type TimeObj = { + time: string; + label: string; +}; + +export type ClockConfig = { + label: string; + timeZone: string; +}; + +function getTimes(clockConfigs: ClockConfig[]) { + const d = new Date(); + const lang = window.navigator.language; + + const clocks: TimeObj[] = []; + + if (!clockConfigs) { + return clocks; + } + + for (const clockConfig of clockConfigs) { + let label = clockConfig.label; + + const options: Intl.DateTimeFormatOptions = { + timeZone: clockConfig.timeZone, + ...timeFormat, + }; + + try { + new Date().toLocaleString(lang, options); + } catch (e) { + // eslint-disable-next-line no-console + console.warn( + `The timezone ${options.timeZone} is invalid. Defaulting to GMT`, + ); + options.timeZone = 'GMT'; + label = 'GMT'; + } + + const time = d.toLocaleTimeString(lang, options); + clocks.push({ time, label }); + } + + return clocks; +} + +export const HeaderWorldClock = ({ + clockConfigs, +}: { + clockConfigs: ClockConfig[]; +}) => { + const defaultTimes: TimeObj[] = []; + const [clocks, setTimes] = React.useState(defaultTimes); + + React.useEffect(() => { + setTimes(getTimes(clockConfigs)); + + const intervalId = setInterval(() => { + setTimes(getTimes(clockConfigs)); + }, 1000); + + return () => { + clearInterval(intervalId); + }; + }, [clockConfigs]); + + if (clocks.length !== 0) { + return ( + <> + {clocks.map(clock => ( + + ))} + + ); + } + return null; +}; diff --git a/plugins/home/src/components/HeaderWorldClock/index.ts b/plugins/home/src/components/HeaderWorldClock/index.ts new file mode 100644 index 0000000000..0893c0586c --- /dev/null +++ b/plugins/home/src/components/HeaderWorldClock/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { HeaderWorldClock } from './HeaderWorldClock'; +export type { ClockConfig } from './HeaderWorldClock'; diff --git a/plugins/home/src/components/index.ts b/plugins/home/src/components/index.ts index aae0cbe26d..1408853f5f 100644 --- a/plugins/home/src/components/index.ts +++ b/plugins/home/src/components/index.ts @@ -16,3 +16,5 @@ export { HomepageCompositionRoot } from './HomepageCompositionRoot'; export { SettingsModal } from './SettingsModal'; +export { HeaderWorldClock } from './HeaderWorldClock'; +export type { ClockConfig } from './HeaderWorldClock'; diff --git a/plugins/home/src/index.ts b/plugins/home/src/index.ts index 0004cf5a23..a8b8e7ab14 100644 --- a/plugins/home/src/index.ts +++ b/plugins/home/src/index.ts @@ -29,5 +29,6 @@ export { ComponentTab, WelcomeTitle, } from './plugin'; -export { SettingsModal } from './components'; +export { SettingsModal, HeaderWorldClock } from './components'; +export type { ClockConfig } from './components'; export { createCardExtension } from './extensions'; diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index 3d52c4ff43..c8211a5323 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -3,13 +3,13 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { ClusterObjects } from '@backstage/plugin-kubernetes-common'; import { Config } from '@backstage/config'; import express from 'express'; import type { FetchResponse } from '@backstage/plugin-kubernetes-common'; import type { KubernetesFetchError } from '@backstage/plugin-kubernetes-common'; -import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; +import type { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { Logger as Logger_2 } from 'winston'; +import type { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common'; // Warning: (ae-missing-release-tag) "AWSClusterDetails" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -40,7 +40,7 @@ export interface ClusterDetails { // Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public @deprecated export function createRouter(options: RouterOptions): Promise; // Warning: (ae-missing-release-tag) "CustomResource" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -71,6 +71,71 @@ export interface FetchResponseWrapper { // @public (undocumented) export interface GKEClusterDetails extends ClusterDetails {} +// Warning: (ae-missing-release-tag) "KubernetesBuilder" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class KubernetesBuilder { + constructor(env: KubernetesEnvironment); + // (undocumented) + build(): Promise<{ + clusterDetails: ClusterDetails[]; + clusterSupplier: KubernetesClustersSupplier; + customResources: CustomResource[]; + fetcher: KubernetesFetcher; + objectsProvider: KubernetesObjectsProvider; + router: express.Router; + serviceLocator: KubernetesServiceLocator; + }>; + // (undocumented) + protected buildClusterSupplier(): KubernetesClustersSupplier; + // (undocumented) + protected buildCustomResources(): CustomResource[]; + // (undocumented) + protected buildFetcher(): KubernetesFetcher; + // (undocumented) + protected buildHttpServiceLocator( + _clusterDetails: ClusterDetails[], + ): KubernetesServiceLocator; + // (undocumented) + protected buildMultiTenantServiceLocator( + clusterDetails: ClusterDetails[], + ): KubernetesServiceLocator; + // (undocumented) + protected buildObjectsProvider( + options: KubernetesObjectsProviderOptions, + ): KubernetesObjectsProvider; + // (undocumented) + protected buildRouter( + objectsProvider: KubernetesObjectsProvider, + clusterDetails: ClusterDetails[], + ): express.Router; + // (undocumented) + protected buildServiceLocator( + method: ServiceLocatorMethod, + clusterDetails: ClusterDetails[], + ): KubernetesServiceLocator; + // (undocumented) + static createBuilder(env: KubernetesEnvironment): KubernetesBuilder; + // (undocumented) + protected readonly env: KubernetesEnvironment; + // (undocumented) + protected fetchClusterDetails( + clusterSupplier: KubernetesClustersSupplier, + ): Promise; + // (undocumented) + protected getObjectTypesToFetch(): ObjectToFetch[] | undefined; + // (undocumented) + protected getServiceLocatorMethod(): ServiceLocatorMethod; + // (undocumented) + setClusterSupplier(clusterSupplier?: KubernetesClustersSupplier): this; + // (undocumented) + setFetcher(fetcher?: KubernetesFetcher): this; + // (undocumented) + setObjectsProvider(objectsProvider?: KubernetesObjectsProvider): this; + // (undocumented) + setServiceLocator(serviceLocator?: KubernetesServiceLocator): this; +} + // Warning: (ae-missing-release-tag) "KubernetesClustersSupplier" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -79,6 +144,16 @@ export interface KubernetesClustersSupplier { getClusters(): Promise; } +// Warning: (ae-missing-release-tag) "KubernetesEnvironment" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface KubernetesEnvironment { + // (undocumented) + config: Config; + // (undocumented) + logger: Logger_2; +} + // Warning: (ae-missing-release-tag) "KubernetesFetcher" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -89,6 +164,32 @@ export interface KubernetesFetcher { ): Promise; } +// Warning: (ae-missing-release-tag) "KubernetesObjectsProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface KubernetesObjectsProvider { + // (undocumented) + getKubernetesObjectsByEntity( + request: ObjectsByEntityRequest, + ): Promise; +} + +// Warning: (ae-missing-release-tag) "KubernetesObjectsProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface KubernetesObjectsProviderOptions { + // (undocumented) + customResources: CustomResource[]; + // (undocumented) + fetcher: KubernetesFetcher; + // (undocumented) + logger: Logger_2; + // (undocumented) + objectTypesToFetch?: ObjectToFetch[]; + // (undocumented) + serviceLocator: KubernetesServiceLocator; +} + // Warning: (ae-missing-release-tag) "KubernetesObjectTypes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -110,16 +211,6 @@ export interface KubernetesServiceLocator { getClustersByServiceId(serviceId: string): Promise; } -// Warning: (ae-forgotten-export) The symbol "KubernetesFanOutHandler" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "makeRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export const makeRouter: ( - logger: Logger_2, - kubernetesFanOutHandler: KubernetesFanOutHandler, - clusterDetails: ClusterDetails[], -) => express.Router; - // Warning: (ae-missing-release-tag) "ObjectFetchParams" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -140,6 +231,11 @@ export interface ObjectFetchParams { serviceId: string; } +// Warning: (ae-missing-release-tag) "ObjectsByEntityRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type ObjectsByEntityRequest = KubernetesRequestBody; + // Warning: (ae-missing-release-tag) "ObjectToFetch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/plugins/kubernetes-backend/src/index.ts b/plugins/kubernetes-backend/src/index.ts index 29818f805c..ac514db104 100644 --- a/plugins/kubernetes-backend/src/index.ts +++ b/plugins/kubernetes-backend/src/index.ts @@ -21,6 +21,7 @@ */ export * from './service/router'; +export * from './service/KubernetesBuilder'; export * from './types/types'; export { DEFAULT_OBJECTS } from './service/KubernetesFanOutHandler'; diff --git a/plugins/kubernetes-backend/src/service/router.test.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts similarity index 54% rename from plugins/kubernetes-backend/src/service/router.test.ts rename to plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts index c70ae44406..1b90e65fd7 100644 --- a/plugins/kubernetes-backend/src/service/router.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts @@ -14,22 +14,32 @@ * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; +import { Config } from '@backstage/config'; +import { getVoidLogger, loadBackendConfig } from '@backstage/backend-common'; import express from 'express'; import request from 'supertest'; -import { makeRouter } from './router'; import { KubernetesFanOutHandler } from './KubernetesFanOutHandler'; +import { + ClusterDetails, + FetchResponseWrapper, + KubernetesClustersSupplier, + KubernetesFetcher, + KubernetesServiceLocator, + ObjectFetchParams, +} from '../types/types'; +import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common'; +import { KubernetesBuilder } from './KubernetesBuilder'; -describe('router', () => { +describe('KubernetesBuilder', () => { let app: express.Express; let kubernetesFanOutHandler: jest.Mocked; + let config: Config; beforeAll(async () => { - kubernetesFanOutHandler = { - getKubernetesObjectsByEntity: jest.fn(), - } as any; + const logger = getVoidLogger(); + config = await loadBackendConfig({ logger, argv: [] }); - const router = makeRouter(getVoidLogger(), kubernetesFanOutHandler, [ + const clusters: ClusterDetails[] = [ { name: 'some-cluster', authProvider: 'serviceAccount', @@ -41,7 +51,22 @@ describe('router', () => { url: 'https://localhost:1235', authProvider: 'google', }, - ]); + ]; + const clusterSupplier: KubernetesClustersSupplier = { + async getClusters() { + return clusters; + }, + }; + + kubernetesFanOutHandler = { + getKubernetesObjectsByEntity: jest.fn(), + } as any; + + const { router } = await KubernetesBuilder.createBuilder({ config, logger }) + .setObjectsProvider(kubernetesFanOutHandler) + .setClusterSupplier(clusterSupplier) + .build(); + app = express().use(router); }); @@ -130,5 +155,86 @@ describe('router', () => { expect(response.status).toEqual(500); expect(response.body).toEqual({ error: 'some internal error' }); }); + + it('custom service locator', async () => { + const logger = getVoidLogger(); + const someCluster: ClusterDetails = { + name: 'some-cluster', + authProvider: 'serviceAccount', + url: 'https://localhost:1234', + serviceAccountToken: 'someToken', + }; + const clusters: ClusterDetails[] = [ + someCluster, + { + name: 'some-other-cluster', + url: 'https://localhost:1235', + authProvider: 'google', + }, + ]; + const clusterSupplier: KubernetesClustersSupplier = { + async getClusters() { + return clusters; + }, + }; + const pod = { + metadata: { + name: 'pod1', + }, + }; + const result: ObjectsByEntityResponse = { + items: [ + { + cluster: { + name: someCluster.name, + }, + errors: [], + resources: [ + { + type: 'pods', + resources: [pod], + }, + ], + }, + ], + }; + + const serviceLocator: KubernetesServiceLocator = { + getClustersByServiceId(_serviceId: string): Promise { + return Promise.resolve([someCluster]); + }, + }; + + const fetcher: KubernetesFetcher = { + fetchObjectsForService( + _params: ObjectFetchParams, + ): Promise { + return Promise.resolve({ + errors: [], + responses: [ + { + type: 'pods', + resources: [pod], + }, + ], + }); + }, + }; + + const { router } = await KubernetesBuilder.createBuilder({ + logger, + config, + }) + .setClusterSupplier(clusterSupplier) + .setServiceLocator(serviceLocator) + .setFetcher(fetcher) + .build(); + app = express().use(router); + + const response = await request(app).post('/services/test-service'); + + expect(response.body).toEqual(result); + expect(response.status).toEqual(200); + }); }); }); diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts new file mode 100644 index 0000000000..bde538bf55 --- /dev/null +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -0,0 +1,254 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Config } from '@backstage/config'; +import express from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; +import { getCombinedClusterDetails } from '../cluster-locator'; +import { MultiTenantServiceLocator } from '../service-locator/MultiTenantServiceLocator'; +import { + ClusterDetails, + KubernetesObjectTypes, + ServiceLocatorMethod, + CustomResource, + KubernetesObjectsProvider, + ObjectsByEntityRequest, + KubernetesClustersSupplier, + KubernetesFetcher, + KubernetesServiceLocator, + KubernetesObjectsProviderOptions, +} from '../types/types'; +import { KubernetesClientProvider } from './KubernetesClientProvider'; +import { + DEFAULT_OBJECTS, + KubernetesFanOutHandler, +} from './KubernetesFanOutHandler'; +import { KubernetesClientBasedFetcher } from './KubernetesFetcher'; + +export interface KubernetesEnvironment { + logger: Logger; + config: Config; +} + +export class KubernetesBuilder { + private clusterSupplier?: KubernetesClustersSupplier; + private objectsProvider?: KubernetesObjectsProvider; + private fetcher?: KubernetesFetcher; + private serviceLocator?: KubernetesServiceLocator; + + static createBuilder(env: KubernetesEnvironment) { + return new KubernetesBuilder(env); + } + + constructor(protected readonly env: KubernetesEnvironment) {} + + public async build() { + const logger = this.env.logger; + + logger.info('Initializing Kubernetes backend'); + + const customResources = this.buildCustomResources(); + + const fetcher = this.fetcher ?? this.buildFetcher(); + + const clusterSupplier = this.clusterSupplier ?? this.buildClusterSupplier(); + + const clusterDetails = await this.fetchClusterDetails(clusterSupplier); + + const serviceLocator = + this.serviceLocator ?? + this.buildServiceLocator(this.getServiceLocatorMethod(), clusterDetails); + + const objectsProvider = + this.objectsProvider ?? + this.buildObjectsProvider({ + logger, + fetcher, + serviceLocator, + customResources, + objectTypesToFetch: this.getObjectTypesToFetch(), + }); + + const router = this.buildRouter(objectsProvider, clusterDetails); + + return { + clusterDetails, + clusterSupplier, + customResources, + fetcher, + objectsProvider, + router, + serviceLocator, + }; + } + + public setClusterSupplier(clusterSupplier?: KubernetesClustersSupplier) { + this.clusterSupplier = clusterSupplier; + return this; + } + + public setObjectsProvider(objectsProvider?: KubernetesObjectsProvider) { + this.objectsProvider = objectsProvider; + return this; + } + + public setFetcher(fetcher?: KubernetesFetcher) { + this.fetcher = fetcher; + return this; + } + + public setServiceLocator(serviceLocator?: KubernetesServiceLocator) { + this.serviceLocator = serviceLocator; + return this; + } + + protected buildCustomResources() { + const customResources: CustomResource[] = ( + this.env.config.getOptionalConfigArray('kubernetes.customResources') ?? [] + ).map( + c => + ({ + group: c.getString('group'), + apiVersion: c.getString('apiVersion'), + plural: c.getString('plural'), + } as CustomResource), + ); + + this.env.logger.info( + `action=LoadingCustomResources numOfCustomResources=${customResources.length}`, + ); + return customResources; + } + + protected buildClusterSupplier(): KubernetesClustersSupplier { + const config = this.env.config; + return { + getClusters() { + return getCombinedClusterDetails(config); + }, + }; + } + + protected buildObjectsProvider( + options: KubernetesObjectsProviderOptions, + ): KubernetesObjectsProvider { + return new KubernetesFanOutHandler(options); + } + + protected buildFetcher(): KubernetesFetcher { + return new KubernetesClientBasedFetcher({ + kubernetesClientProvider: new KubernetesClientProvider(), + logger: this.env.logger, + }); + } + + protected buildServiceLocator( + method: ServiceLocatorMethod, + clusterDetails: ClusterDetails[], + ): KubernetesServiceLocator { + switch (method) { + case 'multiTenant': + return this.buildMultiTenantServiceLocator(clusterDetails); + case 'http': + return this.buildHttpServiceLocator(clusterDetails); + default: + throw new Error( + `Unsupported kubernetes.clusterLocatorMethod "${method}"`, + ); + } + } + + protected buildMultiTenantServiceLocator( + clusterDetails: ClusterDetails[], + ): KubernetesServiceLocator { + return new MultiTenantServiceLocator(clusterDetails); + } + + protected buildHttpServiceLocator( + _clusterDetails: ClusterDetails[], + ): KubernetesServiceLocator { + throw new Error('not implemented'); + } + + protected buildRouter( + objectsProvider: KubernetesObjectsProvider, + clusterDetails: ClusterDetails[], + ): express.Router { + const logger = this.env.logger; + const router = Router(); + router.use(express.json()); + + router.post('/services/:serviceId', async (req, res) => { + const serviceId = req.params.serviceId; + const requestBody: ObjectsByEntityRequest = req.body; + try { + const response = await objectsProvider.getKubernetesObjectsByEntity( + requestBody, + ); + res.json(response); + } catch (e) { + logger.error( + `action=retrieveObjectsByServiceId service=${serviceId}, error=${e}`, + ); + res.status(500).json({ error: e.message }); + } + }); + + router.get('/clusters', async (_, res) => { + res.json({ + items: clusterDetails.map(cd => ({ + name: cd.name, + dashboardUrl: cd.dashboardUrl, + authProvider: cd.authProvider, + })), + }); + }); + return router; + } + + protected async fetchClusterDetails( + clusterSupplier: KubernetesClustersSupplier, + ) { + const clusterDetails = await clusterSupplier.getClusters(); + + this.env.logger.info( + `action=loadClusterDetails numOfClustersLoaded=${clusterDetails.length}`, + ); + + return clusterDetails; + } + + protected getServiceLocatorMethod() { + return this.env.config.getString( + 'kubernetes.serviceLocatorMethod.type', + ) as ServiceLocatorMethod; + } + + protected getObjectTypesToFetch() { + const objectTypesToFetchStrings = this.env.config.getOptionalStringArray( + 'kubernetes.objectTypes', + ) as KubernetesObjectTypes[]; + + let objectTypesToFetch; + + if (objectTypesToFetchStrings) { + objectTypesToFetch = DEFAULT_OBJECTS.filter(obj => + objectTypesToFetchStrings.includes(obj.objectType), + ); + } + return objectTypesToFetch; + } +} diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts index d520c66d7a..1f856e90a9 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts @@ -19,15 +19,17 @@ import { ClusterDetails, CustomResource, KubernetesFetcher, + KubernetesObjectsProviderOptions, KubernetesServiceLocator, + ObjectsByEntityRequest, ObjectToFetch, } from '../types/types'; -import { - ClusterObjects, - KubernetesRequestBody, -} from '@backstage/plugin-kubernetes-common'; import { KubernetesAuthTranslator } from '../kubernetes-auth-translator/types'; import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator/KubernetesAuthTranslatorGenerator'; +import { + ClusterObjects, + ObjectsByEntityResponse, +} from '@backstage/plugin-kubernetes-common'; export const DEFAULT_OBJECTS: ObjectToFetch[] = [ { @@ -74,13 +76,10 @@ export const DEFAULT_OBJECTS: ObjectToFetch[] = [ }, ]; -export interface KubernetesFanOutHandlerOptions { - logger: Logger; - fetcher: KubernetesFetcher; - serviceLocator: KubernetesServiceLocator; - customResources: CustomResource[]; - objectTypesToFetch?: ObjectToFetch[]; -} +export interface KubernetesFanOutHandlerOptions + extends KubernetesObjectsProviderOptions {} + +export interface KubernetesRequestBody extends ObjectsByEntityRequest {} export class KubernetesFanOutHandler { private readonly logger: Logger; @@ -103,7 +102,9 @@ export class KubernetesFanOutHandler { this.objectTypesToFetch = new Set(objectTypesToFetch); } - async getKubernetesObjectsByEntity(requestBody: KubernetesRequestBody) { + async getKubernetesObjectsByEntity( + requestBody: KubernetesRequestBody, + ): Promise { const entityName = requestBody.entity?.metadata?.annotations?.[ 'backstage.io/kubernetes-id' diff --git a/plugins/kubernetes-backend/src/service/router.ts b/plugins/kubernetes-backend/src/service/router.ts index 3fc32dac8b..61082267be 100644 --- a/plugins/kubernetes-backend/src/service/router.ts +++ b/plugins/kubernetes-backend/src/service/router.ts @@ -15,26 +15,10 @@ */ import { Config } from '@backstage/config'; -import express from 'express'; -import Router from 'express-promise-router'; import { Logger } from 'winston'; -import { getCombinedClusterDetails } from '../cluster-locator'; -import { MultiTenantServiceLocator } from '../service-locator/MultiTenantServiceLocator'; -import { - ClusterDetails, - KubernetesClustersSupplier, - KubernetesObjectTypes, - KubernetesServiceLocator, - ServiceLocatorMethod, - CustomResource, -} from '../types/types'; -import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; -import { KubernetesClientProvider } from './KubernetesClientProvider'; -import { - KubernetesFanOutHandler, - DEFAULT_OBJECTS, -} from './KubernetesFanOutHandler'; -import { KubernetesClientBasedFetcher } from './KubernetesFetcher'; +import { KubernetesClustersSupplier } from '../types/types'; +import express from 'express'; +import { KubernetesBuilder } from './KubernetesBuilder'; export interface RouterOptions { logger: Logger; @@ -42,121 +26,24 @@ export interface RouterOptions { clusterSupplier?: KubernetesClustersSupplier; } -const getServiceLocator = ( - config: Config, - clusterDetails: ClusterDetails[], -): KubernetesServiceLocator => { - const serviceLocatorMethod = config.getString( - 'kubernetes.serviceLocatorMethod.type', - ) as ServiceLocatorMethod; - - switch (serviceLocatorMethod) { - case 'multiTenant': - return new MultiTenantServiceLocator(clusterDetails); - case 'http': - throw new Error('not implemented'); - default: - throw new Error( - `Unsupported kubernetes.clusterLocatorMethod "${serviceLocatorMethod}"`, - ); - } -}; - -export const makeRouter = ( - logger: Logger, - kubernetesFanOutHandler: KubernetesFanOutHandler, - clusterDetails: ClusterDetails[], -): express.Router => { - const router = Router(); - router.use(express.json()); - - router.post('/services/:serviceId', async (req, res) => { - const serviceId = req.params.serviceId; - const requestBody: KubernetesRequestBody = req.body; - try { - const response = - await kubernetesFanOutHandler.getKubernetesObjectsByEntity(requestBody); - res.json(response); - } catch (e) { - logger.error( - `action=retrieveObjectsByServiceId service=${serviceId}, error=${e}`, - ); - res.status(500).json({ error: e.message }); - } - }); - - router.get('/clusters', async (_, res) => { - res.json({ - items: clusterDetails.map(cd => ({ - name: cd.name, - dashboardUrl: cd.dashboardUrl, - authProvider: cd.authProvider, - })), - }); - }); - return router; -}; - +/** + * creates and configure a new router for handling the kubernetes backend APIs + * @param options - specifies the options required by this plugin + * @returns a new router + * @deprecated Please use the new KubernetesBuilder instead like this + * ``` + * import { KubernetesBuilder } from '@backstage/plugin-kubernetes-backend'; + * const { router } = await KubernetesBuilder.createBuilder({ + * logger, + * config, + * }).build(); + * ``` + */ export async function createRouter( options: RouterOptions, ): Promise { - const logger = options.logger; - - logger.info('Initializing Kubernetes backend'); - - const customResources: CustomResource[] = ( - options.config.getOptionalConfigArray('kubernetes.customResources') ?? [] - ).map( - c => - ({ - group: c.getString('group'), - apiVersion: c.getString('apiVersion'), - plural: c.getString('plural'), - objectType: 'customresources', - } as CustomResource), - ); - - logger.info( - `action=LoadingCustomResources numOfCustomResources=${customResources.length}`, - ); - - const fetcher = new KubernetesClientBasedFetcher({ - kubernetesClientProvider: new KubernetesClientProvider(), - logger, - }); - - let clusterDetails: ClusterDetails[]; - - if (options.clusterSupplier) { - clusterDetails = await options.clusterSupplier.getClusters(); - } else { - clusterDetails = await getCombinedClusterDetails(options.config); - } - - logger.info( - `action=loadClusterDetails numOfClustersLoaded=${clusterDetails.length}`, - ); - - const serviceLocator = getServiceLocator(options.config, clusterDetails); - const objectTypesToFetchStrings = options.config.getOptionalStringArray( - 'kubernetes.objectTypes', - ) as KubernetesObjectTypes[]; - - let objectTypesToFetch; - - if (objectTypesToFetchStrings) { - objectTypesToFetch = DEFAULT_OBJECTS.filter(obj => - objectTypesToFetchStrings.includes(obj.objectType), - ); - } - - const kubernetesFanOutHandler = new KubernetesFanOutHandler({ - logger, - fetcher, - serviceLocator, - customResources, - objectTypesToFetch, - }); - - return makeRouter(logger, kubernetesFanOutHandler, clusterDetails); + const { router } = await KubernetesBuilder.createBuilder(options) + .setClusterSupplier(options.clusterSupplier) + .build(); + return router; } diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index cff87f265a..30a8d2756c 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -14,9 +14,12 @@ * limitations under the License. */ +import { Logger } from 'winston'; import type { FetchResponse, KubernetesFetchError, + KubernetesRequestBody, + ObjectsByEntityResponse, } from '@backstage/plugin-kubernetes-common'; export interface ObjectFetchParams { @@ -121,3 +124,19 @@ export interface AWSClusterDetails extends ClusterDetails { assumeRole?: string; externalId?: string; } + +export interface KubernetesObjectsProviderOptions { + logger: Logger; + fetcher: KubernetesFetcher; + serviceLocator: KubernetesServiceLocator; + customResources: CustomResource[]; + objectTypesToFetch?: ObjectToFetch[]; +} + +export type ObjectsByEntityRequest = KubernetesRequestBody; + +export interface KubernetesObjectsProvider { + getKubernetesObjectsByEntity( + request: ObjectsByEntityRequest, + ): Promise; +} diff --git a/yarn.lock b/yarn.lock index 4352f27fac..f8bf6c8398 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19591,14 +19591,12 @@ min-indent@^1.0.0: resolved "https://registry.npmjs.org/min-indent/-/min-indent-1.0.0.tgz#cfc45c37e9ec0d8f0a0ec3dd4ef7f7c3abe39256" integrity sha1-z8RcN+nsDY8KDsPdTvf3w6vjklY= -mini-css-extract-plugin@^1.4.1: - version "1.4.1" - resolved "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-1.4.1.tgz#975e27c1d0bd8e052972415f47c79cea5ed37548" - integrity sha512-COAGbpAsU0ioFzj+/RRfO5Qv177L1Z/XAx2EmCF33b8GDDqKygMffBTws2lit8iaPdrbKEY5P+zsseBUCREZWQ== +mini-css-extract-plugin@^2.4.2: + version "2.4.2" + resolved "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.4.2.tgz#b3508191ea479388a4715018c99dd3e6dd40d2d2" + integrity sha512-ZmqShkn79D36uerdED+9qdo1ZYG8C1YsWvXu0UMJxurZnSdgz7gQKO2EGv8T55MhDqG3DYmGtizZNpM/UbTlcA== dependencies: - loader-utils "^2.0.0" - schema-utils "^3.0.0" - webpack-sources "^1.1.0" + schema-utils "^3.1.0" minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: version "1.0.1" @@ -24378,16 +24376,7 @@ schema-utils@^2.6.5, schema-utils@^2.6.6, schema-utils@^2.7.0: ajv "^6.12.4" ajv-keywords "^3.5.2" -schema-utils@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/schema-utils/-/schema-utils-3.0.0.tgz#67502f6aa2b66a2d4032b4279a2944978a0913ef" - integrity sha512-6D82/xSzO094ajanoOSbe4YvXWMfn2A//8Y1+MUqFAJul5Bs+yn36xbK9OtNDcRVSBJ9jjeoXftM6CfztsjOAA== - dependencies: - "@types/json-schema" "^7.0.6" - ajv "^6.12.5" - ajv-keywords "^3.5.2" - -schema-utils@^3.1.0, schema-utils@^3.1.1: +schema-utils@^3.0.0, schema-utils@^3.1.0, schema-utils@^3.1.1: version "3.1.1" resolved "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz#bc74c4b6b6995c1d88f76a8b77bea7219e0c8281" integrity sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw== @@ -27697,7 +27686,7 @@ webpack-node-externals@^3.0.0: resolved "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz#1a3407c158d547a9feb4229a9e3385b7b60c9917" integrity sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ== -webpack-sources@^1.1.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3: +webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3: version "1.4.3" resolved "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==