Merge branch 'master' into feat/dynatrace-plugin-synthetics
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs-node': minor
|
||||
---
|
||||
|
||||
Added local publishing target directory `config`: `techdocs.publisher.local.publishDirectory`
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
'@backstage/cli': minor
|
||||
---
|
||||
|
||||
**BREAKING**: Removed the following deprecated package commands:
|
||||
|
||||
- `app:build` - Use `package build` instead
|
||||
- `app:serve` - Use `package start` instead
|
||||
- `backend:build` - Use `package build` instead
|
||||
- `backend:bundle` - Use `package build` instead
|
||||
- `backend:dev` - Use `package start` instead
|
||||
- `plugin:build` - Use `package build` instead
|
||||
- `plugin:serve` - Use `package start` instead
|
||||
- `build` - Use `package build` instead
|
||||
- `lint` - Use `package lint` instead
|
||||
- `prepack` - Use `package prepack` instead
|
||||
- `postpack` - Use `package postpack` instead
|
||||
|
||||
In order to replace these you need to have [migrated to using package roles](https://backstage.io/docs/tutorials/package-role-migration).
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@techdocs/cli': patch
|
||||
'@backstage/plugin-techdocs-node': patch
|
||||
---
|
||||
|
||||
Fix the flag parsing for `legacyCopyReadmeMdToIndexMd` in `techdocs-cli generate` command, and decouple it's logic from the `techdocs-ref` flag.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-apache-airflow': minor
|
||||
---
|
||||
|
||||
Exposed DagTableComponent as standalone component + added a prop to get only select DAGs instead of the full list
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Upgrade `@rollup/plugin-node-resolve` to `^13.0.6`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/catalog-model': patch
|
||||
---
|
||||
|
||||
Added type predicates for all entity types, e.g. isUserEntity
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Improve plural handling in logging output for secrets
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Support displaying subtitle text in `SidebarSubmenuItem`
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/errors': minor
|
||||
---
|
||||
|
||||
The `ResponseError.fromResponse` now accepts a more narrow response type, in order to avoid incompatibilities between different fetch implementations.
|
||||
|
||||
The `response` property of `ResponseError` has also been narrowed to a new `ConsumedResponse` type that omits all the properties for consuming the body of the response. This is not considered a breaking change as it was always an error to try to consume the body of the response.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-org': patch
|
||||
---
|
||||
|
||||
Render namespaces for teams with subtitles in `MyGroupsSidebarItem`
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend-module-openapi': minor
|
||||
---
|
||||
|
||||
Add basic OpenAPI \$ref support.
|
||||
|
||||
For more information see [here](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend-module-openapi).
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': patch
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
Use entity type predicates from catalog-model
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-stack-overflow': patch
|
||||
'@backstage/plugin-stack-overflow-backend': patch
|
||||
---
|
||||
|
||||
app-config is now picked up properly.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/theme': patch
|
||||
---
|
||||
|
||||
Adds optional `htmlFontSize` property and also sets typography design tokens for h5 and h6 in base theme.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs-backend': patch
|
||||
---
|
||||
|
||||
Add sample headings on the documented component homepage.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs': patch
|
||||
---
|
||||
|
||||
Remove the 60% factor from the font size calculation of headers to use the exact size defined in BackstageTheme.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend-module-ldap': patch
|
||||
---
|
||||
|
||||
Add possibility to resolve relations by RDN, in addition to UUID and DN
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend-module-gitlab': patch
|
||||
---
|
||||
|
||||
Add the possibility in the `GitlabDiscoveryEntityProvider` to scan the whole project instead of concrete groups. For that, use a configuration like this one, where the group parameter is omitted (not mandatory anymore):
|
||||
|
||||
```yaml
|
||||
catalog:
|
||||
providers:
|
||||
gitlab:
|
||||
yourProviderId:
|
||||
host: gitlab-host # Identifies one of the hosts set up in the integrations
|
||||
branch: main # Optional. Uses `master` as default
|
||||
entityFilename: catalog-info.yaml # Optional. Defaults to `catalog-info.yaml`
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs-backend': minor
|
||||
---
|
||||
|
||||
Added local publishing target directory `config`: `techdocs.publisher.local.publishDirectory`
|
||||
+10
-9
@@ -3,7 +3,7 @@
|
||||
_If you're using Backstage in your organization, please try to add your company name to this list. This really helps the project to gain momentum and credibility. It's a small contribution back to the project with a big impact._
|
||||
|
||||
| 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. |
|
||||
@@ -34,9 +34,9 @@ _If you're using Backstage in your organization, please try to add your company
|
||||
| [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 🌕🚀🧑🚀 |
|
||||
| [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), [Kamil Wolny](https://github.com/mrwolny) | 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). |
|
||||
| [DAZN](https://dazn.com/) | [Lou Bichard](https://twitter.com/loujaybee), [Marco Crivellaro](https://github.com/crivetechie), [Alex Hollerith](mailto:alex.hollerith@dazn.com), [Kamil Wolny](https://github.com/mrwolny) | 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. |
|
||||
@@ -161,7 +161,7 @@ _If you're using Backstage in your organization, please try to add your company
|
||||
| [OVHcloud](https://www.ovhcloud.com/fr/) | [Jean-Philippe Blary](https://github.com/blaryjp), [Arnaud Bauer](mailto:arnaud.bauer@ovhcloud.com), [Flavien Chantelot](https://github.com/Dorn-) | We're providing Backstage to our collaborators to ease their daily jobs, and let them extends it using plugins. |
|
||||
| [Procter & Gamble](https://us.pg.com/) | [Binita Nayak](https://github.com/binitan), [Josh Rose](https://github.com/joshuarose), [RJ Winkler](https://github.com/rjwink) | P&G leverages Backstage to build internal developer portal to ensure developers' happiness. This developer portal shall act as single source of information needed by development teams to seamlessly create, find and maintain their software components/resources/documentation. |
|
||||
| [SANS Institute](https://www.sans.org) | [Christopher Klewin](mailto:cklewin@sans.org) | Developer portal for centralized visibility, reporting, and tooling across multiple organizations. |
|
||||
| [Okay](https://www.okayhq.com/) | [Tomas Barreto](mailto:tomas@okayhq.com) | Service catalog, developer portal, and technical documentation |
|
||||
| [Okay](https://www.okayhq.com/) | [Tomas Barreto](mailto:tomas@okayhq.com) | Service catalog, developer portal, and technical documentation |
|
||||
| [Kaluza](https://www.kaluza.com) | [James Condren](mailto:james.condron@kaluza.com) | To provide an automated golden path to developers, with a focus on discovery and documentation |
|
||||
| [LinkedIn](https://linkedin.com) | [Joshua Lawrence](mailto:jlawrence@linkedin.com) | We are building a platform for internal web tools |
|
||||
| [Forto](https://forto.com) | [Rodolfo Matos](mailto:rodolfo.matos@forto.com) | Still in a experimental phase/assessing the organisational fit. We will be using it mostly a developer portal -- pretty standard use case. |
|
||||
@@ -171,15 +171,16 @@ _If you're using Backstage in your organization, please try to add your company
|
||||
| [AEB](https://www.aeb.com/) | [David Fankhänel](mailto:dfl@aeb.com) | Central developer platform for creating new apps via templates, getting an overview via software catalog, etc |
|
||||
| [SALTO Systems](https://saltosystems.com) | [Ian Cowley](mailto:i.cowley@saltosystems.com) | Currently using Backstage as an internal documentation portal. |
|
||||
| [Lummo](https://lummo.com) | [Anjul Sahu](mailto:anjul@lummo.com) | We are building the internal developer portal using Backstage and bringing up all integrations and service information at one place. |
|
||||
| [Frontside](https://frontside.com) | [Taras Mankovski](mailto:taras@frontside.com) |
|
||||
| [Frontside](https://frontside.com) | [Taras Mankovski](mailto:taras@frontside.com) | |
|
||||
| [Stepstone](https://www.stepstone.com/en/) | [Neil Kennedy](mailto:neil.kennedy@stepstone.com) | StepStone is using Backstage to solve problems around ownership and visibility of our applications. We have thousands of repos, multiple legacy systems and a growing platform that is hard to maintain. Backstage is forming the centre of our push to embrace the chaos. |
|
||||
| [idwall](https://idwall.co) | [Rodrigo Catão Araujo](mailto:rodrigo@idwall.co) | Developer Portal for internal engineers to access service catalog, documentation, observability, infrastructure and internal tooling. |
|
||||
| [Jaguar Land Rover](https://www.jaguarlandrover.com) | [Josh Walker](mailto:jwalke18@jaguarlandrover.com) | Users can request a Gitlab user, which creates a commit with the Terraform code. |
|
||||
| [Glovo](http://glovoapp.com/) | [Yaser Toutah](mailto:yaser.toutah@glovoapp.com) | Developer Portal to improve our Developer Experience, identify ownership and track metadata for our services and tools. It's our Service Catalog. In addition to that, we use it for Service Creation, and much more. |
|
||||
| [Glovo](http://glovoapp.com/) | [Yaser Toutah](mailto:yaser.toutah@glovoapp.com) | Developer Portal to improve our Developer Experience, identify ownership and track metadata for our services and tools. It's our Service Catalog. In addition to that, we use it for Service Creation, and much more. |
|
||||
| [Dixa](https://dixa.com) | [Jens Møller](mailto:jsc@dixa.com) | We are in early stages, but using it to get overview of our repositories and ownership of these. We want among many things to use it for compliance and easier access to key metrics for our repos. |
|
||||
| [Notino](https://notino.com) | [Jan Remunda](mailto:jan.remunda@notino.com) | Backstage is our developer portal. We use it as service catalog and for technical documentation. |
|
||||
| [Polarpoint](https://polarpoint.io/) | [Surj Bains](https://github.com/polarpoint-io) | We are using Backstage as our Developer portal as well as for hosting our DevOps portal for software catalog. |
|
||||
| [Niche](https://niche.com) | [Zach Romitz](mailto:zach.romitz@niche.com) | We are using the Software Catalog, Software Templates, API documentation, and Techdocs to try and centralize service information. |
|
||||
| [Mercedes-Benz.io](https://www.mercedes-benz.io/) | [Manuel Santos](https://github.com/manusant) | At Mercedes-Benz we use it as a developer portal with software catalog, TechDocs, Scaffolding and custom plugins. It provides an overview of our tech ecosystem to our product development teams. The portal also serves as a way to foster collaboration among the numerous companies of the Mercedes-Benz Group.
|
||||
| [Funding Circle](https://www.fundingcircle.com/) | [Ariel Pacciaroni](https://github.com/arielpacciaroni) | We are building the internal developer portal using Backstage project and centralizing all services information at one place. The portal helps us track down repositories ownership as well as direct access to key information on every component.
|
||||
| [Clarivate](https://www.clarivate.com) | [Gabriele Carteni](mailto:gabriele.carteni@clarivate.com) | We are building our Developer Portal using Backstage to have a better control over our software ecosystem, integrate SDLC tools and promote best practices |
|
||||
| [Mercedes-Benz.io](https://www.mercedes-benz.io/) | [Manuel Santos](https://github.com/manusant) | At Mercedes-Benz we use it as a developer portal with software catalog, TechDocs, Scaffolding and custom plugins. It provides an overview of our tech ecosystem to our product development teams. The portal also serves as a way to foster collaboration among the numerous companies of the Mercedes-Benz Group. |
|
||||
| [Funding Circle](https://www.fundingcircle.com/) | [Ariel Pacciaroni](https://github.com/arielpacciaroni) | We are building the internal developer portal using Backstage project and centralizing all services information at one place. The portal helps us track down repositories ownership as well as direct access to key information on every component. |
|
||||
| [Clarivate](https://www.clarivate.com) | [Gabriele Carteni](mailto:gabriele.carteni@clarivate.com) | We are building our Developer Portal using Backstage to have a better control over our software ecosystem, integrate SDLC tools and promote best practices |
|
||||
| [Cho Tot](https://www.chotot.com) | [Chotot Team](mailto:sre@chotot.vn) | Internal developer portal, service catalog with CI/CD tools. |
|
||||
|
||||
+3
-3
@@ -304,9 +304,9 @@ cross-spawn@^7.0.0:
|
||||
which "^2.0.1"
|
||||
|
||||
cypress@^10.0.0:
|
||||
version "10.1.0"
|
||||
resolved "https://registry.npmjs.org/cypress/-/cypress-10.1.0.tgz#6514a26c721822a02bc194e9a7f72c3142aea174"
|
||||
integrity sha512-aQ4JVZVib4Xd9FZW8IRZfKelUvqF4y5A+oUbNvn8TlsBmEfIg3m5Xd6Mt6PVU/jHiVJ9Psl905B3ZPnrDcmyuQ==
|
||||
version "10.2.0"
|
||||
resolved "https://registry.npmjs.org/cypress/-/cypress-10.2.0.tgz#ca078abfceb13be2a33cbba6e0e80ded770f542a"
|
||||
integrity sha512-+i9lY5ENlfi2mJwsggzR+XASOIgMd7S/Gd3/13NCpv596n3YSplMAueBTIxNLcxDpTcIksp+9pM3UaDrJDpFqA==
|
||||
dependencies:
|
||||
"@cypress/request" "^2.88.10"
|
||||
"@cypress/xvfb" "^1.2.4"
|
||||
|
||||
+1
-2
@@ -223,8 +223,7 @@ For more information, see our
|
||||
|
||||
No, this is not a service offering. We build the piece of software, and someone
|
||||
in your infrastructure team is responsible for
|
||||
[deploying](https://backstage.io/docs/getting-started/deployment-k8s) and
|
||||
maintaining it.
|
||||
[deploying](https://backstage.io/docs/deployment) and maintaining it.
|
||||
|
||||
### How secure is Backstage?
|
||||
|
||||
|
||||
@@ -74,6 +74,12 @@ techdocs:
|
||||
|
||||
type: 'local'
|
||||
|
||||
# Optional when techdocs.publisher.type is set to 'local'.
|
||||
|
||||
local:
|
||||
# (Optional). Set this to specify where the generated documentation is stored.
|
||||
publishDirectory: '/path/to/local/directory'
|
||||
|
||||
# Required when techdocs.publisher.type is set to 'googleGcs'. Skip otherwise.
|
||||
|
||||
googleGcs:
|
||||
|
||||
@@ -28,7 +28,7 @@ the code.
|
||||
sub-folder which is used for a markdown spellchecker.
|
||||
|
||||
- [`.yarn/`](https://github.com/backstage/backstage/tree/master/.yarn) -
|
||||
Backstage ships with it's own `yarn` implementation. This allows us to have
|
||||
Backstage ships with its own `yarn` implementation. This allows us to have
|
||||
better control over our `yarn.lock` file and hopefully avoid problems due to
|
||||
yarn versioning differences.
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ catalog:
|
||||
anotherProviderId: # another identifier
|
||||
organization: myorg
|
||||
project: myproject
|
||||
repository: '*' # this will match all repos starting with service-*
|
||||
repository: '*' # this will match all repos
|
||||
path: /src/*/catalog-info.yaml # this will search for files deep inside the /src folder
|
||||
yetAotherProviderId: # guess, what? Another one :)
|
||||
host: selfhostedazure.yourcompany.com
|
||||
|
||||
@@ -22,7 +22,7 @@ catalog:
|
||||
yourProviderId:
|
||||
host: gitlab-host # Identifies one of the hosts set up in the integrations
|
||||
branch: main # Optional. Uses `master` as default
|
||||
group: example-group # Group and subgroup (if needed) to look for repositories
|
||||
group: example-group # Optional. Group and subgroup (if needed) to look for repositories. If not present the whole project will be scanned
|
||||
entityFilename: catalog-info.yaml # Optional. Defaults to `catalog-info.yaml`
|
||||
```
|
||||
|
||||
|
||||
@@ -135,3 +135,21 @@ package export.
|
||||
accessed via `<package-name>/beta` or `<package-name>/alpha` imports.
|
||||
- `@alpha` - here be dragons. Not visible in the main package entry point, alpha
|
||||
exports must be accessed via `<package-name>/alpha` imports.
|
||||
|
||||
## Node.js Releases
|
||||
|
||||
The Backstage project uses [Node.js](https://nodejs.org/) for both its development
|
||||
tooling and backend runtime. In order for expectations to be clear we use the
|
||||
following schedule for determining the [Node.js releases](https://nodejs.org/en/about/releases/) that we support:
|
||||
|
||||
- At any given point in time we support exactly two adjacent even-numbered
|
||||
releases of Node.js, for example v12 and v14.
|
||||
- Three months before a Node.js release becomes _Active LTS_ we switch support
|
||||
to that release and the previous one. This is halfway through the _Current LTS_
|
||||
cycle for that release and occurs at the end of July every year.
|
||||
|
||||
When we say _Supporting_ a Node.js release, that means the following:
|
||||
|
||||
- The CI pipeline in the main Backstage repo tests towards the supported releases, and we encourage any other Backstage related projects to do the same.
|
||||
- New Backstage projects created with `@backstage/create-app` will have their `engines.node` version set accordingly.
|
||||
- Dropping compatibility with unsupported releases is not considered a breaking change. This includes using new syntax or APIs, as well as bumping dependencies that drop support for these versions.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: AWS Proton
|
||||
author: Amazon Web Services
|
||||
authorUrl: https://aws.amazon.com/
|
||||
category: Infrastructure
|
||||
description: Create and view AWS Proton services for your components in Backstage.
|
||||
documentation: https://github.com/awslabs/aws-proton-plugins-for-backstage#readme
|
||||
iconUrl: https://github.com/awslabs/aws-proton-plugins-for-backstage/blob/main/docs/images/proton-logo.png?raw=true
|
||||
npmPackageName: '@aws/aws-proton-plugin-for-backstage'
|
||||
@@ -5,7 +5,7 @@ authorUrl: https://bestsellerit.com
|
||||
category: Discovery
|
||||
description: This plugin will show you the cost and resource usage of your application within Google Kubernetes Engine (GKE).
|
||||
documentation: https://github.com/BESTSELLER/backstage-plugin-gkeusage/blob/master/README.md
|
||||
iconUrl: https://bestsellerit.com/img/google-container-engine_avatar.svg
|
||||
iconUrl: img/gke-logo.png
|
||||
npmPackageName: '@bestsellerit/backstage-plugin-gkeusage'
|
||||
tags:
|
||||
- gke
|
||||
|
||||
@@ -5,7 +5,7 @@ authorUrl: https://bestsellerit.com
|
||||
category: Discovery
|
||||
description: This plugin will show you information about Docker images within the Harbor cloud native registry.
|
||||
documentation: https://github.com/BESTSELLER/backstage-plugin-harbor/blob/master/README.md
|
||||
iconUrl: https://bestsellerit.com/img/terraform-harbor/goharbor.jpeg
|
||||
iconUrl: https://raw.githubusercontent.com/cncf/artwork/master/projects/harbor/icon/color/harbor-icon-color.svg
|
||||
npmPackageName: '@bestsellerit/backstage-plugin-harbor'
|
||||
tags:
|
||||
- goharbor
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: Humanitec Platform Orchestrator
|
||||
author: Frontside
|
||||
authorUrl: 'https://frontside.com'
|
||||
category: Deployment # A single category e.g. CI, Machine Learning, Services, Monitoring
|
||||
description: |
|
||||
Show workloads, environments and resources deployed by Humanitec Platform Orchestrator.
|
||||
Plugin includes an Entity ComponentCard, Backend API route and scaffolder actions.
|
||||
documentation: https://github.com/thefrontside/backstage/tree/main/plugins/humanitec
|
||||
iconUrl: img/humanitec-logo.png
|
||||
npmPackageName: '@frontside/backstage-plugin-humanitec'
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
@@ -53,7 +53,9 @@ const updateRedactionList = (
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`${values.size} secrets found in the config which will be redacted`,
|
||||
`${values.size} secret${
|
||||
values.size > 1 ? 's' : ''
|
||||
} found in the config which will be redacted`,
|
||||
);
|
||||
|
||||
setRootLoggerRedactionList(Array.from(values));
|
||||
|
||||
@@ -255,6 +255,36 @@ export { GroupEntityV1alpha1 };
|
||||
// @public
|
||||
export const groupEntityV1alpha1Validator: KindValidator;
|
||||
|
||||
// @public (undocumented)
|
||||
export function isApiEntity(entity: Entity): entity is ApiEntityV1alpha1;
|
||||
|
||||
// @public (undocumented)
|
||||
export function isComponentEntity(
|
||||
entity: Entity,
|
||||
): entity is ComponentEntityV1alpha1;
|
||||
|
||||
// @public (undocumented)
|
||||
export function isDomainEntity(entity: Entity): entity is DomainEntityV1alpha1;
|
||||
|
||||
// @public (undocumented)
|
||||
export function isGroupEntity(entity: Entity): entity is GroupEntityV1alpha1;
|
||||
|
||||
// @public (undocumented)
|
||||
export function isLocationEntity(
|
||||
entity: Entity,
|
||||
): entity is LocationEntityV1alpha1;
|
||||
|
||||
// @public (undocumented)
|
||||
export function isResourceEntity(
|
||||
entity: Entity,
|
||||
): entity is ResourceEntityV1alpha1;
|
||||
|
||||
// @public (undocumented)
|
||||
export function isSystemEntity(entity: Entity): entity is SystemEntityV1alpha1;
|
||||
|
||||
// @public (undocumented)
|
||||
export function isUserEntity(entity: Entity): entity is UserEntityV1alpha1;
|
||||
|
||||
// @public
|
||||
export type KindValidator = {
|
||||
check(entity: Entity): Promise<boolean>;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright 2022 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 './Entity';
|
||||
import {
|
||||
isApiEntity,
|
||||
isComponentEntity,
|
||||
isDomainEntity,
|
||||
isGroupEntity,
|
||||
isLocationEntity,
|
||||
isResourceEntity,
|
||||
isSystemEntity,
|
||||
isUserEntity,
|
||||
} from './conditions';
|
||||
|
||||
const apiEntity: Entity = {
|
||||
apiVersion: '',
|
||||
kind: 'api',
|
||||
metadata: { name: 'api' },
|
||||
};
|
||||
const componentEntity: Entity = {
|
||||
apiVersion: '',
|
||||
kind: 'component',
|
||||
metadata: { name: 'Component' },
|
||||
};
|
||||
const domainEntity: Entity = {
|
||||
apiVersion: '',
|
||||
kind: 'domain',
|
||||
metadata: { name: 'Domain' },
|
||||
};
|
||||
const groupEntity: Entity = {
|
||||
apiVersion: '',
|
||||
kind: 'group',
|
||||
metadata: { name: 'Group' },
|
||||
};
|
||||
const locationEntity: Entity = {
|
||||
apiVersion: '',
|
||||
kind: 'location',
|
||||
metadata: { name: 'Location' },
|
||||
};
|
||||
const resourceEntity: Entity = {
|
||||
apiVersion: '',
|
||||
kind: 'resource',
|
||||
metadata: { name: 'Resource' },
|
||||
};
|
||||
const systemEntity: Entity = {
|
||||
apiVersion: '',
|
||||
kind: 'system',
|
||||
metadata: { name: 'System' },
|
||||
};
|
||||
const userEntity: Entity = {
|
||||
apiVersion: '',
|
||||
kind: 'user',
|
||||
metadata: { name: 'User' },
|
||||
};
|
||||
|
||||
describe('isApiEntity', () => {
|
||||
it('should check for the intended type', () => {
|
||||
expect(isApiEntity(componentEntity)).not.toBeTruthy();
|
||||
expect(isApiEntity(domainEntity)).not.toBeTruthy();
|
||||
expect(isApiEntity(groupEntity)).not.toBeTruthy();
|
||||
expect(isApiEntity(locationEntity)).not.toBeTruthy();
|
||||
expect(isApiEntity(resourceEntity)).not.toBeTruthy();
|
||||
expect(isApiEntity(systemEntity)).not.toBeTruthy();
|
||||
expect(isApiEntity(userEntity)).not.toBeTruthy();
|
||||
expect(isApiEntity(apiEntity)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isComponentEntity', () => {
|
||||
it('should check for the intended type', () => {
|
||||
expect(isComponentEntity(apiEntity)).not.toBeTruthy();
|
||||
expect(isComponentEntity(domainEntity)).not.toBeTruthy();
|
||||
expect(isComponentEntity(groupEntity)).not.toBeTruthy();
|
||||
expect(isComponentEntity(locationEntity)).not.toBeTruthy();
|
||||
expect(isComponentEntity(resourceEntity)).not.toBeTruthy();
|
||||
expect(isComponentEntity(systemEntity)).not.toBeTruthy();
|
||||
expect(isComponentEntity(userEntity)).not.toBeTruthy();
|
||||
expect(isComponentEntity(componentEntity)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDomainEntity', () => {
|
||||
it('should check for the intended type', () => {
|
||||
expect(isDomainEntity(apiEntity)).not.toBeTruthy();
|
||||
expect(isDomainEntity(componentEntity)).not.toBeTruthy();
|
||||
expect(isDomainEntity(groupEntity)).not.toBeTruthy();
|
||||
expect(isDomainEntity(locationEntity)).not.toBeTruthy();
|
||||
expect(isDomainEntity(resourceEntity)).not.toBeTruthy();
|
||||
expect(isDomainEntity(systemEntity)).not.toBeTruthy();
|
||||
expect(isDomainEntity(userEntity)).not.toBeTruthy();
|
||||
expect(isDomainEntity(domainEntity)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGroupEntity', () => {
|
||||
it('should check for the intended type', () => {
|
||||
expect(isGroupEntity(apiEntity)).not.toBeTruthy();
|
||||
expect(isGroupEntity(componentEntity)).not.toBeTruthy();
|
||||
expect(isGroupEntity(domainEntity)).not.toBeTruthy();
|
||||
expect(isGroupEntity(locationEntity)).not.toBeTruthy();
|
||||
expect(isGroupEntity(resourceEntity)).not.toBeTruthy();
|
||||
expect(isGroupEntity(systemEntity)).not.toBeTruthy();
|
||||
expect(isGroupEntity(userEntity)).not.toBeTruthy();
|
||||
expect(isGroupEntity(groupEntity)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLocationEntity', () => {
|
||||
it('should check for the intended type', () => {
|
||||
expect(isLocationEntity(apiEntity)).not.toBeTruthy();
|
||||
expect(isLocationEntity(componentEntity)).not.toBeTruthy();
|
||||
expect(isLocationEntity(domainEntity)).not.toBeTruthy();
|
||||
expect(isLocationEntity(groupEntity)).not.toBeTruthy();
|
||||
expect(isLocationEntity(resourceEntity)).not.toBeTruthy();
|
||||
expect(isLocationEntity(systemEntity)).not.toBeTruthy();
|
||||
expect(isLocationEntity(userEntity)).not.toBeTruthy();
|
||||
expect(isLocationEntity(locationEntity)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isResourceEntity', () => {
|
||||
it('should check for the intended type', () => {
|
||||
expect(isResourceEntity(apiEntity)).not.toBeTruthy();
|
||||
expect(isResourceEntity(componentEntity)).not.toBeTruthy();
|
||||
expect(isResourceEntity(domainEntity)).not.toBeTruthy();
|
||||
expect(isResourceEntity(groupEntity)).not.toBeTruthy();
|
||||
expect(isResourceEntity(locationEntity)).not.toBeTruthy();
|
||||
expect(isResourceEntity(systemEntity)).not.toBeTruthy();
|
||||
expect(isResourceEntity(userEntity)).not.toBeTruthy();
|
||||
expect(isResourceEntity(resourceEntity)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSystemEntity', () => {
|
||||
it('should check for the intended type', () => {
|
||||
expect(isSystemEntity(apiEntity)).not.toBeTruthy();
|
||||
expect(isSystemEntity(componentEntity)).not.toBeTruthy();
|
||||
expect(isSystemEntity(domainEntity)).not.toBeTruthy();
|
||||
expect(isSystemEntity(groupEntity)).not.toBeTruthy();
|
||||
expect(isSystemEntity(locationEntity)).not.toBeTruthy();
|
||||
expect(isSystemEntity(resourceEntity)).not.toBeTruthy();
|
||||
expect(isSystemEntity(userEntity)).not.toBeTruthy();
|
||||
expect(isSystemEntity(systemEntity)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUserEntity', () => {
|
||||
it('should check for the intended type', () => {
|
||||
expect(isUserEntity(apiEntity)).not.toBeTruthy();
|
||||
expect(isUserEntity(componentEntity)).not.toBeTruthy();
|
||||
expect(isUserEntity(domainEntity)).not.toBeTruthy();
|
||||
expect(isUserEntity(groupEntity)).not.toBeTruthy();
|
||||
expect(isUserEntity(locationEntity)).not.toBeTruthy();
|
||||
expect(isUserEntity(resourceEntity)).not.toBeTruthy();
|
||||
expect(isUserEntity(systemEntity)).not.toBeTruthy();
|
||||
expect(isUserEntity(userEntity)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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 {
|
||||
ApiEntity,
|
||||
ComponentEntity,
|
||||
DomainEntity,
|
||||
GroupEntity,
|
||||
LocationEntity,
|
||||
ResourceEntity,
|
||||
SystemEntity,
|
||||
UserEntity,
|
||||
} from '../kinds';
|
||||
import { Entity } from './Entity';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function isApiEntity(entity: Entity): entity is ApiEntity {
|
||||
return entity.kind.toLocaleUpperCase('en-US') === 'API';
|
||||
}
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function isComponentEntity(entity: Entity): entity is ComponentEntity {
|
||||
return entity.kind.toLocaleUpperCase('en-US') === 'COMPONENT';
|
||||
}
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function isDomainEntity(entity: Entity): entity is DomainEntity {
|
||||
return entity.kind.toLocaleUpperCase('en-US') === 'DOMAIN';
|
||||
}
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function isGroupEntity(entity: Entity): entity is GroupEntity {
|
||||
return entity.kind.toLocaleUpperCase('en-US') === 'GROUP';
|
||||
}
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function isLocationEntity(entity: Entity): entity is LocationEntity {
|
||||
return entity.kind.toLocaleUpperCase('en-US') === 'LOCATION';
|
||||
}
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function isResourceEntity(entity: Entity): entity is ResourceEntity {
|
||||
return entity.kind.toLocaleUpperCase('en-US') === 'RESOURCE';
|
||||
}
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function isSystemEntity(entity: Entity): entity is SystemEntity {
|
||||
return entity.kind.toLocaleUpperCase('en-US') === 'SYSTEM';
|
||||
}
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function isUserEntity(entity: Entity): entity is UserEntity {
|
||||
return entity.kind.toLocaleUpperCase('en-US') === 'USER';
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './conditions';
|
||||
export {
|
||||
DEFAULT_NAMESPACE,
|
||||
ANNOTATION_EDIT_URL,
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"@octokit/request": "^5.4.12",
|
||||
"@rollup/plugin-commonjs": "^22.0.0",
|
||||
"@rollup/plugin-json": "^4.1.0",
|
||||
"@rollup/plugin-node-resolve": "^13.0.0",
|
||||
"@rollup/plugin-node-resolve": "^13.0.6",
|
||||
"@rollup/plugin-yaml": "^3.1.0",
|
||||
"@spotify/eslint-config-base": "^13.0.0",
|
||||
"@spotify/eslint-config-react": "^13.0.0",
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* 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 fs from 'fs-extra';
|
||||
import { OptionValues } from 'commander';
|
||||
import { buildBundle } from '../../lib/bundler';
|
||||
import { getEnvironmentParallelism } from '../../lib/parallel';
|
||||
import { loadCliConfig } from '../../lib/config';
|
||||
import { paths } from '../../lib/paths';
|
||||
|
||||
export default async (opts: OptionValues) => {
|
||||
const { name } = await fs.readJson(paths.resolveTarget('package.json'));
|
||||
await buildBundle({
|
||||
entry: 'src/index',
|
||||
parallelism: getEnvironmentParallelism(),
|
||||
statsJsonEnabled: opts.stats,
|
||||
...(await loadCliConfig({
|
||||
args: opts.config,
|
||||
fromPackage: name,
|
||||
})),
|
||||
});
|
||||
};
|
||||
@@ -1,92 +0,0 @@
|
||||
/*
|
||||
* 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 fs from 'fs-extra';
|
||||
import chalk from 'chalk';
|
||||
import uniq from 'lodash/uniq';
|
||||
import { OptionValues } from 'commander';
|
||||
import { serveBundle } from '../../lib/bundler';
|
||||
import { loadCliConfig } from '../../lib/config';
|
||||
import { paths } from '../../lib/paths';
|
||||
import { Lockfile } from '../../lib/versioning';
|
||||
import { forbiddenDuplicatesFilter, includedFilter } from '../versions/lint';
|
||||
|
||||
export default async (opts: OptionValues) => {
|
||||
const lockFilePath = paths.resolveTargetRoot('yarn.lock');
|
||||
if (fs.existsSync(lockFilePath)) {
|
||||
try {
|
||||
const lockfile = await Lockfile.load(lockFilePath);
|
||||
const result = lockfile.analyze({
|
||||
filter: includedFilter,
|
||||
});
|
||||
const problemPackages = [...result.newVersions, ...result.newRanges]
|
||||
.map(({ name }) => name)
|
||||
.filter(name => forbiddenDuplicatesFilter(name));
|
||||
|
||||
if (problemPackages.length > 0) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`⚠️ Some of the following packages may be outdated or have duplicate installations:
|
||||
|
||||
${uniq(problemPackages).join(', ')}
|
||||
`,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`⚠️ The following command may fix the issue, but it could also be an issue within one of your dependencies:
|
||||
|
||||
yarn backstage-cli versions:check --fix
|
||||
`,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`⚠️ Unable to parse yarn.lock file properly:
|
||||
|
||||
${error}
|
||||
|
||||
skipping analyzer for outdated or duplicate installations
|
||||
`,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`⚠️ Unable to find yarn.lock file:
|
||||
|
||||
skipping analyzer for outdated or duplicate installations
|
||||
`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const { name } = await fs.readJson(paths.resolveTarget('package.json'));
|
||||
const waitForExit = await serveBundle({
|
||||
entry: 'src/index',
|
||||
checksEnabled: opts.check,
|
||||
...(await loadCliConfig({
|
||||
args: opts.config,
|
||||
fromPackage: name,
|
||||
withFilteredKeys: true,
|
||||
})),
|
||||
});
|
||||
|
||||
await waitForExit();
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
/*
|
||||
* 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 os from 'os';
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import tar, { CreateOptions } from 'tar';
|
||||
import { OptionValues } from 'commander';
|
||||
import { createDistWorkspace } from '../../lib/packager';
|
||||
import { paths } from '../../lib/paths';
|
||||
import { getEnvironmentParallelism } from '../../lib/parallel';
|
||||
import { buildPackage, Output } from '../../lib/builder';
|
||||
|
||||
const BUNDLE_FILE = 'bundle.tar.gz';
|
||||
const SKELETON_FILE = 'skeleton.tar.gz';
|
||||
|
||||
export default async (opts: OptionValues) => {
|
||||
const targetDir = paths.resolveTarget('dist');
|
||||
const pkg = await fs.readJson(paths.resolveTarget('package.json'));
|
||||
|
||||
// We build the target package without generating type declarations.
|
||||
await buildPackage({ outputs: new Set([Output.cjs]) });
|
||||
|
||||
const tmpDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-bundle'));
|
||||
try {
|
||||
await createDistWorkspace([pkg.name], {
|
||||
targetDir: tmpDir,
|
||||
buildDependencies: Boolean(opts.buildDependencies),
|
||||
buildExcludes: [pkg.name],
|
||||
parallelism: getEnvironmentParallelism(),
|
||||
skeleton: SKELETON_FILE,
|
||||
});
|
||||
|
||||
// We built the target backend package using the regular build process, but the result of
|
||||
// that has now been packed into the dist workspace, so clean up the dist dir.
|
||||
await fs.remove(targetDir);
|
||||
await fs.mkdir(targetDir);
|
||||
|
||||
// Move out skeleton.tar.gz before we create the main bundle, no point having that included up twice.
|
||||
await fs.move(
|
||||
resolvePath(tmpDir, SKELETON_FILE),
|
||||
resolvePath(targetDir, SKELETON_FILE),
|
||||
);
|
||||
|
||||
// Create main bundle.tar.gz, with some tweaks to make it more likely hit Docker build cache.
|
||||
await tar.create(
|
||||
{
|
||||
file: resolvePath(targetDir, BUNDLE_FILE),
|
||||
cwd: tmpDir,
|
||||
portable: true,
|
||||
noMtime: true,
|
||||
gzip: true,
|
||||
} as CreateOptions & { noMtime: boolean },
|
||||
[''],
|
||||
);
|
||||
} finally {
|
||||
await fs.remove(tmpDir);
|
||||
}
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
/*
|
||||
* 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 fs from 'fs-extra';
|
||||
import { OptionValues } from 'commander';
|
||||
import { paths } from '../../lib/paths';
|
||||
import { serveBackend } from '../../lib/bundler/backend';
|
||||
|
||||
export default async (opts: OptionValues) => {
|
||||
// Cleaning dist/ before we start the dev process helps work around an issue
|
||||
// where we end up with the entrypoint executing multiple times, causing
|
||||
// a port bind conflict among other things.
|
||||
await fs.remove(paths.resolveTarget('dist'));
|
||||
|
||||
const waitForExit = await serveBackend({
|
||||
entry: 'src/index',
|
||||
checksEnabled: opts.check,
|
||||
inspectEnabled: opts.inspect,
|
||||
inspectBrkEnabled: opts.inspectBrk,
|
||||
});
|
||||
|
||||
await waitForExit();
|
||||
};
|
||||
@@ -183,53 +183,6 @@ export function registerMigrateCommand(program: Command) {
|
||||
}
|
||||
|
||||
export function registerCommands(program: Command) {
|
||||
// TODO(Rugvip): Deprecate in favor of package variant
|
||||
program
|
||||
.command('app:build')
|
||||
.description('Build an app for a production release [DEPRECATED]')
|
||||
.option('--stats', 'Write bundle stats to output directory')
|
||||
.option(...configOption)
|
||||
.action(lazy(() => import('./app/build').then(m => m.default)));
|
||||
|
||||
// TODO(Rugvip): Deprecate in favor of package variant
|
||||
program
|
||||
.command('app:serve')
|
||||
.description('Serve an app for local development [DEPRECATED]')
|
||||
.option('--check', 'Enable type checking and linting')
|
||||
.option(...configOption)
|
||||
.action(lazy(() => import('./app/serve').then(m => m.default)));
|
||||
|
||||
// TODO(Rugvip): Deprecate in favor of package variant
|
||||
program
|
||||
.command('backend:build')
|
||||
.description('Build a backend plugin [DEPRECATED]')
|
||||
.option('--minify', 'Minify the generated code')
|
||||
.option('--experimental-type-build', 'Enable experimental type build')
|
||||
.action(lazy(() => import('./backend/build').then(m => m.default)));
|
||||
|
||||
// TODO(Rugvip): Deprecate in favor of package variant
|
||||
program
|
||||
.command('backend:bundle')
|
||||
.description('Bundle the backend into a deployment archive [DEPRECATED]')
|
||||
.option(
|
||||
'--build-dependencies',
|
||||
'Build all local package dependencies before bundling the backend',
|
||||
)
|
||||
.action(lazy(() => import('./backend/bundle').then(m => m.default)));
|
||||
|
||||
// TODO(Rugvip): Deprecate in favor of package variant
|
||||
program
|
||||
.command('backend:dev')
|
||||
.description(
|
||||
'Start local development server with HMR for the backend [DEPRECATED]',
|
||||
)
|
||||
.option('--check', 'Enable type checking and linting')
|
||||
.option('--inspect', 'Enable debugger')
|
||||
.option('--inspect-brk', 'Enable debugger with await to attach debugger')
|
||||
// We don't actually use the config in the CLI, just pass them on to the NodeJS process
|
||||
.option(...configOption)
|
||||
.action(lazy(() => import('./backend/dev').then(m => m.default)));
|
||||
|
||||
program
|
||||
.command('create')
|
||||
.storeOptionsAsProperties(false)
|
||||
@@ -268,22 +221,6 @@ export function registerCommands(program: Command) {
|
||||
lazy(() => import('./create-plugin/createPlugin').then(m => m.default)),
|
||||
);
|
||||
|
||||
// TODO(Rugvip): Deprecate in favor of package variant
|
||||
program
|
||||
.command('plugin:build')
|
||||
.description('Build a plugin [DEPRECATED]')
|
||||
.option('--minify', 'Minify the generated code')
|
||||
.option('--experimental-type-build', 'Enable experimental type build')
|
||||
.action(lazy(() => import('./plugin/build').then(m => m.default)));
|
||||
|
||||
// TODO(Rugvip): Deprecate in favor of package variant
|
||||
program
|
||||
.command('plugin:serve')
|
||||
.description('Serves the dev/ folder of a plugin [DEPRECATED]')
|
||||
.option('--check', 'Enable type checking and linting')
|
||||
.option(...configOption)
|
||||
.action(lazy(() => import('./plugin/serve').then(m => m.default)));
|
||||
|
||||
program
|
||||
.command('plugin:diff')
|
||||
.option('--check', 'Fail if changes are required')
|
||||
@@ -291,27 +228,6 @@ export function registerCommands(program: Command) {
|
||||
.description('Diff an existing plugin with the creation template')
|
||||
.action(lazy(() => import('./plugin/diff').then(m => m.default)));
|
||||
|
||||
// TODO(Rugvip): Deprecate in favor of package variant
|
||||
program
|
||||
.command('build')
|
||||
.description('Build a package for publishing [DEPRECATED]')
|
||||
.option('--outputs <formats>', 'List of formats to output [types,cjs,esm]')
|
||||
.option('--minify', 'Minify the generated code')
|
||||
.option('--experimental-type-build', 'Enable experimental type build')
|
||||
.action(lazy(() => import('./oldBuild').then(m => m.default)));
|
||||
|
||||
// TODO(Rugvip): Deprecate in favor of package variant
|
||||
program
|
||||
.command('lint [directories...]')
|
||||
.option(
|
||||
'--format <format>',
|
||||
'Lint report output format',
|
||||
'eslint-formatter-friendly',
|
||||
)
|
||||
.option('--fix', 'Attempt to automatically fix violations')
|
||||
.description('Lint a package [DEPRECATED]')
|
||||
.action(lazy(() => import('./lint').then(m => m.default)));
|
||||
|
||||
// TODO(Rugvip): Deprecate in favor of package variant
|
||||
program
|
||||
.command('test')
|
||||
@@ -400,22 +316,6 @@ export function registerCommands(program: Command) {
|
||||
.description('Check Backstage package versioning')
|
||||
.action(lazy(() => import('./versions/lint').then(m => m.default)));
|
||||
|
||||
// TODO(Rugvip): Deprecate in favor of package variant
|
||||
program
|
||||
.command('prepack')
|
||||
.description(
|
||||
'Prepares a package for packaging before publishing [DEPRECATED]',
|
||||
)
|
||||
.action(lazy(() => import('./pack').then(m => m.pre)));
|
||||
|
||||
// TODO(Rugvip): Deprecate in favor of package variant
|
||||
program
|
||||
.command('postpack')
|
||||
.description(
|
||||
'Restores the changes made by the prepack command [DEPRECATED]',
|
||||
)
|
||||
.action(lazy(() => import('./pack').then(m => m.post)));
|
||||
|
||||
// TODO(Rugvip): Deprecate in favor of package variant
|
||||
program
|
||||
.command('clean')
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/*
|
||||
* 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 { buildPackage, Output } from '../lib/builder';
|
||||
import { OptionValues } from 'commander';
|
||||
|
||||
export default async (opts: OptionValues) => {
|
||||
let outputs = new Set<Output>();
|
||||
|
||||
const { outputs: outputsStr } = opts as { outputs?: string };
|
||||
if (outputsStr) {
|
||||
for (const output of outputsStr.split(',') as (keyof typeof Output)[]) {
|
||||
if (output in Output) {
|
||||
outputs.add(Output[output]);
|
||||
} else {
|
||||
throw new Error(`Unknown output format: ${output}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
outputs = new Set([Output.types, Output.esm, Output.cjs]);
|
||||
}
|
||||
|
||||
await buildPackage({
|
||||
outputs,
|
||||
minify: opts.minify,
|
||||
useApiExtractor: opts.experimentalTypeBuild,
|
||||
});
|
||||
};
|
||||
@@ -1110,6 +1110,7 @@ export type SidebarSubmenuItemDropdownItem = {
|
||||
// @public
|
||||
export type SidebarSubmenuItemProps = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
to?: string;
|
||||
icon?: IconComponent;
|
||||
dropdownItems?: SidebarSubmenuItemDropdownItem[];
|
||||
|
||||
@@ -59,6 +59,13 @@ const useStyles = makeStyles<BackstageTheme>(
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
'text-overflow': 'ellipsis',
|
||||
lineHeight: 1,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 10,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
'text-overflow': 'ellipsis',
|
||||
},
|
||||
dropdownArrow: {
|
||||
position: 'absolute',
|
||||
@@ -105,6 +112,7 @@ export type SidebarSubmenuItemDropdownItem = {
|
||||
* Holds submenu item content.
|
||||
*
|
||||
* title: Text content of submenu item
|
||||
* subtitle: A subtitle displayed under the main title
|
||||
* to: Path to navigate to when item is clicked
|
||||
* icon: Icon displayed on the left of text content
|
||||
* dropdownItems: Optional array of dropdown items displayed when submenu item is clicked.
|
||||
@@ -113,6 +121,7 @@ export type SidebarSubmenuItemDropdownItem = {
|
||||
*/
|
||||
export type SidebarSubmenuItemProps = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
to?: string;
|
||||
icon?: IconComponent;
|
||||
dropdownItems?: SidebarSubmenuItemDropdownItem[];
|
||||
@@ -124,7 +133,7 @@ export type SidebarSubmenuItemProps = {
|
||||
* @public
|
||||
*/
|
||||
export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => {
|
||||
const { title, to, icon: Icon, dropdownItems } = props;
|
||||
const { title, subtitle, to, icon: Icon, dropdownItems } = props;
|
||||
const classes = useStyles();
|
||||
const { setIsHoveredOn } = useContext(SidebarItemWithSubmenuContext);
|
||||
const closeSubmenu = () => {
|
||||
@@ -158,6 +167,12 @@ export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => {
|
||||
{Icon && <Icon fontSize="small" />}
|
||||
<Typography variant="subtitle1" className={classes.label}>
|
||||
{title}
|
||||
<br />
|
||||
{subtitle && (
|
||||
<Typography variant="caption" className={classes.subtitle}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
{showDropDown ? (
|
||||
<ArrowDropUpIcon className={classes.dropdownArrow} />
|
||||
@@ -210,6 +225,12 @@ export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => {
|
||||
{Icon && <Icon fontSize="small" />}
|
||||
<Typography variant="subtitle1" className={classes.label}>
|
||||
{title}
|
||||
<br />
|
||||
{subtitle && (
|
||||
<Typography variant="caption" className={classes.subtitle}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
|
||||
@@ -14,6 +14,17 @@ export class AuthenticationError extends CustomErrorBase {}
|
||||
// @public
|
||||
export class ConflictError extends CustomErrorBase {}
|
||||
|
||||
// @public
|
||||
export type ConsumedResponse = {
|
||||
readonly headers: Headers;
|
||||
readonly ok: boolean;
|
||||
readonly redirected: boolean;
|
||||
readonly status: number;
|
||||
readonly statusText: string;
|
||||
readonly type: ResponseType;
|
||||
readonly url: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export class CustomErrorBase extends Error {
|
||||
constructor(message?: string, cause?: Error | unknown);
|
||||
@@ -67,15 +78,21 @@ export class NotModifiedError extends CustomErrorBase {}
|
||||
|
||||
// @public
|
||||
export function parseErrorResponseBody(
|
||||
response: Response,
|
||||
response: ConsumedResponse & {
|
||||
text(): Promise<string>;
|
||||
},
|
||||
): Promise<ErrorResponseBody>;
|
||||
|
||||
// @public
|
||||
export class ResponseError extends Error {
|
||||
readonly body: ErrorResponseBody;
|
||||
readonly cause: Error;
|
||||
static fromResponse(response: Response): Promise<ResponseError>;
|
||||
readonly response: Response;
|
||||
static fromResponse(
|
||||
response: ConsumedResponse & {
|
||||
text(): Promise<string>;
|
||||
},
|
||||
): Promise<ResponseError>;
|
||||
readonly response: ConsumedResponse;
|
||||
}
|
||||
|
||||
// @public
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ErrorResponseBody,
|
||||
parseErrorResponseBody,
|
||||
} from '../serialization/response';
|
||||
import { ConsumedResponse } from './types';
|
||||
|
||||
/**
|
||||
* An error thrown as the result of a failed server request.
|
||||
@@ -34,7 +35,7 @@ export class ResponseError extends Error {
|
||||
* Note that the body of this response is always consumed. Its parsed form is
|
||||
* in the `body` field.
|
||||
*/
|
||||
readonly response: Response;
|
||||
readonly response: ConsumedResponse;
|
||||
|
||||
/**
|
||||
* The parsed JSON error body, as sent by the server.
|
||||
@@ -59,7 +60,9 @@ export class ResponseError extends Error {
|
||||
* function consumes the body of the response, and assumes that it hasn't
|
||||
* been consumed before.
|
||||
*/
|
||||
static async fromResponse(response: Response): Promise<ResponseError> {
|
||||
static async fromResponse(
|
||||
response: ConsumedResponse & { text(): Promise<string> },
|
||||
): Promise<ResponseError> {
|
||||
const data = await parseErrorResponseBody(response);
|
||||
|
||||
const status = data.response.statusCode || response.status;
|
||||
@@ -77,7 +80,7 @@ export class ResponseError extends Error {
|
||||
|
||||
private constructor(props: {
|
||||
message: string;
|
||||
response: Response;
|
||||
response: ConsumedResponse;
|
||||
data: ErrorResponseBody;
|
||||
cause: Error;
|
||||
}) {
|
||||
|
||||
@@ -27,3 +27,4 @@ export {
|
||||
} from './common';
|
||||
export { CustomErrorBase } from './CustomErrorBase';
|
||||
export { ResponseError } from './ResponseError';
|
||||
export type { ConsumedResponse } from './types';
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2022 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* ConsumedResponse represents a Response that is known to have been consumed.
|
||||
* The methods and properties used to read the body contents are therefore omitted.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ConsumedResponse = {
|
||||
readonly headers: Headers;
|
||||
readonly ok: boolean;
|
||||
readonly redirected: boolean;
|
||||
readonly status: number;
|
||||
readonly statusText: string;
|
||||
readonly type: ResponseType;
|
||||
readonly url: string;
|
||||
};
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConsumedResponse } from '../errors/types';
|
||||
import { SerializedError } from './error';
|
||||
|
||||
/**
|
||||
@@ -53,7 +54,7 @@ export type ErrorResponseBody = {
|
||||
* @param response - The response of a failed request
|
||||
*/
|
||||
export async function parseErrorResponseBody(
|
||||
response: Response,
|
||||
response: ConsumedResponse & { text(): Promise<string> },
|
||||
): Promise<ErrorResponseBody> {
|
||||
try {
|
||||
const text = await response.text();
|
||||
|
||||
@@ -57,8 +57,8 @@ export default async function generate(opts: OptionValues) {
|
||||
runIn: opts.docker ? 'docker' : 'local',
|
||||
dockerImage,
|
||||
pullImage,
|
||||
legacyCopyReadmeMdToIndexMd,
|
||||
mkdocs: {
|
||||
legacyCopyReadmeMdToIndexMd,
|
||||
omitTechdocsCorePlugin,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -145,5 +145,6 @@ export type SimpleThemeOptions = {
|
||||
defaultPageTheme: string;
|
||||
pageTheme?: Record<string, PageTheme>;
|
||||
fontFamily?: string;
|
||||
htmlFontSize?: number;
|
||||
};
|
||||
```
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from './types';
|
||||
import { pageTheme as defaultPageThemes } from './pageTheme';
|
||||
|
||||
const DEFAULT_HTML_FONT_SIZE = 16;
|
||||
const DEFAULT_FONT_FAMILY =
|
||||
'"Helvetica Neue", Helvetica, Roboto, Arial, sans-serif';
|
||||
|
||||
@@ -37,6 +38,7 @@ export function createThemeOptions(
|
||||
): BackstageThemeOptions {
|
||||
const {
|
||||
palette,
|
||||
htmlFontSize = DEFAULT_HTML_FONT_SIZE,
|
||||
fontFamily = DEFAULT_FONT_FAMILY,
|
||||
defaultPageTheme,
|
||||
pageTheme = defaultPageThemes,
|
||||
@@ -57,9 +59,17 @@ export function createThemeOptions(
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
htmlFontSize,
|
||||
fontFamily,
|
||||
h6: {
|
||||
fontWeight: 700,
|
||||
fontSize: 20,
|
||||
marginBottom: 2,
|
||||
},
|
||||
h5: {
|
||||
fontWeight: 700,
|
||||
fontSize: 24,
|
||||
marginBottom: 4,
|
||||
},
|
||||
h4: {
|
||||
fontWeight: 700,
|
||||
|
||||
@@ -149,6 +149,7 @@ export type SimpleThemeOptions = {
|
||||
defaultPageTheme: string;
|
||||
pageTheme?: Record<string, PageTheme>;
|
||||
fontFamily?: string;
|
||||
htmlFontSize?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,6 +42,26 @@ yarn --cwd packages/app add @backstage/plugin-apache-airflow
|
||||
);
|
||||
```
|
||||
|
||||
If you just want to embed the DAGs into an existing page, you can use the `ApacheAirflowDagTable`
|
||||
|
||||
```tsx
|
||||
import { ApacheAirflowDagTable } from '@backstage/plugin-apache-airflow';
|
||||
|
||||
export function SomeEntityPage(): JSX.Element {
|
||||
return (
|
||||
<Grid item md={6} xs={12}>
|
||||
<ApacheAirflowDagTable
|
||||
dagIds={[
|
||||
'example_bash_operator',
|
||||
'example_branch_datetime_operator_2',
|
||||
'example_branch_labels',
|
||||
]}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
For links to the Airflow instance, the `baseUrl` must be defined in
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { RouteRef } from '@backstage/core-plugin-api';
|
||||
|
||||
// @public
|
||||
export const ApacheAirflowDagTable: ({
|
||||
dagIds,
|
||||
}: {
|
||||
dagIds?: string[] | undefined;
|
||||
}) => JSX.Element;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "ApacheAirflowPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
|
||||
@@ -25,6 +25,7 @@ export type ApacheAirflowApi = {
|
||||
discoveryApi: DiscoveryApi;
|
||||
baseUrl: string;
|
||||
listDags(options?: { objectsPerRequest: number }): Promise<Dag[]>;
|
||||
getDags(dagIds: string[]): Promise<{ dags: Dag[]; dagsNotFound: string[] }>;
|
||||
updateDag(dagId: string, isPaused: boolean): Promise<any>;
|
||||
getInstanceStatus(): Promise<InstanceStatus>;
|
||||
getInstanceVersion(): Promise<InstanceVersion>;
|
||||
|
||||
@@ -107,6 +107,15 @@ describe('ApacheAirflowClient', () => {
|
||||
);
|
||||
}),
|
||||
|
||||
rest.get(`${mockBaseUrl}/airflow/dags/:dag_id`, (req, res, ctx) => {
|
||||
const { dag_id } = req.params;
|
||||
const dag = dags.find(d => d.dag_id === dag_id);
|
||||
if (dag) {
|
||||
return res(ctx.json(dag));
|
||||
}
|
||||
return res(ctx.status(404));
|
||||
}),
|
||||
|
||||
rest.patch(`${mockBaseUrl}/airflow/dags/:dag_id`, (req, res, ctx) => {
|
||||
const { dag_id } = req.params;
|
||||
const body = JSON.parse(req.body as string);
|
||||
@@ -160,4 +169,33 @@ describe('ApacheAirflowClient', () => {
|
||||
expect(response.dag_id).toEqual(dagId);
|
||||
expect(response.is_paused).toEqual(true);
|
||||
});
|
||||
|
||||
it('get only some dags', async () => {
|
||||
setupHandlers();
|
||||
const client = new ApacheAirflowClient({
|
||||
discoveryApi: discoveryApi,
|
||||
baseUrl: 'localhost:8080/',
|
||||
});
|
||||
const dagIds = ['mock_dag_1', 'mock_dag_3'];
|
||||
const response = await client.getDags(dagIds);
|
||||
expect(response.dags.length).toEqual(dagIds.length);
|
||||
response.dags.forEach((dag, index) =>
|
||||
expect(dag.dag_id).toEqual(dagIds[index]),
|
||||
);
|
||||
expect(response.dagsNotFound.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('get dags but ignore NOT FOUND errors', async () => {
|
||||
setupHandlers();
|
||||
const client = new ApacheAirflowClient({
|
||||
discoveryApi: discoveryApi,
|
||||
baseUrl: 'localhost:8080/',
|
||||
});
|
||||
const dagIds = ['mock_dag_1', 'a-random-DAG-id'];
|
||||
const response = await client.getDags(dagIds);
|
||||
expect(response.dags.length).toEqual(1);
|
||||
expect(response.dags[0].dag_id).toEqual('mock_dag_1');
|
||||
expect(response.dagsNotFound.length).toEqual(1);
|
||||
expect(response.dagsNotFound[0]).toEqual('a-random-DAG-id');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,6 +75,25 @@ export class ApacheAirflowClient implements ApacheAirflowApi {
|
||||
return dags;
|
||||
}
|
||||
|
||||
async getDags(
|
||||
dagIds: string[],
|
||||
): Promise<{ dags: Dag[]; dagsNotFound: string[] }> {
|
||||
const dagsNotFound: string[] = [];
|
||||
const response = await Promise.all(
|
||||
dagIds.map(id => {
|
||||
return this.fetch<Dag>(`/dags/${id}`).catch(e => {
|
||||
if (e.message.toUpperCase('en-US') === 'NOT FOUND') {
|
||||
dagsNotFound.push(id);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
const dags = response.filter(Boolean) as Dag[];
|
||||
return { dags, dagsNotFound };
|
||||
}
|
||||
|
||||
async updateDag(dagId: string, isPaused: boolean): Promise<Dag> {
|
||||
const init = {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2022 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 { ApacheAirflowApi, apacheAirflowApiRef } from '../../api';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { DagTableComponent } from './DagTableComponent';
|
||||
|
||||
describe('DagTableComponent', () => {
|
||||
const mockApi: jest.Mocked<ApacheAirflowApi> = {
|
||||
listDags: jest.fn().mockResolvedValue([
|
||||
{
|
||||
dag_id: 'mock_dag_1',
|
||||
},
|
||||
{
|
||||
dag_id: 'mock_dag_2',
|
||||
},
|
||||
{
|
||||
dag_id: 'mock_dag_3',
|
||||
},
|
||||
]),
|
||||
getDags: jest.fn().mockResolvedValue({
|
||||
dags: [{ dag_id: 'mock_dag_1' }],
|
||||
dagsNotFound: ['a-random-id'],
|
||||
}),
|
||||
} as any;
|
||||
|
||||
it('should render all DAGs', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
<TestApiProvider apis={[[apacheAirflowApiRef, mockApi]]}>
|
||||
<DagTableComponent />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
['mock_dag_1', 'mock_dag_2', 'mock_dag_3'].forEach(dagId => {
|
||||
expect(getByText(dagId)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render only selected DAGs', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
<TestApiProvider apis={[[apacheAirflowApiRef, mockApi]]}>
|
||||
<DagTableComponent dagIds={['mock_dag_1', 'a-random-id']} />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
expect(getByText('mock_dag_1')).toBeInTheDocument();
|
||||
expect(getByText('Warning: 1 DAGs were not found')).toBeInTheDocument();
|
||||
expect(getByText('a-random-id')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
StatusOK,
|
||||
Table,
|
||||
TableColumn,
|
||||
WarningPanel,
|
||||
} from '@backstage/core-components';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import Box from '@material-ui/core/Box';
|
||||
@@ -129,9 +130,18 @@ export const DenseTable = ({ dags }: DenseTableProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const DagTableComponent = () => {
|
||||
type DagTableComponentProps = {
|
||||
dagIds?: string[];
|
||||
};
|
||||
|
||||
export const DagTableComponent = ({ dagIds }: DagTableComponentProps) => {
|
||||
const apiClient = useApi(apacheAirflowApiRef);
|
||||
|
||||
const { value, loading, error } = useAsync(async (): Promise<Dag[]> => {
|
||||
if (dagIds) {
|
||||
const { dags } = await apiClient.getDags(dagIds);
|
||||
return dags;
|
||||
}
|
||||
return await apiClient.listDags();
|
||||
}, []);
|
||||
|
||||
@@ -146,6 +156,22 @@ export const DagTableComponent = () => {
|
||||
id: el.dag_id, // table records require `id` attribute
|
||||
dagUrl: `${apiClient.baseUrl}dag_details?dag_id=${el.dag_id}`, // construct path to DAG using `baseUrl`
|
||||
}));
|
||||
|
||||
return <DenseTable dags={data || []} />;
|
||||
const dagsNotFound =
|
||||
dagIds && value
|
||||
? dagIds.filter(id => !value.find(d => d.dag_id === id))
|
||||
: [];
|
||||
return (
|
||||
<>
|
||||
{dagsNotFound.length ? (
|
||||
<WarningPanel title={`${dagsNotFound.length} DAGs were not found`}>
|
||||
{dagsNotFound.map(dagId => (
|
||||
<Typography key={dagId}>{dagId}</Typography>
|
||||
))}
|
||||
</WarningPanel>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<DenseTable dags={data || []} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,4 +20,8 @@
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export { apacheAirflowPlugin, ApacheAirflowPage } from './plugin';
|
||||
export {
|
||||
apacheAirflowPlugin,
|
||||
ApacheAirflowPage,
|
||||
ApacheAirflowDagTable,
|
||||
} from './plugin';
|
||||
|
||||
@@ -17,11 +17,12 @@
|
||||
import { rootRouteRef } from './routes';
|
||||
import { apacheAirflowApiRef, ApacheAirflowClient } from './api';
|
||||
import {
|
||||
configApiRef,
|
||||
createApiFactory,
|
||||
createComponentExtension,
|
||||
createPlugin,
|
||||
createRoutableExtension,
|
||||
discoveryApiRef,
|
||||
configApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
export const apacheAirflowPlugin = createPlugin({
|
||||
@@ -49,3 +50,20 @@ export const ApacheAirflowPage = apacheAirflowPlugin.provide(
|
||||
mountPoint: rootRouteRef,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Render the DAGs in a table
|
||||
* If the dagIds is specified, only those DAGs are loaded.
|
||||
* Otherwise, it's going to list all the DAGs
|
||||
* @public
|
||||
* @param dagIds - optional string[] of the DAGs to show in the table. If undefined, it will list all DAGs
|
||||
*/
|
||||
export const ApacheAirflowDagTable = apacheAirflowPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'ApacheAirflowDagTable',
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/DagTableComponent').then(m => m.DagTableComponent),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
+3
-2
@@ -32,10 +32,11 @@ export interface Config {
|
||||
*/
|
||||
host: string;
|
||||
/**
|
||||
* (Required) Gitlab's group[/subgroup] where the discovery is done.
|
||||
* (Optional) Gitlab's group[/subgroup] where the discovery is done.
|
||||
* If not defined the whole project will be scanned.
|
||||
* @visibility backend
|
||||
*/
|
||||
group: string;
|
||||
group?: string;
|
||||
/**
|
||||
* (Optional) Default branch to read the catalog-info.yaml file.
|
||||
* If not set, 'master' will be used.
|
||||
|
||||
@@ -100,7 +100,26 @@ describe('config', () => {
|
||||
});
|
||||
|
||||
expect(() => readGitlabConfigs(config)).toThrow(
|
||||
"Missing required config value at 'catalog.providers.gitlab.test.group'",
|
||||
"Missing required config value at 'catalog.providers.gitlab.test.host'",
|
||||
);
|
||||
});
|
||||
|
||||
it('read full gitlab project', () => {
|
||||
const config = new ConfigReader({
|
||||
catalog: {
|
||||
providers: {
|
||||
gitlab: {
|
||||
test: {
|
||||
host: 'host',
|
||||
branch: 'main',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = readGitlabConfigs(config);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].group).toEqual('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ import { GitlabProviderConfig } from '../lib/types';
|
||||
* @param config - The config object to extract from
|
||||
*/
|
||||
function readGitlabConfig(id: string, config: Config): GitlabProviderConfig {
|
||||
const group = config.getString('group');
|
||||
const group = config.getOptionalString('group') ?? '';
|
||||
const host = config.getString('host');
|
||||
const branch = config.getOptionalString('branch') ?? 'master';
|
||||
const catalogFile =
|
||||
|
||||
@@ -345,206 +345,128 @@ describe('readLdapGroups', () => {
|
||||
|
||||
describe('resolveRelations', () => {
|
||||
describe('lookup', () => {
|
||||
it('matches by DN', () => {
|
||||
const parent = group({
|
||||
metadata: {
|
||||
name: 'parent',
|
||||
annotations: { [LDAP_DN_ANNOTATION]: 'pa' },
|
||||
},
|
||||
});
|
||||
const child = group({
|
||||
metadata: {
|
||||
name: 'child',
|
||||
annotations: { [LDAP_DN_ANNOTATION]: 'ca' },
|
||||
},
|
||||
});
|
||||
const groupMember = new Map<string, Set<string>>([
|
||||
['pa', new Set(['ca'])],
|
||||
]);
|
||||
resolveRelations([parent, child], [], new Map(), new Map(), groupMember);
|
||||
expect(parent.spec.children).toEqual(['group:default/child']);
|
||||
expect(child.spec.parent).toEqual('group:default/parent');
|
||||
});
|
||||
|
||||
it('matches by UUID', () => {
|
||||
const parent = group({
|
||||
metadata: {
|
||||
name: 'parent',
|
||||
annotations: { [LDAP_UUID_ANNOTATION]: 'pa' },
|
||||
},
|
||||
});
|
||||
const child = group({
|
||||
metadata: {
|
||||
name: 'child',
|
||||
annotations: { [LDAP_UUID_ANNOTATION]: 'ca' },
|
||||
},
|
||||
});
|
||||
const groupMember = new Map<string, Set<string>>([
|
||||
['pa', new Set(['ca'])],
|
||||
]);
|
||||
resolveRelations([parent, child], [], new Map(), new Map(), groupMember);
|
||||
expect(parent.spec.children).toEqual(['group:default/child']);
|
||||
expect(child.spec.parent).toEqual('group:default/parent');
|
||||
});
|
||||
it.each([LDAP_DN_ANNOTATION, LDAP_RDN_ANNOTATION, LDAP_UUID_ANNOTATION])(
|
||||
'matches by %s',
|
||||
annotation => {
|
||||
const parent = group({
|
||||
metadata: {
|
||||
name: 'parent',
|
||||
annotations: { [annotation]: 'pa' },
|
||||
},
|
||||
});
|
||||
const child = group({
|
||||
metadata: {
|
||||
name: 'child',
|
||||
annotations: { [annotation]: 'ca' },
|
||||
},
|
||||
});
|
||||
const groupMember = new Map<string, Set<string>>([
|
||||
['pa', new Set(['ca'])],
|
||||
]);
|
||||
resolveRelations(
|
||||
[parent, child],
|
||||
[],
|
||||
new Map(),
|
||||
new Map(),
|
||||
groupMember,
|
||||
);
|
||||
expect(parent.spec.children).toEqual(['group:default/child']);
|
||||
expect(child.spec.parent).toEqual('group:default/parent');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('userMemberOf', () => {
|
||||
it('populates relations by dn', () => {
|
||||
const host = group({
|
||||
metadata: { name: 'host', annotations: { [LDAP_DN_ANNOTATION]: 'ha' } },
|
||||
});
|
||||
const member = user({
|
||||
metadata: {
|
||||
name: 'member',
|
||||
annotations: { [LDAP_DN_ANNOTATION]: 'ma' },
|
||||
},
|
||||
});
|
||||
const userMemberOf = new Map<string, Set<string>>([
|
||||
['ma', new Set(['ha'])],
|
||||
]);
|
||||
resolveRelations([host], [member], userMemberOf, new Map(), new Map());
|
||||
expect(member.spec.memberOf).toEqual(['group:default/host']);
|
||||
});
|
||||
|
||||
it('populates relations by uuid', () => {
|
||||
const host = group({
|
||||
metadata: {
|
||||
name: 'host',
|
||||
annotations: { [LDAP_UUID_ANNOTATION]: 'ha' },
|
||||
},
|
||||
});
|
||||
const member = user({
|
||||
metadata: {
|
||||
name: 'member',
|
||||
annotations: { [LDAP_DN_ANNOTATION]: 'ma' },
|
||||
},
|
||||
});
|
||||
const userMemberOf = new Map<string, Set<string>>([
|
||||
['ma', new Set(['ha'])],
|
||||
]);
|
||||
resolveRelations([host], [member], userMemberOf, new Map(), new Map());
|
||||
expect(member.spec.memberOf).toEqual(['group:default/host']);
|
||||
});
|
||||
it.each([LDAP_DN_ANNOTATION, LDAP_RDN_ANNOTATION, LDAP_UUID_ANNOTATION])(
|
||||
'populates relations by %s',
|
||||
annotation => {
|
||||
const host = group({
|
||||
metadata: { name: 'host', annotations: { [annotation]: 'ha' } },
|
||||
});
|
||||
const member = user({
|
||||
metadata: {
|
||||
name: 'member',
|
||||
annotations: { [annotation]: 'ma' },
|
||||
},
|
||||
});
|
||||
const userMemberOf = new Map<string, Set<string>>([
|
||||
['ma', new Set(['ha'])],
|
||||
]);
|
||||
resolveRelations([host], [member], userMemberOf, new Map(), new Map());
|
||||
expect(member.spec.memberOf).toEqual(['group:default/host']);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('groupMemberOf', () => {
|
||||
it('populates relations by dn', () => {
|
||||
const parent = group({
|
||||
metadata: {
|
||||
name: 'parent',
|
||||
annotations: { [LDAP_DN_ANNOTATION]: 'pa' },
|
||||
},
|
||||
});
|
||||
const child = group({
|
||||
metadata: {
|
||||
name: 'child',
|
||||
annotations: { [LDAP_DN_ANNOTATION]: 'ca' },
|
||||
},
|
||||
});
|
||||
const groupMemberOf = new Map<string, Set<string>>([
|
||||
['ca', new Set(['pa'])],
|
||||
]);
|
||||
resolveRelations(
|
||||
[parent, child],
|
||||
[],
|
||||
new Map(),
|
||||
groupMemberOf,
|
||||
new Map(),
|
||||
);
|
||||
expect(parent.spec.children).toEqual(['group:default/child']);
|
||||
expect(child.spec.parent).toEqual('group:default/parent');
|
||||
});
|
||||
});
|
||||
|
||||
it('populates relations by uuid', () => {
|
||||
const parent = group({
|
||||
metadata: {
|
||||
name: 'parent',
|
||||
annotations: { [LDAP_UUID_ANNOTATION]: 'pa' },
|
||||
it.each([LDAP_DN_ANNOTATION, LDAP_RDN_ANNOTATION, LDAP_UUID_ANNOTATION])(
|
||||
'populates relations by %s',
|
||||
annotation => {
|
||||
const parent = group({
|
||||
metadata: {
|
||||
name: 'parent',
|
||||
annotations: { [annotation]: 'pa' },
|
||||
},
|
||||
});
|
||||
const child = group({
|
||||
metadata: {
|
||||
name: 'child',
|
||||
annotations: { [annotation]: 'ca' },
|
||||
},
|
||||
});
|
||||
const groupMemberOf = new Map<string, Set<string>>([
|
||||
['ca', new Set(['pa'])],
|
||||
]);
|
||||
resolveRelations(
|
||||
[parent, child],
|
||||
[],
|
||||
new Map(),
|
||||
groupMemberOf,
|
||||
new Map(),
|
||||
);
|
||||
expect(parent.spec.children).toEqual(['group:default/child']);
|
||||
expect(child.spec.parent).toEqual('group:default/parent');
|
||||
},
|
||||
});
|
||||
const child = group({
|
||||
metadata: {
|
||||
name: 'child',
|
||||
annotations: { [LDAP_UUID_ANNOTATION]: 'ca' },
|
||||
},
|
||||
});
|
||||
const groupMemberOf = new Map<string, Set<string>>([
|
||||
['ca', new Set(['pa'])],
|
||||
]);
|
||||
resolveRelations([parent, child], [], new Map(), groupMemberOf, new Map());
|
||||
expect(parent.spec.children).toEqual(['group:default/child']);
|
||||
expect(child.spec.parent).toEqual('group:default/parent');
|
||||
);
|
||||
});
|
||||
|
||||
describe('groupMember', () => {
|
||||
it('populates relations by dn', () => {
|
||||
const parent = group({
|
||||
metadata: {
|
||||
name: 'parent',
|
||||
annotations: { [LDAP_DN_ANNOTATION]: 'pa' },
|
||||
},
|
||||
});
|
||||
const child = group({
|
||||
metadata: {
|
||||
name: 'child',
|
||||
annotations: { [LDAP_DN_ANNOTATION]: 'ca' },
|
||||
},
|
||||
});
|
||||
const member = user({
|
||||
metadata: {
|
||||
name: 'member',
|
||||
annotations: { [LDAP_DN_ANNOTATION]: 'ma' },
|
||||
},
|
||||
});
|
||||
const groupMember = new Map<string, Set<string>>([
|
||||
['pa', new Set(['ca', 'ma'])],
|
||||
]);
|
||||
resolveRelations(
|
||||
[parent, child],
|
||||
[member],
|
||||
new Map(),
|
||||
new Map(),
|
||||
groupMember,
|
||||
);
|
||||
expect(parent.spec.children).toEqual(['group:default/child']);
|
||||
expect(child.spec.parent).toEqual('group:default/parent');
|
||||
expect(member.spec.memberOf).toEqual(['group:default/parent']);
|
||||
});
|
||||
|
||||
it('populates relations by uuid', () => {
|
||||
const parent = group({
|
||||
metadata: {
|
||||
name: 'parent',
|
||||
annotations: { [LDAP_UUID_ANNOTATION]: 'pa' },
|
||||
},
|
||||
});
|
||||
const child = group({
|
||||
metadata: {
|
||||
name: 'child',
|
||||
annotations: { [LDAP_UUID_ANNOTATION]: 'ca' },
|
||||
},
|
||||
});
|
||||
const member = user({
|
||||
metadata: {
|
||||
name: 'member',
|
||||
annotations: { [LDAP_UUID_ANNOTATION]: 'ma' },
|
||||
},
|
||||
});
|
||||
const groupMember = new Map<string, Set<string>>([
|
||||
['pa', new Set(['ca', 'ma'])],
|
||||
]);
|
||||
resolveRelations(
|
||||
[parent, child],
|
||||
[member],
|
||||
new Map(),
|
||||
new Map(),
|
||||
groupMember,
|
||||
);
|
||||
expect(parent.spec.children).toEqual(['group:default/child']);
|
||||
expect(child.spec.parent).toEqual('group:default/parent');
|
||||
expect(member.spec.memberOf).toEqual(['group:default/parent']);
|
||||
});
|
||||
it.each([LDAP_DN_ANNOTATION, LDAP_RDN_ANNOTATION, LDAP_UUID_ANNOTATION])(
|
||||
'populates relations by %s',
|
||||
annotation => {
|
||||
const parent = group({
|
||||
metadata: {
|
||||
name: 'parent',
|
||||
annotations: { [annotation]: 'pa' },
|
||||
},
|
||||
});
|
||||
const child = group({
|
||||
metadata: {
|
||||
name: 'child',
|
||||
annotations: { [annotation]: 'ca' },
|
||||
},
|
||||
});
|
||||
const member = user({
|
||||
metadata: {
|
||||
name: 'member',
|
||||
annotations: { [annotation]: 'ma' },
|
||||
},
|
||||
});
|
||||
const groupMember = new Map<string, Set<string>>([
|
||||
['pa', new Set(['ca', 'ma'])],
|
||||
]);
|
||||
resolveRelations(
|
||||
[parent, child],
|
||||
[member],
|
||||
new Map(),
|
||||
new Map(),
|
||||
groupMember,
|
||||
);
|
||||
expect(parent.spec.children).toEqual(['group:default/child']);
|
||||
expect(child.spec.parent).toEqual('group:default/parent');
|
||||
expect(member.spec.memberOf).toEqual(['group:default/parent']);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -365,11 +365,13 @@ export function resolveRelations(
|
||||
for (const user of users) {
|
||||
userMap.set(stringifyEntityRef(user), user);
|
||||
userMap.set(user.metadata.annotations![LDAP_DN_ANNOTATION], user);
|
||||
userMap.set(user.metadata.annotations![LDAP_RDN_ANNOTATION], user);
|
||||
userMap.set(user.metadata.annotations![LDAP_UUID_ANNOTATION], user);
|
||||
}
|
||||
for (const group of groups) {
|
||||
groupMap.set(stringifyEntityRef(group), group);
|
||||
groupMap.set(group.metadata.annotations![LDAP_DN_ANNOTATION], group);
|
||||
groupMap.set(group.metadata.annotations![LDAP_RDN_ANNOTATION], group);
|
||||
groupMap.set(group.metadata.annotations![LDAP_UUID_ANNOTATION], group);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,27 @@
|
||||
# Catalog Backend Module for OpenAPI specifications
|
||||
|
||||
This is an extension module to the plugin-catalog-backend plugin, providing extensions targeted at OpenAPI specifications.
|
||||
|
||||
With this you can split your OpenAPI definition into multiple files and reference them. They will be bundled, using an UrlReader, during processing and stored as a single specification.
|
||||
|
||||
## Installation
|
||||
|
||||
### Install the package
|
||||
|
||||
```bash
|
||||
# From your Backstage root directory
|
||||
yarn add --cwd packages/backend @backstage/plugin-catalog-backend-module-openapi
|
||||
```
|
||||
|
||||
### Adding the plugin to your `packages/backend`
|
||||
|
||||
The processor can be added by importing `OpenApiRefProcessor` in `src/plugins/catalog.ts` in your `backend` package and adding the following.
|
||||
|
||||
```ts
|
||||
builder.addProcessor(
|
||||
OpenApiRefProcessor.fromConfig(env.config, {
|
||||
logger: env.logger,
|
||||
reader: env.reader,
|
||||
}),
|
||||
);
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
## API Report File for "@backstage/plugin-catalog-backend-module-openapi"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { CatalogProcessor } from '@backstage/plugin-catalog-backend';
|
||||
import { Config } from '@backstage/config';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { LocationSpec } from '@backstage/plugin-catalog-backend';
|
||||
import { Logger } from 'winston';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { UrlReader } from '@backstage/backend-common';
|
||||
|
||||
// @public (undocumented)
|
||||
export class OpenApiRefProcessor implements CatalogProcessor {
|
||||
constructor(options: {
|
||||
integrations: ScmIntegrations;
|
||||
logger: Logger;
|
||||
reader: UrlReader;
|
||||
});
|
||||
// (undocumented)
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options: {
|
||||
logger: Logger;
|
||||
reader: UrlReader;
|
||||
},
|
||||
): OpenApiRefProcessor;
|
||||
// (undocumented)
|
||||
getProcessorName(): string;
|
||||
// (undocumented)
|
||||
preProcessEntity(entity: Entity, location: LocationSpec): Promise<Entity>;
|
||||
}
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@backstage/plugin-catalog-backend-module-openapi",
|
||||
"description": "A Backstage catalog backend module that helps with OpenAPI specifications",
|
||||
"version": "0.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"backstage": {
|
||||
"role": "backend-plugin-module"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "plugins/catalog-backend-module-openapi"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "backstage-cli package build",
|
||||
"lint": "backstage-cli package lint",
|
||||
"test": "backstage-cli package test",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack",
|
||||
"clean": "backstage-cli package clean",
|
||||
"start": "backstage-cli package start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apidevtools/swagger-parser": "^10.1.0",
|
||||
"@backstage/backend-common": "^0.14.1-next.0",
|
||||
"@backstage/catalog-model": "^1.1.0-next.0",
|
||||
"@backstage/config": "^1.0.1",
|
||||
"@backstage/integration": "^1.2.2-next.0",
|
||||
"@backstage/plugin-catalog-backend": "^1.2.1-next.0",
|
||||
"winston": "^3.2.1",
|
||||
"yaml": "^2.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-test-utils": "^0.1.26-next.0",
|
||||
"@backstage/cli": "^0.17.3-next.0",
|
||||
"openapi-types": "^11.0.1"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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 { getVoidLogger } from '@backstage/backend-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { LocationSpec } from '@backstage/plugin-catalog-backend';
|
||||
import { OpenApiRefProcessor } from './OpenApiRefProcessor';
|
||||
import { bundleOpenApiSpecification } from './lib';
|
||||
|
||||
jest.mock('./lib', () => ({
|
||||
bundleOpenApiSpecification: jest.fn(),
|
||||
}));
|
||||
|
||||
const bundledSpecification = '<bundled-specification>';
|
||||
|
||||
describe('OpenApiRefProcessor', () => {
|
||||
const mockLocation = (): LocationSpec => ({
|
||||
type: 'url',
|
||||
target: `https://github.com/owner/repo/blob/main/catalog-info.yaml`,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
(bundleOpenApiSpecification as any).mockResolvedValue(bundledSpecification);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('preProcessEntity', () => {
|
||||
const setupTest = ({ kind = 'API', spec = {} } = {}) => {
|
||||
const entity = {
|
||||
kind,
|
||||
spec: { definition: '<openapi-definition>', ...spec },
|
||||
};
|
||||
const config = new ConfigReader({});
|
||||
const reader = {
|
||||
read: jest.fn(),
|
||||
readTree: jest.fn(),
|
||||
search: jest.fn(),
|
||||
};
|
||||
const processor = OpenApiRefProcessor.fromConfig(config, {
|
||||
logger: getVoidLogger(),
|
||||
reader,
|
||||
});
|
||||
|
||||
return { entity, processor };
|
||||
};
|
||||
|
||||
it('should bundle OpenAPI specifications', async () => {
|
||||
const { entity, processor } = setupTest({
|
||||
kind: 'API',
|
||||
spec: { type: 'openapi' },
|
||||
});
|
||||
|
||||
const result = await processor.preProcessEntity(
|
||||
entity as any,
|
||||
mockLocation(),
|
||||
);
|
||||
|
||||
expect(result.spec?.definition).toEqual(bundledSpecification);
|
||||
});
|
||||
|
||||
it('should ignore other kinds', async () => {
|
||||
const { entity, processor } = setupTest({ kind: 'Group' });
|
||||
|
||||
const result = await processor.preProcessEntity(
|
||||
entity as any,
|
||||
mockLocation(),
|
||||
);
|
||||
|
||||
expect(result).toEqual(entity);
|
||||
});
|
||||
|
||||
it('should ignore other specification types', async () => {
|
||||
const { entity, processor } = setupTest({
|
||||
kind: 'Group',
|
||||
spec: { type: 'asyncapi' },
|
||||
});
|
||||
|
||||
const result = await processor.preProcessEntity(
|
||||
entity as any,
|
||||
mockLocation(),
|
||||
);
|
||||
|
||||
expect(result).toEqual(entity);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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 { UrlReader } from '@backstage/backend-common';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import {
|
||||
CatalogProcessor,
|
||||
LocationSpec,
|
||||
} from '@backstage/plugin-catalog-backend';
|
||||
import { bundleOpenApiSpecification } from './lib';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
/** @public */
|
||||
export class OpenApiRefProcessor implements CatalogProcessor {
|
||||
private readonly integrations: ScmIntegrations;
|
||||
private readonly logger: Logger;
|
||||
private readonly reader: UrlReader;
|
||||
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options: { logger: Logger; reader: UrlReader },
|
||||
) {
|
||||
const integrations = ScmIntegrations.fromConfig(config);
|
||||
|
||||
return new OpenApiRefProcessor({
|
||||
...options,
|
||||
integrations,
|
||||
});
|
||||
}
|
||||
|
||||
constructor(options: {
|
||||
integrations: ScmIntegrations;
|
||||
logger: Logger;
|
||||
reader: UrlReader;
|
||||
}) {
|
||||
this.integrations = options.integrations;
|
||||
this.logger = options.logger;
|
||||
this.reader = options.reader;
|
||||
}
|
||||
|
||||
getProcessorName(): string {
|
||||
return 'OpenApiRefProcessor';
|
||||
}
|
||||
|
||||
async preProcessEntity(
|
||||
entity: Entity,
|
||||
location: LocationSpec,
|
||||
): Promise<Entity> {
|
||||
if (
|
||||
!entity ||
|
||||
entity.kind !== 'API' ||
|
||||
(entity.spec && entity.spec.type !== 'openapi')
|
||||
) {
|
||||
return entity;
|
||||
}
|
||||
|
||||
const scmIntegration = this.integrations.byUrl(location.target);
|
||||
if (!scmIntegration) {
|
||||
return entity;
|
||||
}
|
||||
|
||||
this.logger.debug(`Bundling OpenAPI specification from ${location.target}`);
|
||||
try {
|
||||
const bundledSpec = await bundleOpenApiSpecification(
|
||||
entity.spec!.definition?.toString(),
|
||||
location.target,
|
||||
this.reader,
|
||||
scmIntegration,
|
||||
);
|
||||
|
||||
return {
|
||||
...entity,
|
||||
spec: { ...entity.spec, definition: bundledSpec },
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Unable to bundle OpenAPI specification`, error);
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-11
@@ -13,14 +13,4 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { OptionValues } from 'commander';
|
||||
import { buildPackage, Output } from '../../lib/builder';
|
||||
|
||||
export default async (opts: OptionValues) => {
|
||||
await buildPackage({
|
||||
outputs: new Set([Output.cjs, Output.types]),
|
||||
minify: opts.minify,
|
||||
useApiExtractor: opts.experimentalTypeBuild,
|
||||
});
|
||||
};
|
||||
export { OpenApiRefProcessor } from './OpenApiRefProcessor';
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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 { ConfigReader } from '@backstage/config';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { bundleOpenApiSpecification } from './bundle';
|
||||
|
||||
const specification = `
|
||||
openapi: "3.0.0"
|
||||
info:
|
||||
version: 1.0.0
|
||||
title: Swagger Petstore
|
||||
license:
|
||||
name: MIT
|
||||
servers:
|
||||
- url: http://petstore.swagger.io/v1
|
||||
paths:
|
||||
/pets:
|
||||
get:
|
||||
$ref: "./paths/pets/list.yaml"
|
||||
`;
|
||||
|
||||
const list = `
|
||||
---
|
||||
summary: List all pets
|
||||
operationId: listPets
|
||||
tags:
|
||||
- pets
|
||||
responses:
|
||||
'200':
|
||||
description: A paged array of pets
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
`;
|
||||
|
||||
const expectedResult = `
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
version: 1.0.0
|
||||
title: Swagger Petstore
|
||||
license:
|
||||
name: MIT
|
||||
servers:
|
||||
- url: http://petstore.swagger.io/v1
|
||||
paths:
|
||||
/pets:
|
||||
get:
|
||||
summary: List all pets
|
||||
operationId: listPets
|
||||
tags:
|
||||
- pets
|
||||
responses:
|
||||
"200":
|
||||
description: A paged array of pets
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
`;
|
||||
|
||||
describe('bundleOpenApiSpecification', () => {
|
||||
const readUrl = jest.fn();
|
||||
const reader = {
|
||||
readUrl,
|
||||
read: jest.fn(),
|
||||
readTree: jest.fn(),
|
||||
search: jest.fn(),
|
||||
};
|
||||
|
||||
const scmIntegration = ScmIntegrations.fromConfig(new ConfigReader({})).byUrl(
|
||||
'https://github.com/owner/repo/blob/main/openapi.yaml',
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return undefined if no specification is supplied', async () => {
|
||||
expect(
|
||||
await bundleOpenApiSpecification(
|
||||
undefined,
|
||||
'https://github.com/owner/repo/blob/main/openapi.yaml',
|
||||
reader,
|
||||
scmIntegration as any,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the bundled specification', async () => {
|
||||
readUrl.mockResolvedValue({
|
||||
buffer: jest.fn().mockResolvedValue(list),
|
||||
});
|
||||
|
||||
const result = await bundleOpenApiSpecification(
|
||||
specification,
|
||||
'https://github.com/owner/repo/blob/main/openapi.yaml',
|
||||
reader,
|
||||
scmIntegration as any,
|
||||
);
|
||||
|
||||
expect(result).toEqual(expectedResult.trimStart());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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 { UrlReader } from '@backstage/backend-common';
|
||||
import { ScmIntegration } from '@backstage/integration';
|
||||
import SwaggerParser from '@apidevtools/swagger-parser';
|
||||
import { parse, stringify } from 'yaml';
|
||||
import * as path from 'path';
|
||||
|
||||
const protocolPattern = /^(\w{2,}):\/\//i;
|
||||
const getProtocol = (refPath: string) => {
|
||||
const match = protocolPattern.exec(refPath);
|
||||
if (match) {
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export async function bundleOpenApiSpecification(
|
||||
specification: string | undefined,
|
||||
targetUrl: string,
|
||||
reader: UrlReader,
|
||||
scmIntegration: ScmIntegration,
|
||||
): Promise<string | undefined> {
|
||||
const fileUrlReaderResolver: SwaggerParser.ResolverOptions = {
|
||||
canRead: file => {
|
||||
const protocol = getProtocol(file.url);
|
||||
return protocol === undefined || protocol === 'file';
|
||||
},
|
||||
read: async file => {
|
||||
const relativePath = path.relative('.', file.url);
|
||||
const url = scmIntegration.resolveUrl({
|
||||
base: targetUrl,
|
||||
url: relativePath,
|
||||
});
|
||||
if (reader.readUrl) {
|
||||
const data = await reader.readUrl(url);
|
||||
return data.buffer();
|
||||
}
|
||||
throw new Error('UrlReader has no readUrl method defined');
|
||||
},
|
||||
};
|
||||
|
||||
if (!specification) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const options: SwaggerParser.Options = {
|
||||
resolve: {
|
||||
file: fileUrlReaderResolver,
|
||||
http: true,
|
||||
},
|
||||
};
|
||||
const specObject = parse(specification);
|
||||
const bundledSpec = await SwaggerParser.bundle(specObject, options);
|
||||
return stringify(bundledSpec);
|
||||
}
|
||||
+1
-21
@@ -13,24 +13,4 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { OptionValues } from 'commander';
|
||||
import { serveBundle } from '../../lib/bundler';
|
||||
import { loadCliConfig } from '../../lib/config';
|
||||
import { paths } from '../../lib/paths';
|
||||
|
||||
export default async (opts: OptionValues) => {
|
||||
const { name } = await fs.readJson(paths.resolveTarget('package.json'));
|
||||
const waitForExit = await serveBundle({
|
||||
entry: 'dev/index',
|
||||
checksEnabled: opts.check,
|
||||
...(await loadCliConfig({
|
||||
args: opts.config,
|
||||
fromPackage: name,
|
||||
withFilteredKeys: true,
|
||||
})),
|
||||
});
|
||||
|
||||
await waitForExit();
|
||||
};
|
||||
export * from './bundle';
|
||||
+1
-10
@@ -14,13 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { OptionValues } from 'commander';
|
||||
import { buildPackage, Output } from '../../lib/builder';
|
||||
|
||||
export default async (opts: OptionValues) => {
|
||||
await buildPackage({
|
||||
outputs: new Set([Output.esm, Output.types]),
|
||||
minify: opts.minify,
|
||||
useApiExtractor: opts.experimentalTypeBuild,
|
||||
});
|
||||
};
|
||||
export {};
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
} from '@backstage/backend-common';
|
||||
import {
|
||||
Entity,
|
||||
isUserEntity,
|
||||
stringifyEntityRef,
|
||||
UserEntity,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
@@ -93,13 +93,9 @@ export class DefaultCatalogCollator {
|
||||
return formatted.toLowerCase();
|
||||
}
|
||||
|
||||
private isUserEntity(entity: Entity): entity is UserEntity {
|
||||
return entity.kind.toLocaleUpperCase('en-US') === 'USER';
|
||||
}
|
||||
|
||||
private getDocumentText(entity: Entity): string {
|
||||
let documentText = entity.metadata.description || '';
|
||||
if (this.isUserEntity(entity)) {
|
||||
if (isUserEntity(entity)) {
|
||||
if (entity.spec?.profile?.displayName && documentText) {
|
||||
// combine displayName and description
|
||||
const displayName = entity.spec?.profile?.displayName;
|
||||
|
||||
@@ -14,15 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity, UserEntity, GroupEntity } from '@backstage/catalog-model';
|
||||
|
||||
function isUserEntity(entity: Entity): entity is UserEntity {
|
||||
return entity.kind.toLocaleUpperCase('en-US') === 'USER';
|
||||
}
|
||||
|
||||
function isGroupEntity(entity: Entity): entity is GroupEntity {
|
||||
return entity.kind.toLocaleUpperCase('en-US') === 'GROUP';
|
||||
}
|
||||
import { Entity, isUserEntity, isGroupEntity } from '@backstage/catalog-model';
|
||||
|
||||
export function getDocumentText(entity: Entity): string {
|
||||
const documentTexts: string[] = [];
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity, ComponentEntity } from '@backstage/catalog-model';
|
||||
import { ComponentEntity, Entity } from '@backstage/catalog-model';
|
||||
|
||||
function strCmp(a: string | undefined, b: string | undefined): boolean {
|
||||
return Boolean(
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"backstage": {
|
||||
"role": "backend-plugin"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"backstage": {
|
||||
"role": "frontend-plugin"
|
||||
"role": "common-library"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "backstage-cli package start",
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"backstage": {
|
||||
"role": "frontend-plugin"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import {
|
||||
DEFAULT_NAMESPACE,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import {
|
||||
SidebarItem,
|
||||
SidebarSubmenu,
|
||||
@@ -32,7 +35,6 @@ import {
|
||||
catalogApiRef,
|
||||
CatalogApi,
|
||||
entityRouteRef,
|
||||
humanizeEntityRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { getCompoundEntityRef } from '@backstage/catalog-model';
|
||||
|
||||
@@ -94,9 +96,11 @@ export const MyGroupsSidebarItem = (props: {
|
||||
{groups?.map(function groupsMap(group) {
|
||||
return (
|
||||
<SidebarSubmenuItem
|
||||
title={
|
||||
group.metadata.title ||
|
||||
humanizeEntityRef(group, { defaultKind: 'group' })
|
||||
title={group.metadata.title || group.metadata.name}
|
||||
subtitle={
|
||||
group.metadata.namespace !== DEFAULT_NAMESPACE
|
||||
? group.metadata.namespace
|
||||
: undefined
|
||||
}
|
||||
to={catalogEntityRoute(getCompoundEntityRef(group))}
|
||||
icon={icon}
|
||||
|
||||
@@ -17,59 +17,59 @@
|
||||
import React from 'react';
|
||||
import { screen, render, waitFor, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { configApiRef, analyticsApiRef } from '@backstage/core-plugin-api';
|
||||
import { ApiProvider, ConfigReader } from '@backstage/core-app-api';
|
||||
import { MockAnalyticsApi, TestApiRegistry } from '@backstage/test-utils';
|
||||
|
||||
import { ConfigReader } from '@backstage/core-app-api';
|
||||
import {
|
||||
MockAnalyticsApi,
|
||||
TestApiProvider,
|
||||
renderWithEffects,
|
||||
} from '@backstage/test-utils';
|
||||
import { searchApiRef } from '../../api';
|
||||
import { SearchContextProvider } from '../../context';
|
||||
import { SearchBar } from './SearchBar';
|
||||
|
||||
jest.mock('@backstage/core-plugin-api', () => ({
|
||||
...jest.requireActual('@backstage/core-plugin-api'),
|
||||
}));
|
||||
const createInitialState = ({
|
||||
term = '',
|
||||
filters = {},
|
||||
types = ['*'],
|
||||
pageCursor = '',
|
||||
} = {}) => ({
|
||||
term,
|
||||
filters,
|
||||
types,
|
||||
pageCursor,
|
||||
});
|
||||
|
||||
describe('SearchBar', () => {
|
||||
const initialState = {
|
||||
term: '',
|
||||
filters: {},
|
||||
types: ['*'],
|
||||
pageCursor: '',
|
||||
};
|
||||
const query = jest.fn().mockResolvedValue({ results: [] });
|
||||
|
||||
const query = jest.fn().mockResolvedValue({});
|
||||
const analyticsApiSpy = new MockAnalyticsApi();
|
||||
let apiRegistry: TestApiRegistry;
|
||||
const analyticsApiMock = new MockAnalyticsApi();
|
||||
|
||||
apiRegistry = TestApiRegistry.from(
|
||||
[
|
||||
configApiRef,
|
||||
new ConfigReader({
|
||||
app: { title: 'Mock title' },
|
||||
}),
|
||||
],
|
||||
[searchApiRef, { query }],
|
||||
);
|
||||
const configApiMock = new ConfigReader({
|
||||
app: { title: 'Mock title' },
|
||||
});
|
||||
|
||||
const name = 'Search';
|
||||
const term = 'term';
|
||||
const searchApiMock = { query };
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks();
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('Renders without exploding', async () => {
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[searchApiRef, searchApiMock],
|
||||
]}
|
||||
>
|
||||
<SearchContextProvider>
|
||||
<SearchBar />
|
||||
</SearchContextProvider>
|
||||
</ApiProvider>,
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('Search in Mock title'),
|
||||
).toBeInTheDocument();
|
||||
@@ -77,59 +77,72 @@ describe('SearchBar', () => {
|
||||
});
|
||||
|
||||
it('Renders with custom placeholder', async () => {
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={{ ...initialState }}>
|
||||
<SearchBar placeholder="This is a custom placeholder" />
|
||||
const placeholder = 'placeholder';
|
||||
|
||||
await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[searchApiRef, searchApiMock],
|
||||
]}
|
||||
>
|
||||
<SearchContextProvider>
|
||||
<SearchBar placeholder={placeholder} />
|
||||
</SearchContextProvider>
|
||||
,
|
||||
</ApiProvider>,
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByPlaceholderText('This is a custom placeholder'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(placeholder)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Renders based on initial search', async () => {
|
||||
const term = 'term';
|
||||
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={{ ...initialState, term }}>
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[searchApiRef, searchApiMock],
|
||||
]}
|
||||
>
|
||||
<SearchContextProvider initialState={createInitialState({ term })}>
|
||||
<SearchBar />
|
||||
</SearchContextProvider>
|
||||
,
|
||||
</ApiProvider>,
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name })).toHaveValue(term);
|
||||
expect(screen.getByRole('textbox', { name: 'Search' })).toHaveValue(term);
|
||||
});
|
||||
});
|
||||
|
||||
it('Updates term state when text is entered', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
jest.useFakeTimers();
|
||||
const defaultDebounceTime = 200;
|
||||
const user = userEvent.setup({ delay: null });
|
||||
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[searchApiRef, searchApiMock],
|
||||
]}
|
||||
>
|
||||
<SearchContextProvider>
|
||||
<SearchBar />
|
||||
</SearchContextProvider>
|
||||
,
|
||||
</ApiProvider>,
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
const textbox = screen.getByRole('textbox', { name });
|
||||
const textbox = screen.getByRole('textbox', { name: 'Search' });
|
||||
|
||||
const value = 'value';
|
||||
|
||||
await user.type(textbox, value);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(defaultDebounceTime);
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -139,26 +152,36 @@ describe('SearchBar', () => {
|
||||
expect(query).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ term: value }),
|
||||
);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('Clear button clears term state', async () => {
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={{ ...initialState, term }}>
|
||||
const term = 'term';
|
||||
|
||||
await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[searchApiRef, searchApiMock],
|
||||
]}
|
||||
>
|
||||
<SearchContextProvider initialState={createInitialState({ term })}>
|
||||
<SearchBar />
|
||||
</SearchContextProvider>
|
||||
</ApiProvider>,
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
const textbox = screen.getByRole('textbox', { name: 'Search' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name })).toHaveValue(term);
|
||||
expect(textbox).toHaveValue(term);
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Clear' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name })).toHaveValue('');
|
||||
expect(textbox).toHaveValue('');
|
||||
});
|
||||
|
||||
expect(query).toHaveBeenLastCalledWith(
|
||||
@@ -167,12 +190,19 @@ describe('SearchBar', () => {
|
||||
});
|
||||
|
||||
it('Should not show clear button', async () => {
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={{ ...initialState, term }}>
|
||||
const term = 'term';
|
||||
|
||||
await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[searchApiRef, searchApiMock],
|
||||
]}
|
||||
>
|
||||
<SearchContextProvider initialState={createInitialState({ term })}>
|
||||
<SearchBar clearButton={false} />
|
||||
</SearchContextProvider>
|
||||
</ApiProvider>,
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -183,170 +213,168 @@ describe('SearchBar', () => {
|
||||
});
|
||||
|
||||
it('Adheres to provided debounceTime', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
jest.useFakeTimers();
|
||||
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const debounceTime = 600;
|
||||
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[searchApiRef, searchApiMock],
|
||||
]}
|
||||
>
|
||||
<SearchContextProvider>
|
||||
<SearchBar debounceTime={debounceTime} />
|
||||
</SearchContextProvider>
|
||||
,
|
||||
</ApiProvider>,
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const textbox = screen.getByRole('textbox', { name });
|
||||
const textbox = await screen.findByRole('textbox', { name: 'Search' });
|
||||
|
||||
const value = 'value';
|
||||
|
||||
await user.type(textbox, value);
|
||||
|
||||
expect(query).not.toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ term: value }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(query).not.toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ term: value }),
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(debounceTime);
|
||||
});
|
||||
expect(textbox).toHaveValue(value);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(textbox).toHaveValue(value);
|
||||
});
|
||||
|
||||
expect(query).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ term: value }),
|
||||
);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not capture analytics event if not enabled in app', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
jest.useFakeTimers();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
|
||||
const debounceTime = 600;
|
||||
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchBar debounceTime={debounceTime} />
|
||||
await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[searchApiRef, searchApiMock],
|
||||
]}
|
||||
>
|
||||
<SearchContextProvider>
|
||||
<SearchBar />
|
||||
</SearchContextProvider>
|
||||
,
|
||||
</ApiProvider>,
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const textbox = screen.getByRole('textbox', { name });
|
||||
const textbox = await screen.findByRole('textbox', { name: 'Search' });
|
||||
|
||||
const value = 'value';
|
||||
|
||||
await user.type(textbox, value);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(debounceTime);
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(textbox).toHaveValue(value));
|
||||
await waitFor(() => {
|
||||
expect(textbox).toHaveValue(value);
|
||||
});
|
||||
|
||||
expect(analyticsApiMock.getEvents()).toHaveLength(0);
|
||||
|
||||
expect(analyticsApiSpy.getEvents()).toHaveLength(0);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('captures analytics events if enabled in app', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
jest.useFakeTimers();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const types = ['techdocs', 'software-catalog'];
|
||||
|
||||
const debounceTime = 600;
|
||||
|
||||
apiRegistry = TestApiRegistry.from(
|
||||
[analyticsApiRef, analyticsApiSpy],
|
||||
[
|
||||
configApiRef,
|
||||
new ConfigReader({
|
||||
app: {
|
||||
title: 'Mock title',
|
||||
analytics: {
|
||||
ga: {
|
||||
trackingId: 'xyz123',
|
||||
await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[searchApiRef, searchApiMock],
|
||||
[analyticsApiRef, analyticsApiMock],
|
||||
[
|
||||
configApiRef,
|
||||
new ConfigReader({
|
||||
app: {
|
||||
title: 'Mock title',
|
||||
analytics: {
|
||||
ga: {
|
||||
trackingId: 'xyz123',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
[searchApiRef, { query }],
|
||||
);
|
||||
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider
|
||||
initialState={{
|
||||
term: '',
|
||||
types: ['techdocs', 'software-catalog'],
|
||||
filters: {},
|
||||
}}
|
||||
>
|
||||
<SearchBar debounceTime={debounceTime} />
|
||||
}),
|
||||
],
|
||||
]}
|
||||
>
|
||||
<SearchContextProvider initialState={createInitialState({ types })}>
|
||||
<SearchBar />
|
||||
</SearchContextProvider>
|
||||
</ApiProvider>,
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name })).toBeInTheDocument();
|
||||
});
|
||||
const textbox = await screen.findByRole('textbox', { name: 'Search' });
|
||||
|
||||
const textbox = screen.getByRole('textbox', { name });
|
||||
|
||||
const value = 'value';
|
||||
let value = 'value';
|
||||
|
||||
await user.type(textbox, value);
|
||||
|
||||
expect(analyticsApiSpy.getEvents()).toHaveLength(0);
|
||||
expect(analyticsApiMock.getEvents()).toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(debounceTime);
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(textbox).toHaveValue(value));
|
||||
|
||||
expect(analyticsApiSpy.getEvents()).toHaveLength(1);
|
||||
expect(analyticsApiSpy.getEvents()[0]).toEqual({
|
||||
expect(analyticsApiMock.getEvents()).toHaveLength(1);
|
||||
expect(analyticsApiMock.getEvents()[0]).toEqual({
|
||||
action: 'search',
|
||||
context: {
|
||||
extension: 'SearchBar',
|
||||
pluginId: 'search',
|
||||
routeRef: 'unknown',
|
||||
searchTypes: 'software-catalog,techdocs',
|
||||
searchTypes: types.toString(),
|
||||
},
|
||||
subject: 'value',
|
||||
subject: value,
|
||||
});
|
||||
|
||||
await user.clear(textbox);
|
||||
|
||||
value = 'new value';
|
||||
|
||||
// make sure new term is captured
|
||||
await user.type(textbox, 'new value');
|
||||
await user.type(textbox, value);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(debounceTime);
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(textbox).toHaveValue('new value'));
|
||||
await waitFor(() => expect(textbox).toHaveValue(value));
|
||||
|
||||
expect(analyticsApiSpy.getEvents()).toHaveLength(2);
|
||||
expect(analyticsApiSpy.getEvents()[1]).toEqual({
|
||||
expect(analyticsApiMock.getEvents()).toHaveLength(2);
|
||||
expect(analyticsApiMock.getEvents()[1]).toEqual({
|
||||
action: 'search',
|
||||
context: {
|
||||
extension: 'SearchBar',
|
||||
pluginId: 'search',
|
||||
routeRef: 'unknown',
|
||||
searchTypes: 'software-catalog,techdocs',
|
||||
searchTypes: types.toString(),
|
||||
},
|
||||
subject: 'new value',
|
||||
subject: value,
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
/*
|
||||
* 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 { screen, render, waitFor, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { configApiRef, analyticsApiRef } from '@backstage/core-plugin-api';
|
||||
import { ApiProvider, ConfigReader } from '@backstage/core-app-api';
|
||||
import { MockAnalyticsApi, TestApiRegistry } from '@backstage/test-utils';
|
||||
import {
|
||||
SearchContextProvider,
|
||||
searchApiRef,
|
||||
} from '@backstage/plugin-search-react';
|
||||
|
||||
import { SearchBar } from './SearchBar';
|
||||
|
||||
jest.mock('@backstage/core-plugin-api', () => ({
|
||||
...jest.requireActual('@backstage/core-plugin-api'),
|
||||
}));
|
||||
|
||||
describe('SearchBar', () => {
|
||||
const initialState = {
|
||||
term: '',
|
||||
filters: {},
|
||||
types: ['*'],
|
||||
pageCursor: '',
|
||||
};
|
||||
|
||||
const query = jest.fn().mockResolvedValue({});
|
||||
const analyticsApiSpy = new MockAnalyticsApi();
|
||||
let apiRegistry: TestApiRegistry;
|
||||
|
||||
apiRegistry = TestApiRegistry.from(
|
||||
[
|
||||
configApiRef,
|
||||
new ConfigReader({
|
||||
app: { title: 'Mock title' },
|
||||
}),
|
||||
],
|
||||
[searchApiRef, { query }],
|
||||
);
|
||||
|
||||
const name = 'Search';
|
||||
const term = 'term';
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('Renders without exploding', async () => {
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchBar />
|
||||
</SearchContextProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('Search in Mock title'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Renders with custom placeholder', async () => {
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={{ ...initialState }}>
|
||||
<SearchBar placeholder="This is a custom placeholder" />
|
||||
</SearchContextProvider>
|
||||
,
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByPlaceholderText('This is a custom placeholder'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Renders based on initial search', async () => {
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={{ ...initialState, term }}>
|
||||
<SearchBar />
|
||||
</SearchContextProvider>
|
||||
,
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name })).toHaveValue(term);
|
||||
});
|
||||
});
|
||||
|
||||
it('Updates term state when text is entered', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
jest.useFakeTimers();
|
||||
const defaultDebounceTime = 200;
|
||||
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchBar />
|
||||
</SearchContextProvider>
|
||||
,
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
const textbox = screen.getByRole('textbox', { name });
|
||||
|
||||
const value = 'value';
|
||||
|
||||
await user.type(textbox, value);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(defaultDebounceTime);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(textbox).toHaveValue(value);
|
||||
});
|
||||
|
||||
expect(query).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ term: value }),
|
||||
);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('Clear button clears term state', async () => {
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={{ ...initialState, term }}>
|
||||
<SearchBar />
|
||||
</SearchContextProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name })).toHaveValue(term);
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Clear' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name })).toHaveValue('');
|
||||
});
|
||||
|
||||
expect(query).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ term: '' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should not show clear button', async () => {
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={{ ...initialState, term }}>
|
||||
<SearchBar clearButton={false} />
|
||||
</SearchContextProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Clear' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Adheres to provided debounceTime', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
jest.useFakeTimers();
|
||||
|
||||
const debounceTime = 600;
|
||||
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchBar debounceTime={debounceTime} />
|
||||
</SearchContextProvider>
|
||||
,
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const textbox = screen.getByRole('textbox', { name });
|
||||
|
||||
const value = 'value';
|
||||
|
||||
await user.type(textbox, value);
|
||||
|
||||
expect(query).not.toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ term: value }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(debounceTime);
|
||||
});
|
||||
expect(textbox).toHaveValue(value);
|
||||
|
||||
expect(query).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ term: value }),
|
||||
);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not capture analytics event if not enabled in app', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
jest.useFakeTimers();
|
||||
|
||||
const debounceTime = 600;
|
||||
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchBar debounceTime={debounceTime} />
|
||||
</SearchContextProvider>
|
||||
,
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const textbox = screen.getByRole('textbox', { name });
|
||||
|
||||
const value = 'value';
|
||||
|
||||
await user.type(textbox, value);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(debounceTime);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(textbox).toHaveValue(value));
|
||||
|
||||
expect(analyticsApiSpy.getEvents()).toHaveLength(0);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('captures analytics events if enabled in app', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
jest.useFakeTimers();
|
||||
|
||||
const debounceTime = 600;
|
||||
|
||||
apiRegistry = TestApiRegistry.from(
|
||||
[analyticsApiRef, analyticsApiSpy],
|
||||
[
|
||||
configApiRef,
|
||||
new ConfigReader({
|
||||
app: {
|
||||
title: 'Mock title',
|
||||
analytics: {
|
||||
ga: {
|
||||
trackingId: 'xyz123',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
[searchApiRef, { query }],
|
||||
);
|
||||
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider
|
||||
initialState={{
|
||||
term: '',
|
||||
types: ['techdocs', 'software-catalog'],
|
||||
filters: {},
|
||||
}}
|
||||
>
|
||||
<SearchBar debounceTime={debounceTime} />
|
||||
</SearchContextProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const textbox = screen.getByRole('textbox', { name });
|
||||
|
||||
const value = 'value';
|
||||
|
||||
await user.type(textbox, value);
|
||||
|
||||
expect(analyticsApiSpy.getEvents()).toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(debounceTime);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(textbox).toHaveValue(value));
|
||||
|
||||
expect(analyticsApiSpy.getEvents()).toHaveLength(1);
|
||||
expect(analyticsApiSpy.getEvents()[0]).toEqual({
|
||||
action: 'search',
|
||||
context: {
|
||||
extension: 'App',
|
||||
pluginId: 'root',
|
||||
routeRef: 'unknown',
|
||||
searchTypes: 'software-catalog,techdocs',
|
||||
},
|
||||
subject: 'value',
|
||||
});
|
||||
|
||||
await user.clear(textbox);
|
||||
|
||||
// make sure new term is captured
|
||||
await user.type(textbox, 'new value');
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(debounceTime);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(textbox).toHaveValue('new value'));
|
||||
|
||||
expect(analyticsApiSpy.getEvents()).toHaveLength(2);
|
||||
expect(analyticsApiSpy.getEvents()[1]).toEqual({
|
||||
action: 'search',
|
||||
context: {
|
||||
extension: 'App',
|
||||
pluginId: 'root',
|
||||
routeRef: 'unknown',
|
||||
searchTypes: 'software-catalog,techdocs',
|
||||
},
|
||||
subject: 'new value',
|
||||
});
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -18,11 +18,11 @@ export interface Config {
|
||||
/**
|
||||
* Configuration options for the stack overflow plugin
|
||||
*/
|
||||
stackoverflow: {
|
||||
stackoverflow?: {
|
||||
/**
|
||||
* The base url of the Stack Overflow API used for the plugin
|
||||
* @visibility backend
|
||||
*/
|
||||
baseUrl: string;
|
||||
baseUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"config"
|
||||
]
|
||||
"config.d.ts"
|
||||
],
|
||||
"configSchema": "config.d.ts"
|
||||
}
|
||||
|
||||
Vendored
+2
-2
@@ -18,11 +18,11 @@ export interface Config {
|
||||
/**
|
||||
* Configuration options for the stack overflow plugin
|
||||
*/
|
||||
stackoverflow: {
|
||||
stackoverflow?: {
|
||||
/**
|
||||
* The base url of the Stack Overflow API used for the plugin
|
||||
* @visibility frontend
|
||||
*/
|
||||
baseUrl: string;
|
||||
baseUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"config"
|
||||
]
|
||||
"config.d.ts"
|
||||
],
|
||||
"configSchema": "config.d.ts"
|
||||
}
|
||||
|
||||
Vendored
+7
@@ -66,6 +66,13 @@ export interface Config {
|
||||
publisher?:
|
||||
| {
|
||||
type: 'local';
|
||||
|
||||
local?: {
|
||||
/**
|
||||
* Directory to store generated static files.
|
||||
*/
|
||||
publishDirectory?: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'awsS3';
|
||||
|
||||
@@ -11,6 +11,20 @@ You can see also:
|
||||
|
||||
## Basic Markdown
|
||||
|
||||
Headings:
|
||||
|
||||
# h1
|
||||
|
||||
## h2
|
||||
|
||||
### h3
|
||||
|
||||
#### h4
|
||||
|
||||
##### h5
|
||||
|
||||
###### h6
|
||||
|
||||
Here is a bulleted list:
|
||||
|
||||
- Item one
|
||||
|
||||
@@ -111,10 +111,10 @@ export class TechdocsGenerator implements GeneratorBase {
|
||||
parsedLocationAnnotation,
|
||||
this.scmIntegrations,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.options.legacyCopyReadmeMdToIndexMd) {
|
||||
await patchIndexPreBuild({ inputDir, logger: childLogger, docsDir });
|
||||
}
|
||||
if (this.options.legacyCopyReadmeMdToIndexMd) {
|
||||
await patchIndexPreBuild({ inputDir, logger: childLogger, docsDir });
|
||||
}
|
||||
|
||||
if (!this.options.omitTechdocsCoreMkdocsPlugin) {
|
||||
|
||||
@@ -236,5 +236,32 @@ describe('local publisher', () => {
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should work with a configured directory', async () => {
|
||||
const customConfig = new ConfigReader({
|
||||
techdocs: {
|
||||
publisher: {
|
||||
local: {
|
||||
publishDirectory: tmpDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
mockFs({
|
||||
[tmpDir]: {
|
||||
'index.html': 'found it',
|
||||
},
|
||||
});
|
||||
const legacyPublisher = LocalPublish.fromConfig(
|
||||
customConfig,
|
||||
logger,
|
||||
testDiscovery,
|
||||
);
|
||||
app = express().use(legacyPublisher.docsRouter());
|
||||
|
||||
const response = await request(app).get('/index.html');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toEqual('found it');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,40 +44,27 @@ import {
|
||||
} from './helpers';
|
||||
import { ForwardedError } from '@backstage/errors';
|
||||
|
||||
// TODO: Use a more persistent storage than node_modules or /tmp directory.
|
||||
// Make it configurable with techdocs.publisher.local.publishDirectory
|
||||
let staticDocsDir = '';
|
||||
try {
|
||||
staticDocsDir = resolvePackagePath(
|
||||
'@backstage/plugin-techdocs-backend',
|
||||
'static/docs',
|
||||
);
|
||||
} catch (err) {
|
||||
// This will most probably never be used.
|
||||
// The try/catch is introduced so that techdocs-cli can import @backstage/plugin-techdocs-node
|
||||
// on CI/CD without installing techdocs backend plugin.
|
||||
staticDocsDir = os.tmpdir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Local publisher which uses the local filesystem to store the generated static files. It uses a directory
|
||||
* called "static" at the root of techdocs-backend plugin.
|
||||
* Local publisher which uses the local filesystem to store the generated static files. It uses by default a
|
||||
* directory called "static" at the root of techdocs-backend plugin unless a directory has been configured by
|
||||
* "techdocs.publisher.local.publishDirectory".
|
||||
*/
|
||||
export class LocalPublish implements PublisherBase {
|
||||
private readonly legacyPathCasing: boolean;
|
||||
private readonly logger: Logger;
|
||||
private readonly discovery: PluginEndpointDiscovery;
|
||||
private readonly staticDocsDir: string;
|
||||
|
||||
// TODO: Move the logic of setting staticDocsDir based on config over to
|
||||
// fromConfig, and set the value as a class parameter.
|
||||
constructor(options: {
|
||||
logger: Logger;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
legacyPathCasing: boolean;
|
||||
staticDocsDir: string;
|
||||
}) {
|
||||
this.logger = options.logger;
|
||||
this.discovery = options.discovery;
|
||||
this.legacyPathCasing = options.legacyPathCasing;
|
||||
this.staticDocsDir = options.staticDocsDir;
|
||||
}
|
||||
|
||||
static fromConfig(
|
||||
@@ -90,10 +77,28 @@ export class LocalPublish implements PublisherBase {
|
||||
'techdocs.legacyUseCaseSensitiveTripletPaths',
|
||||
) || false;
|
||||
|
||||
let staticDocsDir = config.getOptionalString(
|
||||
'techdocs.publisher.local.publishDirectory',
|
||||
);
|
||||
if (!staticDocsDir) {
|
||||
try {
|
||||
staticDocsDir = resolvePackagePath(
|
||||
'@backstage/plugin-techdocs-backend',
|
||||
'static/docs',
|
||||
);
|
||||
} catch (err) {
|
||||
// This will most probably never be used.
|
||||
// The try/catch is introduced so that techdocs-cli can import @backstage/plugin-techdocs-node
|
||||
// on CI/CD without installing techdocs backend plugin.
|
||||
staticDocsDir = os.tmpdir();
|
||||
}
|
||||
}
|
||||
|
||||
return new LocalPublish({
|
||||
logger,
|
||||
discovery,
|
||||
legacyPathCasing,
|
||||
staticDocsDir,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -144,7 +149,7 @@ export class LocalPublish implements PublisherBase {
|
||||
const techdocsApiUrl = await this.discovery.getBaseUrl('techdocs');
|
||||
const publishedFilePaths = (await getFileTreeRecursively(publishDir)).map(
|
||||
abs => {
|
||||
return abs.split(`${staticDocsDir}/`)[1];
|
||||
return abs.split(`${this.staticDocsDir}/`)[1];
|
||||
},
|
||||
);
|
||||
|
||||
@@ -222,9 +227,8 @@ export class LocalPublish implements PublisherBase {
|
||||
// Otherwise, redirect to the new path.
|
||||
return res.redirect(301, req.baseUrl + newPath);
|
||||
});
|
||||
|
||||
router.use(
|
||||
express.static(staticDocsDir, {
|
||||
express.static(this.staticDocsDir, {
|
||||
// Handle content-type header the same as all other publishers.
|
||||
setHeaders: (res, filePath) => {
|
||||
const fileExtension = path.extname(filePath);
|
||||
@@ -275,13 +279,16 @@ export class LocalPublish implements PublisherBase {
|
||||
concurrency = 25,
|
||||
}): Promise<void> {
|
||||
// Iterate through every file in the root of the publisher.
|
||||
const files = await getFileTreeRecursively(staticDocsDir);
|
||||
const files = await getFileTreeRecursively(this.staticDocsDir);
|
||||
const limit = createLimiter(concurrency);
|
||||
|
||||
await Promise.all(
|
||||
files.map(f =>
|
||||
limit(async file => {
|
||||
const relativeFile = file.replace(`${staticDocsDir}${path.sep}`, '');
|
||||
const relativeFile = file.replace(
|
||||
`${this.staticDocsDir}${path.sep}`,
|
||||
'',
|
||||
);
|
||||
const newFile = lowerCaseEntityTripletInStoragePath(relativeFile);
|
||||
|
||||
// If all parts are already lowercase, ignore.
|
||||
@@ -311,7 +318,7 @@ export class LocalPublish implements PublisherBase {
|
||||
* Utility wrapper around path.join(), used to control legacy case logic.
|
||||
*/
|
||||
protected staticEntityPathJoin(...allParts: string[]): string {
|
||||
let staticEntityPath = staticDocsDir;
|
||||
let staticEntityPath = this.staticDocsDir;
|
||||
|
||||
allParts
|
||||
.map(part => part.split(path.sep))
|
||||
|
||||
@@ -16,8 +16,14 @@
|
||||
|
||||
import { RuleOptions } from './types';
|
||||
|
||||
type RuleTypography = RuleOptions['theme']['typography'];
|
||||
|
||||
type BackstageTypography = RuleTypography & {
|
||||
htmlFontSize?: number;
|
||||
};
|
||||
|
||||
type TypographyHeadings = Pick<
|
||||
RuleOptions['theme']['typography'],
|
||||
RuleTypography,
|
||||
'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
||||
>;
|
||||
|
||||
@@ -33,13 +39,15 @@ export default ({ theme }: RuleOptions) => `
|
||||
}
|
||||
|
||||
${headings.reduce<string>((style, heading) => {
|
||||
const htmlFontSize =
|
||||
(theme.typography as BackstageTypography).htmlFontSize ?? 16;
|
||||
const styles = theme.typography[heading];
|
||||
const { lineHeight, fontFamily, fontWeight, fontSize } = styles;
|
||||
const calculate = (value: typeof fontSize) => {
|
||||
let factor: number | string = 1;
|
||||
if (typeof value === 'number') {
|
||||
// 60% of the size defined because it is too big
|
||||
factor = (value / 16) * 0.6;
|
||||
// convert px to rem
|
||||
factor = value / htmlFontSize;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
factor = value.replace('rem', '');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user