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/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/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/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/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/.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/)
+
+
+
+## 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/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 1a0caad7e9..c17580a415 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"
@@ -24373,16 +24371,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==
@@ -27692,7 +27681,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==